UI completion pass + accountability & benchmarking
UI (daily-drivable now): - Board: dnd-kit drag-and-drop between columns; click a card → task detail drawer (Sheet) with status, member assignee picker, send-to-AI-seat dispatch, description/artifact, parent/children navigation; seat-triad assignee chips (AI indigo monogram / human slate). - Cartable page (the personal pending slice), Members & invitations page (invite + copy join token; V1 sends no email), Review inbox now shows a word-level diff of your edits vs the proposal (lib/diff.ts, LCS), Org chart page (React Flow: org → teams → seats in the human/open/AI triad). Nav reordered; nothing left "soon". Accountability & benchmarking: - Identity: GET /members (directory + org role) and GET /invitations (with join token, inviter-only) — the directory also resolves names client-side everywhere. - OrgBoard: work_item_transitions recorded on every status change (AddWorkItemTransitions migration); GET /performance — per assignee (human and AI on the same scale): pending by column, done, worked hours (time in InProgress), avg cycle time (start of work → done), plus the unassigned-pending count. Owner-level capability. - Performance page: benchmark table merging board metrics with AI trust metrics (approval rate + edit distance from analytics); flags work with no one accountable. Verified: build green; ArchitectureTests 8/8; IntegrationTests 43/43 (new: directory, invitations list + Member 403s, transition-derived worked-hours/cycle-time, unassigned count); client npm build green (TS strict). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+351
-125
@@ -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,94 +231,313 @@ export function BoardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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">
|
||||
<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>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{items.map((task) => {
|
||||
const mine = task.assigneeKind === 'Member' && task.assigneeId === memberId
|
||||
return (
|
||||
<Card key={task.id}>
|
||||
<CardContent className="flex flex-col gap-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">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{COLUMNS.map((c) => (
|
||||
<SelectItem key={c.value} value={c.value}>
|
||||
{c.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</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"
|
||||
>
|
||||
<span>{task.title}</span>
|
||||
<Badge variant="secondary">{task.status}</Badge>
|
||||
</div>
|
||||
))}
|
||||
{cartable.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">Nothing assigned to you yet.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<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 (
|
||||
<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">
|
||||
{label}
|
||||
<Badge variant="secondary">{count}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<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 (
|
||||
<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>
|
||||
<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>
|
||||
<SelectGroup>
|
||||
{COLUMNS.map((c) => (
|
||||
<SelectItem key={c.value} value={c.value}>
|
||||
{c.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<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.')
|
||||
}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+254
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -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!;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user