feat: L'Ami Fiduciaire V1.0.0 — full codebase with Story 0.1 complete

Initial commit of the L'Ami Fiduciaire SaaS platform built on Laravel 12,
Vue 3, Inertia.js 2, and Tailwind CSS 4.

Story 0.1 (rename folders to declarations in database) is implemented and
code-reviewed: migration, rollback, and 6 Pest tests all passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 23:33:10 +00:00
commit 35545c2a8f
1517 changed files with 246774 additions and 0 deletions

View File

@@ -0,0 +1,393 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Head, Link } from '@inertiajs/vue3';
import {
Briefcase,
Building2,
Users,
FolderOpen,
AlertTriangle,
Clock,
FileCheck,
MessageSquareWarning,
ArrowRight,
Folder,
} from 'lucide-vue-next';
import AppLayout from '@/layouts/AppLayout.vue';
import type { BreadcrumbItem } from '@/types';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { dashboard } from '@/routes';
import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
type AssignedFolder = {
id: number;
title: string;
type: string;
client_name: string;
status: string;
due_date: string | null;
priority: string | null;
showUrl: string;
};
type NotificationItem = {
id: number;
title: string;
client_name: string;
due_date?: string;
showUrl: string;
};
type Props = {
assignedFolders: AssignedFolder[];
notifications: {
overdue: NotificationItem[];
due_soon: NotificationItem[];
documents_received: NotificationItem[];
awaiting_validation: NotificationItem[];
};
workspaceName: string | null;
foldersUrl: string | null;
clientsUrl: string | null;
};
const props = defineProps<Props>();
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Dashboard',
href: dashboard().url,
},
];
const hasWorkspace = computed(() => !!props.workspaceName);
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',
additional_documents_requested: 'Pièces complémentaires',
waiting_client_validation: 'En attente validation',
validated: 'Validé',
closed: 'Clôturé',
cancelled: 'Annulé',
};
const statusVariant: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
draft: 'secondary',
waiting_documents: 'outline',
documents_received: 'default',
processing: 'default',
additional_documents_requested: 'default',
waiting_client_validation: 'outline',
validated: 'secondary',
closed: 'secondary',
cancelled: 'secondary',
};
function statusLabel(s: string): string {
return statusLabels[s] ?? s;
}
function typeLabel(t: string): string {
return typeLabels[t] ?? t;
}
function progressPercent(status: string): number {
const steps: Record<string, number> = {
draft: 0,
waiting_documents: 10,
documents_received: 30,
processing: 50,
additional_documents_requested: 45,
waiting_client_validation: 80,
validated: 100,
closed: 100,
cancelled: 0,
};
return steps[status] ?? 50;
}
const hasAnyNotifications = computed(
() =>
props.notifications.overdue.length > 0 ||
props.notifications.due_soon.length > 0 ||
props.notifications.documents_received.length > 0 ||
props.notifications.awaiting_validation.length > 0,
);
</script>
<template>
<Head title="Dashboard" />
<AppLayout :breadcrumbs="breadcrumbs">
<div class="flex h-full flex-1 flex-col gap-6 overflow-x-auto rounded-xl p-4">
<!-- Quick links when no workspace -->
<div v-if="!hasWorkspace" class="grid auto-rows-min gap-4 md:grid-cols-3">
<Link href="/users"
class="relative flex aspect-video flex-col items-center justify-center gap-2 overflow-hidden rounded-xl border border-sidebar-border/70 transition-colors hover:bg-muted/50 dark:border-sidebar-border">
<Users class="h-8 w-8" />
<span class="font-medium">Users</span>
<span class="text-xs text-muted-foreground">Manage users</span>
</Link>
<Link href="/workspaces"
class="relative flex aspect-video flex-col items-center justify-center gap-2 overflow-hidden rounded-xl border border-sidebar-border/70 transition-colors hover:bg-muted/50 dark:border-sidebar-border">
<Building2 class="h-8 w-8" />
<span class="font-medium">Workspaces</span>
<span class="text-xs text-muted-foreground">Cabinets comptables</span>
</Link>
<Link v-if="clientsUrl" :href="clientsUrl"
class="relative flex aspect-video flex-col items-center justify-center gap-2 overflow-hidden rounded-xl border border-sidebar-border/70 transition-colors hover:bg-muted/50 dark:border-sidebar-border">
<Briefcase class="h-8 w-8" />
<span class="font-medium">Clients</span>
<span class="text-xs text-muted-foreground">Manage clients</span>
</Link>
</div>
<div v-if="hasWorkspace" class="grid auto-rows-min gap-4 md:grid-cols-3">
<Link href="/folders"
class="relative flex aspect-video flex-col items-center justify-center gap-2 overflow-hidden rounded-xl border border-sidebar-border/70 transition-colors hover:bg-muted/50 dark:border-sidebar-border">
<Folder class="h-8 w-8" />
<span class="font-medium">Dossiers</span>
<span class="text-xs text-muted-foreground">Manage folders</span>
</Link>
<Link v-if="clientsUrl" :href="clientsUrl"
class="relative flex aspect-video flex-col items-center justify-center gap-2 overflow-hidden rounded-xl border border-sidebar-border/70 transition-colors hover:bg-muted/50 dark:border-sidebar-border">
<Briefcase class="h-8 w-8" />
<span class="font-medium">Clients</span>
<span class="text-xs text-muted-foreground">Manage clients</span>
</Link>
<div
class="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"
>
<PlaceholderPattern />
</div>
</div>
<!-- Workspace dashboard -->
<template v-if="hasWorkspace">
<!-- Notifications -->
<div v-if="hasAnyNotifications" class="space-y-4">
<h2 class="text-lg font-semibold">À traiter</h2>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card v-if="notifications.overdue.length > 0" class="border-destructive/50 bg-destructive/5">
<CardHeader class="pb-2">
<CardTitle class="flex items-center gap-2 text-base">
<AlertTriangle class="h-4 w-4 text-destructive" />
En retard
</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
<Link v-for="item in notifications.overdue" :key="item.id" :href="item.showUrl"
class="flex items-center justify-between rounded-md p-2 text-sm transition-colors hover:bg-muted/50">
<div class="truncate">
<span class="font-medium">{{
item.title
}}</span>
<span class="ml-1 text-muted-foreground">
{{ item.client_name }}
</span>
</div>
<ArrowRight class="h-4 w-4 shrink-0" />
</Link>
</CardContent>
</Card>
<Card v-if="notifications.due_soon.length > 0" class="border-amber-500/50 bg-amber-500/5">
<CardHeader class="pb-2">
<CardTitle class="flex items-center gap-2 text-base">
<Clock class="h-4 w-4 text-amber-600" />
Échéance proche
</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
<Link v-for="item in notifications.due_soon" :key="item.id" :href="item.showUrl"
class="flex items-center justify-between rounded-md p-2 text-sm transition-colors hover:bg-muted/50">
<div class="truncate">
<span class="font-medium">{{
item.title
}}</span>
<span class="ml-1 text-muted-foreground">
{{ item.client_name }}
{{ item.due_date }}
</span>
</div>
<ArrowRight class="h-4 w-4 shrink-0" />
</Link>
</CardContent>
</Card>
<Card v-if="notifications.documents_received.length > 0" class="border-primary/50 bg-primary/5">
<CardHeader class="pb-2">
<CardTitle class="flex items-center gap-2 text-base">
<FileCheck class="h-4 w-4 text-primary" />
Documents reçus
</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
<Link v-for="item in notifications.documents_received" :key="item.id"
:href="item.showUrl"
class="flex items-center justify-between rounded-md p-2 text-sm transition-colors hover:bg-muted/50">
<div class="truncate">
<span class="font-medium">{{
item.title
}}</span>
<span class="ml-1 text-muted-foreground">
{{ item.client_name }}
</span>
</div>
<ArrowRight class="h-4 w-4 shrink-0" />
</Link>
</CardContent>
</Card>
<Card v-if="notifications.awaiting_validation.length > 0"
class="border-blue-500/50 bg-blue-500/5">
<CardHeader class="pb-2">
<CardTitle class="flex items-center gap-2 text-base">
<MessageSquareWarning class="h-4 w-4 text-blue-600" />
En attente validation client
</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
<Link v-for="item in notifications.awaiting_validation" :key="item.id"
:href="item.showUrl"
class="flex items-center justify-between rounded-md p-2 text-sm transition-colors hover:bg-muted/50">
<div class="truncate">
<span class="font-medium">{{
item.title
}}</span>
<span class="ml-1 text-muted-foreground">
{{ item.client_name }}
</span>
</div>
<ArrowRight class="h-4 w-4 shrink-0" />
</Link>
</CardContent>
</Card>
</div>
</div>
<!-- My assigned dossiers -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold">
Mes dossiers {{ workspaceName }}
</h2>
<Button v-if="foldersUrl" variant="outline" as-child>
<Link :href="foldersUrl">
Tous les dossiers
<ArrowRight class="ml-1 h-4 w-4" />
</Link>
</Button>
</div>
<Card v-if="assignedFolders.length > 0" class="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">
Dossier / Client
</th>
<th class="h-10 px-4 text-left font-medium">
Type
</th>
<th class="h-10 px-4 text-left font-medium">
Statut
</th>
<th class="h-10 px-4 text-left font-medium">
Progression
</th>
<th class="h-10 px-4 text-left font-medium">
Date limite
</th>
<th class="h-10 w-10 px-4"></th>
</tr>
</thead>
<tbody>
<tr v-for="folder in assignedFolders" :key="folder.id"
class="border-b border-sidebar-border/50 last:border-0 transition-colors hover:bg-muted/30">
<td class="px-4 py-3">
<Link :href="folder.showUrl" class="block font-medium hover:underline">
{{ folder.title }}
</Link>
<span class="block text-xs text-muted-foreground">
{{ folder.client_name }}
</span>
</td>
<td class="px-4 py-3 text-muted-foreground">
{{ typeLabel(folder.type) }}
</td>
<td class="px-4 py-3">
<Badge :variant="statusVariant[folder.status] ?? 'secondary'
">
{{
statusLabel(folder.status)
}}
</Badge>
</td>
<td class="px-4 py-3">
<div class="flex h-2 w-24 overflow-hidden rounded-full bg-muted">
<div class="h-full bg-primary transition-all" :style="{
width: `${progressPercent(folder.status)}%`,
}" />
</div>
<span class="text-xs text-muted-foreground">
{{ progressPercent(folder.status) }}%
</span>
</td>
<td class="px-4 py-3">
<span :class="{
'text-destructive font-medium':
folder.due_date &&
folder.due_date <
new Date()
.toISOString()
.slice(0, 10),
}">
{{ folder.due_date || '—' }}
</span>
</td>
<td class="px-4 py-3">
<Button variant="ghost" size="sm" as-child>
<Link :href="folder.showUrl">
Voir
<ArrowRight class="ml-1 h-3 w-3" />
</Link>
</Button>
</td>
</tr>
</tbody>
</table>
</div>
</Card>
<Card v-else>
<CardContent class="flex flex-col items-center justify-center py-12">
<FolderOpen class="mb-3 h-12 w-12 text-muted-foreground" />
<p class="mb-2 text-muted-foreground">
Aucun dossier ne vous est assigné pour le moment.
</p>
<Button v-if="foldersUrl" as-child>
<Link :href="foldersUrl">Voir tous les dossiers</Link>
</Button>
</CardContent>
</Card>
</div>
</template>
</div>
</AppLayout>
</template>

