refactor(ops): decompose ops-client into panels + components (slice 6)

956-line ops-client.tsx → a 230-line orchestrator + ops/panels/{kb,secrets,
area-leads}-panel.tsx (kb+processes share kb-panel via a variant prop) +
ops/components/{kb-modal,secret-modal,area-lead-modal,secret-row,kb-entry-row}.tsx
+ types.ts, following the customize/ exemplar. Behavior preserved (direct writes
kept as-is for now — the secrets one is fixed in the next commit). tsc 0, build ok.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Moheeb Zara
2026-08-04 06:14:24 -07:00
co-authored by Claude Opus 4.8
parent f86fff62d0
commit 7bc6609ac2
10 changed files with 963 additions and 781 deletions
+72
View File
@@ -0,0 +1,72 @@
'use client'
import { Pencil, Trash2, Users2 } from 'lucide-react'
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyContent } from '@/components/ui/empty'
import type { AreaLead } from '../types'
// ─── Area Leads Panel ──────────────────────────────────────────────────────────
export function AreaLeadsPanel({
leads,
onAdd,
onEdit,
onDelete,
}: {
leads: AreaLead[]
onAdd: () => void
onEdit: (lead: AreaLead) => void
onDelete: (id: string) => void
}) {
return (
<div>
{leads.length > 0 ? (
<div className="bg-card rounded border border-border divide-y divide-border">
{leads.map(lead => (
<div key={lead.id} className="flex items-center gap-3 px-4 py-4 group hover:bg-muted/20 transition">
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center text-xs font-mono font-bold text-primary flex-shrink-0">
{lead.area_name.slice(0, 2).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="font-sans text-sm font-medium text-foreground">{lead.area_name}</p>
<p className="font-mono text-xs text-muted-foreground">{lead.lead_handle}</p>
{lead.description && (
<p className="font-mono text-[10px] text-muted-foreground/60 mt-0.5">{lead.description}</p>
)}
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
<button
onClick={() => onEdit(lead)}
className="text-muted-foreground hover:text-primary transition p-1"
title="Edit"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => onDelete(lead.id)}
className="text-muted-foreground hover:text-red-500 transition p-1"
title="Remove"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
) : (
<Empty className="bg-card border border-dashed border-border">
<EmptyHeader>
<EmptyMedia variant="icon"><Users2 /></EmptyMedia>
<EmptyTitle>No area leads assigned yet</EmptyTitle>
</EmptyHeader>
<EmptyContent>
<button
onClick={onAdd}
className="font-mono text-xs text-primary hover:underline"
>
+ Assign first area lead
</button>
</EmptyContent>
</Empty>
)}
</div>
)
}
+137
View File
@@ -0,0 +1,137 @@
'use client'
import { Search, FileText } from 'lucide-react'
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyContent } from '@/components/ui/empty'
import { KbEntryRow } from '../components/kb-entry-row'
import type { AclRoleOption, KbEntry } from '../types'
// ─── KB / Processes Panel ──────────────────────────────────────────────────────
// Shared body for the "Knowledge Base" and "Processes" tabs. The KB variant
// splits pinned/critical entries into their own section; the processes variant
// renders a single flat list. Both receive already-filtered entries.
export function KbPanel({
variant,
entries,
search,
onAdd,
onEdit,
onDelete,
canManageAcl,
aclRoleOptions,
aclByEntity,
}: {
variant: 'kb' | 'processes'
entries: KbEntry[]
search: string
onAdd: () => void
onEdit: (e: KbEntry) => void
onDelete: (id: string) => void
canManageAcl?: boolean
aclRoleOptions?: AclRoleOption[]
aclByEntity?: Record<string, string[]>
}) {
if (variant === 'processes') {
return (
<div className="space-y-4">
{entries.length > 0 ? (
<div className="bg-card rounded border border-border divide-y divide-border">
{entries.map(entry => (
<KbEntryRow
key={entry.id}
entry={entry}
onEdit={onEdit}
onDelete={onDelete}
canManageAcl={canManageAcl}
aclRoleOptions={aclRoleOptions}
aclByEntity={aclByEntity}
/>
))}
</div>
) : (
<Empty className="bg-card border border-dashed border-border">
<EmptyHeader>
<EmptyMedia variant="icon">{search ? <Search /> : <FileText />}</EmptyMedia>
<EmptyTitle>
{search ? `No results for "${search}"` : 'No process entries yet'}
</EmptyTitle>
</EmptyHeader>
{!search && (
<EmptyContent>
<button
onClick={onAdd}
className="font-mono text-xs text-primary hover:underline"
>
+ Add first process
</button>
</EmptyContent>
)}
</Empty>
)}
</div>
)
}
const pinnedKb = entries.filter(e => e.is_pinned)
const unpinnedKb = entries.filter(e => !e.is_pinned)
return (
<div className="space-y-6">
{pinnedKb.length > 0 && (
<div>
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">Pinned / Critical</p>
<div className="bg-card rounded border border-border divide-y divide-border">
{pinnedKb.map(entry => (
<KbEntryRow
key={entry.id}
entry={entry}
onEdit={onEdit}
onDelete={onDelete}
canManageAcl={canManageAcl}
aclRoleOptions={aclRoleOptions}
aclByEntity={aclByEntity}
/>
))}
</div>
</div>
)}
{unpinnedKb.length > 0 && (
<div>
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">All Entries</p>
<div className="bg-card rounded border border-border divide-y divide-border">
{unpinnedKb.map(entry => (
<KbEntryRow
key={entry.id}
entry={entry}
onEdit={onEdit}
onDelete={onDelete}
canManageAcl={canManageAcl}
aclRoleOptions={aclRoleOptions}
aclByEntity={aclByEntity}
/>
))}
</div>
</div>
)}
{entries.length === 0 && (
<Empty className="bg-card border border-dashed border-border">
<EmptyHeader>
<EmptyMedia variant="icon">{search ? <Search /> : <FileText />}</EmptyMedia>
<EmptyTitle>
{search ? `No results for "${search}"` : 'No knowledge base entries yet'}
</EmptyTitle>
</EmptyHeader>
{!search && (
<EmptyContent>
<button
onClick={onAdd}
className="font-mono text-xs text-primary hover:underline"
>
+ Add first entry
</button>
</EmptyContent>
)}
</Empty>
)}
</div>
)
}
+72
View File
@@ -0,0 +1,72 @@
'use client'
import { Lock } from 'lucide-react'
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyContent } from '@/components/ui/empty'
import { SecretRow } from '../components/secret-row'
import type { AclRoleOption, Secret } from '../types'
// ─── Secrets & Credentials Panel ───────────────────────────────────────────────
export function SecretsPanel({
canSeeSecrets,
secrets,
search,
onAdd,
onDelete,
canManageAcl,
aclRoleOptions,
aclByEntity,
}: {
canSeeSecrets: boolean
secrets: Secret[]
search: string
onAdd: () => void
onDelete: (id: string) => void
canManageAcl?: boolean
aclRoleOptions?: AclRoleOption[]
aclByEntity?: Record<string, string[]>
}) {
return (
<div>
{!canSeeSecrets ? (
<Empty className="bg-card border border-dashed border-border">
<EmptyHeader>
<EmptyMedia variant="icon"><Lock /></EmptyMedia>
<EmptyTitle>Admin or board access required to view secrets</EmptyTitle>
</EmptyHeader>
</Empty>
) : secrets.length > 0 ? (
<div className="bg-card rounded border border-border divide-y divide-border">
{secrets.map(s => (
<SecretRow
key={s.id}
secret={s}
onDelete={onDelete}
canManageAcl={canManageAcl}
aclRoleOptions={aclRoleOptions}
aclInitial={aclByEntity?.[`secret:${s.id}`] ?? []}
/>
))}
</div>
) : (
<Empty className="bg-card border border-dashed border-border">
<EmptyHeader>
<EmptyMedia variant="icon"><Lock /></EmptyMedia>
<EmptyTitle>
{search ? `No results for "${search}"` : 'No secrets stored yet'}
</EmptyTitle>
</EmptyHeader>
{!search && (
<EmptyContent>
<button
onClick={onAdd}
className="font-mono text-xs text-primary hover:underline"
>
+ Add first secret
</button>
</EmptyContent>
)}
</Empty>
)}
</div>
)
}