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 { Toaster } from '@/components/ui/sonner'
|
||||||
import { AnalyticsPage } from '@/pages/AnalyticsPage'
|
import { AnalyticsPage } from '@/pages/AnalyticsPage'
|
||||||
import { BoardPage } from '@/pages/BoardPage'
|
import { BoardPage } from '@/pages/BoardPage'
|
||||||
|
import { CartablePage } from '@/pages/CartablePage'
|
||||||
import { LoginPage } from '@/pages/LoginPage'
|
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 { ReviewsPage } from '@/pages/ReviewsPage'
|
||||||
import { SeatsPage } from '@/pages/SeatsPage'
|
import { SeatsPage } from '@/pages/SeatsPage'
|
||||||
import { useAuth } from '@/store/auth'
|
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="/seats" element={token ? <SeatsPage /> : <Navigate to="/login" replace />} />
|
||||||
<Route path="/reviews" element={token ? <ReviewsPage /> : <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="/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 />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<Toaster richColors position="top-right" />
|
<Toaster richColors position="top-right" />
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { Link, useLocation } from 'react-router'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { cn } from '@/lib/utils'
|
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">
|
<nav className="flex flex-1 flex-col gap-1 p-3">
|
||||||
<NavItem icon={LayoutDashboard} label="Board" to="/" />
|
<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={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={ChartColumn} label="Analytics" to="/analytics" />
|
||||||
<NavItem icon={Inbox} label="Cartable" muted />
|
|
||||||
<NavItem icon={Network} label="Org chart" muted />
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<Separator className="bg-sidebar-border" />
|
<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 { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Plus, UserPlus } from 'lucide-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 { toast } from 'sonner'
|
||||||
import { AppShell } from '@/components/AppShell'
|
import { AppShell } from '@/components/AppShell'
|
||||||
import { StatusDot } from '@/components/StatusDot'
|
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
@@ -23,7 +30,9 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
|
import { useMembers, useSeats, type MemberRow, type SeatRow } from '@/lib/useDirectory'
|
||||||
import { useAuth } from '@/store/auth'
|
import { useAuth } from '@/store/auth'
|
||||||
|
|
||||||
const COLUMNS = [
|
const COLUMNS = [
|
||||||
@@ -33,20 +42,22 @@ const COLUMNS = [
|
|||||||
{ value: 'Done', label: 'Done' },
|
{ value: 'Done', label: 'Done' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
interface Team {
|
export interface Task {
|
||||||
id: string
|
|
||||||
organizationId: string
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Task {
|
|
||||||
id: string
|
id: string
|
||||||
teamId: string
|
teamId: string
|
||||||
title: string
|
title: string
|
||||||
|
description?: string | null
|
||||||
type: string
|
type: string
|
||||||
status: string
|
status: string
|
||||||
assigneeKind: string
|
assigneeKind: string
|
||||||
assigneeId?: string | null
|
assigneeId?: string | null
|
||||||
|
parentId?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Team {
|
||||||
|
id: string
|
||||||
|
organizationId: string
|
||||||
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Board {
|
interface Board {
|
||||||
@@ -56,21 +67,23 @@ interface Board {
|
|||||||
|
|
||||||
export function BoardPage() {
|
export function BoardPage() {
|
||||||
const memberId = useAuth((s) => s.memberId)
|
const memberId = useAuth((s) => s.memberId)
|
||||||
const email = useAuth((s) => s.email)
|
|
||||||
const organizationId = useAuth((s) => s.organizationId)
|
const organizationId = useAuth((s) => s.organizationId)
|
||||||
|
|
||||||
const [orgName, setOrgName] = useState('')
|
const [orgName, setOrgName] = useState('')
|
||||||
const [teams, setTeams] = useState<Team[]>([])
|
const [teams, setTeams] = useState<Team[]>([])
|
||||||
const [teamId, setTeamId] = useState<string | null>(null)
|
const [teamId, setTeamId] = useState<string | null>(null)
|
||||||
const [board, setBoard] = useState<Board | null>(null)
|
const [board, setBoard] = useState<Board | null>(null)
|
||||||
const [cartable, setCartable] = useState<Task[]>([])
|
|
||||||
const [newTeam, setNewTeam] = useState('')
|
const [newTeam, setNewTeam] = useState('')
|
||||||
const [newTask, setNewTask] = 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 () => {
|
const loadTeams = useCallback(async () => {
|
||||||
if (!organizationId) {
|
if (!organizationId) return
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const result = await api.get<Team[]>(`/api/orgboard/teams?organizationId=${organizationId}`)
|
const result = await api.get<Team[]>(`/api/orgboard/teams?organizationId=${organizationId}`)
|
||||||
setTeams(result)
|
setTeams(result)
|
||||||
@@ -83,7 +96,6 @@ export function BoardPage() {
|
|||||||
const loadBoard = useCallback(async (id: string) => {
|
const loadBoard = useCallback(async (id: string) => {
|
||||||
try {
|
try {
|
||||||
setBoard(await api.get<Board>(`/api/orgboard/board?teamId=${id}`))
|
setBoard(await api.get<Board>(`/api/orgboard/board?teamId=${id}`))
|
||||||
setCartable(await api.get<Task[]>('/api/orgboard/cartable'))
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error((err as Error).message)
|
toast.error((err as Error).message)
|
||||||
}
|
}
|
||||||
@@ -94,9 +106,7 @@ export function BoardPage() {
|
|||||||
}, [loadTeams])
|
}, [loadTeams])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (teamId) {
|
if (teamId) void loadBoard(teamId)
|
||||||
void loadBoard(teamId)
|
|
||||||
}
|
|
||||||
}, [teamId, loadBoard])
|
}, [teamId, loadBoard])
|
||||||
|
|
||||||
async function run(action: () => Promise<unknown>) {
|
async function run(action: () => Promise<unknown>) {
|
||||||
@@ -123,9 +133,7 @@ export function BoardPage() {
|
|||||||
|
|
||||||
const createTask = () =>
|
const createTask = () =>
|
||||||
run(async () => {
|
run(async () => {
|
||||||
if (!teamId) {
|
if (!teamId || !newTask.trim()) return
|
||||||
return
|
|
||||||
}
|
|
||||||
await api.post('/api/orgboard/tasks', { teamId, title: newTask, type: 'Story' })
|
await api.post('/api/orgboard/tasks', { teamId, title: newTask, type: 'Story' })
|
||||||
setNewTask('')
|
setNewTask('')
|
||||||
await loadBoard(teamId)
|
await loadBoard(teamId)
|
||||||
@@ -134,27 +142,28 @@ export function BoardPage() {
|
|||||||
const move = (id: string, status: string) =>
|
const move = (id: string, status: string) =>
|
||||||
run(async () => {
|
run(async () => {
|
||||||
await api.patch(`/api/orgboard/tasks/${id}/move`, { status })
|
await api.patch(`/api/orgboard/tasks/${id}/move`, { status })
|
||||||
if (teamId) {
|
if (teamId) await loadBoard(teamId)
|
||||||
await loadBoard(teamId)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const assignToMe = (id: string) =>
|
const allTasks = useMemo(() => board?.columns.flatMap((c) => c.items) ?? [], [board])
|
||||||
run(async () => {
|
const openTask = allTasks.find((t) => t.id === openTaskId) ?? null
|
||||||
await api.patch(`/api/orgboard/tasks/${id}/assign`, { memberId })
|
|
||||||
if (teamId) {
|
|
||||||
await loadBoard(teamId)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-6">
|
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-6">
|
||||||
<header>
|
<header>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Board</h1>
|
<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>
|
</header>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@@ -212,9 +221,7 @@ export function BoardPage() {
|
|||||||
placeholder="New task title…"
|
placeholder="New task title…"
|
||||||
className="max-w-md"
|
className="max-w-md"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') createTask()
|
||||||
createTask()
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button onClick={createTask}>
|
<Button onClick={createTask}>
|
||||||
@@ -224,94 +231,313 @@ export function BoardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<DndContext sensors={sensors} onDragEnd={onDragEnd}>
|
||||||
{COLUMNS.map((column) => {
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
const items = board?.columns.find((c) => c.status === column.value)?.items ?? []
|
{COLUMNS.map((column) => {
|
||||||
return (
|
const items = board?.columns.find((c) => c.status === column.value)?.items ?? []
|
||||||
<Card key={column.value} className="bg-muted/30">
|
return (
|
||||||
<CardHeader>
|
<DroppableColumn key={column.value} status={column.value} label={column.label} count={items.length}>
|
||||||
<CardTitle className="flex items-center justify-between text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
{items.map((task) => (
|
||||||
{column.label}
|
<DraggableCard
|
||||||
<Badge variant="secondary">{items.length}</Badge>
|
key={task.id}
|
||||||
</CardTitle>
|
task={task}
|
||||||
</CardHeader>
|
memberId={memberId}
|
||||||
<CardContent className="flex flex-col gap-3">
|
members={members}
|
||||||
{items.map((task) => {
|
seats={seats}
|
||||||
const mine = task.assigneeKind === 'Member' && task.assigneeId === memberId
|
onOpen={() => setOpenTaskId(task.id)}
|
||||||
return (
|
/>
|
||||||
<Card key={task.id}>
|
))}
|
||||||
<CardContent className="flex flex-col gap-3">
|
{items.length === 0 && <p className="py-6 text-center text-xs text-muted-foreground">No tasks</p>}
|
||||||
<div className="flex items-start justify-between gap-2">
|
</DroppableColumn>
|
||||||
<span className="text-sm font-medium leading-snug">{task.title}</span>
|
)
|
||||||
<Badge variant="outline">{task.type}</Badge>
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-2">
|
</DndContext>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TaskDrawer
|
||||||
|
task={openTask}
|
||||||
|
allTasks={allTasks}
|
||||||
|
members={members}
|
||||||
|
seats={seats}
|
||||||
|
onClose={() => setOpenTaskId(null)}
|
||||||
|
onChanged={() => teamId && loadBoard(teamId)}
|
||||||
|
onOpenTask={(id) => setOpenTaskId(id)}
|
||||||
|
/>
|
||||||
</AppShell>
|
</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 { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
|
import { diffWords } from '@/lib/diff'
|
||||||
import { useAuth } from '@/store/auth'
|
import { useAuth } from '@/store/auth'
|
||||||
|
|
||||||
interface ReviewItem {
|
interface ReviewItem {
|
||||||
@@ -156,6 +157,27 @@ function ReviewCard({ item, onDecided }: { item: ReviewItem; onDecided: (id: str
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor={`children-${item.id}`}>Child tasks (one per line)</Label>
|
<Label htmlFor={`children-${item.id}`}>Child tasks (one per line)</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|||||||
@@ -32,3 +32,15 @@ internal sealed record InviteRequest(
|
|||||||
internal sealed record InviteResponse(Guid InvitationId, string Token);
|
internal sealed record InviteResponse(Guid InvitationId, string Token);
|
||||||
|
|
||||||
internal sealed record AcceptInviteRequest(string Token, string DisplayName, string Password);
|
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("/bootstrap", Bootstrap).AllowAnonymous();
|
||||||
group.MapPost("/auth/login", Login).AllowAnonymous();
|
group.MapPost("/auth/login", Login).AllowAnonymous();
|
||||||
group.MapGet("/me", Me).RequireAuthorization();
|
group.MapGet("/me", Me).RequireAuthorization();
|
||||||
|
group.MapGet("/members", ListMembers).RequireAuthorization();
|
||||||
group.MapPost("/invitations", CreateInvitation).RequireAuthorization();
|
group.MapPost("/invitations", CreateInvitation).RequireAuthorization();
|
||||||
|
group.MapGet("/invitations", ListInvitations).RequireAuthorization();
|
||||||
group.MapPost("/invitations/accept", AcceptInvitation).AllowAnonymous();
|
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(
|
private static async Task<IResult> Bootstrap(
|
||||||
BootstrapRequest request,
|
BootstrapRequest request,
|
||||||
IdentityDbContext db,
|
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.MapGet("/seats", ListSeats).RequireAuthorization();
|
||||||
group.MapPost("/seats/{id:guid}/agent", ConfigureAgent).RequireAuthorization();
|
group.MapPost("/seats/{id:guid}/agent", ConfigureAgent).RequireAuthorization();
|
||||||
group.MapGet("/seats/{id:guid}/agent", GetAgent).RequireAuthorization();
|
group.MapGet("/seats/{id:guid}/agent", GetAgent).RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapGet("/performance", PerformanceEndpoints.Get).RequireAuthorization();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static TaskResponse ToResponse(WorkItem item) => new(
|
private static TaskResponse ToResponse(WorkItem item) => new(
|
||||||
@@ -175,7 +177,15 @@ internal static class OrgBoardEndpoints
|
|||||||
return Results.Forbid();
|
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 db.SaveChangesAsync(ct);
|
||||||
await audit.WriteAsync(new AuditEvent("task.moved", "WorkItem", item.Id, user.MemberId, request.Status.ToString()), 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");
|
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
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ internal sealed class OrgBoardDbContext(DbContextOptions<OrgBoardDbContext> opti
|
|||||||
public DbSet<Seat> Seats => Set<Seat>();
|
public DbSet<Seat> Seats => Set<Seat>();
|
||||||
public DbSet<Agent> Agents => Set<Agent>();
|
public DbSet<Agent> Agents => Set<Agent>();
|
||||||
public DbSet<WorkItem> WorkItems => Set<WorkItem>();
|
public DbSet<WorkItem> WorkItems => Set<WorkItem>();
|
||||||
|
public DbSet<WorkItemTransition> Transitions => Set<WorkItemTransition>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
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 => w.TeamId);
|
||||||
workItem.HasIndex(w => new { w.AssigneeKind, w.AssigneeId });
|
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