View File

@@ -0,0 +1,212 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { dashboard, login, register } from '@/routes';
import AppLogoIcon from '@/components/AppLogoIcon.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import {
Building2,
Calendar,
FileCheck,
Mail,
Shield,
Users,
} from 'lucide-vue-next';
withDefaults(
defineProps<{
canRegister: boolean;
}>(),
{
canRegister: true,
},
);
</script>
<template>
<Head :title="`${$page.props.name} — Gestion des dossiers fiscaux`" />
<div class="min-h-screen bg-background">
<!-- Header -->
<header class="sticky top-0 z-50 border-b border-sidebar-border/70 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div class="mx-auto flex h-16 max-w-6xl items-center justify-between px-4">
<Link href="/" class="flex items-center gap-2 font-semibold">
<AppLogoIcon class="size-8 fill-current text-primary" />
<span>{{ $page.props.name }}</span>
</Link>
<nav class="flex items-center gap-4">
<template v-if="$page.props.auth.user">
<Button as-child>
<Link :href="dashboard()">Tableau de bord</Link>
</Button>
</template>
<template v-else>
<Button variant="ghost" as-child>
<Link :href="login()">Connexion</Link>
</Button>
<Button v-if="canRegister" as-child>
<Link :href="register()">Créer un compte</Link>
</Button>
</template>
</nav>
</div>
</header>
<main>
<!-- Hero -->
<section class="px-4 py-20 md:py-28">
<div class="mx-auto max-w-4xl text-center">
<h1 class="text-4xl font-bold tracking-tight sm:text-5xl lg:text-6xl">
Simplifiez la gestion des
<span class="text-primary">dossiers fiscaux</span>
</h1>
<p class="mt-6 max-w-2xl mx-auto text-lg text-muted-foreground">
Plateforme dédiée aux cabinets d'expertise comptable au Maroc.
Centralisez les documents, les demandes de pièces et les validations clients en un seul endroit.
</p>
<div class="mt-10 flex flex-wrap items-center justify-center gap-4">
<template v-if="$page.props.auth.user">
<Button size="lg" as-child>
<Link :href="dashboard()">Accéder au tableau de bord</Link>
</Button>
</template>
<template v-else>
<Button size="lg" as-child>
<Link :href="register()">Commencer gratuitement</Link>
</Button>
<Button size="lg" variant="outline" as-child>
<Link :href="login()">Se connecter</Link>
</Button>
</template>
</div>
</div>
</section>
<Separator />
<!-- Features -->
<section class="px-4 py-20">
<div class="mx-auto max-w-6xl">
<div class="text-center mb-16">
<h2 class="text-3xl font-bold tracking-tight sm:text-4xl">
Tout ce dont votre cabinet a besoin
</h2>
<p class="mt-4 max-w-2xl mx-auto text-muted-foreground">
Une solution complète pour gérer les échanges de documents fiscaux avec vos clients.
</p>
</div>
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader>
<div class="mb-2 flex size-12 items-center justify-center rounded-lg bg-primary/10">
<Users class="size-6 text-primary" />
</div>
<CardTitle>Gestion des clients</CardTitle>
<CardDescription>
Gérez vos clients et leurs informations (ICE, IF, RC, CNSS)
</CardDescription>
</CardHeader>
</Card>
<Card>
<CardHeader>
<div class="mb-2 flex size-12 items-center justify-center rounded-lg bg-primary/10">
<Calendar class="size-6 text-primary" />
</div>
<CardTitle>Dossiers par type</CardTitle>
<CardDescription>
TVA, IS, IR, CNSS, Bilan annuel créez et suivez vos dossiers
</CardDescription>
</CardHeader>
</Card>
<Card>
<CardHeader>
<div class="mb-2 flex size-12 items-center justify-center rounded-lg bg-primary/10">
<Mail class="size-6 text-primary" />
</div>
<CardTitle>Invitations sécurisées</CardTitle>
<CardDescription>
Envoyez des liens par email pour que vos clients déposent leurs documents
</CardDescription>
</CardHeader>
</Card>
<Card>
<CardHeader>
<div class="mb-2 flex size-12 items-center justify-center rounded-lg bg-primary/10">
<FileCheck class="size-6 text-primary" />
</div>
<CardTitle>Documents centralisés</CardTitle>
<CardDescription>
Tous les documents dans un espace unique, avec historique et téléchargement
</CardDescription>
</CardHeader>
</Card>
<!-- <Card>
<CardHeader>
<div class="mb-2 flex size-12 items-center justify-center rounded-lg bg-primary/10">
<Building2 class="size-6 text-primary" />
</div>
<CardTitle>Multi-tenant</CardTitle>
<CardDescription>
Un workspace par cabinet, isolation complète des données
</CardDescription>
</CardHeader>
</Card> -->
<Card>
<CardHeader>
<div class="mb-2 flex size-12 items-center justify-center rounded-lg bg-primary/10">
<Shield class="size-6 text-primary" />
</div>
<CardTitle>Validation client</CardTitle>
<CardDescription>
Demandez une confirmation avec signature pour valider vos situations
</CardDescription>
</CardHeader>
</Card>
</div>
</div>
</section>
<Separator />
<!-- CTA -->
<section class="px-4 py-20">
<div class="mx-auto max-w-4xl text-center">
<h2 class="text-3xl font-bold tracking-tight sm:text-4xl">
Prêt à simplifier votre quotidien ?
</h2>
<p class="mt-4 text-muted-foreground">
Rejoignez les cabinets qui font confiance à {{ $page.props.name }}.
</p>
<div class="mt-8">
<template v-if="$page.props.auth.user">
<Button size="lg" as-child>
<Link :href="dashboard()">Accéder au tableau de bord</Link>
</Button>
</template>
<template v-else>
<Button size="lg" as-child>
<Link :href="register()">Créer un compte gratuit</Link>
</Button>
</template>
</div>
</div>
</section>
</main>
<!-- Footer -->
<footer class="border-t border-sidebar-border/70 py-8">
<div class="mx-auto max-w-6xl px-4">
<div class="flex flex-col items-center justify-between gap-4 sm:flex-row">
<div class="flex items-center gap-2">
<AppLogoIcon class="size-5 fill-current text-muted-foreground" />
<span class="text-sm text-muted-foreground">{{ $page.props.name }}</span>
</div>
<p class="text-sm text-muted-foreground">
Tous droits réservés © {{ new Date().getFullYear() }} {{ $page.props.name }}.
</p>
</div>
</div>
</footer>
</div>
</template>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
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';
import AuthLayout from '@/layouts/AuthLayout.vue';
import { store } from '@/routes/password/confirm';
</script>
<template>
<AuthLayout
title="Confirm your password"
description="This is a secure area of the application. Please confirm your password before continuing."
>
<Head title="Confirm password" />
<Form
v-bind="store.form()"
reset-on-success
v-slot="{ errors, processing }"
>
<div class="space-y-6">
<div class="grid gap-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
name="password"
class="mt-1 block w-full"
required
autocomplete="current-password"
autofocus
/>
<InputError :message="errors.password" />
</div>
<div class="flex items-center">
<Button
class="w-full"
:disabled="processing"
data-test="confirm-password-button"
>
<Spinner v-if="processing" />
Confirm Password
</Button>
</div>
</div>
</Form>
</AuthLayout>
</template>

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import InputError from '@/components/InputError.vue';
import TextLink from '@/components/TextLink.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';
import AuthLayout from '@/layouts/AuthLayout.vue';
import { login } from '@/routes';
import { email } from '@/routes/password';
defineProps<{
status?: string;
}>();
</script>
<template>
<AuthLayout
title="Forgot password"
description="Enter your email to receive a password reset link"
>
<Head title="Forgot password" />
<div
v-if="status"
class="mb-4 text-center text-sm font-medium text-green-600"
>
{{ status }}
</div>
<div class="space-y-6">
<Form v-bind="email.form()" v-slot="{ errors, processing }">
<div class="grid gap-2">
<Label for="email">Email address</Label>
<Input
id="email"
type="email"
name="email"
autocomplete="off"
autofocus
placeholder="email@example.com"
/>
<InputError :message="errors.email" />
</div>
<div class="my-6 flex items-center justify-start">
<Button
class="w-full"
:disabled="processing"
data-test="email-password-reset-link-button"
>
<Spinner v-if="processing" />
Email password reset link
</Button>
</div>
</Form>
<div class="space-x-1 text-center text-sm text-muted-foreground">
<span>Or, return to</span>
<TextLink :href="login()">log in</TextLink>
</div>
</div>
</AuthLayout>
</template>

