Includes BMAD bmb/bmm/cis/tea workflow modules, folder (declaration) feature implementation (controllers, models, enums, views, tests), claude/cursor command configs, and email templates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
697 lines
34 KiB
Vue
697 lines
34 KiB
Vue
<script setup lang="ts">
|
||
import { Form, Head, Link, useForm } from '@inertiajs/vue3';
|
||
import Heading from '@/components/Heading.vue';
|
||
import AppLayout from '@/layouts/AppLayout.vue';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||
import { computed, ref, watch, nextTick } from 'vue';
|
||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||
import { Timeline } from '@/components/ui/timeline';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Label } from '@/components/ui/label';
|
||
import { Spinner } from '@/components/ui/spinner';
|
||
import MessageBubble from '@/components/folders/MessageBubble.vue';
|
||
import { CheckCircle2, Download, Paperclip, Send } from 'lucide-vue-next';
|
||
|
||
type Folder = {
|
||
id: number;
|
||
title: string;
|
||
type: string;
|
||
client_id: number;
|
||
client_name: string;
|
||
period_year: number | null;
|
||
period_month: number | null;
|
||
period_quarter: number | null;
|
||
due_date: string | null;
|
||
status: string;
|
||
priority: string | null;
|
||
assigned_to: number | null;
|
||
assignee_name: string | null;
|
||
validated_at: string | null;
|
||
closed_at: string | null;
|
||
notes_internal: string | null;
|
||
notes_client: string | null;
|
||
created_at: string | null;
|
||
};
|
||
|
||
type Message = {
|
||
id: number;
|
||
type: string;
|
||
body: string;
|
||
sent_by_type: string;
|
||
sender_name: string;
|
||
created_at: string;
|
||
attachments?: Array<{
|
||
id: number;
|
||
file_name: string;
|
||
mime_type: string;
|
||
size: string;
|
||
downloadUrl: string;
|
||
}>;
|
||
confirmation_status?: 'pending' | 'confirmed' | 'refused' | null;
|
||
};
|
||
|
||
type Document = {
|
||
id: number;
|
||
name: string;
|
||
file_name: string;
|
||
size: string;
|
||
created_at: string;
|
||
uploaded_by: string;
|
||
downloadUrl: string;
|
||
is_downloaded: boolean;
|
||
};
|
||
|
||
type WorkspaceUser = {
|
||
id: number;
|
||
name: string;
|
||
};
|
||
|
||
type Props = {
|
||
folder: Folder;
|
||
messages: Message[];
|
||
documents: Document[];
|
||
messagesStoreUrl: string;
|
||
mediaStoreUrl: string;
|
||
messageTypeLabels: Record<string, string>;
|
||
indexUrl: string;
|
||
editUrl: string;
|
||
workspaceUsers: WorkspaceUser[];
|
||
mentionStoreUrl: string;
|
||
canMention: boolean;
|
||
};
|
||
|
||
const props = defineProps<Props>();
|
||
|
||
const reactiveDocuments = ref(props.documents.map((d) => ({ ...d })));
|
||
watch(() => props.documents, (newDocs) => {
|
||
reactiveDocuments.value = newDocs.map((d) => ({ ...d }));
|
||
});
|
||
|
||
function onDocumentDownload(doc: Document & { is_downloaded: boolean }) {
|
||
doc.is_downloaded = true;
|
||
}
|
||
|
||
const mentionForm = useForm({
|
||
user_id: '',
|
||
message: '',
|
||
});
|
||
|
||
function submitMention() {
|
||
mentionForm.post(props.mentionStoreUrl, {
|
||
preserveScroll: true,
|
||
onSuccess: () => mentionForm.reset(),
|
||
});
|
||
}
|
||
|
||
const tabFromUrl = () => {
|
||
const params = new URLSearchParams(window.location.search);
|
||
return params.get('tab') === 'messages' ? 'messages' : params.get('tab') === 'documents' ? 'documents' : 'overview';
|
||
};
|
||
const tab = ref(tabFromUrl());
|
||
watch(tab, (t) => {
|
||
const url = new URL(window.location.href);
|
||
url.searchParams.set('tab', t);
|
||
window.history.replaceState({}, '', url.toString());
|
||
});
|
||
|
||
const typeLabels: Record<string, string> = {
|
||
vat: 'TVA',
|
||
vat_monthly: 'TVA mensuelle',
|
||
vat_quarterly: 'TVA trimestrielle',
|
||
corporate_tax: 'IS',
|
||
income_tax: 'IR',
|
||
cnss: 'CNSS',
|
||
annual_balance: 'Bilan',
|
||
other: 'Autre',
|
||
};
|
||
|
||
const statusLabels: Record<string, string> = {
|
||
draft: 'Brouillon',
|
||
waiting_documents: 'En attente documents',
|
||
documents_received: 'Documents reçus',
|
||
processing: 'En cours de traitement',
|
||
additional_documents_requested: 'Pièces complémentaires demandées',
|
||
waiting_client_validation: 'En attente validation client',
|
||
validated: 'Validé',
|
||
closed: 'Clôturé',
|
||
cancelled: 'Annulé',
|
||
};
|
||
|
||
const priorityLabels: Record<string, string> = {
|
||
low: 'Basse',
|
||
medium: 'Normale',
|
||
high: 'Haute',
|
||
};
|
||
|
||
function formatPeriod(folder: Folder): string {
|
||
const parts: string[] = [];
|
||
if (folder.period_year) parts.push(String(folder.period_year));
|
||
if (folder.period_quarter) parts.push(`T${folder.period_quarter}`);
|
||
if (folder.period_month) parts.push(`M${folder.period_month}`);
|
||
return parts.join(' - ') || '—';
|
||
}
|
||
|
||
function formatDateTime(iso: string | null): { date: string; time: string } | null {
|
||
if (!iso) return null;
|
||
const d = new Date(iso);
|
||
return {
|
||
date: d.toLocaleDateString('fr-FR', {
|
||
day: 'numeric',
|
||
month: 'long',
|
||
year: 'numeric',
|
||
}),
|
||
time: d.toLocaleTimeString('fr-FR', {
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
}),
|
||
};
|
||
}
|
||
|
||
const messagesContainerRef = ref<HTMLElement | null>(null);
|
||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||
const selectedFiles = ref<File[]>([]);
|
||
const documentsFileInputRef = ref<HTMLInputElement | null>(null);
|
||
const selectedDocuments = ref<File[]>([]);
|
||
|
||
function scrollToBottom() {
|
||
nextTick(() => {
|
||
const el = messagesContainerRef.value;
|
||
if (el) el.scrollTop = el.scrollHeight;
|
||
});
|
||
}
|
||
|
||
watch(
|
||
() => props.messages.length,
|
||
() => scrollToBottom(),
|
||
{ immediate: true },
|
||
);
|
||
|
||
watch(tab, (newTab) => {
|
||
if (newTab === 'messages') scrollToBottom();
|
||
});
|
||
|
||
function triggerFileSelect() {
|
||
fileInputRef.value?.click();
|
||
}
|
||
|
||
function onFilesChanged(e: Event) {
|
||
const input = e.target as HTMLInputElement;
|
||
selectedFiles.value = input.files ? Array.from(input.files) : [];
|
||
}
|
||
|
||
function removeFile(index: number) {
|
||
selectedFiles.value = selectedFiles.value.filter((_, i) => i !== index);
|
||
if (fileInputRef.value) {
|
||
fileInputRef.value.value = '';
|
||
const dt = new DataTransfer();
|
||
selectedFiles.value.forEach((f) => dt.items.add(f));
|
||
fileInputRef.value.files = dt.files;
|
||
}
|
||
}
|
||
|
||
function triggerDocumentsFileSelect() {
|
||
documentsFileInputRef.value?.click();
|
||
}
|
||
|
||
function onDocumentsFilesChanged(e: Event) {
|
||
const input = e.target as HTMLInputElement;
|
||
if (input.files?.length) {
|
||
selectedDocuments.value = [...selectedDocuments.value, ...Array.from(input.files)];
|
||
syncDocumentsToInput();
|
||
}
|
||
}
|
||
|
||
function removeDocument(index: number) {
|
||
selectedDocuments.value = selectedDocuments.value.filter((_, i) => i !== index);
|
||
syncDocumentsToInput();
|
||
}
|
||
|
||
function syncDocumentsToInput() {
|
||
if (!documentsFileInputRef.value) return;
|
||
const dt = new DataTransfer();
|
||
selectedDocuments.value.forEach((f) => dt.items.add(f));
|
||
documentsFileInputRef.value.files = dt.files;
|
||
}
|
||
|
||
function formatFileSize(bytes: number): string {
|
||
if (bytes < 1024) return `${bytes} o`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
|
||
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
|
||
}
|
||
|
||
function autoResizeTextarea(e: Event) {
|
||
const ta = e.target as HTMLTextAreaElement;
|
||
ta.style.height = 'auto';
|
||
ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`;
|
||
}
|
||
|
||
const messagesChronological = computed(() => [...props.messages].reverse());
|
||
|
||
const folderTimelineItems = computed(() => {
|
||
const folder = props.folder;
|
||
const items: Array<{
|
||
title: string;
|
||
date?: string;
|
||
time?: string;
|
||
state: 'completed' | 'pending' | 'current';
|
||
}> = [];
|
||
|
||
// Documents reçus
|
||
const docsReceived =
|
||
['documents_received', 'processing', 'additional_documents_requested', 'waiting_client_validation', 'validated', 'closed'].includes(
|
||
folder.status,
|
||
);
|
||
items.push({
|
||
title: docsReceived ? 'Documents reçus' : 'En attente des documents',
|
||
state: docsReceived ? 'completed' : folder.status === 'waiting_documents' ? 'current' : 'pending',
|
||
});
|
||
|
||
// Validation client
|
||
const validatedFmt = formatDateTime(folder.validated_at);
|
||
items.push({
|
||
title: folder.validated_at ? 'Validé par le client' : 'Validation client',
|
||
date: validatedFmt?.date,
|
||
time: validatedFmt?.time,
|
||
state: folder.validated_at ? 'completed' : folder.status === 'waiting_client_validation' ? 'current' : 'pending',
|
||
});
|
||
|
||
// Clôture
|
||
const closedFmt = formatDateTime(folder.closed_at);
|
||
items.push({
|
||
title: folder.closed_at ? 'Dossier clôturé' : 'Clôture du dossier',
|
||
date: closedFmt?.date,
|
||
time: closedFmt?.time,
|
||
state: folder.closed_at ? 'completed' : folder.status === 'closed' ? 'current' : 'pending',
|
||
});
|
||
|
||
return items;
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<AppLayout :breadcrumbs="[
|
||
{ title: 'Dossiers', href: props.indexUrl },
|
||
{ title: props.folder.title },
|
||
]">
|
||
|
||
<Head :title="props.folder.title" />
|
||
|
||
<div class="flex flex-col h-full">
|
||
<div
|
||
class="flex items-center justify-between border-b border-sidebar-border/70 dark:border-sidebar-border p-4">
|
||
<Heading variant="small" :title="props.folder.title"
|
||
:description="typeLabels[folder.type] ?? folder.type" />
|
||
<Button variant="outline" as-child>
|
||
<Link :href="editUrl">Modifier le dossier</Link>
|
||
</Button>
|
||
</div>
|
||
|
||
<Tabs v-model="tab" class="h-full overflow-auto w-full flex-grow gap-0">
|
||
<TabsList class="w-full rounded-none py-0 px-0 h-auto !bg-background">
|
||
<TabsTrigger value="overview" class="border-0 py-2 border-b-2 !shadow-none rounded-none border-sidebar-border/70 dark:border-sidebar-border data-[state=active]:border-primary transition-all">
|
||
Aperçu
|
||
</TabsTrigger>
|
||
<TabsTrigger value="messages" class="border-0 py-2 border-b-2 !shadow-none rounded-none border-sidebar-border/70 dark:border-sidebar-border data-[state=active]:border-primary transition-all ">
|
||
Messages
|
||
</TabsTrigger>
|
||
<TabsTrigger value="documents" class="border-0 py-2 border-b-2 !shadow-none rounded-none border-sidebar-border/70 dark:border-sidebar-border data-[state=active]:border-primary transition-all">
|
||
Documents
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
<TabsContent value="overview" class="p-4">
|
||
<div class="grid grid-cols-12 gap-4">
|
||
<div class="col-span-8">
|
||
<div class="rounded-xl border border-sidebar-border/70 dark:border-sidebar-border overflow-hidden">
|
||
<dl class="divide-y divide-sidebar-border/70">
|
||
<div class="flex flex-col gap-1 px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||
<dt class="text-sm font-medium text-muted-foreground">
|
||
Client
|
||
</dt>
|
||
<dd class="text-sm sm:col-span-2">
|
||
{{ folder.client_name || '—' }}
|
||
</dd>
|
||
</div>
|
||
<div class="flex flex-col gap-1 px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||
<dt class="text-sm font-medium text-muted-foreground">
|
||
Type
|
||
</dt>
|
||
<dd class="text-sm sm:col-span-2">
|
||
{{ typeLabels[folder.type] ?? folder.type }}
|
||
</dd>
|
||
</div>
|
||
<div class="flex flex-col gap-1 px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||
<dt class="text-sm font-medium text-muted-foreground">
|
||
Période
|
||
</dt>
|
||
<dd class="text-sm sm:col-span-2">
|
||
{{ formatPeriod(folder) }}
|
||
</dd>
|
||
</div>
|
||
<div class="flex flex-col gap-1 px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||
<dt class="text-sm font-medium text-muted-foreground">
|
||
Date ouverture
|
||
</dt>
|
||
<dd class="text-sm sm:col-span-2">
|
||
{{ folder.created_at ? new Date(folder.created_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' }) : '—' }}
|
||
</dd>
|
||
</div>
|
||
<div class="flex flex-col gap-1 px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||
<dt class="text-sm font-medium text-muted-foreground">
|
||
Date limite
|
||
</dt>
|
||
<dd class="text-sm sm:col-span-2">
|
||
{{ folder.due_date || '—' }}
|
||
</dd>
|
||
</div>
|
||
<div class="flex flex-col gap-1 px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||
<dt class="text-sm font-medium text-muted-foreground">
|
||
Statut
|
||
</dt>
|
||
<dd class="text-sm sm:col-span-2">
|
||
{{ statusLabels[folder.status] ?? folder.status }}
|
||
</dd>
|
||
</div>
|
||
<div class="flex flex-col gap-1 px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||
<dt class="text-sm font-medium text-muted-foreground">
|
||
Priorité
|
||
</dt>
|
||
<dd class="text-sm sm:col-span-2">
|
||
{{ folder.priority ? (priorityLabels[folder.priority] ?? folder.priority) : '—' }}
|
||
</dd>
|
||
</div>
|
||
<div class="flex flex-col gap-1 px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||
<dt class="text-sm font-medium text-muted-foreground">
|
||
Assigné à
|
||
</dt>
|
||
<dd class="text-sm sm:col-span-2">
|
||
{{ folder.assignee_name || '—' }}
|
||
</dd>
|
||
</div>
|
||
<div class="flex flex-col gap-1 px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||
<dt class="text-sm font-medium text-muted-foreground">
|
||
Validé le
|
||
</dt>
|
||
<dd class="text-sm sm:col-span-2">
|
||
{{ folder.validated_at || '—' }}
|
||
</dd>
|
||
</div>
|
||
<div class="flex flex-col gap-1 px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||
<dt class="text-sm font-medium text-muted-foreground">
|
||
Clôturé le
|
||
</dt>
|
||
<dd class="text-sm sm:col-span-2">
|
||
{{ folder.closed_at || '—' }}
|
||
</dd>
|
||
</div>
|
||
<div v-if="folder.notes_internal"
|
||
class="flex flex-col gap-1 px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||
<dt class="text-sm font-medium text-muted-foreground">
|
||
Notes internes
|
||
</dt>
|
||
<dd class="text-sm sm:col-span-2 whitespace-pre-wrap">
|
||
{{ folder.notes_internal }}
|
||
</dd>
|
||
</div>
|
||
<div v-if="folder.notes_client"
|
||
class="flex flex-col gap-1 px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||
<dt class="text-sm font-medium text-muted-foreground">
|
||
Notes client
|
||
</dt>
|
||
<dd class="text-sm sm:col-span-2 whitespace-pre-wrap">
|
||
{{ folder.notes_client }}
|
||
</dd>
|
||
</div>
|
||
</dl>
|
||
</div>
|
||
<div v-if="canMention" class="mt-4 rounded-xl border border-sidebar-border/70 dark:border-sidebar-border p-4">
|
||
<h3 class="mb-3 text-sm font-medium">Notifier un collaborateur</h3>
|
||
<form @submit.prevent="submitMention" class="space-y-3">
|
||
<div>
|
||
<Label for="mention-user" class="text-sm">Collaborateur</Label>
|
||
<select
|
||
id="mention-user"
|
||
v-model="mentionForm.user_id"
|
||
class="mt-1 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring"
|
||
required
|
||
>
|
||
<option value="" disabled>Sélectionner...</option>
|
||
<option v-for="u in workspaceUsers" :key="u.id" :value="u.id">
|
||
{{ u.name }}
|
||
</option>
|
||
</select>
|
||
<p v-if="mentionForm.errors.user_id" class="mt-1 text-xs text-destructive">{{ mentionForm.errors.user_id }}</p>
|
||
</div>
|
||
<div>
|
||
<Label for="mention-message" class="text-sm">Message</Label>
|
||
<textarea
|
||
id="mention-message"
|
||
v-model="mentionForm.message"
|
||
rows="2"
|
||
class="mt-1 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||
placeholder="Ex : Merci de traiter ce dossier en priorité"
|
||
required
|
||
maxlength="500"
|
||
/>
|
||
<p v-if="mentionForm.errors.message" class="mt-1 text-xs text-destructive">{{ mentionForm.errors.message }}</p>
|
||
</div>
|
||
<Button type="submit" size="sm" :disabled="mentionForm.processing">
|
||
Envoyer la notification
|
||
</Button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
<div class="col-span-4">
|
||
<Timeline
|
||
:items="folderTimelineItems"
|
||
class="rounded-xl border border-sidebar-border/70 dark:border-sidebar-border p-4"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</TabsContent>
|
||
<TabsContent value="messages" class="flex flex-col min-h-0 p-0 h-full max-h-full relative">
|
||
<div
|
||
ref="messagesContainerRef"
|
||
class="flex-1 overflow-y-auto overscroll-contain px-4 py-6 min-h-0 absolute top-0 left-0 right-0 bottom-0 overflow-hidden pb-24"
|
||
>
|
||
<div v-if="messages.length" class="mx-auto max-w-3xl space-y-4">
|
||
<MessageBubble
|
||
v-for="msg in messagesChronological"
|
||
:key="msg.id"
|
||
:message="{ ...msg, attachments: msg.attachments ?? [] }"
|
||
:message-type-labels="messageTypeLabels"
|
||
/>
|
||
</div>
|
||
<div
|
||
v-else
|
||
class="flex min-h-[200px] items-center justify-center text-center text-muted-foreground"
|
||
>
|
||
<p>Aucun message. Envoyez une invitation ou un message pour commencer.</p>
|
||
</div>
|
||
</div>
|
||
<div class="shrink-0 px-4 pb-4 bg-gradient-to-t from-background to-transparent absolute bottom-0 w-full">
|
||
<Form
|
||
:action="messagesStoreUrl"
|
||
method="post"
|
||
enctype="multipart/form-data"
|
||
:force-form-data="true"
|
||
class="mx-auto max-w-3xl"
|
||
v-slot="{ processing }"
|
||
@submit="selectedFiles = []"
|
||
>
|
||
<input
|
||
ref="fileInputRef"
|
||
type="file"
|
||
name="files[]"
|
||
multiple
|
||
accept="*/*"
|
||
class="hidden"
|
||
@change="onFilesChanged"
|
||
/>
|
||
<div class="flex flex-col gap-2">
|
||
<div
|
||
v-if="selectedFiles.length"
|
||
class="flex flex-wrap gap-1.5"
|
||
>
|
||
<span
|
||
v-for="(f, i) in selectedFiles"
|
||
:key="i"
|
||
class="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs"
|
||
>
|
||
{{ f.name }}
|
||
<button
|
||
type="button"
|
||
class="hover:bg-muted-foreground/20 rounded p-0.5"
|
||
@click.prevent="removeFile(i)"
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
</div>
|
||
<div
|
||
class="flex items-end gap-2 rounded-xl border border-input bg-background px-3 py-2 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2"
|
||
>
|
||
<select
|
||
name="type"
|
||
required
|
||
class="mr-2 shrink-0 border-0 bg-transparent py-2 pr-6 text-sm text-muted-foreground focus:outline-none focus:ring-0"
|
||
>
|
||
<option value="invite">Invitation</option>
|
||
<option value="situation">Situation</option>
|
||
<option value="file_request">Demande de pièces</option>
|
||
<option value="confirmation">Validation</option>
|
||
<option value="text">Message</option>
|
||
</select>
|
||
<textarea
|
||
name="body"
|
||
required
|
||
rows="1"
|
||
placeholder="Écrire un message..."
|
||
class="min-h-[24px] max-h-[200px] flex-1 resize-none overflow-hidden border-0 bg-transparent py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50"
|
||
:disabled="processing"
|
||
@input="autoResizeTextarea"
|
||
/>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
class="shrink-0 text-muted-foreground"
|
||
@click="triggerFileSelect"
|
||
>
|
||
<Paperclip class="size-4" />
|
||
</Button>
|
||
<Button
|
||
type="submit"
|
||
size="icon"
|
||
class="shrink-0"
|
||
:disabled="processing"
|
||
>
|
||
<Spinner v-if="processing" class="size-4" />
|
||
<Send v-else class="size-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Form>
|
||
</div>
|
||
</TabsContent>
|
||
<TabsContent value="documents" class="p-4">
|
||
<div class="space-y-4">
|
||
<Form
|
||
:action="mediaStoreUrl"
|
||
method="post"
|
||
enctype="multipart/form-data"
|
||
:force-form-data="true"
|
||
class="space-y-4 rounded-xl border border-sidebar-border/70 p-4"
|
||
v-slot="{ processing }"
|
||
@submit="selectedDocuments = []"
|
||
>
|
||
<input
|
||
ref="documentsFileInputRef"
|
||
type="file"
|
||
name="files[]"
|
||
multiple
|
||
accept="*/*"
|
||
class="hidden"
|
||
@change="onDocumentsFilesChanged"
|
||
/>
|
||
<div class="flex flex-wrap items-end gap-2">
|
||
<div class="flex-1 space-y-2 min-w-[200px]">
|
||
<Label>Ajouter des fichiers</Label>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
class="w-full justify-start"
|
||
:disabled="processing"
|
||
@click="triggerDocumentsFileSelect"
|
||
>
|
||
<Paperclip class="mr-2 size-4" />
|
||
Choisir des fichiers
|
||
</Button>
|
||
</div>
|
||
<Button
|
||
type="submit"
|
||
:disabled="processing || selectedDocuments.length === 0"
|
||
>
|
||
<Spinner v-if="processing" class="mr-2 size-4" />
|
||
Télécharger
|
||
</Button>
|
||
</div>
|
||
<div
|
||
v-if="selectedDocuments.length"
|
||
class="rounded-lg border border-sidebar-border/70 bg-muted/30 p-2"
|
||
>
|
||
<p class="mb-2 text-sm font-medium text-muted-foreground">
|
||
{{ selectedDocuments.length }} fichier(s) à déposer
|
||
</p>
|
||
<ul class="space-y-1.5">
|
||
<li
|
||
v-for="(file, i) in selectedDocuments"
|
||
:key="`${file.name}-${i}`"
|
||
class="flex items-center justify-between gap-2 rounded-md bg-background px-2 py-1.5 text-sm"
|
||
>
|
||
<span class="truncate">{{ file.name }}</span>
|
||
<span class="flex shrink-0 items-center gap-2">
|
||
<span class="text-muted-foreground">
|
||
{{ formatFileSize(file.size) }}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
class="rounded p-0.5 hover:bg-muted-foreground/20"
|
||
aria-label="Retirer"
|
||
@click.prevent="removeDocument(i)"
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</Form>
|
||
<div
|
||
v-if="reactiveDocuments.length"
|
||
class="rounded-xl border border-sidebar-border/70 overflow-hidden"
|
||
>
|
||
<table class="w-full text-sm">
|
||
<thead class="bg-muted/50">
|
||
<tr>
|
||
<th class="px-4 py-2 text-left font-medium">Nom</th>
|
||
<th class="px-4 py-2 text-left font-medium">Taille</th>
|
||
<th class="px-4 py-2 text-left font-medium">Déposé par</th>
|
||
<th class="px-4 py-2 text-left font-medium">Date</th>
|
||
<th class="px-4 py-2"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-sidebar-border/70">
|
||
<tr v-for="doc in reactiveDocuments" :key="doc.id">
|
||
<td class="px-4 py-2">
|
||
<span class="inline-flex items-center gap-1.5">
|
||
{{ doc.file_name }}
|
||
<CheckCircle2 v-if="doc.is_downloaded" class="size-3.5 text-green-500" />
|
||
</span>
|
||
</td>
|
||
<td class="px-4 py-2">{{ doc.size }}</td>
|
||
<td class="px-4 py-2">{{ doc.uploaded_by }}</td>
|
||
<td class="px-4 py-2">{{ doc.created_at }}</td>
|
||
<td class="px-4 py-2">
|
||
<Button variant="ghost" size="sm" as-child>
|
||
<a :href="doc.downloadUrl" download @click="onDocumentDownload(doc)">
|
||
<Download class="size-4" />
|
||
</a>
|
||
</Button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div v-if="!reactiveDocuments.length" class="rounded-xl border border-sidebar-border/70 p-8 text-center text-muted-foreground">
|
||
Aucun document. Ajoutez des fichiers ci-dessus.
|
||
</div>
|
||
</div>
|
||
</TabsContent>
|
||
</Tabs>
|
||
|
||
|
||
</div>
|
||
</AppLayout>
|
||
</template>
|