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 { 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'
|
||||
@@ -33,6 +34,7 @@ export default function App() {
|
||||
<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 />} />
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Sparkles,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Workflow,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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={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" />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user