View File

@@ -0,0 +1,110 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import InputError from '@/components/InputError.vue';
import TextLink from '@/components/TextLink.vue';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import AuthBase from '@/layouts/AuthLayout.vue';
import { register } from '@/routes';
import { store } from '@/routes/login';
import { request } from '@/routes/password';
defineProps<{
status?: string;
canResetPassword: boolean;
canRegister: boolean;
}>();
</script>
<template>
<AuthBase
title="Log in to your account"
description="Enter your email and password below to log in"
>
<Head title="Log in" />
<div
v-if="status"
class="mb-4 text-center text-sm font-medium text-green-600"
>
{{ status }}
</div>
<Form
v-bind="store.form()"
:reset-on-success="['password']"
v-slot="{ errors, processing }"
class="flex flex-col gap-6"
>
<div class="grid gap-6">
<div class="grid gap-2">
<Label for="email">Email address</Label>
<Input
id="email"
type="email"
name="email"
required
autofocus
:tabindex="1"
autocomplete="email"
placeholder="email@example.com"
/>
<InputError :message="errors.email" />
</div>
<div class="grid gap-2">
<div class="flex items-center justify-between">
<Label for="password">Password</Label>
<TextLink
v-if="canResetPassword"
:href="request()"
class="text-sm"
:tabindex="5"
>
Forgot password?
</TextLink>
</div>
<Input
id="password"
type="password"
name="password"
required
:tabindex="2"
autocomplete="current-password"
placeholder="Password"
/>
<InputError :message="errors.password" />
</div>
<div class="flex items-center justify-between">
<Label for="remember" class="flex items-center space-x-3">
<Checkbox id="remember" name="remember" :tabindex="3" />
<span>Remember me</span>
</Label>
</div>
<Button
type="submit"
class="mt-4 w-full"
:tabindex="4"
:disabled="processing"
data-test="login-button"
>
<Spinner v-if="processing" />
Log in
</Button>
</div>
<div
class="text-center text-sm text-muted-foreground"
v-if="canRegister"
>
Don't have an account?
<TextLink :href="register()" :tabindex="5">Sign up</TextLink>
</div>
</Form>
</AuthBase>
</template>

View File

@@ -0,0 +1,108 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import InputError from '@/components/InputError.vue';
import TextLink from '@/components/TextLink.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';
import AuthBase from '@/layouts/AuthLayout.vue';
import { login } from '@/routes';
import { store } from '@/routes/register';
</script>
<template>
<AuthBase
title="Create an account"
description="Enter your details below to create your account"
>
<Head title="Register" />
<Form
v-bind="store.form()"
:reset-on-success="['password', 'password_confirmation']"
v-slot="{ errors, processing }"
class="flex flex-col gap-6"
>
<div class="grid gap-6">
<div class="grid gap-2">
<Label for="name">Name</Label>
<Input
id="name"
type="text"
required
autofocus
:tabindex="1"
autocomplete="name"
name="name"
placeholder="Full name"
/>
<InputError :message="errors.name" />
</div>
<div class="grid gap-2">
<Label for="email">Email address</Label>
<Input
id="email"
type="email"
required
:tabindex="2"
autocomplete="email"
name="email"
placeholder="email@example.com"
/>
<InputError :message="errors.email" />
</div>
<div class="grid gap-2">
<Label for="password">Password</Label>
<Input
id="password"
type="password"
required
:tabindex="3"
autocomplete="new-password"
name="password"
placeholder="Password"
/>
<InputError :message="errors.password" />
</div>
<div class="grid gap-2">
<Label for="password_confirmation">Confirm password</Label>
<Input
id="password_confirmation"
type="password"
required
:tabindex="4"
autocomplete="new-password"
name="password_confirmation"
placeholder="Confirm password"
/>
<InputError :message="errors.password_confirmation" />
</div>
<Button
type="submit"
class="mt-2 w-full"
tabindex="5"
:disabled="processing"
data-test="register-user-button"
>
<Spinner v-if="processing" />
Create account
</Button>
</div>
<div class="text-center text-sm text-muted-foreground">
Already have an account?
<TextLink
:href="login()"
class="underline underline-offset-4"
:tabindex="6"
>Log in</TextLink
>
</div>
</Form>
</AuthBase>
</template>

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { ref } 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';
import AuthLayout from '@/layouts/AuthLayout.vue';
import { update } from '@/routes/password';
const props = defineProps<{
token: string;
email: string;
}>();
const inputEmail = ref(props.email);
</script>
<template>
<AuthLayout
title="Reset password"
description="Please enter your new password below"
>
<Head title="Reset password" />
<Form
v-bind="update.form()"
:transform="(data) => ({ ...data, token, email })"
:reset-on-success="['password', 'password_confirmation']"
v-slot="{ errors, processing }"
>
<div class="grid gap-6">
<div class="grid gap-2">
<Label for="email">Email</Label>
<Input
id="email"
type="email"
name="email"
autocomplete="email"
v-model="inputEmail"
class="mt-1 block w-full"
readonly
/>
<InputError :message="errors.email" class="mt-2" />
</div>
<div class="grid gap-2">
<Label for="password">Password</Label>
<Input
id="password"
type="password"
name="password"
autocomplete="new-password"
class="mt-1 block w-full"
autofocus
placeholder="Password"
/>
<InputError :message="errors.password" />
</div>
<div class="grid gap-2">
<Label for="password_confirmation">
Confirm Password
</Label>
<Input
id="password_confirmation"
type="password"
name="password_confirmation"
autocomplete="new-password"
class="mt-1 block w-full"
placeholder="Confirm password"
/>
<InputError :message="errors.password_confirmation" />
</div>
<Button
type="submit"
class="mt-4 w-full"
:disabled="processing"
data-test="reset-password-button"
>
<Spinner v-if="processing" />
Reset password
</Button>
</div>
</Form>
</AuthLayout>
</template>

View File

