Compare commits
13 Commits
848bd49352
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d6a0638c59 | |||
| c12935ad74 | |||
| 5c2b697b66 | |||
| 0658061580 | |||
| 1e33d57b4e | |||
| 861efa4e20 | |||
| e26f304675 | |||
| 63fef8799d | |||
| 1f562fd633 | |||
| 9993ebb2b4 | |||
| cb9ce34309 | |||
| b398e68c8b | |||
| 9c18ddfc8f |
@@ -4,12 +4,14 @@ import { AgentProfilesPage } from '@/pages/AgentProfilesPage'
|
||||
import { AnalyticsPage } from '@/pages/AnalyticsPage'
|
||||
import { BoardPage } from '@/pages/BoardPage'
|
||||
import { CartablePage } from '@/pages/CartablePage'
|
||||
import { DeliveryPage } from '@/pages/DeliveryPage'
|
||||
import { GetStartedPage } from '@/pages/GetStartedPage'
|
||||
import { KnowledgeBasePage } from '@/pages/KnowledgeBasePage'
|
||||
import { LoginPage } from '@/pages/LoginPage'
|
||||
import { MembersPage } from '@/pages/MembersPage'
|
||||
import { OrgChartPage } from '@/pages/OrgChartPage'
|
||||
import { PerformancePage } from '@/pages/PerformancePage'
|
||||
import { PipelinePage } from '@/pages/PipelinePage'
|
||||
import { ProductProfilesPage } from '@/pages/ProductProfilesPage'
|
||||
import { ReviewsPage } from '@/pages/ReviewsPage'
|
||||
import { SeatsPage } from '@/pages/SeatsPage'
|
||||
@@ -31,6 +33,8 @@ export default function App() {
|
||||
<Route path="/seats" element={token ? <SeatsPage /> : <Navigate to="/login" replace />} />
|
||||
<Route path="/reviews" element={token ? <ReviewsPage /> : <Navigate to="/login" replace />} />
|
||||
<Route path="/analytics" element={token ? <AnalyticsPage /> : <Navigate to="/login" replace />} />
|
||||
<Route path="/delivery" element={token ? <DeliveryPage /> : <Navigate to="/login" replace />} />
|
||||
<Route path="/pipeline" element={token ? <PipelinePage /> : <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 />} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { Link, useLocation } from 'react-router'
|
||||
import {
|
||||
BookMarked,
|
||||
@@ -17,16 +17,58 @@ import {
|
||||
Rocket,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Workflow,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuth } from '@/store/auth'
|
||||
|
||||
/** Event other pages fire after dispatching a run or deciding a review, so the badge updates at once. */
|
||||
export const REVIEWS_CHANGED = 'teamup:reviews-changed'
|
||||
|
||||
/** Tracks the count of held actions awaiting review: polls, and refetches on focus / a change event. */
|
||||
function usePendingReviewCount(organizationId: string | null): number {
|
||||
const [count, setCount] = useState(0)
|
||||
useEffect(() => {
|
||||
if (!organizationId) {
|
||||
setCount(0)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
const tick = async () => {
|
||||
try {
|
||||
const items = await api.get<unknown[]>(`/api/governance/reviews?organizationId=${organizationId}&status=Pending`)
|
||||
if (!cancelled) setCount(items.length)
|
||||
} catch {
|
||||
// leave the last known count on a transient failure
|
||||
}
|
||||
}
|
||||
void tick()
|
||||
const id = setInterval(tick, 6000)
|
||||
const refetch = () => void tick()
|
||||
window.addEventListener(REVIEWS_CHANGED, refetch)
|
||||
window.addEventListener('focus', refetch)
|
||||
document.addEventListener('visibilitychange', refetch)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(id)
|
||||
window.removeEventListener(REVIEWS_CHANGED, refetch)
|
||||
window.removeEventListener('focus', refetch)
|
||||
document.removeEventListener('visibilitychange', refetch)
|
||||
}
|
||||
}, [organizationId])
|
||||
return count
|
||||
}
|
||||
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
const email = useAuth((s) => s.email)
|
||||
const logout = useAuth((s) => s.logout)
|
||||
const organizationId = useAuth((s) => s.organizationId)
|
||||
const reviewCount = usePendingReviewCount(organizationId)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen text-foreground">
|
||||
@@ -59,7 +101,8 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<NavItem icon={LayoutDashboard} label="Board" to="/" color="#38bdf8" />
|
||||
<NavItem icon={Sparkles} label="Team" to="/team" color="#38bdf8" />
|
||||
<NavItem icon={Inbox} label="Cartable" to="/cartable" color="#38bdf8" />
|
||||
<NavItem icon={ShieldCheck} label="Review inbox" to="/reviews" color="#38bdf8" />
|
||||
<NavItem icon={Workflow} label="Delivery pipeline" to="/pipeline" color="#38bdf8" />
|
||||
<NavItem icon={ShieldCheck} label="Review inbox" to="/reviews" color="#38bdf8" badge={reviewCount} />
|
||||
|
||||
<NavSection label="Organization" />
|
||||
<NavItem icon={Boxes} label="Structure" to="/structure" color="#34d399" />
|
||||
@@ -73,6 +116,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<NavItem icon={Package} label="Product profiles" to="/product-profiles" color="#a78bfa" />
|
||||
|
||||
<NavSection label="Insights" />
|
||||
<NavItem icon={TrendingUp} label="Delivery" to="/delivery" color="#2dd4bf" />
|
||||
<NavItem icon={Gauge} label="Performance" to="/performance" color="#2dd4bf" />
|
||||
<NavItem icon={ChartColumn} label="Analytics" to="/analytics" color="#2dd4bf" />
|
||||
|
||||
@@ -115,12 +159,14 @@ function NavItem({
|
||||
to,
|
||||
muted,
|
||||
color,
|
||||
badge,
|
||||
}: {
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
to?: string
|
||||
muted?: boolean
|
||||
color?: string
|
||||
badge?: number
|
||||
}) {
|
||||
const location = useLocation()
|
||||
const active = to ? location.pathname === to : false
|
||||
@@ -142,6 +188,11 @@ function NavItem({
|
||||
<Icon className="size-3.5" />
|
||||
</span>
|
||||
{label}
|
||||
{!!badge && badge > 0 && (
|
||||
<span className="ml-auto grid h-5 min-w-5 place-items-center rounded-full bg-amber-400 px-1.5 text-[11px] font-semibold text-amber-950">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
{muted && <span className="ml-auto text-[10px] uppercase tracking-wide opacity-70">soon</span>}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
/**
|
||||
* Pull the most likely runnable code out of an artifact: the largest fenced code block if the agent
|
||||
* wrapped its answer in markdown, otherwise the whole text.
|
||||
*/
|
||||
export function extractCode(artifact: string): string {
|
||||
const blocks = [...artifact.matchAll(/```(?:tsx|jsx|ts|js|javascript|typescript)?\s*\n([\s\S]*?)```/g)].map((m) => m[1])
|
||||
if (blocks.length === 0) return artifact.trim()
|
||||
return blocks.sort((a, b) => b.length - a.length)[0].trim()
|
||||
}
|
||||
|
||||
/** Builds a self-contained HTML document that transpiles the component (Babel) and renders it with
|
||||
* React + Tailwind from CDN. The agent's code must define a component named `App`. */
|
||||
function harness(code: string): string {
|
||||
const json = JSON.stringify(code)
|
||||
return `<!doctype html><html><head><meta charset="utf-8"/>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7/babel.min.js"></script>
|
||||
<script type="importmap">{"imports":{"react":"https://esm.sh/react@18.3.1","react-dom":"https://esm.sh/react-dom@18.3.1","react-dom/client":"https://esm.sh/react-dom@18.3.1/client","react/jsx-runtime":"https://esm.sh/react@18.3.1/jsx-runtime","react/jsx-dev-runtime":"https://esm.sh/react@18.3.1/jsx-dev-runtime"}}</script>
|
||||
<style>body{margin:0;font-family:system-ui,sans-serif}.__err{padding:18px;font:12px/1.6 ui-monospace,monospace;color:#b91c1c;white-space:pre-wrap}</style>
|
||||
</head><body>
|
||||
<div id="root"></div>
|
||||
<script>
|
||||
var CODE = ${json};
|
||||
function esc(s){return String(s).replace(/[<&]/g,function(c){return c==='<'?'<':'&'})}
|
||||
function showErr(m){var r=document.getElementById('root');if(r)r.innerHTML='<div class="__err">Preview error:\\n'+esc(m)+'</div>'}
|
||||
window.addEventListener('error',function(e){showErr(e.message||e)});
|
||||
window.addEventListener('unhandledrejection',function(e){showErr((e.reason&&e.reason.message)||e.reason||'Error')});
|
||||
try{
|
||||
var t = Babel.transform(CODE,{presets:[['react',{runtime:'automatic'}],'typescript'],filename:'App.tsx'}).code;
|
||||
var boot = t + "\\nimport React from 'react';\\nimport { createRoot } from 'react-dom/client';\\nvar __C = (typeof App!=='undefined') ? App : null;\\nif(!__C){ throw new Error('No component named App. Output a component, e.g. export default function App(){ return <div/> }'); }\\ncreateRoot(document.getElementById('root')).render(React.createElement(__C));";
|
||||
var s=document.createElement('script');s.type='module';s.textContent=boot;document.body.appendChild(s);
|
||||
}catch(e){showErr(e.message)}
|
||||
</script>
|
||||
</body></html>`
|
||||
}
|
||||
|
||||
/** Runs an agent's React component artifact live, in a sandboxed iframe. */
|
||||
export function LivePreview({ artifact, className }: { artifact: string; className?: string }) {
|
||||
const srcDoc = useMemo(() => harness(extractCode(artifact)), [artifact])
|
||||
return (
|
||||
<iframe
|
||||
title="Live preview"
|
||||
srcDoc={srcDoc}
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
className={className ?? 'h-[70vh] w-full rounded-lg border bg-white'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { AlertTriangle, BrainCircuit, Check, Inbox, Loader2, Wrench } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
/** The live run record as returned by GET /api/assembler/runs/{id}. */
|
||||
interface Run {
|
||||
id: string
|
||||
status: 'Queued' | 'Running' | 'Completed' | 'Failed' | string
|
||||
actionType: string | null
|
||||
actionRisk: string | null
|
||||
output: string | null
|
||||
error: string | null
|
||||
resultJson: string | null
|
||||
latencyMs: number | null
|
||||
createdAtUtc: string
|
||||
completedAtUtc: string | null
|
||||
}
|
||||
|
||||
interface ToolCall {
|
||||
name?: string
|
||||
tool?: string
|
||||
server?: string
|
||||
ok?: boolean
|
||||
}
|
||||
|
||||
const TERMINAL = new Set(['Completed', 'Failed'])
|
||||
|
||||
/** Pulls the tool-call list out of the run's result JSON, if the agent used any MCP tools. */
|
||||
function readToolCalls(run: Run | null): ToolCall[] {
|
||||
if (!run?.resultJson) return []
|
||||
try {
|
||||
const parsed = JSON.parse(run.resultJson) as { toolCalls?: ToolCall[] }
|
||||
return Array.isArray(parsed.toolCalls) ? parsed.toolCalls : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches one agent run live: polls the run until it reaches a terminal state and renders a
|
||||
* step-by-step timeline (Queued → Thinking → Done) with elapsed time, the action the agent took,
|
||||
* its risk tag, any tool calls, and where the result landed.
|
||||
*/
|
||||
export function RunProgress({
|
||||
runId,
|
||||
onSettled,
|
||||
}: {
|
||||
runId: string
|
||||
/** Fired once when the run reaches Completed/Failed — lets the parent refresh the board + badge. */
|
||||
onSettled?: (run: Run) => void
|
||||
}) {
|
||||
const [run, setRun] = useState<Run | null>(null)
|
||||
const [elapsed, setElapsed] = useState(0)
|
||||
const settledRef = useRef(false)
|
||||
const onSettledRef = useRef(onSettled)
|
||||
onSettledRef.current = onSettled
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
let pollTimer = 0
|
||||
const start = Date.now()
|
||||
|
||||
const tick = window.setInterval(() => {
|
||||
if (active && !settledRef.current) setElapsed(Math.round((Date.now() - start) / 1000))
|
||||
}, 1000)
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const r = await api.get<Run>(`/api/assembler/runs/${runId}`)
|
||||
if (!active) return
|
||||
setRun(r)
|
||||
if (TERMINAL.has(r.status)) {
|
||||
settledRef.current = true
|
||||
onSettledRef.current?.(r)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
/* transient — keep polling */
|
||||
}
|
||||
if (active) pollTimer = window.setTimeout(poll, 1200)
|
||||
}
|
||||
void poll()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
window.clearTimeout(pollTimer)
|
||||
window.clearInterval(tick)
|
||||
}
|
||||
}, [runId])
|
||||
|
||||
const status = run?.status ?? 'Queued'
|
||||
const failed = status === 'Failed'
|
||||
const done = status === 'Completed'
|
||||
const running = status === 'Running'
|
||||
const tools = readToolCalls(run)
|
||||
const seconds = run?.latencyMs != null ? (run.latencyMs / 1000).toFixed(1) : elapsed.toString()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-xl border bg-card/60 p-4 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold">Agent at work</span>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{seconds}s</span>
|
||||
</div>
|
||||
|
||||
<ol className="flex flex-col gap-2.5">
|
||||
<Step
|
||||
state={status === 'Queued' ? 'active' : 'done'}
|
||||
icon={Inbox}
|
||||
title="Queued"
|
||||
detail="Task accepted — waiting for a worker."
|
||||
/>
|
||||
<Step
|
||||
state={failed && !done ? (run?.output ? 'done' : 'error') : running ? 'active' : status === 'Queued' ? 'pending' : 'done'}
|
||||
icon={BrainCircuit}
|
||||
title="Thinking"
|
||||
detail="Assembling the prompt and calling the model."
|
||||
/>
|
||||
{tools.length > 0 && (
|
||||
<Step
|
||||
state="done"
|
||||
icon={Wrench}
|
||||
title={`Used ${tools.length} tool${tools.length === 1 ? '' : 's'}`}
|
||||
detail={tools.map((t) => t.name ?? t.tool).filter(Boolean).join(', ')}
|
||||
/>
|
||||
)}
|
||||
<Step
|
||||
state={failed ? 'error' : done ? 'done' : 'pending'}
|
||||
icon={done ? Check : AlertTriangle}
|
||||
title={failed ? 'Failed' : 'Delivered'}
|
||||
detail={
|
||||
failed
|
||||
? run?.error ?? 'The run failed.'
|
||||
: done
|
||||
? `${run?.actionType ?? 'Result'} produced — sent to the review inbox.`
|
||||
: 'Result will land in the review inbox.'
|
||||
}
|
||||
/>
|
||||
</ol>
|
||||
|
||||
{done && run?.actionType && (
|
||||
<div className="flex flex-wrap items-center gap-2 border-t pt-3">
|
||||
<span className="rounded-md bg-muted px-2 py-0.5 text-xs font-medium">{run.actionType}</span>
|
||||
{run.actionRisk && (
|
||||
<span className="rounded-md bg-amber-500/15 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-400">
|
||||
{run.actionRisk} risk
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{done && run?.output && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">Show output</summary>
|
||||
<div className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap rounded-lg bg-muted p-3 leading-relaxed">
|
||||
{run.output}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type StepState = 'pending' | 'active' | 'done' | 'error'
|
||||
|
||||
function Step({
|
||||
state,
|
||||
icon: Icon,
|
||||
title,
|
||||
detail,
|
||||
}: {
|
||||
state: StepState
|
||||
icon: typeof Check
|
||||
title: string
|
||||
detail?: string
|
||||
}) {
|
||||
const ring =
|
||||
state === 'done'
|
||||
? 'border-approved bg-approved/15 text-approved'
|
||||
: state === 'active'
|
||||
? 'border-primary bg-primary/15 text-primary'
|
||||
: state === 'error'
|
||||
? 'border-destructive bg-destructive/15 text-destructive'
|
||||
: 'border-border bg-muted/40 text-muted-foreground'
|
||||
|
||||
return (
|
||||
<li className="flex items-start gap-3">
|
||||
<span className={`mt-0.5 grid size-7 shrink-0 place-items-center rounded-full border ${ring}`}>
|
||||
{state === 'active' ? <Loader2 className="size-3.5 animate-spin" /> : <Icon className="size-3.5" />}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className={`text-sm font-medium ${state === 'pending' ? 'text-muted-foreground' : ''}`}>{title}</p>
|
||||
{detail && <p className="text-xs text-muted-foreground">{detail}</p>}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
+12
-1
@@ -21,7 +21,18 @@ async function request<T>(method: string, url: string, body?: unknown): Promise<
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
return contentType.includes('application/json') ? ((await response.json()) as T) : (undefined as T)
|
||||
if (contentType.includes('application/json')) {
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
// A non-JSON 2xx on an /api call is almost always the SPA fallback (index.html) — i.e. the route
|
||||
// doesn't exist on the backend that's running (a stale build). Fail loudly so callers surface it,
|
||||
// instead of returning undefined and letting the page crash on `.map`/`.length`.
|
||||
if (url.startsWith('/api') && contentType.includes('text/html')) {
|
||||
throw new Error(`Unexpected HTML from ${url}. The API is likely running an older build — restart the server.`)
|
||||
}
|
||||
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
export const api = {
|
||||
|
||||
+108
-11
@@ -8,9 +8,11 @@ import {
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core'
|
||||
import { Bot, Plus } from 'lucide-react'
|
||||
import { Bot, Play, Plus, Sparkles, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { AppShell } from '@/components/AppShell'
|
||||
import { AppShell, REVIEWS_CHANGED } from '@/components/AppShell'
|
||||
import { LivePreview } from '@/components/LivePreview'
|
||||
import { RunProgress } from '@/components/RunProgress'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -73,7 +75,8 @@ export function BoardPage() {
|
||||
|
||||
const [orgName, setOrgName] = useState('')
|
||||
const [teams, setTeams] = useState<Team[]>([])
|
||||
const [teamId, setTeamId] = useState<string | null>(null)
|
||||
// Remember the selected team across refreshes (it used to snap back to the first team).
|
||||
const [teamId, setTeamId] = useState<string | null>(() => localStorage.getItem('teamup.board.team'))
|
||||
const [board, setBoard] = useState<Board | null>(null)
|
||||
const [newTeam, setNewTeam] = useState('')
|
||||
const [newTask, setNewTask] = useState('')
|
||||
@@ -90,7 +93,10 @@ export function BoardPage() {
|
||||
try {
|
||||
const result = await api.get<Team[]>(`/api/orgboard/teams?organizationId=${organizationId}`)
|
||||
setTeams(result)
|
||||
setTeamId((current) => current ?? result[0]?.id ?? null)
|
||||
// Keep the current/remembered team if it still exists; otherwise fall back to the first.
|
||||
setTeamId((current) =>
|
||||
current && result.some((team) => team.id === current) ? current : result[0]?.id ?? null,
|
||||
)
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
}
|
||||
@@ -112,6 +118,11 @@ export function BoardPage() {
|
||||
if (teamId) void loadBoard(teamId)
|
||||
}, [teamId, loadBoard])
|
||||
|
||||
// Persist the selected team so a refresh stays on it.
|
||||
useEffect(() => {
|
||||
if (teamId) localStorage.setItem('teamup.board.team', teamId)
|
||||
}, [teamId])
|
||||
|
||||
async function run(action: () => Promise<unknown>) {
|
||||
try {
|
||||
await action()
|
||||
@@ -396,8 +407,16 @@ function TaskDrawer({
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [seatId, setSeatId] = useState<string>('')
|
||||
const [preview, setPreview] = useState(false)
|
||||
const [runId, setRunId] = useState<string | null>(null)
|
||||
const aiSeats = seats.filter((s) => s.state === 'Ai')
|
||||
|
||||
// Switching to a different task clears the live run panel so it never shows a stale run.
|
||||
const taskId = task?.id
|
||||
useEffect(() => {
|
||||
setRunId(null)
|
||||
}, [taskId])
|
||||
|
||||
if (!task) {
|
||||
return null
|
||||
}
|
||||
@@ -502,17 +521,38 @@ function TaskDrawer({
|
||||
</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.',
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
let started: string | null = null
|
||||
act(async () => {
|
||||
const run = await api.post<{ id: string }>('/api/assembler/runs', { seatId, workItemId: task.id })
|
||||
started = run?.id ?? null
|
||||
// Visible feedback: the task leaves Backlog for In Progress while the agent works.
|
||||
if (task.status === 'Backlog') {
|
||||
await api.patch(`/api/orgboard/tasks/${task.id}/move`, { status: 'InProgress' })
|
||||
}
|
||||
}, 'Agent started — watch it work below.').then(() => {
|
||||
if (started) setRunId(started)
|
||||
window.dispatchEvent(new Event(REVIEWS_CHANGED))
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Bot data-icon="inline-start" />
|
||||
Run
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Live process: watch the agent move through Queued → Thinking → Delivered in real time. */}
|
||||
{runId && (
|
||||
<RunProgress
|
||||
key={runId}
|
||||
runId={runId}
|
||||
onSettled={() => {
|
||||
// Result is in the review inbox and the board has moved — refresh both.
|
||||
window.dispatchEvent(new Event(REVIEWS_CHANGED))
|
||||
onChanged()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -529,7 +569,30 @@ function TaskDrawer({
|
||||
|
||||
{children.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Child tasks</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Child tasks</Label>
|
||||
{aiSeats.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
act(async () => {
|
||||
const res = await api.post<{ dispatched: number }>(
|
||||
`/api/orgboard/tasks/${task.id}/run-all`,
|
||||
seatId ? { seatId } : {},
|
||||
)
|
||||
if (res.dispatched === 0) throw new Error('Nothing left to run — all children are done or delivered.')
|
||||
}, 'Autopilot started — running every outstanding child on the AI seats.').then(() => {
|
||||
window.dispatchEvent(new Event(REVIEWS_CHANGED))
|
||||
setTimeout(() => window.dispatchEvent(new Event(REVIEWS_CHANGED)), 5000)
|
||||
})
|
||||
}
|
||||
>
|
||||
<Sparkles data-icon="inline-start" /> Run all with AI
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{children.map((child) => (
|
||||
<button
|
||||
@@ -545,7 +608,41 @@ function TaskDrawer({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex items-center gap-2 border-t pt-4">
|
||||
{task.description && (
|
||||
<Button variant="outline" size="sm" onClick={() => setPreview(true)}>
|
||||
<Play data-icon="inline-start" /> Preview
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete "${task.title}"? This can't be undone.`)) {
|
||||
act(() => api.del(`/api/orgboard/tasks/${task.id}`), 'Task deleted.').then(onClose)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 data-icon="inline-start" /> Delete task
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
|
||||
{preview && task.description && (
|
||||
<div className="fixed inset-0 z-[100] flex flex-col gap-2 bg-background/95 p-4 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Live preview · {task.title}</span>
|
||||
<Button size="sm" variant="outline" onClick={() => setPreview(false)}>Close</Button>
|
||||
</div>
|
||||
<LivePreview artifact={task.description} className="w-full flex-1 rounded-lg border bg-white" />
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Runs the artifact as a React component (Babel + Tailwind, no build). Define a component named App.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Download, Gauge } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { AppShell } from '@/components/AppShell'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { api } from '@/lib/api'
|
||||
import { useAuth } from '@/store/auth'
|
||||
|
||||
interface Product {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface Team {
|
||||
id: string
|
||||
name: string
|
||||
productId: string | null
|
||||
}
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
title: string
|
||||
status: string
|
||||
type: string
|
||||
}
|
||||
|
||||
interface Board {
|
||||
columns: { status: string; items: Task[] }[]
|
||||
}
|
||||
|
||||
interface Analytics {
|
||||
approvalRate: number | null
|
||||
avgEditDistance: number | null
|
||||
tasksDone: number
|
||||
pendingReviews: number
|
||||
}
|
||||
|
||||
const COLUMNS = ['Backlog', 'InProgress', 'InReview', 'Done'] as const
|
||||
const LABEL: Record<string, string> = { Backlog: 'Backlog', InProgress: 'In progress', InReview: 'In review', Done: 'Done' }
|
||||
|
||||
interface TeamProgress {
|
||||
team: Team
|
||||
counts: Record<string, number>
|
||||
total: number
|
||||
done: number
|
||||
remaining: Task[]
|
||||
}
|
||||
|
||||
export function DeliveryPage() {
|
||||
const organizationId = useAuth((s) => s.organizationId)
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
const [productId, setProductId] = useState<string | null>(() => localStorage.getItem('teamup.delivery.product'))
|
||||
const [teams, setTeams] = useState<TeamProgress[]>([])
|
||||
const [analytics, setAnalytics] = useState<Analytics | null>(null)
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!organizationId) return
|
||||
void (async () => {
|
||||
try {
|
||||
const [list, an] = await Promise.all([
|
||||
api.get<Product[]>(`/api/orgboard/products?organizationId=${organizationId}`),
|
||||
api.get<Analytics>(`/api/governance/analytics?organizationId=${organizationId}`).catch(() => null),
|
||||
])
|
||||
setProducts(list)
|
||||
setAnalytics(an)
|
||||
setProductId((cur) => (cur && list.some((p) => p.id === cur) ? cur : list[0]?.id ?? null))
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
}
|
||||
})()
|
||||
}, [organizationId])
|
||||
|
||||
const loadProduct = useCallback(async (pid: string) => {
|
||||
try {
|
||||
const allTeams = await api.get<Team[]>(`/api/orgboard/teams?organizationId=${organizationId}`)
|
||||
const productTeams = allTeams.filter((t) => t.productId === pid)
|
||||
const progress = await Promise.all(
|
||||
productTeams.map(async (team) => {
|
||||
const board = await api.get<Board>(`/api/orgboard/board?teamId=${team.id}`).catch(() => ({ columns: [] }))
|
||||
const counts: Record<string, number> = {}
|
||||
let total = 0
|
||||
const remaining: Task[] = []
|
||||
for (const col of board.columns) {
|
||||
counts[col.status] = col.items.length
|
||||
total += col.items.length
|
||||
if (col.status !== 'Done') remaining.push(...col.items)
|
||||
}
|
||||
return { team, counts, total, done: counts.Done ?? 0, remaining }
|
||||
}),
|
||||
)
|
||||
setTeams(progress)
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
}
|
||||
}, [organizationId])
|
||||
|
||||
useEffect(() => {
|
||||
if (productId) {
|
||||
localStorage.setItem('teamup.delivery.product', productId)
|
||||
void loadProduct(productId)
|
||||
}
|
||||
}, [productId, loadProduct])
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const counts: Record<string, number> = { Backlog: 0, InProgress: 0, InReview: 0, Done: 0 }
|
||||
let total = 0
|
||||
for (const t of teams) {
|
||||
for (const c of COLUMNS) counts[c] += t.counts[c] ?? 0
|
||||
total += t.total
|
||||
}
|
||||
const done = counts.Done
|
||||
const pct = total > 0 ? Math.round((done / total) * 100) : 0
|
||||
return { counts, total, done, pct, remaining: total - done }
|
||||
}, [teams])
|
||||
|
||||
const product = products.find((p) => p.id === productId) ?? null
|
||||
|
||||
// Download the product as a zip of its delivered artifacts. The export endpoint streams a file, so we
|
||||
// fetch it with the auth header (the api helper only does JSON), then trigger a browser download.
|
||||
const downloadProject = useCallback(async () => {
|
||||
if (!productId) return
|
||||
setDownloading(true)
|
||||
try {
|
||||
const token = useAuth.getState().token
|
||||
const res = await fetch(`/api/orgboard/products/${productId}/export`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
if (!res.ok) throw new Error(`Export failed (${res.status})`)
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${(product?.name ?? 'project').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}.zip`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
}
|
||||
}, [productId, product])
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="mx-auto max-w-4xl p-6">
|
||||
<header className="mb-5 flex items-center justify-between gap-4">
|
||||
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
|
||||
<Gauge className="size-6" /> Delivery
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{product && (
|
||||
<Button variant="outline" size="sm" disabled={downloading} onClick={downloadProject}>
|
||||
<Download data-icon="inline-start" />
|
||||
{downloading ? 'Preparing…' : 'Download project'}
|
||||
</Button>
|
||||
)}
|
||||
{products.length > 0 && (
|
||||
<Select value={productId ?? ''} onValueChange={setProductId}>
|
||||
<SelectTrigger className="w-56"><SelectValue placeholder="Pick a product" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{products.map((p) => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{product && (
|
||||
<>
|
||||
{/* Progress hero */}
|
||||
<div className="mb-5 rounded-xl border bg-card/60 p-5 backdrop-blur-sm">
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{product.name}</p>
|
||||
<p className="text-3xl font-semibold">{totals.pct}% complete</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{totals.done} of {totals.total} tasks done · {totals.remaining} remaining
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 h-3 w-full overflow-hidden rounded-full bg-muted/60">
|
||||
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${totals.pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column breakdown */}
|
||||
<div className="mb-5 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{COLUMNS.map((c) => (
|
||||
<div key={c} className="rounded-lg border bg-card/50 p-3 backdrop-blur-sm">
|
||||
<p className="text-xs text-muted-foreground">{LABEL[c]}</p>
|
||||
<p className="text-2xl font-semibold">{totals.counts[c]}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quality (from analytics) */}
|
||||
{analytics && (
|
||||
<div className="mb-5 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<Stat label="Approval rate" value={analytics.approvalRate == null ? '—' : `${Math.round(analytics.approvalRate * 100)}%`} />
|
||||
<Stat label="Avg edit distance" value={analytics.avgEditDistance == null ? '—' : analytics.avgEditDistance.toFixed(2)} />
|
||||
<Stat label="Awaiting review" value={String(analytics.pendingReviews)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Per team */}
|
||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Teams</h2>
|
||||
<div className="mb-6 flex flex-col gap-2">
|
||||
{teams.map((t) => {
|
||||
const pct = t.total > 0 ? Math.round((t.done / t.total) * 100) : 0
|
||||
return (
|
||||
<div key={t.team.id} className="flex items-center gap-3 rounded-lg border bg-card/50 px-3 py-2 backdrop-blur-sm">
|
||||
<span className="w-40 shrink-0 truncate text-sm font-medium">{t.team.name}</span>
|
||||
<div className="h-2 flex-1 overflow-hidden rounded-full bg-muted/60">
|
||||
<div className="h-full rounded-full bg-approved" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="w-24 shrink-0 text-right text-xs text-muted-foreground">{t.done}/{t.total} · {pct}%</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{teams.length === 0 && <p className="text-sm text-muted-foreground">No teams on this product yet.</p>}
|
||||
</div>
|
||||
|
||||
{/* Remaining */}
|
||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Remaining ({totals.remaining})
|
||||
</h2>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{teams.flatMap((t) => t.remaining.map((task) => ({ task, team: t.team.name }))).slice(0, 40).map(({ task, team }) => (
|
||||
<div key={task.id} className="flex items-center gap-2 rounded-md border bg-card/40 px-3 py-1.5 text-sm backdrop-blur-sm">
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] uppercase text-muted-foreground">{LABEL[task.status] ?? task.status}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{task.title}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{team}</span>
|
||||
</div>
|
||||
))}
|
||||
{totals.remaining === 0 && <p className="text-sm text-muted-foreground">🎉 Nothing remaining — everything is done.</p>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card/50 p-3 backdrop-blur-sm">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="text-2xl font-semibold">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ArrowRight, Check, GitBranch, Plus, Workflow } 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 } 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 {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { api } from '@/lib/api'
|
||||
import { useAuth } from '@/store/auth'
|
||||
|
||||
interface Division {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface ChangeSummary {
|
||||
id: string
|
||||
customerName: string
|
||||
title: string
|
||||
status: string
|
||||
estimateHours: number | null
|
||||
amount: number | null
|
||||
currency: string
|
||||
stepCount: number
|
||||
doneStepCount: number
|
||||
}
|
||||
|
||||
interface ChangeStep {
|
||||
id: string
|
||||
divisionId: string | null
|
||||
divisionName: string | null
|
||||
title: string
|
||||
estimateHours: number
|
||||
status: string
|
||||
dependsOnStepId: string | null
|
||||
order: number
|
||||
}
|
||||
|
||||
interface ChangeDetail {
|
||||
summary: ChangeSummary
|
||||
description: string | null
|
||||
totalStepHours: number
|
||||
steps: ChangeStep[]
|
||||
}
|
||||
|
||||
const STAGES = ['Requested', 'Estimated', 'Approved', 'Paid', 'Live'] as const
|
||||
const STEP_STATUSES = ['Pending', 'InProgress', 'Done', 'Blocked'] as const
|
||||
|
||||
// The single commercial action available at each pipeline stage.
|
||||
const ACTION: Record<string, { label: string; path: string } | null> = {
|
||||
Requested: { label: 'Send estimate', path: 'estimate' },
|
||||
Estimated: { label: 'Mark approved', path: 'approve' },
|
||||
Approved: { label: 'Record payment', path: 'pay' },
|
||||
Paid: { label: 'Go live', path: 'go-live' },
|
||||
Live: null,
|
||||
Rejected: null,
|
||||
}
|
||||
|
||||
const STATUS_TONE: Record<string, string> = {
|
||||
Requested: 'bg-muted text-muted-foreground',
|
||||
Estimated: 'bg-sky-500/15 text-sky-700 dark:text-sky-400',
|
||||
Approved: 'bg-violet-500/15 text-violet-700 dark:text-violet-400',
|
||||
Paid: 'bg-amber-500/15 text-amber-700 dark:text-amber-400',
|
||||
Live: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
|
||||
Rejected: 'bg-destructive/15 text-destructive',
|
||||
}
|
||||
|
||||
export function PipelinePage() {
|
||||
const organizationId = useAuth((s) => s.organizationId)
|
||||
const [requests, setRequests] = useState<ChangeSummary[]>([])
|
||||
const [divisions, setDivisions] = useState<Division[]>([])
|
||||
const [openId, setOpenId] = useState<string | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!organizationId) return
|
||||
try {
|
||||
const [list, divs] = await Promise.all([
|
||||
api.get<ChangeSummary[]>(`/api/orgboard/change-requests?organizationId=${organizationId}`),
|
||||
api.get<Division[]>(`/api/orgboard/divisions?organizationId=${organizationId}`).catch(() => []),
|
||||
])
|
||||
setRequests(Array.isArray(list) ? list : [])
|
||||
setDivisions(Array.isArray(divs) ? divs : [])
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
}
|
||||
}, [organizationId])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="mx-auto max-w-4xl p-6">
|
||||
<header className="mb-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
|
||||
<Workflow className="size-6" /> Delivery pipeline
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Customer change requests across divisions: estimate → approve → pay → go-live.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setCreating((v) => !v)}>
|
||||
<Plus data-icon="inline-start" /> New request
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{creating && organizationId && (
|
||||
<NewRequestForm
|
||||
organizationId={organizationId}
|
||||
onCreated={() => {
|
||||
setCreating(false)
|
||||
void load()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pipeline legend */}
|
||||
<div className="mb-5 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{STAGES.map((s, i) => (
|
||||
<span key={s} className="flex items-center gap-1.5">
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 font-medium">{s}</span>
|
||||
{i < STAGES.length - 1 && <ArrowRight className="size-3" />}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{requests.map((cr) => {
|
||||
const pct = cr.stepCount > 0 ? Math.round((cr.doneStepCount / cr.stepCount) * 100) : 0
|
||||
return (
|
||||
<button
|
||||
key={cr.id}
|
||||
type="button"
|
||||
onClick={() => setOpenId(cr.id)}
|
||||
className="flex items-center gap-3 rounded-xl border bg-card/60 px-4 py-3 text-left backdrop-blur-sm transition-colors hover:border-ring/60"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">{cr.title}</span>
|
||||
<span className={`shrink-0 rounded-md px-2 py-0.5 text-xs font-medium ${STATUS_TONE[cr.status] ?? 'bg-muted'}`}>
|
||||
{cr.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{cr.customerName}
|
||||
{cr.estimateHours != null && ` · ${cr.estimateHours}h`}
|
||||
{cr.amount != null && ` · ${cr.amount.toLocaleString()} ${cr.currency}`}
|
||||
{cr.stepCount > 0 && ` · ${cr.doneStepCount}/${cr.stepCount} steps`}
|
||||
</p>
|
||||
</div>
|
||||
{cr.stepCount > 0 && (
|
||||
<div className="hidden h-2 w-24 shrink-0 overflow-hidden rounded-full bg-muted/60 sm:block">
|
||||
<div className="h-full rounded-full bg-emerald-500 transition-all" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{requests.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-10 text-center text-sm text-muted-foreground">
|
||||
No change requests yet. Log a customer ask to start the pipeline.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{openId && (
|
||||
<RequestDrawer
|
||||
id={openId}
|
||||
divisions={divisions}
|
||||
onClose={() => setOpenId(null)}
|
||||
onChanged={load}
|
||||
/>
|
||||
)}
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
function NewRequestForm({ organizationId, onCreated }: { organizationId: string; onCreated: () => void }) {
|
||||
const [customer, setCustomer] = useState('')
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function submit() {
|
||||
if (!customer.trim() || !title.trim()) {
|
||||
toast.error('Customer and title are required.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.post('/api/orgboard/change-requests', { organizationId, customerName: customer, title, description })
|
||||
toast.success('Change request logged.')
|
||||
onCreated()
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-5 flex flex-col gap-3 rounded-xl border bg-card/60 p-4 backdrop-blur-sm">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Customer</Label>
|
||||
<Input value={customer} onChange={(e) => setCustomer(e.target.value)} placeholder="Acme Corp" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Title</Label>
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Add SSO to the portal" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>What they're asking for</Label>
|
||||
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={3} />
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" disabled={busy} onClick={submit}>Log request</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RequestDrawer({
|
||||
id,
|
||||
divisions,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
id: string
|
||||
divisions: Division[]
|
||||
onClose: () => void
|
||||
onChanged: () => void
|
||||
}) {
|
||||
const [detail, setDetail] = useState<ChangeDetail | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [amount, setAmount] = useState('')
|
||||
const [currency, setCurrency] = useState('USD')
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
setDetail(await api.get<ChangeDetail>(`/api/orgboard/change-requests/${id}`))
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
}
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
void reload()
|
||||
}, [reload])
|
||||
|
||||
async function act(fn: () => Promise<ChangeDetail>, success?: string) {
|
||||
setBusy(true)
|
||||
try {
|
||||
setDetail(await fn())
|
||||
if (success) toast.success(success)
|
||||
onChanged()
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const status = detail?.summary.status ?? 'Requested'
|
||||
const action = ACTION[status]
|
||||
const stageIndex = STAGES.indexOf(status as (typeof STAGES)[number])
|
||||
|
||||
return (
|
||||
<Sheet open onOpenChange={(open) => !open && onClose()}>
|
||||
<SheetContent className="overflow-y-auto sm:max-w-xl">
|
||||
{detail && (
|
||||
<>
|
||||
<SheetHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`rounded-md px-2 py-0.5 text-xs font-medium ${STATUS_TONE[status] ?? 'bg-muted'}`}>{status}</span>
|
||||
<Badge variant="outline">{detail.summary.customerName}</Badge>
|
||||
</div>
|
||||
<SheetTitle>{detail.summary.title}</SheetTitle>
|
||||
{detail.description && <SheetDescription>{detail.description}</SheetDescription>}
|
||||
</SheetHeader>
|
||||
|
||||
{/* Stage stepper */}
|
||||
<div className="flex items-center gap-1 px-4">
|
||||
{STAGES.map((s, i) => {
|
||||
const done = stageIndex >= 0 && i <= stageIndex
|
||||
return (
|
||||
<div key={s} className="flex flex-1 flex-col items-center gap-1">
|
||||
<div className="flex w-full items-center">
|
||||
<span className={`grid size-6 shrink-0 place-items-center rounded-full border text-[10px] ${done ? 'border-emerald-500 bg-emerald-500/15 text-emerald-600' : 'border-border bg-muted/40 text-muted-foreground'}`}>
|
||||
{done ? <Check className="size-3" /> : i + 1}
|
||||
</span>
|
||||
{i < STAGES.length - 1 && <span className={`h-0.5 flex-1 ${stageIndex > i ? 'bg-emerald-500' : 'bg-border'}`} />}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">{s}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Totals + commercial action */}
|
||||
<div className="mx-4 flex flex-col gap-3 rounded-xl border bg-card/60 p-4 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Total estimate</span>
|
||||
<span className="font-semibold">{detail.totalStepHours}h{detail.summary.amount != null && ` · ${detail.summary.amount.toLocaleString()} ${detail.summary.currency}`}</span>
|
||||
</div>
|
||||
|
||||
{status === 'Requested' && (
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<Label className="text-xs">Quote amount (optional)</Label>
|
||||
<Input value={amount} onChange={(e) => setAmount(e.target.value)} inputMode="decimal" placeholder="12000" />
|
||||
</div>
|
||||
<div className="flex w-24 flex-col gap-1">
|
||||
<Label className="text-xs">Currency</Label>
|
||||
<Input value={currency} onChange={(e) => setCurrency(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{action && (
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
act(
|
||||
() =>
|
||||
api.post<ChangeDetail>(`/api/orgboard/change-requests/${id}/${action.path}`,
|
||||
action.path === 'estimate'
|
||||
? { amount: amount ? Number(amount) : null, currency }
|
||||
: {}),
|
||||
`${action.label} done.`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{action.label} <ArrowRight data-icon="inline-end" />
|
||||
</Button>
|
||||
)}
|
||||
{status !== 'Live' && status !== 'Rejected' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => act(() => api.post<ChangeDetail>(`/api/orgboard/change-requests/${id}/reject`, {}), 'Rejected.')}
|
||||
>
|
||||
Reject request
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
<div className="flex flex-col gap-2 px-4 pb-6">
|
||||
<Label className="flex items-center gap-1.5"><GitBranch className="size-4" /> Cross-division steps</Label>
|
||||
{detail.steps.map((step) => (
|
||||
<div key={step.id} className="flex items-center gap-2 rounded-lg border bg-card/50 px-3 py-2 text-sm backdrop-blur-sm">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">{step.title}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{step.divisionName ?? 'Unassigned'} · {step.estimateHours}h
|
||||
{step.dependsOnStepId && ' · depends on a prior step'}
|
||||
</p>
|
||||
</div>
|
||||
<Select value={step.status} onValueChange={(v) => act(() => api.patch<ChangeDetail>(`/api/orgboard/change-requests/${id}/steps/${step.id}`, { status: v }))}>
|
||||
<SelectTrigger className="h-8 w-32 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{STEP_STATUSES.map((s) => <SelectItem key={s} value={s}>{s}</SelectItem>)}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
{detail.steps.length === 0 && <p className="text-xs text-muted-foreground">No steps yet — break the work down by division below.</p>}
|
||||
|
||||
<AddStepForm id={id} divisions={divisions} steps={detail.steps} onAdded={(d) => setDetail(d)} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
function AddStepForm({
|
||||
id,
|
||||
divisions,
|
||||
steps,
|
||||
onAdded,
|
||||
}: {
|
||||
id: string
|
||||
divisions: Division[]
|
||||
steps: ChangeStep[]
|
||||
onAdded: (detail: ChangeDetail) => void
|
||||
}) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [hours, setHours] = useState('')
|
||||
const [divisionId, setDivisionId] = useState<string>('')
|
||||
const [dependsOn, setDependsOn] = useState<string>('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function submit() {
|
||||
if (!title.trim() || !hours) {
|
||||
toast.error('Step title and hours are required.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
const detail = await api.post<ChangeDetail>(`/api/orgboard/change-requests/${id}/steps`, {
|
||||
title,
|
||||
estimateHours: Number(hours),
|
||||
divisionId: divisionId || null,
|
||||
dependsOnStepId: dependsOn || null,
|
||||
})
|
||||
onAdded(detail)
|
||||
setTitle('')
|
||||
setHours('')
|
||||
setDivisionId('')
|
||||
setDependsOn('')
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-dashed p-3">
|
||||
<div className="flex gap-2">
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Step (e.g. Build SSO connector)" className="flex-1" />
|
||||
<Input value={hours} onChange={(e) => setHours(e.target.value)} inputMode="decimal" placeholder="Hours" className="w-24" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select value={divisionId} onValueChange={setDivisionId}>
|
||||
<SelectTrigger className="flex-1"><SelectValue placeholder="Division (optional)" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{divisions.map((d) => <SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>)}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{steps.length > 0 && (
|
||||
<Select value={dependsOn} onValueChange={setDependsOn}>
|
||||
<SelectTrigger className="flex-1"><SelectValue placeholder="Depends on (optional)" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{steps.map((s) => <SelectItem key={s.id} value={s.id}>{s.title}</SelectItem>)}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" variant="outline" disabled={busy} onClick={submit}>
|
||||
<Plus data-icon="inline-start" /> Add step
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { REVIEWS_CHANGED } from '@/components/AppShell'
|
||||
import { AgentFace } from '@/components/AgentFace'
|
||||
import { MarkdownEditor } from '@/components/MarkdownEditor'
|
||||
import { api } from '@/lib/api'
|
||||
@@ -169,6 +170,7 @@ function ReviewCard({ item, onDecided }: { item: ReviewItem; onDecided: (id: str
|
||||
toast.info('Sent back to the agent')
|
||||
}
|
||||
onDecided(item.id)
|
||||
window.dispatchEvent(new Event(REVIEWS_CHANGED))
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
} finally {
|
||||
|
||||
Binary file not shown.
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeamUp.Modules.Assembler.Domain;
|
||||
using TeamUp.Modules.Assembler.Persistence;
|
||||
using TeamUp.Modules.Assembler.Queue;
|
||||
@@ -12,6 +13,18 @@ internal sealed class AgentRunDispatcher(AssemblerDbContext db, JobQueue queue,
|
||||
{
|
||||
public async Task<Guid> DispatchAsync(Guid seatId, Guid workItemId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Defensive: never stack a duplicate run while one is already in flight for this (seat, task).
|
||||
// Repeated "Run" clicks return the in-flight run instead of dispatching another model call.
|
||||
var inFlight = await db.AgentRuns
|
||||
.Where(r => r.SeatId == seatId && r.WorkItemId == workItemId
|
||||
&& (r.Status == AgentRunStatus.Queued || r.Status == AgentRunStatus.Running))
|
||||
.Select(r => (Guid?)r.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (inFlight is { } existing)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var run = new AgentRun(seatId, workItemId, clock.GetUtcNow());
|
||||
db.AgentRuns.Add(run);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
@@ -94,12 +94,20 @@ internal sealed class AgentRunExecutor(
|
||||
run.Complete(output, assembled.PrimaryAction, assembled.PrimaryActionRisk, result, completion.LatencyMs, clock.GetUtcNow());
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Only a spec / story-breakdown agent proposes child stories. Other actions (code, design,
|
||||
// tests) produce an artifact, not a backlog — so don't mistake a numbered list in their
|
||||
// output (e.g. a list of file names) for child tasks.
|
||||
var primarySkill = context.SkillKeys.Count > 0 ? context.SkillKeys[0] : null;
|
||||
var childTitles = primarySkill is "spec-writing" or "story-breakdown"
|
||||
? OutputParser.ExtractChildTitles(output)
|
||||
: Array.Empty<string>();
|
||||
|
||||
// Hand the parsed action to the gate: autonomy vs risk → execute now or hold in review.
|
||||
var gate = await actionGate.EvaluateAsync(
|
||||
new AgentActionProposal(
|
||||
run.Id, run.SeatId, context.AgentId, run.WorkItemId, context.TeamId, context.OrganizationId,
|
||||
context.Autonomy, assembled.PrimaryAction, assembled.PrimaryActionRisk,
|
||||
context.TaskTitle, output, OutputParser.ExtractChildTitles(output), assembled.Trace),
|
||||
context.TaskTitle, output, childTitles, assembled.Trace),
|
||||
cancellationToken);
|
||||
logger.LogInformation(
|
||||
"Run {RunId}: {Action} ({Risk}) → {Outcome}.",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeamUp.Modules.Governance.Domain;
|
||||
using TeamUp.Modules.Governance.Persistence;
|
||||
using TeamUp.SharedKernel.Access;
|
||||
@@ -25,6 +26,20 @@ internal sealed class ActionGate(
|
||||
|
||||
if (GatePolicy.ShouldHold(proposal.Autonomy, risk))
|
||||
{
|
||||
// Defensive: collapse duplicates. If an identical action is already pending for this
|
||||
// (task, agent), keep that one rather than stacking another copy in the review inbox.
|
||||
var existing = await db.ReviewItems
|
||||
.Where(r => r.WorkItemId == proposal.WorkItemId
|
||||
&& r.AgentId == proposal.AgentId
|
||||
&& r.ActionKind == proposal.ActionKind
|
||||
&& r.Status == ReviewStatus.Pending)
|
||||
.Select(r => (Guid?)r.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (existing is { } existingId)
|
||||
{
|
||||
return new GateResult(GateOutcome.Held, existingId);
|
||||
}
|
||||
|
||||
var item = new ReviewItem(proposal, clock.GetUtcNow());
|
||||
db.ReviewItems.Add(item);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
using TeamUp.SharedKernel.Domain;
|
||||
|
||||
namespace TeamUp.Modules.OrgBoard.Domain;
|
||||
|
||||
/// <summary>Where a customer change request sits in the commercial delivery pipeline.</summary>
|
||||
internal enum ChangeRequestStatus
|
||||
{
|
||||
Requested, // logged from the customer, not yet scoped
|
||||
Estimated, // hours + price quoted, awaiting the customer's decision
|
||||
Approved, // customer approved the quote — cleared to schedule work
|
||||
Paid, // payment received — cleared to go live
|
||||
Live, // delivered / live for the customer
|
||||
Rejected, // customer declined, or we won't do it
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A customer change request flowing across divisions: logged → estimated (total hours + price) →
|
||||
/// approved → paid → live. The cross-division work and its dependencies live on the request's steps
|
||||
/// (<see cref="ChangeRequestStep"/>); the request itself owns the commercial pipeline and its guards —
|
||||
/// you can't approve before estimating, take payment before approval, or go live before payment.
|
||||
/// </summary>
|
||||
internal sealed class ChangeRequest : Entity
|
||||
{
|
||||
public Guid OrganizationId { get; private set; }
|
||||
public string CustomerName { get; private set; } = null!;
|
||||
public string Title { get; private set; } = null!;
|
||||
public string? Description { get; private set; }
|
||||
public ChangeRequestStatus Status { get; private set; }
|
||||
public decimal? EstimateHours { get; private set; }
|
||||
public decimal? Amount { get; private set; }
|
||||
public string Currency { get; private set; } = "USD";
|
||||
public DateTimeOffset CreatedAtUtc { get; private set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; private set; }
|
||||
public DateTimeOffset? EstimatedAtUtc { get; private set; }
|
||||
public DateTimeOffset? ApprovedAtUtc { get; private set; }
|
||||
public DateTimeOffset? PaidAtUtc { get; private set; }
|
||||
public DateTimeOffset? LiveAtUtc { get; private set; }
|
||||
|
||||
private ChangeRequest()
|
||||
{
|
||||
}
|
||||
|
||||
public ChangeRequest(Guid organizationId, string customerName, string title, string? description, DateTimeOffset nowUtc)
|
||||
{
|
||||
OrganizationId = organizationId;
|
||||
CustomerName = customerName;
|
||||
Title = title;
|
||||
Description = description;
|
||||
Status = ChangeRequestStatus.Requested;
|
||||
CreatedAtUtc = nowUtc;
|
||||
UpdatedAtUtc = nowUtc;
|
||||
}
|
||||
|
||||
/// <summary>Quote the request: lock in the total hours (summed from its steps) and an optional price.</summary>
|
||||
public void Estimate(decimal totalHours, decimal? amount, string? currency, DateTimeOffset nowUtc)
|
||||
{
|
||||
Require(ChangeRequestStatus.Requested, ChangeRequestStatus.Estimated);
|
||||
EstimateHours = totalHours;
|
||||
Amount = amount;
|
||||
if (!string.IsNullOrWhiteSpace(currency))
|
||||
{
|
||||
Currency = currency.Trim().ToUpperInvariant();
|
||||
}
|
||||
|
||||
Status = ChangeRequestStatus.Estimated;
|
||||
EstimatedAtUtc = nowUtc;
|
||||
UpdatedAtUtc = nowUtc;
|
||||
}
|
||||
|
||||
public void Approve(DateTimeOffset nowUtc)
|
||||
{
|
||||
Require(ChangeRequestStatus.Estimated);
|
||||
Status = ChangeRequestStatus.Approved;
|
||||
ApprovedAtUtc = nowUtc;
|
||||
UpdatedAtUtc = nowUtc;
|
||||
}
|
||||
|
||||
public void RecordPayment(DateTimeOffset nowUtc)
|
||||
{
|
||||
Require(ChangeRequestStatus.Approved);
|
||||
Status = ChangeRequestStatus.Paid;
|
||||
PaidAtUtc = nowUtc;
|
||||
UpdatedAtUtc = nowUtc;
|
||||
}
|
||||
|
||||
public void GoLive(DateTimeOffset nowUtc)
|
||||
{
|
||||
Require(ChangeRequestStatus.Paid);
|
||||
Status = ChangeRequestStatus.Live;
|
||||
LiveAtUtc = nowUtc;
|
||||
UpdatedAtUtc = nowUtc;
|
||||
}
|
||||
|
||||
public void Reject(DateTimeOffset nowUtc)
|
||||
{
|
||||
if (Status is ChangeRequestStatus.Live or ChangeRequestStatus.Rejected)
|
||||
{
|
||||
throw new InvalidOperationException($"A {Status} change request can't be rejected.");
|
||||
}
|
||||
|
||||
Status = ChangeRequestStatus.Rejected;
|
||||
UpdatedAtUtc = nowUtc;
|
||||
}
|
||||
|
||||
private void Require(params ChangeRequestStatus[] allowed)
|
||||
{
|
||||
if (Array.IndexOf(allowed, Status) < 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Change request is {Status}; this step requires {string.Join(" or ", allowed)}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using TeamUp.SharedKernel.Domain;
|
||||
|
||||
namespace TeamUp.Modules.OrgBoard.Domain;
|
||||
|
||||
internal enum ChangeStepStatus
|
||||
{
|
||||
Pending,
|
||||
InProgress,
|
||||
Done,
|
||||
Blocked,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One unit of cross-division work on a change request: a division's slice, its hours estimate, and an
|
||||
/// optional dependency on an earlier step (e.g. Ops can't go live until Engineering's step is done).
|
||||
/// The chain of <see cref="DependsOnStepId"/> links is how a request models "a lot of dependencies".
|
||||
/// </summary>
|
||||
internal sealed class ChangeRequestStep : Entity
|
||||
{
|
||||
public Guid ChangeRequestId { get; private set; }
|
||||
public Guid? DivisionId { get; private set; }
|
||||
public string Title { get; private set; } = null!;
|
||||
public decimal EstimateHours { get; private set; }
|
||||
public ChangeStepStatus Status { get; private set; }
|
||||
public Guid? DependsOnStepId { get; private set; }
|
||||
public int Order { get; private set; }
|
||||
public DateTimeOffset CreatedAtUtc { get; private set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; private set; }
|
||||
|
||||
private ChangeRequestStep()
|
||||
{
|
||||
}
|
||||
|
||||
public ChangeRequestStep(
|
||||
Guid changeRequestId,
|
||||
Guid? divisionId,
|
||||
string title,
|
||||
decimal estimateHours,
|
||||
Guid? dependsOnStepId,
|
||||
int order,
|
||||
DateTimeOffset nowUtc)
|
||||
{
|
||||
ChangeRequestId = changeRequestId;
|
||||
DivisionId = divisionId;
|
||||
Title = title;
|
||||
EstimateHours = estimateHours;
|
||||
Status = ChangeStepStatus.Pending;
|
||||
DependsOnStepId = dependsOnStepId;
|
||||
Order = order;
|
||||
CreatedAtUtc = nowUtc;
|
||||
UpdatedAtUtc = nowUtc;
|
||||
}
|
||||
|
||||
public void Advance(ChangeStepStatus status, DateTimeOffset nowUtc)
|
||||
{
|
||||
Status = status;
|
||||
UpdatedAtUtc = nowUtc;
|
||||
}
|
||||
|
||||
public void SetEstimate(decimal estimateHours, DateTimeOffset nowUtc)
|
||||
{
|
||||
EstimateHours = estimateHours;
|
||||
UpdatedAtUtc = nowUtc;
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,9 @@ internal sealed class WorkItem : Entity
|
||||
UpdatedAtUtc = nowUtc;
|
||||
}
|
||||
|
||||
/// <summary>Detach from a parent (used when the parent is deleted, so the child stays on the board).</summary>
|
||||
public void ClearParent() => ParentId = null;
|
||||
|
||||
/// <summary>Appends an approved agent artifact (spec / test plan) to the task.</summary>
|
||||
public void AttachArtifact(string content, DateTimeOffset nowUtc)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeamUp.Modules.OrgBoard.Domain;
|
||||
using TeamUp.Modules.OrgBoard.Persistence;
|
||||
using TeamUp.SharedKernel.Access;
|
||||
using TeamUp.SharedKernel.Auditing;
|
||||
|
||||
namespace TeamUp.Modules.OrgBoard.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// The cross-division delivery pipeline: a customer change request is logged, scoped into per-division
|
||||
/// steps (with hours + dependencies), then advanced through estimate → approve → pay → go-live. Each
|
||||
/// commercial transition is guarded on the aggregate, so the pipeline can only move forward in order.
|
||||
/// Reads need board-view; the commercial actions are owner-level (same capability as shaping the org).
|
||||
/// </summary>
|
||||
internal static class ChangeRequestEndpoints
|
||||
{
|
||||
public static void MapTo(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapPost("/change-requests", Create).RequireAuthorization();
|
||||
group.MapGet("/change-requests", List).RequireAuthorization();
|
||||
group.MapGet("/change-requests/{id:guid}", Get).RequireAuthorization();
|
||||
group.MapPost("/change-requests/{id:guid}/steps", AddStep).RequireAuthorization();
|
||||
group.MapPatch("/change-requests/{id:guid}/steps/{stepId:guid}", AdvanceStep).RequireAuthorization();
|
||||
group.MapPost("/change-requests/{id:guid}/estimate", Estimate).RequireAuthorization();
|
||||
group.MapPost("/change-requests/{id:guid}/approve", Approve).RequireAuthorization();
|
||||
group.MapPost("/change-requests/{id:guid}/pay", Pay).RequireAuthorization();
|
||||
group.MapPost("/change-requests/{id:guid}/go-live", GoLive).RequireAuthorization();
|
||||
group.MapPost("/change-requests/{id:guid}/reject", Reject).RequireAuthorization();
|
||||
}
|
||||
|
||||
private static async Task<IResult> Create(
|
||||
CreateChangeRequestRequest request, ICurrentUser user, IPermissionService permissions,
|
||||
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
|
||||
{
|
||||
if (!permissions.Has(Capability.CreateProductsAndTeams, ScopeRef.Org(request.OrganizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.CustomerName) || string.IsNullOrWhiteSpace(request.Title))
|
||||
{
|
||||
return Results.BadRequest("Customer and title are required.");
|
||||
}
|
||||
|
||||
var cr = new ChangeRequest(
|
||||
request.OrganizationId, request.CustomerName.Trim(), request.Title.Trim(),
|
||||
string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(), clock.GetUtcNow());
|
||||
db.ChangeRequests.Add(cr);
|
||||
await db.SaveChangesAsync(ct);
|
||||
await audit.WriteAsync(new AuditEvent("change-request.created", "ChangeRequest", cr.Id, user.MemberId, cr.Title), ct);
|
||||
return Results.Ok(ToSummary(cr, steps: [], doneSteps: 0));
|
||||
}
|
||||
|
||||
private static async Task<IResult> List(
|
||||
Guid organizationId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
|
||||
{
|
||||
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Org(organizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
var requests = await db.ChangeRequests
|
||||
.Where(c => c.OrganizationId == organizationId)
|
||||
.OrderByDescending(c => c.CreatedAtUtc)
|
||||
.ToListAsync(ct);
|
||||
var ids = requests.Select(c => c.Id).ToList();
|
||||
var steps = await db.ChangeRequestSteps.Where(s => ids.Contains(s.ChangeRequestId)).ToListAsync(ct);
|
||||
var byRequest = steps.GroupBy(s => s.ChangeRequestId).ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
var summaries = requests
|
||||
.Select(c =>
|
||||
{
|
||||
var its = byRequest.TryGetValue(c.Id, out var list) ? list : [];
|
||||
return ToSummary(c, its, its.Count(s => s.Status == ChangeStepStatus.Done));
|
||||
})
|
||||
.ToList();
|
||||
return Results.Ok(summaries);
|
||||
}
|
||||
|
||||
private static async Task<IResult> Get(
|
||||
Guid id, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
|
||||
{
|
||||
var cr = await db.ChangeRequests.FirstOrDefaultAsync(c => c.Id == id, ct);
|
||||
if (cr is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Org(cr.OrganizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
var steps = await db.ChangeRequestSteps
|
||||
.Where(s => s.ChangeRequestId == id)
|
||||
.OrderBy(s => s.Order)
|
||||
.ToListAsync(ct);
|
||||
var divisions = await db.Divisions
|
||||
.Where(d => d.OrganizationId == cr.OrganizationId)
|
||||
.ToDictionaryAsync(d => d.Id, d => d.Name, ct);
|
||||
|
||||
return Results.Ok(ToDetail(cr, steps, divisions));
|
||||
}
|
||||
|
||||
private static async Task<IResult> AddStep(
|
||||
Guid id, AddChangeStepRequest request, ICurrentUser user, IPermissionService permissions,
|
||||
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
|
||||
{
|
||||
var cr = await LoadForWrite(db, permissions, id, ct);
|
||||
if (cr.Error is not null)
|
||||
{
|
||||
return cr.Error;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Title))
|
||||
{
|
||||
return Results.BadRequest("Step title is required.");
|
||||
}
|
||||
|
||||
if (request.DivisionId is { } divisionId
|
||||
&& !await db.Divisions.AnyAsync(d => d.Id == divisionId && d.OrganizationId == cr.Request!.OrganizationId, ct))
|
||||
{
|
||||
return Results.BadRequest("Division not found in this organization.");
|
||||
}
|
||||
|
||||
var order = await db.ChangeRequestSteps.CountAsync(s => s.ChangeRequestId == id, ct);
|
||||
var step = new ChangeRequestStep(
|
||||
id, request.DivisionId, request.Title.Trim(), request.EstimateHours,
|
||||
request.DependsOnStepId, order, clock.GetUtcNow());
|
||||
db.ChangeRequestSteps.Add(step);
|
||||
await db.SaveChangesAsync(ct);
|
||||
await audit.WriteAsync(new AuditEvent("change-request.step-added", "ChangeRequest", id, user.MemberId, step.Title), ct);
|
||||
return Results.Ok(await BuildDetail(db, cr.Request!, ct));
|
||||
}
|
||||
|
||||
private static async Task<IResult> AdvanceStep(
|
||||
Guid id, Guid stepId, AdvanceStepRequest request, ICurrentUser user, IPermissionService permissions,
|
||||
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
|
||||
{
|
||||
var cr = await LoadForWrite(db, permissions, id, ct);
|
||||
if (cr.Error is not null)
|
||||
{
|
||||
return cr.Error;
|
||||
}
|
||||
|
||||
var step = await db.ChangeRequestSteps.FirstOrDefaultAsync(s => s.Id == stepId && s.ChangeRequestId == id, ct);
|
||||
if (step is null)
|
||||
{
|
||||
return Results.NotFound("Step not found.");
|
||||
}
|
||||
|
||||
step.Advance(request.Status, clock.GetUtcNow());
|
||||
await db.SaveChangesAsync(ct);
|
||||
await audit.WriteAsync(new AuditEvent("change-request.step-advanced", "ChangeRequest", id, user.MemberId, request.Status.ToString()), ct);
|
||||
return Results.Ok(await BuildDetail(db, cr.Request!, ct));
|
||||
}
|
||||
|
||||
private static async Task<IResult> Estimate(
|
||||
Guid id, EstimateChangeRequestRequest request, ICurrentUser user, IPermissionService permissions,
|
||||
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
|
||||
{
|
||||
var cr = await LoadForWrite(db, permissions, id, ct);
|
||||
if (cr.Error is not null)
|
||||
{
|
||||
return cr.Error;
|
||||
}
|
||||
|
||||
var steps = await db.ChangeRequestSteps.Where(s => s.ChangeRequestId == id).ToListAsync(ct);
|
||||
if (steps.Count == 0)
|
||||
{
|
||||
return Results.BadRequest("Add at least one step before estimating.");
|
||||
}
|
||||
|
||||
var totalHours = steps.Sum(s => s.EstimateHours);
|
||||
return await Transition(
|
||||
db, audit, user, cr.Request!, c => c.Estimate(totalHours, request.Amount, request.Currency, clock.GetUtcNow()),
|
||||
"change-request.estimated", $"{totalHours}h", ct);
|
||||
}
|
||||
|
||||
private static Task<IResult> Approve(
|
||||
Guid id, ICurrentUser user, IPermissionService permissions, IAuditLog audit,
|
||||
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct) =>
|
||||
SimpleTransition(id, user, permissions, audit, db, c => c.Approve(clock.GetUtcNow()), "change-request.approved", ct);
|
||||
|
||||
private static Task<IResult> Pay(
|
||||
Guid id, ICurrentUser user, IPermissionService permissions, IAuditLog audit,
|
||||
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct) =>
|
||||
SimpleTransition(id, user, permissions, audit, db, c => c.RecordPayment(clock.GetUtcNow()), "change-request.paid", ct);
|
||||
|
||||
private static Task<IResult> GoLive(
|
||||
Guid id, ICurrentUser user, IPermissionService permissions, IAuditLog audit,
|
||||
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct) =>
|
||||
SimpleTransition(id, user, permissions, audit, db, c => c.GoLive(clock.GetUtcNow()), "change-request.live", ct);
|
||||
|
||||
private static Task<IResult> Reject(
|
||||
Guid id, ICurrentUser user, IPermissionService permissions, IAuditLog audit,
|
||||
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct) =>
|
||||
SimpleTransition(id, user, permissions, audit, db, c => c.Reject(clock.GetUtcNow()), "change-request.rejected", ct);
|
||||
|
||||
private static async Task<IResult> SimpleTransition(
|
||||
Guid id, ICurrentUser user, IPermissionService permissions, IAuditLog audit, OrgBoardDbContext db,
|
||||
Action<ChangeRequest> apply, string auditAction, CancellationToken ct)
|
||||
{
|
||||
var cr = await LoadForWrite(db, permissions, id, ct);
|
||||
if (cr.Error is not null)
|
||||
{
|
||||
return cr.Error;
|
||||
}
|
||||
|
||||
return await Transition(db, audit, user, cr.Request!, apply, auditAction, cr.Request!.Status.ToString(), ct);
|
||||
}
|
||||
|
||||
// Apply a guarded pipeline transition, turning the domain's guard violation into a 400.
|
||||
private static async Task<IResult> Transition(
|
||||
OrgBoardDbContext db, IAuditLog audit, ICurrentUser user, ChangeRequest cr,
|
||||
Action<ChangeRequest> apply, string auditAction, string detail, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
apply(cr);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(ex.Message);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
await audit.WriteAsync(new AuditEvent(auditAction, "ChangeRequest", cr.Id, user.MemberId, detail), ct);
|
||||
return Results.Ok(await BuildDetail(db, cr, ct));
|
||||
}
|
||||
|
||||
private static async Task<(ChangeRequest? Request, IResult? Error)> LoadForWrite(
|
||||
OrgBoardDbContext db, IPermissionService permissions, Guid id, CancellationToken ct)
|
||||
{
|
||||
var cr = await db.ChangeRequests.FirstOrDefaultAsync(c => c.Id == id, ct);
|
||||
if (cr is null)
|
||||
{
|
||||
return (null, Results.NotFound());
|
||||
}
|
||||
|
||||
if (!permissions.Has(Capability.CreateProductsAndTeams, ScopeRef.Org(cr.OrganizationId)))
|
||||
{
|
||||
return (null, Results.Forbid());
|
||||
}
|
||||
|
||||
return (cr, null);
|
||||
}
|
||||
|
||||
private static async Task<ChangeRequestDetail> BuildDetail(OrgBoardDbContext db, ChangeRequest cr, CancellationToken ct)
|
||||
{
|
||||
var steps = await db.ChangeRequestSteps
|
||||
.Where(s => s.ChangeRequestId == cr.Id)
|
||||
.OrderBy(s => s.Order)
|
||||
.ToListAsync(ct);
|
||||
var divisions = await db.Divisions
|
||||
.Where(d => d.OrganizationId == cr.OrganizationId)
|
||||
.ToDictionaryAsync(d => d.Id, d => d.Name, ct);
|
||||
return ToDetail(cr, steps, divisions);
|
||||
}
|
||||
|
||||
private static ChangeRequestSummary ToSummary(ChangeRequest cr, IReadOnlyList<ChangeRequestStep> steps, int doneSteps) =>
|
||||
new(cr.Id, cr.OrganizationId, cr.CustomerName, cr.Title, cr.Status.ToString(),
|
||||
cr.EstimateHours, cr.Amount, cr.Currency, steps.Count, doneSteps);
|
||||
|
||||
private static ChangeRequestDetail ToDetail(
|
||||
ChangeRequest cr, IReadOnlyList<ChangeRequestStep> steps, Dictionary<Guid, string> divisions) =>
|
||||
new(
|
||||
ToSummary(cr, steps, steps.Count(s => s.Status == ChangeStepStatus.Done)),
|
||||
cr.Description,
|
||||
steps.Sum(s => s.EstimateHours),
|
||||
steps.Select(s => new ChangeStepResponse(
|
||||
s.Id,
|
||||
s.DivisionId,
|
||||
s.DivisionId is { } d && divisions.TryGetValue(d, out var name) ? name : null,
|
||||
s.Title,
|
||||
s.EstimateHours,
|
||||
s.Status.ToString(),
|
||||
s.DependsOnStepId,
|
||||
s.Order)).ToList(),
|
||||
cr.CreatedAtUtc,
|
||||
cr.EstimatedAtUtc,
|
||||
cr.ApprovedAtUtc,
|
||||
cr.PaidAtUtc,
|
||||
cr.LiveAtUtc);
|
||||
}
|
||||
@@ -29,6 +29,13 @@ internal sealed record MoveTaskRequest(WorkItemStatus Status);
|
||||
|
||||
internal sealed record AssignTaskRequest(Guid MemberId);
|
||||
|
||||
// Autopilot: fan a parent task's outstanding children out to the team's AI seats in one shot.
|
||||
internal sealed record RunAllRequest(Guid? SeatId = null);
|
||||
|
||||
internal sealed record RunDispatch(Guid WorkItemId, string Title, Guid SeatId, Guid RunId);
|
||||
|
||||
internal sealed record RunAllResponse(int Dispatched, IReadOnlyList<RunDispatch> Runs);
|
||||
|
||||
internal sealed record TaskResponse(
|
||||
Guid Id,
|
||||
Guid TeamId,
|
||||
@@ -72,6 +79,49 @@ internal sealed record AgentResponse(
|
||||
List<string> Docs,
|
||||
string? Persona);
|
||||
|
||||
// --- Cross-division delivery pipeline: customer change requests ---
|
||||
|
||||
internal sealed record CreateChangeRequestRequest(Guid OrganizationId, string CustomerName, string Title, string? Description);
|
||||
|
||||
internal sealed record AddChangeStepRequest(string Title, decimal EstimateHours, Guid? DivisionId = null, Guid? DependsOnStepId = null);
|
||||
|
||||
internal sealed record EstimateChangeRequestRequest(decimal? Amount = null, string? Currency = null);
|
||||
|
||||
internal sealed record AdvanceStepRequest(ChangeStepStatus Status);
|
||||
|
||||
internal sealed record ChangeStepResponse(
|
||||
Guid Id,
|
||||
Guid? DivisionId,
|
||||
string? DivisionName,
|
||||
string Title,
|
||||
decimal EstimateHours,
|
||||
string Status,
|
||||
Guid? DependsOnStepId,
|
||||
int Order);
|
||||
|
||||
internal sealed record ChangeRequestSummary(
|
||||
Guid Id,
|
||||
Guid OrganizationId,
|
||||
string CustomerName,
|
||||
string Title,
|
||||
string Status,
|
||||
decimal? EstimateHours,
|
||||
decimal? Amount,
|
||||
string Currency,
|
||||
int StepCount,
|
||||
int DoneStepCount);
|
||||
|
||||
internal sealed record ChangeRequestDetail(
|
||||
ChangeRequestSummary Summary,
|
||||
string? Description,
|
||||
decimal TotalStepHours,
|
||||
IReadOnlyList<ChangeStepResponse> Steps,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
DateTimeOffset? EstimatedAtUtc,
|
||||
DateTimeOffset? ApprovedAtUtc,
|
||||
DateTimeOffset? PaidAtUtc,
|
||||
DateTimeOffset? LiveAtUtc);
|
||||
|
||||
// --- Agent profiles (AGENTS.md): a per-org library of reusable agent definitions ---
|
||||
|
||||
internal sealed record UploadAgentProfileRequest(Guid OrganizationId, string Content);
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
using System.Globalization;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
@@ -5,6 +9,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using TeamUp.Modules.OrgBoard.Domain;
|
||||
using TeamUp.Modules.OrgBoard.Persistence;
|
||||
using TeamUp.SharedKernel.Access;
|
||||
using TeamUp.SharedKernel.Ai;
|
||||
using TeamUp.SharedKernel.Auditing;
|
||||
using TeamUp.SharedKernel.Modularity;
|
||||
|
||||
@@ -24,12 +29,15 @@ internal static class OrgBoardEndpoints
|
||||
group.MapGet("/products", ListProducts).RequireAuthorization();
|
||||
group.MapGet("/products/{id:guid}/identity", GetProductIdentity).RequireAuthorization();
|
||||
group.MapPut("/products/{id:guid}/identity", SetProductIdentity).RequireAuthorization();
|
||||
group.MapGet("/products/{id:guid}/export", ExportProduct).RequireAuthorization();
|
||||
group.MapPost("/teams", CreateTeam).RequireAuthorization();
|
||||
group.MapGet("/teams", ListTeams).RequireAuthorization();
|
||||
group.MapPost("/tasks", CreateTask).RequireAuthorization();
|
||||
group.MapGet("/board", GetBoard).RequireAuthorization();
|
||||
group.MapPatch("/tasks/{id:guid}/move", MoveTask).RequireAuthorization();
|
||||
group.MapPatch("/tasks/{id:guid}/assign", AssignTask).RequireAuthorization();
|
||||
group.MapPost("/tasks/{id:guid}/run-all", RunAllChildren).RequireAuthorization();
|
||||
group.MapDelete("/tasks/{id:guid}", DeleteTask).RequireAuthorization();
|
||||
group.MapGet("/cartable", Cartable).RequireAuthorization();
|
||||
|
||||
group.MapPost("/seats", CreateSeat).RequireAuthorization();
|
||||
@@ -41,6 +49,7 @@ internal static class OrgBoardEndpoints
|
||||
|
||||
AgentProfileEndpoints.MapTo(group);
|
||||
ProductProfileEndpoints.MapTo(group);
|
||||
ChangeRequestEndpoints.MapTo(group);
|
||||
}
|
||||
|
||||
private static TaskResponse ToResponse(WorkItem item) => new(
|
||||
@@ -229,6 +238,154 @@ internal static class OrgBoardEndpoints
|
||||
return Results.Ok(new ProductIdentityResponse(product.Id, product.Name, product.Identity));
|
||||
}
|
||||
|
||||
// Matches a fenced code block, capturing its language hint and body, so we can write a delivered
|
||||
// artifact out as a real source file (App.tsx, schema.sql, …) instead of a wall of markdown.
|
||||
private static readonly Regex FenceRx = new(
|
||||
@"```(?<lang>[a-zA-Z0-9+#.]*)\s*\n(?<body>.*?)```",
|
||||
RegexOptions.Singleline | RegexOptions.Compiled);
|
||||
|
||||
// Download the product as a project: PRODUCT.md + every team's delivered artifacts as files,
|
||||
// plus a README manifest. This is the "an agent did the work, now I download the result" payoff —
|
||||
// a portable bundle of what the team (human + AI) produced, gated on board-view permission.
|
||||
private static async Task<IResult> ExportProduct(
|
||||
Guid id, IPermissionService permissions, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
|
||||
{
|
||||
var product = await db.Products.FirstOrDefaultAsync(p => p.Id == id, ct);
|
||||
if (product is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Org(product.OrganizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
var teams = await db.Teams
|
||||
.Where(t => t.ProductId == id)
|
||||
.OrderBy(t => t.CreatedAtUtc)
|
||||
.ToListAsync(ct);
|
||||
var teamIds = teams.Select(t => t.Id).ToList();
|
||||
var teamsById = teams.ToDictionary(t => t.Id);
|
||||
|
||||
// Only items that actually carry a delivered artifact are worth exporting.
|
||||
var items = await db.WorkItems
|
||||
.Where(w => teamIds.Contains(w.TeamId) && w.Description != null && w.Description != "")
|
||||
.OrderBy(w => w.CreatedAtUtc)
|
||||
.ToListAsync(ct);
|
||||
|
||||
using var buffer = new MemoryStream();
|
||||
using (var zip = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
var manifest = new StringBuilder();
|
||||
manifest.Append("# ").Append(product.Name).Append("\n\n");
|
||||
manifest.Append("_Exported from TeamUp on ")
|
||||
.Append(clock.GetUtcNow().ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture)).Append("._\n\n");
|
||||
manifest.Append(items.Count).Append(" delivered artifact(s) across ")
|
||||
.Append(teams.Count).Append(" team(s).\n\n");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(product.Identity))
|
||||
{
|
||||
WriteEntry(zip, "PRODUCT.md", product.Identity!);
|
||||
manifest.Append("- `PRODUCT.md` — product identity\n");
|
||||
}
|
||||
|
||||
var counters = new Dictionary<Guid, int>();
|
||||
foreach (var item in items)
|
||||
{
|
||||
var team = teamsById[item.TeamId];
|
||||
var folder = Slug(team.Name);
|
||||
var n = counters.TryGetValue(item.TeamId, out var c) ? c + 1 : 1;
|
||||
counters[item.TeamId] = n;
|
||||
|
||||
var (ext, content) = RenderArtifact(item.Description!);
|
||||
var path = $"{folder}/{n:D2}-{Slug(item.Title)}{ext}";
|
||||
WriteEntry(zip, path, content);
|
||||
manifest.Append("- `").Append(path).Append("` — ").Append(item.Title).Append('\n');
|
||||
}
|
||||
|
||||
WriteEntry(zip, "README.md", manifest.ToString());
|
||||
}
|
||||
|
||||
return Results.File(buffer.ToArray(), "application/zip", $"{Slug(product.Name)}.zip");
|
||||
}
|
||||
|
||||
private static void WriteEntry(ZipArchive zip, string path, string content)
|
||||
{
|
||||
var entry = zip.CreateEntry(path, CompressionLevel.Optimal);
|
||||
using var stream = entry.Open();
|
||||
using var writer = new StreamWriter(stream, new UTF8Encoding(false));
|
||||
writer.Write(content);
|
||||
}
|
||||
|
||||
// If a delivered artifact is essentially one fenced code block, write it out as that source file;
|
||||
// otherwise keep it as markdown.
|
||||
private static (string Ext, string Content) RenderArtifact(string artifact)
|
||||
{
|
||||
var matches = FenceRx.Matches(artifact);
|
||||
if (matches.Count > 0)
|
||||
{
|
||||
var largest = matches
|
||||
.OrderByDescending(m => m.Groups["body"].Value.Length)
|
||||
.First();
|
||||
var body = largest.Groups["body"].Value;
|
||||
if (body.Length >= artifact.Trim().Length * 0.5)
|
||||
{
|
||||
return (ExtensionFor(largest.Groups["lang"].Value), body.TrimEnd() + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
return (".md", artifact);
|
||||
}
|
||||
|
||||
private static string ExtensionFor(string lang) => lang.ToLowerInvariant() switch
|
||||
{
|
||||
"tsx" => ".tsx",
|
||||
"ts" or "typescript" => ".ts",
|
||||
"jsx" => ".jsx",
|
||||
"js" or "javascript" => ".js",
|
||||
"cs" or "csharp" => ".cs",
|
||||
"py" or "python" => ".py",
|
||||
"go" or "golang" => ".go",
|
||||
"java" => ".java",
|
||||
"rb" or "ruby" => ".rb",
|
||||
"php" => ".php",
|
||||
"rs" or "rust" => ".rs",
|
||||
"sql" => ".sql",
|
||||
"html" => ".html",
|
||||
"css" => ".css",
|
||||
"scss" => ".scss",
|
||||
"json" => ".json",
|
||||
"yaml" or "yml" => ".yml",
|
||||
"sh" or "bash" or "shell" => ".sh",
|
||||
"md" or "markdown" => ".md",
|
||||
_ => ".txt",
|
||||
};
|
||||
|
||||
// Filesystem-safe lower-kebab slug for folder/file names.
|
||||
private static string Slug(string value)
|
||||
{
|
||||
var sb = new StringBuilder(value.Length);
|
||||
foreach (var ch in value.Trim().ToLowerInvariant())
|
||||
{
|
||||
sb.Append(char.IsLetterOrDigit(ch) ? ch : '-');
|
||||
}
|
||||
|
||||
var slug = sb.ToString();
|
||||
while (slug.Contains("--", StringComparison.Ordinal))
|
||||
{
|
||||
slug = slug.Replace("--", "-", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
slug = slug.Trim('-');
|
||||
if (slug.Length == 0)
|
||||
{
|
||||
return "item";
|
||||
}
|
||||
|
||||
return slug.Length > 50 ? slug[..50].Trim('-') : slug;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListTeams(
|
||||
Guid organizationId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
|
||||
{
|
||||
@@ -273,6 +430,36 @@ internal static class OrgBoardEndpoints
|
||||
return Results.Ok(ToResponse(item));
|
||||
}
|
||||
|
||||
// Remove a task from the board. Its children are detached (kept as top-level) rather than deleted,
|
||||
// and its status-transition history is dropped. Any agent runs/reviews it spawned are left as history.
|
||||
private static async Task<IResult> DeleteTask(
|
||||
Guid id, ICurrentUser user, IPermissionService permissions,
|
||||
IAuditLog audit, OrgBoardDbContext db, CancellationToken ct)
|
||||
{
|
||||
var item = await db.WorkItems.FirstOrDefaultAsync(w => w.Id == id, ct);
|
||||
if (item is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == item.TeamId, ct);
|
||||
if (team is null || !permissions.Has(Capability.WorkTasks, ScopeRef.Team(item.TeamId), ScopeRef.Org(team.OrganizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
foreach (var child in await db.WorkItems.Where(w => w.ParentId == id).ToListAsync(ct))
|
||||
{
|
||||
child.ClearParent();
|
||||
}
|
||||
|
||||
db.Transitions.RemoveRange(await db.Transitions.Where(t => t.WorkItemId == id).ToListAsync(ct));
|
||||
db.WorkItems.Remove(item);
|
||||
await db.SaveChangesAsync(ct);
|
||||
await audit.WriteAsync(new AuditEvent("task.deleted", "WorkItem", id, user.MemberId, item.Title), ct);
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetBoard(
|
||||
Guid teamId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
|
||||
{
|
||||
@@ -354,6 +541,76 @@ internal static class OrgBoardEndpoints
|
||||
return Results.Ok(ToResponse(item));
|
||||
}
|
||||
|
||||
// Autopilot for a big task: dispatch a run for every outstanding child in one shot, fanning them
|
||||
// across the team's configured AI seats (round-robin), or all to one seat if specified. Children
|
||||
// that already carry an artifact or are done are skipped, so re-running only picks up what's left.
|
||||
// The agents still act per their own autonomy — gated ones land in review; this just starts them all.
|
||||
private static async Task<IResult> RunAllChildren(
|
||||
Guid id, RunAllRequest request, ICurrentUser user, IPermissionService permissions,
|
||||
IAgentDispatcher dispatcher, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
|
||||
{
|
||||
var parent = await db.WorkItems.FirstOrDefaultAsync(w => w.Id == id, ct);
|
||||
if (parent is null)
|
||||
{
|
||||
return Results.NotFound("Task not found.");
|
||||
}
|
||||
|
||||
var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == parent.TeamId, ct);
|
||||
if (team is null)
|
||||
{
|
||||
return Results.NotFound("Team not found.");
|
||||
}
|
||||
|
||||
if (!permissions.Has(Capability.WorkTasks, ScopeRef.Team(team.Id), ScopeRef.Org(team.OrganizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
// Outstanding children: not done and no delivered artifact yet.
|
||||
var children = await db.WorkItems
|
||||
.Where(w => w.ParentId == id
|
||||
&& w.Status != WorkItemStatus.Done
|
||||
&& (w.Description == null || w.Description == ""))
|
||||
.OrderBy(w => w.CreatedAtUtc)
|
||||
.ToListAsync(ct);
|
||||
if (children.Count == 0)
|
||||
{
|
||||
return Results.Ok(new RunAllResponse(0, Array.Empty<RunDispatch>()));
|
||||
}
|
||||
|
||||
// The pool of AI seats to run them on: configured AI seats on the team (optionally just one).
|
||||
var seats = await db.Seats
|
||||
.Where(s => s.TeamId == team.Id && s.State == SeatState.Ai && s.AgentId != null)
|
||||
.OrderBy(s => s.CreatedAtUtc)
|
||||
.ToListAsync(ct);
|
||||
if (request.SeatId is { } chosen)
|
||||
{
|
||||
seats = seats.Where(s => s.Id == chosen).ToList();
|
||||
}
|
||||
|
||||
if (seats.Count == 0)
|
||||
{
|
||||
return Results.BadRequest("No configured AI seat on this team to run the tasks.");
|
||||
}
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
var dispatched = new List<RunDispatch>(children.Count);
|
||||
for (var i = 0; i < children.Count; i++)
|
||||
{
|
||||
var seat = seats[i % seats.Count];
|
||||
var runId = await dispatcher.DispatchAsync(seat.Id, children[i].Id, ct);
|
||||
if (children[i].Status == WorkItemStatus.Backlog)
|
||||
{
|
||||
children[i].MoveTo(WorkItemStatus.InProgress, now);
|
||||
}
|
||||
|
||||
dispatched.Add(new RunDispatch(children[i].Id, children[i].Title, seat.Id, runId));
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Ok(new RunAllResponse(dispatched.Count, dispatched));
|
||||
}
|
||||
|
||||
private static async Task<IResult> Cartable(ICurrentUser user, OrgBoardDbContext db, CancellationToken ct)
|
||||
{
|
||||
var memberId = user.MemberId;
|
||||
|
||||
+598
@@ -0,0 +1,598 @@
|
||||
// <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("20260617041239_AddChangeRequests")]
|
||||
partial class AddChangeRequests
|
||||
{
|
||||
/// <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.PrimitiveCollection<List<Guid>>("McpServerIds")
|
||||
.IsRequired()
|
||||
.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<string>("Persona")
|
||||
.HasColumnType("text");
|
||||
|
||||
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.AgentProfile", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("AuthoredByMemberId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ContentHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Monogram")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("character varying(8)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<Guid?>("OrganizationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Origin")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ProfileKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("RecommendedAutonomy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.PrimitiveCollection<List<string>>("Roles")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.PrimitiveCollection<List<string>>("SkillKeys")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Version")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Visibility")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationId");
|
||||
|
||||
b.HasIndex("OrganizationId", "ProfileKey", "Version")
|
||||
.IsUnique();
|
||||
|
||||
NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("OrganizationId", "ProfileKey", "Version"), false);
|
||||
|
||||
b.ToTable("agent_profiles", "orgboard");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.ChangeRequest", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<decimal?>("Amount")
|
||||
.HasPrecision(12, 2)
|
||||
.HasColumnType("numeric(12,2)");
|
||||
|
||||
b.Property<DateTimeOffset?>("ApprovedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Currency")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("character varying(8)");
|
||||
|
||||
b.Property<string>("CustomerName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<decimal?>("EstimateHours")
|
||||
.HasPrecision(9, 2)
|
||||
.HasColumnType("numeric(9,2)");
|
||||
|
||||
b.Property<DateTimeOffset?>("EstimatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("LiveAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("OrganizationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("PaidAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationId");
|
||||
|
||||
b.ToTable("change_requests", "orgboard");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.ChangeRequestStep", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ChangeRequestId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("DependsOnStepId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("DivisionId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<decimal>("EstimateHours")
|
||||
.HasPrecision(9, 2)
|
||||
.HasColumnType("numeric(9,2)");
|
||||
|
||||
b.Property<int>("Order")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ChangeRequestId");
|
||||
|
||||
b.ToTable("change_request_steps", "orgboard");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Division", 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("divisions", "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.Product", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("DivisionId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Identity")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<Guid>("OrganizationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DivisionId");
|
||||
|
||||
b.HasIndex("OrganizationId");
|
||||
|
||||
b.ToTable("products", "orgboard");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.ProductProfile", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("AuthoredByMemberId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ContentHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
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.Property<string>("Origin")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("ProfileKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Version")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Visibility")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationId");
|
||||
|
||||
b.HasIndex("OrganizationId", "ProfileKey", "Version")
|
||||
.IsUnique();
|
||||
|
||||
NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("OrganizationId", "ProfileKey", "Version"), false);
|
||||
|
||||
b.ToTable("product_profiles", "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.Property<Guid?>("ProductId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationId");
|
||||
|
||||
b.HasIndex("ProductId");
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddChangeRequests : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "change_request_steps",
|
||||
schema: "orgboard",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ChangeRequestId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
DivisionId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
Title = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: false),
|
||||
EstimateHours = table.Column<decimal>(type: "numeric(9,2)", precision: 9, scale: 2, nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
|
||||
DependsOnStepId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
Order = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_change_request_steps", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "change_requests",
|
||||
schema: "orgboard",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CustomerName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
Title = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: false),
|
||||
Description = table.Column<string>(type: "text", nullable: true),
|
||||
Status = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
|
||||
EstimateHours = table.Column<decimal>(type: "numeric(9,2)", precision: 9, scale: 2, nullable: true),
|
||||
Amount = table.Column<decimal>(type: "numeric(12,2)", precision: 12, scale: 2, nullable: true),
|
||||
Currency = table.Column<string>(type: "character varying(8)", maxLength: 8, nullable: false),
|
||||
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
EstimatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
ApprovedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
PaidAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
LiveAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_change_requests", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_change_request_steps_ChangeRequestId",
|
||||
schema: "orgboard",
|
||||
table: "change_request_steps",
|
||||
column: "ChangeRequestId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_change_requests_OrganizationId",
|
||||
schema: "orgboard",
|
||||
table: "change_requests",
|
||||
column: "OrganizationId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "change_request_steps",
|
||||
schema: "orgboard");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "change_requests",
|
||||
schema: "orgboard");
|
||||
}
|
||||
}
|
||||
}
|
||||
+110
@@ -170,6 +170,116 @@ namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
|
||||
b.ToTable("agent_profiles", "orgboard");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.ChangeRequest", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<decimal?>("Amount")
|
||||
.HasPrecision(12, 2)
|
||||
.HasColumnType("numeric(12,2)");
|
||||
|
||||
b.Property<DateTimeOffset?>("ApprovedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Currency")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("character varying(8)");
|
||||
|
||||
b.Property<string>("CustomerName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<decimal?>("EstimateHours")
|
||||
.HasPrecision(9, 2)
|
||||
.HasColumnType("numeric(9,2)");
|
||||
|
||||
b.Property<DateTimeOffset?>("EstimatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("LiveAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("OrganizationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("PaidAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationId");
|
||||
|
||||
b.ToTable("change_requests", "orgboard");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.ChangeRequestStep", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ChangeRequestId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("DependsOnStepId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("DivisionId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<decimal>("EstimateHours")
|
||||
.HasPrecision(9, 2)
|
||||
.HasColumnType("numeric(9,2)");
|
||||
|
||||
b.Property<int>("Order")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ChangeRequestId");
|
||||
|
||||
b.ToTable("change_request_steps", "orgboard");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Division", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
||||
@@ -17,6 +17,8 @@ internal sealed class OrgBoardDbContext(DbContextOptions<OrgBoardDbContext> opti
|
||||
public DbSet<ProductProfile> ProductProfiles => Set<ProductProfile>();
|
||||
public DbSet<WorkItem> WorkItems => Set<WorkItem>();
|
||||
public DbSet<WorkItemTransition> Transitions => Set<WorkItemTransition>();
|
||||
public DbSet<ChangeRequest> ChangeRequests => Set<ChangeRequest>();
|
||||
public DbSet<ChangeRequestStep> ChangeRequestSteps => Set<ChangeRequestStep>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -133,5 +135,28 @@ internal sealed class OrgBoardDbContext(DbContextOptions<OrgBoardDbContext> opti
|
||||
transition.HasIndex(t => t.WorkItemId);
|
||||
transition.HasIndex(t => t.TeamId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ChangeRequest>(cr =>
|
||||
{
|
||||
cr.ToTable("change_requests");
|
||||
cr.HasKey(c => c.Id);
|
||||
cr.Property(c => c.CustomerName).HasMaxLength(200).IsRequired();
|
||||
cr.Property(c => c.Title).HasMaxLength(300).IsRequired();
|
||||
cr.Property(c => c.Status).HasConversion<string>().HasMaxLength(16);
|
||||
cr.Property(c => c.EstimateHours).HasPrecision(9, 2);
|
||||
cr.Property(c => c.Amount).HasPrecision(12, 2);
|
||||
cr.Property(c => c.Currency).HasMaxLength(8).IsRequired();
|
||||
cr.HasIndex(c => c.OrganizationId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ChangeRequestStep>(step =>
|
||||
{
|
||||
step.ToTable("change_request_steps");
|
||||
step.HasKey(s => s.Id);
|
||||
step.Property(s => s.Title).HasMaxLength(300).IsRequired();
|
||||
step.Property(s => s.Status).HasConversion<string>().HasMaxLength(16);
|
||||
step.Property(s => s.EstimateHours).HasPrecision(9, 2);
|
||||
step.HasIndex(s => s.ChangeRequestId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,14 @@ internal sealed class BoardWriter(OrgBoardDbContext db, TimeProvider clock) : IB
|
||||
var item = await db.WorkItems.FirstOrDefaultAsync(w => w.Id == workItemId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Work item {workItemId} not found.");
|
||||
|
||||
item.AttachArtifact(content, clock.GetUtcNow());
|
||||
var now = clock.GetUtcNow();
|
||||
item.AttachArtifact(content, now);
|
||||
// Surface the delivered artifact on the board: move it into review (unless already done).
|
||||
if (item.Status is WorkItemStatus.Backlog or WorkItemStatus.InProgress)
|
||||
{
|
||||
item.MoveTo(WorkItemStatus.InReview, now);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user