(Feat) V2
This commit is contained in:
+10
-1
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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<TaskStatus>(task?.status ?? defaultStatus)
|
||||
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 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) */}
|
||||
<label className="label">Pôle</label>
|
||||
{/* Pôles — sélection multiple par pastilles (clique pour ajouter/retirer) */}
|
||||
<label className="label">
|
||||
Pôle(s) {poles.length > 0 && <span className="text-subtle">· {poles.length}</span>}
|
||||
</label>
|
||||
<div className="mb-3">
|
||||
<PoleChips
|
||||
selected={pole ? [pole] : []}
|
||||
onToggle={(p) => setPole((cur) => (cur === p ? '' : p))}
|
||||
/>
|
||||
<PoleChips selected={poles} onToggle={togglePole} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -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 (
|
||||
<button
|
||||
key={m.id}
|
||||
|
||||
+10
-1
@@ -10,6 +10,8 @@ export interface AdminUser {
|
||||
role: MemberRole
|
||||
roles: MemberRole[]
|
||||
is_admin: boolean
|
||||
avatar_color: string
|
||||
avatar_url: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
@@ -52,5 +54,12 @@ export const adminDeleteUser = (id: string): Promise<{ ok: true }> => call('dele
|
||||
|
||||
export const adminUpdateUser = (
|
||||
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 })
|
||||
|
||||
+8
-1
@@ -150,6 +150,12 @@ export function memberPoles(p: { role?: MemberRole; roles?: MemberRole[] }): Pol
|
||||
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 {
|
||||
id: string
|
||||
name: string
|
||||
@@ -165,7 +171,8 @@ export interface Task {
|
||||
description: string | null
|
||||
status: TaskStatus
|
||||
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[]
|
||||
due_date: string | null
|
||||
position: number
|
||||
|
||||
+171
-14
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ShieldCheck, UserPlus, Trash2, Loader2, Crown, Mail } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ShieldCheck, UserPlus, Trash2, Loader2, Crown, Mail, Pencil, Camera } from 'lucide-react'
|
||||
import { useApp } from '../lib/AppContext'
|
||||
import { useFeedback } from '../lib/feedback'
|
||||
import { MemberRole, memberPoles } from '../lib/types'
|
||||
import { MemberRole, Profile, memberPoles } from '../lib/types'
|
||||
import {
|
||||
AdminUser,
|
||||
adminListUsers,
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
adminDeleteUser,
|
||||
adminUpdateUser
|
||||
} 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'
|
||||
|
||||
export default function Admin(): JSX.Element {
|
||||
@@ -20,6 +21,7 @@ export default function Admin(): JSX.Element {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [editing, setEditing] = useState<AdminUser | null>(null)
|
||||
|
||||
const load = async (): Promise<void> => {
|
||||
setLoading(true)
|
||||
@@ -142,16 +144,25 @@ export default function Admin(): JSX.Element {
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<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 className="flex items-center justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setEditing(u)}
|
||||
title="Modifier le compte"
|
||||
className="text-muted hover:text-accent"
|
||||
>
|
||||
<Pencil 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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -267,3 +290,137 @@ function CreateUserModal({
|
||||
</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
@@ -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 {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Avatar profile={undefined} size={24} />
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{t.pole && (
|
||||
{taskPoles(t).length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1">
|
||||
<span className={`badge ${poleColor(t.pole)}`}>
|
||||
{POLE_LABELS[t.pole]}
|
||||
</span>
|
||||
{taskPoles(t).map((p) => (
|
||||
<span key={p} className={`badge ${poleColor(p)}`}>
|
||||
{POLE_LABELS[p]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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<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> = {}
|
||||
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 })
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
@@ -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 = '{}';
|
||||
|
||||
Reference in New Issue
Block a user