@@ -0,0 +1,133 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { computed, ref } from 'vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from '@/components/ui/input-otp';
import AuthLayout from '@/layouts/AuthLayout.vue';
import type { TwoFactorConfigContent } from '@/types';
import { store } from '@/routes/two-factor/login';
const authConfigContent = computed<TwoFactorConfigContent>(() => {
if (showRecoveryInput.value) {
return {
title: 'Recovery Code',
description:
'Please confirm access to your account by entering one of your emergency recovery codes.',
buttonText: 'login using an authentication code',
};
}
return {
title: 'Authentication Code',
description:
'Enter the authentication code provided by your authenticator application.',
buttonText: 'login using a recovery code',
};
});
const showRecoveryInput = ref<boolean>(false);
const toggleRecoveryMode = (clearErrors: () => void): void => {
showRecoveryInput.value = !showRecoveryInput.value;
clearErrors();
code.value = '';
};
const code = ref<string>('');
</script>
<template>
<AuthLayout
:title="authConfigContent.title"
:description="authConfigContent.description"
>
<Head title="Two-Factor Authentication" />
<div class="space-y-6">
<template v-if="!showRecoveryInput">
<Form
v-bind="store.form()"
class="space-y-4"
reset-on-error
@error="code = ''"
#default="{ errors, processing, clearErrors }"
>
<input type="hidden" name="code" :value="code" />
<div
class="flex flex-col items-center justify-center space-y-3 text-center"
>
<div class="flex w-full items-center justify-center">
<InputOTP
id="otp"
v-model="code"
:maxlength="6"
:disabled="processing"
autofocus
>
<InputOTPGroup>
<InputOTPSlot
v-for="index in 6"
:key="index"
:index="index - 1"
/>
</InputOTPGroup>
</InputOTP>
</div>
<InputError :message="errors.code" />
</div>
<Button type="submit" class="w-full" :disabled="processing"
>Continue</Button
>
<div class="text-center text-sm text-muted-foreground">
<span>or you can </span>
<button
type="button"
class="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
@click="() => toggleRecoveryMode(clearErrors)"
>
{{ authConfigContent.buttonText }}
</button>
</div>
</Form>
</template>
<template v-else>
<Form
v-bind="store.form()"
class="space-y-4"
reset-on-error
#default="{ errors, processing, clearErrors }"
>
<Input
name="recovery_code"
type="text"
placeholder="Enter recovery code"
:autofocus="showRecoveryInput"
required
/>
<InputError :message="errors.recovery_code" />
<Button type="submit" class="w-full" :disabled="processing"
>Continue</Button
>
<div class="text-center text-sm text-muted-foreground">
<span>or you can </span>
<button
type="button"
class="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
@click="() => toggleRecoveryMode(clearErrors)"
>
{{ authConfigContent.buttonText }}
</button>
</div>
</Form>
</template>
</div>
</AuthLayout>
</template>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import TextLink from '@/components/TextLink.vue';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import AuthLayout from '@/layouts/AuthLayout.vue';
import { logout } from '@/routes';
import { send } from '@/routes/verification';
defineProps<{
status?: string;
}>();
</script>
<template>
<AuthLayout
title="Verify email"
description="Please verify your email address by clicking on the link we just emailed to you."
>
<Head title="Email verification" />
<div
v-if="status === 'verification-link-sent'"
class="mb-4 text-center text-sm font-medium text-green-600"
>
A new verification link has been sent to the email address you
provided during registration.
</div>
<Form
v-bind="send.form()"
class="space-y-6 text-center"
v-slot="{ processing }"
>
<Button :disabled="processing" variant="secondary">
<Spinner v-if="processing" />
Resend verification email
</Button>
<TextLink
:href="logout()"
as="button"
class="mx-auto block text-sm"
>
Log out
</TextLink>
</Form>
</AuthLayout>
</template>

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import { Form, Head, usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
import AppLogoIcon from '@/components/AppLogoIcon.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';
type Folder = {
id: number;
title: string;
client_name: string;
};
type Props = {
folder: Folder;
token: string;
submitUrl: string;
};
defineProps<Props>();
const page = usePage<{ flash?: { type?: string; message?: string } }>();
const flash = computed(() => page.props.flash);
</script>
<template>
<div class="flex min-h-svh flex-col bg-background">
<Head :title="`Confirmation - ${folder.title}`" />
<header class="border-b border-sidebar-border/70 px-4 py-4">
<div class="mx-auto flex max-w-2xl items-center gap-3">
<AppLogoIcon class="size-8 fill-current text-[var(--foreground)]" />
<div>
<h1 class="font-medium">{{ folder.title }}</h1>
<p class="text-sm text-muted-foreground">{{ folder.client_name }}</p>
</div>
</div>
</header>
<main class="mx-auto w-full max-w-2xl flex-1 p-4">
<div v-if="flash?.message" class="mb-4 rounded-lg bg-green-100 p-3 text-sm text-green-800 dark:bg-green-900/30 dark:text-green-300">
{{ flash.message }}
</div>
<div class="space-y-6">
<div>
<h2 class="text-lg font-medium">Confirmer la situation</h2>
<p class="mt-1 text-sm text-muted-foreground">
Veuillez signer ci-dessous pour confirmer la situation présentée par votre cabinet.
</p>
</div>
<Form :action="submitUrl" method="post" class="space-y-4" v-slot="{ processing }">
<div class="space-y-2">
<Label for="signature">Signature (nom complet)</Label>
<Input
id="signature"
type="text"
name="signature"
placeholder="Votre nom"
required
:disabled="processing"
/>
</div>
<Button type="submit" :disabled="processing">
<Spinner v-if="processing" class="mr-2 size-4" />
Confirmer
</Button>
</Form>
</div>
</main>
</div>
</template>

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
import { Form, Head, usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
import AppLogoIcon from '@/components/AppLogoIcon.vue';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
type Folder = {
id: number;
title: string;
client_name: string;
};
type Props = {
folder: Folder;
token: string;
submitUrl: string;
};
defineProps<Props>();
const page = usePage<{ flash?: { type?: string; message?: string } }>();
const flash = computed(() => page.props.flash);
</script>
<template>
<div class="flex min-h-svh flex-col bg-background">
<Head :title="`Refus - ${folder.title}`" />
<header class="border-b border-sidebar-border/70 px-4 py-4">
<div class="mx-auto flex max-w-2xl items-center gap-3">
<AppLogoIcon class="size-8 fill-current text-[var(--foreground)]" />
<div>
<h1 class="font-medium">{{ folder.title }}</h1>
<p class="text-sm text-muted-foreground">{{ folder.client_name }}</p>
</div>
</div>
</header>
<main class="mx-auto w-full max-w-2xl flex-1 p-4">
<div v-if="flash?.message" class="mb-4 rounded-lg bg-green-100 p-3 text-sm text-green-800 dark:bg-green-900/30 dark:text-green-300">
{{ flash.message }}
</div>
<div class="space-y-6">
<div>
<h2 class="text-lg font-medium">Refuser la situation</h2>
<p class="mt-1 text-sm text-muted-foreground">
Vous pouvez indiquer la raison de votre refus (facultatif).
</p>
</div>
<Form :action="submitUrl" method="post" class="space-y-4" v-slot="{ processing }">
<div class="space-y-2">
<Label for="reason">Raison du refus (facultatif)</Label>
<textarea
id="reason"
name="reason"
rows="4"
placeholder="Précisez si besoin..."
:disabled="processing"
class="flex min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<Button type="submit" variant="destructive" :disabled="processing">
<Spinner v-if="processing" class="mr-2 size-4" />
Confirmer le refus
</Button>
</Form>
</div>
</main>
</div>
</template>

View File

@@ -0,0 +1,157 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { ref, computed } from 'vue';
import { usePage } from '@inertiajs/vue3';
import AppLogoIcon from '@/components/AppLogoIcon.vue';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { FileUp } from 'lucide-vue-next';
type Folder = {
id: number;
title: string;
client_name: string;
};
type Document = {
id: number;
name: string;
file_name: string;
size: string;
created_at: string;
};
type Props = {
folder: Folder;
token: string;
documents: Document[];
uploadUrl: string;
csrfToken: string;
};
const props = defineProps<Props>();
const page = usePage<{ flash?: { type?: string; message?: string } }>();
const flash = computed(() => page.props.flash);
const fileInput = ref<HTMLInputElement | null>(null);
const isDragging = ref(false);
const isSubmitting = ref(false);
function triggerFileSelect() {
fileInput.value?.click();
}
function onDragOver(e: DragEvent) {
e.preventDefault();
isDragging.value = true;
}
function onDragLeave() {
isDragging.value = false;
}
function onDrop(e: DragEvent) {
e.preventDefault();
isDragging.value = false;
const files = e.dataTransfer?.files;
if (files?.length && fileInput.value) {
const dt = new DataTransfer();
Array.from(files).forEach((f) => dt.items.add(f));
fileInput.value.files = dt.files;
}
}
function submit() {
if (!fileInput.value?.files?.length) return;
isSubmitting.value = true;
const form = document.getElementById('upload-form') as HTMLFormElement;
form.submit();
}
</script>
<template>
<div class="flex min-h-svh flex-col bg-background">
<Head :title="`Dépôt - ${folder.title}`" />
<header class="border-b border-sidebar-border/70 px-4 py-4">
<div class="mx-auto flex max-w-2xl items-center justify-between">
<div class="flex items-center gap-3">
<AppLogoIcon class="size-8 fill-current text-foreground" />
<div>
<h1 class="font-medium">{{ folder.title }}</h1>
<p class="text-sm text-muted-foreground">{{ folder.client_name }}</p>
</div>
</div>
</div>
</header>
<main class="mx-auto w-full max-w-2xl flex-1 p-4">
<div v-if="flash?.message" class="mb-4 rounded-lg bg-green-100 p-3 text-sm text-green-800 dark:bg-green-900/30 dark:text-green-300">
{{ flash.message }}
</div>
<div class="space-y-6">
<div>
<h2 class="text-lg font-medium">Déposer vos documents</h2>
<p class="mt-1 text-sm text-muted-foreground">
Glissez-déposez vos fichiers ou cliquez pour sélectionner.
</p>
</div>
<form
id="upload-form"
:action="uploadUrl"
method="post"
enctype="multipart/form-data"
class="space-y-4"
@submit.prevent="submit"
>
<input type="hidden" name="_token" :value="csrfToken" />
<input
ref="fileInput"
type="file"
name="files[]"
multiple
required
class="hidden"
accept="*/*"
/>
<div
class="rounded-xl border-2 border-dashed p-8 text-center transition-colors"
:class="[
isDragging
? 'border-primary bg-primary/5'
: 'border-sidebar-border/70 hover:border-primary/50',
]"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
>
<FileUp class="mx-auto size-12 text-muted-foreground" />
<p class="mt-2 text-sm font-medium">
{{ fileInput?.files?.length ? `${fileInput.files.length} fichier(s) sélectionné(s)` : 'Aucun fichier sélectionné' }}
</p>
<Button type="button" variant="outline" class="mt-2" @click="triggerFileSelect">
Choisir des fichiers
</Button>
</div>
<Button type="submit" :disabled="!fileInput?.files?.length || isSubmitting">
<Spinner v-if="isSubmitting" class="mr-2 size-4" />
Envoyer
</Button>
</form>
<div v-if="documents.length" class="rounded-xl border border-sidebar-border/70 p-4">
<h3 class="font-medium">Documents déjà déposés</h3>
<ul class="mt-2 space-y-1 text-sm text-muted-foreground">
<li v-for="doc in documents" :key="doc.id" class="flex justify-between">
<span>{{ doc.file_name }}</span>
<span>{{ doc.size }} {{ doc.created_at }}</span>
</li>
</ul>
</div>
</div>
</main>
</div>
</template>

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3';
import ClientForm from '@/components/ClientForm.vue';
import type { ClientFormData } from '@/components/ClientForm.vue';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import { Button } from '@/components/ui/button';
type WorkspaceUser = {
id: number;
name: string;
email: string;
};
type Props = {
indexUrl: string;
storeUrl: string;
legalForms: Record<string, string>;
clientStatusLabels: Record<string, string>;
workspaceUsers: WorkspaceUser[];
};
const props = defineProps<Props>();
const form = useForm<ClientFormData>({
company_name: '',
legal_form: '',
ice: '',
fiscal_id: '',
rc: '',
cnss: '',
patente: '',
contacts: [
{
full_name: '',
job_title: '',
email: '',
phone: '',
is_principal: true,
},
],
internal_responsible_id: '',
status: 'actif',
internal_notes: '',
});
function submit() {
form.post(props.storeUrl);
}
</script>
<template>
<AppLayout
:breadcrumbs="[
{ title: 'Clients', href: props.indexUrl },
{ title: 'Ajouter un client' },
]"
>
<Head title="Ajouter un client" />
<div class="flex flex-col space-y-6 p-4">
<div class="flex items-center justify-between">
<Heading
title="Ajouter un client"
description="Créer un nouveau client dans le workspace"
/>
<Button variant="outline" as-child>
<Link :href="indexUrl">Retour</Link>
</Button>
</div>
<ClientForm
:form="form"
:legal-forms="props.legalForms"
:client-status-labels="props.clientStatusLabels"
:workspace-users="props.workspaceUsers"
submit-label="Créer le client"
@submit="submit"
/>
</div>
</AppLayout>
</template>

