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,
|
||||
"fullscreen": false,
|
||||
"center": true,
|
||||
"backgroundColor": "#0f1117"
|
||||
"backgroundColor": "#0f1117",
|
||||
"dragDropEnabled": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
@@ -5,11 +5,13 @@ import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import { supa } from '../lib/supabase'
|
||||
import { useApp } from '../lib/AppContext'
|
||||
import { useFeedback } from '../lib/feedback'
|
||||
import { AppNotification } from '../lib/types'
|
||||
import type { Page } from './Layout'
|
||||
|
||||
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 [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
@@ -39,13 +41,20 @@ export default function NotificationBell({ setPage }: { setPage: (p: Page) => vo
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ 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()
|
||||
return () => {
|
||||
supa().removeChannel(ch)
|
||||
}
|
||||
}, [uid, load])
|
||||
}, [uid, load, toast])
|
||||
|
||||
// Fermeture au clic extérieur.
|
||||
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)))
|
||||
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')
|
||||
}
|
||||
|
||||
|
||||
+12
-1
@@ -6,6 +6,11 @@ import { Profile, Project } from './types'
|
||||
|
||||
type Stage = 'loading' | 'setup' | 'login' | 'ready'
|
||||
|
||||
export interface FocusEntity {
|
||||
kind: 'task' | 'bug'
|
||||
id: string
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
stage: Stage
|
||||
session: Session | null
|
||||
@@ -17,6 +22,9 @@ interface AppState {
|
||||
refreshProjects: () => Promise<void>
|
||||
refreshProfile: () => 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)
|
||||
@@ -33,6 +41,7 @@ export function AppProvider({ children }: { children: ReactNode }): JSX.Element
|
||||
const [profile, setProfile] = useState<Profile | null>(null)
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [currentProject, setCurrentProject] = useState<Project | null>(null)
|
||||
const [focusEntity, setFocusEntity] = useState<FocusEntity | null>(null)
|
||||
|
||||
const refreshProfile = useCallback(async () => {
|
||||
const uid = (await supa().auth.getUser()).data.user?.id
|
||||
@@ -109,7 +118,9 @@ export function AppProvider({ children }: { children: ReactNode }): JSX.Element
|
||||
saveConfig,
|
||||
refreshProjects,
|
||||
refreshProfile,
|
||||
signOut
|
||||
signOut,
|
||||
focusEntity,
|
||||
setFocusEntity
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
+11
-1
@@ -33,7 +33,7 @@ const STATUS_STYLES: Record<BugStatus, string> = {
|
||||
}
|
||||
|
||||
export default function Bugs(): JSX.Element {
|
||||
const { currentProject, profile } = useApp()
|
||||
const { currentProject, profile, focusEntity, setFocusEntity } = useApp()
|
||||
const { members, byId } = useMembers()
|
||||
const [bugs, setBugs] = useState<Bug[]>([])
|
||||
const [filter, setFilter] = useState<BugStatus | 'all'>('all')
|
||||
@@ -55,6 +55,16 @@ export default function Bugs(): JSX.Element {
|
||||
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 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 {
|
||||
const { currentProject } = useApp()
|
||||
const { currentProject, focusEntity, setFocusEntity } = useApp()
|
||||
const { members, byId } = useMembers()
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [times, setTimes] = useState<Record<string, number>>({})
|
||||
@@ -76,6 +76,16 @@ export default function Kanban(): JSX.Element {
|
||||
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
|
||||
useEffect(() => {
|
||||
if (!projectId) return
|
||||
@@ -198,7 +208,7 @@ export default function Kanban(): JSX.Element {
|
||||
</span>
|
||||
<span className={`badge ${accent.badge}`}>{colTasks.length}</span>
|
||||
</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) => {
|
||||
const mins = times[t.id] ?? 0
|
||||
return (
|
||||
@@ -215,7 +225,7 @@ export default function Kanban(): JSX.Element {
|
||||
setOverStatus(null)
|
||||
}}
|
||||
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' : ''
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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 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(
|
||||
weekEnd,
|
||||
'd MMM yyyy',
|
||||
@@ -213,6 +223,26 @@ export default function Planning(): JSX.Element {
|
||||
})}
|
||||
</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 && (
|
||||
<TimeCardModal
|
||||
day={modal.day}
|
||||
|
||||
Reference in New Issue
Block a user