1225 lines
37 KiB
JavaScript
1225 lines
37 KiB
JavaScript
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>
|
||
);
|
||
}
|