View File

@@ -0,0 +1,110 @@
<script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3';
import ClientForm from '@/components/ClientForm.vue';
import type { ClientContactData, ClientFormData } from '@/components/ClientForm.vue';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import { Button } from '@/components/ui/button';
type WorkspaceUser = {
id: number;
name: string;
email: string;
};
type ClientContact = {
id: number;
full_name: string;
job_title: string | null;
email: string | null;
phone: string | null;
is_principal: boolean;
};
type Client = {
id: number;
company_name: string;
legal_form: string;
ice: string | null;
fiscal_id: string | null;
rc: string | null;
cnss: string | null;
patente: string | null;
contacts: ClientContact[];
internal_responsible_id: number | null;
status: string | null;
internal_notes: string | null;
};
type Props = {
client: Client;
indexUrl: string;
updateUrl: string;
legalForms: Record<string, string>;
clientStatusLabels: Record<string, string>;
workspaceUsers: WorkspaceUser[];
};
const props = defineProps<Props>();
const form = useForm<ClientFormData>({
company_name: props.client.company_name,
legal_form: props.client.legal_form,
ice: props.client.ice ?? '',
fiscal_id: props.client.fiscal_id ?? '',
rc: props.client.rc ?? '',
cnss: props.client.cnss ?? '',
patente: props.client.patente ?? '',
contacts: props.client.contacts.map(
(c): ClientContactData => ({
id: c.id,
full_name: c.full_name,
job_title: c.job_title ?? '',
email: c.email ?? '',
phone: c.phone ?? '',
is_principal: c.is_principal,
}),
),
internal_responsible_id:
props.client.internal_responsible_id != null
? String(props.client.internal_responsible_id)
: '',
status: props.client.status ?? 'actif',
internal_notes: props.client.internal_notes ?? '',
});
function submit() {
form.put(props.updateUrl);
}
</script>
<template>
<AppLayout
:breadcrumbs="[
{ title: 'Clients', href: props.indexUrl },
{ title: 'Modifier le client' },
]"
>
<Head :title="`Modifier ${props.client.company_name}`" />
<div class="flex flex-col space-y-6 p-4">
<div class="flex items-center justify-between">
<Heading
:title="`Modifier ${props.client.company_name}`"
description="Mettre à jour les informations du client"
/>
<Button variant="outline" as-child>
<Link :href="indexUrl">Retour</Link>
</Button>
</div>
<ClientForm
:form="form"
:legal-forms="props.legalForms"
:client-status-labels="props.clientStatusLabels"
:workspace-users="props.workspaceUsers"
submit-label="Enregistrer les modifications"
@submit="submit"
/>
</div>
</AppLayout>
</template>

View File

@@ -0,0 +1,205 @@
<script setup lang="ts">
import { Head, Link, router } from '@inertiajs/vue3';
import { Building2 } 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 Client = {
id: number;
company_name: string;
legal_form: string;
ice: string | null;
status: 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 = {
clients: PaginatedData<Client>;
createUrl: string;
workspaceName: string;
};
defineProps<Props>();
function destroy(client: Client) {
if (
window.confirm(
`Êtes-vous sûr de vouloir supprimer « ${client.company_name} » ?`,
)
) {
router.delete(client.destroyUrl);
}
}
const statusLabels: Record<string, string> = {
actif: 'Actif',
inactif: 'Inactif',
suspendu: 'Suspendu',
};
function getLegalFormLabel(legalForm: string): string {
const labels: Record<string, string> = {
sarl: 'SARL',
sa: 'SA',
snc: 'SNC',
scs: 'SCS',
eurl: 'EURL',
sel: 'SEL',
auto_entrepreneur: 'Auto-entrepreneur',
entreprise_individuelle: 'Entreprise individuelle',
other: 'Autre',
};
return labels[legalForm] ?? legalForm;
}
</script>
<template>
<AppLayout
:breadcrumbs="[
{ title: 'Clients' },
]"
>
<Head title="Clients" />
<div class="flex flex-col space-y-6 p-4">
<div class="flex items-center justify-between">
<Heading
variant="small"
title="Clients"
:description="`Gérer les clients du workspace « ${workspaceName} »`"
/>
<Button as-child>
<Link :href="createUrl">Ajouter un client</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"
>
Raison sociale
</th>
<th
class="h-10 px-4 text-left font-medium align-middle"
>
Forme juridique
</th>
<th
class="h-10 px-4 text-left font-medium align-middle"
>
ICE
</th>
<th
class="h-10 px-4 text-left font-medium align-middle"
>
Statut
</th>
<th
class="h-10 px-4 text-right font-medium align-middle"
>
Actions
</th>
</tr>
</thead>
<tbody>
<tr
v-for="client in clients.data"
:key="client.id"
class="border-b border-sidebar-border/50 last:border-0"
>
<td class="px-4 py-3 font-medium">
<Link
:href="client.showUrl"
class="hover:underline"
>
{{ client.company_name }}
</Link>
</td>
<td class="px-4 py-3 text-muted-foreground">
{{ getLegalFormLabel(client.legal_form) }}
</td>
<td class="px-4 py-3 text-muted-foreground">
{{ client.ice || '—' }}
</td>
<td class="px-4 py-3 text-muted-foreground">
{{ client.status ? statusLabels[client.status] ?? client.status : '—' }}
</td>
<td class="px-4 py-3 text-right space-x-2">
<Button variant="outline" size="sm" as-child>
<Link :href="client.showUrl"
>Voir</Link
>
</Button>
<Button variant="outline" size="sm" as-child>
<Link :href="client.editUrl"
>Modifier</Link
>
</Button>
<Button
variant="destructive"
size="sm"
@click="destroy(client)"
>
Supprimer
</Button>
</td>
</tr>
<tr v-if="!clients.data.length">
<td
colspan="5"
class="px-4 py-8 text-center text-muted-foreground"
>
<div class="flex flex-col items-center gap-2">
<Building2 class="h-10 w-10" />
<p>Aucun client pour le moment.</p>
<Button as-child>
<Link :href="createUrl"
>Ajouter votre premier
client</Link
>
</Button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<Pagination
:pagination="{
from: clients.from ?? 0,
to: clients.to ?? 0,
total: clients.total,
current_page: clients.current_page,
last_page: clients.last_page,
per_page: clients.per_page,
}"
/>
</div>
</AppLayout>
</template>

View File

