Files
L-Ami-Fiduciaire/resources/js/pages/team/Index.vue
Saad Ibn-Ezzoubayr 4807376c49 feat: implement Story 2.2 — Priority Alerts Panel with UI fixes
Add PriorityAlertsPanel component to the dashboard, update DashboardController
with alert logic, and apply misc UI fixes across sidebar, forms, and pages.
Includes epic-1 retrospective and sprint status update.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 12:33:27 +00:00

609 lines
23 KiB
Vue

<script setup lang="ts">
import { Head, router, useForm } from '@inertiajs/vue3';
import {
MoreHorizontal,
Shield,
UserCog,
UserMinus,
UserPlus,
Users,
} from 'lucide-vue-next';
import { computed, ref, watch } from 'vue';
import Heading from '@/components/Heading.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import AppLayout from '@/layouts/AppLayout.vue';
import { dashboard } from '@/routes';
import { index as teamIndex } from '@/routes/team';
import type { BreadcrumbItem, TeamPageProps, TeamMember } from '@/types';
type Props = TeamPageProps;
const ROLE_OWNER = 'owner';
const ROLE_MANAGER = 'manager';
const props = defineProps<Props>();
const showInviteDialog = ref(false);
const form = useForm({
email: '',
role: 'worker',
});
function submitInvite() {
form.post(props.inviteUrl, {
onSuccess: () => {
showInviteDialog.value = false;
form.reset();
},
});
}
// ── Role Change Dialog ──
const showRoleDialog = ref(false);
const roleChangeMember = ref<TeamMember | null>(null);
const selectedRole = ref('');
const roleChangeProcessing = ref(false);
function openRoleDialog(member: TeamMember) {
roleChangeMember.value = member;
selectedRole.value = member.role;
showRoleDialog.value = true;
}
const isRoleUnchanged = computed(
() => selectedRole.value === roleChangeMember.value?.role,
);
function submitRoleChange() {
if (!roleChangeMember.value || isRoleUnchanged.value) return;
router.patch(
roleChangeMember.value.updateRoleUrl,
{ role: selectedRole.value },
{
onStart: () => {
roleChangeProcessing.value = true;
},
onFinish: () => {
roleChangeProcessing.value = false;
},
onSuccess: () => {
showRoleDialog.value = false;
roleChangeMember.value = null;
},
},
);
}
// ── Remove Member Dialog ──
const showRemoveDialog = ref(false);
const removeMember = ref<TeamMember | null>(null);
const removeProcessing = ref(false);
function openRemoveDialog(member: TeamMember) {
removeMember.value = member;
showRemoveDialog.value = true;
}
function submitRemove() {
if (!removeMember.value) return;
router.delete(removeMember.value.removeUrl, {
onStart: () => {
removeProcessing.value = true;
},
onFinish: () => {
removeProcessing.value = false;
},
onSuccess: () => {
showRemoveDialog.value = false;
removeMember.value = null;
},
});
}
// ── Permissions Dialog ──
const showPermissionsDialog = ref(false);
const permissionsMember = ref<TeamMember | null>(null);
const permissionsProcessing = ref(false);
function openPermissionsDialog(member: TeamMember) {
permissionsMember.value = member;
showPermissionsDialog.value = true;
}
function togglePermission(key: string, value: boolean) {
if (!permissionsMember.value?.permissionsUrl) return;
const updatedPermissions = {
...permissionsMember.value.permissions,
[key]: value,
};
permissionsProcessing.value = true;
router.put(
permissionsMember.value.permissionsUrl,
{ permissions: updatedPermissions },
{
preserveScroll: true,
onFinish: () => {
permissionsProcessing.value = false;
},
onSuccess: () => {
if (permissionsMember.value) {
permissionsMember.value = {
...permissionsMember.value,
permissions: updatedPermissions,
};
}
},
},
);
}
// Keep permissionsMember in sync when props update (e.g. Inertia partial reload)
watch(
() => props.members,
(members) => {
if (!permissionsMember.value || !showPermissionsDialog.value) return;
const updated = members.find(
(m) =>
m.workspace_user_id ===
permissionsMember.value!.workspace_user_id,
);
if (updated) {
permissionsMember.value = updated;
}
},
);
function canShowActions(member: TeamMember): boolean {
// No actions for current user's own row
if (member.id === props.authUserId) return false;
// No actions for Owner rows (managers cannot modify owners)
if (member.role === ROLE_OWNER) return false;
return true;
}
const allMembers = computed(() => {
const active = props.members.map((m) => ({
id: `member-${m.id}`,
name: m.name,
email: m.email,
role: m.role,
date: m.joined_at,
status: m.status,
}));
const pending = props.pendingInvitations.map((inv) => ({
id: `invite-${inv.id}`,
name: null as string | null,
email: inv.email,
role: inv.role,
date: inv.invited_at,
status: inv.status,
}));
return [...active, ...pending];
});
const isEmpty = computed(
() => props.members.length <= 1 && props.pendingInvitations.length === 0,
);
const roleLabels = computed<Record<string, string>>(() => ({
owner: 'Propriétaire',
...props.roles,
}));
const statusLabels: Record<string, string> = {
active: 'Actif',
pending: 'En attente',
};
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
function getMemberData(member: {
id: string;
status: string;
}): TeamMember | null {
if (member.status !== 'active') return null;
const userId = Number(member.id.replace('member-', ''));
return props.members.find((m) => m.id === userId) ?? null;
}
const breadcrumbs: BreadcrumbItem[] = [
{ title: 'Dashboard', href: dashboard().url },
{ title: 'Équipe', href: teamIndex().url },
];
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head title="Équipe" />
<!-- Invite Dialog (shared across empty state and header) -->
<Dialog v-model:open="showInviteDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>Inviter un membre</DialogTitle>
<DialogDescription>
Envoyez une invitation par email pour ajouter un membre
à votre équipe.
</DialogDescription>
</DialogHeader>
<form class="space-y-4" @submit.prevent="submitInvite">
<div class="space-y-2">
<Label for="email">Adresse email</Label>
<Input
id="email"
v-model="form.email"
type="email"
placeholder="nom@exemple.com"
required
/>
<p
v-if="form.errors.email"
class="text-sm text-destructive"
>
{{ form.errors.email }}
</p>
</div>
<div class="space-y-2">
<Label for="role">Rôle</Label>
<Select v-model="form.role">
<SelectTrigger>
<SelectValue
placeholder="Sélectionner un rôle"
/>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="(label, value) in roles"
:key="value"
:value="value"
>
{{ label }}
</SelectItem>
</SelectContent>
</Select>
<p
v-if="form.errors.role"
class="text-sm text-destructive"
>
{{ form.errors.role }}
</p>
</div>
<DialogFooter>
<Button type="submit" :disabled="form.processing">
Envoyer l'invitation
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<!-- Role Change Dialog -->
<Dialog v-model:open="showRoleDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>Changer le rôle</DialogTitle>
<DialogDescription>
Sélectionnez le nouveau rôle pour
{{ roleChangeMember?.name }}
</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<div class="space-y-2">
<Label>Rôle</Label>
<Select v-model="selectedRole">
<SelectTrigger>
<SelectValue
placeholder="Sélectionner un rôle"
/>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="(label, value) in roles"
:key="value"
:value="value"
>
{{ label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button
:disabled="isRoleUnchanged || roleChangeProcessing"
@click="submitRoleChange"
>
Confirmer
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
<!-- Permissions Dialog -->
<Dialog v-model:open="showPermissionsDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>Gérer les permissions</DialogTitle>
<DialogDescription>
Configurez les permissions de
{{ permissionsMember?.name }}
</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<div
v-for="(label, key) in availablePermissions"
:key="key"
class="flex items-center justify-between rounded-lg border p-3"
>
<Label :for="`perm-${key}`" class="cursor-pointer">
{{ label }}
</Label>
<Switch
:id="`perm-${key}`"
:checked="
permissionsMember?.permissions?.[key] ?? false
"
:disabled="permissionsProcessing"
@update:checked="
(val: boolean) => togglePermission(key, val)
"
/>
</div>
</div>
</DialogContent>
</Dialog>
<!-- Remove Member Dialog -->
<Dialog v-model:open="showRemoveDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>Retirer le membre</DialogTitle>
<DialogDescription>
Êtes-vous sûr de vouloir retirer
{{ removeMember?.name }} de l'espace de travail ? Cette
action est irréversible.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" @click="showRemoveDialog = false">
Annuler
</Button>
<Button
variant="destructive"
:disabled="removeProcessing"
@click="submitRemove"
>
Retirer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div class="flex flex-col space-y-6 p-4">
<div class="flex items-center justify-between">
<Heading
variant="small"
title="Équipe"
description="Gérer les membres de votre équipe"
/>
<Button v-if="canManageTeam" @click="showInviteDialog = true">
<UserPlus class="mr-2 h-4 w-4" />
Inviter un membre
</Button>
</div>
<!-- Empty State -->
<div
v-if="isEmpty"
class="flex flex-col items-center justify-center rounded-xl border border-sidebar-border/70 px-4 py-16 text-center dark:border-sidebar-border"
>
<Users class="mb-4 h-12 w-12 text-muted-foreground" />
<h3 class="text-lg font-medium">Aucun membre</h3>
<p class="mt-1 text-sm text-muted-foreground">
Invitez votre premier membre d'équipe
</p>
<Button
v-if="canManageTeam"
class="mt-4"
@click="showInviteDialog = true"
>
<UserPlus class="mr-2 h-4 w-4" />
Inviter un membre
</Button>
</div>
<!-- Team Table -->
<div
v-else
class="overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"
>
<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 align-middle font-medium"
>
Nom
</th>
<th
class="h-10 px-4 text-left align-middle font-medium"
>
Email
</th>
<th
class="h-10 px-4 text-left align-middle font-medium"
>
Rôle
</th>
<th
class="h-10 px-4 text-left align-middle font-medium"
>
Rejoint le
</th>
<th
class="h-10 px-4 text-left align-middle font-medium"
>
Statut
</th>
<th
v-if="canManageTeam"
class="h-10 w-12 px-4 text-left align-middle font-medium"
>
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="member in allMembers"
:key="member.id"
class="border-b border-sidebar-border/50 last:border-0 hover:bg-muted/50"
>
<td class="px-4 py-3 font-medium">
{{ member.name ?? '' }}
</td>
<td class="px-4 py-3 text-muted-foreground">
{{ member.email }}
</td>
<td class="px-4 py-3">
<Badge variant="secondary">
{{
roleLabels[member.role] ??
member.role
}}
</Badge>
</td>
<td class="px-4 py-3 text-muted-foreground">
{{ formatDate(member.date) }}
</td>
<td class="px-4 py-3">
<Badge
:variant="
member.status === 'active'
? 'default'
: 'outline'
"
>
{{ statusLabels[member.status] }}
</Badge>
</td>
<td v-if="canManageTeam" class="px-4 py-3">
<template
v-if="
getMemberData(member) &&
canShowActions(
getMemberData(member)!,
)
"
>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="icon"
>
<MoreHorizontal
class="h-4 w-4"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
v-if="
isOwner &&
getMemberData(member)!
.role ===
ROLE_MANAGER
"
@click="
openPermissionsDialog(
getMemberData(
member,
)!,
)
"
>
<Shield
class="mr-2 h-4 w-4"
/>
Gérer les permissions
</DropdownMenuItem>
<DropdownMenuItem
@click="
openRoleDialog(
getMemberData(
member,
)!,
)
"
>
<UserCog
class="mr-2 h-4 w-4"
/>
Changer le rôle
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
class="text-destructive"
@click="
openRemoveDialog(
getMemberData(
member,
)!,
)
"
>
<UserMinus
class="mr-2 h-4 w-4"
/>
Retirer de l'espace
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</AppLayout>
</template>