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:
2026-03-22 21:24:17 +01:00
parent a02b5f12d8
commit 7a18c40361
695 changed files with 86662 additions and 0 deletions

View 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 (JanMar)' },
{ value: '2', label: 'T2 (AvrJuin)' },
{ value: '3', label: 'T3 (JuilSep)' },
{ value: '4', label: 'T4 (OctDé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>

View 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>

View 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>