Compare commits
18 Commits
13d01391da
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d6a0638c59 | |||
| c12935ad74 | |||
| 5c2b697b66 | |||
| 0658061580 | |||
| 1e33d57b4e | |||
| 861efa4e20 | |||
| e26f304675 | |||
| 63fef8799d | |||
| 1f562fd633 | |||
| 9993ebb2b4 | |||
| cb9ce34309 | |||
| b398e68c8b | |||
| 9c18ddfc8f | |||
| 848bd49352 | |||
| e24dc49970 | |||
| 0496cb1436 | |||
| c9be692d52 | |||
| 8a033a2a6f |
@@ -4,10 +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'
|
||||
@@ -24,10 +28,13 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route path="/login" element={token ? <Navigate to="/" replace /> : <LoginPage />} />
|
||||
<Route path="/" element={token ? <BoardPage /> : <Navigate to="/login" replace />} />
|
||||
<Route path="/start" element={token ? <GetStartedPage /> : <Navigate to="/login" replace />} />
|
||||
<Route path="/team" element={token ? <TeamPage /> : <Navigate to="/login" replace />} />
|
||||
<Route path="/seats" element={token ? <SeatsPage /> : <Navigate to="/login" replace />} />
|
||||
<Route path="/reviews" element={token ? <ReviewsPage /> : <Navigate to="/login" replace />} />
|
||||
<Route path="/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 />} />
|
||||
@@ -36,6 +43,7 @@ export default function App() {
|
||||
<Route path="/agent-profiles" element={token ? <AgentProfilesPage /> : <Navigate to="/login" replace />} />
|
||||
<Route path="/product-profiles" element={token ? <ProductProfilesPage /> : <Navigate to="/login" replace />} />
|
||||
<Route path="/performance" element={token ? <PerformancePage /> : <Navigate to="/login" replace />} />
|
||||
<Route path="/help" element={token ? <KnowledgeBasePage /> : <Navigate to="/login" replace />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<Toaster richColors position="top-right" />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { Link, useLocation } from 'react-router'
|
||||
import {
|
||||
BookMarked,
|
||||
BookOpen,
|
||||
BookUser,
|
||||
Bot,
|
||||
Boxes,
|
||||
@@ -13,27 +14,76 @@ import {
|
||||
LogOut,
|
||||
Network,
|
||||
Package,
|
||||
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">
|
||||
<aside
|
||||
className="flex w-60 shrink-0 flex-col border-r border-white/15 text-sidebar-foreground backdrop-blur-2xl"
|
||||
style={{ background: 'linear-gradient(180deg, oklch(0.27 0.1 287 / 0.78) 0%, oklch(0.2 0.085 298 / 0.78) 100%)' }}
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(168deg, oklch(0.32 0.14 268 / 0.82) 0%, oklch(0.28 0.15 292 / 0.82) 42%, oklch(0.26 0.15 318 / 0.82) 72%, oklch(0.24 0.13 340 / 0.82) 100%)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 px-5 py-4">
|
||||
<span className="grid size-8 place-items-center rounded-md bg-sidebar-primary font-bold text-sidebar-primary-foreground">
|
||||
<span
|
||||
className="grid size-8 place-items-center rounded-md font-bold text-white shadow-sm"
|
||||
style={{ background: 'linear-gradient(135deg, #6366f1, #d946ef)' }}
|
||||
>
|
||||
T
|
||||
</span>
|
||||
<div className="leading-tight">
|
||||
@@ -44,20 +94,34 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
|
||||
<Separator className="bg-sidebar-border" />
|
||||
|
||||
<nav className="flex flex-1 flex-col gap-1 p-3">
|
||||
<NavItem icon={LayoutDashboard} label="Board" to="/" />
|
||||
<NavItem icon={Sparkles} label="Team" to="/team" />
|
||||
<NavItem icon={Inbox} label="Cartable" to="/cartable" />
|
||||
<NavItem icon={ShieldCheck} label="Review inbox" to="/reviews" />
|
||||
<NavItem icon={Bot} label="AI seats" to="/seats" />
|
||||
<NavItem icon={BookUser} label="Agent profiles" to="/agent-profiles" />
|
||||
<NavItem icon={BookMarked} label="Skills" to="/skills" />
|
||||
<NavItem icon={Package} label="Product profiles" to="/product-profiles" />
|
||||
<NavItem icon={Network} label="Org chart" to="/org" />
|
||||
<NavItem icon={Boxes} label="Structure" to="/structure" />
|
||||
<NavItem icon={Users} label="Members" to="/members" />
|
||||
<NavItem icon={Gauge} label="Performance" to="/performance" />
|
||||
<NavItem icon={ChartColumn} label="Analytics" to="/analytics" />
|
||||
<nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto p-3">
|
||||
<NavItem icon={Rocket} label="Get started" to="/start" color="#fbbf24" />
|
||||
|
||||
<NavSection label="Work" />
|
||||
<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={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" />
|
||||
<NavItem icon={Network} label="Org chart" to="/org" color="#34d399" />
|
||||
<NavItem icon={Users} label="Members" to="/members" color="#34d399" />
|
||||
|
||||
<NavSection label="AI & libraries" />
|
||||
<NavItem icon={Bot} label="AI seats" to="/seats" color="#a78bfa" />
|
||||
<NavItem icon={BookMarked} label="Skills" to="/skills" color="#a78bfa" />
|
||||
<NavItem icon={BookUser} label="Agent profiles" to="/agent-profiles" color="#a78bfa" />
|
||||
<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" />
|
||||
|
||||
<NavSection label="Help" />
|
||||
<NavItem icon={BookOpen} label="Knowledge base" to="/help" color="#f472b6" />
|
||||
</nav>
|
||||
|
||||
<Separator className="bg-sidebar-border" />
|
||||
@@ -81,22 +145,34 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
function NavSection({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="px-3 pb-1 pt-4 text-[10px] font-semibold uppercase tracking-wider text-sidebar-foreground/45">
|
||||
{label}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NavItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
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
|
||||
|
||||
const className = cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors',
|
||||
'group/nav flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors',
|
||||
active
|
||||
? 'bg-white/15 font-medium text-white shadow-sm ring-1 ring-white/15 backdrop-blur-sm'
|
||||
: 'text-sidebar-foreground/80',
|
||||
@@ -105,8 +181,18 @@ function NavItem({
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<Icon className="size-4" />
|
||||
<span
|
||||
className="grid size-6 shrink-0 place-items-center rounded-md transition-colors"
|
||||
style={{ backgroundColor: color ? `${color}26` : undefined, color }}
|
||||
>
|
||||
<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 = {
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
export interface KbArticle {
|
||||
id: string
|
||||
category: string
|
||||
title: string
|
||||
summary: string
|
||||
keywords: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export const KB_CATEGORIES = [
|
||||
'Getting started',
|
||||
'Concepts',
|
||||
'Build your team',
|
||||
'Run & review',
|
||||
'Libraries',
|
||||
'Insights & governance',
|
||||
'Troubleshooting',
|
||||
] as const
|
||||
|
||||
// Authored as Markdown. Examples use 4-space indented code blocks (no backticks) so they render
|
||||
// cleanly. Keep articles practical: what it is, steps, and an example.
|
||||
export const KB_ARTICLES: KbArticle[] = [
|
||||
{
|
||||
id: 'what-is-teamup',
|
||||
category: 'Concepts',
|
||||
title: 'What is TeamUp?',
|
||||
summary: 'A live org chart that does work — humans and governed AI agents on one board.',
|
||||
keywords: 'overview intro what is teamup agents',
|
||||
body: `## What is TeamUp?
|
||||
|
||||
TeamUp.AI is a **live org chart that does work**. You model your organization — divisions, products, teams, and the role-seats inside them — and any open seat can be filled by a **governed AI agent** that does that role's work: writing specs, breaking down stories, implementing, drafting test plans, reviewing.
|
||||
|
||||
Humans and AI work side by side on one board, and **every AI action is a proposal a human reviews** before it takes effect.
|
||||
|
||||
### Why it's different
|
||||
- It has a concept of a **team** and of **role coverage** — not just a single assistant in one editor.
|
||||
- Output is **governed**: an action waits in a review queue unless you allow it to run.
|
||||
- It measures **human edit distance** — how little you change an agent's output before approving. Low and falling means the AI is trusted.
|
||||
|
||||
### The loop in one line
|
||||
Model the org → give the product an identity → staff seats with agents → assign work → review the action, result, and log → approve → the result lands, memory compounds, and the next role picks up automatically.`,
|
||||
},
|
||||
{
|
||||
id: 'orientation',
|
||||
category: 'Concepts',
|
||||
title: '5-minute orientation: the key terms',
|
||||
summary: 'Seats, agents, skills, autonomy, the action gate, memory, and the metric.',
|
||||
keywords: 'concepts terms glossary seat agent skill autonomy gate memory',
|
||||
body: `## The terms you'll see everywhere
|
||||
|
||||
- **Organization → Division → Product → Team → Seat** — your structure. A *team* runs a board; a *seat* is a role on a team.
|
||||
- **Seat states (the triad)** — *Human* (slate), *Open* (amber), or *AI* (indigo). An open seat can be staffed by an agent.
|
||||
- **Agent** — the AI in a seat: a name + persona, matched **skills**, an **autonomy** level, a model connection, and optional docs/tools.
|
||||
- **Skill** — a reusable capability (a SKILL.md) such as *spec-writing* or *test-plan-generation*. Gated by golden tests.
|
||||
- **Product identity (PRODUCT.md)** — a brief shared by every agent on the product, injected into every run.
|
||||
- **Autonomy dial** — *Draft-only / Gated / Autonomous*: whether an action waits for a human or runs immediately.
|
||||
- **Action & risk** — every output is an action with a risk tag (*read / draft / publish / destructive*).
|
||||
- **Action gate** — compares autonomy to risk; holds the action for review or executes it. **Destructive always waits for a human.**
|
||||
- **Review inbox** — where held actions wait. You see the action, the result, and the run log, then approve / edit / send back.
|
||||
- **Working memory** — decisions you approve become recallable team and product memory, read at the next run.
|
||||
- **Human edit distance** — the north-star metric: how much you change agent output before approving.`,
|
||||
},
|
||||
{
|
||||
id: 'quick-start',
|
||||
category: 'Getting started',
|
||||
title: 'Quick start: zero to your first AI agent',
|
||||
summary: 'The whole A-to-Z in seven steps. The Get started page tracks your progress.',
|
||||
keywords: 'quick start setup onboarding first agent a to z',
|
||||
body: `## From zero to a working AI team
|
||||
|
||||
The **Get started** page (top of the sidebar) tracks these steps and shows what's done.
|
||||
|
||||
1. **Sign in** — or, first time, choose *Bootstrap the owner* to create your org.
|
||||
2. **Model the org** (Structure) — add a product and at least one team under it.
|
||||
3. **Give the product an identity** (Structure → Identity) — write a short PRODUCT.md brief.
|
||||
4. **Connect a model** (AI seats → Model connections) — add your API key (BYOK).
|
||||
5. **Staff a seat** (AI seats) — pick a role, choose skills + autonomy + model, save. The seat turns AI.
|
||||
6. **Fill the backlog** (Board) — create tasks and assign them to agents or humans.
|
||||
7. **Review the first output** (Review inbox) — the agent's proposal waits for your approval.
|
||||
|
||||
### Example
|
||||
Create product **HRM**, team **HRM Engineering**, staff a **Backend Engineer** seat with an agent named *Dex* (skills: code-implementation, bug-diagnosis; autonomy: Gated). Create a story, assign it to Dex, and watch it run — its output lands in your review inbox.`,
|
||||
},
|
||||
{
|
||||
id: 'model-org',
|
||||
category: 'Build your team',
|
||||
title: 'Model your org: divisions, products, teams',
|
||||
summary: 'Build the spine: Organization → Divisions → Products/Services → Teams.',
|
||||
keywords: 'structure org division product team build spine',
|
||||
body: `## Model the organization
|
||||
|
||||
Go to **Structure**.
|
||||
|
||||
1. *(Optional)* Add **Divisions** — top-level slices (Technical, Finance, HR…).
|
||||
2. Add a **Product** or **Service** — engineering divisions ship products; others run services. Example: *HRM*.
|
||||
3. Add **Teams** and attach each to a product. A team is the unit that runs a board. Example: *HRM Product*, *HRM Engineering*, *HRM Quality*.
|
||||
|
||||
### Why product matters
|
||||
A team should sit **under a product** — that's what lets the product's shared identity and memory reach the team's agents. Teams with no product still work, but they don't get product-level context.`,
|
||||
},
|
||||
{
|
||||
id: 'product-identity',
|
||||
category: 'Build your team',
|
||||
title: 'Write a product identity (PRODUCT.md)',
|
||||
summary: 'A brief shared by every agent on the product, injected into every run.',
|
||||
keywords: 'product identity product.md brief shared context',
|
||||
body: `## Give the product an identity
|
||||
|
||||
On **Structure**, click **Identity** on a product. Write a PRODUCT.md — frontmatter + a Markdown brief — using the Edit / Preview tabs, then **Save**. It is now injected into every agent run on the product.
|
||||
|
||||
### What to include
|
||||
- What the product is and who it serves.
|
||||
- Its modules / scope.
|
||||
- Conventions every contributor must follow.
|
||||
|
||||
### Example PRODUCT.md
|
||||
|
||||
---
|
||||
product: HRM
|
||||
version: 1.0.0
|
||||
summary: A modular HRM application for mid-sized companies.
|
||||
---
|
||||
|
||||
# HRM — Human Resource Management
|
||||
|
||||
Employees, attendance, leave, payroll, recruitment, performance.
|
||||
|
||||
## Conventions
|
||||
- Money in minor units (integers); currency per company.
|
||||
- Dates/times stored in UTC; shown in the company timezone.
|
||||
- PII is access-controlled by role; least privilege.
|
||||
|
||||
**Tip:** keep reusable templates in **Product profiles** and apply one to a product in a click.`,
|
||||
},
|
||||
{
|
||||
id: 'byok',
|
||||
category: 'Build your team',
|
||||
title: 'Connect a model (BYOK)',
|
||||
summary: 'Add your own API key — owner-only, encrypted, never returned.',
|
||||
keywords: 'byok model api key connection openai endpoint',
|
||||
body: `## Connect a model
|
||||
|
||||
Go to **AI seats → Model connections**.
|
||||
|
||||
1. Click **Add**. Enter a **name**, **provider** (OpenAI-compatible), **model**, and your **API key**.
|
||||
2. *(Optional)* Set a **Base URL** for a gateway. You can paste a base URL *or* the full chat-completions URL — both work.
|
||||
3. Save, then click **Test** to confirm a call succeeds.
|
||||
|
||||
### Security
|
||||
Keys are **owner-only**, **encrypted at rest**, used **server-side only**, and **never returned** to a client after saving. Team owners assign a connection from a list; they never see the key.`,
|
||||
},
|
||||
{
|
||||
id: 'staff-seat',
|
||||
category: 'Build your team',
|
||||
title: 'Staff a seat with an AI agent',
|
||||
summary: 'Turn an open role-seat into a governed AI teammate.',
|
||||
keywords: 'staff seat agent configure autonomy skills persona',
|
||||
body: `## Staff a seat
|
||||
|
||||
Go to **AI seats**, pick a team, and select a role-seat (e.g. *Product Owner*).
|
||||
|
||||
1. *(Optional)* **Start from a profile** (AGENTS.md) to prefill identity, skills, and persona.
|
||||
2. Give the agent a **name** and **monogram**.
|
||||
3. Set **autonomy** — start with **Gated** so output waits for review.
|
||||
4. Pick **skills** — the configurator suggests a set for the role.
|
||||
5. Choose the **model connection**. *(Optional)* attach **docs** and **MCP tool servers**.
|
||||
6. **Save** — the seat turns AI and the agent appears with a live animated face.
|
||||
|
||||
### Example
|
||||
*Ava*, Product Owner — skills: spec-writing, story-breakdown, requirements-analysis; autonomy: Gated; model: your connection. Ava now drafts specs and child stories for any task you assign her, holding them in review.`,
|
||||
},
|
||||
{
|
||||
id: 'board-assign',
|
||||
category: 'Run & review',
|
||||
title: 'Create and assign work on the board',
|
||||
summary: 'Tasks across Backlog → In progress → In review → Done, assigned to humans or AI.',
|
||||
keywords: 'board task assign columns story spec drag',
|
||||
body: `## Run delivery on the board
|
||||
|
||||
Go to **Board** and pick a team.
|
||||
|
||||
1. **Create a task** — give it a title and type (Spec, Story, Test, Review).
|
||||
2. **Assign** it to a human or to an **AI agent** (open the task to assign).
|
||||
3. **Move** tasks across the columns: Backlog → In progress → In review → Done.
|
||||
|
||||
Assigning a task to an AI seat **dispatches a run**: the work is queued and executed off the request path, and the agent's face animates *thinking → working*. A Gated agent's output then waits in the **Review inbox**.`,
|
||||
},
|
||||
{
|
||||
id: 'review-inbox',
|
||||
category: 'Run & review',
|
||||
title: 'Read the review: action, result, and run log',
|
||||
summary: 'Every held item shows what the AI will do, what it produced, and how it got there.',
|
||||
keywords: 'review inbox action result run log trace approve transparency',
|
||||
body: `## The review inbox in depth
|
||||
|
||||
Every held action is shown in three parts so you can decide with full context.
|
||||
|
||||
1. **Action** — a plain statement of what approving does (e.g. "write this artifact to the board and create N child tasks"), with a warning for destructive actions.
|
||||
2. **Result** — the proposed **artifact** (editable) and the proposed **child tasks**. As you edit, a live **diff** highlights exactly what you changed.
|
||||
3. **Run log** — expand it to see *how the agent got there*:
|
||||
- latency, the agent and its autonomy;
|
||||
- which **skills** were applied;
|
||||
- which **tools** were available and **actually called** (with success/failure);
|
||||
- how many **memory hits** were recalled and whether the **product identity** was included;
|
||||
- the **raw model output** and the full **assembled prompt**.
|
||||
|
||||
### Example
|
||||
Ava's "Employee management" spec shows: *write-spec · Draft*, the drafted spec, and a run log reading "21.3s · skills: spec-writing, story-breakdown · product identity included". You read exactly what she did before approving.`,
|
||||
},
|
||||
{
|
||||
id: 'approve-edit-sendback',
|
||||
category: 'Run & review',
|
||||
title: 'Approve, edit & approve, or send back',
|
||||
summary: 'Your three choices — and the one that feeds the metric.',
|
||||
keywords: 'approve edit send back decision edit distance metric',
|
||||
body: `## Your three decisions
|
||||
|
||||
- **Approve** — the result executes: the artifact lands on the board and any child tasks are created. Recorded with zero edit distance.
|
||||
- **Edit & approve** — your edited version executes, and the **edit distance** (how much you changed) is recorded. *This is the metric.*
|
||||
- **Send back** — nothing executes; the item returns to the agent to try again.
|
||||
|
||||
### Why edit-and-approve matters
|
||||
The whole bet is that you **edit a little** rather than rewrite from scratch. Edit-and-approve is what feeds human edit distance — the signal that proves an agent is trustworthy. Watch it fall over a sprint in **Analytics**.`,
|
||||
},
|
||||
{
|
||||
id: 'handoff-memory',
|
||||
category: 'Run & review',
|
||||
title: 'The PO→QA handoff and working memory',
|
||||
summary: 'Done stories hand off to QA automatically; approvals become shared memory.',
|
||||
keywords: 'handoff qa done memory correction product team shared',
|
||||
body: `## Automatic handoff
|
||||
|
||||
When a story is marked **Done** on a team, TeamUp automatically creates a **QA task** for the team's QA agent, with provenance back to the story. The QA agent drafts a test plan that waits in review.
|
||||
|
||||
Guardrails prevent loops: QA output never re-triggers QA, and a task gets at most one handoff.
|
||||
|
||||
## Working memory
|
||||
|
||||
Every decision you approve — and especially every **correction** — is written to **working memory** and read back, by relevance, at the next run.
|
||||
|
||||
Memory is **layered**: a product's decisions are shared by **every agent across the product's teams**, while team memory keeps local context. So your corrections compound into institutional knowledge instead of being repeated.
|
||||
|
||||
### Example
|
||||
You correct Ava's spec to say "money in minor units". Next time *any* HRM agent runs, that correction is recalled — Dex the backend engineer sees it too.`,
|
||||
},
|
||||
{
|
||||
id: 'libraries',
|
||||
category: 'Libraries',
|
||||
title: 'Skills, agent profiles & product profiles',
|
||||
summary: 'Reusable, versioned building blocks — author, version, publish, install, apply.',
|
||||
keywords: 'skills agent profiles product profiles library marketplace version fork publish',
|
||||
body: `## Three reusable libraries
|
||||
|
||||
- **Skills** (SKILL.md) — capabilities an agent runs (spec-writing, code-implementation…). Free builtins ship for everyone; author your own, version them, and publish to the marketplace. A skill must be **Published** (a role + a passing golden test) to run.
|
||||
- **Agent profiles** (AGENTS.md) — reusable agent definitions (identity + skills + autonomy + persona). Apply one to a seat to prefill it.
|
||||
- **Product profiles** (PRODUCT.md) — reusable product identities. **Apply** one to a product to set its shared identity.
|
||||
|
||||
### Common actions (all three libraries)
|
||||
1. **View** — read the full .md (Edit / Preview).
|
||||
2. **Edit** / **New version** — change your own, or bump the version.
|
||||
3. **Fork** — copy a builtin into your org to customize.
|
||||
4. **Publish / Unpublish** — list or unlist on the marketplace.
|
||||
5. **Install** — copy a marketplace item into your library.`,
|
||||
},
|
||||
{
|
||||
id: 'analytics',
|
||||
category: 'Insights & governance',
|
||||
title: 'Analytics & the metric',
|
||||
summary: 'Approval rate, tasks done, and human edit distance per agent.',
|
||||
keywords: 'analytics metric edit distance approval rate performance trend',
|
||||
body: `## Proving the bet
|
||||
|
||||
The **Analytics** view tracks the numbers that matter:
|
||||
- **Approval rate** and **tasks done**;
|
||||
- **Human edit distance** per agent, with a trend line.
|
||||
|
||||
If edit distance is **low and falling** across a sprint, your AI teammates are trusted and saving you more time than they cost to supervise. The **Performance** view puts humans and AI on the same accountability scale.`,
|
||||
},
|
||||
{
|
||||
id: 'governance',
|
||||
category: 'Insights & governance',
|
||||
title: 'Governance & safety — what TeamUp guarantees',
|
||||
summary: 'Permission checks, the action gate, BYOK, and data-not-instructions.',
|
||||
keywords: 'governance safety security permission gate destructive byok air-gap',
|
||||
body: `## What TeamUp guarantees
|
||||
|
||||
- **Permission on every mutation**, at the right scope. The UI is never trusted for authorization.
|
||||
- **AI output is a proposal.** Nothing takes effect until the action gate allows it — and **destructive actions always wait for a human**, whatever the autonomy.
|
||||
- **BYOK keys** are owner-only, encrypted at rest, used server-side only, never returned to a client.
|
||||
- **Retrieved content** (docs, code, tool output) is treated as **data, not instructions**.
|
||||
- **Skills are golden-tested** — only passing skills can run or be published.
|
||||
- **Self-hostable and air-gappable** as a single unit.`,
|
||||
},
|
||||
{
|
||||
id: 'troubleshooting',
|
||||
category: 'Troubleshooting',
|
||||
title: 'Troubleshooting & tips',
|
||||
summary: 'Identity not injecting, skills not running, model test failing, and more.',
|
||||
keywords: 'troubleshooting tips problems identity skill draft model test fails',
|
||||
body: `## Common issues
|
||||
|
||||
- **Agent identity doesn't inject?** A team must be **under a product**, and that product must have a **PRODUCT.md identity**, for the shared identity to appear in runs.
|
||||
- **A skill won't run?** It must be **Published** — give it at least one role and a passing golden test.
|
||||
- **Model test fails?** Check the provider, model name, key, and endpoint. A full *chat-completions* URL works as well as a base URL.
|
||||
- **Nothing in the review inbox?** Assign a task to a **Gated** AI seat — Autonomous agents execute low-risk actions directly without holding.
|
||||
|
||||
## Tips
|
||||
- **Start agents Gated**, then raise autonomy once you trust them.
|
||||
- **Edit-and-approve** (don't rewrite) — that's what feeds the metric.
|
||||
- **Mark a story Done** to fire the PO→QA handoff.`,
|
||||
},
|
||||
]
|
||||
+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,136 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import { ArrowRight, Check, Circle, Rocket } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { AppShell } from '@/components/AppShell'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuth } from '@/store/auth'
|
||||
|
||||
interface Step {
|
||||
title: string
|
||||
desc: string
|
||||
done: boolean
|
||||
to: string
|
||||
cta: string
|
||||
}
|
||||
|
||||
export function GetStartedPage() {
|
||||
const organizationId = useAuth((s) => s.organizationId)
|
||||
const [steps, setSteps] = useState<Step[] | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!organizationId) return
|
||||
try {
|
||||
const [products, teams, configs] = await Promise.all([
|
||||
api.get<{ id: string }[]>(`/api/orgboard/products?organizationId=${organizationId}`),
|
||||
api.get<{ id: string; productId: string | null }[]>(`/api/orgboard/teams?organizationId=${organizationId}`),
|
||||
api.get<{ id: string }[]>(`/api/integrations/api-configs?organizationId=${organizationId}`),
|
||||
])
|
||||
|
||||
// Walk each team once for seats + board; cap the fan-out for big orgs.
|
||||
const teamsToScan = teams.slice(0, 12)
|
||||
const seatsByTeam = await Promise.all(
|
||||
teamsToScan.map((t) => api.get<{ state: string }[]>(`/api/orgboard/seats?teamId=${t.id}`).catch(() => [])),
|
||||
)
|
||||
const boards = await Promise.all(
|
||||
teamsToScan.map((t) =>
|
||||
api.get<{ columns: { items: unknown[] }[] }>(`/api/orgboard/board?teamId=${t.id}`).catch(() => ({ columns: [] })),
|
||||
),
|
||||
)
|
||||
const identities = await Promise.all(
|
||||
products.slice(0, 12).map((p) =>
|
||||
api.get<{ identity: string | null }>(`/api/orgboard/products/${p.id}/identity`).catch(() => ({ identity: null })),
|
||||
),
|
||||
)
|
||||
const reviews = await api
|
||||
.get<unknown[]>(`/api/governance/reviews?organizationId=${organizationId}`)
|
||||
.catch(() => [])
|
||||
|
||||
const hasAiSeat = seatsByTeam.some((seats) => seats.some((s) => s.state === 'Ai'))
|
||||
const hasTask = boards.some((b) => b.columns.some((c) => c.items.length > 0))
|
||||
const hasIdentity = identities.some((i) => !!i.identity)
|
||||
|
||||
setSteps([
|
||||
{ title: 'Workspace ready', desc: 'Your organization exists and you are signed in as its owner.', done: true, to: '/', cta: 'Open board' },
|
||||
{ title: 'Model the org', desc: 'Create divisions → products/services → teams. A team runs a board.', done: teams.length > 0, to: '/structure', cta: 'Structure' },
|
||||
{ title: 'Give the product an identity', desc: 'Write a PRODUCT.md brief — shared by every agent on the product.', done: hasIdentity, to: '/structure', cta: 'Set identity' },
|
||||
{ title: 'Connect a model (BYOK)', desc: 'Add an API key (OpenAI-compatible). Owner-only, encrypted, never returned.', done: configs.length > 0, to: '/seats', cta: 'Add connection' },
|
||||
{ title: 'Staff a seat with an AI agent', desc: 'Pick a role-seat, choose skills + autonomy + the model, and turn it AI.', done: hasAiSeat, to: '/seats', cta: 'Staff a seat' },
|
||||
{ title: 'Fill the backlog', desc: 'Create tasks on the board — assign them to humans or AI agents.', done: hasTask, to: '/', cta: 'Create tasks' },
|
||||
{ title: 'Review the first agent output', desc: 'Assign a task to an agent; its output waits in the review inbox to approve.', done: reviews.length > 0, to: '/reviews', cta: 'Review inbox' },
|
||||
])
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
}
|
||||
}, [organizationId])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const doneCount = steps?.filter((s) => s.done).length ?? 0
|
||||
const total = steps?.length ?? 7
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="mx-auto max-w-2xl p-6">
|
||||
<header className="mb-6">
|
||||
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
|
||||
<Rocket className="size-6" /> Get started
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Build a human + AI team from A to Z. {doneCount} of {total} steps done.
|
||||
</p>
|
||||
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-muted/60">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{ width: `${(doneCount / total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{steps?.map((step, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<span
|
||||
className={cn(
|
||||
'grid size-8 shrink-0 place-items-center rounded-full text-sm font-semibold',
|
||||
step.done ? 'bg-approved/20 text-approved' : 'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{step.done ? <Check className="size-4" /> : <Circle className="size-3.5" />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={cn('text-sm font-medium', step.done && 'text-muted-foreground line-through')}>
|
||||
{i + 1}. {step.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{step.desc}</p>
|
||||
</div>
|
||||
<Button asChild variant={step.done ? 'ghost' : 'outline'} size="sm" className="shrink-0">
|
||||
<Link to={step.to}>
|
||||
{step.done ? 'View' : step.cta}
|
||||
<ArrowRight data-icon="inline-end" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{steps === null && <p className="text-sm text-muted-foreground">Checking your setup…</p>}
|
||||
</div>
|
||||
|
||||
{steps && doneCount === total && (
|
||||
<Card className="mt-4">
|
||||
<CardContent className="py-5 text-center text-sm">
|
||||
🎉 Your human + AI team is running end to end. Mark a story <b>Done</b> to fire the PO→QA handoff,
|
||||
and watch <Link to="/analytics" className="text-primary underline">Analytics</Link> for human edit distance.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { BookOpen, ChevronDown, ChevronRight, Search } from 'lucide-react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { AppShell } from '@/components/AppShell'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { KB_ARTICLES, KB_CATEGORIES, type KbArticle } from '@/lib/kbArticles'
|
||||
import '@/components/markdown.css'
|
||||
|
||||
export function KnowledgeBasePage() {
|
||||
const [query, setQuery] = useState('')
|
||||
const [open, setOpen] = useState<Set<string>>(new Set())
|
||||
|
||||
const matches = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return KB_ARTICLES
|
||||
return KB_ARTICLES.filter((a) =>
|
||||
`${a.title} ${a.summary} ${a.keywords} ${a.body}`.toLowerCase().includes(q),
|
||||
)
|
||||
}, [query])
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
return KB_CATEGORIES.map((category) => ({
|
||||
category,
|
||||
articles: matches.filter((a) => a.category === category),
|
||||
})).filter((g) => g.articles.length > 0)
|
||||
}, [matches])
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setOpen((s) => {
|
||||
const next = new Set(s)
|
||||
next.has(id) ? next.delete(id) : next.add(id)
|
||||
return next
|
||||
})
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="mx-auto max-w-3xl p-6">
|
||||
<header className="mb-5">
|
||||
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
|
||||
<BookOpen className="size-6" /> Knowledge base
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
How to work with TeamUp — concepts, step-by-step guides, and examples.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="relative mb-6">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search the knowledge base…"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{grouped.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No articles match “{query}”.</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
{grouped.map((group) => (
|
||||
<section key={group.category} className="flex flex-col gap-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{group.category}
|
||||
</h2>
|
||||
{group.articles.map((article) => (
|
||||
<ArticleCard
|
||||
key={article.id}
|
||||
article={article}
|
||||
open={open.has(article.id) || (!!query && matches.length <= 4)}
|
||||
onToggle={() => toggle(article.id)}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
function ArticleCard({ article, open, onToggle }: { article: KbArticle; open: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex w-full items-start gap-3 py-4 text-left"
|
||||
>
|
||||
{open ? <ChevronDown className="mt-0.5 size-4 shrink-0 text-muted-foreground" /> : <ChevronRight className="mt-0.5 size-4 shrink-0 text-muted-foreground" />}
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium">{article.title}</span>
|
||||
<span className={cn('block text-xs text-muted-foreground', open && 'sr-only')}>{article.summary}</span>
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="md-prose pb-5 pl-7">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{article.body}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -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.
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