@@ -0,0 +1,513 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { computed } from 'vue';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import FolderCalendar from '@/components/clients/FolderCalendar.vue';
import { Building2, FileText, FolderOpen } from 'lucide-vue-next';
type ClientContact = {
id: number;
full_name: string;
job_title: string | null;
email: string | null;
phone: string | null;
is_principal: boolean;
};
type Client = {
id: number;
company_name: string;
legal_form: string;
ice: string | null;
fiscal_id: string | null;
rc: string | null;
cnss: string | null;
patente: string | null;
contacts: ClientContact[];
internal_responsible_id: number | null;
internal_responsible_name: string | null;
status: string | null;
internal_notes: string | null;
};
type Folder = {
id: number;
title: string;
type: string;
status: string;
due_date: string | null;
created_at: string;
showUrl: string;
};
type Stats = {
total: number;
by_status: Record<string, number>;
by_type: Record<string, number>;
};
type Props = {
client: Client;
folders: Folder[];
stats: Stats;
indexUrl: string;
editUrl: string;
createFolderUrl: string;
};
const props = defineProps<Props>();
const statusLabels: Record<string, string> = {
actif: 'Actif',
inactif: 'Inactif',
suspendu: 'Suspendu',
};
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 folderStatusLabels: 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é',
};
function getLegalFormLabel(legalForm: string): string {
const labels: Record<string, string> = {
sarl: 'SARL',
sa: 'SA',
snc: 'SNC',
scs: 'SCS',
eurl: 'EURL',
sel: 'SEL',
auto_entrepreneur: 'Auto-entrepreneur',
entreprise_individuelle: 'Entreprise individuelle',
other: 'Autre',
};
return labels[legalForm] ?? legalForm;
}
function getFieldValue(fieldKey: string): string {
const client = props.client as Record<string, unknown>;
if (fieldKey === 'legal_form') {
return getLegalFormLabel((client.legal_form as string) ?? '');
}
if (fieldKey === 'status') {
const status = client.status as string | null;
return status ? (statusLabels[status] ?? status) : '—';
}
if (fieldKey === 'internal_responsible_name') {
return (client.internal_responsible_name as string) ?? '—';
}
const val = client[fieldKey];
return val != null && val !== '' ? String(val) : '—';
}
</script>
<template>
<AppLayout
:breadcrumbs="[
{ title: 'Clients', href: props.indexUrl },
{ title: props.client.company_name },
]"
>
<Head :title="props.client.company_name" />
<div class="flex flex-col space-y-6 p-4">
<div class="flex items-center justify-between">
<Heading
:title="props.client.company_name"
:description="getLegalFormLabel(props.client.legal_form)"
/>
<div class="flex gap-2">
<Button variant="outline" as-child>
<Link :href="createFolderUrl">Nouveau dossier</Link>
</Button>
<Button variant="outline" as-child>
<Link :href="editUrl">Modifier le client</Link>
</Button>
</div>
</div>
<!-- Stats -->
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader
class="flex flex-row items-center justify-between space-y-0 pb-2"
>
<CardTitle class="text-sm font-medium"
>Total dossiers</CardTitle
>
<FolderOpen class="size-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">
{{ stats.total }}
</div>
</CardContent>
</Card>
<Card>
<CardHeader
class="flex flex-row items-center justify-between space-y-0 pb-2"
>
<CardTitle class="text-sm font-medium"
>En cours</CardTitle
>
<FileText class="size-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">
{{
(stats.by_status?.processing ?? 0) +
(stats.by_status
?.additional_documents_requested ?? 0) +
(stats.by_status?.waiting_documents ?? 0) +
(stats.by_status?.documents_received ?? 0) +
(stats.by_status
?.waiting_client_validation ?? 0)
}}
</div>
</CardContent>
</Card>
<Card>
<CardHeader
class="flex flex-row items-center justify-between space-y-0 pb-2"
>
<CardTitle class="text-sm font-medium"
>Validés</CardTitle
>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">
{{ stats.by_status?.validated ?? 0 }}
</div>
</CardContent>
</Card>
<Card>
<CardHeader
class="flex flex-row items-center justify-between space-y-0 pb-2"
>
<CardTitle class="text-sm font-medium"
>Clôturés</CardTitle
>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">
{{ stats.by_status?.closed ?? 0 }}
</div>
</CardContent>
</Card>
</div>
<div class="grid gap-6 lg:grid-cols-3">
<!-- Client info -->
<div class="space-y-6 lg:col-span-2">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Building2 class="size-4" />
Informations société
</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="grid gap-4 sm:grid-cols-2">
<div>
<p
class="text-sm font-medium text-muted-foreground"
>
ICE
</p>
<p class="text-sm">
{{ getFieldValue('ice') }}
</p>
</div>
<div>
<p
class="text-sm font-medium text-muted-foreground"
>
IF
</p>
<p class="text-sm">
{{ getFieldValue('fiscal_id') }}
</p>
</div>
<div>
<p
class="text-sm font-medium text-muted-foreground"
>
RC
</p>
<p class="text-sm">
{{ getFieldValue('rc') }}
</p>
</div>
<div>
<p
class="text-sm font-medium text-muted-foreground"
>
CNSS
</p>
<p class="text-sm">
{{ getFieldValue('cnss') }}
</p>
</div>
<div>
<p
class="text-sm font-medium text-muted-foreground"
>
Patente
</p>
<p class="text-sm">
{{ getFieldValue('patente') }}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Responsables</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div
v-for="contact in client.contacts"
:key="contact.id"
class="rounded-lg border border-sidebar-border/70 p-4"
>
<div class="mb-2 flex items-center gap-2">
<p class="text-sm font-medium">
{{ contact.full_name }}
</p>
<Badge
v-if="
contact.is_principal &&
client.contacts.length > 1
"
variant="secondary"
>
Principal
</Badge>
</div>
<div class="grid gap-2 sm:grid-cols-2">
<div v-if="contact.job_title">
<p
class="text-sm text-muted-foreground"
>
Fonction
</p>
<p class="text-sm">
{{ contact.job_title }}
</p>
</div>
<div v-if="contact.email">
<p
class="text-sm text-muted-foreground"
>
Email
</p>
<p class="text-sm">
{{ contact.email }}
</p>
</div>
<div v-if="contact.phone">
<p
class="text-sm text-muted-foreground"
>
Téléphone
</p>
<p class="text-sm">
{{ contact.phone }}
</p>
</div>
</div>
</div>
<p
v-if="!client.contacts.length"
class="text-sm text-muted-foreground"
>
Aucun responsable enregistré.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Suivi interne</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div>
<p
class="text-sm font-medium text-muted-foreground"
>
Responsable
</p>
<p class="text-sm">
{{
getFieldValue(
'internal_responsible_name',
)
}}
</p>
</div>
<div>
<p
class="text-sm font-medium text-muted-foreground"
>
Statut
</p>
<p class="text-sm">
{{ getFieldValue('status') }}
</p>
</div>
<div v-if="client.internal_notes">
<p
class="text-sm font-medium text-muted-foreground"
>
Notes
</p>
<p class="whitespace-pre-wrap text-sm">
{{ client.internal_notes }}
</p>
</div>
</CardContent>
</Card>
</div>
<!-- Calendar -->
<div>
<Card>
<CardHeader>
<CardTitle>Calendrier des échéances</CardTitle>
<p class="text-sm text-muted-foreground">
Dossiers par date limite
</p>
</CardHeader>
<CardContent>
<FolderCalendar :folders="folders" />
</CardContent>
</Card>
</div>
</div>
<!-- Folders history -->
<Card>
<CardHeader>
<CardTitle>Historique des dossiers</CardTitle>
<p class="text-sm text-muted-foreground">
Derniers dossiers du client
</p>
</CardHeader>
<CardContent>
<div
v-if="folders.length"
class="overflow-x-auto rounded-xl border border-sidebar-border/70"
>
<table class="w-full text-sm">
<thead class="bg-muted/50">
<tr>
<th
class="px-4 py-3 text-left font-medium"
>
Dossier
</th>
<th
class="px-4 py-3 text-left font-medium"
>
Type
</th>
<th
class="px-4 py-3 text-left font-medium"
>
Statut
</th>
<th
class="px-4 py-3 text-left font-medium"
>
Échéance
</th>
<th
class="px-4 py-3 text-left font-medium"
>
Créé le
</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-sidebar-border/70">
<tr
v-for="folder in folders"
:key="folder.id"
class="hover:bg-muted/30"
>
<td class="px-4 py-3 font-medium">
{{ folder.title }}
</td>
<td class="px-4 py-3">
{{
typeLabels[folder.type] ??
folder.type
}}
</td>
<td class="px-4 py-3">
{{
folderStatusLabels[
folder.status
] ?? folder.status
}}
</td>
<td class="px-4 py-3">
{{ folder.due_date ?? '—' }}
</td>
<td class="px-4 py-3">
{{ folder.created_at }}
</td>
<td class="px-4 py-3">
<Button
variant="ghost"
size="sm"
as-child
>
<Link :href="folder.showUrl"
>Voir</Link
>
</Button>
</td>
</tr>
</tbody>
</table>
</div>
<div
v-else
class="rounded-xl border border-sidebar-border/70 p-8 text-center text-muted-foreground"
>
Aucun dossier.
<Link
:href="createFolderUrl"
class="text-primary underline"
>Créer un dossier</Link
>
</div>
</CardContent>
</Card>
</div>
</AppLayout>
</template>

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

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

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

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

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import AppearanceTabs from '@/components/AppearanceTabs.vue';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import type { BreadcrumbItem } from '@/types';
import { edit } from '@/routes/appearance';
const breadcrumbItems: BreadcrumbItem[] = [
{
title: 'Appearance settings',
href: edit().url,
},
];
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbItems">
<Head title="Appearance settings" />
<h1 class="sr-only">Appearance Settings</h1>
<SettingsLayout>
<div class="space-y-6">
<Heading
variant="small"
title="Appearance settings"
description="Update your account's appearance settings"
/>
<AppearanceTabs />
</div>
</SettingsLayout>
</AppLayout>
</template>

View File

@@ -0,0 +1,116 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import Heading from '@/components/Heading.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 AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import type { BreadcrumbItem } from '@/types';
import PasswordController from '@/actions/App/Http/Controllers/Settings/PasswordController';
import { edit } from '@/routes/user-password';
const breadcrumbItems: BreadcrumbItem[] = [
{
title: 'Password settings',
href: edit().url,
},
];
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbItems">
<Head title="Password settings" />
<h1 class="sr-only">Password Settings</h1>
<SettingsLayout>
<div class="space-y-6">
<Heading
variant="small"
title="Update password"
description="Ensure your account is using a long, random password to stay secure"
/>
<Form
v-bind="PasswordController.update.form()"
:options="{
preserveScroll: true,
}"
reset-on-success
:reset-on-error="[
'password',
'password_confirmation',
'current_password',
]"
class="space-y-6"
v-slot="{ errors, processing, recentlySuccessful }"
>
<div class="grid gap-2">
<Label for="current_password">Current password</Label>
<Input
id="current_password"
name="current_password"
type="password"
class="mt-1 block w-full"
autocomplete="current-password"
placeholder="Current password"
/>
<InputError :message="errors.current_password" />
</div>
<div class="grid gap-2">
<Label for="password">New password</Label>
<Input
id="password"
name="password"
type="password"
class="mt-1 block w-full"
autocomplete="new-password"
placeholder="New password"
/>
<InputError :message="errors.password" />
</div>
<div class="grid gap-2">
<Label for="password_confirmation"
>Confirm password</Label
>
<Input
id="password_confirmation"
name="password_confirmation"
type="password"
class="mt-1 block w-full"
autocomplete="new-password"
placeholder="Confirm password"
/>
<InputError :message="errors.password_confirmation" />
</div>
<div class="flex items-center gap-4">
<Button
:disabled="processing"
data-test="update-password-button"
>Save password</Button
>
<Transition
enter-active-class="transition ease-in-out"
enter-from-class="opacity-0"
leave-active-class="transition ease-in-out"
leave-to-class="opacity-0"
>
<p
v-show="recentlySuccessful"
class="text-sm text-neutral-600"
>
Saved.
</p>
</Transition>
</div>
</Form>
</div>
</SettingsLayout>
</AppLayout>
</template>

