Merge: UI completion pass + accountability & benchmarking

Task drawer, board drag-and-drop, cartable, members & invitations, review diff, org chart —
plus transition-derived working hours, cycle time, pending load, and the AI-vs-human
benchmark page. 8 arch + 43 integration tests green, client build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-10 12:54:13 +03:30
21 changed files with 1907 additions and 130 deletions
+8
View File
@@ -2,7 +2,11 @@ import { Navigate, Route, Routes } from 'react-router'
import { Toaster } from '@/components/ui/sonner'
import { AnalyticsPage } from '@/pages/AnalyticsPage'
import { BoardPage } from '@/pages/BoardPage'
import { CartablePage } from '@/pages/CartablePage'
import { LoginPage } from '@/pages/LoginPage'
import { MembersPage } from '@/pages/MembersPage'
import { OrgChartPage } from '@/pages/OrgChartPage'
import { PerformancePage } from '@/pages/PerformancePage'
import { ReviewsPage } from '@/pages/ReviewsPage'
import { SeatsPage } from '@/pages/SeatsPage'
import { useAuth } from '@/store/auth'
@@ -18,6 +22,10 @@ export default function App() {
<Route path="/seats" element={token ? <SeatsPage /> : <Navigate to="/login" replace />} />
<Route path="/reviews" element={token ? <ReviewsPage /> : <Navigate to="/login" replace />} />
<Route path="/analytics" element={token ? <AnalyticsPage /> : <Navigate to="/login" replace />} />
<Route path="/cartable" element={token ? <CartablePage /> : <Navigate to="/login" replace />} />
<Route path="/members" element={token ? <MembersPage /> : <Navigate to="/login" replace />} />
<Route path="/org" element={token ? <OrgChartPage /> : <Navigate to="/login" replace />} />
<Route path="/performance" element={token ? <PerformancePage /> : <Navigate to="/login" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Toaster richColors position="top-right" />
+17 -4
View File
@@ -1,6 +1,17 @@
import type { ReactNode } from 'react'
import { Link, useLocation } from 'react-router'
import { Bot, ChartColumn, Inbox, type LucideIcon, LayoutDashboard, LogOut, Network, ShieldCheck } from 'lucide-react'
import {
Bot,
ChartColumn,
Gauge,
Inbox,
type LucideIcon,
LayoutDashboard,
LogOut,
Network,
ShieldCheck,
Users,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
@@ -27,11 +38,13 @@ export function AppShell({ children }: { children: ReactNode }) {
<nav className="flex flex-1 flex-col gap-1 p-3">
<NavItem icon={LayoutDashboard} label="Board" to="/" />
<NavItem icon={Bot} label="AI seats" to="/seats" />
<NavItem icon={Inbox} label="Cartable" to="/cartable" />
<NavItem icon={ShieldCheck} label="Review inbox" to="/reviews" />
<NavItem icon={Bot} label="AI seats" to="/seats" />
<NavItem icon={Network} label="Org chart" to="/org" />
<NavItem icon={Users} label="Members" to="/members" />
<NavItem icon={Gauge} label="Performance" to="/performance" />
<NavItem icon={ChartColumn} label="Analytics" to="/analytics" />
<NavItem icon={Inbox} label="Cartable" muted />
<NavItem icon={Network} label="Org chart" muted />
</nav>
<Separator className="bg-sidebar-border" />
+67
View File
@@ -0,0 +1,67 @@
import * as React from "react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
function SheetContent({
className,
children,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content>) {
return (
<SheetPrimitive.Portal>
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className="fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=closed]:fade-out-0"
/>
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"fixed inset-y-0 right-0 z-50 flex h-full w-full max-w-lg flex-col gap-4 overflow-y-auto border-l bg-background p-6 shadow-lg data-[state=open]:animate-in data-[state=open]:slide-in-from-right data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<X className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPrimitive.Portal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="sheet-header" className={cn("flex flex-col gap-1.5", className)} {...props} />
}
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-lg font-semibold tracking-tight", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export { Sheet, SheetClose, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetTrigger }
+59
View File
@@ -0,0 +1,59 @@
export interface DiffSegment {
kind: 'same' | 'removed' | 'added'
text: string
}
const MAX_TOKENS = 1500
/**
* Word-level diff (LCS) between two texts — used by the review inbox to show what the reviewer
* changed vs the agent's proposal. Inputs are capped so the O(n·m) table stays cheap.
*/
export function diffWords(before: string, after: string): DiffSegment[] {
const a = tokenize(before).slice(0, MAX_TOKENS)
const b = tokenize(after).slice(0, MAX_TOKENS)
// LCS length table.
const dp: number[][] = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0))
for (let i = a.length - 1; i >= 0; i--) {
for (let j = b.length - 1; j >= 0; j--) {
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1])
}
}
// Walk the table, merging consecutive segments of the same kind.
const segments: DiffSegment[] = []
const push = (kind: DiffSegment['kind'], text: string) => {
const last = segments[segments.length - 1]
if (last && last.kind === kind) {
last.text += text
} else {
segments.push({ kind, text })
}
}
let i = 0
let j = 0
while (i < a.length && j < b.length) {
if (a[i] === b[j]) {
push('same', a[i])
i++
j++
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
push('removed', a[i])
i++
} else {
push('added', b[j])
j++
}
}
while (i < a.length) push('removed', a[i++])
while (j < b.length) push('added', b[j++])
return segments
}
/** Splits text into words + whitespace separators (kept, so the diff re-renders faithfully). */
function tokenize(text: string): string[] {
return text.split(/(\s+)/).filter((t) => t.length > 0)
}
+47
View File
@@ -0,0 +1,47 @@
import { useEffect, useState } from 'react'
import { api } from './api'
export interface MemberRow {
id: string
email: string
displayName: string
role: string | null
}
export interface SeatRow {
id: string
teamId: string
roleName: string
state: string
memberId: string | null
agentId: string | null
}
/** The org member directory — for assignee pickers and name resolution. */
export function useMembers(organizationId: string | null) {
const [members, setMembers] = useState<MemberRow[]>([])
useEffect(() => {
if (!organizationId) return
api
.get<MemberRow[]>(`/api/identity/members?organizationId=${organizationId}`)
.then(setMembers)
.catch(() => setMembers([]))
}, [organizationId])
return members
}
/** The team's seats — for AI dispatch pickers and agent-name resolution on cards. */
export function useSeats(teamId: string | null) {
const [seats, setSeats] = useState<SeatRow[]>([])
useEffect(() => {
if (!teamId) {
setSeats([])
return
}
api
.get<SeatRow[]>(`/api/orgboard/seats?teamId=${teamId}`)
.then(setSeats)
.catch(() => setSeats([]))
}, [teamId])
return seats
}
+323 -97
View File
@@ -1,9 +1,16 @@
import { useCallback, useEffect, useState } from 'react'
import { Plus, UserPlus } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
DndContext,
PointerSensor,
useDraggable,
useDroppable,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import { Bot, Plus } from 'lucide-react'
import { toast } from 'sonner'
import { AppShell } from '@/components/AppShell'
import { StatusDot } from '@/components/StatusDot'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
@@ -23,7 +30,9 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { api } from '@/lib/api'
import { useMembers, useSeats, type MemberRow, type SeatRow } from '@/lib/useDirectory'
import { useAuth } from '@/store/auth'
const COLUMNS = [
@@ -33,20 +42,22 @@ const COLUMNS = [
{ value: 'Done', label: 'Done' },
] as const
interface Team {
id: string
organizationId: string
name: string
}
interface Task {
export interface Task {
id: string
teamId: string
title: string
description?: string | null
type: string
status: string
assigneeKind: string
assigneeId?: string | null
parentId?: string | null
}
interface Team {
id: string
organizationId: string
name: string
}
interface Board {
@@ -56,21 +67,23 @@ interface Board {
export function BoardPage() {
const memberId = useAuth((s) => s.memberId)
const email = useAuth((s) => s.email)
const organizationId = useAuth((s) => s.organizationId)
const [orgName, setOrgName] = useState('')
const [teams, setTeams] = useState<Team[]>([])
const [teamId, setTeamId] = useState<string | null>(null)
const [board, setBoard] = useState<Board | null>(null)
const [cartable, setCartable] = useState<Task[]>([])
const [newTeam, setNewTeam] = useState('')
const [newTask, setNewTask] = useState('')
const [openTaskId, setOpenTaskId] = useState<string | null>(null)
const members = useMembers(organizationId)
const seats = useSeats(teamId)
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }))
const loadTeams = useCallback(async () => {
if (!organizationId) {
return
}
if (!organizationId) return
try {
const result = await api.get<Team[]>(`/api/orgboard/teams?organizationId=${organizationId}`)
setTeams(result)
@@ -83,7 +96,6 @@ export function BoardPage() {
const loadBoard = useCallback(async (id: string) => {
try {
setBoard(await api.get<Board>(`/api/orgboard/board?teamId=${id}`))
setCartable(await api.get<Task[]>('/api/orgboard/cartable'))
} catch (err) {
toast.error((err as Error).message)
}
@@ -94,9 +106,7 @@ export function BoardPage() {
}, [loadTeams])
useEffect(() => {
if (teamId) {
void loadBoard(teamId)
}
if (teamId) void loadBoard(teamId)
}, [teamId, loadBoard])
async function run(action: () => Promise<unknown>) {
@@ -123,9 +133,7 @@ export function BoardPage() {
const createTask = () =>
run(async () => {
if (!teamId) {
return
}
if (!teamId || !newTask.trim()) return
await api.post('/api/orgboard/tasks', { teamId, title: newTask, type: 'Story' })
setNewTask('')
await loadBoard(teamId)
@@ -134,27 +142,28 @@ export function BoardPage() {
const move = (id: string, status: string) =>
run(async () => {
await api.patch(`/api/orgboard/tasks/${id}/move`, { status })
if (teamId) {
await loadBoard(teamId)
}
if (teamId) await loadBoard(teamId)
})
const assignToMe = (id: string) =>
run(async () => {
await api.patch(`/api/orgboard/tasks/${id}/assign`, { memberId })
if (teamId) {
await loadBoard(teamId)
}
})
const allTasks = useMemo(() => board?.columns.flatMap((c) => c.items) ?? [], [board])
const openTask = allTasks.find((t) => t.id === openTaskId) ?? null
const initials = (email ?? '?').slice(0, 2).toUpperCase()
const onDragEnd = (event: DragEndEvent) => {
const taskId = String(event.active.id)
const target = event.over ? String(event.over.id) : null
if (!target) return
const current = allTasks.find((t) => t.id === taskId)
if (current && current.status !== target) void move(taskId, target)
}
return (
<AppShell>
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-6">
<header>
<h1 className="text-2xl font-semibold tracking-tight">Board</h1>
<p className="text-sm text-muted-foreground">{orgName || 'Your organization'}</p>
<p className="text-sm text-muted-foreground">
Drag cards between columns click a card for details.
</p>
</header>
<Card>
@@ -212,9 +221,7 @@ export function BoardPage() {
placeholder="New task title…"
className="max-w-md"
onKeyDown={(e) => {
if (e.key === 'Enter') {
createTask()
}
if (e.key === 'Enter') createTask()
}}
/>
<Button onClick={createTask}>
@@ -224,30 +231,212 @@ export function BoardPage() {
</div>
)}
<DndContext sensors={sensors} onDragEnd={onDragEnd}>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
{COLUMNS.map((column) => {
const items = board?.columns.find((c) => c.status === column.value)?.items ?? []
return (
<Card key={column.value} className="bg-muted/30">
<DroppableColumn key={column.value} status={column.value} label={column.label} count={items.length}>
{items.map((task) => (
<DraggableCard
key={task.id}
task={task}
memberId={memberId}
members={members}
seats={seats}
onOpen={() => setOpenTaskId(task.id)}
/>
))}
{items.length === 0 && <p className="py-6 text-center text-xs text-muted-foreground">No tasks</p>}
</DroppableColumn>
)
})}
</div>
</DndContext>
</div>
<TaskDrawer
task={openTask}
allTasks={allTasks}
members={members}
seats={seats}
onClose={() => setOpenTaskId(null)}
onChanged={() => teamId && loadBoard(teamId)}
onOpenTask={(id) => setOpenTaskId(id)}
/>
</AppShell>
)
}
function DroppableColumn({
status,
label,
count,
children,
}: {
status: string
label: string
count: number
children: React.ReactNode
}) {
const { setNodeRef, isOver } = useDroppable({ id: status })
return (
<Card ref={setNodeRef} className={`bg-muted/30 transition-shadow ${isOver ? 'ring-2 ring-ring' : ''}`}>
<CardHeader>
<CardTitle className="flex items-center justify-between text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{column.label}
<Badge variant="secondary">{items.length}</Badge>
{label}
<Badge variant="secondary">{count}</Badge>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{items.map((task) => {
const mine = task.assigneeKind === 'Member' && task.assigneeId === memberId
<CardContent className="flex min-h-24 flex-col gap-3">{children}</CardContent>
</Card>
)
}
function DraggableCard({
task,
memberId,
members,
seats,
onOpen,
}: {
task: Task
memberId: string | null
members: MemberRow[]
seats: SeatRow[]
onOpen: () => void
}) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: task.id })
const style = transform ? { transform: `translate(${transform.x}px, ${transform.y}px)` } : undefined
return (
<Card key={task.id}>
<CardContent className="flex flex-col gap-3">
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}
onClick={onOpen}
className={`cursor-pointer ${isDragging ? 'relative z-50 opacity-70' : ''}`}
>
<Card className="hover:border-ring/50">
<CardContent className="flex flex-col gap-2 py-3">
<div className="flex items-start justify-between gap-2">
<span className="text-sm font-medium leading-snug">{task.title}</span>
<Badge variant="outline">{task.type}</Badge>
</div>
<div className="flex items-center justify-between gap-2">
<Select value={task.status} onValueChange={(v) => move(task.id, v)}>
<SelectTrigger className="h-8 w-[136px] text-xs">
<AssigneeChip task={task} memberId={memberId} members={members} seats={seats} />
</CardContent>
</Card>
</div>
)
}
/** The seat-state triad on cards: AI = indigo monogram, human = slate, unassigned = muted. */
function AssigneeChip({
task,
memberId,
members,
seats,
}: {
task: Task
memberId: string | null
members: MemberRow[]
seats: SeatRow[]
}) {
if (task.assigneeKind === 'Agent') {
const seat = seats.find((s) => s.agentId === task.assigneeId)
return (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="grid size-5 place-items-center rounded bg-seat-ai text-[9px] font-bold text-white">AI</span>
{seat?.roleName ?? 'AI seat'}
</span>
)
}
if (task.assigneeKind === 'Member') {
const member = members.find((m) => m.id === task.assigneeId)
const label = task.assigneeId === memberId ? 'You' : (member?.displayName ?? 'Member')
return (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="grid size-5 place-items-center rounded bg-seat-human text-[9px] font-bold text-white">
{(member?.displayName ?? '?').slice(0, 2).toUpperCase()}
</span>
{label}
</span>
)
}
return <span className="text-xs text-muted-foreground/60">Unassigned</span>
}
function TaskDrawer({
task,
allTasks,
members,
seats,
onClose,
onChanged,
onOpenTask,
}: {
task: Task | null
allTasks: Task[]
members: MemberRow[]
seats: SeatRow[]
onClose: () => void
onChanged: () => void
onOpenTask: (id: string) => void
}) {
const [busy, setBusy] = useState(false)
const [seatId, setSeatId] = useState<string>('')
const aiSeats = seats.filter((s) => s.state === 'Ai')
if (!task) {
return null
}
const children = allTasks.filter((t) => t.parentId === task.id)
const parent = task.parentId ? allTasks.find((t) => t.id === task.parentId) : null
async function act(action: () => Promise<unknown>, success?: string) {
setBusy(true)
try {
await action()
if (success) toast.success(success)
onChanged()
} catch (err) {
toast.error((err as Error).message)
} finally {
setBusy(false)
}
}
return (
<Sheet open onOpenChange={(open) => !open && onClose()}>
<SheetContent>
<SheetHeader>
<div className="flex items-center gap-2">
<Badge variant="outline">{task.type}</Badge>
<Badge variant="secondary">{task.status}</Badge>
</div>
<SheetTitle>{task.title}</SheetTitle>
<SheetDescription>
{parent ? (
<button type="button" className="text-primary hover:underline" onClick={() => onOpenTask(parent.id)}>
{parent.title}
</button>
) : (
'Top-level task'
)}
</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-2">
<Label>Status</Label>
<Select
value={task.status}
onValueChange={(v) => act(() => api.patch(`/api/orgboard/tasks/${task.id}/move`, { status: v }))}
>
<SelectTrigger className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -260,58 +449,95 @@ export function BoardPage() {
</SelectGroup>
</SelectContent>
</Select>
{task.assigneeKind === 'Member' ? (
<span className="flex items-center gap-2 text-xs text-muted-foreground">
<StatusDot tone="human" />
<Avatar className="size-6">
<AvatarFallback className="text-[10px]">
{mine ? initials : '··'}
</AvatarFallback>
</Avatar>
{mine ? 'You' : 'Assigned'}
</span>
) : (
<Button variant="outline" size="sm" onClick={() => assignToMe(task.id)}>
<UserPlus data-icon="inline-start" />
Assign to me
</Button>
)}
</div>
</CardContent>
</Card>
)
})}
{items.length === 0 && (
<p className="py-6 text-center text-xs text-muted-foreground">No tasks</p>
)}
</CardContent>
</Card>
)
})}
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">My cartable</CardTitle>
<CardDescription>Tasks assigned to you across teams.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{cartable.map((task) => (
<div
key={task.id}
className="flex items-center justify-between rounded-md border px-3 py-2 text-sm"
<div className="flex flex-col gap-2">
<Label>Assignee</Label>
<Select
value={task.assigneeKind === 'Member' ? (task.assigneeId ?? '') : ''}
onValueChange={(v) =>
act(() => api.patch(`/api/orgboard/tasks/${task.id}/assign`, { memberId: v }), 'Assigned.')
}
>
<span>{task.title}</span>
<Badge variant="secondary">{task.status}</Badge>
</div>
<SelectTrigger className="w-full">
<SelectValue placeholder={task.assigneeKind === 'Agent' ? 'Assigned to an AI seat' : 'Pick a member'} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{members.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.displayName} ({m.email})
</SelectItem>
))}
{cartable.length === 0 && (
<p className="text-sm text-muted-foreground">Nothing assigned to you yet.</p>
)}
</CardContent>
</Card>
</SelectGroup>
</SelectContent>
</Select>
</div>
</AppShell>
{aiSeats.length > 0 && (
<div className="flex flex-col gap-2">
<Label>Send to an AI seat</Label>
<div className="flex gap-2">
<Select value={seatId} onValueChange={setSeatId}>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Pick a seat" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{aiSeats.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.roleName}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
disabled={busy || !seatId}
onClick={() =>
act(
() => api.post('/api/assembler/runs', { seatId, workItemId: task.id }),
'Dispatched — the proposal will land in the review inbox.',
)
}
>
<Bot data-icon="inline-start" />
Run
</Button>
</div>
</div>
)}
<div className="flex flex-col gap-2">
<Label>Description / artifact</Label>
{task.description ? (
<div className="max-h-72 overflow-auto whitespace-pre-wrap rounded-lg bg-muted p-3 text-xs leading-relaxed">
{task.description}
</div>
) : (
<p className="text-sm text-muted-foreground">No description yet approved agent artifacts land here.</p>
)}
</div>
{children.length > 0 && (
<div className="flex flex-col gap-2">
<Label>Child tasks</Label>
<div className="flex flex-col gap-1.5">
{children.map((child) => (
<button
key={child.id}
type="button"
onClick={() => onOpenTask(child.id)}
className="flex items-center justify-between rounded-md border px-3 py-2 text-left text-sm hover:border-ring/60"
>
<span className="truncate">{child.title}</span>
<Badge variant="secondary">{child.status}</Badge>
</button>
))}
</div>
</div>
)}
</SheetContent>
</Sheet>
)
}
+96
View File
@@ -0,0 +1,96 @@
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { AppShell } from '@/components/AppShell'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { api } from '@/lib/api'
interface Task {
id: string
teamId: string
title: string
type: string
status: string
}
const GROUPS = [
{ status: 'InProgress', label: 'In progress' },
{ status: 'InReview', label: 'In review' },
{ status: 'Backlog', label: 'Backlog' },
{ status: 'Done', label: 'Recently done' },
] as const
/** The cartable: one person's pending slice — everything assigned to them, most urgent first. */
export function CartablePage() {
const [tasks, setTasks] = useState<Task[] | null>(null)
const load = useCallback(async () => {
try {
setTasks(await api.get<Task[]>('/api/orgboard/cartable'))
} catch (err) {
toast.error((err as Error).message)
setTasks([])
}
}, [])
useEffect(() => {
void load()
}, [load])
return (
<AppShell>
<div className="mx-auto max-w-3xl p-6">
<div className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight">Cartable</h1>
<p className="text-sm text-muted-foreground">Everything waiting on you, across all your teams.</p>
</div>
{tasks === null && (
<div className="flex flex-col gap-4">
<Skeleton className="h-28 w-full" />
<Skeleton className="h-28 w-full" />
</div>
)}
{tasks?.length === 0 && (
<Card>
<CardContent className="py-10 text-center text-sm text-muted-foreground">
Nothing assigned to you yet.
</CardContent>
</Card>
)}
<div className="flex flex-col gap-4">
{tasks &&
tasks.length > 0 &&
GROUPS.map((group) => {
const items = tasks.filter((t) => t.status === group.status)
if (items.length === 0) return null
return (
<Card key={group.status}>
<CardHeader>
<CardTitle className="flex items-center justify-between text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{group.label}
<Badge variant="secondary">{items.length}</Badge>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{items.map((task) => (
<div
key={task.id}
className="flex items-center justify-between rounded-md border px-3 py-2 text-sm"
>
<span className="truncate">{task.title}</span>
<Badge variant="outline">{task.type}</Badge>
</div>
))}
</CardContent>
</Card>
)
})}
</div>
</div>
</AppShell>
)
}
+189
View File
@@ -0,0 +1,189 @@
import { useCallback, useEffect, useState } from 'react'
import { Copy, UserPlus } from 'lucide-react'
import { toast } from 'sonner'
import { AppShell } from '@/components/AppShell'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { api } from '@/lib/api'
import { useMembers } from '@/lib/useDirectory'
import { useAuth } from '@/store/auth'
interface Invitation {
id: string
email: string
scopeType: string
scopeId: string
role: string
status: string
token: string
createdAtUtc: string
}
const ROLES = ['Member', 'TeamOwner', 'Viewer', 'Owner'] as const
export function MembersPage() {
const organizationId = useAuth((s) => s.organizationId)
const members = useMembers(organizationId)
const [invitations, setInvitations] = useState<Invitation[]>([])
const [email, setEmail] = useState('')
const [role, setRole] = useState<string>('Member')
const [busy, setBusy] = useState(false)
const loadInvitations = useCallback(async () => {
if (!organizationId) return
try {
setInvitations(await api.get<Invitation[]>(`/api/identity/invitations?organizationId=${organizationId}`))
} catch {
setInvitations([]) // non-owners simply don't see the invitations panel
}
}, [organizationId])
useEffect(() => {
void loadInvitations()
}, [loadInvitations])
async function invite() {
if (!organizationId || !email.trim()) return
setBusy(true)
try {
await api.post('/api/identity/invitations', {
email,
scopeType: 'Organization',
scopeId: organizationId,
role,
organizationId,
})
setEmail('')
toast.success('Invitation created — copy the join token below.')
await loadInvitations()
} catch (err) {
toast.error((err as Error).message)
} finally {
setBusy(false)
}
}
async function copyToken(invitation: Invitation) {
await navigator.clipboard.writeText(invitation.token)
toast.success('Join token copied — share it; they accept on the login page.')
}
return (
<AppShell>
<div className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Members</h1>
<p className="text-sm text-muted-foreground">Who's in the org, and who's invited.</p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Invite someone</CardTitle>
<CardDescription>
V1 sends no email share the join token; they redeem it from the login page.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-2">
<Label htmlFor="invite-email">Email</Label>
<Input
id="invite-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-64"
placeholder="dev@company.com"
/>
</div>
<div className="flex flex-col gap-2">
<Label>Role</Label>
<Select value={role} onValueChange={setRole}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{ROLES.map((r) => (
<SelectItem key={r} value={r}>
{r}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<Button onClick={invite} disabled={busy || !email.trim()}>
<UserPlus data-icon="inline-start" />
Invite
</Button>
</CardContent>
</Card>
{invitations.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Invitations</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{invitations.map((invitation) => (
<div
key={invitation.id}
className="flex items-center justify-between gap-3 rounded-md border px-3 py-2 text-sm"
>
<div className="min-w-0">
<div className="truncate font-medium">{invitation.email}</div>
<div className="text-xs text-muted-foreground">
{invitation.role} · {new Date(invitation.createdAtUtc).toLocaleDateString()}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Badge variant={invitation.status === 'Pending' ? 'outline' : 'secondary'}>
{invitation.status}
</Badge>
{invitation.status === 'Pending' && (
<Button variant="outline" size="sm" onClick={() => copyToken(invitation)}>
<Copy data-icon="inline-start" />
Copy token
</Button>
)}
</div>
</div>
))}
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">Members ({members.length})</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{members.map((member) => (
<div key={member.id} className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
<span className="flex items-center gap-2">
<span className="grid size-6 place-items-center rounded bg-seat-human text-[10px] font-bold text-white">
{member.displayName.slice(0, 2).toUpperCase()}
</span>
<span className="font-medium">{member.displayName}</span>
<span className="text-muted-foreground">{member.email}</span>
</span>
<Badge variant="secondary">{member.role ?? 'Member'}</Badge>
</div>
))}
</CardContent>
</Card>
</div>
</AppShell>
)
}
+124
View File
@@ -0,0 +1,124 @@
import { useEffect, useMemo, useState } from 'react'
import { Background, ReactFlow, type Edge, type Node } from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import { toast } from 'sonner'
import { AppShell } from '@/components/AppShell'
import { api } from '@/lib/api'
import { useAuth } from '@/store/auth'
import type { SeatRow } from '@/lib/useDirectory'
interface Team {
id: string
organizationId: string
name: string
}
const TEAM_WIDTH = 280
const SEAT_HEIGHT = 64
/** The live org chart: org → teams → seats, painted with the human/open/AI triad. */
export function OrgChartPage() {
const organizationId = useAuth((s) => s.organizationId)
const [teams, setTeams] = useState<Team[]>([])
const [seatsByTeam, setSeatsByTeam] = useState<Record<string, SeatRow[]>>({})
useEffect(() => {
if (!organizationId) return
void (async () => {
try {
const teamList = await api.get<Team[]>(`/api/orgboard/teams?organizationId=${organizationId}`)
setTeams(teamList)
const entries = await Promise.all(
teamList.map(async (team) => {
try {
return [team.id, await api.get<SeatRow[]>(`/api/orgboard/seats?teamId=${team.id}`)] as const
} catch {
return [team.id, []] as const
}
}),
)
setSeatsByTeam(Object.fromEntries(entries))
} catch (err) {
toast.error((err as Error).message)
}
})()
}, [organizationId])
const { nodes, edges } = useMemo(() => buildGraph(teams, seatsByTeam), [teams, seatsByTeam])
return (
<AppShell>
<div className="flex h-full flex-col p-6">
<div className="mb-4">
<h1 className="text-2xl font-semibold tracking-tight">Org chart</h1>
<p className="text-sm text-muted-foreground">
The live org: <span className="font-medium text-seat-human">human</span> ·{' '}
<span className="font-medium text-seat-open">open</span> ·{' '}
<span className="font-medium text-seat-ai">AI</span> seats.
</p>
</div>
<div className="min-h-[480px] flex-1 overflow-hidden rounded-xl border bg-background">
<ReactFlow nodes={nodes} edges={edges} fitView proOptions={{ hideAttribution: true }}>
<Background gap={20} />
</ReactFlow>
</div>
</div>
</AppShell>
)
}
function buildGraph(teams: Team[], seatsByTeam: Record<string, SeatRow[]>): { nodes: Node[]; edges: Edge[] } {
const nodes: Node[] = []
const edges: Edge[] = []
if (teams.length === 0) {
return { nodes, edges }
}
const totalWidth = teams.length * TEAM_WIDTH
nodes.push({
id: 'org',
position: { x: totalWidth / 2 - 90, y: 0 },
data: { label: 'Organization' },
style: {
background: 'var(--color-sidebar, #1e1b4b)',
color: 'white',
fontWeight: 600,
borderRadius: 10,
border: 'none',
width: 180,
},
})
teams.forEach((team, teamIndex) => {
const x = teamIndex * TEAM_WIDTH
nodes.push({
id: team.id,
position: { x, y: 110 },
data: { label: team.name },
style: { borderRadius: 10, fontWeight: 600, width: 200 },
})
edges.push({ id: `org-${team.id}`, source: 'org', target: team.id })
const seats = seatsByTeam[team.id] ?? []
seats.forEach((seat, seatIndex) => {
const color = seat.state === 'Ai' ? '#4f46e5' : seat.state === 'Human' ? '#475569' : '#d97706'
nodes.push({
id: seat.id,
position: { x: x + 10, y: 210 + seatIndex * SEAT_HEIGHT },
data: { label: `${seat.roleName} · ${seat.state === 'Ai' ? 'AI' : seat.state}` },
style: {
background: color,
color: 'white',
borderRadius: 8,
border: 'none',
fontSize: 12,
width: 180,
},
})
edges.push({ id: `${team.id}-${seat.id}`, source: team.id, target: seat.id })
})
})
return { nodes, edges }
}
+184
View File
@@ -0,0 +1,184 @@
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { AppShell } from '@/components/AppShell'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { api } from '@/lib/api'
import { useMembers } from '@/lib/useDirectory'
import { useAuth } from '@/store/auth'
interface PerformanceRow {
assigneeKind: 'Member' | 'Agent'
assigneeId: string
name: string | null
backlog: number
inProgress: number
inReview: number
done: number
workedHours: number
avgCycleHours: number | null
}
interface Performance {
unassignedPending: number
rows: PerformanceRow[]
}
interface AgentAnalytics {
agentId: string
approvalRate: number | null
avgEditDistance: number | null
reviews: number
}
interface Analytics {
agents: AgentAnalytics[]
}
/**
* Accountability & benchmarking: humans and AI on the same scale — who owns what (pending load),
* hours worked (time in progress), throughput, cycle time — and for AI seats, the trust metrics
* (approval rate + edit distance) alongside.
*/
export function PerformancePage() {
const organizationId = useAuth((s) => s.organizationId)
const members = useMembers(organizationId)
const [performance, setPerformance] = useState<Performance | null>(null)
const [analytics, setAnalytics] = useState<Analytics | null>(null)
const load = useCallback(async () => {
if (!organizationId) return
try {
setPerformance(await api.get<Performance>(`/api/orgboard/performance?organizationId=${organizationId}`))
} catch (err) {
toast.error((err as Error).message)
setPerformance({ unassignedPending: 0, rows: [] })
}
try {
setAnalytics(await api.get<Analytics>(`/api/governance/analytics?organizationId=${organizationId}`))
} catch {
setAnalytics({ agents: [] })
}
}, [organizationId])
useEffect(() => {
void load()
}, [load])
const rows = (performance?.rows ?? []).map((row) => {
const name =
row.name ??
members.find((m) => m.id === row.assigneeId)?.displayName ??
(row.assigneeKind === 'Agent' ? 'AI agent' : 'Member')
const agentMetrics =
row.assigneeKind === 'Agent' ? analytics?.agents.find((a) => a.agentId === row.assigneeId) : undefined
return { ...row, name, agentMetrics, pending: row.backlog + row.inProgress + row.inReview }
})
return (
<AppShell>
<div className="mx-auto flex max-w-5xl flex-col gap-6 p-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Team performance</h1>
<p className="text-sm text-muted-foreground">
Who's accountable for what — humans and AI benchmarked on the same scale.
</p>
</div>
{performance === null ? (
<Skeleton className="h-64 w-full" />
) : (
<>
{performance.unassignedPending > 0 && (
<Card className="border-seat-open/50">
<CardContent className="flex items-center justify-between py-4 text-sm">
<span>
<span className="font-semibold">{performance.unassignedPending}</span> pending task
{performance.unassignedPending === 1 ? '' : 's'} with <strong>no one accountable</strong>.
</span>
<Badge variant="outline" className="border-seat-open text-seat-open">
needs an owner
</Badge>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">Benchmark</CardTitle>
<CardDescription>
Working hours = time tasks spent In&nbsp;Progress · cycle time = start of work → done. Approval
rate and edit distance apply to AI seats.
</CardDescription>
</CardHeader>
<CardContent>
{rows.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
No assigned work yet — assign tasks to people or AI seats to populate this view.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 font-medium">Assignee</th>
<th className="py-2 font-medium">Pending</th>
<th className="py-2 font-medium">Done</th>
<th className="py-2 font-medium">Worked (h)</th>
<th className="py-2 font-medium">Cycle (h)</th>
<th className="py-2 font-medium">Approval</th>
<th className="py-2 font-medium">Edit dist.</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={`${row.assigneeKind}-${row.assigneeId}`} className="border-b last:border-0">
<td className="py-2.5">
<span className="flex items-center gap-2">
<span
className={`grid size-6 shrink-0 place-items-center rounded text-[10px] font-bold text-white ${
row.assigneeKind === 'Agent' ? 'bg-seat-ai' : 'bg-seat-human'
}`}
>
{row.assigneeKind === 'Agent' ? 'AI' : row.name.slice(0, 2).toUpperCase()}
</span>
<span className="font-medium">{row.name}</span>
</span>
</td>
<td className="py-2.5">
<span title={`Backlog ${row.backlog} · In progress ${row.inProgress} · In review ${row.inReview}`}>
{row.pending}
</span>
</td>
<td className="py-2.5">{row.done}</td>
<td className="py-2.5">{row.workedHours.toFixed(1)}</td>
<td className="py-2.5">{row.avgCycleHours?.toFixed(1) ?? ''}</td>
<td className="py-2.5">
{row.agentMetrics?.approvalRate != null
? `${Math.round(row.agentMetrics.approvalRate * 100)}%`
: row.assigneeKind === 'Agent'
? ''
: 'n/a'}
</td>
<td className="py-2.5">
{row.agentMetrics?.avgEditDistance != null
? row.agentMetrics.avgEditDistance.toFixed(3)
: row.assigneeKind === 'Agent'
? ''
: 'n/a'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</>
)}
</div>
</AppShell>
)
}
+22
View File
@@ -9,6 +9,7 @@ import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { api } from '@/lib/api'
import { diffWords } from '@/lib/diff'
import { useAuth } from '@/store/auth'
interface ReviewItem {
@@ -156,6 +157,27 @@ function ReviewCard({ item, onDecided }: { item: ReviewItem; onDecided: (id: str
/>
</div>
{content !== item.content && (
<div className="flex flex-col gap-2">
<Label>Your edits (vs the proposal)</Label>
<div className="max-h-40 overflow-auto whitespace-pre-wrap rounded-lg border bg-muted/40 p-3 text-xs leading-relaxed">
{diffWords(item.content, content).map((segment, i) =>
segment.kind === 'same' ? (
<span key={i}>{segment.text}</span>
) : segment.kind === 'removed' ? (
<del key={i} className="rounded bg-destructive/15 text-destructive">
{segment.text}
</del>
) : (
<ins key={i} className="rounded bg-seat-ai/15 font-medium text-primary no-underline">
{segment.text}
</ins>
),
)}
</div>
</div>
)}
<div className="flex flex-col gap-2">
<Label htmlFor={`children-${item.id}`}>Child tasks (one per line)</Label>
<Textarea
@@ -32,3 +32,15 @@ internal sealed record InviteRequest(
internal sealed record InviteResponse(Guid InvitationId, string Token);
internal sealed record AcceptInviteRequest(string Token, string DisplayName, string Password);
internal sealed record MemberRow(Guid Id, string Email, string DisplayName, string? Role);
internal sealed record InvitationRow(
Guid Id,
string Email,
string ScopeType,
Guid ScopeId,
string Role,
string Status,
string Token,
DateTimeOffset CreatedAtUtc);
@@ -23,10 +23,55 @@ internal static class IdentityEndpoints
group.MapPost("/bootstrap", Bootstrap).AllowAnonymous();
group.MapPost("/auth/login", Login).AllowAnonymous();
group.MapGet("/me", Me).RequireAuthorization();
group.MapGet("/members", ListMembers).RequireAuthorization();
group.MapPost("/invitations", CreateInvitation).RequireAuthorization();
group.MapGet("/invitations", ListInvitations).RequireAuthorization();
group.MapPost("/invitations/accept", AcceptInvitation).AllowAnonymous();
}
// The org directory, for assignment pickers and the members page. Any board viewer may read it.
private static async Task<IResult> ListMembers(
Guid organizationId, IPermissionService permissions, IdentityDbContext db, CancellationToken ct)
{
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Org(organizationId)))
{
return Results.Forbid();
}
var members = await db.Members
.OrderBy(m => m.CreatedAtUtc)
.Select(m => new { m.Id, m.Email, m.DisplayName })
.ToListAsync(ct);
var orgRoles = await db.Memberships
.Where(ms => ms.ScopeType == ScopeType.Organization && ms.ScopeId == organizationId)
.ToDictionaryAsync(ms => ms.MemberId, ms => ms.Role.ToString(), ct);
return Results.Ok(members
.Select(m => new MemberRow(m.Id, m.Email, m.DisplayName, orgRoles.GetValueOrDefault(m.Id)))
.ToList());
}
// Pending/accepted invitations, for the members page. Inviter-level capability required;
// the token is included so the inviter can copy the join link (V1 sends no email).
private static async Task<IResult> ListInvitations(
Guid organizationId, IPermissionService permissions, IdentityDbContext db, CancellationToken ct)
{
if (!permissions.Has(Capability.InvitePeople, ScopeRef.Org(organizationId)))
{
return Results.Forbid();
}
var invitations = await db.Invitations
.OrderByDescending(i => i.CreatedAtUtc)
.Take(100)
.Select(i => new InvitationRow(
i.Id, i.Email, i.ScopeType.ToString(), i.ScopeId, i.Role.ToString(),
i.Status.ToString(), i.Token, i.CreatedAtUtc))
.ToListAsync(ct);
return Results.Ok(invitations);
}
private static async Task<IResult> Bootstrap(
BootstrapRequest request,
IdentityDbContext db,
@@ -0,0 +1,37 @@
using TeamUp.SharedKernel.Domain;
namespace TeamUp.Modules.OrgBoard.Domain;
/// <summary>
/// An immutable record of a board-status change. The raw material for accountability metrics:
/// working hours (time in InProgress), cycle time (first InProgress → Done), and throughput.
/// </summary>
internal sealed class WorkItemTransition : Entity
{
public Guid WorkItemId { get; private set; }
public Guid TeamId { get; private set; }
public WorkItemStatus FromStatus { get; private set; }
public WorkItemStatus ToStatus { get; private set; }
public Guid? ActorMemberId { get; private set; }
public DateTimeOffset OccurredAtUtc { get; private set; }
private WorkItemTransition()
{
}
public WorkItemTransition(
Guid workItemId,
Guid teamId,
WorkItemStatus fromStatus,
WorkItemStatus toStatus,
Guid? actorMemberId,
DateTimeOffset occurredAtUtc)
{
WorkItemId = workItemId;
TeamId = teamId;
FromStatus = fromStatus;
ToStatus = toStatus;
ActorMemberId = actorMemberId;
OccurredAtUtc = occurredAtUtc;
}
}
@@ -30,6 +30,8 @@ internal static class OrgBoardEndpoints
group.MapGet("/seats", ListSeats).RequireAuthorization();
group.MapPost("/seats/{id:guid}/agent", ConfigureAgent).RequireAuthorization();
group.MapGet("/seats/{id:guid}/agent", GetAgent).RequireAuthorization();
group.MapGet("/performance", PerformanceEndpoints.Get).RequireAuthorization();
}
private static TaskResponse ToResponse(WorkItem item) => new(
@@ -175,7 +177,15 @@ internal static class OrgBoardEndpoints
return Results.Forbid();
}
item!.MoveTo(request.Status, clock.GetUtcNow());
var fromStatus = item!.Status;
item.MoveTo(request.Status, clock.GetUtcNow());
if (fromStatus != request.Status)
{
// The raw material for working-hours / cycle-time accountability metrics.
db.Transitions.Add(new WorkItemTransition(
item.Id, team.Id, fromStatus, request.Status, user.MemberId, clock.GetUtcNow()));
}
await db.SaveChangesAsync(ct);
await audit.WriteAsync(new AuditEvent("task.moved", "WorkItem", item.Id, user.MemberId, request.Status.ToString()), ct);
@@ -0,0 +1,141 @@
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using TeamUp.Modules.OrgBoard.Domain;
using TeamUp.Modules.OrgBoard.Persistence;
using TeamUp.SharedKernel.Access;
namespace TeamUp.Modules.OrgBoard.Endpoints;
internal sealed record PerformanceRow(
string AssigneeKind,
Guid AssigneeId,
string? Name,
int Backlog,
int InProgress,
int InReview,
int Done,
double WorkedHours,
double? AvgCycleHours);
internal sealed record PerformanceResponse(int UnassignedPending, List<PerformanceRow> Rows);
/// <summary>
/// Accountability metrics per assignee — human and AI on the same scale: pending load by column
/// (who is accountable for what), working hours (time tasks spent InProgress, attributed to the
/// current assignee), throughput (done), and avg cycle time (first InProgress → Done).
/// </summary>
internal static class PerformanceEndpoints
{
public static async Task<IResult> Get(
Guid organizationId, IPermissionService permissions, OrgBoardDbContext db,
TimeProvider clock, CancellationToken ct)
{
if (!permissions.Has(Capability.ViewAuditLog, ScopeRef.Org(organizationId)))
{
return Results.Forbid();
}
var teamIds = await db.Teams
.Where(t => t.OrganizationId == organizationId)
.Select(t => t.Id)
.ToListAsync(ct);
var items = await db.WorkItems.Where(w => teamIds.Contains(w.TeamId)).ToListAsync(ct);
var transitions = (await db.Transitions.Where(t => teamIds.Contains(t.TeamId)).ToListAsync(ct))
.GroupBy(t => t.WorkItemId)
.ToDictionary(g => g.Key, g => g.OrderBy(t => t.OccurredAtUtc).ToList());
var agentNames = await db.Agents.ToDictionaryAsync(a => a.Id, a => a.Name, ct);
var now = clock.GetUtcNow();
var rows = items
.Where(i => i.AssigneeKind != AssigneeKind.Unassigned && i.AssigneeId.HasValue)
.GroupBy(i => (i.AssigneeKind, AssigneeId: i.AssigneeId!.Value))
.Select(group =>
{
var byStatus = group.GroupBy(i => i.Status).ToDictionary(s => s.Key, s => s.Count());
var workedHours = group.Sum(i => HoursInProgress(i, transitions, now));
var cycles = group
.Where(i => i.Status == WorkItemStatus.Done)
.Select(i => CycleHours(i, transitions))
.Where(h => h.HasValue)
.Select(h => h!.Value)
.ToList();
return new PerformanceRow(
group.Key.AssigneeKind.ToString(),
group.Key.AssigneeId,
group.Key.AssigneeKind == AssigneeKind.Agent
? agentNames.GetValueOrDefault(group.Key.AssigneeId)
: null, // member names are joined client-side from /api/identity/members
byStatus.GetValueOrDefault(WorkItemStatus.Backlog),
byStatus.GetValueOrDefault(WorkItemStatus.InProgress),
byStatus.GetValueOrDefault(WorkItemStatus.InReview),
byStatus.GetValueOrDefault(WorkItemStatus.Done),
Math.Round(workedHours, 2),
cycles.Count == 0 ? null : Math.Round(cycles.Average(), 2));
})
.OrderByDescending(r => r.Done)
.ToList();
var unassignedPending = items.Count(i =>
i.AssigneeKind == AssigneeKind.Unassigned && i.Status != WorkItemStatus.Done);
return Results.Ok(new PerformanceResponse(unassignedPending, rows));
}
/// <summary>Total hours the item has spent in InProgress (open span counts up to now).</summary>
private static double HoursInProgress(
WorkItem item,
Dictionary<Guid, List<WorkItemTransition>> transitions,
DateTimeOffset now)
{
if (!transitions.TryGetValue(item.Id, out var list))
{
return 0;
}
double hours = 0;
DateTimeOffset? entered = null;
foreach (var transition in list)
{
if (transition.ToStatus == WorkItemStatus.InProgress)
{
entered ??= transition.OccurredAtUtc;
}
else if (entered.HasValue && transition.FromStatus == WorkItemStatus.InProgress)
{
hours += (transition.OccurredAtUtc - entered.Value).TotalHours;
entered = null;
}
}
if (entered.HasValue)
{
hours += (now - entered.Value).TotalHours;
}
return hours;
}
/// <summary>First entry into InProgress (or creation) → the last transition to Done.</summary>
private static double? CycleHours(
WorkItem item,
Dictionary<Guid, List<WorkItemTransition>> transitions)
{
if (!transitions.TryGetValue(item.Id, out var list))
{
return null;
}
var done = list.LastOrDefault(t => t.ToStatus == WorkItemStatus.Done);
if (done is null)
{
return null;
}
var started = list.FirstOrDefault(t => t.ToStatus == WorkItemStatus.InProgress)?.OccurredAtUtc
?? item.CreatedAtUtc;
var hours = (done.OccurredAtUtc - started).TotalHours;
return hours < 0 ? null : hours;
}
}
@@ -0,0 +1,254 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using TeamUp.Modules.OrgBoard.Persistence;
#nullable disable
namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
{
[DbContext(typeof(OrgBoardDbContext))]
[Migration("20260610090933_AddWorkItemTransitions")]
partial class AddWorkItemTransitions
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("orgboard")
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Agent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ApiConfigId")
.HasColumnType("uuid");
b.Property<string>("Autonomy")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<List<string>>("Docs")
.IsRequired()
.HasColumnType("text[]");
b.Property<Guid?>("FallbackApiConfigId")
.HasColumnType("uuid");
b.Property<string>("Monogram")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<Guid>("SeatId")
.HasColumnType("uuid");
b.PrimitiveCollection<List<string>>("SkillKeys")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("SeatId")
.IsUnique();
b.ToTable("agents", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Organization", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.HasKey("Id");
b.ToTable("organizations", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Seat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AgentId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("MemberId")
.HasColumnType("uuid");
b.Property<string>("RoleName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("TeamId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TeamId");
b.ToTable("seats", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Team", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.ToTable("teams", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.WorkItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssigneeId")
.HasColumnType("uuid");
b.Property<string>("AssigneeKind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedByMemberId")
.HasColumnType("uuid");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("TeamId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("TeamId");
b.HasIndex("AssigneeKind", "AssigneeId");
b.ToTable("work_items", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.WorkItemTransition", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("ActorMemberId")
.HasColumnType("uuid");
b.Property<string>("FromStatus")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTimeOffset>("OccurredAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("TeamId")
.HasColumnType("uuid");
b.Property<string>("ToStatus")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("WorkItemId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TeamId");
b.HasIndex("WorkItemId");
b.ToTable("work_item_transitions", "orgboard");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,53 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddWorkItemTransitions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "work_item_transitions",
schema: "orgboard",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
WorkItemId = table.Column<Guid>(type: "uuid", nullable: false),
TeamId = table.Column<Guid>(type: "uuid", nullable: false),
FromStatus = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
ToStatus = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
ActorMemberId = table.Column<Guid>(type: "uuid", nullable: true),
OccurredAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_work_item_transitions", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_work_item_transitions_TeamId",
schema: "orgboard",
table: "work_item_transitions",
column: "TeamId");
migrationBuilder.CreateIndex(
name: "IX_work_item_transitions_WorkItemId",
schema: "orgboard",
table: "work_item_transitions",
column: "WorkItemId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "work_item_transitions",
schema: "orgboard");
}
}
}
@@ -208,6 +208,43 @@ namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
b.ToTable("work_items", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.WorkItemTransition", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("ActorMemberId")
.HasColumnType("uuid");
b.Property<string>("FromStatus")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTimeOffset>("OccurredAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("TeamId")
.HasColumnType("uuid");
b.Property<string>("ToStatus")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("WorkItemId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TeamId");
b.HasIndex("WorkItemId");
b.ToTable("work_item_transitions", "orgboard");
});
#pragma warning restore 612, 618
}
}
@@ -12,6 +12,7 @@ internal sealed class OrgBoardDbContext(DbContextOptions<OrgBoardDbContext> opti
public DbSet<Seat> Seats => Set<Seat>();
public DbSet<Agent> Agents => Set<Agent>();
public DbSet<WorkItem> WorkItems => Set<WorkItem>();
public DbSet<WorkItemTransition> Transitions => Set<WorkItemTransition>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -62,5 +63,15 @@ internal sealed class OrgBoardDbContext(DbContextOptions<OrgBoardDbContext> opti
workItem.HasIndex(w => w.TeamId);
workItem.HasIndex(w => new { w.AssigneeKind, w.AssigneeId });
});
modelBuilder.Entity<WorkItemTransition>(transition =>
{
transition.ToTable("work_item_transitions");
transition.HasKey(t => t.Id);
transition.Property(t => t.FromStatus).HasConversion<string>().HasMaxLength(16);
transition.Property(t => t.ToStatus).HasConversion<string>().HasMaxLength(16);
transition.HasIndex(t => t.WorkItemId);
transition.HasIndex(t => t.TeamId);
});
}
}
@@ -0,0 +1,142 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Xunit;
namespace TeamUp.IntegrationTests;
/// <summary>
/// The accountability surface: the member directory, the invitations list, work-item transitions,
/// and the per-assignee performance metrics (pending load, done, worked hours, cycle time).
/// </summary>
public sealed class PerformanceTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
{
private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId);
private sealed record AuthResponse(string Token, Guid MemberId);
private sealed record InviteResponse(Guid InvitationId, string Token);
private sealed record TeamResponse(Guid Id, Guid OrganizationId, string Name);
private sealed record TaskResponse(
Guid Id, Guid TeamId, string Title, string? Description, string Type,
string Status, string AssigneeKind, Guid? AssigneeId, Guid? ParentId);
private sealed record MemberRow(Guid Id, string Email, string DisplayName, string? Role);
private sealed record InvitationRow(
Guid Id, string Email, string ScopeType, Guid ScopeId, string Role, string Status,
string Token, DateTimeOffset CreatedAtUtc);
private sealed record PerformanceRow(
string AssigneeKind, Guid AssigneeId, string? Name,
int Backlog, int InProgress, int InReview, int Done,
double WorkedHours, double? AvgCycleHours);
private sealed record PerformanceResponse(int UnassignedPending, List<PerformanceRow> Rows);
[Fact]
public async Task Members_invitations_and_performance_metrics_work()
{
await using var factory = new TeamUpWebFactory(postgres.ConnectionString);
using var anon = factory.CreateClient();
var owner = await PostOk<BootstrapResponse>(anon, "/api/identity/bootstrap", new
{
organizationName = "AliaSaaS",
ownerEmail = "owner@alia.test",
ownerDisplayName = "Owner",
ownerPassword = "Passw0rd!",
});
using var client = Authed(factory, owner.Token);
await client.PostAsJsonAsync("/api/orgboard/organizations", new { organizationId = owner.OrganizationId, name = "AliaSaaS" });
var team = await PostOk<TeamResponse>(client, "/api/orgboard/teams", new { organizationId = owner.OrganizationId, name = "IPNOPS" });
// The member directory lists the owner with their org role.
var members = await client.GetFromJsonAsync<List<MemberRow>>(
$"/api/identity/members?organizationId={owner.OrganizationId}");
var ownerRow = Assert.Single(members!);
Assert.Equal("Owner", ownerRow.Role);
// Invitations are listed (with the join token) for inviter-level callers…
var invite = await PostOk<InviteResponse>(client, "/api/identity/invitations", new
{
email = "dev@alia.test",
scopeType = "Organization",
scopeId = owner.OrganizationId,
role = "Member",
organizationId = owner.OrganizationId,
});
var invitations = await client.GetFromJsonAsync<List<InvitationRow>>(
$"/api/identity/invitations?organizationId={owner.OrganizationId}");
Assert.Contains(invitations!, i => i.Id == invite.InvitationId && i.Status == "Pending" && i.Token.Length > 0);
// …but a plain Member is 403'd from the invitations list and the performance view.
var member = await PostOk<AuthResponse>(anon, "/api/identity/invitations/accept",
new { token = invite.Token, displayName = "Dev", password = "Passw0rd!" });
using (var memberClient = Authed(factory, member.Token))
{
Assert.Equal(HttpStatusCode.Forbidden,
(await memberClient.GetAsync($"/api/identity/invitations?organizationId={owner.OrganizationId}")).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden,
(await memberClient.GetAsync($"/api/orgboard/performance?organizationId={owner.OrganizationId}")).StatusCode);
}
// Work a task through the board: assign → InProgress → Done (transitions recorded).
var task = await PostOk<TaskResponse>(client, "/api/orgboard/tasks", new
{
teamId = team.Id,
title = "Ship the login screen",
type = "Story",
});
await PatchOk<TaskResponse>(client, $"/api/orgboard/tasks/{task.Id}/assign", new { memberId = owner.MemberId });
await PatchOk<TaskResponse>(client, $"/api/orgboard/tasks/{task.Id}/move", new { status = "InProgress" });
await PatchOk<TaskResponse>(client, $"/api/orgboard/tasks/{task.Id}/move", new { status = "Done" });
// A second task stays unassigned and pending.
await PostOk<TaskResponse>(client, "/api/orgboard/tasks", new
{
teamId = team.Id,
title = "Unowned chore",
type = "Story",
});
var performance = await client.GetFromJsonAsync<PerformanceResponse>(
$"/api/orgboard/performance?organizationId={owner.OrganizationId}");
Assert.Equal(1, performance!.UnassignedPending);
var row = Assert.Single(performance.Rows, r => r.AssigneeKind == "Member" && r.AssigneeId == owner.MemberId);
Assert.Equal(1, row.Done);
Assert.Equal(0, row.Backlog + row.InProgress + row.InReview);
Assert.True(row.WorkedHours >= 0);
Assert.NotNull(row.AvgCycleHours); // InProgress → Done was recorded via transitions
Assert.Null(row.Name); // member names resolve client-side from the directory
}
private static HttpClient Authed(TeamUpWebFactory factory, string token)
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
return client;
}
private static async Task<T> PostOk<T>(HttpClient client, string url, object body)
{
var response = await client.PostAsJsonAsync(url, body);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var value = await response.Content.ReadFromJsonAsync<T>();
Assert.NotNull(value);
return value!;
}
private static async Task<T> PatchOk<T>(HttpClient client, string url, object body)
{
var response = await client.PatchAsJsonAsync(url, body);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var value = await response.Content.ReadFromJsonAsync<T>();
Assert.NotNull(value);
return value!;
}
}