diff --git a/app/(app)/ops/components/area-lead-modal.tsx b/app/(app)/ops/components/area-lead-modal.tsx new file mode 100644 index 0000000..5933929 --- /dev/null +++ b/app/(app)/ops/components/area-lead-modal.tsx @@ -0,0 +1,86 @@ +'use client' + +import { useState } from 'react' +import { createClient } from '@/lib/supabase/client' +import { toast } from 'sonner' +import type { TablesInsert, TablesUpdate } from '@/types/database' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import type { AreaLead } from '../types' + +// ─── Area Lead Modal ─────────────────────────────────────────────────────────── +export function AreaLeadModal({ + lead, + onClose, + onSaved, + spaceId, +}: { + lead: AreaLead | null + onClose: () => void + onSaved: (l: AreaLead) => void + spaceId: string +}) { + const isEdit = !!lead + const [areaName, setAreaName] = useState(lead?.area_name ?? '') + const [memberName, setMemberName] = useState(lead?.lead_handle ?? '') + const [contactInfo, setContactInfo] = useState(lead?.description ?? '') + const [saving, setSaving] = useState(false) + + async function handleSave(e: React.FormEvent) { + e.preventDefault() + if (!areaName.trim() || !memberName.trim()) return + setSaving(true) + const supabase = createClient() + let result + if (isEdit) { + result = await supabase.from('area_leads').update({ + area_name: areaName.trim(), + lead_handle: memberName.trim(), + description: contactInfo.trim() || null, + // TODO(types): remove `as never` after regenerating types/database.ts (missing FK relationship metadata) + } satisfies TablesUpdate<'area_leads'> as never).eq('id', lead.id).select('*').single() + } else { + result = await supabase.from('area_leads').insert({ + space_id: spaceId, + area_name: areaName.trim(), + lead_handle: memberName.trim(), + description: contactInfo.trim() || null, + // TODO(types): remove `as never` after regenerating types/database.ts (missing FK relationship metadata) + } satisfies TablesInsert<'area_leads'> as never).select('*').single() + } + setSaving(false) + if (result.error) { toast.error(result.error.message); return } + toast.success(isEdit ? 'Area lead updated' : 'Area lead added') + onSaved(result.data as AreaLead) + onClose() + } + + return ( + { if (!o) onClose() }}> + + + {isEdit ? 'Edit Area Lead' : 'Add Area Lead'} + +
+
+ + setAreaName(e.target.value)} className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" placeholder="e.g. Woodshop" required /> +
+
+ + setMemberName(e.target.value)} className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" placeholder="e.g. Alice Smith" required /> +
+
+ + setContactInfo(e.target.value)} className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" placeholder="e.g. alice@example.com or Slack @alice" /> +
+
+ + +
+
+
+
+ ) +} diff --git a/app/(app)/ops/components/kb-entry-row.tsx b/app/(app)/ops/components/kb-entry-row.tsx new file mode 100644 index 0000000..bd580cd --- /dev/null +++ b/app/(app)/ops/components/kb-entry-row.tsx @@ -0,0 +1,129 @@ +'use client' + +import { useState } from 'react' +import { Pin, Pencil, Trash2 } from 'lucide-react' +import { SafeMarkdown } from '@/components/safe-markdown' +import { deleteKbEntry } from '@/lib/actions' +import { OpsAclEditor } from '@/components/ops/ops-acl-editor' +import { toast } from 'sonner' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { useConfirm } from '@/components/ui/confirm' +import type { AclRoleOption, KbEntry } from '../types' +import { VISIBILITY_LABELS, VISIBILITY_COLORS } from '../types' + +// ─── KB Entry Row ────────────────────────────────────────────────────────────── +export function KbEntryRow({ + entry, + onEdit, + onDelete, + canManageAcl, + aclRoleOptions, + aclByEntity, +}: { + entry: KbEntry + onEdit: (e: KbEntry) => void + onDelete: (id: string) => void + canManageAcl?: boolean + aclRoleOptions?: AclRoleOption[] + aclByEntity?: Record +}) { + const confirm = useConfirm() + const [viewing, setViewing] = useState(false) + const aclEntityType: 'kb' | 'process' = entry.tags?.includes('process') ? 'process' : 'kb' + const aclInitial = aclByEntity?.[`${aclEntityType}:${entry.id}`] ?? [] + + async function handleDelete(e: React.MouseEvent) { + e.stopPropagation() + if (!(await confirm({ title: 'Delete entry', description: `"${entry.title}" will be permanently removed.`, confirmText: 'Delete', destructive: true }))) return + const result = await deleteKbEntry(entry.id) + if ('error' in result && result.error) { toast.error(result.error); return } + toast.success('Entry deleted') + onDelete(entry.id) + } + + function handleEdit(e: React.MouseEvent) { + e.stopPropagation() + onEdit(entry) + } + + return ( + <> +
setViewing(true)} + className={`flex items-start gap-3 px-4 py-4 hover:bg-muted/20 transition group border-l-4 cursor-pointer ${ + entry.is_pinned + ? entry.visibility === 'admin_only' ? 'border-l-red-400' : entry.visibility === 'board' ? 'border-l-amber-400' : 'border-l-primary' + : 'border-l-transparent' + }`} + > +
+ {entry.icon || ( + + + + )} +
+
+
+

