From 2a5d9bb0fd651baf4a843eaacad8718a2b791e8a Mon Sep 17 00:00:00 2001 From: Mathew Date: Wed, 1 Jul 2026 15:44:20 +0200 Subject: [PATCH] (Feat) V2 --- .claude/settings.json | 11 +- Setp.txt | 5 + src/components/TaskModal.tsx | 51 ++++--- src/lib/admin.ts | 11 +- src/lib/types.ts | 9 +- src/pages/Admin.tsx | 185 ++++++++++++++++++++++-- src/pages/Kanban.tsx | 18 ++- supabase/functions/admin-users/index.ts | 33 ++++- supabase/schema.sql | 3 +- supabase/update_all.sql | 11 ++ 10 files changed, 285 insertions(+), 52 deletions(-) create mode 100644 Setp.txt diff --git a/.claude/settings.json b/.claude/settings.json index 31f3acc..72f11d5 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -3,7 +3,16 @@ "allow": [ "Bash(git check-ignore *)", "Bash(ls src-tauri/.gitignore)", - "Bash(cat src-tauri/.gitignore)" + "Bash(cat src-tauri/.gitignore)", + "Bash(git push *)", + "PowerShell(node -v)", + "PowerShell(npm -v)", + "PowerShell(rustc --version)", + "PowerShell(cargo --version)", + "PowerShell(npm run tauri:build)", + "PowerShell(Get-ChildItem -Path \"src-tauri\\\\target\\\\release\\\\app.exe\",\"src-tauri\\\\target\\\\release\\\\bundle\\\\msi\",\"src-tauri\\\\target\\\\release\\\\bundle\\\\nsis\" -File | Select-Object Name, @{N='MB';E={[math]::Round\\($_.Length/1MB,1\\)}} | Format-Table -AutoSize)", + "PowerShell(npm run typecheck)", + "PowerShell(Get-ChildItem -Path \"src-tauri\\\\target\\\\release\\\\app.exe\",\"src-tauri\\\\target\\\\release\\\\bundle\\\\msi\",\"src-tauri\\\\target\\\\release\\\\bundle\\\\nsis\" -File | Select-Object Name, @{N='MB';E={[math]::Round\\($_.Length/1MB,1\\)}}, LastWriteTime | Format-Table -AutoSize)" ] } } diff --git a/Setp.txt b/Setp.txt new file mode 100644 index 0000000..19186e6 --- /dev/null +++ b/Setp.txt @@ -0,0 +1,5 @@ +Sur ton autre PC (fixe/portable) +Première fois (si le projet n'y est pas encore) : + +npm install +npm run tauri:dev \ No newline at end of file diff --git a/src/components/TaskModal.tsx b/src/components/TaskModal.tsx index 8acd88a..72ce7be 100644 --- a/src/components/TaskModal.tsx +++ b/src/components/TaskModal.tsx @@ -17,6 +17,7 @@ import { TASK_STATUS_LABELS, PRIORITY_LABELS, memberPoles, + taskPoles, formatMinutes } from '../lib/types' import { Modal, Select, Avatar, PoleChips } from './ui' @@ -42,8 +43,11 @@ export default function TaskModal({ const [description, setDescription] = useState(task?.description ?? '') const [status, setStatus] = useState(task?.status ?? defaultStatus) const [priority, setPriority] = useState(task?.priority ?? 'medium') - const [pole, setPole] = useState(task?.pole ?? '') + const [poles, setPoles] = useState(task ? taskPoles(task) : []) const [assignees, setAssignees] = useState(task?.assignee_ids ?? []) + + const togglePole = (p: Pole): void => + setPoles((cur) => (cur.includes(p) ? cur.filter((x) => x !== p) : [...cur, p])) const [dueDate, setDueDate] = useState(task?.due_date ?? '') const [busy, setBusy] = useState(false) @@ -59,13 +63,15 @@ export default function TaskModal({ description: description.trim() || null, status, priority, - pole: pole || null, + pole: poles[0] ?? null, // pôle principal (compat) + poles, assignee_ids: assignees, due_date: dueDate || null } - // Notifier le pôle si on lui confie la tâche (création, ou pôle modifié). - const poleChanged = pole && (!task || task.pole !== pole) + // Notifier chaque pôle nouvellement confié (création, ou pôle ajouté). + const before = task ? taskPoles(task) : [] + const newPoles = poles.filter((p) => !before.includes(p)) let entityId = task?.id ?? null if (task) { @@ -79,17 +85,19 @@ export default function TaskModal({ entityId = (data as { id: string } | null)?.id ?? null } - if (poleChanged && pole && entityId) { - await notifyPole({ - projectId, - pole, - kind: 'task', - title: 'Nouvelle tâche', - body: title.trim(), - entityId, - actorId: profile?.id ?? null, - members - }) + if (entityId) { + for (const pole of newPoles) { + await notifyPole({ + projectId, + pole, + kind: 'task', + title: 'Nouvelle tâche', + body: title.trim(), + entityId, + actorId: profile?.id ?? null, + members + }) + } } await onSaved() @@ -130,13 +138,12 @@ export default function TaskModal({ onChange={(e) => setDescription(e.target.value)} /> - {/* Pôle — sélection par pastilles (clique pour choisir, reclique pour retirer) */} - + {/* Pôles — sélection multiple par pastilles (clique pour ajouter/retirer) */} +
- setPole((cur) => (cur === p ? '' : p))} - /> +
@@ -182,7 +189,7 @@ export default function TaskModal({ )} {members.map((m) => { const on = assignees.includes(m.id) - const inPole = pole && memberPoles(m).includes(pole) + const inPole = poles.some((p) => memberPoles(m).includes(p)) return ( - +
+ + +
) @@ -172,6 +183,18 @@ export default function Admin(): JSX.Element { }} /> )} + + {editing && ( + setEditing(null)} + onSaved={async () => { + setEditing(null) + await load() + toast('Compte mis à jour.', 'success') + }} + /> + )}
) } @@ -267,3 +290,137 @@ function CreateUserModal({ ) } + +function EditUserModal({ + user, + onClose, + onSaved +}: { + user: AdminUser + onClose: () => void + onSaved: () => Promise +}): JSX.Element { + const { toast } = useFeedback() + const [fullName, setFullName] = useState(user.full_name) + const [email, setEmail] = useState(user.email) + const [password, setPassword] = useState('') + const [roles, setRoles] = useState(memberPoles(user)) + const [avatarUrl, setAvatarUrl] = useState(user.avatar_url ?? null) + const [uploading, setUploading] = useState(false) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const fileRef = useRef(null) + + const toggleRole = (pole: MemberRole): void => + setRoles((cur) => (cur.includes(pole) ? cur.filter((r) => r !== pole) : [...cur, pole])) + + const pickPhoto = async (e: React.ChangeEvent): Promise => { + const file = e.target.files?.[0] + if (!file) return + if (!file.type.startsWith('image/')) { + toast('Choisis un fichier image.', 'error') + return + } + setUploading(true) + try { + setAvatarUrl(await uploadAvatar(file, user.id)) + } catch (err) { + toast((err as Error).message, 'error') + } finally { + setUploading(false) + } + } + + const submit = async (): Promise => { + setError('') + if (!fullName.trim()) { + setError('Le nom ne peut pas être vide.') + return + } + if (!email.trim()) { + setError('Un email valide est requis.') + return + } + if (password && password.length < 6) { + setError('Le mot de passe doit faire au moins 6 caractères.') + return + } + setBusy(true) + try { + const patch: Parameters[1] = { roles } + if (fullName.trim() !== user.full_name) patch.full_name = fullName.trim() + if (email.trim() !== user.email) patch.email = email.trim() + if (password) patch.password = password + if ((avatarUrl ?? null) !== (user.avatar_url ?? null)) patch.avatar_url = avatarUrl + await adminUpdateUser(user.id, patch) + await onSaved() + } catch (e) { + setError((e as Error).message) + setBusy(false) + } + } + + return ( + + +
+
+ + {uploading && ( +
+ +
+ )} +
+ + + {avatarUrl && ( + + )} +
+ + + setFullName(e.target.value)} + /> + + setEmail(e.target.value)} + /> + + setPassword(e.target.value)} + /> + +
+ +
+ + {error &&

{error}

} + + +
+ ) +} diff --git a/src/pages/Kanban.tsx b/src/pages/Kanban.tsx index 1725a55..9e6dd0d 100644 --- a/src/pages/Kanban.tsx +++ b/src/pages/Kanban.tsx @@ -8,12 +8,14 @@ import { useMembers } from '../lib/useMembers' import { Task, TaskStatus, + Pole, TimeLog, TASK_STATUS_ORDER, TASK_STATUS_LABELS, POLE_LABELS, POLE_ORDER, poleColor, + taskPoles, formatMinutes } from '../lib/types' import { Avatar, PriorityBadge, Select } from '../components/ui' @@ -122,7 +124,7 @@ export default function Kanban(): JSX.Element { tasks.filter( (t) => (!fAssignee || (t.assignee_ids ?? []).includes(fAssignee)) && - (!fPole || t.pole === fPole) + (!fPole || taskPoles(t).includes(fPole as Pole)) ), [tasks, fAssignee, fPole] ) @@ -244,16 +246,16 @@ export default function Kanban(): JSX.Element { )} - ) : ( - - )} + ) : null} - {t.pole && ( + {taskPoles(t).length > 0 && (
- - {POLE_LABELS[t.pole]} - + {taskPoles(t).map((p) => ( + + {POLE_LABELS[p]} + + ))}
)} diff --git a/supabase/functions/admin-users/index.ts b/supabase/functions/admin-users/index.ts index dd2c5ed..630f62c 100644 --- a/supabase/functions/admin-users/index.ts +++ b/supabase/functions/admin-users/index.ts @@ -81,6 +81,8 @@ Deno.serve(async (req) => { role: pr.role ?? 'other', roles, is_admin: pr.is_admin ?? false, + avatar_color: pr.avatar_color ?? '#7c5cff', + avatar_url: pr.avatar_url ?? null, created_at: pr.created_at ?? u.created_at } }) @@ -131,14 +133,34 @@ Deno.serve(async (req) => { } case 'update': { - const { id, full_name, role, roles, is_admin } = p + const { id, full_name, email, password, role, roles, is_admin, avatar_url } = p if (!id) return json({ error: 'Identifiant manquant.' }, 400) if (id === user.id && is_admin === false) return json({ error: 'Tu ne peux pas retirer ton propre statut admin.' }, 400) + // --- a) Champs du compte auth (email / mot de passe / métadonnées) --- + const authPatch: Record = {} + if (email !== undefined && String(email).trim()) { + authPatch.email = String(email).trim() + authPatch.email_confirm = true // évite le mail de confirmation + } + if (password !== undefined && String(password) !== '') { + if (String(password).length < 6) + return json({ error: 'Le mot de passe doit faire au moins 6 caractères.' }, 400) + authPatch.password = String(password) + } + if (full_name !== undefined) + authPatch.user_metadata = { full_name, role: Array.isArray(roles) ? roles[0] : role } + if (Object.keys(authPatch).length > 0) { + const { error: authErr } = await admin.auth.admin.updateUserById(id, authPatch) + if (authErr) return json({ error: authErr.message }, 400) + } + + // --- b) Champs du profil (nom, pôles, admin) --- const patch: Record = {} if (full_name !== undefined) patch.full_name = full_name if (is_admin !== undefined) patch.is_admin = !!is_admin + if (avatar_url !== undefined) patch.avatar_url = avatar_url || null // Gestion des pôles : roles = liste complète, role = principal (roles[0]). if (Array.isArray(roles)) { const primary = roles[0] ?? 'other' @@ -148,10 +170,13 @@ Deno.serve(async (req) => { patch.role = role patch.roles = [role] } - if (Object.keys(patch).length === 0) return json({ error: 'Rien à modifier.' }, 400) + if (Object.keys(patch).length > 0) { + const { error } = await admin.from('profiles').update(patch).eq('id', id) + if (error) return json({ error: error.message }, 400) + } - const { error } = await admin.from('profiles').update(patch).eq('id', id) - if (error) return json({ error: error.message }, 400) + if (Object.keys(authPatch).length === 0 && Object.keys(patch).length === 0) + return json({ error: 'Rien à modifier.' }, 400) return json({ ok: true }) } diff --git a/supabase/schema.sql b/supabase/schema.sql index 17f31b3..07bfadd 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -56,7 +56,8 @@ create table if not exists public.tasks ( description text, status task_status not null default 'todo', priority priority_level not null default 'medium', - pole member_role, + pole member_role, -- pôle principal (= poles[0], compat) + poles member_role[] not null default '{}', -- tous les pôles concernés par la tâche assignee_ids uuid[] not null default '{}', due_date date, position double precision not null default 0, diff --git a/supabase/update_all.sql b/supabase/update_all.sql index a1bbdbd..70805f3 100644 --- a/supabase/update_all.sql +++ b/supabase/update_all.sql @@ -143,3 +143,14 @@ create policy avatars_update on storage.objects for update to authenticated using (bucket_id = 'avatars'); create policy avatars_delete on storage.objects for delete to authenticated using (bucket_id = 'avatars'); + +-- --------------------------------------------------------------------- +-- v7 — Plusieurs pôles par tâche +-- --------------------------------------------------------------------- +alter table public.tasks + add column if not exists poles member_role[] not null default '{}'; + +-- Reprend le pôle unique existant dans le tableau (si présent). +update public.tasks + set poles = array[pole] + where pole is not null and poles = '{}';