Louis Mazin 4db822aebe big maj
2026-10-05 17:52:28 +02:00

1225 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState } from 'react';
import { useAuth } from '@/context/useAuth';
import { can } from '@/lib/permissions';
import {
Check,
BarChart3,
ChevronDown,
ClipboardList,
Globe2,
Info,
LockKeyhole,
MessageCircleQuestionMark,
Pencil,
Plus,
RotateCcw,
Search,
StickyNote,
Trash2,
Users,
} from 'lucide-react';
import { notesApi } from '../api/notes';
import PollCard from '@/components/notes/PollCard';
import PollEditor from '@/components/notes/PollEditor';
import { emptyPoll, pollForm } from '@/components/notes/pollUtils';
import MainCard from '@/components/MainCard';
import LoadingCard from '@/components/LoadingCard';
import ConfirmDialog from '@/components/ConfirmDialog';
import {
Alert,
AlertDescription,
} from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
} from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
NativeSelect,
NativeSelectOption,
} from '@/components/ui/native-select';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
const kinds = {
poll: { label: 'Sondage', icon: BarChart3, color: 'text-cyan-700 bg-cyan-50 dark:text-cyan-300 dark:bg-cyan-950/40' },
task: {
label: 'Tâche',
icon: ClipboardList,
color:
'text-blue-700 bg-blue-50 dark:text-blue-300 dark:bg-blue-950/40',
},
info: {
label: 'Information',
icon: Info,
color:
'text-emerald-700 bg-emerald-50 dark:text-emerald-300 dark:bg-emerald-950/40',
},
memo: {
label: 'Mémo',
icon: StickyNote,
color:
'text-amber-700 bg-amber-50 dark:text-amber-300 dark:bg-amber-950/40',
},
sugg: {
label: 'Suggestion',
icon: MessageCircleQuestionMark,
color:
'text-violet-700 bg-violet-50 dark:text-violet-300 dark:bg-violet-950/40',
},
};
const emptyForm = () => ({
title: '',
content: '',
kind: 'memo',
poll_settings: emptyPoll(),
recipient_ids: [],
is_public: true,
});
const userLabel = (user) =>
user
? `${user.first_name} ${user.last_name}`.trim()
|| user.username
: 'Compte supprimé';
const dateLabel = (value) =>
new Date(value).toLocaleString(
'fr-FR',
{
dateStyle: 'medium',
timeStyle: 'short',
},
);
export default function NotesPage() {
const { user } = useAuth();
const canCreate = can(user, 'create_publication');
const [result, setResult] = useState({
count: 0,
results: [],
next: null,
previous: null,
});
const [
recipients,
setRecipients,
] = useState([]);
const [
recipientsError,
setRecipientsError,
] = useState('');
const [
filters,
setFilters,
] = useState({
kind: '',
scope: 'all',
state: 'all',
search: '',
});
const [
searchText,
setSearchText,
] = useState('');
const [page, setPage] = useState(1);
const [
revision,
setRevision,
] = useState(0);
const [
isLoading,
setIsLoading,
] = useState(true);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [
dialogOpen,
setDialogOpen,
] = useState(false);
const [
recipientPopoverOpen,
setRecipientPopoverOpen,
] = useState(false);
const [
editing,
setEditing,
] = useState(null);
const [
form,
setForm,
] = useState(emptyForm);
const [
formError,
setFormError,
] = useState('');
const [
saving,
setSaving,
] = useState(false);
const [
busyId,
setBusyId,
] = useState(null);
const mutationInFlight = useRef(false);
/*
* ============================
* Chargement des publications
* ============================
*/
useEffect(() => {
const controller =
new AbortController();
notesApi
.list(
{
...filters,
page,
},
{
signal: controller.signal,
},
)
.then((data) => {
if (!controller.signal.aborted) {
setResult(data);
setError('');
}
})
.catch((requestError) => {
if (
controller.signal.aborted
) {
return;
}
if (
requestError.response?.status ===
404
&& page > 1
) {
setPage(1);
} else {
setError(
requestError.message,
);
}
})
.finally(() => {
if (
!controller.signal.aborted
) {
setIsLoading(false);
}
});
return () =>
controller.abort();
}, [
filters,
page,
revision,
]);
/*
* ============================
* Annuaire
* ============================
*/
useEffect(() => {
const controller =
new AbortController();
notesApi
.recipients({
signal: controller.signal,
})
.then((data) => {
if (!controller.signal.aborted) {
setRecipients(data);
setRecipientsError('');
}
})
.catch((requestError) => {
if (
!controller.signal.aborted
) {
setRecipientsError(
requestError.message,
);
}
});
return () =>
controller.abort();
}, [revision]);
const reload = () => {
setIsLoading(true);
setRevision(
(value) => value + 1,
);
};
const changeFilters = (
changes,
) => {
setIsLoading(true);
setPage(1);
setFilters(
(current) => ({
...current,
...changes,
}),
);
};
/*
* ============================
* Éditeur
* ============================
*/
const openEditor = (
note = null,
) => {
if (!note && !canCreate) return;
setEditing(note);
setForm(
note
? {
title: note.title,
content: note.content,
kind: note.kind,
poll_settings: pollForm(note.poll),
recipient_ids:
note.recipients?.map(
(person) =>
person.id,
) ?? [],
is_public:
note.is_public !== false,
}
: emptyForm(),
);
setFormError('');
setRecipientPopoverOpen(false);
setDialogOpen(true);
};
/*
* ============================
* Destinataires
* ============================
*/
const toggleRecipient = (
id,
) => {
setForm(
(current) => {
const selected =
current.recipient_ids.includes(
id,
);
return {
...current,
recipient_ids: selected
? current.recipient_ids.filter(
(recipientId) =>
recipientId !== id,
)
: [
...current.recipient_ids,
id,
],
};
},
);
};
const recipientButtonLabel = () => {
const count =
form.recipient_ids.length;
if (count === 0) {
return form.is_public ? 'Toute l’équipe' : 'Aucun destinataire';
}
if (count === 1) {
const person =
recipients.find(
(recipient) =>
recipient.id
=== form.recipient_ids[0],
);
return person
? userLabel(person)
: '1 personne';
}
return `${count} personnes`;
};
/*
* ============================
* Public / privé
* ============================
*/
const changeVisibility = (
checked,
) => {
setForm((current) => ({
...current,
is_public: checked,
}));
setRecipientPopoverOpen(false);
setFormError('');
};
/*
* ============================
* Enregistrement
* ============================
*/
const save = async (event) => {
event.preventDefault();
if (!editing && !canCreate) { setFormError('La création de publications n’est pas activée pour ton compte.'); return; }
if (
mutationInFlight.current
) {
return;
}
if (
form.kind !== 'poll'
&& !form.is_public
&& form.recipient_ids.length === 0
) {
setFormError(
'Une publication privée doit avoir au moins un destinataire.',
);
return;
}
mutationInFlight.current = true;
setSaving(true);
setFormError('');
const payload = {
...form,
title:
form.title.trim(),
content:
form.content.trim(),
};
if (form.kind === 'poll') {
payload.is_public = true;
payload.recipient_ids = [];
payload.poll_settings = { ...form.poll_settings, options: form.poll_settings.options.map((option) => option.trim()) };
} else {
delete payload.poll_settings;
}
try {
if (editing) {
await notesApi.update(
editing.id,
payload,
);
} else {
await notesApi.create(
payload,
);
}
setDialogOpen(false);
setNotice(
editing
? 'Publication modifiée.'
: 'Publication créée.',
);
setPage(1);
reload();
} catch (requestError) {
setFormError(
requestError.message,
);
} finally {
mutationInFlight.current =
false;
setSaving(false);
}
};
/*
* ============================
* Suppression / complétion
* ============================
*/
const mutateNote = async (
note,
operation,
) => {
if (
mutationInFlight.current
) {
return;
}
mutationInFlight.current = true;
setBusyId(note.id);
setError('');
setNotice('');
try {
if (
operation === 'delete'
) {
await notesApi.remove(
note.id,
);
setNotice(
'Publication supprimée.',
);
setPage(1);
} else {
await notesApi.complete(
note.id,
!note.is_done,
);
setNotice(
note.is_done
? 'Tâche remise à faire.'
: 'Tâche terminée.',
);
}
reload();
} catch (requestError) {
setError(
requestError.message,
);
} finally {
mutationInFlight.current =
false;
setBusyId(null);
}
};
if (isLoading) {
return (
<LoadingCard
icon={ClipboardList}
title="Espace d'échanges"
message="Chargement des publications..."
/>
);
}
return (
<MainCard
className="max-md:[&>[data-slot=card-header]]:flex-wrap"
title="Espace d'échanges"
icon={ClipboardList}
headerContent={canCreate &&
<Button
size="sm"
onClick={() =>
openEditor()
}
>
<Plus className="size-4" />
<span>
Nouvelle publication
</span>
</Button>
}
>
<CardContent className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
{!canCreate && <p className="text-sm text-muted-foreground">Tu peux consulter les publications et répondre aux sondages. Pour publier, demande l’accès à un responsable.</p>}
{error && (
<Alert variant="destructive">
<AlertDescription>
{error}
</AlertDescription>
</Alert>
)}
{notice && (
<p
role="status"
className="text-sm text-muted-foreground"
>
{notice}
</p>
)}
{/* ===================== */}
{/* FILTRES */}
{/* ===================== */}
<div className="flex flex-wrap items-end gap-3">
<div className="grid min-w-36 flex-1 gap-1.5">
<Label htmlFor="note-scope">
Afficher
</Label>
<NativeSelect
id="note-scope"
className="w-full"
value={filters.scope}
onChange={(event) =>
changeFilters({
scope:
event.target.value,
})
}
>
<NativeSelectOption value="all">
Toute l’équipe
</NativeSelectOption>
<NativeSelectOption value="to_me">
Pour moi
</NativeSelectOption>
<NativeSelectOption value="mine">
Mes publications
</NativeSelectOption>
</NativeSelect>
</div>
<div className="grid min-w-36 flex-1 gap-1.5">
<Label htmlFor="note-kind-filter">
Type
</Label>
<NativeSelect
id="note-kind-filter"
className="w-full"
value={filters.kind}
onChange={(event) =>
changeFilters({
kind:
event.target.value,
state: 'all',
})
}
>
<NativeSelectOption value="">
Tous les types
</NativeSelectOption>
{Object.entries(
kinds,
).map(
([key, kind]) => (
<NativeSelectOption
key={key}
value={key}
>
{kind.label}
</NativeSelectOption>
),
)}
</NativeSelect>
</div>
<div className="grid min-w-36 flex-1 gap-1.5">
<Label htmlFor="note-state-filter">
État des tâches
</Label>
<NativeSelect
id="note-state-filter"
className="w-full"
value={filters.state}
disabled={[
'info',
'memo',
'sugg',
].includes(
filters.kind,
)}
onChange={(event) =>
changeFilters({
state:
event.target.value,
})
}
>
<NativeSelectOption value="all">
Tous les états
</NativeSelectOption>
<NativeSelectOption value="pending">
À faire
</NativeSelectOption>
<NativeSelectOption value="done">
Terminées
</NativeSelectOption>
</NativeSelect>
</div>
<form
className="flex min-w-48 flex-[2] items-end gap-2"
onSubmit={(event) => {
event.preventDefault();
changeFilters({
search:
searchText.trim(),
});
}}
>
<div className="grid flex-1 gap-1.5">
<Label htmlFor="note-search">
Rechercher
</Label>
<Input
id="note-search"
value={searchText}
onChange={(event) =>
setSearchText(
event.target.value,
)
}
placeholder="Titre ou contenu…"
/>
</div>
<Button
type="submit"
variant="outline"
size="icon"
aria-label="Rechercher"
>
<Search className="size-4" />
</Button>
</form>
<Button
variant="ghost"
size="icon"
disabled={isLoading}
onClick={reload}
aria-label="Actualiser"
>
<RotateCcw className="size-4" />
</Button>
</div>
{/* ===================== */}
{/* PUBLICATIONS */}
{/* ===================== */}
{error ? null
: result.results.length === 0 ? (
<div className="rounded-xl border border-dashed px-6 py-12 text-center">
<StickyNote className="mx-auto mb-3 size-8 text-muted-foreground" />
<p className="font-medium">
Aucune publication à afficher
</p>
<p className="mt-1 text-sm text-muted-foreground">
Créez une tâche,
partagez une information
ou ajustez les filtres.
</p>
<Button
className="mt-4"
variant="outline"
onClick={() =>
openEditor()
}
>
Créer une publication
</Button>
</div>
) : (
<>
<div className="grid items-start gap-4 md:grid-cols-2 xl:grid-cols-3">
{result.results.map(
(note) => {
const kind =
kinds[note.kind];
const Icon =
kind.icon;
return (
<Card
key={note.id}
className={
note.is_done
? 'bg-muted/30'
: ''
}
>
<CardContent className="space-y-3">
{/* Badges */}
<div className="flex flex-wrap items-center gap-2">
<Badge
className={
kind.color
}
>
<Icon className="size-3.5" />
{kind.label}
</Badge>
{note.kind
=== 'task' && (
<Badge
variant={
note.is_done
? 'secondary'
: 'outline'
}
>
{note.is_done
? 'Terminée'
: 'À faire'}
</Badge>
)}
<Badge variant="outline">
{note.is_public ? (
<Globe2 className="size-3.5" />
) : (
<LockKeyhole className="size-3.5" />
)}
{note.is_public ? 'Public' : 'Privé'}
</Badge>
</div>
{/* Titre */}
<h2
className={`break-words text-base font-semibold ${
note.is_done
? 'text-muted-foreground'
: ''
}`}
>
{note.title}
</h2>
{/* Contenu */}
{note.content && (
<p className="whitespace-pre-wrap break-words text-sm leading-relaxed">
{note.content}
</p>
)}
{note.kind === 'poll' && <PollCard note={note} onChange={(updated) => setResult((current) => ({ ...current, results: current.results.map((item) => item.id === updated.id ? updated : item) }))} />}
{/* Destinataires */}
<div className="flex items-start gap-2 text-sm">
<Users className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<span>
{note.recipients?.length
? `Pour ${note.recipients
.map(
userLabel,
)
.join(', ')}`
: note.is_public ? 'Pour toute l’équipe' : 'Aucun destinataire'}
</span>
</div>
{/* Métadonnées */}
<div className="space-y-1 text-xs text-muted-foreground">
<p>
Par{' '}
{userLabel(
note.author,
)}
{' · '}
<time
dateTime={
note.created_at
}
>
{dateLabel(
note.created_at,
)}
</time>
</p>
{note.completed_at && (
<p>
Terminée le{' '}
{dateLabel(
note.completed_at,
)}
</p>
)}
</div>
{/* Actions */}
{(note.can_complete
|| note.can_edit) && (
<div className="flex flex-wrap items-center gap-2 border-t pt-3">
{note.can_complete && (
<Button
size="sm"
variant={
note.is_done
? 'outline'
: 'default'
}
disabled={
busyId
!== null
}
onClick={() =>
mutateNote(
note,
'complete',
)
}
>
{note.is_done ? (
<RotateCcw className="size-3.5" />
) : (
<Check className="size-3.5" />
)}
{note.is_done
? 'Remettre à faire'
: 'Terminer'}
</Button>
)}
{note.can_edit && (
<>
<Button
variant="ghost"
size="icon-sm"
disabled={
busyId
!== null
}
aria-label={`Modifier ${note.title}`}
onClick={() =>
openEditor(
note,
)
}
>
<Pencil className="size-4" />
</Button>
<ConfirmDialog
title="Supprimer la publication"
description={`Supprimer « ${note.title} » ? Cette action est définitive.`}
confirmText="Supprimer"
onConfirm={() =>
mutateNote(
note,
'delete',
)
}
>
<Button
variant="ghost"
size="icon-sm"
disabled={
busyId
!== null
}
aria-label={`Supprimer ${note.title}`}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</ConfirmDialog>
</>
)}
</div>
)}
</CardContent>
</Card>
);
},
)}
</div>
{(result.next
|| result.previous) && (
<div className="flex items-center justify-center gap-3 py-2">
<Button
variant="outline"
disabled={
!result.previous
}
onClick={() => {
setIsLoading(true);
setPage(
(value) =>
value - 1,
);
}}
>
Précédent
</Button>
<span className="text-sm">
Page {page}
</span>
<Button
variant="outline"
disabled={
!result.next
}
onClick={() => {
setIsLoading(true);
setPage(
(value) =>
value + 1,
);
}}
>
Suivant
</Button>
</div>
)}
</>
)}
</CardContent>
{/* ========================= */}
{/* DIALOGUE */}
{/* ========================= */}
<Dialog
open={dialogOpen}
onOpenChange={(open) => {
if (!saving) {
setDialogOpen(open);
}
}}
>
<DialogContent
className="max-h-[90svh] overflow-y-auto sm:max-w-lg"
showCloseButton={!saving}
>
<form
onSubmit={save}
className="space-y-4"
>
<DialogHeader>
<DialogTitle>
{editing
? 'Modifier la publication'
: 'Nouvelle publication'}
</DialogTitle>
<DialogDescription>
{form.kind === 'poll'
? 'Le sondage est automatiquement public et accessible à toute l’équipe.'
: 'Choisis à qui tu t’adresses et qui peut voir la publication. Une publication publique reste visible par toute l’équipe, même lorsqu’elle est destinée à certaines personnes.'}
</DialogDescription>
</DialogHeader>
{formError && (
<Alert variant="destructive">
<AlertDescription>
{formError}
</AlertDescription>
</Alert>
)}
{form.kind !== 'poll' && recipientsError && (
<Alert variant="destructive">
<AlertDescription>
Annuaire indisponible :{' '}
{recipientsError}
</AlertDescription>
</Alert>
)}
{/* Type */}
<div className="grid gap-1.5">
<Label htmlFor="note-kind">
Type
</Label>
<NativeSelect
id="note-kind"
className="w-full"
value={form.kind}
disabled={saving || Boolean(editing?.poll?.is_locked)}
onChange={(event) => {
const kind = event.target.value;
setForm({
...form,
kind,
...(kind === 'poll' ? { is_public: true, recipient_ids: [] } : {}),
});
if (kind === 'poll') setRecipientPopoverOpen(false);
}}
>
{Object.entries(
kinds,
).map(
([key, kind]) => (
<NativeSelectOption
key={key}
value={key}
>
{kind.label}
</NativeSelectOption>
),
)}
</NativeSelect>
</div>
{/* Destinataires */}
<div className={form.kind === 'poll' ? 'hidden' : 'grid gap-1.5'}>
<Label>
Destinataires
</Label>
<Popover
open={
recipientPopoverOpen
}
onOpenChange={
setRecipientPopoverOpen
}
>
<PopoverTrigger render={
<Button
type="button"
variant="outline"
disabled={saving || Boolean(editing?.poll?.is_locked)}
className="w-full justify-between font-normal"
>
<span className="truncate">
{recipientButtonLabel()}
</span>
<ChevronDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
} />
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] p-0"
>
<div className="max-h-64 overflow-y-auto">
{/* Utilisateurs */}
<div className="p-2">
{recipients.length === 0 ? (
<p className="px-2 py-4 text-center text-sm text-muted-foreground">
Aucun utilisateur disponible
</p>
) : (
recipients.map(
(person) => {
const checked =
form.recipient_ids.includes(
person.id,
);
return (
<label
key={
person.id
}
className="flex w-full cursor-pointer items-center gap-3 rounded-md px-2 py-2 text-left text-sm hover:bg-accent"
>
<Checkbox
checked={
checked
}
disabled={saving || Boolean(editing?.poll?.is_locked)}
onCheckedChange={() => toggleRecipient(person.id)}
/>
<div className="min-w-0 flex-1">
<p className="truncate">
{userLabel(
person,
)}
</p>
{(
person.first_name
|| person.last_name
) && (
<p className="truncate text-xs text-muted-foreground">
@{person.username}
</p>
)}
</div>
</label>
);
},
)
)}
</div>
</div>
</PopoverContent>
</Popover>
<p className="text-xs text-muted-foreground">
{form.is_public
? 'Facultatif : sélectionnez les personnes concernées. Sans sélection, la publication s’adresse à toute l’équipe, y compris les futurs inscrits.'
: 'En privé, choisissez les personnes autorisées à voir la publication.'}
</p>
</div>
{/* Public / privé */}
<div className={form.kind === 'poll' ? 'hidden' : 'flex items-center justify-between gap-4 rounded-lg border p-3'}>
<div className="space-y-1">
<Label
htmlFor="note-public"
className="flex items-center gap-2"
>
{form.is_public ? (
<Globe2 className="size-4" />
) : (
<LockKeyhole className="size-4" />
)}
{form.is_public
? 'Public'
: 'Privé'}
</Label>
<p className="text-xs text-muted-foreground">
{form.is_public
? "Visible par toute l’équipe, y compris les futurs utilisateurs"
: form.recipient_ids.length
? 'Visible uniquement par vous et les destinataires'
: 'Sélectionnez au moins un destinataire'}
</p>
</div>
<Switch
id="note-public"
checked={
form.is_public
}
disabled={saving || Boolean(editing?.poll?.is_locked)}
onCheckedChange={
changeVisibility
}
/>
</div>
{/* Titre */}
<div className="grid gap-1.5">
<Label htmlFor="note-title">
Titre
</Label>
<Input
id="note-title"
required
maxLength={160}
value={form.title}
disabled={saving || Boolean(editing?.poll?.is_locked)}
onChange={(event) =>
setForm({
...form,
title:
event.target.value,
})
}
placeholder="Ex. Préparer la commande de demain"
/>
</div>
{/* Contenu */}
<div className="grid gap-1.5">
<Label htmlFor="note-content">
Détails{' '}
<span className="font-normal text-muted-foreground">
(facultatif)
</span>
</Label>
<Textarea
id="note-content"
rows={5}
maxLength={5000}
value={form.content}
disabled={saving}
onChange={(event) =>
setForm({
...form,
content:
event.target.value,
})
}
placeholder="Précisions, consignes ou informations utiles…"
/>
</div>
{form.kind === 'poll' && <PollEditor value={form.poll_settings} onChange={(poll_settings) => setForm((current) => ({ ...current, poll_settings }))} disabled={saving} locked={Boolean(editing?.poll?.is_locked)} />}
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
type="button"
variant="outline"
disabled={saving}
onClick={() =>
setDialogOpen(false)
}
>
Annuler
</Button>
<Button
type="submit"
disabled={
saving
|| !form.title.trim()
|| (
form.kind !== 'poll'
&& !form.is_public
&& form.recipient_ids
.length === 0
)
}
>
{saving
? 'Enregistrement…'
: editing
? 'Enregistrer'
: 'Publier'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</MainCard>
);
}