diff --git a/backend/notes/permissions.py b/backend/notes/permissions.py index bab54da..2434610 100644 --- a/backend/notes/permissions.py +++ b/backend/notes/permissions.py @@ -26,9 +26,10 @@ def can_complete_note(user, note): return False return ( - is_admin(user) + (note.is_public and not note.recipients.exists()) + or is_admin(user) or note.author_id == user.id or note.recipients.filter( pk=user.pk, ).exists() - ) \ No newline at end of file + ) diff --git a/backend/notes/serializers.py b/backend/notes/serializers.py index b7fa22b..c46b000 100644 --- a/backend/notes/serializers.py +++ b/backend/notes/serializers.py @@ -145,4 +145,4 @@ class NoteSerializer(serializers.ModelSerializer): class NoteCompletionSerializer( serializers.Serializer, ): - is_done = serializers.BooleanField() \ No newline at end of file + is_done = serializers.BooleanField() diff --git a/backend/notes/views.py b/backend/notes/views.py index b2494ec..42fbf1f 100644 --- a/backend/notes/views.py +++ b/backend/notes/views.py @@ -113,7 +113,8 @@ class NoteListView( elif scope == 'to_me': notes = notes.filter( - recipients=self.request.user, + Q(is_public=True, recipients__isnull=True) + | Q(recipients=self.request.user), ) elif scope != 'all': @@ -317,4 +318,4 @@ class NoteRecipientListView( 'last_name', 'username', ) - ) \ No newline at end of file + ) diff --git a/frontend/src/pages/NotesPage.jsx b/frontend/src/pages/NotesPage.jsx index 0fd8349..6a5e8c7 100644 --- a/frontend/src/pages/NotesPage.jsx +++ b/frontend/src/pages/NotesPage.jsx @@ -1,1090 +1,477 @@ 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'; - + 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) => { - - /* - - * 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, - - }; - - }, - - ); - + setForm((current) => ({ + ...current, + is_public: checked, + })); + setRecipientPopoverOpen(false); 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 ( ); } - return ( - - openEditor() - } - > - - - Nouvelle publication - - - } - > - - {error && ( - - - {error} - - - )} - {notice && ( -

- {notice} -

- )} - {/* ===================== */} - {/* FILTRES */} - {/* ===================== */} -
-
- - - changeFilters({ - scope: - event.target.value, - }) - } - > - - Toute l’équipe - - - Pour moi - - - Mes publications - - -
-
- - - changeFilters({ - kind: - event.target.value, - state: 'all', - }) - } - > - - Tous les types - - {Object.entries( - kinds, - ).map( - ([key, kind]) => ( - - {kind.label} - - ), - )} - -
-
- - - changeFilters({ - state: - event.target.value, - }) - } - > - - Tous les états - - - À faire - - - Terminées - - -
-
{ - event.preventDefault(); - changeFilters({ - search: - searchText.trim(), - }); - }} - > -
- - - setSearchText( - event.target.value, - ) - } - placeholder="Titre ou contenu…" - /> -
- -
- -
- {/* ===================== */} - {/* PUBLICATIONS */} - {/* ===================== */} - {error ? null - : result.results.length === 0 ? ( -
- -

- Aucune publication à afficher -

-

- Créez une tâche, - partagez une information - ou ajustez les filtres. -

- -
- ) : ( - <> -
- {result.results.map( - (note) => { - const kind = - kinds[note.kind]; - const Icon = - kind.icon; - return ( - - - {/* Badges */} -
- - - {kind.label} - - {note.kind - === 'task' && ( - - {note.is_done - ? 'Terminée' - : 'À faire'} - - )} - - {!note.is_public && ( - - - + + {note.is_public ? ( + + ) : ( - - Privé - - - - )} - + )} + {note.is_public ? 'Public' : 'Privé'} +
- {/* Titre */} -

- {note.title} -

- {/* Contenu */} - {note.content && ( -

- {note.content} -

- )} - {/* Destinataires */} -
- - - {note.recipients?.length - ? `Pour ${note.recipients - .map( - userLabel, - ) - .join(', ')}` - - : 'Aucun destinataire'} - + : note.is_public ? 'Pour toute l’équipe' : 'Aucun destinataire'} -
- {/* Métadonnées */} -
-

- Par{' '} - {userLabel( - note.author, - )} - {' · '} - -

- {note.completed_at && ( -

- Terminée le{' '} - {dateLabel( - note.completed_at, - )} -

- )} -
- {/* Actions */} - {(note.can_complete - || note.can_edit) && ( -
- {note.can_complete && ( - - )} - {note.can_edit && ( - <> - - - mutateNote( - note, - 'delete', - ) - } - > - - - - )} -
- )} -
-
- ); - }, - )} -
- {(result.next - || result.previous) && ( -
- - - Page {page} - - -
- )} - - )} -
- {/* ========================= */} - {/* DIALOGUE */} - {/* ========================= */} - { - if (!saving) { - setDialogOpen(open); - } - }} - > - -
- - - {editing - ? 'Modifier la publication' - : 'Nouvelle publication'} - - - - Sélectionnez les personnes - - concernées et choisissez la - - visibilité de la publication. - + Choisissez les personnes concernées, puis la visibilité. + Une publication publique reste visible par toute l’équipe, + même lorsqu’elle est destinée à certaines personnes. - - {formError && ( - - - {formError} - - - )} - {recipientsError && ( - - - Annuaire indisponible :{' '} - {recipientsError} - - - )} - {/* Type */} -
- - - setForm({ - ...form, - kind: - event.target.value, - }) - } - > - {Object.entries( - kinds, - ).map( - ([key, kind]) => ( - - {kind.label} - - ), - )} - -
- {/* Destinataires */} -
- - - - - - -
- - {/* - - * "Tout le monde" n'est proposé - - * que pour les publications publiques. - - */} - - {form.is_public && ( - -
- - - -
- - )} - {/* Utilisateurs */} -
- {recipients.length === 0 ? ( -

- Aucun utilisateur disponible -

- ) : ( - recipients.map( - (person) => { - const checked = - form.recipient_ids.includes( - person.id, - ); - return ( - - - + ); - }, - ) - )} -
-
-
-
- - {!form.is_public && ( - -

- - En privé, choisissez - - explicitement les personnes - - autorisées à voir la publication. - -

- - )} - +

+ {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.'} +

- {/* Public / privé */} -
-
- -

- {form.is_public - - ? "Visible par toute l'équipe" - + ? "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'} -

-
- -
- {/* Titre */} -
- - - setForm({ - ...form, - title: - event.target.value, - }) - } - placeholder="Ex. Préparer la commande de demain" - /> -
- {/* Contenu */} -
- -