Theme 2: cross-division delivery pipeline (change requests)
A customer change request now flows through a guarded commercial pipeline: Requested -> Estimated -> Approved -> Paid -> Live. The cross-division work and its dependencies live on the request's steps (a division's slice + hours + an optional depends-on link), and estimating sums the steps into a total. Each transition is guarded on the ChangeRequest aggregate, so it can only move forward in order; guard violations surface as 400s. - Domain: ChangeRequest + ChangeRequestStep aggregates with stage guards - Persistence: two tables + EF migration (applied) - Endpoints under /api/orgboard/change-requests: create/list/detail, add/advance steps, and estimate/approve/pay/go-live/reject (reads need board-view, commercial actions are owner-level) - New Delivery pipeline page: request list with stage + step progress, a detail drawer with a stage stepper, the next commercial action, quote entry, and a per-division step breakdown with dependencies Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import { LoginPage } from '@/pages/LoginPage'
|
|||||||
import { MembersPage } from '@/pages/MembersPage'
|
import { MembersPage } from '@/pages/MembersPage'
|
||||||
import { OrgChartPage } from '@/pages/OrgChartPage'
|
import { OrgChartPage } from '@/pages/OrgChartPage'
|
||||||
import { PerformancePage } from '@/pages/PerformancePage'
|
import { PerformancePage } from '@/pages/PerformancePage'
|
||||||
|
import { PipelinePage } from '@/pages/PipelinePage'
|
||||||
import { ProductProfilesPage } from '@/pages/ProductProfilesPage'
|
import { ProductProfilesPage } from '@/pages/ProductProfilesPage'
|
||||||
import { ReviewsPage } from '@/pages/ReviewsPage'
|
import { ReviewsPage } from '@/pages/ReviewsPage'
|
||||||
import { SeatsPage } from '@/pages/SeatsPage'
|
import { SeatsPage } from '@/pages/SeatsPage'
|
||||||
@@ -33,6 +34,7 @@ export default function App() {
|
|||||||
<Route path="/reviews" element={token ? <ReviewsPage /> : <Navigate to="/login" replace />} />
|
<Route path="/reviews" element={token ? <ReviewsPage /> : <Navigate to="/login" replace />} />
|
||||||
<Route path="/analytics" element={token ? <AnalyticsPage /> : <Navigate to="/login" replace />} />
|
<Route path="/analytics" element={token ? <AnalyticsPage /> : <Navigate to="/login" replace />} />
|
||||||
<Route path="/delivery" element={token ? <DeliveryPage /> : <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="/cartable" element={token ? <CartablePage /> : <Navigate to="/login" replace />} />
|
||||||
<Route path="/members" element={token ? <MembersPage /> : <Navigate to="/login" replace />} />
|
<Route path="/members" element={token ? <MembersPage /> : <Navigate to="/login" replace />} />
|
||||||
<Route path="/org" element={token ? <OrgChartPage /> : <Navigate to="/login" replace />} />
|
<Route path="/org" element={token ? <OrgChartPage /> : <Navigate to="/login" replace />} />
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Users,
|
Users,
|
||||||
|
Workflow,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
@@ -100,6 +101,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
<NavItem icon={LayoutDashboard} label="Board" to="/" color="#38bdf8" />
|
<NavItem icon={LayoutDashboard} label="Board" to="/" color="#38bdf8" />
|
||||||
<NavItem icon={Sparkles} label="Team" to="/team" color="#38bdf8" />
|
<NavItem icon={Sparkles} label="Team" to="/team" color="#38bdf8" />
|
||||||
<NavItem icon={Inbox} label="Cartable" to="/cartable" 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} />
|
<NavItem icon={ShieldCheck} label="Review inbox" to="/reviews" color="#38bdf8" badge={reviewCount} />
|
||||||
|
|
||||||
<NavSection label="Organization" />
|
<NavSection label="Organization" />
|
||||||
|
|||||||
@@ -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(list)
|
||||||
|
setDivisions(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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -79,6 +79,49 @@ internal sealed record AgentResponse(
|
|||||||
List<string> Docs,
|
List<string> Docs,
|
||||||
string? Persona);
|
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 ---
|
// --- Agent profiles (AGENTS.md): a per-org library of reusable agent definitions ---
|
||||||
|
|
||||||
internal sealed record UploadAgentProfileRequest(Guid OrganizationId, string Content);
|
internal sealed record UploadAgentProfileRequest(Guid OrganizationId, string Content);
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ internal static class OrgBoardEndpoints
|
|||||||
|
|
||||||
AgentProfileEndpoints.MapTo(group);
|
AgentProfileEndpoints.MapTo(group);
|
||||||
ProductProfileEndpoints.MapTo(group);
|
ProductProfileEndpoints.MapTo(group);
|
||||||
|
ChangeRequestEndpoints.MapTo(group);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static TaskResponse ToResponse(WorkItem item) => new(
|
private static TaskResponse ToResponse(WorkItem item) => new(
|
||||||
|
|||||||
+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");
|
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 =>
|
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Division", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ internal sealed class OrgBoardDbContext(DbContextOptions<OrgBoardDbContext> opti
|
|||||||
public DbSet<ProductProfile> ProductProfiles => Set<ProductProfile>();
|
public DbSet<ProductProfile> ProductProfiles => Set<ProductProfile>();
|
||||||
public DbSet<WorkItem> WorkItems => Set<WorkItem>();
|
public DbSet<WorkItem> WorkItems => Set<WorkItem>();
|
||||||
public DbSet<WorkItemTransition> Transitions => Set<WorkItemTransition>();
|
public DbSet<WorkItemTransition> Transitions => Set<WorkItemTransition>();
|
||||||
|
public DbSet<ChangeRequest> ChangeRequests => Set<ChangeRequest>();
|
||||||
|
public DbSet<ChangeRequestStep> ChangeRequestSteps => Set<ChangeRequestStep>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
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.WorkItemId);
|
||||||
transition.HasIndex(t => t.TeamId);
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user