{entry.title}

+ {entry.is_pinned && } +
+

{entry.content?.slice(0, 120)}

+

+ {entry.area && {entry.area} · } + updated {new Date(entry.updated_at).toLocaleDateString()} by {entry.updated_by_name} +

+
+
+ + {VISIBILITY_LABELS[entry.visibility] ?? entry.visibility} + +
+ + +
+
+
+ + { if (!o) setViewing(false) }}> + + +
+
+
+ {entry.icon && {entry.icon}} + {entry.title} + {entry.is_pinned && } +
+

+ {entry.area && {entry.area} · } + {VISIBILITY_LABELS[entry.visibility] ?? entry.visibility} + {' · '} + updated {new Date(entry.updated_at).toLocaleDateString()} by {entry.updated_by_name ?? 'unknown'} +

+
+ +
+
+
+ {entry.content ?? ''} + {canManageAcl && ( + + )} +
+
+
+ + ) +} diff --git a/app/(app)/ops/components/kb-modal.tsx b/app/(app)/ops/components/kb-modal.tsx new file mode 100644 index 0000000..9b853a1 --- /dev/null +++ b/app/(app)/ops/components/kb-modal.tsx @@ -0,0 +1,142 @@ +'use client' + +import { useState } from 'react' +import { createKbEntry, updateKbEntry } from '@/lib/actions' +import { toast } from 'sonner' +import type { Enums } from '@/types/database' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import type { KbEntry } from '../types' + +// ─── KB Entry Modal ──────────────────────────────────────────────────────────── +export function KbModal({ + entry, + onClose, + onSaved, +}: { + entry: KbEntry | null + onClose: () => void + onSaved: (entry: KbEntry) => void +}) { + const isEdit = !!entry + const [title, setTitle] = useState(entry?.title ?? '') + const [content, setContent] = useState(entry?.content ?? '') + const [area, setArea] = useState(entry?.area ?? '') + const [visibility, setVisibility] = useState(entry?.visibility ?? 'all_members') + const [isPinned, setIsPinned] = useState(entry?.is_pinned ?? false) + const [isProcess, setIsProcess] = useState(entry?.tags?.includes('process') ?? false) + const [saving, setSaving] = useState(false) + + async function handleSave(e: React.FormEvent) { + e.preventDefault() + if (!title.trim() || !content.trim()) return + setSaving(true) + const tags = isProcess ? ['process'] : [] + let result + if (isEdit) { + result = await updateKbEntry(entry.id, { title, content, area, visibility, is_pinned: isPinned, tags }) + } else { + result = await createKbEntry({ title, content, area, visibility, is_pinned: isPinned, tags }) + } + setSaving(false) + if ('error' in result && result.error) { + toast.error(result.error) + return + } + toast.success(isEdit ? 'Entry updated' : 'Entry created') + if (!isEdit && 'data' in result && result.data) onSaved(result.data as KbEntry) + else if (isEdit) onSaved({ ...entry, title, content, area, visibility, is_pinned: isPinned, tags }) + onClose() + } + + return ( + { if (!o) onClose() }}> + + + {isEdit ? 'Edit Entry' : 'New KB Entry'} + +
+
+ + setTitle(e.target.value)} + className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" + placeholder="e.g. How to open the space" + required + /> +
+
+ +