(Feat) V2

This commit is contained in:
2026-07-01 15:44:20 +02:00
parent 9789100334
commit 2a5d9bb0fd
10 changed files with 285 additions and 52 deletions
+10 -1
View File
@@ -3,7 +3,16 @@
"allow": [ "allow": [
"Bash(git check-ignore *)", "Bash(git check-ignore *)",
"Bash(ls src-tauri/.gitignore)", "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)"
] ]
} }
} }
+5
View File
@@ -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
+29 -22
View File
@@ -17,6 +17,7 @@ import {
TASK_STATUS_LABELS, TASK_STATUS_LABELS,
PRIORITY_LABELS, PRIORITY_LABELS,
memberPoles, memberPoles,
taskPoles,
formatMinutes formatMinutes
} from '../lib/types' } from '../lib/types'
import { Modal, Select, Avatar, PoleChips } from './ui' import { Modal, Select, Avatar, PoleChips } from './ui'
@@ -42,8 +43,11 @@ export default function TaskModal({
const [description, setDescription] = useState(task?.description ?? '') const [description, setDescription] = useState(task?.description ?? '')
const [status, setStatus] = useState<TaskStatus>(task?.status ?? defaultStatus) const [status, setStatus] = useState<TaskStatus>(task?.status ?? defaultStatus)
const [priority, setPriority] = useState<Priority>(task?.priority ?? 'medium') const [priority, setPriority] = useState<Priority>(task?.priority ?? 'medium')
const [pole, setPole] = useState<Pole | ''>(task?.pole ?? '') const [poles, setPoles] = useState<Pole[]>(task ? taskPoles(task) : [])
const [assignees, setAssignees] = useState<string[]>(task?.assignee_ids ?? []) const [assignees, setAssignees] = useState<string[]>(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 [dueDate, setDueDate] = useState(task?.due_date ?? '')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
@@ -59,13 +63,15 @@ export default function TaskModal({
description: description.trim() || null, description: description.trim() || null,
status, status,
priority, priority,
pole: pole || null, pole: poles[0] ?? null, // pôle principal (compat)
poles,
assignee_ids: assignees, assignee_ids: assignees,
due_date: dueDate || null due_date: dueDate || null
} }
// Notifier le pôle si on lui confie la tâche (création, ou pôle modifié). // Notifier chaque pôle nouvellement confié (création, ou pôle ajouté).
const poleChanged = pole && (!task || task.pole !== pole) const before = task ? taskPoles(task) : []
const newPoles = poles.filter((p) => !before.includes(p))
let entityId = task?.id ?? null let entityId = task?.id ?? null
if (task) { if (task) {
@@ -79,17 +85,19 @@ export default function TaskModal({
entityId = (data as { id: string } | null)?.id ?? null entityId = (data as { id: string } | null)?.id ?? null
} }
if (poleChanged && pole && entityId) { if (entityId) {
await notifyPole({ for (const pole of newPoles) {
projectId, await notifyPole({
pole, projectId,
kind: 'task', pole,
title: 'Nouvelle tâche', kind: 'task',
body: title.trim(), title: 'Nouvelle tâche',
entityId, body: title.trim(),
actorId: profile?.id ?? null, entityId,
members actorId: profile?.id ?? null,
}) members
})
}
} }
await onSaved() await onSaved()
@@ -130,13 +138,12 @@ export default function TaskModal({
onChange={(e) => setDescription(e.target.value)} 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) */}
<label className="label">Pôle</label> <label className="label">
Pôle(s) {poles.length > 0 && <span className="text-subtle">· {poles.length}</span>}
</label>
<div className="mb-3"> <div className="mb-3">
<PoleChips <PoleChips selected={poles} onToggle={togglePole} />
selected={pole ? [pole] : []}
onToggle={(p) => setPole((cur) => (cur === p ? '' : p))}
/>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -182,7 +189,7 @@ export default function TaskModal({
)} )}
{members.map((m) => { {members.map((m) => {
const on = assignees.includes(m.id) const on = assignees.includes(m.id)
const inPole = pole && memberPoles(m).includes(pole) const inPole = poles.some((p) => memberPoles(m).includes(p))
return ( return (
<button <button
key={m.id} key={m.id}
+10 -1
View File
@@ -10,6 +10,8 @@ export interface AdminUser {
role: MemberRole role: MemberRole
roles: MemberRole[] roles: MemberRole[]
is_admin: boolean is_admin: boolean
avatar_color: string
avatar_url: string | null
created_at: string created_at: string
} }
@@ -52,5 +54,12 @@ export const adminDeleteUser = (id: string): Promise<{ ok: true }> => call('dele
export const adminUpdateUser = ( export const adminUpdateUser = (
id: string, id: string,
patch: { full_name?: string; roles?: MemberRole[]; is_admin?: boolean } patch: {
full_name?: string
email?: string
password?: string
roles?: MemberRole[]
is_admin?: boolean
avatar_url?: string | null
}
): Promise<{ ok: true }> => call('update', { id, ...patch }) ): Promise<{ ok: true }> => call('update', { id, ...patch })
+8 -1
View File
@@ -150,6 +150,12 @@ export function memberPoles(p: { role?: MemberRole; roles?: MemberRole[] }): Pol
return p.role ? [p.role] : [] return p.role ? [p.role] : []
} }
/** Liste des pôles d'une tâche (avec repli sur l'ancien pôle unique). */
export function taskPoles(t: { pole?: Pole | null; poles?: Pole[] }): Pole[] {
if (t.poles && t.poles.length) return t.poles
return t.pole ? [t.pole] : []
}
export interface Project { export interface Project {
id: string id: string
name: string name: string
@@ -165,7 +171,8 @@ export interface Task {
description: string | null description: string | null
status: TaskStatus status: TaskStatus
priority: Priority priority: Priority
pole: Pole | null pole: Pole | null // pôle principal (= poles[0]) conservé pour compat
poles: Pole[] // tous les pôles concernés par la tâche
assignee_ids: string[] assignee_ids: string[]
due_date: string | null due_date: string | null
position: number position: number
+171 -14
View File
@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { ShieldCheck, UserPlus, Trash2, Loader2, Crown, Mail } from 'lucide-react' import { ShieldCheck, UserPlus, Trash2, Loader2, Crown, Mail, Pencil, Camera } from 'lucide-react'
import { useApp } from '../lib/AppContext' import { useApp } from '../lib/AppContext'
import { useFeedback } from '../lib/feedback' import { useFeedback } from '../lib/feedback'
import { MemberRole, memberPoles } from '../lib/types' import { MemberRole, Profile, memberPoles } from '../lib/types'
import { import {
AdminUser, AdminUser,
adminListUsers, adminListUsers,
@@ -10,7 +10,8 @@ import {
adminDeleteUser, adminDeleteUser,
adminUpdateUser adminUpdateUser
} from '../lib/admin' } from '../lib/admin'
import { Modal, EmptyState, PoleChips } from '../components/ui' import { uploadAvatar } from '../lib/avatar'
import { Avatar, Modal, EmptyState, PoleChips } from '../components/ui'
import PageHeader from '../components/PageHeader' import PageHeader from '../components/PageHeader'
export default function Admin(): JSX.Element { export default function Admin(): JSX.Element {
@@ -20,6 +21,7 @@ export default function Admin(): JSX.Element {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [editing, setEditing] = useState<AdminUser | null>(null)
const load = async (): Promise<void> => { const load = async (): Promise<void> => {
setLoading(true) setLoading(true)
@@ -142,16 +144,25 @@ export default function Admin(): JSX.Element {
</button> </button>
</td> </td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
<button <div className="flex items-center justify-end gap-3">
onClick={() => remove(u)} <button
disabled={isMe} onClick={() => setEditing(u)}
title={isMe ? 'Impossible de supprimer son propre compte' : 'Supprimer'} title="Modifier le compte"
className={`text-muted hover:text-rose-600 ${ className="text-muted hover:text-accent"
isMe ? 'cursor-not-allowed opacity-40' : '' >
}`} <Pencil size={16} />
> </button>
<Trash2 size={16} /> <button
</button> onClick={() => remove(u)}
disabled={isMe}
title={isMe ? 'Impossible de supprimer son propre compte' : 'Supprimer'}
className={`text-muted hover:text-rose-600 ${
isMe ? 'cursor-not-allowed opacity-40' : ''
}`}
>
<Trash2 size={16} />
</button>
</div>
</td> </td>
</tr> </tr>
) )
@@ -172,6 +183,18 @@ export default function Admin(): JSX.Element {
}} }}
/> />
)} )}
{editing && (
<EditUserModal
user={editing}
onClose={() => setEditing(null)}
onSaved={async () => {
setEditing(null)
await load()
toast('Compte mis à jour.', 'success')
}}
/>
)}
</div> </div>
) )
} }
@@ -267,3 +290,137 @@ function CreateUserModal({
</Modal> </Modal>
) )
} }
function EditUserModal({
user,
onClose,
onSaved
}: {
user: AdminUser
onClose: () => void
onSaved: () => Promise<void>
}): 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<MemberRole[]>(memberPoles(user))
const [avatarUrl, setAvatarUrl] = useState<string | null>(user.avatar_url ?? null)
const [uploading, setUploading] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const fileRef = useRef<HTMLInputElement>(null)
const toggleRole = (pole: MemberRole): void =>
setRoles((cur) => (cur.includes(pole) ? cur.filter((r) => r !== pole) : [...cur, pole]))
const pickPhoto = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
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<void> => {
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<typeof adminUpdateUser>[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 (
<Modal title="Modifier le compte" onClose={onClose}>
<label className="label">Photo de profil</label>
<div className="mb-3 flex items-center gap-3">
<div className="relative">
<Avatar
profile={{ full_name: fullName || '?', avatar_color: user.avatar_color, avatar_url: avatarUrl } as Profile}
size={56}
/>
{uploading && (
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50">
<Loader2 className="animate-spin text-white" size={18} />
</div>
)}
</div>
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={pickPhoto} />
<button className="btn-ghost" onClick={() => fileRef.current?.click()} disabled={uploading}>
<Camera size={16} /> {avatarUrl ? 'Changer' : 'Ajouter'}
</button>
{avatarUrl && (
<button
className="text-xs text-muted hover:text-rose-600"
onClick={() => setAvatarUrl(null)}
>
Retirer
</button>
)}
</div>
<label className="label">Nom complet</label>
<input
className="input mb-3"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
/>
<label className="label">Email</label>
<input
className="input mb-3"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<label className="label">Nouveau mot de passe</label>
<input
className="input mb-3"
type="text"
placeholder="laisser vide pour ne pas changer"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<label className="label">Pôles (un ou plusieurs)</label>
<div className="mb-4">
<PoleChips selected={roles} onToggle={toggleRole} />
</div>
{error && <p className="mb-3 text-sm text-rose-600">{error}</p>}
<button className="btn-primary w-full" onClick={submit} disabled={busy}>
{busy && <Loader2 className="animate-spin" size={16} />}
Enregistrer les modifications
</button>
</Modal>
)
}
+10 -8
View File
@@ -8,12 +8,14 @@ import { useMembers } from '../lib/useMembers'
import { import {
Task, Task,
TaskStatus, TaskStatus,
Pole,
TimeLog, TimeLog,
TASK_STATUS_ORDER, TASK_STATUS_ORDER,
TASK_STATUS_LABELS, TASK_STATUS_LABELS,
POLE_LABELS, POLE_LABELS,
POLE_ORDER, POLE_ORDER,
poleColor, poleColor,
taskPoles,
formatMinutes formatMinutes
} from '../lib/types' } from '../lib/types'
import { Avatar, PriorityBadge, Select } from '../components/ui' import { Avatar, PriorityBadge, Select } from '../components/ui'
@@ -122,7 +124,7 @@ export default function Kanban(): JSX.Element {
tasks.filter( tasks.filter(
(t) => (t) =>
(!fAssignee || (t.assignee_ids ?? []).includes(fAssignee)) && (!fAssignee || (t.assignee_ids ?? []).includes(fAssignee)) &&
(!fPole || t.pole === fPole) (!fPole || taskPoles(t).includes(fPole as Pole))
), ),
[tasks, fAssignee, fPole] [tasks, fAssignee, fPole]
) )
@@ -244,16 +246,16 @@ export default function Kanban(): JSX.Element {
</span> </span>
)} )}
</div> </div>
) : ( ) : null}
<Avatar profile={undefined} size={24} />
)}
</div> </div>
{t.pole && ( {taskPoles(t).length > 0 && (
<div className="mb-2 flex flex-wrap gap-1"> <div className="mb-2 flex flex-wrap gap-1">
<span className={`badge ${poleColor(t.pole)}`}> {taskPoles(t).map((p) => (
{POLE_LABELS[t.pole]} <span key={p} className={`badge ${poleColor(p)}`}>
</span> {POLE_LABELS[p]}
</span>
))}
</div> </div>
)} )}
+29 -4
View File
@@ -81,6 +81,8 @@ Deno.serve(async (req) => {
role: pr.role ?? 'other', role: pr.role ?? 'other',
roles, roles,
is_admin: pr.is_admin ?? false, 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 created_at: pr.created_at ?? u.created_at
} }
}) })
@@ -131,14 +133,34 @@ Deno.serve(async (req) => {
} }
case 'update': { 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) return json({ error: 'Identifiant manquant.' }, 400)
if (id === user.id && is_admin === false) if (id === user.id && is_admin === false)
return json({ error: 'Tu ne peux pas retirer ton propre statut admin.' }, 400) 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<string, unknown> = {}
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<string, unknown> = {} const patch: Record<string, unknown> = {}
if (full_name !== undefined) patch.full_name = full_name if (full_name !== undefined) patch.full_name = full_name
if (is_admin !== undefined) patch.is_admin = !!is_admin 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]). // Gestion des pôles : roles = liste complète, role = principal (roles[0]).
if (Array.isArray(roles)) { if (Array.isArray(roles)) {
const primary = roles[0] ?? 'other' const primary = roles[0] ?? 'other'
@@ -148,10 +170,13 @@ Deno.serve(async (req) => {
patch.role = role patch.role = role
patch.roles = [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 (Object.keys(authPatch).length === 0 && Object.keys(patch).length === 0)
if (error) return json({ error: error.message }, 400) return json({ error: 'Rien à modifier.' }, 400)
return json({ ok: true }) return json({ ok: true })
} }
+2 -1
View File
@@ -56,7 +56,8 @@ create table if not exists public.tasks (
description text, description text,
status task_status not null default 'todo', status task_status not null default 'todo',
priority priority_level not null default 'medium', 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 '{}', assignee_ids uuid[] not null default '{}',
due_date date, due_date date,
position double precision not null default 0, position double precision not null default 0,
+11
View File
@@ -143,3 +143,14 @@ create policy avatars_update on storage.objects
for update to authenticated using (bucket_id = 'avatars'); for update to authenticated using (bucket_id = 'avatars');
create policy avatars_delete on storage.objects create policy avatars_delete on storage.objects
for delete to authenticated using (bucket_id = 'avatars'); 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 = '{}';