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
|
||||
|
||||
Reference in New Issue
Block a user