View File

@@ -0,0 +1,131 @@
<script setup lang="ts">
import { Form, Head, Link, usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
import DeleteUser from '@/components/DeleteUser.vue';
import Heading from '@/components/Heading.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 AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import type { BreadcrumbItem } from '@/types';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import { edit } from '@/routes/profile';
import { send } from '@/routes/verification';
type Props = {
mustVerifyEmail: boolean;
status?: string;
};
defineProps<Props>();
const breadcrumbItems: BreadcrumbItem[] = [
{
title: 'Profile settings',
href: edit().url,
},
];
const page = usePage();
const user = computed(() => page.props.auth.user);
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbItems">
<Head title="Profile settings" />
<h1 class="sr-only">Profile Settings</h1>
<SettingsLayout>
<div class="flex flex-col space-y-6">
<Heading
variant="small"
title="Profile information"
description="Update your name and email address"
/>
<Form
v-bind="ProfileController.update.form()"
class="space-y-6"
v-slot="{ errors, processing, recentlySuccessful }"
>
<div class="grid gap-2">
<Label for="name">Name</Label>
<Input
id="name"
class="mt-1 block w-full"
name="name"
:default-value="user.name"
required
autocomplete="name"
placeholder="Full name"
/>
<InputError class="mt-2" :message="errors.name" />
</div>
<div class="grid gap-2">
<Label for="email">Email address</Label>
<Input
id="email"
type="email"
class="mt-1 block w-full"
name="email"
:default-value="user.email"
required
autocomplete="username"
placeholder="Email address"
/>
<InputError class="mt-2" :message="errors.email" />
</div>
<div v-if="mustVerifyEmail && !user.email_verified_at">
<p class="-mt-4 text-sm text-muted-foreground">
Your email address is unverified.
<Link
:href="send()"
as="button"
class="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
>
Click here to resend the verification email.
</Link>
</p>
<div
v-if="status === 'verification-link-sent'"
class="mt-2 text-sm font-medium text-green-600"
>
A new verification link has been sent to your email
address.
</div>
</div>
<div class="flex items-center gap-4">
<Button
:disabled="processing"
data-test="update-profile-button"
>Save</Button
>
<Transition
enter-active-class="transition ease-in-out"
enter-from-class="opacity-0"
leave-active-class="transition ease-in-out"
leave-to-class="opacity-0"
>
<p
v-show="recentlySuccessful"
class="text-sm text-neutral-600"
>
Saved.
</p>
</Transition>
</div>
</Form>
</div>
<DeleteUser />
</SettingsLayout>
</AppLayout>
</template>

View File

@@ -0,0 +1,125 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { ShieldBan, ShieldCheck } from 'lucide-vue-next';
import { onUnmounted, ref } from 'vue';
import Heading from '@/components/Heading.vue';
import TwoFactorRecoveryCodes from '@/components/TwoFactorRecoveryCodes.vue';
import TwoFactorSetupModal from '@/components/TwoFactorSetupModal.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import type { BreadcrumbItem } from '@/types';
import { disable, enable, show } from '@/routes/two-factor';
type Props = {
requiresConfirmation?: boolean;
twoFactorEnabled?: boolean;
};
withDefaults(defineProps<Props>(), {
requiresConfirmation: false,
twoFactorEnabled: false,
});
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Two-Factor Authentication',
href: show.url(),
},
];
const { hasSetupData, clearTwoFactorAuthData } = useTwoFactorAuth();
const showSetupModal = ref<boolean>(false);
onUnmounted(() => {
clearTwoFactorAuthData();
});
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head title="Two-Factor Authentication" />
<h1 class="sr-only">Two-Factor Authentication Settings</h1>
<SettingsLayout>
<div class="space-y-6">
<Heading
variant="small"
title="Two-Factor Authentication"
description="Manage your two-factor authentication settings"
/>
<div
v-if="!twoFactorEnabled"
class="flex flex-col items-start justify-start space-y-4"
>
<Badge variant="destructive">Disabled</Badge>
<p class="text-muted-foreground">
When you enable two-factor authentication, you will be
prompted for a secure pin during login. This pin can be
retrieved from a TOTP-supported application on your
phone.
</p>
<div>
<Button
v-if="hasSetupData"
@click="showSetupModal = true"
>
<ShieldCheck />Continue Setup
</Button>
<Form
v-else
v-bind="enable.form()"
@success="showSetupModal = true"
#default="{ processing }"
>
<Button type="submit" :disabled="processing">
<ShieldCheck />Enable 2FA</Button
></Form
>
</div>
</div>
<div
v-else
class="flex flex-col items-start justify-start space-y-4"
>
<Badge variant="default">Enabled</Badge>
<p class="text-muted-foreground">
With two-factor authentication enabled, you will be
prompted for a secure, random pin during login, which
you can retrieve from the TOTP-supported application on
your phone.
</p>
<TwoFactorRecoveryCodes />
<div class="relative inline">
<Form v-bind="disable.form()" #default="{ processing }">
<Button
variant="destructive"
type="submit"
:disabled="processing"
>
<ShieldBan />
Disable 2FA
</Button>
</Form>
</div>
</div>
<TwoFactorSetupModal
v-model:isOpen="showSetupModal"
:requiresConfirmation="requiresConfirmation"
:twoFactorEnabled="twoFactorEnabled"
/>
</div>
</SettingsLayout>
</AppLayout>
</template>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import UserForm from '@/components/UserForm.vue';
import type { UserFormData } from '@/components/UserForm.vue';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import type { BreadcrumbItem } from '@/types';
type Props = {
indexUrl: string;
storeUrl: string;
userGroups: Record<string, string>;
};
const props = defineProps<Props>();
const form = useForm<UserFormData>({
name: '',
email: '',
password: '',
password_confirmation: '',
group: 'user',
});
function submit() {
form.post(props.storeUrl);
}
</script>
<template>
<AppLayout
:breadcrumbs="[
{ title: 'Users', href: props.indexUrl },
{ title: 'Create user' },
]"
>
<Head title="Create user" />
<div class="flex flex-col space-y-6 p-4">
<Heading
title="Create user"
description="Add a new user to the system"
/>
<UserForm
:form="form"
:user-groups="userGroups"
:show-password-fields="true"
submit-label="Create user"
@submit="submit"
/>
</div>
</AppLayout>
</template>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import UserForm from '@/components/UserForm.vue';
import type { UserFormData } from '@/components/UserForm.vue';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
type User = {
id: number;
name: string;
email: string;
group: string;
};
type Props = {
user: User;
indexUrl: string;
updateUrl: string;
userGroups: Record<string, string>;
};
const props = defineProps<Props>();
const form = useForm<UserFormData>({
name: props.user.name,
email: props.user.email,
password: '',
password_confirmation: '',
group: props.user.group,
});
function submit() {
form.put(props.updateUrl);
}
</script>
<template>
<AppLayout
:breadcrumbs="[
{ title: 'Users', href: props.indexUrl },
{ title: 'Edit user' },
]"
>
<Head :title="`Edit ${props.user.name}`" />
<div class="flex flex-col space-y-6 p-4">
<Heading
:title="`Edit ${props.user.name}`"
description="Update the user's information"
/>
<UserForm
:form="form"
:user-groups="userGroups"
:show-password-fields="true"
:password-required="false"
submit-label="Update user"
@submit="submit"
/>
</div>
</AppLayout>
</template>

View File

