feat: complete Epic 0 — foundation migration & infrastructure setup
Stories 0.2-0.5: rename folders→declarations (backend+frontend), configure Redis for cache/queue/sessions, add foundation database migrations (permissions, archived_at), replace DeclarationStatus enum with architecture lifecycle values, create DeclarationObserver for status transition validation and auto-archive, fix controller status transitions to respect observer rules. 93 tests pass (240 assertions). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import { BookOpen, Briefcase, Building2, Folder, HelpCircle, LayoutGrid, Users } from 'lucide-vue-next';
|
||||
import {
|
||||
BookOpen,
|
||||
Briefcase,
|
||||
Building2,
|
||||
FileStack,
|
||||
HelpCircle,
|
||||
LayoutGrid,
|
||||
Users,
|
||||
} from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import NavFooter from '@/components/NavFooter.vue';
|
||||
import NavMain from '@/components/NavMain.vue';
|
||||
@@ -14,9 +22,9 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { dashboard } from '@/routes';
|
||||
import type { NavItem } from '@/types';
|
||||
import AppLogo from './AppLogo.vue';
|
||||
import { dashboard } from '@/routes';
|
||||
import WorkspaceSwitcher from './WorkspaceSwitcher.vue';
|
||||
|
||||
const page = usePage();
|
||||
@@ -36,9 +44,9 @@ const mainNavItems = computed<NavItem[]>(() => {
|
||||
icon: Briefcase,
|
||||
},
|
||||
{
|
||||
title: 'Dossiers',
|
||||
href: '/folders',
|
||||
icon: Folder,
|
||||
title: 'Déclarations',
|
||||
href: '/declarations',
|
||||
icon: FileStack,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -90,9 +98,16 @@ const footerNavItems: NavItem[] = [
|
||||
<SidebarContent>
|
||||
<NavMain :items="mainNavItems" />
|
||||
<template
|
||||
v-if="['admin', 'superadmin'].includes(String($page.props.auth.user?.group ?? ''))"
|
||||
v-if="
|
||||
['admin', 'superadmin'].includes(
|
||||
String($page.props.auth.user?.group ?? ''),
|
||||
)
|
||||
"
|
||||
>
|
||||
<NavMain :items="administrationNavItems" label="Administration" />
|
||||
<NavMain
|
||||
:items="administrationNavItems"
|
||||
label="Administration"
|
||||
/>
|
||||
</template>
|
||||
</SidebarContent>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Building2, Plus, Settings, Trash2, User } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -19,7 +20,6 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Building2, Plus, Settings, Trash2, User } from 'lucide-vue-next';
|
||||
|
||||
export type ClientContactData = {
|
||||
id?: number;
|
||||
@@ -180,9 +180,7 @@ const inputClass =
|
||||
<InputError :message="form.errors.ice" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="fiscal_id"
|
||||
>IF (Identifiant Fiscal)</Label
|
||||
>
|
||||
<Label for="fiscal_id">IF (Identifiant Fiscal)</Label>
|
||||
<Input
|
||||
id="fiscal_id"
|
||||
v-model="form.fiscal_id"
|
||||
@@ -241,7 +239,7 @@ const inputClass =
|
||||
Responsables
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Personnes à contacter pour les échanges et dossiers
|
||||
Personnes à contacter pour les échanges et déclarations
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
@@ -288,16 +286,12 @@ const inputClass =
|
||||
placeholder="Prénom Nom"
|
||||
:class="inputClass"
|
||||
:aria-invalid="
|
||||
!!form.errors[
|
||||
`contacts.${index}.full_name`
|
||||
]
|
||||
!!form.errors[`contacts.${index}.full_name`]
|
||||
"
|
||||
/>
|
||||
<InputError
|
||||
:message="
|
||||
form.errors[
|
||||
`contacts.${index}.full_name`
|
||||
]
|
||||
form.errors[`contacts.${index}.full_name`]
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
@@ -314,9 +308,7 @@ const inputClass =
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label :for="`contact_email_${index}`"
|
||||
>Email</Label
|
||||
>
|
||||
<Label :for="`contact_email_${index}`">Email</Label>
|
||||
<Input
|
||||
:id="`contact_email_${index}`"
|
||||
v-model="contact.email"
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
export type FolderFormData = {
|
||||
export type DeclarationFormData = {
|
||||
client_id: number | '';
|
||||
title: string;
|
||||
type: string;
|
||||
@@ -34,10 +34,10 @@ type WorkspaceUser = {
|
||||
};
|
||||
|
||||
type Props = {
|
||||
form: Form<FolderFormData>;
|
||||
folderTypeLabels: Record<string, string>;
|
||||
folderStatusLabels: Record<string, string>;
|
||||
folderPriorityLabels: Record<string, string>;
|
||||
form: Form<DeclarationFormData>;
|
||||
declarationTypeLabels: Record<string, string>;
|
||||
declarationStatusLabels: Record<string, string>;
|
||||
declarationPriorityLabels: Record<string, string>;
|
||||
clients: Client[];
|
||||
workspaceUsers: WorkspaceUser[];
|
||||
submitLabel?: string;
|
||||
@@ -89,7 +89,7 @@ watch(
|
||||
id="client_id"
|
||||
v-model="form.client_id"
|
||||
required
|
||||
class="border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
class="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-invalid="!!form.errors.client_id"
|
||||
>
|
||||
<option value="" disabled>Sélectionner un client</option>
|
||||
@@ -123,12 +123,12 @@ watch(
|
||||
id="type"
|
||||
v-model="form.type"
|
||||
required
|
||||
class="border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
class="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-invalid="!!form.errors.type"
|
||||
>
|
||||
<option value="" disabled>Sélectionner un type</option>
|
||||
<option
|
||||
v-for="(label, value) in folderTypeLabels"
|
||||
v-for="(label, value) in declarationTypeLabels"
|
||||
:key="value"
|
||||
:value="value"
|
||||
>
|
||||
@@ -145,7 +145,7 @@ watch(
|
||||
id="period_year"
|
||||
v-model="form.period_year"
|
||||
required
|
||||
class="border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
class="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-invalid="!!form.errors.period_year"
|
||||
>
|
||||
<option v-for="y in years" :key="y" :value="y">
|
||||
@@ -160,7 +160,7 @@ watch(
|
||||
<select
|
||||
id="period_month"
|
||||
v-model="form.period_month"
|
||||
class="border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
class="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-invalid="!!form.errors.period_month"
|
||||
>
|
||||
<option
|
||||
@@ -180,7 +180,7 @@ watch(
|
||||
<select
|
||||
id="period_quarter"
|
||||
v-model="form.period_quarter"
|
||||
class="border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
class="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-invalid="!!form.errors.period_quarter"
|
||||
>
|
||||
<option
|
||||
@@ -212,12 +212,12 @@ watch(
|
||||
<select
|
||||
id="status"
|
||||
v-model="form.status"
|
||||
class="border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
class="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-invalid="!!form.errors.status"
|
||||
>
|
||||
<option value="" disabled>Sélectionner un statut</option>
|
||||
<option
|
||||
v-for="(label, value) in folderStatusLabels"
|
||||
v-for="(label, value) in declarationStatusLabels"
|
||||
:key="value"
|
||||
:value="value"
|
||||
>
|
||||
@@ -234,12 +234,12 @@ watch(
|
||||
<select
|
||||
id="priority"
|
||||
v-model="form.priority"
|
||||
class="border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
class="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-invalid="!!form.errors.priority"
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option
|
||||
v-for="(label, value) in folderPriorityLabels"
|
||||
v-for="(label, value) in declarationPriorityLabels"
|
||||
:key="value"
|
||||
:value="value"
|
||||
>
|
||||
@@ -253,7 +253,7 @@ watch(
|
||||
<select
|
||||
id="assigned_to"
|
||||
v-model="form.assigned_to"
|
||||
class="border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
class="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-invalid="!!form.errors.assigned_to"
|
||||
>
|
||||
<option :value="''">—</option>
|
||||
@@ -275,7 +275,7 @@ watch(
|
||||
id="notes_internal"
|
||||
v-model="form.notes_internal"
|
||||
rows="3"
|
||||
class="border-input bg-background w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
placeholder="Notes confidentielles"
|
||||
:aria-invalid="!!form.errors.notes_internal"
|
||||
/>
|
||||
@@ -288,7 +288,7 @@ watch(
|
||||
id="notes_client"
|
||||
v-model="form.notes_client"
|
||||
rows="3"
|
||||
class="border-input bg-background w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
placeholder="Notes partagées avec le client"
|
||||
:aria-invalid="!!form.errors.notes_client"
|
||||
/>
|
||||
@@ -299,7 +299,7 @@ watch(
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="form.processing"
|
||||
data-test="folder-form-submit"
|
||||
data-test="declaration-form-submit"
|
||||
>
|
||||
<Spinner v-if="form.processing" />
|
||||
{{ submitLabel }}
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { router, usePage } from '@inertiajs/vue3';
|
||||
import { Bell } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -16,8 +16,8 @@ type NotificationItem = {
|
||||
id: string;
|
||||
type: string;
|
||||
data: {
|
||||
folder_id?: number;
|
||||
folder_title?: string;
|
||||
declaration_id?: number;
|
||||
declaration_title?: string;
|
||||
mentioned_by_name?: string;
|
||||
message?: string;
|
||||
url?: string;
|
||||
@@ -36,7 +36,8 @@ type UserNotifications = {
|
||||
const page = usePage();
|
||||
|
||||
const userNotifications = computed<UserNotifications>(() => {
|
||||
return (page.props as Record<string, unknown>).userNotifications as UserNotifications;
|
||||
return (page.props as Record<string, unknown>)
|
||||
.userNotifications as UserNotifications;
|
||||
});
|
||||
|
||||
const unreadCount = computed(() => userNotifications.value?.unread_count ?? 0);
|
||||
@@ -63,7 +64,7 @@ function navigateToNotification(notification: NotificationItem) {
|
||||
|
||||
router.visit(targetUrl, {
|
||||
onError: () => {
|
||||
// Folder may have been deleted — mark as read anyway
|
||||
// Declaration may have been deleted — mark as read anyway
|
||||
if (!notification.read_at) {
|
||||
markAsRead(notification);
|
||||
}
|
||||
@@ -86,7 +87,7 @@ function markAllAsRead() {
|
||||
<Bell class="size-4" />
|
||||
<span
|
||||
v-if="unreadCount > 0"
|
||||
class="absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground"
|
||||
class="absolute -top-0.5 -right-0.5 flex size-4 items-center justify-center rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground"
|
||||
>
|
||||
{{ unreadCount > 9 ? '9+' : unreadCount }}
|
||||
</span>
|
||||
@@ -96,11 +97,17 @@ function markAllAsRead() {
|
||||
<DropdownMenuLabel>Notifications</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<div v-if="isLoading" class="px-2 py-4 text-center text-sm text-muted-foreground">
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="px-2 py-4 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
Chargement...
|
||||
</div>
|
||||
|
||||
<div v-else-if="!items.length" class="px-2 py-4 text-center text-sm text-muted-foreground">
|
||||
<div
|
||||
v-else-if="!items.length"
|
||||
class="px-2 py-4 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
Aucune notification.
|
||||
</div>
|
||||
|
||||
@@ -114,17 +121,27 @@ function markAllAsRead() {
|
||||
>
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<span class="text-xs font-medium">
|
||||
{{ notification.data?.mentioned_by_name ?? 'Système' }}
|
||||
{{
|
||||
notification.data?.mentioned_by_name ??
|
||||
'Système'
|
||||
}}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ notification.created_at }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
<span v-if="notification.data?.folder_title" class="font-medium text-foreground">
|
||||
{{ notification.data.folder_title }}
|
||||
<span
|
||||
v-if="notification.data?.declaration_title"
|
||||
class="font-medium text-foreground"
|
||||
>
|
||||
{{ notification.data.declaration_title }}
|
||||
</span>
|
||||
{{ notification.data?.message ? ` — ${notification.data.message}` : '' }}
|
||||
{{
|
||||
notification.data?.message
|
||||
? ` — ${notification.data.message}`
|
||||
: ''
|
||||
}}
|
||||
</p>
|
||||
<span
|
||||
v-if="!notification.read_at"
|
||||
|
||||
@@ -1,42 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-vue-next';
|
||||
import { computed, ref } from 'vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-vue-next';
|
||||
|
||||
type Folder = {
|
||||
type Declaration = {
|
||||
id: number;
|
||||
due_date: string | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
folders: Folder[];
|
||||
declarations: Declaration[];
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const monthNames = [
|
||||
'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
|
||||
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre',
|
||||
'Janvier',
|
||||
'Février',
|
||||
'Mars',
|
||||
'Avril',
|
||||
'Mai',
|
||||
'Juin',
|
||||
'Juillet',
|
||||
'Août',
|
||||
'Septembre',
|
||||
'Octobre',
|
||||
'Novembre',
|
||||
'Décembre',
|
||||
];
|
||||
const dayNames = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'];
|
||||
|
||||
const current = ref(new Date());
|
||||
|
||||
const monthLabel = computed(() =>
|
||||
`${monthNames[current.value.getMonth()]} ${current.value.getFullYear()}`,
|
||||
const monthLabel = computed(
|
||||
() =>
|
||||
`${monthNames[current.value.getMonth()]} ${current.value.getFullYear()}`,
|
||||
);
|
||||
|
||||
const datesWithFolders = computed(() => {
|
||||
const datesWithDeclarations = computed(() => {
|
||||
const set = new Set<string>();
|
||||
props.folders.forEach((f) => {
|
||||
props.declarations.forEach((f) => {
|
||||
if (f.due_date) set.add(f.due_date);
|
||||
});
|
||||
return set;
|
||||
});
|
||||
|
||||
const foldersByDate = computed(() => {
|
||||
const declarationsByDate = computed(() => {
|
||||
const map = new Map<string, number>();
|
||||
props.folders.forEach((f) => {
|
||||
props.declarations.forEach((f) => {
|
||||
if (f.due_date) {
|
||||
map.set(f.due_date, (map.get(f.due_date) ?? 0) + 1);
|
||||
}
|
||||
@@ -52,7 +63,11 @@ const calendarDays = computed(() => {
|
||||
const startDay = (first.getDay() + 6) % 7;
|
||||
const daysInMonth = last.getDate();
|
||||
|
||||
const days: Array<{ date: Date | null; dateStr: string | null; count: number }> = [];
|
||||
const days: Array<{
|
||||
date: Date | null;
|
||||
dateStr: string | null;
|
||||
count: number;
|
||||
}> = [];
|
||||
|
||||
for (let i = 0; i < startDay; i++) {
|
||||
days.push({ date: null, dateStr: null, count: 0 });
|
||||
@@ -63,18 +78,24 @@ const calendarDays = computed(() => {
|
||||
days.push({
|
||||
date,
|
||||
dateStr,
|
||||
count: foldersByDate.value.get(dateStr) ?? 0,
|
||||
count: declarationsByDate.value.get(dateStr) ?? 0,
|
||||
});
|
||||
}
|
||||
return days;
|
||||
});
|
||||
|
||||
function prevMonth() {
|
||||
current.value = new Date(current.value.getFullYear(), current.value.getMonth() - 1);
|
||||
current.value = new Date(
|
||||
current.value.getFullYear(),
|
||||
current.value.getMonth() - 1,
|
||||
);
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
current.value = new Date(current.value.getFullYear(), current.value.getMonth() + 1);
|
||||
current.value = new Date(
|
||||
current.value.getFullYear(),
|
||||
current.value.getMonth() + 1,
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -111,11 +132,8 @@ function nextMonth() {
|
||||
>
|
||||
<template v-if="cell.date">
|
||||
{{ cell.date.getDate() }}
|
||||
<span
|
||||
v-if="cell.count > 0"
|
||||
class="mt-0.5 text-[10px]"
|
||||
>
|
||||
{{ cell.count }} dossier{{ cell.count > 1 ? 's' : '' }}
|
||||
<span v-if="cell.count > 0" class="mt-0.5 text-[10px]">
|
||||
{{ cell.count }} décl.
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CheckCircle2, Download, FileText } from 'lucide-vue-next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
type Props = {
|
||||
message: {
|
||||
@@ -27,13 +27,19 @@ const props = defineProps<Props>();
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
invite: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
situation: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
file_request: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300',
|
||||
confirmation: 'bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-300',
|
||||
situation:
|
||||
'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
file_request:
|
||||
'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300',
|
||||
confirmation:
|
||||
'bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-300',
|
||||
text: 'bg-slate-100 text-slate-700 dark:bg-slate-800/50 dark:text-slate-300',
|
||||
};
|
||||
|
||||
const confirmationStatusLabels: Record<string, { label: string; class: string }> = {
|
||||
const confirmationStatusLabels: Record<
|
||||
string,
|
||||
{ label: string; class: string }
|
||||
> = {
|
||||
pending: {
|
||||
label: 'En attente',
|
||||
class: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
@@ -80,12 +86,18 @@ function getTypeColor(type: string): string {
|
||||
<span
|
||||
v-if="message.confirmation_status"
|
||||
class="inline-flex rounded px-2 py-0.5 text-xs font-medium"
|
||||
:class="confirmationStatusLabels[message.confirmation_status]?.class ?? ''"
|
||||
:class="
|
||||
confirmationStatusLabels[message.confirmation_status]
|
||||
?.class ?? ''
|
||||
"
|
||||
>
|
||||
{{ confirmationStatusLabels[message.confirmation_status]?.label ?? message.confirmation_status }}
|
||||
{{
|
||||
confirmationStatusLabels[message.confirmation_status]
|
||||
?.label ?? message.confirmation_status
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 whitespace-pre-wrap text-sm">{{ message.body }}</p>
|
||||
<p class="mt-2 text-sm whitespace-pre-wrap">{{ message.body }}</p>
|
||||
<div
|
||||
v-if="message.attachments?.length"
|
||||
class="mt-3 flex flex-wrap gap-2"
|
||||
@@ -95,31 +107,30 @@ function getTypeColor(type: string): string {
|
||||
:key="att.id"
|
||||
class="flex items-center gap-2 rounded-lg border border-sidebar-border/70 bg-background/50 p-2"
|
||||
>
|
||||
<div class="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded bg-muted">
|
||||
<div
|
||||
class="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded bg-muted"
|
||||
>
|
||||
<img
|
||||
v-if="isImageMime(att.mime_type)"
|
||||
:src="att.downloadUrl"
|
||||
:alt="att.file_name"
|
||||
class="size-10 object-cover"
|
||||
/>
|
||||
<FileText
|
||||
v-else
|
||||
class="size-5 text-muted-foreground"
|
||||
/>
|
||||
<FileText v-else class="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="inline-flex items-center gap-1.5 truncate text-xs font-medium">
|
||||
<p
|
||||
class="inline-flex items-center gap-1.5 truncate text-xs font-medium"
|
||||
>
|
||||
{{ att.file_name }}
|
||||
<CheckCircle2 v-if="att.is_downloaded" class="size-3.5 text-green-500" />
|
||||
<CheckCircle2
|
||||
v-if="att.is_downloaded"
|
||||
class="size-3.5 text-green-500"
|
||||
/>
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">{{ att.size }}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
as-child
|
||||
class="shrink-0"
|
||||
>
|
||||
<Button variant="ghost" size="sm" as-child class="shrink-0">
|
||||
<a
|
||||
:href="att.downloadUrl"
|
||||
target="_blank"
|
||||
Reference in New Issue
Block a user