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:
393
resources/js/pages/Dashboard.vue
Normal file
393
resources/js/pages/Dashboard.vue
Normal 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>
|
||||
212
resources/js/pages/Welcome.vue
Normal file
212
resources/js/pages/Welcome.vue
Normal 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>
|
||||
53
resources/js/pages/auth/ConfirmPassword.vue
Normal file
53
resources/js/pages/auth/ConfirmPassword.vue
Normal 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>
|
||||
65
resources/js/pages/auth/ForgotPassword.vue
Normal file
65
resources/js/pages/auth/ForgotPassword.vue
Normal 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>
|
||||
110
resources/js/pages/auth/Login.vue
Normal file
110
resources/js/pages/auth/Login.vue
Normal 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>
|
||||
108
resources/js/pages/auth/Register.vue
Normal file
108
resources/js/pages/auth/Register.vue
Normal 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>
|
||||
89
resources/js/pages/auth/ResetPassword.vue
Normal file
89
resources/js/pages/auth/ResetPassword.vue
Normal 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>
|
||||
133
resources/js/pages/auth/TwoFactorChallenge.vue
Normal file
133
resources/js/pages/auth/TwoFactorChallenge.vue
Normal 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>
|
||||
49
resources/js/pages/auth/VerifyEmail.vue
Normal file
49
resources/js/pages/auth/VerifyEmail.vue
Normal 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>
|
||||
75
resources/js/pages/client/Confirm.vue
Normal file
75
resources/js/pages/client/Confirm.vue
Normal 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>
|
||||
74
resources/js/pages/client/Refuse.vue
Normal file
74
resources/js/pages/client/Refuse.vue
Normal 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>
|
||||
157
resources/js/pages/client/Upload.vue
Normal file
157
resources/js/pages/client/Upload.vue
Normal 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>
|
||||
81
resources/js/pages/clients/Create.vue
Normal file
81
resources/js/pages/clients/Create.vue
Normal 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>
|
||||
110
resources/js/pages/clients/Edit.vue
Normal file
110
resources/js/pages/clients/Edit.vue
Normal 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>
|
||||
205
resources/js/pages/clients/Index.vue
Normal file
205
resources/js/pages/clients/Index.vue
Normal 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>
|
||||
513
resources/js/pages/clients/Show.vue
Normal file
513
resources/js/pages/clients/Show.vue
Normal 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>
|
||||
91
resources/js/pages/folders/Create.vue
Normal file
91
resources/js/pages/folders/Create.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||
import FolderForm from '@/components/FolderForm.vue';
|
||||
import type { FolderFormData } from '@/components/FolderForm.vue';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
|
||||
type Client = {
|
||||
id: number;
|
||||
company_name: string;
|
||||
};
|
||||
|
||||
type WorkspaceUser = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
indexUrl: string;
|
||||
storeUrl: string;
|
||||
initialClientId?: number | null;
|
||||
folderTypeLabels: Record<string, string>;
|
||||
folderStatusLabels: Record<string, string>;
|
||||
folderPriorityLabels: Record<string, string>;
|
||||
clients: Client[];
|
||||
workspaceUsers: WorkspaceUser[];
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const form = useForm<FolderFormData>({
|
||||
client_id: props.initialClientId ? String(props.initialClientId) : '',
|
||||
title: '',
|
||||
type: 'vat_monthly',
|
||||
period_year: currentYear,
|
||||
period_month: '',
|
||||
period_quarter: '',
|
||||
due_date: '',
|
||||
status: 'draft',
|
||||
priority: 'medium',
|
||||
assigned_to: '',
|
||||
notes_internal: '',
|
||||
notes_client: '',
|
||||
});
|
||||
|
||||
function submit() {
|
||||
form.post(props.storeUrl);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout
|
||||
:breadcrumbs="[
|
||||
{ title: 'Dossiers', href: props.indexUrl },
|
||||
{ title: 'Créer un dossier' },
|
||||
]"
|
||||
>
|
||||
<Head title="Créer un dossier" />
|
||||
|
||||
<div class="flex flex-col space-y-6 p-4">
|
||||
<Heading
|
||||
title="Créer un dossier"
|
||||
description="Créer un nouveau dossier fiscal"
|
||||
/>
|
||||
<div
|
||||
v-if="!props.clients.length"
|
||||
class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-200"
|
||||
>
|
||||
Aucun client dans ce workspace. Créez d'abord un
|
||||
<Link href="/clients" class="font-medium underline">
|
||||
client
|
||||
</Link>
|
||||
pour pouvoir créer un dossier.
|
||||
</div>
|
||||
<FolderForm
|
||||
v-else
|
||||
:form="form"
|
||||
:folder-type-labels="props.folderTypeLabels"
|
||||
:folder-status-labels="props.folderStatusLabels"
|
||||
:folder-priority-labels="props.folderPriorityLabels"
|
||||
:clients="props.clients"
|
||||
:workspace-users="props.workspaceUsers"
|
||||
submit-label="Créer le dossier"
|
||||
@submit="submit"
|
||||
/>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
94
resources/js/pages/folders/Edit.vue
Normal file
94
resources/js/pages/folders/Edit.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, useForm } from '@inertiajs/vue3';
|
||||
import FolderForm from '@/components/FolderForm.vue';
|
||||
import type { FolderFormData } from '@/components/FolderForm.vue';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
|
||||
type Client = {
|
||||
id: number;
|
||||
company_name: string;
|
||||
};
|
||||
|
||||
type WorkspaceUser = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
type Folder = {
|
||||
id: number;
|
||||
title: string;
|
||||
type: string;
|
||||
client_id: number;
|
||||
period_year: number;
|
||||
period_month: number | null;
|
||||
period_quarter: number | null;
|
||||
due_date: string | null;
|
||||
status: string;
|
||||
priority: string | null;
|
||||
assigned_to: number | null;
|
||||
notes_internal: string | null;
|
||||
notes_client: string | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
folder: Folder;
|
||||
indexUrl: string;
|
||||
updateUrl: string;
|
||||
folderTypeLabels: Record<string, string>;
|
||||
folderStatusLabels: Record<string, string>;
|
||||
folderPriorityLabels: Record<string, string>;
|
||||
clients: Client[];
|
||||
workspaceUsers: WorkspaceUser[];
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const form = useForm<FolderFormData>({
|
||||
client_id: props.folder.client_id,
|
||||
title: props.folder.title,
|
||||
type: props.folder.type,
|
||||
period_year: props.folder.period_year,
|
||||
period_month: props.folder.period_month ?? '',
|
||||
period_quarter: props.folder.period_quarter ?? '',
|
||||
due_date: props.folder.due_date ?? '',
|
||||
status: props.folder.status ?? 'draft',
|
||||
priority: props.folder.priority ?? 'medium',
|
||||
assigned_to: props.folder.assigned_to ?? '',
|
||||
notes_internal: props.folder.notes_internal ?? '',
|
||||
notes_client: props.folder.notes_client ?? '',
|
||||
});
|
||||
|
||||
function submit() {
|
||||
form.put(props.updateUrl);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout
|
||||
:breadcrumbs="[
|
||||
{ title: 'Dossiers', href: props.indexUrl },
|
||||
{ title: 'Modifier le dossier' },
|
||||
]"
|
||||
>
|
||||
<Head :title="`Modifier ${props.folder.title}`" />
|
||||
|
||||
<div class="flex flex-col space-y-6 p-4">
|
||||
<Heading
|
||||
:title="`Modifier ${props.folder.title}`"
|
||||
description="Mettre à jour les informations du dossier"
|
||||
/>
|
||||
<FolderForm
|
||||
:form="form"
|
||||
:folder-type-labels="props.folderTypeLabels"
|
||||
:folder-status-labels="props.folderStatusLabels"
|
||||
:folder-priority-labels="props.folderPriorityLabels"
|
||||
:clients="props.clients"
|
||||
:workspace-users="props.workspaceUsers"
|
||||
submit-label="Enregistrer les modifications"
|
||||
@submit="submit"
|
||||
/>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
216
resources/js/pages/folders/Index.vue
Normal file
216
resources/js/pages/folders/Index.vue
Normal file
@@ -0,0 +1,216 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link, router } from '@inertiajs/vue3';
|
||||
import { FolderOpen } from 'lucide-vue-next';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import Pagination from '@/components/Pagination.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
type Folder = {
|
||||
id: number;
|
||||
title: string;
|
||||
type: string;
|
||||
client_name: string;
|
||||
status: string;
|
||||
due_date: string | null;
|
||||
showUrl: string;
|
||||
editUrl: string;
|
||||
destroyUrl: string;
|
||||
};
|
||||
|
||||
type PaginatedData<T> = {
|
||||
data: T[];
|
||||
from: number | null;
|
||||
to: number | null;
|
||||
total: number;
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
path: string;
|
||||
first_page_url: string;
|
||||
prev_page_url: string | null;
|
||||
next_page_url: string | null;
|
||||
last_page_url: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
folders: PaginatedData<Folder>;
|
||||
createUrl: string;
|
||||
workspaceName: string;
|
||||
};
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
function destroy(folder: Folder) {
|
||||
if (
|
||||
window.confirm(
|
||||
`Êtes-vous sûr de vouloir supprimer « ${folder.title} » ?`,
|
||||
)
|
||||
) {
|
||||
router.delete(folder.destroyUrl);
|
||||
}
|
||||
}
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
vat: 'TVA',
|
||||
vat_monthly: 'TVA mensuelle',
|
||||
vat_quarterly: 'TVA trimestrielle',
|
||||
corporate_tax: 'IS',
|
||||
income_tax: 'IR',
|
||||
cnss: 'CNSS',
|
||||
annual_balance: 'Bilan',
|
||||
other: 'Autre',
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
draft: 'Brouillon',
|
||||
waiting_documents: 'En attente documents',
|
||||
documents_received: 'Documents reçus',
|
||||
processing: 'En cours de traitement',
|
||||
additional_documents_requested: 'Pièces complémentaires demandées',
|
||||
waiting_client_validation: 'En attente validation client',
|
||||
validated: 'Validé',
|
||||
closed: 'Clôturé',
|
||||
cancelled: 'Annulé',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout
|
||||
:breadcrumbs="[
|
||||
{ title: 'Dossiers' },
|
||||
]"
|
||||
>
|
||||
<Head title="Dossiers" />
|
||||
|
||||
<div class="flex flex-col space-y-6 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<Heading
|
||||
variant="small"
|
||||
title="Dossiers"
|
||||
:description="`Gérer les dossiers du workspace « ${workspaceName} »`"
|
||||
/>
|
||||
<Button as-child>
|
||||
<Link :href="createUrl">Créer un dossier</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-xl border border-sidebar-border/70 dark:border-sidebar-border overflow-hidden"
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="border-b border-sidebar-border/70 bg-muted/50">
|
||||
<tr>
|
||||
<th
|
||||
class="h-10 px-4 text-left font-medium align-middle"
|
||||
>
|
||||
Titre
|
||||
</th>
|
||||
<th
|
||||
class="h-10 px-4 text-left font-medium align-middle"
|
||||
>
|
||||
Client
|
||||
</th>
|
||||
<th
|
||||
class="h-10 px-4 text-left font-medium align-middle"
|
||||
>
|
||||
Type
|
||||
</th>
|
||||
<th
|
||||
class="h-10 px-4 text-left font-medium align-middle"
|
||||
>
|
||||
Statut
|
||||
</th>
|
||||
<th
|
||||
class="h-10 px-4 text-left font-medium align-middle"
|
||||
>
|
||||
Date limite
|
||||
</th>
|
||||
<th
|
||||
class="h-10 px-4 text-right font-medium align-middle"
|
||||
>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="folder in folders.data"
|
||||
:key="folder.id"
|
||||
class="border-b border-sidebar-border/50 last:border-0"
|
||||
>
|
||||
<td class="px-4 py-3 font-medium">
|
||||
<Link
|
||||
:href="folder.showUrl"
|
||||
class="hover:underline"
|
||||
>
|
||||
{{ folder.title }}
|
||||
</Link>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-muted-foreground">
|
||||
{{ folder.client_name }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-muted-foreground">
|
||||
{{ typeLabels[folder.type] ?? folder.type }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-muted-foreground">
|
||||
{{ statusLabels[folder.status] ?? folder.status }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-muted-foreground">
|
||||
{{ folder.due_date || '—' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right space-x-2">
|
||||
<Button variant="outline" size="sm" as-child>
|
||||
<Link :href="folder.showUrl"
|
||||
>Voir</Link
|
||||
>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" as-child>
|
||||
<Link :href="folder.editUrl"
|
||||
>Modifier</Link
|
||||
>
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
@click="destroy(folder)"
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!folders.data.length">
|
||||
<td
|
||||
colspan="6"
|
||||
class="px-4 py-8 text-center text-muted-foreground"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<FolderOpen class="h-10 w-10" />
|
||||
<p>Aucun dossier pour le moment.</p>
|
||||
<Button as-child>
|
||||
<Link :href="createUrl"
|
||||
>Créer votre premier
|
||||
dossier</Link
|
||||
>
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<Pagination
|
||||
:pagination="{
|
||||
from: folders.from ?? 0,
|
||||
to: folders.to ?? 0,
|
||||
total: folders.total,
|
||||
current_page: folders.current_page,
|
||||
last_page: folders.last_page,
|
||||
per_page: folders.per_page,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
696
resources/js/pages/folders/Show.vue
Normal file
696
resources/js/pages/folders/Show.vue
Normal file
@@ -0,0 +1,696 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head, Link, useForm } from '@inertiajs/vue3';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { computed, ref, watch, nextTick } from 'vue';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Timeline } from '@/components/ui/timeline';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import MessageBubble from '@/components/folders/MessageBubble.vue';
|
||||
import { CheckCircle2, Download, Paperclip, Send } from 'lucide-vue-next';
|
||||
|
||||
type Folder = {
|
||||
id: number;
|
||||
title: string;
|
||||
type: string;
|
||||
client_id: number;
|
||||
client_name: string;
|
||||
period_year: number | null;
|
||||
period_month: number | null;
|
||||
period_quarter: number | null;
|
||||
due_date: string | null;
|
||||
status: string;
|
||||
priority: string | null;
|
||||
assigned_to: number | null;
|
||||
assignee_name: string | null;
|
||||
validated_at: string | null;
|
||||
closed_at: string | null;
|
||||
notes_internal: string | null;
|
||||
notes_client: string | null;
|
||||
created_at: string | null;
|
||||
};
|
||||
|
||||
type Message = {
|
||||
id: number;
|
||||
type: string;
|
||||
body: string;
|
||||
sent_by_type: string;
|
||||
sender_name: string;
|
||||
created_at: string;
|
||||
attachments?: Array<{
|
||||
id: number;
|
||||
file_name: string;
|
||||
mime_type: string;
|
||||
size: string;
|
||||
downloadUrl: string;
|
||||
}>;
|
||||
confirmation_status?: 'pending' | 'confirmed' | 'refused' | null;
|
||||
};
|
||||
|
||||
type Document = {
|
||||
id: number;
|
||||
name: string;
|
||||
file_name: string;
|
||||
size: string;
|
||||
created_at: string;
|
||||
uploaded_by: string;
|
||||
downloadUrl: string;
|
||||
is_downloaded: boolean;
|
||||
};
|
||||
|
||||
type WorkspaceUser = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
folder: Folder;
|
||||
messages: Message[];
|
||||
documents: Document[];
|
||||
messagesStoreUrl: string;
|
||||
mediaStoreUrl: string;
|
||||
messageTypeLabels: Record<string, string>;
|
||||
indexUrl: string;
|
||||
editUrl: string;
|
||||
workspaceUsers: WorkspaceUser[];
|
||||
mentionStoreUrl: string;
|
||||
canMention: boolean;
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const reactiveDocuments = ref(props.documents.map((d) => ({ ...d })));
|
||||
watch(() => props.documents, (newDocs) => {
|
||||
reactiveDocuments.value = newDocs.map((d) => ({ ...d }));
|
||||
});
|
||||
|
||||
function onDocumentDownload(doc: Document & { is_downloaded: boolean }) {
|
||||
doc.is_downloaded = true;
|
||||
}
|
||||
|
||||
const mentionForm = useForm({
|
||||
user_id: '',
|
||||
message: '',
|
||||
});
|
||||
|
||||
function submitMention() {
|
||||
mentionForm.post(props.mentionStoreUrl, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => mentionForm.reset(),
|
||||
});
|
||||
}
|
||||
|
||||
const tabFromUrl = () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get('tab') === 'messages' ? 'messages' : params.get('tab') === 'documents' ? 'documents' : 'overview';
|
||||
};
|
||||
const tab = ref(tabFromUrl());
|
||||
watch(tab, (t) => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('tab', t);
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
});
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
vat: 'TVA',
|
||||
vat_monthly: 'TVA mensuelle',
|
||||
vat_quarterly: 'TVA trimestrielle',
|
||||
corporate_tax: 'IS',
|
||||
income_tax: 'IR',
|
||||
cnss: 'CNSS',
|
||||
annual_balance: 'Bilan',
|
||||
other: 'Autre',
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
draft: 'Brouillon',
|
||||
waiting_documents: 'En attente documents',
|
||||
documents_received: 'Documents reçus',
|
||||
processing: 'En cours de traitement',
|
||||
additional_documents_requested: 'Pièces complémentaires demandées',
|
||||
waiting_client_validation: 'En attente validation client',
|
||||
validated: 'Validé',
|
||||
closed: 'Clôturé',
|
||||
cancelled: 'Annulé',
|
||||
};
|
||||
|
||||
const priorityLabels: Record<string, string> = {
|
||||
low: 'Basse',
|
||||
medium: 'Normale',
|
||||
high: 'Haute',
|
||||
};
|
||||
|
||||
function formatPeriod(folder: Folder): string {
|
||||
const parts: string[] = [];
|
||||
if (folder.period_year) parts.push(String(folder.period_year));
|
||||
if (folder.period_quarter) parts.push(`T${folder.period_quarter}`);
|
||||
if (folder.period_month) parts.push(`M${folder.period_month}`);
|
||||
return parts.join(' - ') || '—';
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string | null): { date: string; time: string } | null {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso);
|
||||
return {
|
||||
date: d.toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}),
|
||||
time: d.toLocaleTimeString('fr-FR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const messagesContainerRef = ref<HTMLElement | null>(null);
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
const selectedFiles = ref<File[]>([]);
|
||||
const documentsFileInputRef = ref<HTMLInputElement | null>(null);
|
||||
const selectedDocuments = ref<File[]>([]);
|
||||
|
||||
function scrollToBottom() {
|
||||
nextTick(() => {
|
||||
const el = messagesContainerRef.value;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.messages.length,
|
||||
() => scrollToBottom(),
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(tab, (newTab) => {
|
||||
if (newTab === 'messages') scrollToBottom();
|
||||
});
|
||||
|
||||
function triggerFileSelect() {
|
||||
fileInputRef.value?.click();
|
||||
}
|
||||
|
||||
function onFilesChanged(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
selectedFiles.value = input.files ? Array.from(input.files) : [];
|
||||
}
|
||||
|
||||
function removeFile(index: number) {
|
||||
selectedFiles.value = selectedFiles.value.filter((_, i) => i !== index);
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = '';
|
||||
const dt = new DataTransfer();
|
||||
selectedFiles.value.forEach((f) => dt.items.add(f));
|
||||
fileInputRef.value.files = dt.files;
|
||||
}
|
||||
}
|
||||
|
||||
function triggerDocumentsFileSelect() {
|
||||
documentsFileInputRef.value?.click();
|
||||
}
|
||||
|
||||
function onDocumentsFilesChanged(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files?.length) {
|
||||
selectedDocuments.value = [...selectedDocuments.value, ...Array.from(input.files)];
|
||||
syncDocumentsToInput();
|
||||
}
|
||||
}
|
||||
|
||||
function removeDocument(index: number) {
|
||||
selectedDocuments.value = selectedDocuments.value.filter((_, i) => i !== index);
|
||||
syncDocumentsToInput();
|
||||
}
|
||||
|
||||
function syncDocumentsToInput() {
|
||||
if (!documentsFileInputRef.value) return;
|
||||
const dt = new DataTransfer();
|
||||
selectedDocuments.value.forEach((f) => dt.items.add(f));
|
||||
documentsFileInputRef.value.files = dt.files;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} o`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
|
||||
}
|
||||
|
||||
function autoResizeTextarea(e: Event) {
|
||||
const ta = e.target as HTMLTextAreaElement;
|
||||
ta.style.height = 'auto';
|
||||
ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`;
|
||||
}
|
||||
|
||||
const messagesChronological = computed(() => [...props.messages].reverse());
|
||||
|
||||
const folderTimelineItems = computed(() => {
|
||||
const folder = props.folder;
|
||||
const items: Array<{
|
||||
title: string;
|
||||
date?: string;
|
||||
time?: string;
|
||||
state: 'completed' | 'pending' | 'current';
|
||||
}> = [];
|
||||
|
||||
// Documents reçus
|
||||
const docsReceived =
|
||||
['documents_received', 'processing', 'additional_documents_requested', 'waiting_client_validation', 'validated', 'closed'].includes(
|
||||
folder.status,
|
||||
);
|
||||
items.push({
|
||||
title: docsReceived ? 'Documents reçus' : 'En attente des documents',
|
||||
state: docsReceived ? 'completed' : folder.status === 'waiting_documents' ? 'current' : 'pending',
|
||||
});
|
||||
|
||||
// Validation client
|
||||
const validatedFmt = formatDateTime(folder.validated_at);
|
||||
items.push({
|
||||
title: folder.validated_at ? 'Validé par le client' : 'Validation client',
|
||||
date: validatedFmt?.date,
|
||||
time: validatedFmt?.time,
|
||||
state: folder.validated_at ? 'completed' : folder.status === 'waiting_client_validation' ? 'current' : 'pending',
|
||||
});
|
||||
|
||||
// Clôture
|
||||
const closedFmt = formatDateTime(folder.closed_at);
|
||||
items.push({
|
||||
title: folder.closed_at ? 'Dossier clôturé' : 'Clôture du dossier',
|
||||
date: closedFmt?.date,
|
||||
time: closedFmt?.time,
|
||||
state: folder.closed_at ? 'completed' : folder.status === 'closed' ? 'current' : 'pending',
|
||||
});
|
||||
|
||||
return items;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="[
|
||||
{ title: 'Dossiers', href: props.indexUrl },
|
||||
{ title: props.folder.title },
|
||||
]">
|
||||
|
||||
<Head :title="props.folder.title" />
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-sidebar-border/70 dark:border-sidebar-border p-4">
|
||||
<Heading variant="small" :title="props.folder.title"
|
||||
:description="typeLabels[folder.type] ?? folder.type" />
|
||||
<Button variant="outline" as-child>
|
||||
<Link :href="editUrl">Modifier le dossier</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs v-model="tab" class="h-full overflow-auto w-full flex-grow gap-0">
|
||||
<TabsList class="w-full rounded-none py-0 px-0 h-auto !bg-background">
|
||||
<TabsTrigger value="overview" class="border-0 py-2 border-b-2 !shadow-none rounded-none border-sidebar-border/70 dark:border-sidebar-border data-[state=active]:border-primary transition-all">
|
||||
Aperçu
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="messages" class="border-0 py-2 border-b-2 !shadow-none rounded-none border-sidebar-border/70 dark:border-sidebar-border data-[state=active]:border-primary transition-all ">
|
||||
Messages
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="documents" class="border-0 py-2 border-b-2 !shadow-none rounded-none border-sidebar-border/70 dark:border-sidebar-border data-[state=active]:border-primary transition-all">
|
||||
Documents
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview" class="p-4">
|
||||
<div class="grid grid-cols-12 gap-4">
|
||||
<div class="col-span-8">
|
||||
<div class="rounded-xl border border-sidebar-border/70 dark:border-sidebar-border overflow-hidden">
|
||||
<dl class="divide-y divide-sidebar-border/70">
|
||||
<div class="flex flex-col gap-1 px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="text-sm font-medium text-muted-foreground">
|
||||
Client
|
||||
</dt>
|
||||
<dd class="text-sm sm:col-span-2">
|
||||
{{ folder.client_name || '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="text-sm font-medium text-muted-foreground">
|
||||
Type
|
||||
</dt>
|
||||
<dd class="text-sm sm:col-span-2">
|
||||
{{ typeLabels[folder.type] ?? folder.type }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="text-sm font-medium text-muted-foreground">
|
||||
Période
|
||||
</dt>
|
||||
<dd class="text-sm sm:col-span-2">
|
||||
{{ formatPeriod(folder) }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="text-sm font-medium text-muted-foreground">
|
||||
Date ouverture
|
||||
</dt>
|
||||
<dd class="text-sm sm:col-span-2">
|
||||
{{ folder.created_at ? new Date(folder.created_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' }) : '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="text-sm font-medium text-muted-foreground">
|
||||
Date limite
|
||||
</dt>
|
||||
<dd class="text-sm sm:col-span-2">
|
||||
{{ folder.due_date || '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="text-sm font-medium text-muted-foreground">
|
||||
Statut
|
||||
</dt>
|
||||
<dd class="text-sm sm:col-span-2">
|
||||
{{ statusLabels[folder.status] ?? folder.status }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="text-sm font-medium text-muted-foreground">
|
||||
Priorité
|
||||
</dt>
|
||||
<dd class="text-sm sm:col-span-2">
|
||||
{{ folder.priority ? (priorityLabels[folder.priority] ?? folder.priority) : '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="text-sm font-medium text-muted-foreground">
|
||||
Assigné à
|
||||
</dt>
|
||||
<dd class="text-sm sm:col-span-2">
|
||||
{{ folder.assignee_name || '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="text-sm font-medium text-muted-foreground">
|
||||
Validé le
|
||||
</dt>
|
||||
<dd class="text-sm sm:col-span-2">
|
||||
{{ folder.validated_at || '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="text-sm font-medium text-muted-foreground">
|
||||
Clôturé le
|
||||
</dt>
|
||||
<dd class="text-sm sm:col-span-2">
|
||||
{{ folder.closed_at || '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="folder.notes_internal"
|
||||
class="flex flex-col gap-1 px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="text-sm font-medium text-muted-foreground">
|
||||
Notes internes
|
||||
</dt>
|
||||
<dd class="text-sm sm:col-span-2 whitespace-pre-wrap">
|
||||
{{ folder.notes_internal }}
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="folder.notes_client"
|
||||
class="flex flex-col gap-1 px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="text-sm font-medium text-muted-foreground">
|
||||
Notes client
|
||||
</dt>
|
||||
<dd class="text-sm sm:col-span-2 whitespace-pre-wrap">
|
||||
{{ folder.notes_client }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div v-if="canMention" class="mt-4 rounded-xl border border-sidebar-border/70 dark:border-sidebar-border p-4">
|
||||
<h3 class="mb-3 text-sm font-medium">Notifier un collaborateur</h3>
|
||||
<form @submit.prevent="submitMention" class="space-y-3">
|
||||
<div>
|
||||
<Label for="mention-user" class="text-sm">Collaborateur</Label>
|
||||
<select
|
||||
id="mention-user"
|
||||
v-model="mentionForm.user_id"
|
||||
class="mt-1 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
required
|
||||
>
|
||||
<option value="" disabled>Sélectionner...</option>
|
||||
<option v-for="u in workspaceUsers" :key="u.id" :value="u.id">
|
||||
{{ u.name }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="mentionForm.errors.user_id" class="mt-1 text-xs text-destructive">{{ mentionForm.errors.user_id }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label for="mention-message" class="text-sm">Message</Label>
|
||||
<textarea
|
||||
id="mention-message"
|
||||
v-model="mentionForm.message"
|
||||
rows="2"
|
||||
class="mt-1 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="Ex : Merci de traiter ce dossier en priorité"
|
||||
required
|
||||
maxlength="500"
|
||||
/>
|
||||
<p v-if="mentionForm.errors.message" class="mt-1 text-xs text-destructive">{{ mentionForm.errors.message }}</p>
|
||||
</div>
|
||||
<Button type="submit" size="sm" :disabled="mentionForm.processing">
|
||||
Envoyer la notification
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-4">
|
||||
<Timeline
|
||||
:items="folderTimelineItems"
|
||||
class="rounded-xl border border-sidebar-border/70 dark:border-sidebar-border p-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="messages" class="flex flex-col min-h-0 p-0 h-full max-h-full relative">
|
||||
<div
|
||||
ref="messagesContainerRef"
|
||||
class="flex-1 overflow-y-auto overscroll-contain px-4 py-6 min-h-0 absolute top-0 left-0 right-0 bottom-0 overflow-hidden pb-24"
|
||||
>
|
||||
<div v-if="messages.length" class="mx-auto max-w-3xl space-y-4">
|
||||
<MessageBubble
|
||||
v-for="msg in messagesChronological"
|
||||
:key="msg.id"
|
||||
:message="{ ...msg, attachments: msg.attachments ?? [] }"
|
||||
:message-type-labels="messageTypeLabels"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex min-h-[200px] items-center justify-center text-center text-muted-foreground"
|
||||
>
|
||||
<p>Aucun message. Envoyez une invitation ou un message pour commencer.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 px-4 pb-4 bg-gradient-to-t from-background to-transparent absolute bottom-0 w-full">
|
||||
<Form
|
||||
:action="messagesStoreUrl"
|
||||
method="post"
|
||||
enctype="multipart/form-data"
|
||||
:force-form-data="true"
|
||||
class="mx-auto max-w-3xl"
|
||||
v-slot="{ processing }"
|
||||
@submit="selectedFiles = []"
|
||||
>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
name="files[]"
|
||||
multiple
|
||||
accept="*/*"
|
||||
class="hidden"
|
||||
@change="onFilesChanged"
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
v-if="selectedFiles.length"
|
||||
class="flex flex-wrap gap-1.5"
|
||||
>
|
||||
<span
|
||||
v-for="(f, i) in selectedFiles"
|
||||
:key="i"
|
||||
class="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs"
|
||||
>
|
||||
{{ f.name }}
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-muted-foreground/20 rounded p-0.5"
|
||||
@click.prevent="removeFile(i)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-end gap-2 rounded-xl border border-input bg-background px-3 py-2 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2"
|
||||
>
|
||||
<select
|
||||
name="type"
|
||||
required
|
||||
class="mr-2 shrink-0 border-0 bg-transparent py-2 pr-6 text-sm text-muted-foreground focus:outline-none focus:ring-0"
|
||||
>
|
||||
<option value="invite">Invitation</option>
|
||||
<option value="situation">Situation</option>
|
||||
<option value="file_request">Demande de pièces</option>
|
||||
<option value="confirmation">Validation</option>
|
||||
<option value="text">Message</option>
|
||||
</select>
|
||||
<textarea
|
||||
name="body"
|
||||
required
|
||||
rows="1"
|
||||
placeholder="Écrire un message..."
|
||||
class="min-h-[24px] max-h-[200px] flex-1 resize-none overflow-hidden border-0 bg-transparent py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="processing"
|
||||
@input="autoResizeTextarea"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="shrink-0 text-muted-foreground"
|
||||
@click="triggerFileSelect"
|
||||
>
|
||||
<Paperclip class="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
class="shrink-0"
|
||||
:disabled="processing"
|
||||
>
|
||||
<Spinner v-if="processing" class="size-4" />
|
||||
<Send v-else class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="documents" class="p-4">
|
||||
<div class="space-y-4">
|
||||
<Form
|
||||
:action="mediaStoreUrl"
|
||||
method="post"
|
||||
enctype="multipart/form-data"
|
||||
:force-form-data="true"
|
||||
class="space-y-4 rounded-xl border border-sidebar-border/70 p-4"
|
||||
v-slot="{ processing }"
|
||||
@submit="selectedDocuments = []"
|
||||
>
|
||||
<input
|
||||
ref="documentsFileInputRef"
|
||||
type="file"
|
||||
name="files[]"
|
||||
multiple
|
||||
accept="*/*"
|
||||
class="hidden"
|
||||
@change="onDocumentsFilesChanged"
|
||||
/>
|
||||
<div class="flex flex-wrap items-end gap-2">
|
||||
<div class="flex-1 space-y-2 min-w-[200px]">
|
||||
<Label>Ajouter des fichiers</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="w-full justify-start"
|
||||
:disabled="processing"
|
||||
@click="triggerDocumentsFileSelect"
|
||||
>
|
||||
<Paperclip class="mr-2 size-4" />
|
||||
Choisir des fichiers
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="processing || selectedDocuments.length === 0"
|
||||
>
|
||||
<Spinner v-if="processing" class="mr-2 size-4" />
|
||||
Télécharger
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedDocuments.length"
|
||||
class="rounded-lg border border-sidebar-border/70 bg-muted/30 p-2"
|
||||
>
|
||||
<p class="mb-2 text-sm font-medium text-muted-foreground">
|
||||
{{ selectedDocuments.length }} fichier(s) à déposer
|
||||
</p>
|
||||
<ul class="space-y-1.5">
|
||||
<li
|
||||
v-for="(file, i) in selectedDocuments"
|
||||
:key="`${file.name}-${i}`"
|
||||
class="flex items-center justify-between gap-2 rounded-md bg-background px-2 py-1.5 text-sm"
|
||||
>
|
||||
<span class="truncate">{{ file.name }}</span>
|
||||
<span class="flex shrink-0 items-center gap-2">
|
||||
<span class="text-muted-foreground">
|
||||
{{ formatFileSize(file.size) }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-0.5 hover:bg-muted-foreground/20"
|
||||
aria-label="Retirer"
|
||||
@click.prevent="removeDocument(i)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Form>
|
||||
<div
|
||||
v-if="reactiveDocuments.length"
|
||||
class="rounded-xl border border-sidebar-border/70 overflow-hidden"
|
||||
>
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-muted/50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left font-medium">Nom</th>
|
||||
<th class="px-4 py-2 text-left font-medium">Taille</th>
|
||||
<th class="px-4 py-2 text-left font-medium">Déposé par</th>
|
||||
<th class="px-4 py-2 text-left font-medium">Date</th>
|
||||
<th class="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-sidebar-border/70">
|
||||
<tr v-for="doc in reactiveDocuments" :key="doc.id">
|
||||
<td class="px-4 py-2">
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
{{ doc.file_name }}
|
||||
<CheckCircle2 v-if="doc.is_downloaded" class="size-3.5 text-green-500" />
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2">{{ doc.size }}</td>
|
||||
<td class="px-4 py-2">{{ doc.uploaded_by }}</td>
|
||||
<td class="px-4 py-2">{{ doc.created_at }}</td>
|
||||
<td class="px-4 py-2">
|
||||
<Button variant="ghost" size="sm" as-child>
|
||||
<a :href="doc.downloadUrl" download @click="onDocumentDownload(doc)">
|
||||
<Download class="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="!reactiveDocuments.length" class="rounded-xl border border-sidebar-border/70 p-8 text-center text-muted-foreground">
|
||||
Aucun document. Ajoutez des fichiers ci-dessus.
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
35
resources/js/pages/settings/Appearance.vue
Normal file
35
resources/js/pages/settings/Appearance.vue
Normal 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>
|
||||
116
resources/js/pages/settings/Password.vue
Normal file
116
resources/js/pages/settings/Password.vue
Normal 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>
|
||||
131
resources/js/pages/settings/Profile.vue
Normal file
131
resources/js/pages/settings/Profile.vue
Normal 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>
|
||||
125
resources/js/pages/settings/TwoFactor.vue
Normal file
125
resources/js/pages/settings/TwoFactor.vue
Normal 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>
|
||||
53
resources/js/pages/users/Create.vue
Normal file
53
resources/js/pages/users/Create.vue
Normal 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>
|
||||
61
resources/js/pages/users/Edit.vue
Normal file
61
resources/js/pages/users/Edit.vue
Normal 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>
|
||||
162
resources/js/pages/users/Index.vue
Normal file
162
resources/js/pages/users/Index.vue
Normal 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>
|
||||
52
resources/js/pages/workspaces/Create.vue
Normal file
52
resources/js/pages/workspaces/Create.vue
Normal 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>
|
||||
61
resources/js/pages/workspaces/Edit.vue
Normal file
61
resources/js/pages/workspaces/Edit.vue
Normal 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>
|
||||
181
resources/js/pages/workspaces/Index.vue
Normal file
181
resources/js/pages/workspaces/Index.vue
Normal 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>
|
||||
204
resources/js/pages/workspaces/Show.vue
Normal file
204
resources/js/pages/workspaces/Show.vue
Normal 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>
|
||||
Reference in New Issue
Block a user