Initial commit of the L'Ami Fiduciaire SaaS platform built on Laravel 12, Vue 3, Inertia.js 2, and Tailwind CSS 4. Story 0.1 (rename folders to declarations in database) is implemented and code-reviewed: migration, rollback, and 6 Pest tests all passing. 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>
|