Files
L-Ami-Fiduciaire/resources/js/pages/declarations/Show.vue
Saad Ibn-Ezzoubayr c89d1879bf feat: complete Epic 1 — team management & permission system
- Story 1.1: Permission enum, config, AuthorizesPermissions & HasWorkspaceScope traits, member→worker migration
- Story 1.2: Team page with member list, invitation system with queued email
- Story 1.3: Role assignment (Manager/Worker) and member removal with activity logging
- Story 1.4: Owner-only permission toggle matrix for Managers (manage team, view logs, configure portal)
- Story 1.5: Role-based access enforcement — Workers see only assigned declarations/clients, sidebar scoping
- Story 1.6: Workspace switcher dropdown for multi-workspace users with session-based switching
- 83 new/modified files, 182 tests passing with zero regressions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 00:12:50 +00:00

958 lines
43 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 { CheckCircle2, Download, Paperclip, Send } from 'lucide-vue-next';
import { computed, ref, watch, nextTick } from 'vue';
import MessageBubble from '@/components/declarations/MessageBubble.vue';
import Heading from '@/components/Heading.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Timeline } from '@/components/ui/timeline';
import AppLayout from '@/layouts/AppLayout.vue';
type Declaration = {
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 = {
declaration: Declaration;
messages: Message[];
documents: Document[];
messagesStoreUrl: string;
mediaStoreUrl: string;
messageTypeLabels: Record<string, string>;
indexUrl: string;
editUrl: string;
workspaceUsers: WorkspaceUser[];
mentionStoreUrl: string;
canMention: boolean;
canEdit: boolean;
canDelete: 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(declaration: Declaration): string {
const parts: string[] = [];
if (declaration.period_year) parts.push(String(declaration.period_year));
if (declaration.period_quarter)
parts.push(`T${declaration.period_quarter}`);
if (declaration.period_month) parts.push(`M${declaration.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 declarationTimelineItems = computed(() => {
const declaration = props.declaration;
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(declaration.status);
items.push({
title: docsReceived ? 'Documents reçus' : 'En attente des documents',
state: docsReceived
? 'completed'
: declaration.status === 'waiting_documents'
? 'current'
: 'pending',
});
// Validation client
const validatedFmt = formatDateTime(declaration.validated_at);
items.push({
title: declaration.validated_at
? 'Validé par le client'
: 'Validation client',
date: validatedFmt?.date,
time: validatedFmt?.time,
state: declaration.validated_at
? 'completed'
: declaration.status === 'waiting_client_validation'
? 'current'
: 'pending',
});
// Clôture
const closedFmt = formatDateTime(declaration.closed_at);
items.push({
title: declaration.closed_at
? 'Déclaration clôturée'
: 'Clôture de la déclaration',
date: closedFmt?.date,
time: closedFmt?.time,
state: declaration.closed_at
? 'completed'
: declaration.status === 'closed'
? 'current'
: 'pending',
});
return items;
});
</script>
<template>
<AppLayout
:breadcrumbs="[
{ title: 'Déclarations', href: props.indexUrl },
{ title: props.declaration.title },
]"
>
<Head :title="props.declaration.title" />
<div class="flex h-full flex-col">
<div
class="flex items-center justify-between border-b border-sidebar-border/70 p-4 dark:border-sidebar-border"
>
<Heading
variant="small"
:title="props.declaration.title"
:description="
typeLabels[declaration.type] ?? declaration.type
"
/>
<Button v-if="props.canEdit" variant="outline" as-child>
<Link :href="editUrl">Modifier la déclaration</Link>
</Button>
</div>
<Tabs
v-model="tab"
class="h-full w-full flex-grow gap-0 overflow-auto"
>
<TabsList
class="h-auto w-full rounded-none !bg-background px-0 py-0"
>
<TabsTrigger
value="overview"
class="rounded-none border-0 border-b-2 border-sidebar-border/70 py-2 !shadow-none transition-all data-[state=active]:border-primary dark:border-sidebar-border"
>
Aperçu
</TabsTrigger>
<TabsTrigger
value="messages"
class="rounded-none border-0 border-b-2 border-sidebar-border/70 py-2 !shadow-none transition-all data-[state=active]:border-primary dark:border-sidebar-border"
>
Messages
</TabsTrigger>
<TabsTrigger
value="documents"
class="rounded-none border-0 border-b-2 border-sidebar-border/70 py-2 !shadow-none transition-all data-[state=active]:border-primary dark:border-sidebar-border"
>
Documents
</TabsTrigger>
</TabsList>
<TabsContent value="overview" class="p-4">
<div class="grid grid-cols-12 gap-4">
<div class="col-span-8">
<div
class="overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"
>
<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">
{{ declaration.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[declaration.type] ??
declaration.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(declaration) }}
</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">
{{
declaration.created_at
? new Date(
declaration.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">
{{ declaration.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[
declaration.status
] ?? declaration.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">
{{
declaration.priority
? (priorityLabels[
declaration.priority
] ?? declaration.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">
{{
declaration.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">
{{
declaration.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">
{{ declaration.closed_at || '—' }}
</dd>
</div>
<div
v-if="declaration.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 whitespace-pre-wrap sm:col-span-2"
>
{{ declaration.notes_internal }}
</dd>
</div>
<div
v-if="declaration.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 whitespace-pre-wrap sm:col-span-2"
>
{{ declaration.notes_client }}
</dd>
</div>
</dl>
</div>
<div
v-if="canMention"
class="mt-4 rounded-xl border border-sidebar-border/70 p-4 dark:border-sidebar-border"
>
<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:ring-2 focus:ring-ring focus:outline-none"
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:ring-2 focus:ring-ring focus:outline-none"
placeholder="Ex : Merci de traiter cette déclaration 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="declarationTimelineItems"
class="rounded-xl border border-sidebar-border/70 p-4 dark:border-sidebar-border"
/>
</div>
</div>
</TabsContent>
<TabsContent
value="messages"
class="relative flex h-full max-h-full min-h-0 flex-col p-0"
>
<div
ref="messagesContainerRef"
class="absolute top-0 right-0 bottom-0 left-0 min-h-0 flex-1 overflow-hidden overflow-y-auto overscroll-contain px-4 py-6 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="absolute bottom-0 w-full shrink-0 bg-gradient-to-t from-background to-transparent px-4 pb-4"
>
<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="rounded p-0.5 hover:bg-muted-foreground/20"
@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:ring-0 focus:outline-none"
>
<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="max-h-[200px] min-h-[24px] flex-1 resize-none overflow-hidden border-0 bg-transparent py-2 text-sm placeholder:text-muted-foreground focus:ring-0 focus:outline-none 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="min-w-[200px] flex-1 space-y-2">
<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="overflow-hidden rounded-xl border border-sidebar-border/70"
>
<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>