Kanban drag&drop + notifications vivantes + total hebdo par membre

- Kanban: active le drag&drop en désactivant dragDropEnabled côté Tauri
  (le webview interceptait le drop OS et bloquait le DnD HTML5) ;
  surbrillance de la colonne cible pendant le glissement.
- Notifications: toast à la réception en temps réel, et clic sur une notif
  ouvre directement la tâche/le bug concerné (via focusEntity partagé).
- Feuille de temps: récap du total de la semaine par membre sous la grille.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 14:02:27 +02:00
parent 8a0fa70c10
commit 78c4d253db
6 changed files with 82 additions and 9 deletions
+2 -1
View File
@@ -20,7 +20,8 @@
"resizable": true, "resizable": true,
"fullscreen": false, "fullscreen": false,
"center": true, "center": true,
"backgroundColor": "#0f1117" "backgroundColor": "#0f1117",
"dragDropEnabled": false
} }
], ],
"security": { "security": {
+14 -3
View File
@@ -5,11 +5,13 @@ import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale' import { fr } from 'date-fns/locale'
import { supa } from '../lib/supabase' import { supa } from '../lib/supabase'
import { useApp } from '../lib/AppContext' import { useApp } from '../lib/AppContext'
import { useFeedback } from '../lib/feedback'
import { AppNotification } from '../lib/types' import { AppNotification } from '../lib/types'
import type { Page } from './Layout' import type { Page } from './Layout'
export default function NotificationBell({ setPage }: { setPage: (p: Page) => void }): JSX.Element { export default function NotificationBell({ setPage }: { setPage: (p: Page) => void }): JSX.Element {
const { profile } = useApp() const { profile, setFocusEntity } = useApp()
const { toast } = useFeedback()
const [items, setItems] = useState<AppNotification[]>([]) const [items, setItems] = useState<AppNotification[]>([])
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
@@ -39,13 +41,20 @@ export default function NotificationBell({ setPage }: { setPage: (p: Page) => vo
.on( .on(
'postgres_changes', 'postgres_changes',
{ event: '*', schema: 'public', table: 'notifications', filter: `user_id=eq.${uid}` }, { event: '*', schema: 'public', table: 'notifications', filter: `user_id=eq.${uid}` },
() => load() (payload) => {
// Toast à la réception d'une nouvelle notification.
if (payload.eventType === 'INSERT') {
const n = payload.new as AppNotification
toast(n.body ? `${n.title} : ${n.body}` : n.title, 'info')
}
load()
}
) )
.subscribe() .subscribe()
return () => { return () => {
supa().removeChannel(ch) supa().removeChannel(ch)
} }
}, [uid, load]) }, [uid, load, toast])
// Fermeture au clic extérieur. // Fermeture au clic extérieur.
useEffect(() => { useEffect(() => {
@@ -70,6 +79,8 @@ export default function NotificationBell({ setPage }: { setPage: (p: Page) => vo
setItems((list) => list.map((x) => (x.id === n.id ? { ...x, read: true } : x))) setItems((list) => list.map((x) => (x.id === n.id ? { ...x, read: true } : x)))
await supa().from('notifications').update({ read: true }).eq('id', n.id) await supa().from('notifications').update({ read: true }).eq('id', n.id)
} }
// Ouvre directement la tâche / le bug concerné(e) sur la page cible.
if (n.entity_id) setFocusEntity({ kind: n.kind, id: n.entity_id })
setPage(n.kind === 'bug' ? 'bugs' : 'kanban') setPage(n.kind === 'bug' ? 'bugs' : 'kanban')
} }
+12 -1
View File
@@ -6,6 +6,11 @@ import { Profile, Project } from './types'
type Stage = 'loading' | 'setup' | 'login' | 'ready' type Stage = 'loading' | 'setup' | 'login' | 'ready'
export interface FocusEntity {
kind: 'task' | 'bug'
id: string
}
interface AppState { interface AppState {
stage: Stage stage: Stage
session: Session | null session: Session | null
@@ -17,6 +22,9 @@ interface AppState {
refreshProjects: () => Promise<void> refreshProjects: () => Promise<void>
refreshProfile: () => Promise<void> refreshProfile: () => Promise<void>
signOut: () => Promise<void> signOut: () => Promise<void>
// Élément à ouvrir (depuis une notification) : la page cible s'en saisit.
focusEntity: FocusEntity | null
setFocusEntity: (f: FocusEntity | null) => void
} }
const Ctx = createContext<AppState | null>(null) const Ctx = createContext<AppState | null>(null)
@@ -33,6 +41,7 @@ export function AppProvider({ children }: { children: ReactNode }): JSX.Element
const [profile, setProfile] = useState<Profile | null>(null) const [profile, setProfile] = useState<Profile | null>(null)
const [projects, setProjects] = useState<Project[]>([]) const [projects, setProjects] = useState<Project[]>([])
const [currentProject, setCurrentProject] = useState<Project | null>(null) const [currentProject, setCurrentProject] = useState<Project | null>(null)
const [focusEntity, setFocusEntity] = useState<FocusEntity | null>(null)
const refreshProfile = useCallback(async () => { const refreshProfile = useCallback(async () => {
const uid = (await supa().auth.getUser()).data.user?.id const uid = (await supa().auth.getUser()).data.user?.id
@@ -109,7 +118,9 @@ export function AppProvider({ children }: { children: ReactNode }): JSX.Element
saveConfig, saveConfig,
refreshProjects, refreshProjects,
refreshProfile, refreshProfile,
signOut signOut,
focusEntity,
setFocusEntity
}} }}
> >
{children} {children}
+11 -1
View File
@@ -33,7 +33,7 @@ const STATUS_STYLES: Record<BugStatus, string> = {
} }
export default function Bugs(): JSX.Element { export default function Bugs(): JSX.Element {
const { currentProject, profile } = useApp() const { currentProject, profile, focusEntity, setFocusEntity } = useApp()
const { members, byId } = useMembers() const { members, byId } = useMembers()
const [bugs, setBugs] = useState<Bug[]>([]) const [bugs, setBugs] = useState<Bug[]>([])
const [filter, setFilter] = useState<BugStatus | 'all'>('all') const [filter, setFilter] = useState<BugStatus | 'all'>('all')
@@ -55,6 +55,16 @@ export default function Bugs(): JSX.Element {
load() load()
}, [load]) }, [load])
// Ouverture d'un bug demandé par une notification.
useEffect(() => {
if (focusEntity?.kind !== 'bug') return
const b = bugs.find((x) => x.id === focusEntity.id)
if (b) {
setEditing(b)
setFocusEntity(null)
}
}, [focusEntity, bugs, setFocusEntity])
const shown = bugs.filter((b) => filter === 'all' || b.status === filter) const shown = bugs.filter((b) => filter === 'all' || b.status === filter)
const openCount = bugs.filter((b) => b.status === 'open' || b.status === 'in_progress').length const openCount = bugs.filter((b) => b.status === 'open' || b.status === 'in_progress').length
+13 -3
View File
@@ -29,7 +29,7 @@ const STATUS_ACCENT: Record<TaskStatus, { dot: string; badge: string; bar: strin
} }
export default function Kanban(): JSX.Element { export default function Kanban(): JSX.Element {
const { currentProject } = useApp() const { currentProject, focusEntity, setFocusEntity } = useApp()
const { members, byId } = useMembers() const { members, byId } = useMembers()
const [tasks, setTasks] = useState<Task[]>([]) const [tasks, setTasks] = useState<Task[]>([])
const [times, setTimes] = useState<Record<string, number>>({}) const [times, setTimes] = useState<Record<string, number>>({})
@@ -76,6 +76,16 @@ export default function Kanban(): JSX.Element {
load() load()
}, [load]) }, [load])
// Ouverture d'une tâche demandée par une notification.
useEffect(() => {
if (focusEntity?.kind !== 'task') return
const t = tasks.find((x) => x.id === focusEntity.id)
if (t) {
setEditing(t)
setFocusEntity(null)
}
}, [focusEntity, tasks, setFocusEntity])
// Synchro temps réel // Synchro temps réel
useEffect(() => { useEffect(() => {
if (!projectId) return if (!projectId) return
@@ -198,7 +208,7 @@ export default function Kanban(): JSX.Element {
</span> </span>
<span className={`badge ${accent.badge}`}>{colTasks.length}</span> <span className={`badge ${accent.badge}`}>{colTasks.length}</span>
</div> </div>
<div className="flex-1 space-y-2 overflow-y-auto px-2 pb-2"> <div className="flex-1 space-y-2 overflow-y-auto px-2 pb-2 pt-2">
{colTasks.map((t) => { {colTasks.map((t) => {
const mins = times[t.id] ?? 0 const mins = times[t.id] ?? 0
return ( return (
@@ -215,7 +225,7 @@ export default function Kanban(): JSX.Element {
setOverStatus(null) setOverStatus(null)
}} }}
onClick={() => setEditing(t)} onClick={() => setEditing(t)}
className={`card cursor-pointer p-3 transition-all hover:-translate-y-0.5 hover:border-accent ${ className={`card cursor-pointer p-3 transition-shadow hover:border-accent hover:shadow-md ${
dragId === t.id ? 'opacity-40' : '' dragId === t.id ? 'opacity-40' : ''
}`} }`}
> >
+30
View File
@@ -81,6 +81,16 @@ export default function Planning(): JSX.Element {
const totalOn = (day: Date): number => logsOn(day).reduce((s, l) => s + l.minutes, 0) const totalOn = (day: Date): number => logsOn(day).reduce((s, l) => s + l.minutes, 0)
const weekTotal = visibleLogs.reduce((s, l) => s + l.minutes, 0) const weekTotal = visibleLogs.reduce((s, l) => s + l.minutes, 0)
// Total de la semaine par membre (sur toute l'équipe, non filtré).
const perMember = useMemo(() => {
const totals: Record<string, number> = {}
logs.forEach((l) => {
const k = l.user_id ?? 'none'
totals[k] = (totals[k] ?? 0) + l.minutes
})
return Object.entries(totals).sort((a, b) => b[1] - a[1])
}, [logs])
const title = `Semaine du ${format(weekStart, 'd MMM', { locale: fr })} au ${format( const title = `Semaine du ${format(weekStart, 'd MMM', { locale: fr })} au ${format(
weekEnd, weekEnd,
'd MMM yyyy', 'd MMM yyyy',
@@ -213,6 +223,26 @@ export default function Planning(): JSX.Element {
})} })}
</div> </div>
{/* Récap de la semaine par membre */}
{perMember.length > 0 && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 border-t border-border px-6 py-2.5">
<span className="text-xs font-medium uppercase text-subtle">Cette semaine</span>
{perMember.map(([uid, mins]) => (
<span key={uid} className="flex items-center gap-1.5 text-sm">
{uid === 'none' ? (
<span className="text-subtle">Sans membre</span>
) : (
<>
<Avatar profile={byId(uid)} size={20} />
<span className="text-ink">{byId(uid)?.full_name ?? 'Inconnu'}</span>
</>
)}
<span className="font-semibold text-accent2">{formatMinutes(mins)}</span>
</span>
))}
</div>
)}
{modal && ( {modal && (
<TimeCardModal <TimeCardModal
day={modal.day} day={modal.day}