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:
@@ -20,7 +20,8 @@
|
|||||||
"resizable": true,
|
"resizable": true,
|
||||||
"fullscreen": false,
|
"fullscreen": false,
|
||||||
"center": true,
|
"center": true,
|
||||||
"backgroundColor": "#0f1117"
|
"backgroundColor": "#0f1117",
|
||||||
|
"dragDropEnabled": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user