2026-09-25 11:02:14 +02:00

2590 lines
39 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 {
Check,
ChevronDown,
ClipboardList,
Globe2,
Info,
LockKeyhole,
MessageCircleQuestionMark,
Pencil,
Plus,
RotateCcw,
Search,
StickyNote,
Trash2,
Users,
} from 'lucide-react';
import { notesApi } from '../api/notes';
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,
DialogFooter,
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 = {
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',
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 [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,
) => {
setEditing(note);
setForm(
note
? {
title: note.title,
content: note.content,
kind: note.kind,
recipient_ids:
note.recipients?.map(
(person) =>
person.id,
) ?? [],
is_public:
note.is_public !== false,
}
: emptyForm(),
);
setFormError('');
setRecipientPopoverOpen(false);
setDialogOpen(true);
};
/*
* ============================
* Destinataires
* ============================
*/
const allRecipientIds =
recipients.map(
(person) => person.id,
);
const allRecipientsSelected =
recipients.length > 0
&& recipients.every(
(person) =>
form.recipient_ids.includes(
person.id,
),
);
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 toggleAllRecipients = () => {
/*
* "Tout le monde" n'est disponible
* que pour une publication publique.
*/
if (!form.is_public) {
return;
}
setForm(
(current) => ({
...current,
recipient_ids:
allRecipientsSelected
? []
: allRecipientIds,
}),
);
};
const recipientButtonLabel = () => {
const count =
form.recipient_ids.length;
/*
* On n'affiche "Tout le monde"
* que pour une publication publique.
*
* En privé, même si tous les comptes
* ont été sélectionnés manuellement,
* on affiche le nombre de personnes.
*/
if (
form.is_public
&& count > 0
&& allRecipientsSelected
) {
return 'Tout le monde';
}
if (count === 0) {
return '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) => {
/*
* Si "Tout le monde" était sélectionné
* en mode public et que l'on passe
* en privé, on vide les destinataires.
*
* Cela oblige à choisir explicitement
* les personnes autorisées à voir
* la publication privée.
*/
const everyoneWasSelected =
recipients.length > 0
&& recipients.every(
(person) =>
current.recipient_ids.includes(
person.id,
),
);
return {
...current,
is_public: checked,
recipient_ids:
!checked
&& everyoneWasSelected
? []
: current.recipient_ids,
};
},
);
setFormError('');
};
/*
* ============================
* Enregistrement
* ============================
*/
const save = async (event) => {
event.preventDefault();
if (
mutationInFlight.current
) {
return;
}
if (
!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(),
};
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
title="Espace d'échanges"
icon={ClipboardList}
headerContent={
<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">
{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>
)}
{!note.is_public && (
<Badge variant="outline">
<LockKeyhole className="size-3.5" />
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>
)}
{/* 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(', ')}`
: '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>
Sélectionnez les personnes
concernées et choisissez la
visibilité de la publication.
</DialogDescription>
</DialogHeader>
{formError && (
<Alert variant="destructive">
<AlertDescription>
{formError}
</AlertDescription>
</Alert>
)}
{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}
onChange={(event) =>
setForm({
...form,
kind:
event.target.value,
})
}
>
{Object.entries(
kinds,
).map(
([key, kind]) => (
<NativeSelectOption
key={key}
value={key}
>
{kind.label}
</NativeSelectOption>
),
)}
</NativeSelect>
</div>
{/* Destinataires */}
<div className="grid gap-1.5">
<Label>
Destinataires
</Label>
<Popover
open={
recipientPopoverOpen
}
onOpenChange={
setRecipientPopoverOpen
}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
disabled={saving}
className="w-full justify-between font-normal"
>
<span className="truncate">
{recipientButtonLabel()}
</span>
<ChevronDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] p-0"
>
<div className="max-h-64 overflow-y-auto">
{/*
* "Tout le monde" n'est proposé
* que pour les publications publiques.
*/}
{form.is_public && (
<div className="sticky top-0 z-10 border-b bg-popover p-2">
<button
type="button"
className="flex w-full items-center gap-3 rounded-md px-2 py-2 text-left text-sm hover:bg-accent"
onClick={
toggleAllRecipients
}
>
<Checkbox
checked={
allRecipientsSelected
}
tabIndex={-1}
className="pointer-events-none"
/>
<Users className="size-4 text-muted-foreground" />
<span className="font-medium">
Tout le monde
</span>
</button>
</div>
)}
{/* 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 (
<button
key={
person.id
}
type="button"
className="flex w-full items-center gap-3 rounded-md px-2 py-2 text-left text-sm hover:bg-accent"
onClick={() =>
toggleRecipient(
person.id,
)
}
>
<Checkbox
checked={
checked
}
tabIndex={-1}
className="pointer-events-none"
/>
<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>
</button>
);
},
)
)}
</div>
</div>
</PopoverContent>
</Popover>
{!form.is_public && (
<p className="text-xs text-muted-foreground">
En privé, choisissez
explicitement les personnes
autorisées à voir la publication.
</p>
)}
</div>
{/* Public / privé */}
<div className="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"
: 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}
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}
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>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={saving}
onClick={() =>
setDialogOpen(false)
}
>
Annuler
</Button>
<Button
type="submit"
disabled={
saving
|| !form.title.trim()
|| (
!form.is_public
&& form.recipient_ids
.length === 0
)
}
>
{saving
? 'Enregistrement…'
: editing
? 'Enregistrer'
: 'Publier'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</MainCard>
);
}