Files
L-Ami-Fiduciaire/resources/js/pages/folders/Show.vue
Saad Zoubir 7a18c40361 chore: add BMAD framework modules, folder features, and tooling configs
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>
2026-03-22 21:24:17 +01:00

697 lines
34 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>