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>
This commit is contained in:
309
resources/js/components/FolderForm.vue
Normal file
309
resources/js/components/FolderForm.vue
Normal file
@@ -0,0 +1,309 @@
|
||||
<script setup lang="ts">
|
||||
import type { Form } from '@inertiajs/vue3';
|
||||
import { watch } from 'vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
export type FolderFormData = {
|
||||
client_id: number | '';
|
||||
title: string;
|
||||
type: string;
|
||||
period_year: number | string;
|
||||
period_month: number | string;
|
||||
period_quarter: number | string;
|
||||
due_date: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
assigned_to: number | '';
|
||||
notes_internal: string;
|
||||
notes_client: string;
|
||||
};
|
||||
|
||||
type Client = {
|
||||
id: number;
|
||||
company_name: string;
|
||||
};
|
||||
|
||||
type WorkspaceUser = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
form: Form<FolderFormData>;
|
||||
folderTypeLabels: Record<string, string>;
|
||||
folderStatusLabels: Record<string, string>;
|
||||
folderPriorityLabels: Record<string, string>;
|
||||
clients: Client[];
|
||||
workspaceUsers: WorkspaceUser[];
|
||||
submitLabel?: string;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
submitLabel: 'Enregistrer',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [];
|
||||
}>();
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const years = Array.from({ length: 10 }, (_, i) => currentYear - 2 + i);
|
||||
const months = [
|
||||
{ value: '', label: '—' },
|
||||
...Array.from({ length: 12 }, (_, i) => ({
|
||||
value: (i + 1).toString(),
|
||||
label: `${i + 1}`,
|
||||
})),
|
||||
];
|
||||
const quarters = [
|
||||
{ value: '', label: '—' },
|
||||
{ value: '1', label: 'T1 (Jan–Mar)' },
|
||||
{ value: '2', label: 'T2 (Avr–Juin)' },
|
||||
{ value: '3', label: 'T3 (Juil–Sep)' },
|
||||
{ value: '4', label: 'T4 (Oct–Déc)' },
|
||||
];
|
||||
|
||||
const isVatMonthly = () => props.form.type === 'vat_monthly';
|
||||
const isVatQuarterly = () => props.form.type === 'vat_quarterly';
|
||||
|
||||
// Clear both period fields when type changes
|
||||
watch(
|
||||
() => props.form.type,
|
||||
() => {
|
||||
props.form.period_month = '';
|
||||
props.form.period_quarter = '';
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="emit('submit')" class="flex flex-col space-y-6">
|
||||
<div class="grid gap-2">
|
||||
<Label for="client_id">Client</Label>
|
||||
<select
|
||||
id="client_id"
|
||||
v-model="form.client_id"
|
||||
required
|
||||
class="border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-invalid="!!form.errors.client_id"
|
||||
>
|
||||
<option value="" disabled>Sélectionner un client</option>
|
||||
<option
|
||||
v-for="client in clients"
|
||||
:key="client.id"
|
||||
:value="client.id"
|
||||
>
|
||||
{{ client.company_name }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError :message="form.errors.client_id" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="title">Titre</Label>
|
||||
<Input
|
||||
id="title"
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Ex. Déclaration TVA - T1 2026"
|
||||
aria-invalid="!!form.errors.title"
|
||||
/>
|
||||
<InputError :message="form.errors.title" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="type">Type</Label>
|
||||
<select
|
||||
id="type"
|
||||
v-model="form.type"
|
||||
required
|
||||
class="border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-invalid="!!form.errors.type"
|
||||
>
|
||||
<option value="" disabled>Sélectionner un type</option>
|
||||
<option
|
||||
v-for="(label, value) in folderTypeLabels"
|
||||
:key="value"
|
||||
:value="value"
|
||||
>
|
||||
{{ label }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError :message="form.errors.type" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2 sm:grid-cols-3">
|
||||
<div class="grid gap-2">
|
||||
<Label for="period_year">Année</Label>
|
||||
<select
|
||||
id="period_year"
|
||||
v-model="form.period_year"
|
||||
required
|
||||
class="border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-invalid="!!form.errors.period_year"
|
||||
>
|
||||
<option v-for="y in years" :key="y" :value="y">
|
||||
{{ y }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError :message="form.errors.period_year" />
|
||||
</div>
|
||||
<template v-if="isVatMonthly()">
|
||||
<div class="grid gap-2">
|
||||
<Label for="period_month">Mois</Label>
|
||||
<select
|
||||
id="period_month"
|
||||
v-model="form.period_month"
|
||||
class="border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-invalid="!!form.errors.period_month"
|
||||
>
|
||||
<option
|
||||
v-for="m in months"
|
||||
:key="m.value"
|
||||
:value="m.value"
|
||||
>
|
||||
{{ m.label }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError :message="form.errors.period_month" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="isVatQuarterly()">
|
||||
<div class="grid gap-2">
|
||||
<Label for="period_quarter">Trimestre</Label>
|
||||
<select
|
||||
id="period_quarter"
|
||||
v-model="form.period_quarter"
|
||||
class="border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-invalid="!!form.errors.period_quarter"
|
||||
>
|
||||
<option
|
||||
v-for="q in quarters"
|
||||
:key="q.value"
|
||||
:value="q.value"
|
||||
>
|
||||
{{ q.label }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError :message="form.errors.period_quarter" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label for="due_date">Date limite</Label>
|
||||
<Input
|
||||
id="due_date"
|
||||
v-model="form.due_date"
|
||||
type="date"
|
||||
aria-invalid="!!form.errors.due_date"
|
||||
/>
|
||||
<InputError :message="form.errors.due_date" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="status">Statut</Label>
|
||||
<select
|
||||
id="status"
|
||||
v-model="form.status"
|
||||
class="border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-invalid="!!form.errors.status"
|
||||
>
|
||||
<option value="" disabled>Sélectionner un statut</option>
|
||||
<option
|
||||
v-for="(label, value) in folderStatusLabels"
|
||||
:key="value"
|
||||
:value="value"
|
||||
>
|
||||
{{ label }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError :message="form.errors.status" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label for="priority">Priorité</Label>
|
||||
<select
|
||||
id="priority"
|
||||
v-model="form.priority"
|
||||
class="border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-invalid="!!form.errors.priority"
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option
|
||||
v-for="(label, value) in folderPriorityLabels"
|
||||
:key="value"
|
||||
:value="value"
|
||||
>
|
||||
{{ label }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError :message="form.errors.priority" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="assigned_to">Assigné à</Label>
|
||||
<select
|
||||
id="assigned_to"
|
||||
v-model="form.assigned_to"
|
||||
class="border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-invalid="!!form.errors.assigned_to"
|
||||
>
|
||||
<option :value="''">—</option>
|
||||
<option
|
||||
v-for="user in workspaceUsers"
|
||||
:key="user.id"
|
||||
:value="user.id"
|
||||
>
|
||||
{{ user.name }} ({{ user.email }})
|
||||
</option>
|
||||
</select>
|
||||
<InputError :message="form.errors.assigned_to" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="notes_internal">Notes internes</Label>
|
||||
<textarea
|
||||
id="notes_internal"
|
||||
v-model="form.notes_internal"
|
||||
rows="3"
|
||||
class="border-input bg-background w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
placeholder="Notes confidentielles"
|
||||
:aria-invalid="!!form.errors.notes_internal"
|
||||
/>
|
||||
<InputError :message="form.errors.notes_internal" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="notes_client">Notes client</Label>
|
||||
<textarea
|
||||
id="notes_client"
|
||||
v-model="form.notes_client"
|
||||
rows="3"
|
||||
class="border-input bg-background w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
placeholder="Notes partagées avec le client"
|
||||
:aria-invalid="!!form.errors.notes_client"
|
||||
/>
|
||||
<InputError :message="form.errors.notes_client" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="form.processing"
|
||||
data-test="folder-form-submit"
|
||||
>
|
||||
<Spinner v-if="form.processing" />
|
||||
{{ submitLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
124
resources/js/components/clients/FolderCalendar.vue
Normal file
124
resources/js/components/clients/FolderCalendar.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-vue-next';
|
||||
|
||||
type Folder = {
|
||||
id: number;
|
||||
due_date: string | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
folders: Folder[];
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const monthNames = [
|
||||
'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
|
||||
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre',
|
||||
];
|
||||
const dayNames = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'];
|
||||
|
||||
const current = ref(new Date());
|
||||
|
||||
const monthLabel = computed(() =>
|
||||
`${monthNames[current.value.getMonth()]} ${current.value.getFullYear()}`,
|
||||
);
|
||||
|
||||
const datesWithFolders = computed(() => {
|
||||
const set = new Set<string>();
|
||||
props.folders.forEach((f) => {
|
||||
if (f.due_date) set.add(f.due_date);
|
||||
});
|
||||
return set;
|
||||
});
|
||||
|
||||
const foldersByDate = computed(() => {
|
||||
const map = new Map<string, number>();
|
||||
props.folders.forEach((f) => {
|
||||
if (f.due_date) {
|
||||
map.set(f.due_date, (map.get(f.due_date) ?? 0) + 1);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
const calendarDays = computed(() => {
|
||||
const year = current.value.getFullYear();
|
||||
const month = current.value.getMonth();
|
||||
const first = new Date(year, month, 1);
|
||||
const last = new Date(year, month + 1, 0);
|
||||
const startDay = (first.getDay() + 6) % 7;
|
||||
const daysInMonth = last.getDate();
|
||||
|
||||
const days: Array<{ date: Date | null; dateStr: string | null; count: number }> = [];
|
||||
|
||||
for (let i = 0; i < startDay; i++) {
|
||||
days.push({ date: null, dateStr: null, count: 0 });
|
||||
}
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const date = new Date(year, month, d);
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||
days.push({
|
||||
date,
|
||||
dateStr,
|
||||
count: foldersByDate.value.get(dateStr) ?? 0,
|
||||
});
|
||||
}
|
||||
return days;
|
||||
});
|
||||
|
||||
function prevMonth() {
|
||||
current.value = new Date(current.value.getFullYear(), current.value.getMonth() - 1);
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
current.value = new Date(current.value.getFullYear(), current.value.getMonth() + 1);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-xl border border-sidebar-border/70 bg-card p-4">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="icon" @click="prevMonth">
|
||||
<ChevronLeft class="size-4" />
|
||||
</Button>
|
||||
<span class="text-sm font-medium">{{ monthLabel }}</span>
|
||||
<Button variant="ghost" size="icon" @click="nextMonth">
|
||||
<ChevronRight class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="grid grid-cols-7 gap-1 text-center text-xs">
|
||||
<div
|
||||
v-for="day in dayNames"
|
||||
:key="day"
|
||||
class="py-1 font-medium text-muted-foreground"
|
||||
>
|
||||
{{ day }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(cell, i) in calendarDays"
|
||||
:key="i"
|
||||
class="flex min-h-8 flex-col items-center justify-center rounded-md py-1"
|
||||
:class="[
|
||||
!cell.date
|
||||
? 'invisible'
|
||||
: cell.count > 0
|
||||
? 'bg-primary/15 font-medium'
|
||||
: 'text-muted-foreground hover:bg-muted/50',
|
||||
]"
|
||||
>
|
||||
<template v-if="cell.date">
|
||||
{{ cell.date.getDate() }}
|
||||
<span
|
||||
v-if="cell.count > 0"
|
||||
class="mt-0.5 text-[10px]"
|
||||
>
|
||||
{{ cell.count }} dossier{{ cell.count > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
136
resources/js/components/folders/MessageBubble.vue
Normal file
136
resources/js/components/folders/MessageBubble.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CheckCircle2, Download, FileText } from 'lucide-vue-next';
|
||||
|
||||
type Props = {
|
||||
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;
|
||||
is_downloaded?: boolean;
|
||||
}>;
|
||||
confirmation_status?: 'pending' | 'confirmed' | 'refused' | null;
|
||||
};
|
||||
messageTypeLabels: Record<string, string>;
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
invite: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
situation: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
file_request: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300',
|
||||
confirmation: 'bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-300',
|
||||
text: 'bg-slate-100 text-slate-700 dark:bg-slate-800/50 dark:text-slate-300',
|
||||
};
|
||||
|
||||
const confirmationStatusLabels: Record<string, { label: string; class: string }> = {
|
||||
pending: {
|
||||
label: 'En attente',
|
||||
class: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
},
|
||||
confirmed: {
|
||||
label: 'Validé',
|
||||
class: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
|
||||
},
|
||||
refused: {
|
||||
label: 'Refusé',
|
||||
class: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',
|
||||
},
|
||||
};
|
||||
|
||||
function isImageMime(mime?: string | null): boolean {
|
||||
return mime?.startsWith('image/') ?? false;
|
||||
}
|
||||
|
||||
function getTypeColor(type: string): string {
|
||||
return typeColors[type] ?? 'bg-muted text-muted-foreground';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="rounded-2xl px-4 py-3"
|
||||
:class="
|
||||
message.sent_by_type === 'user'
|
||||
? 'ml-auto max-w-[85%] bg-muted'
|
||||
: 'mr-auto max-w-[85%] bg-muted/70'
|
||||
"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 text-sm">
|
||||
<span class="font-medium">{{ message.sender_name }}</span>
|
||||
<span class="text-muted-foreground">{{ message.created_at }}</span>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="inline-flex rounded px-2 py-0.5 text-xs font-medium"
|
||||
:class="getTypeColor(message.type)"
|
||||
>
|
||||
{{ messageTypeLabels[message.type] ?? message.type }}
|
||||
</span>
|
||||
<span
|
||||
v-if="message.confirmation_status"
|
||||
class="inline-flex rounded px-2 py-0.5 text-xs font-medium"
|
||||
:class="confirmationStatusLabels[message.confirmation_status]?.class ?? ''"
|
||||
>
|
||||
{{ confirmationStatusLabels[message.confirmation_status]?.label ?? message.confirmation_status }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 whitespace-pre-wrap text-sm">{{ message.body }}</p>
|
||||
<div
|
||||
v-if="message.attachments?.length"
|
||||
class="mt-3 flex flex-wrap gap-2"
|
||||
>
|
||||
<div
|
||||
v-for="att in message.attachments"
|
||||
:key="att.id"
|
||||
class="flex items-center gap-2 rounded-lg border border-sidebar-border/70 bg-background/50 p-2"
|
||||
>
|
||||
<div class="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded bg-muted">
|
||||
<img
|
||||
v-if="isImageMime(att.mime_type)"
|
||||
:src="att.downloadUrl"
|
||||
:alt="att.file_name"
|
||||
class="size-10 object-cover"
|
||||
/>
|
||||
<FileText
|
||||
v-else
|
||||
class="size-5 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="inline-flex items-center gap-1.5 truncate text-xs font-medium">
|
||||
{{ att.file_name }}
|
||||
<CheckCircle2 v-if="att.is_downloaded" class="size-3.5 text-green-500" />
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">{{ att.size }}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
as-child
|
||||
class="shrink-0"
|
||||
>
|
||||
<a
|
||||
:href="att.downloadUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
download
|
||||
@click="att.is_downloaded = true"
|
||||
>
|
||||
<Download class="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
91
resources/js/pages/folders/Create.vue
Normal file
91
resources/js/pages/folders/Create.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||
import FolderForm from '@/components/FolderForm.vue';
|
||||
import type { FolderFormData } from '@/components/FolderForm.vue';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
|
||||
type Client = {
|
||||
id: number;
|
||||
company_name: string;
|
||||
};
|
||||
|
||||
type WorkspaceUser = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
indexUrl: string;
|
||||
storeUrl: string;
|
||||
initialClientId?: number | null;
|
||||
folderTypeLabels: Record<string, string>;
|
||||
folderStatusLabels: Record<string, string>;
|
||||
folderPriorityLabels: Record<string, string>;
|
||||
clients: Client[];
|
||||
workspaceUsers: WorkspaceUser[];
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const form = useForm<FolderFormData>({
|
||||
client_id: props.initialClientId ? String(props.initialClientId) : '',
|
||||
title: '',
|
||||
type: 'vat_monthly',
|
||||
period_year: currentYear,
|
||||
period_month: '',
|
||||
period_quarter: '',
|
||||
due_date: '',
|
||||
status: 'draft',
|
||||
priority: 'medium',
|
||||
assigned_to: '',
|
||||
notes_internal: '',
|
||||
notes_client: '',
|
||||
});
|
||||
|
||||
function submit() {
|
||||
form.post(props.storeUrl);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout
|
||||
:breadcrumbs="[
|
||||
{ title: 'Dossiers', href: props.indexUrl },
|
||||
{ title: 'Créer un dossier' },
|
||||
]"
|
||||
>
|
||||
<Head title="Créer un dossier" />
|
||||
|
||||
<div class="flex flex-col space-y-6 p-4">
|
||||
<Heading
|
||||
title="Créer un dossier"
|
||||
description="Créer un nouveau dossier fiscal"
|
||||
/>
|
||||
<div
|
||||
v-if="!props.clients.length"
|
||||
class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-200"
|
||||
>
|
||||
Aucun client dans ce workspace. Créez d'abord un
|
||||
<Link href="/clients" class="font-medium underline">
|
||||
client
|
||||
</Link>
|
||||
pour pouvoir créer un dossier.
|
||||
</div>
|
||||
<FolderForm
|
||||
v-else
|
||||
:form="form"
|
||||
:folder-type-labels="props.folderTypeLabels"
|
||||
:folder-status-labels="props.folderStatusLabels"
|
||||
:folder-priority-labels="props.folderPriorityLabels"
|
||||
:clients="props.clients"
|
||||
:workspace-users="props.workspaceUsers"
|
||||
submit-label="Créer le dossier"
|
||||
@submit="submit"
|
||||
/>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
94
resources/js/pages/folders/Edit.vue
Normal file
94
resources/js/pages/folders/Edit.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, useForm } from '@inertiajs/vue3';
|
||||
import FolderForm from '@/components/FolderForm.vue';
|
||||
import type { FolderFormData } from '@/components/FolderForm.vue';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
|
||||
type Client = {
|
||||
id: number;
|
||||
company_name: string;
|
||||
};
|
||||
|
||||
type WorkspaceUser = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
type Folder = {
|
||||
id: number;
|
||||
title: string;
|
||||
type: string;
|
||||
client_id: number;
|
||||
period_year: number;
|
||||
period_month: number | null;
|
||||
period_quarter: number | null;
|
||||
due_date: string | null;
|
||||
status: string;
|
||||
priority: string | null;
|
||||
assigned_to: number | null;
|
||||
notes_internal: string | null;
|
||||
notes_client: string | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
folder: Folder;
|
||||
indexUrl: string;
|
||||
updateUrl: string;
|
||||
folderTypeLabels: Record<string, string>;
|
||||
folderStatusLabels: Record<string, string>;
|
||||
folderPriorityLabels: Record<string, string>;
|
||||
clients: Client[];
|
||||
workspaceUsers: WorkspaceUser[];
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const form = useForm<FolderFormData>({
|
||||
client_id: props.folder.client_id,
|
||||
title: props.folder.title,
|
||||
type: props.folder.type,
|
||||
period_year: props.folder.period_year,
|
||||
period_month: props.folder.period_month ?? '',
|
||||
period_quarter: props.folder.period_quarter ?? '',
|
||||
due_date: props.folder.due_date ?? '',
|
||||
status: props.folder.status ?? 'draft',
|
||||
priority: props.folder.priority ?? 'medium',
|
||||
assigned_to: props.folder.assigned_to ?? '',
|
||||
notes_internal: props.folder.notes_internal ?? '',
|
||||
notes_client: props.folder.notes_client ?? '',
|
||||
});
|
||||
|
||||
function submit() {
|
||||
form.put(props.updateUrl);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout
|
||||
:breadcrumbs="[
|
||||
{ title: 'Dossiers', href: props.indexUrl },
|
||||
{ title: 'Modifier le dossier' },
|
||||
]"
|
||||
>
|
||||
<Head :title="`Modifier ${props.folder.title}`" />
|
||||
|
||||
<div class="flex flex-col space-y-6 p-4">
|
||||
<Heading
|
||||
:title="`Modifier ${props.folder.title}`"
|
||||
description="Mettre à jour les informations du dossier"
|
||||
/>
|
||||
<FolderForm
|
||||
:form="form"
|
||||
:folder-type-labels="props.folderTypeLabels"
|
||||
:folder-status-labels="props.folderStatusLabels"
|
||||
:folder-priority-labels="props.folderPriorityLabels"
|
||||
:clients="props.clients"
|
||||
:workspace-users="props.workspaceUsers"
|
||||
submit-label="Enregistrer les modifications"
|
||||
@submit="submit"
|
||||
/>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
216
resources/js/pages/folders/Index.vue
Normal file
216
resources/js/pages/folders/Index.vue
Normal file
@@ -0,0 +1,216 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link, router } from '@inertiajs/vue3';
|
||||
import { FolderOpen } from 'lucide-vue-next';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import Pagination from '@/components/Pagination.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
type Folder = {
|
||||
id: number;
|
||||
title: string;
|
||||
type: string;
|
||||
client_name: string;
|
||||
status: string;
|
||||
due_date: string | null;
|
||||
showUrl: string;
|
||||
editUrl: string;
|
||||
destroyUrl: string;
|
||||
};
|
||||
|
||||
type PaginatedData<T> = {
|
||||
data: T[];
|
||||
from: number | null;
|
||||
to: number | null;
|
||||
total: number;
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
path: string;
|
||||
first_page_url: string;
|
||||
prev_page_url: string | null;
|
||||
next_page_url: string | null;
|
||||
last_page_url: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
folders: PaginatedData<Folder>;
|
||||
createUrl: string;
|
||||
workspaceName: string;
|
||||
};
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
function destroy(folder: Folder) {
|
||||
if (
|
||||
window.confirm(
|
||||
`Êtes-vous sûr de vouloir supprimer « ${folder.title} » ?`,
|
||||
)
|
||||
) {
|
||||
router.delete(folder.destroyUrl);
|
||||
}
|
||||
}
|
||||
|
||||
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é',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout
|
||||
:breadcrumbs="[
|
||||
{ title: 'Dossiers' },
|
||||
]"
|
||||
>
|
||||
<Head title="Dossiers" />
|
||||
|
||||
<div class="flex flex-col space-y-6 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<Heading
|
||||
variant="small"
|
||||
title="Dossiers"
|
||||
:description="`Gérer les dossiers du workspace « ${workspaceName} »`"
|
||||
/>
|
||||
<Button as-child>
|
||||
<Link :href="createUrl">Créer un dossier</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-xl border border-sidebar-border/70 dark:border-sidebar-border overflow-hidden"
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="border-b border-sidebar-border/70 bg-muted/50">
|
||||
<tr>
|
||||
<th
|
||||
class="h-10 px-4 text-left font-medium align-middle"
|
||||
>
|
||||
Titre
|
||||
</th>
|
||||
<th
|
||||
class="h-10 px-4 text-left font-medium align-middle"
|
||||
>
|
||||
Client
|
||||
</th>
|
||||
<th
|
||||
class="h-10 px-4 text-left font-medium align-middle"
|
||||
>
|
||||
Type
|
||||
</th>
|
||||
<th
|
||||
class="h-10 px-4 text-left font-medium align-middle"
|
||||
>
|
||||
Statut
|
||||
</th>
|
||||
<th
|
||||
class="h-10 px-4 text-left font-medium align-middle"
|
||||
>
|
||||
Date limite
|
||||
</th>
|
||||
<th
|
||||
class="h-10 px-4 text-right font-medium align-middle"
|
||||
>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="folder in folders.data"
|
||||
:key="folder.id"
|
||||
class="border-b border-sidebar-border/50 last:border-0"
|
||||
>
|
||||
<td class="px-4 py-3 font-medium">
|
||||
<Link
|
||||
:href="folder.showUrl"
|
||||
class="hover:underline"
|
||||
>
|
||||
{{ folder.title }}
|
||||
</Link>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-muted-foreground">
|
||||
{{ folder.client_name }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-muted-foreground">
|
||||
{{ typeLabels[folder.type] ?? folder.type }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-muted-foreground">
|
||||
{{ statusLabels[folder.status] ?? folder.status }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-muted-foreground">
|
||||
{{ folder.due_date || '—' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right space-x-2">
|
||||
<Button variant="outline" size="sm" as-child>
|
||||
<Link :href="folder.showUrl"
|
||||
>Voir</Link
|
||||
>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" as-child>
|
||||
<Link :href="folder.editUrl"
|
||||
>Modifier</Link
|
||||
>
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
@click="destroy(folder)"
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!folders.data.length">
|
||||
<td
|
||||
colspan="6"
|
||||
class="px-4 py-8 text-center text-muted-foreground"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<FolderOpen class="h-10 w-10" />
|
||||
<p>Aucun dossier pour le moment.</p>
|
||||
<Button as-child>
|
||||
<Link :href="createUrl"
|
||||
>Créer votre premier
|
||||
dossier</Link
|
||||
>
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<Pagination
|
||||
:pagination="{
|
||||
from: folders.from ?? 0,
|
||||
to: folders.to ?? 0,
|
||||
total: folders.total,
|
||||
current_page: folders.current_page,
|
||||
last_page: folders.last_page,
|
||||
per_page: folders.per_page,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
696
resources/js/pages/folders/Show.vue
Normal file
696
resources/js/pages/folders/Show.vue
Normal file
@@ -0,0 +1,696 @@
|
||||
<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>
|
||||
21
resources/views/emails/folder-confirmation.blade.php
Normal file
21
resources/views/emails/folder-confirmation.blade.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<x-mail::message>
|
||||
# Demande de validation
|
||||
|
||||
Bonjour,
|
||||
|
||||
Votre cabinet comptable vous demande de valider la situation pour le dossier **{{ $folderTitle }}**.
|
||||
|
||||
{{ $body }}
|
||||
|
||||
Veuillez confirmer ou refuser cette demande en cliquant sur l'un des boutons ci-dessous.
|
||||
|
||||
<x-mail::button :url="$confirmUrl" color="success">
|
||||
Confirmer
|
||||
</x-mail::button>
|
||||
|
||||
<x-mail::button :url="$refuseUrl" color="danger">
|
||||
Refuser
|
||||
</x-mail::button>
|
||||
|
||||
Ce lien est valide jusqu'au {{ $expiresAt }}.
|
||||
</x-mail::message>
|
||||
17
resources/views/emails/folder-file-request.blade.php
Normal file
17
resources/views/emails/folder-file-request.blade.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<x-mail::message>
|
||||
# Documents complémentaires demandés
|
||||
|
||||
Bonjour,
|
||||
|
||||
Votre cabinet comptable vous demande des documents complémentaires pour le dossier **{{ $folderTitle }}**.
|
||||
|
||||
{{ $body }}
|
||||
|
||||
Cliquez sur le bouton ci-dessous pour déposer les documents demandés.
|
||||
|
||||
<x-mail::button :url="$uploadUrl" color="primary">
|
||||
Déposer mes documents
|
||||
</x-mail::button>
|
||||
|
||||
Ce lien est valide jusqu'au {{ $expiresAt }}.
|
||||
</x-mail::message>
|
||||
17
resources/views/emails/folder-invite.blade.php
Normal file
17
resources/views/emails/folder-invite.blade.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<x-mail::message>
|
||||
# Invitation à déposer vos documents
|
||||
|
||||
Bonjour,
|
||||
|
||||
Votre cabinet comptable vous invite à déposer les documents nécessaires pour le dossier **{{ $folderTitle }}**.
|
||||
|
||||
Cliquez sur le bouton ci-dessous pour accéder à l'interface sécurisée de dépôt de documents.
|
||||
|
||||
<x-mail::button :url="$uploadUrl" color="primary">
|
||||
Déposer mes documents
|
||||
</x-mail::button>
|
||||
|
||||
Ce lien est valide jusqu'au {{ $expiresAt }}.
|
||||
|
||||
Si vous n'avez pas demandé cette invitation, vous pouvez ignorer cet email.
|
||||
</x-mail::message>
|
||||
13
resources/views/emails/folder-mention.blade.php
Normal file
13
resources/views/emails/folder-mention.blade.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<x-mail::message>
|
||||
# Mention sur un dossier
|
||||
|
||||
Bonjour,
|
||||
|
||||
**{{ $mentionedByName }}** vous a mentionné sur le dossier **{{ $folderTitle }}**.
|
||||
|
||||
> {{ $message }}
|
||||
|
||||
<x-mail::button :url="$url" color="primary">
|
||||
Voir le dossier
|
||||
</x-mail::button>
|
||||
</x-mail::message>
|
||||
17
resources/views/emails/folder-situation.blade.php
Normal file
17
resources/views/emails/folder-situation.blade.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<x-mail::message>
|
||||
# Situation mise à jour
|
||||
|
||||
Bonjour,
|
||||
|
||||
Votre cabinet comptable a mis à jour la situation pour le dossier **{{ $folderTitle }}**.
|
||||
|
||||
{{ $body }}
|
||||
|
||||
Cliquez sur le bouton ci-dessous pour déposer les documents manquants.
|
||||
|
||||
<x-mail::button :url="$uploadUrl" color="primary">
|
||||
Déposer mes documents
|
||||
</x-mail::button>
|
||||
|
||||
Ce lien est valide jusqu'au {{ $expiresAt }}.
|
||||
</x-mail::message>
|
||||
13
resources/views/emails/folder-text-message.blade.php
Normal file
13
resources/views/emails/folder-text-message.blade.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<x-mail::message>
|
||||
# Nouveau message
|
||||
|
||||
Bonjour,
|
||||
|
||||
Vous avez reçu un nouveau message pour le dossier **{{ $folderTitle }}**.
|
||||
|
||||
> {{ $body }}
|
||||
|
||||
<x-mail::button :url="$messagesUrl" color="primary">
|
||||
Voir les messages
|
||||
</x-mail::button>
|
||||
</x-mail::message>
|
||||
Reference in New Issue
Block a user