@@ -0,0 +1,162 @@
<script setup lang="ts">
import { Head, Link, router } from '@inertiajs/vue3';
import { Users } 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';
import { Badge } from '@/components/ui/badge';
type User = {
id: number;
name: string;
email: string;
group: string;
created_at?: 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 = {
users: PaginatedData<User>;
createUrl: string;
};
defineProps<Props>();
function destroy(user: User) {
if (window.confirm(`Are you sure you want to delete ${user.name}?`)) {
router.delete(user.destroyUrl);
}
}
function formatGroup(group: string): string {
return group.charAt(0).toUpperCase() + group.slice(1);
}
</script>
<template>
<AppLayout
:breadcrumbs="[
{ title: 'Users' },
]"
>
<Head title="Users" />
<div class="flex flex-col space-y-6 p-4">
<div class="flex items-center justify-between">
<Heading
variant="small"
title="Users"
description="Manage application users"
/>
<Button as-child>
<Link :href="createUrl">Create user</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"
>
Name
</th>
<th
class="h-10 px-4 text-left font-medium align-middle"
>
Email
</th>
<th
class="h-10 px-4 text-left font-medium align-middle"
>
Group
</th>
<th
class="h-10 px-4 text-right font-medium align-middle"
>
Actions
</th>
</tr>
</thead>
<tbody>
<tr
v-for="user in users.data"
:key="user.id"
class="border-b border-sidebar-border/50 last:border-0"
>
<td class="px-4 py-3 font-medium">
{{ user.name }}
</td>
<td class="px-4 py-3">{{ user.email }}</td>
<td class="px-4 py-3">
<Badge variant="secondary">
{{ formatGroup(user.group) }}
</Badge>
</td>
<td class="px-4 py-3 text-right space-x-2">
<Button variant="outline" size="sm" as-child>
<Link :href="user.editUrl">Edit</Link>
</Button>
<Button
variant="destructive"
size="sm"
@click="destroy(user)"
>
Delete
</Button>
</td>
</tr>
<tr v-if="!users.data.length">
<td
colspan="4"
class="px-4 py-8 text-center text-muted-foreground"
>
<div class="flex flex-col items-center gap-2">
<Users class="h-10 w-10" />
<p>No users yet.</p>
<Button as-child>
<Link :href="createUrl"
>Create your first user</Link
>
</Button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<Pagination
:pagination="{
from: users.from ?? 0,
to: users.to ?? 0,
total: users.total,
current_page: users.current_page,
last_page: users.last_page,
per_page: users.per_page,
}"
/>
</div>
</AppLayout>
</template>

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import WorkspaceForm from '@/components/WorkspaceForm.vue';
import type { WorkspaceFormData } from '@/components/WorkspaceForm.vue';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
type Props = {
indexUrl: string;
storeUrl: string;
users: Array<{ id: number; name: string; email: string }>;
workspaceUserRoles: Record<string, string>;
};
const props = defineProps<Props>();
const form = useForm<WorkspaceFormData>({
name: '',
slug: '',
user_ids: [],
user_roles: {},
});
function submit() {
form.post(props.storeUrl);
}
</script>
<template>
<AppLayout
:breadcrumbs="[
{ title: 'Workspaces', href: props.indexUrl },
{ title: 'Create workspace' },
]"
>
<Head title="Create workspace" />
<div class="flex flex-col space-y-6 p-4">
<Heading
title="Create workspace"
description="Add a new accounting firm workspace (cabinet comptable)"
/>
<WorkspaceForm
:form="form"
:users="props.users ?? []"
:workspace-user-roles="props.workspaceUserRoles ?? {}"
submit-label="Create workspace"
@submit="submit"
/>
</div>
</AppLayout>
</template>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import WorkspaceForm from '@/components/WorkspaceForm.vue';
import type { WorkspaceFormData } from '@/components/WorkspaceForm.vue';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
type Workspace = {
id: number;
name: string;
slug: string;
user_ids: number[];
user_roles: Record<number, string>;
};
type Props = {
workspace: Workspace;
indexUrl: string;
updateUrl: string;
users: Array<{ id: number; name: string; email: string }>;
workspaceUserRoles: Record<string, string>;
};
const props = defineProps<Props>();
const form = useForm<WorkspaceFormData>({
name: props.workspace.name,
slug: props.workspace.slug,
user_ids: props.workspace.user_ids ?? [],
user_roles: props.workspace.user_roles ?? {},
});
function submit() {
form.put(props.updateUrl);
}
</script>
<template>
<AppLayout
:breadcrumbs="[
{ title: 'Workspaces', href: props.indexUrl },
{ title: 'Edit workspace' },
]"
>
<Head :title="`Edit ${props.workspace.name}`" />
<div class="flex flex-col space-y-6 p-4">
<Heading
:title="`Edit ${props.workspace.name}`"
description="Update the workspace information"
/>
<WorkspaceForm
:form="form"
:users="props.users ?? []"
:workspace-user-roles="props.workspaceUserRoles ?? {}"
submit-label="Update workspace"
@submit="submit"
/>
</div>
</AppLayout>
</template>

View File

@@ -0,0 +1,181 @@
<script setup lang="ts">
import { Head, Link, router } from '@inertiajs/vue3';
import { Building2 } 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';
import { Badge } from '@/components/ui/badge';
type Workspace = {
id: number;
name: string;
slug: string;
users_count: number;
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 = {
workspaces: PaginatedData<Workspace>;
createUrl: string;
};
defineProps<Props>();
function destroy(workspace: Workspace) {
if (
window.confirm(
`Are you sure you want to delete "${workspace.name}"? All users will be unassigned.`,
)
) {
router.delete(workspace.destroyUrl);
}
}
</script>
<template>
<AppLayout
:breadcrumbs="[
{ title: 'Workspaces' },
]"
>
<Head title="Workspaces" />
<div class="flex flex-col space-y-6 p-4">
<div class="flex items-center justify-between">
<Heading
variant="small"
title="Workspaces"
description="Manage accounting firm workspaces (cabinets comptables)"
/>
<Button as-child>
<Link :href="createUrl">Create workspace</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"
>
Name
</th>
<th
class="h-10 px-4 text-left font-medium align-middle"
>
Slug
</th>
<th
class="h-10 px-4 text-left font-medium align-middle"
>
Users
</th>
<th
class="h-10 px-4 text-right font-medium align-middle"
>
Actions
</th>
</tr>
</thead>
<tbody>
<tr
v-for="workspace in workspaces.data"
:key="workspace.id"
class="border-b border-sidebar-border/50 last:border-0"
>
<td class="px-4 py-3 font-medium">
<Link
:href="workspace.showUrl"
class="hover:underline"
>
{{ workspace.name }}
</Link>
</td>
<td class="px-4 py-3 text-muted-foreground">
{{ workspace.slug }}
</td>
<td class="px-4 py-3">
<Badge variant="secondary">
{{ workspace.users_count }} user{{
workspace.users_count !== 1
? 's'
: ''
}}
</Badge>
</td>
<td class="px-4 py-3 text-right space-x-2">
<Button variant="outline" size="sm" as-child>
<Link :href="workspace.showUrl"
>View</Link
>
</Button>
<Button variant="outline" size="sm" as-child>
<Link :href="workspace.editUrl"
>Edit</Link
>
</Button>
<Button
variant="destructive"
size="sm"
@click="destroy(workspace)"
>
Delete
</Button>
</td>
</tr>
<tr v-if="!workspaces.data.length">
<td
colspan="4"
class="px-4 py-8 text-center text-muted-foreground"
>
<div class="flex flex-col items-center gap-2">
<Building2 class="h-10 w-10" />
<p>No workspaces yet.</p>
<Button as-child>
<Link :href="createUrl"
>Create your first
workspace</Link
>
</Button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<Pagination
:pagination="{
from: workspaces.from ?? 0,
to: workspaces.to ?? 0,
total: workspaces.total,
current_page: workspaces.current_page,
last_page: workspaces.last_page,
per_page: workspaces.per_page,
}"
/>
</div>
</AppLayout>
</template>

View File

@@ -0,0 +1,204 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Head, Link } from '@inertiajs/vue3';
import { User, FolderOpen, Building2, Calendar, AlertCircle } from 'lucide-vue-next';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
type WorkspaceUser = {
id: number;
name: string;
email: string;
role: string;
};
type Workspace = {
id: number;
name: string;
slug: string;
users: WorkspaceUser[];
};
type Stats = {
clients: number;
folders: number;
folders_by_status: Record<string, number>;
folders_this_month: number;
folders_needing_attention: number;
};
type Props = {
workspace: Workspace;
stats: Stats;
indexUrl: string;
editUrl: string;
};
const props = defineProps<Props>();
function roleLabel(role: string): string {
return role.charAt(0).toUpperCase() + role.slice(1);
}
const inProgressCount = computed(
() =>
(props.stats.folders_by_status?.processing ?? 0) +
(props.stats.folders_by_status?.additional_documents_requested ?? 0) +
(props.stats.folders_by_status?.documents_received ?? 0),
);
const validatedCount = computed(
() => props.stats.folders_by_status?.validated ?? 0,
);
</script>
<template>
<AppLayout
:breadcrumbs="[
{ title: 'Workspaces', href: props.indexUrl },
{ title: props.workspace.name },
]"
>
<Head :title="props.workspace.name" />
<div class="flex flex-col space-y-6 p-4">
<div class="flex items-center justify-between">
<Heading
:title="props.workspace.name"
:description="`Workspace ${props.workspace.slug}`"
/>
<Button variant="outline" as-child>
<Link :href="props.editUrl">Edit workspace</Link>
</Button>
</div>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-6">
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">
Clients
</CardTitle>
<Building2 class="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ props.stats.clients }}</div>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">
Dossiers
</CardTitle>
<FolderOpen class="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ props.stats.folders }}</div>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">
En cours
</CardTitle>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ inProgressCount }}</div>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">
Validés
</CardTitle>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ validatedCount }}</div>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">
Ce mois
</CardTitle>
<Calendar class="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ props.stats.folders_this_month }}</div>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">
À traiter
</CardTitle>
<AlertCircle class="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ props.stats.folders_needing_attention }}</div>
</CardContent>
</Card>
</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"
>
User
</th>
<th
class="h-10 px-4 text-left font-medium align-middle"
>
Role
</th>
</tr>
</thead>
<tbody>
<tr
v-for="user in props.workspace.users"
:key="user.id"
class="border-b border-sidebar-border/50 last:border-0"
>
<td class="px-4 py-3">
<div class="flex flex-col">
<span class="font-medium">{{
user.name
}}</span>
<span
class="text-xs text-muted-foreground"
>{{ user.email }}</span
>
</div>
</td>
<td class="px-4 py-3">
<Badge variant="secondary">
{{ roleLabel(user.role) }}
</Badge>
</td>
</tr>
<tr v-if="!props.workspace.users.length">
<td
colspan="2"
class="px-4 py-8 text-center text-muted-foreground"
>
<div class="flex flex-col items-center gap-2">
<User class="h-10 w-10" />
<p>No users in this workspace.</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</AppLayout>
</template>