fix: implement comprehensive bug fixes across multiple components\n\nFix chat, tasks, ops, import, and PayPal components for full functionality.

This commit is contained in:
v0
2026-03-11 05:30:44 +00:00
parent 93cc7af60b
commit 88071abd3f
11 changed files with 1803 additions and 369 deletions
+133 -50
View File
@@ -2,7 +2,7 @@
import { useState, useEffect, useRef } from 'react'
import { createClient } from '@/lib/supabase/client'
import { Hash, Users2 } from 'lucide-react'
import { Hash, Users2, Send } from 'lucide-react'
interface Props {
member: any
@@ -20,18 +20,50 @@ export default function CommsClient({ member, space, channels }: Props) {
useEffect(() => {
if (!selectedChannel) return
loadMessages()
const supabase = createClient()
const subscription = supabase
.channel(`channel:${selectedChannel.id}`)
.on('postgres_changes', { event: '*', schema: 'public', table: 'comms_messages', filter: `channel_id=eq.${selectedChannel.id}` }, payload => {
if (payload.eventType === 'INSERT') {
setMessages(prev => [...prev, payload.new])
}
})
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'comms_messages',
filter: `channel_id=eq.${selectedChannel.id}`,
},
payload => {
setMessages(prev => {
// Dedup: don't add if we already have a message with this id
// (e.g. from the optimistic update that was then confirmed)
if (prev.some(m => m.id === payload.new.id)) return prev
// Also replace any temp message from same user sent at ~same time
const hasTempForThisUser = prev.some(
m =>
typeof m.id === 'string' &&
m.id.startsWith('temp-') &&
m.user_id === payload.new.user_id &&
m.content === payload.new.content,
)
if (hasTempForThisUser) {
return prev.map(m =>
typeof m.id === 'string' &&
m.id.startsWith('temp-') &&
m.user_id === payload.new.user_id &&
m.content === payload.new.content
? payload.new
: m,
)
}
return [...prev, payload.new]
})
},
)
.subscribe()
return () => { subscription.unsubscribe() }
return () => {
subscription.unsubscribe()
}
}, [selectedChannel])
useEffect(() => {
@@ -51,21 +83,58 @@ export default function CommsClient({ member, space, channels }: Props) {
async function sendMessage(e: React.FormEvent) {
e.preventDefault()
if (!newMessage.trim() || sending) return
const content = newMessage.trim()
if (!content || sending) return
setSending(true)
const supabase = createClient()
await supabase.from('comms_messages').insert({
// Optimistic insert — message appears immediately for the sender
const tempId = `temp-${Date.now()}`
const optimisticMsg = {
id: tempId,
channel_id: selectedChannel.id,
space_id: space?.id ?? member.space_id,
user_id: member.user_id,
display_name: member.display_name,
handle: member.handle,
content: newMessage.trim(),
})
content,
created_at: new Date().toISOString(),
}
setMessages(prev => [...prev, optimisticMsg])
setNewMessage('')
const supabase = createClient()
const { data, error } = await supabase
.from('comms_messages')
.insert({
channel_id: selectedChannel.id,
space_id: space?.id ?? member.space_id,
user_id: member.user_id,
display_name: member.display_name,
handle: member.handle,
content,
})
.select()
.single()
if (error) {
// Roll back optimistic message on failure
setMessages(prev => prev.filter(m => m.id !== tempId))
setNewMessage(content)
} else if (data) {
// Replace temp with confirmed message (realtime may also fire — dedup handles it)
setMessages(prev => prev.map(m => (m.id === tempId ? data : m)))
}
setSending(false)
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendMessage(e as unknown as React.FormEvent)
}
}
const generalChannels = channels.filter(c => c.channel_type === 'general')
const areaChannels = channels.filter(c => c.channel_type === 'area')
const projectChannels = channels.filter(c => c.channel_type === 'project')
@@ -151,7 +220,7 @@ export default function CommsClient({ member, space, channels }: Props) {
}`}
>
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span className="truncate">{channel.name}</span>
</button>
@@ -161,12 +230,12 @@ export default function CommsClient({ member, space, channels }: Props) {
</div>
</aside>
{/* Messages area */}
{/* Main chat area */}
{selectedChannel ? (
<div className="flex-1 flex flex-col">
<div className="flex-1 flex flex-col overflow-hidden">
{/* Channel header */}
<div className="px-4 py-3 border-b border-border flex items-center gap-2">
<Hash className="w-5 h-5 text-primary" />
<div className="bg-card border-b border-border px-4 py-3 flex items-center gap-2">
<Hash className="w-4 h-4 text-muted-foreground" />
<div>
<p className="font-sans text-sm font-medium text-foreground">{selectedChannel.name}</p>
{selectedChannel.description && (
@@ -181,33 +250,41 @@ export default function CommsClient({ member, space, channels }: Props) {
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((msg, i) => {
const showAvatar = i === 0 || messages[i - 1]?.user_id !== msg.user_id
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.length === 0 && (
<div className="flex items-center justify-center h-full">
<p className="font-sans text-sm text-muted-foreground">No messages yet. Say hello!</p>
</div>
)}
{messages.map(msg => {
const isMe = msg.user_id === member.user_id
const isTemp = typeof msg.id === 'string' && msg.id.startsWith('temp-')
return (
<div key={msg.id} className={`flex items-start gap-3 ${showAvatar ? '' : 'pl-[52px]'}`}>
{showAvatar && (
<div className="w-8 h-8 rounded bg-primary/10 flex items-center justify-center text-[10px] font-mono font-bold text-primary flex-shrink-0">
{(msg.display_name || 'U').split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2)}
</div>
)}
<div className="flex-1 min-w-0">
{showAvatar && (
<div className="flex items-baseline gap-2 mb-0.5">
<span className="font-sans text-sm font-medium text-foreground">{msg.display_name || 'Unknown'}</span>
{msg.handle && <span className="font-mono text-[10px] text-muted-foreground">@{msg.handle}</span>}
<span className="font-mono text-[10px] text-muted-foreground">
{new Date(msg.created_at).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
<div key={msg.id} className={`flex gap-3 ${isMe ? 'flex-row-reverse' : ''}`}>
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-[10px] font-mono font-bold flex-shrink-0 ${
isMe ? 'bg-primary text-white' : 'bg-muted text-muted-foreground'
}`}>
{(msg.display_name || msg.handle || '?').slice(0, 2).toUpperCase()}
</div>
<div className={`max-w-[65%] ${isMe ? 'items-end' : 'items-start'} flex flex-col gap-0.5`}>
<div className="flex items-center gap-2">
{!isMe && (
<span className="font-sans text-xs font-medium text-foreground">
{msg.display_name || msg.handle}
</span>
</div>
)}
<p className="font-sans text-sm text-foreground leading-relaxed break-words">
{(msg.content || '').split(/(@\w+|#\w+)/g).map((part: string, j: number) => {
if (part.startsWith('@')) return <span key={j} className="text-primary font-medium">{part}</span>
if (part.startsWith('#')) return <span key={j} className="text-blue-600 font-medium">{part}</span>
return part
})}
</p>
)}
<span className="font-mono text-[10px] text-muted-foreground/60">
{new Date(msg.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
{isTemp && ' · sending...'}
</span>
</div>
<div className={`px-3 py-2 rounded-2xl font-sans text-sm leading-relaxed ${
isMe
? `bg-primary text-white rounded-tr-sm ${isTemp ? 'opacity-70' : ''}`
: 'bg-card border border-border text-foreground rounded-tl-sm'
}`}>
{msg.content}
</div>
</div>
</div>
)
@@ -215,29 +292,35 @@ export default function CommsClient({ member, space, channels }: Props) {
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-4 border-t border-border">
{/* Message input */}
<div className="p-4 border-t border-border bg-card">
<form onSubmit={sendMessage} className="flex gap-2">
<input
type="text"
value={newMessage}
onChange={e => setNewMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={`Message #${selectedChannel.name}`}
className="flex-1 bg-muted border border-border rounded px-3 py-2 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition"
className="flex-1 bg-background border border-border rounded-full px-4 py-2 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition"
disabled={sending}
/>
<button
type="submit"
disabled={!newMessage.trim() || sending}
className="bg-primary text-white px-4 py-2 rounded font-sans text-sm hover:bg-primary/90 transition disabled:opacity-40"
className="w-9 h-9 bg-primary text-white rounded-full flex items-center justify-center hover:bg-primary/90 transition disabled:opacity-40 disabled:cursor-not-allowed flex-shrink-0"
aria-label="Send message"
>
Send
<Send className="w-4 h-4" />
</button>
</form>
<p className="font-mono text-[10px] text-muted-foreground/50 mt-1.5 px-2">
Press Enter to send
</p>
</div>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<p className="font-sans text-sm">Select a channel to view messages</p>
<div className="flex-1 flex items-center justify-center">
<p className="font-sans text-sm text-muted-foreground">Select a channel to start messaging</p>
</div>
)}
</div>
+474
View File
@@ -0,0 +1,474 @@
'use client'
import { useState, useRef, useCallback, useMemo } from 'react'
import { Upload, CheckCircle, AlertCircle, X, ChevronDown, ArrowRight, FileText, RefreshCcw } from 'lucide-react'
import { toast } from 'sonner'
import { importPaymentsCsv } from '@/lib/actions'
// ─── Lightweight CSV parser (handles quoted fields, no external deps) ──────────
function parseCSV(text: string): { headers: string[]; rows: string[][] } {
const lines: string[] = []
let current = ''
let inQuotes = false
for (let i = 0; i < text.length; i++) {
const ch = text[i]
if (ch === '"') {
if (inQuotes && text[i + 1] === '"') { current += '"'; i++ }
else inQuotes = !inQuotes
} else if (ch === '\n' && !inQuotes) {
lines.push(current.trimEnd())
current = ''
} else if (ch === '\r' && !inQuotes) {
// skip
} else {
current += ch
}
}
if (current.trim()) lines.push(current.trimEnd())
function splitRow(row: string): string[] {
const fields: string[] = []
let field = ''
let inQ = false
for (let i = 0; i < row.length; i++) {
const c = row[i]
if (c === '"') {
if (inQ && row[i + 1] === '"') { field += '"'; i++ }
else inQ = !inQ
} else if (c === ',' && !inQ) {
fields.push(field.trim())
field = ''
} else {
field += c
}
}
fields.push(field.trim())
return fields
}
const nonEmpty = lines.filter(l => l.trim())
if (nonEmpty.length === 0) return { headers: [], rows: [] }
const headers = splitRow(nonEmpty[0]).map(h => h.replace(/^"|"$/g, ''))
const rows = nonEmpty.slice(1).map(l => splitRow(l).map(v => v.replace(/^"|"$/g, '')))
return { headers, rows }
}
// ─── Import types ─────────────────────────────────────────────────────────────
type ImportMode = 'members' | 'payments'
type MemberFieldKey = 'display_name' | 'email' | 'phone' | 'handle' | 'tier' | 'joined_at' | 'last_paid_at' | 'has_card_access' | 'skip'
type PaymentFieldKey = 'amount' | 'from_identifier' | 'from_note' | 'transaction_date' | 'platform' | 'skip'
const MEMBER_FIELDS: { key: MemberFieldKey; label: string; required?: boolean }[] = [
{ key: 'display_name', label: 'Display Name', required: true },
{ key: 'email', label: 'Email', required: true },
{ key: 'phone', label: 'Phone' },
{ key: 'handle', label: 'Handle / Username' },
{ key: 'tier', label: 'Tier / Membership Type' },
{ key: 'joined_at', label: 'Join Date' },
{ key: 'last_paid_at', label: 'Last Paid Date' },
{ key: 'has_card_access', label: 'Card Access' },
{ key: 'skip', label: 'Skip this column' },
]
const PAYMENT_FIELDS: { key: PaymentFieldKey; label: string; required?: boolean }[] = [
{ key: 'amount', label: 'Amount', required: true },
{ key: 'from_identifier', label: 'Sender / From Name', required: true },
{ key: 'transaction_date', label: 'Date', required: true },
{ key: 'platform', label: 'Platform (venmo/paypal/cash)' },
{ key: 'from_note', label: 'Note / Memo' },
{ key: 'skip', label: 'Skip this column' },
]
// Auto-detect column mapping from header names
function autoMap(headers: string[], mode: ImportMode): Record<string, string> {
const map: Record<string, string> = {}
const memberPatterns: [RegExp, MemberFieldKey][] = [
[/name|full.?name|display/i, 'display_name'],
[/email/i, 'email'],
[/phone|mobile/i, 'phone'],
[/handle|username|slack|discord/i, 'handle'],
[/tier|type|member.?type|plan|level/i, 'tier'],
[/join|joined|start/i, 'joined_at'],
[/paid|dues|last.?paid/i, 'last_paid_at'],
[/card|access|fob|keyfob/i, 'has_card_access'],
]
const paymentPatterns: [RegExp, PaymentFieldKey][] = [
[/amount|sum|total|\$/i, 'amount'],
[/from|sender|name|payer/i, 'from_identifier'],
[/note|memo|desc/i, 'from_note'],
[/date|time|when/i, 'transaction_date'],
[/platform|source|via/i, 'platform'],
]
const patterns = mode === 'members' ? memberPatterns : paymentPatterns
headers.forEach(h => {
const match = patterns.find(([re]) => re.test(h))
map[h] = match ? match[1] : 'skip'
})
return map
}
// ─── Step indicators ──────────────────────────────────────────────────────────
function Steps({ current }: { current: number }) {
const steps = ['Upload File', 'Map Columns', 'Preview', 'Import']
return (
<div className="flex items-center gap-2 mb-8">
{steps.map((label, i) => (
<div key={i} className="flex items-center gap-2">
<div className={`flex items-center gap-1.5 ${i <= current ? 'text-primary' : 'text-muted-foreground'}`}>
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-mono font-bold ${
i < current ? 'bg-primary text-white' : i === current ? 'bg-primary text-white ring-2 ring-primary/30' : 'border-2 border-border'
}`}>
{i < current ? <CheckCircle className="w-3.5 h-3.5" /> : i + 1}
</div>
<span className="font-sans text-sm">{label}</span>
</div>
{i < 3 && <span className="text-border font-mono">—</span>}
</div>
))}
</div>
)
}
// ─── Main component ───────────────────────────────────────────────────────────
interface Props {
spaceId: string
role: string
}
type ImportResult = { success: number; failed: number; errors: string[] }
export default function ImportClient({ spaceId, role }: Props) {
const [mode, setMode] = useState<ImportMode>('members')
const [step, setStep] = useState(0)
const [file, setFile] = useState<File | null>(null)
const [headers, setHeaders] = useState<string[]>([])
const [rows, setRows] = useState<string[][]>([])
const [columnMap, setColumnMap] = useState<Record<string, string>>({})
const [importing, setImporting] = useState(false)
const [result, setResult] = useState<ImportResult | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const platform = mode === 'payments' ? 'csv' : undefined
function handleFile(f: File) {
const reader = new FileReader()
reader.onload = e => {
const text = e.target?.result as string
const { headers: h, rows: r } = parseCSV(text)
if (h.length === 0) { toast.error('Could not parse file — check that it is a valid CSV'); return }
setFile(f)
setHeaders(h)
setRows(r)
setColumnMap(autoMap(h, mode))
setStep(1)
}
reader.readAsText(f)
}
function handleDrop(e: React.DragEvent) {
e.preventDefault()
const f = e.dataTransfer.files[0]
if (f) handleFile(f)
}
const previewRows = useMemo(() => {
return rows.slice(0, 5).map(row => {
const obj: Record<string, string> = {}
headers.forEach((h, i) => {
if (columnMap[h] && columnMap[h] !== 'skip') {
obj[columnMap[h]] = row[i] ?? ''
}
})
return obj
})
}, [rows, headers, columnMap])
const requiredFields = mode === 'members'
? MEMBER_FIELDS.filter(f => f.required).map(f => f.key)
: PAYMENT_FIELDS.filter(f => f.required).map(f => f.key)
const mappedFields = Object.values(columnMap).filter(v => v !== 'skip')
const missingRequired = requiredFields.filter(r => !mappedFields.includes(r))
async function runImport() {
setImporting(true)
const errors: string[] = []
let success = 0
if (mode === 'payments') {
const paymentRows = rows.map((row, i) => {
const obj: Record<string, string> = {}
headers.forEach((h, j) => {
if (columnMap[h] && columnMap[h] !== 'skip') obj[columnMap[h]] = row[j] ?? ''
})
const amount = parseFloat((obj.amount ?? '').replace(/[$,\s]/g, ''))
if (isNaN(amount) || !obj.from_identifier || !obj.transaction_date) {
errors.push(`Row ${i + 2}: missing required fields`)
return null
}
return {
platform: (obj.platform || 'csv') as any,
amount,
from_identifier: obj.from_identifier,
from_note: obj.from_note,
transaction_date: new Date(obj.transaction_date).toISOString(),
}
}).filter(Boolean) as any[]
const result = await importPaymentsCsv(paymentRows)
if ('error' in result && result.error) {
toast.error(result.error)
setImporting(false)
return
}
success = (result as any).count ?? paymentRows.length
} else {
// Members import — call supabase directly since there's no existing action for bulk member import
const { createClient } = await import('@/lib/supabase/client')
const supabase = createClient()
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
const obj: Record<string, string> = {}
headers.forEach((h, j) => {
if (columnMap[h] && columnMap[h] !== 'skip') obj[columnMap[h]] = row[j] ?? ''
})
if (!obj.display_name || !obj.email) {
errors.push(`Row ${i + 2}: missing name or email`)
continue
}
const joined_at = obj.joined_at ? new Date(obj.joined_at).toISOString() : new Date().toISOString()
const last_paid_at = obj.last_paid_at ? new Date(obj.last_paid_at).toISOString() : null
const has_card_access = /yes|true|1|y/i.test(obj.has_card_access ?? '')
const { error } = await supabase.from('space_members').insert({
space_id: spaceId,
display_name: obj.display_name,
email: obj.email,
phone: obj.phone || null,
handle: obj.handle || null,
tier: obj.tier || 'member',
role: 'member',
status: 'current',
approved: true,
joined_at,
last_paid_at,
has_card_access,
})
if (error) errors.push(`Row ${i + 2} (${obj.email}): ${error.message}`)
else success++
}
}
setImporting(false)
setResult({ success, failed: errors.length, errors })
setStep(3)
if (success > 0) toast.success(`Imported ${success} ${mode === 'members' ? 'members' : 'payments'}`)
if (errors.length > 0) toast.error(`${errors.length} rows failed`)
}
function reset() {
setStep(0)
setFile(null)
setHeaders([])
setRows([])
setColumnMap({})
setResult(null)
}
return (
<div className="p-6 max-w-3xl">
{/* Mode selector */}
<div className="flex items-center gap-3 mb-6">
<p className="font-sans text-sm text-muted-foreground">Import type:</p>
{(['members', 'payments'] as ImportMode[]).map(m => (
<button
key={m}
onClick={() => { setMode(m); reset() }}
className={`font-sans text-sm px-3 py-1.5 rounded border transition capitalize ${
mode === m ? 'bg-primary text-white border-primary' : 'border-border text-foreground hover:border-primary/50'
}`}
>
{m}
</button>
))}
</div>
<Steps current={step} />
{/* ─── Step 0: Upload ─── */}
{step === 0 && (
<div className="bg-card rounded border border-border p-6">
<div
onDrop={handleDrop}
onDragOver={e => e.preventDefault()}
onClick={() => inputRef.current?.click()}
className="border-2 border-dashed border-border rounded-lg p-12 text-center hover:border-primary/50 transition cursor-pointer"
>
<Upload className="w-10 h-10 text-muted-foreground mx-auto mb-3" />
<p className="font-sans text-sm text-foreground mb-1">Drop your CSV file here</p>
<p className="font-mono text-xs text-muted-foreground/60">or click to browse · CSV format</p>
</div>
<input ref={inputRef} type="file" accept=".csv,.txt" className="hidden" onChange={e => { const f = e.target.files?.[0]; if (f) handleFile(f) }} />
<div className="mt-4 p-4 bg-muted/30 rounded border border-border">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-2">
Expected columns for {mode} import
</p>
<p className="font-sans text-xs text-muted-foreground leading-relaxed">
{mode === 'members'
? 'Required: display_name/Full Name, email. Optional: phone, handle, tier, joined_at, last_paid_at, has_card_access'
: 'Required: amount, from_identifier/name, transaction_date. Optional: platform, note/memo'}
</p>
</div>
</div>
)}
{/* ─── Step 1: Map Columns ─── */}
{step === 1 && (
<div className="space-y-4">
<div className="bg-card rounded border border-border overflow-hidden">
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
<div>
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Map Columns to App Fields</p>
<p className="font-sans text-xs text-muted-foreground mt-0.5">{file?.name} · {rows.length} rows</p>
</div>
<button onClick={reset} className="text-muted-foreground hover:text-foreground transition"><X className="w-4 h-4" /></button>
</div>
<div className="divide-y divide-border">
{headers.map(header => (
<div key={header} className="grid grid-cols-[1fr_auto_1fr] items-center gap-3 px-4 py-3">
<div className="bg-muted/50 border border-border rounded px-3 py-2 font-mono text-xs text-foreground">
{header}
{rows[0] && (
<span className="text-muted-foreground ml-2">— e.g. {rows[0][headers.indexOf(header)] || '(empty)'}</span>
)}
</div>
<ArrowRight className="w-4 h-4 text-primary flex-shrink-0" />
<select
value={columnMap[header] ?? 'skip'}
onChange={e => setColumnMap(prev => ({ ...prev, [header]: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-mono text-xs text-foreground focus:outline-none focus:border-primary transition"
>
{(mode === 'members' ? MEMBER_FIELDS : PAYMENT_FIELDS).map(f => (
<option key={f.key} value={f.key}>{f.label}{f.required ? ' *' : ''}</option>
))}
</select>
</div>
))}
</div>
</div>
{missingRequired.length > 0 && (
<div className="flex items-start gap-2 bg-amber-50 border border-amber-200 rounded p-3">
<AlertCircle className="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" />
<p className="font-sans text-xs text-amber-800">
Required fields not yet mapped: {missingRequired.join(', ')}
</p>
</div>
)}
<div className="flex justify-between">
<button onClick={reset} className="border border-border bg-card text-foreground font-sans text-sm px-4 py-2 rounded hover:border-primary/50 transition">Back</button>
<button
onClick={() => setStep(2)}
disabled={missingRequired.length > 0}
className="bg-primary text-white font-sans text-sm px-4 py-2 rounded hover:bg-primary/90 transition disabled:opacity-50"
>
Preview Import →
</button>
</div>
</div>
)}
{/* ─── Step 2: Preview ─── */}
{step === 2 && (
<div className="space-y-4">
<div className="bg-card rounded border border-border overflow-hidden">
<div className="px-4 py-3 border-b border-border">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">
Preview (first 5 of {rows.length} rows)
</p>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border bg-muted/30">
{Object.keys(previewRows[0] ?? {}).map(k => (
<th key={k} className="px-3 py-2 font-mono text-[10px] text-muted-foreground text-left">{k}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{previewRows.map((row, i) => (
<tr key={i} className="hover:bg-muted/10">
{Object.values(row).map((v, j) => (
<td key={j} className="px-3 py-2 font-sans text-foreground truncate max-w-[160px]">{v || <span className="text-muted-foreground/40">—</span>}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="bg-card border border-border rounded p-4 flex items-center gap-3">
<FileText className="w-5 h-5 text-primary flex-shrink-0" />
<div>
<p className="font-sans text-sm font-medium text-foreground">{rows.length} rows ready to import</p>
<p className="font-sans text-xs text-muted-foreground">into {mode === 'members' ? 'Space Members' : 'Payments'} table</p>
</div>
</div>
<div className="flex justify-between">
<button onClick={() => setStep(1)} className="border border-border bg-card text-foreground font-sans text-sm px-4 py-2 rounded hover:border-primary/50 transition">Back</button>
<button
onClick={runImport}
disabled={importing}
className="bg-primary text-white font-sans text-sm px-6 py-2 rounded hover:bg-primary/90 transition disabled:opacity-50 flex items-center gap-2"
>
{importing && <RefreshCcw className="w-4 h-4 animate-spin" />}
{importing ? 'Importing...' : `Import ${rows.length} rows →`}
</button>
</div>
</div>
)}
{/* ─── Step 3: Results ─── */}
{step === 3 && result && (
<div className="space-y-4">
<div className={`rounded border p-5 flex items-start gap-3 ${result.failed === 0 ? 'bg-green-50 border-green-200' : 'bg-amber-50 border-amber-200'}`}>
{result.failed === 0
? <CheckCircle className="w-5 h-5 text-green-500 flex-shrink-0 mt-0.5" />
: <AlertCircle className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" />
}
<div>
<p className="font-sans text-sm font-medium text-foreground">
{result.success} imported successfully{result.failed > 0 ? `, ${result.failed} failed` : ''}
</p>
{result.errors.length > 0 && (
<ul className="mt-2 space-y-1">
{result.errors.slice(0, 10).map((e, i) => (
<li key={i} className="font-mono text-[11px] text-amber-800">{e}</li>
))}
{result.errors.length > 10 && (
<li className="font-mono text-[11px] text-amber-800">...and {result.errors.length - 10} more</li>
)}
</ul>
)}
</div>
</div>
<button onClick={reset} className="bg-primary text-white font-sans text-sm px-4 py-2 rounded hover:bg-primary/90 transition">
Import Another File
</button>
</div>
)}
</div>
)
}
+10 -125
View File
@@ -1,146 +1,31 @@
import { createClient } from '@/lib/supabase/server'
import ImportClient from './import-client'
export default async function ImportPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
const { data: member } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user!.id).eq('status', 'current').single()
if (!user) return null
const isAdmin = member?.role === 'admin' || member?.role === 'board'
const { data: member } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
const isAdmin = member?.role === 'admin' || member?.role === 'board' || member?.role === 'treasurer'
if (!isAdmin) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<p className="font-sans text-sm text-muted-foreground">Admin access required</p>
<p className="font-sans text-sm text-muted-foreground">Admin or Treasurer access required</p>
</div>
)
}
return (
<div className="min-h-screen bg-background">
<div className="bg-sidebar px-6 py-3">
<div className="bg-sidebar px-6 py-3 flex items-center justify-between">
<h1 className="text-white font-sans text-lg font-semibold">Import & Sync</h1>
<p className="font-mono text-xs text-sidebar-foreground/50">CSV import · member & payment data</p>
</div>
{/* Tabs */}
<div className="bg-card border-b border-border px-6 flex gap-6">
{['CSV / Excel', 'Database Connect', 'Webhooks & API', 'Integrations'].map((tab, i) => (
<button key={tab} className={`font-sans text-sm py-3 border-b-2 transition ${
i === 0 ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'
}`}>
{tab}
</button>
))}
</div>
<div className="p-6 max-w-3xl">
{/* Steps */}
<div className="flex items-center gap-2 mb-8">
{[{ n: 1, label: 'Upload File', done: false }, { n: 2, label: 'Map Columns', active: false }, { n: 3, label: 'Preview' }, { n: 4, label: 'Import' }].map((step, i) => (
<div key={i} className="flex items-center gap-2">
<div className={`flex items-center gap-1.5 ${i === 0 ? 'text-primary' : 'text-muted-foreground'}`}>
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-mono font-bold ${
i === 0 ? 'bg-primary text-white' : 'border-2 border-border'
}`}>
{i + 1}
</div>
<span className="font-sans text-sm">{step.label}</span>
</div>
{i < 3 && <span className="text-border">—</span>}
</div>
))}
</div>
{/* Upload area */}
<div className="bg-card rounded border border-border p-8 mb-6">
<div className="border-2 border-dashed border-border rounded-lg p-12 text-center hover:border-primary/50 transition cursor-pointer">
<svg className="w-10 h-10 text-muted-foreground mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="font-sans text-sm text-muted-foreground mb-1">Drop your CSV or Excel file here</p>
<p className="font-mono text-xs text-muted-foreground/60">or click to browse</p>
</div>
</div>
{/* Column mapping preview */}
<div className="bg-card rounded border border-border overflow-hidden mb-6">
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">
Map Columns to App Fields
</p>
<p className="font-mono text-[10px] text-muted-foreground">Upload a file to begin mapping</p>
</div>
<div className="divide-y divide-border">
{[
['Full Name', 'display_name'],
['Email Address', 'email'],
['MemberType', 'tier'],
['JoinDate', 'joined_at'],
['PhoneNumber', 'phone'],
['LastDuesDate', 'last_paid_at'],
['CardAccess', 'has_card_access'],
].map(([col, field]) => (
<div key={col} className="grid grid-cols-[1fr_auto_1fr] items-center gap-3 px-4 py-3">
<div className="bg-muted/50 border border-border rounded px-3 py-2 font-mono text-xs text-muted-foreground">
{col}
</div>
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
<div className="bg-background border border-border rounded px-3 py-2 font-mono text-xs text-foreground">
{field}
</div>
</div>
))}
</div>
<div className="px-4 py-4 flex justify-end gap-3 border-t border-border">
<button className="bg-card border border-border text-foreground font-sans text-sm px-4 py-2 rounded hover:border-primary/50 transition">
Back
</button>
<button className="bg-primary text-white font-sans text-sm px-4 py-2 rounded hover:bg-primary/90 transition">
Preview Import →
</button>
</div>
</div>
{/* Database Connector */}
<div className="bg-card rounded border border-border p-6">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-4">
Database Connector (Advanced)
</p>
<p className="font-sans text-sm text-muted-foreground mb-4 leading-relaxed">
Connect directly to your existing member database. Supports PostgreSQL, MySQL, SQLite, and MongoDB.
Credentials are stored encrypted and never shared.
</p>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className="font-sans text-sm text-foreground block mb-2">Database Type</label>
<select 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">
<option>PostgreSQL (Ruby on Rails)</option>
<option>MySQL</option>
<option>SQLite</option>
<option>MongoDB</option>
</select>
</div>
<div>
<label className="font-sans text-sm text-foreground block mb-2">Connection String</label>
<input
type="text"
placeholder="postgres://user:pass@host:5432/db"
className="w-full bg-background border border-border rounded px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition"
/>
</div>
</div>
<div className="flex gap-3">
<button className="border border-border bg-card text-foreground font-sans text-sm px-4 py-2 rounded hover:border-primary/50 transition">
Test Connection
</button>
<button className="bg-primary text-white font-sans text-sm px-4 py-2 rounded hover:bg-primary/90 transition">
Connect & Map Schema →
</button>
</div>
</div>
</div>
<ImportClient spaceId={member!.space_id} role={member!.role} />
</div>
)
}
+145
View File
@@ -0,0 +1,145 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft, Pin, Lock } from 'lucide-react'
import { createKbEntry, updateKbEntry } from '@/lib/actions'
import { toast } from 'sonner'
interface Props {
entry?: any
member: any
}
export default function KbEntryEditor({ entry, member }: Props) {
const router = useRouter()
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')
router.push('/ops')
}
return (
<div className="min-h-screen bg-background">
<div className="bg-sidebar px-6 py-3 flex items-center gap-4">
<Link href="/ops" className="text-sidebar-foreground/70 hover:text-sidebar-foreground transition">
<ArrowLeft className="w-4 h-4" />
</Link>
<h1 className="text-white font-sans text-lg font-semibold">{isEdit ? 'Edit Entry' : 'New KB Entry'}</h1>
</div>
<div className="p-6 max-w-3xl">
<form onSubmit={handleSave} className="space-y-5">
<div className="bg-card border border-border rounded-lg p-5 space-y-4">
<div>
<label className="font-sans text-xs font-medium text-muted-foreground block mb-1.5">Title *</label>
<input
value={title}
onChange={e => setTitle(e.target.value)}
className="w-full bg-background border border-border rounded px-3 py-2.5 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition"
placeholder="e.g. How to open the space, Emergency contacts..."
required
/>
</div>
<div>
<label className="font-sans text-xs font-medium text-muted-foreground block mb-1.5">Content *</label>
<textarea
value={content}
onChange={e => setContent(e.target.value)}
rows={16}
className="w-full bg-background border border-border rounded px-3 py-2.5 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition resize-y leading-relaxed"
placeholder="Write the full content here. Markdown formatting is supported."
required
/>
</div>
</div>
<div className="bg-card border border-border rounded-lg p-5 space-y-4">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Settings</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="font-sans text-xs font-medium text-muted-foreground block mb-1.5">Area</label>
<input
value={area}
onChange={e => setArea(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, Kitchen, Network"
/>
</div>
<div>
<label className="font-sans text-xs font-medium text-muted-foreground block mb-1.5">Visibility</label>
<select
value={visibility}
onChange={e => setVisibility(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"
>
<option value="all_members">All Members</option>
<option value="board">Board Only</option>
<option value="admin_only">Admin Only</option>
</select>
</div>
</div>
<div className="flex items-center gap-6 pt-1">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={isPinned}
onChange={e => setIsPinned(e.target.checked)}
className="w-4 h-4 rounded accent-primary"
/>
<Pin className="w-3.5 h-3.5 text-muted-foreground" />
<span className="font-sans text-sm text-foreground">Pin to top (critical info)</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={isProcess}
onChange={e => setIsProcess(e.target.checked)}
className="w-4 h-4 rounded accent-primary"
/>
<span className="font-sans text-sm text-foreground">Tag as Process</span>
</label>
</div>
</div>
<div className="flex items-center justify-between">
<Link href="/ops" className="border border-border bg-card text-foreground font-sans text-sm px-4 py-2 rounded hover:border-primary/50 transition">
Cancel
</Link>
<button
type="submit"
disabled={saving || !title.trim() || !content.trim()}
className="bg-primary text-white font-sans text-sm px-6 py-2 rounded hover:bg-primary/90 transition disabled:opacity-50"
>
{saving ? 'Saving...' : isEdit ? 'Save Changes' : 'Create Entry'}
</button>
</div>
</form>
</div>
</div>
)
}
+23
View File
@@ -0,0 +1,23 @@
import { createClient } from '@/lib/supabase/server'
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
import KbEntryEditor from './kb-entry-editor'
export default async function OpsEntryPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return null
const { data: member } = await supabase
.from('space_members').select('space_id, role, display_name').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return null
const { data: entry } = await supabase
.from('knowledge_base').select('*').eq('id', id).eq('space_id', member.space_id).single()
if (!entry) notFound()
return <KbEntryEditor entry={entry} member={member} />
}
+14
View File
@@ -0,0 +1,14 @@
import { createClient } from '@/lib/supabase/server'
import KbEntryEditor from '../[id]/kb-entry-editor'
export default async function NewOpsEntryPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return null
const { data: member } = await supabase
.from('space_members').select('space_id, role, display_name').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return null
return <KbEntryEditor member={member} />
}
+796
View File
@@ -0,0 +1,796 @@
'use client'
import { useState, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { Plus, Search, Lock, Pin, Eye, EyeOff, Pencil, Trash2, X, ChevronDown, Users2 } from 'lucide-react'
import { createKbEntry, updateKbEntry, deleteKbEntry } from '@/lib/actions'
import { createClient } from '@/lib/supabase/client'
import { toast } from 'sonner'
interface KbEntry {
id: string
title: string
content: string
description?: string
area?: string
visibility: string
is_pinned: boolean
tags?: string[]
icon?: string
updated_by_name?: string
updated_at: string
created_at: string
}
interface AreaLead {
id: string
area_name: string
member_name: string
member_id?: string
contact_info?: string
}
interface Secret {
id: string
title: string
area?: string
created_at: string
}
interface Props {
member: any
spaceId: string
kbEntries: KbEntry[]
areaLeads: AreaLead[]
secrets: Secret[]
canSeeSecrets: boolean
}
type Tab = 'kb' | 'processes' | 'secrets' | 'area-leads'
const TABS: { id: Tab; label: string }[] = [
{ id: 'kb', label: 'Knowledge Base' },
{ id: 'processes', label: 'Processes' },
{ id: 'secrets', label: 'Secrets & Credentials' },
{ id: 'area-leads', label: 'Area Leads' },
]
const VISIBILITY_LABELS: Record<string, string> = {
all_members: 'All Members',
board: 'Board',
admin_only: 'Admin Only',
}
const VISIBILITY_COLORS: Record<string, string> = {
admin_only: 'text-red-600 bg-red-50 border-red-200',
board: 'text-amber-600 bg-amber-50 border-amber-200',
all_members: 'text-primary bg-primary/5 border-primary/20',
}
// ─── KB Entry Modal ────────────────────────────────────────────────────────────
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 (
<div className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4" onClick={onClose}>
<div className="bg-card border border-border rounded-lg w-full max-w-2xl max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<p className="font-sans text-sm font-semibold text-foreground">{isEdit ? 'Edit Entry' : 'New KB Entry'}</p>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground"><X className="w-4 h-4" /></button>
</div>
<form onSubmit={handleSave} className="p-5 space-y-4">
<div>
<label className="font-sans text-xs font-medium text-muted-foreground block mb-1.5">Title *</label>
<input
value={title}
onChange={e => 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
/>
</div>
<div>
<label className="font-sans text-xs font-medium text-muted-foreground block mb-1.5">Content *</label>
<textarea
value={content}
onChange={e => setContent(e.target.value)}
rows={8}
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 resize-y"
placeholder="Write the full content here..."
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="font-sans text-xs font-medium text-muted-foreground block mb-1.5">Area</label>
<input
value={area}
onChange={e => setArea(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, Kitchen"
/>
</div>
<div>
<label className="font-sans text-xs font-medium text-muted-foreground block mb-1.5">Visibility</label>
<select
value={visibility}
onChange={e => setVisibility(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"
>
<option value="all_members">All Members</option>
<option value="board">Board Only</option>
<option value="admin_only">Admin Only</option>
</select>
</div>
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={isPinned}
onChange={e => setIsPinned(e.target.checked)}
className="w-4 h-4 rounded border-border accent-primary"
/>
<span className="font-sans text-sm text-foreground">Pin (critical / always visible)</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={isProcess}
onChange={e => setIsProcess(e.target.checked)}
className="w-4 h-4 rounded border-border accent-primary"
/>
<span className="font-sans text-sm text-foreground">Tag as Process</span>
</label>
</div>
<div className="flex justify-end gap-3 pt-2 border-t border-border">
<button
type="button"
onClick={onClose}
className="border border-border bg-card text-foreground font-sans text-sm px-4 py-2 rounded hover:border-primary/50 transition"
>
Cancel
</button>
<button
type="submit"
disabled={saving || !title.trim() || !content.trim()}
className="bg-primary text-white font-sans text-sm px-4 py-2 rounded hover:bg-primary/90 transition disabled:opacity-50"
>
{saving ? 'Saving...' : isEdit ? 'Save Changes' : 'Create Entry'}
</button>
</div>
</form>
</div>
</div>
)
}
// ─── Add Secret Modal ──────────────────────────────────────────────────────────
function AddSecretModal({
onClose,
onSaved,
spaceId,
}: {
onClose: () => void
onSaved: (s: Secret) => void
spaceId: string
}) {
const [title, setTitle] = useState('')
const [area, setArea] = useState('')
const [value, setValue] = useState('')
const [saving, setSaving] = useState(false)
async function handleSave(e: React.FormEvent) {
e.preventDefault()
if (!title.trim() || !value.trim()) return
setSaving(true)
const supabase = createClient()
const { data, error } = await supabase.from('secrets').insert({
space_id: spaceId,
title: title.trim(),
area: area.trim() || null,
value: value.trim(),
}).select('id, title, area, created_at').single()
setSaving(false)
if (error) { toast.error(error.message); return }
toast.success('Secret saved')
onSaved(data as Secret)
onClose()
}
return (
<div className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4" onClick={onClose}>
<div className="bg-card border border-border rounded-lg w-full max-w-md" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<p className="font-sans text-sm font-semibold text-foreground">Add Secret / Credential</p>
<button onClick={onClose}><X className="w-4 h-4 text-muted-foreground" /></button>
</div>
<form onSubmit={handleSave} className="p-5 space-y-4">
<div>
<label className="font-sans text-xs font-medium text-muted-foreground block mb-1.5">Label *</label>
<input
value={title}
onChange={e => 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. WiFi Password"
required
/>
</div>
<div>
<label className="font-sans text-xs font-medium text-muted-foreground block mb-1.5">Area</label>
<input
value={area}
onChange={e => setArea(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. Network"
/>
</div>
<div>
<label className="font-sans text-xs font-medium text-muted-foreground block mb-1.5">Secret Value *</label>
<textarea
value={value}
onChange={e => setValue(e.target.value)}
rows={3}
className="w-full bg-background border border-border rounded px-3 py-2 font-mono text-sm text-foreground focus:outline-none focus:border-primary transition"
placeholder="Stored encrypted at rest"
required
/>
</div>
<div className="flex justify-end gap-3 pt-2 border-t border-border">
<button type="button" onClick={onClose} className="border border-border bg-card text-foreground font-sans text-sm px-4 py-2 rounded hover:border-primary/50 transition">Cancel</button>
<button type="submit" disabled={saving || !title.trim() || !value.trim()} className="bg-primary text-white font-sans text-sm px-4 py-2 rounded hover:bg-primary/90 transition disabled:opacity-50">
{saving ? 'Saving...' : 'Save Secret'}
</button>
</div>
</form>
</div>
</div>
)
}
// ─── Area Lead Modal ───────────────────────────────────────────────────────────
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?.member_name ?? '')
const [contactInfo, setContactInfo] = useState(lead?.contact_info ?? '')
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(),
member_name: memberName.trim(),
contact_info: contactInfo.trim() || null,
}).eq('id', lead.id).select('*').single()
} else {
result = await supabase.from('area_leads').insert({
space_id: spaceId,
area_name: areaName.trim(),
member_name: memberName.trim(),
contact_info: contactInfo.trim() || null,
}).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 (
<div className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4" onClick={onClose}>
<div className="bg-card border border-border rounded-lg w-full max-w-md" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<p className="font-sans text-sm font-semibold text-foreground">{isEdit ? 'Edit Area Lead' : 'Add Area Lead'}</p>
<button onClick={onClose}><X className="w-4 h-4 text-muted-foreground" /></button>
</div>
<form onSubmit={handleSave} className="p-5 space-y-4">
<div>
<label className="font-sans text-xs font-medium text-muted-foreground block mb-1.5">Area Name *</label>
<input value={areaName} onChange={e => 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 />
</div>
<div>
<label className="font-sans text-xs font-medium text-muted-foreground block mb-1.5">Member Name *</label>
<input value={memberName} onChange={e => 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 />
</div>
<div>
<label className="font-sans text-xs font-medium text-muted-foreground block mb-1.5">Contact Info</label>
<input value={contactInfo} onChange={e => 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. [email protected] or Slack @alice" />
</div>
<div className="flex justify-end gap-3 pt-2 border-t border-border">
<button type="button" onClick={onClose} className="border border-border bg-card text-foreground font-sans text-sm px-4 py-2 rounded hover:border-primary/50 transition">Cancel</button>
<button type="submit" disabled={saving || !areaName.trim() || !memberName.trim()} className="bg-primary text-white font-sans text-sm px-4 py-2 rounded hover:bg-primary/90 transition disabled:opacity-50">
{saving ? 'Saving...' : isEdit ? 'Save Changes' : 'Add Lead'}
</button>
</div>
</form>
</div>
</div>
)
}
// ─── Secret Row (reveal on click) ─────────────────────────────────────────────
function SecretRow({ secret, onDelete }: { secret: Secret; onDelete: (id: string) => void }) {
const [revealed, setRevealed] = useState(false)
const [value, setValue] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
async function reveal() {
if (revealed) { setRevealed(false); return }
setLoading(true)
const supabase = createClient()
const { data } = await supabase.from('secrets').select('value').eq('id', secret.id).single()
setLoading(false)
if (data) { setValue(data.value); setRevealed(true) }
}
async function handleDelete() {
if (!confirm(`Delete secret "${secret.title}"? This cannot be undone.`)) return
const supabase = createClient()
const { error } = await supabase.from('secrets').delete().eq('id', secret.id)
if (error) { toast.error(error.message); return }
toast.success('Secret deleted')
onDelete(secret.id)
}
return (
<div className="flex items-center gap-3 px-4 py-3">
<div className="w-8 h-8 rounded bg-amber-50 border border-amber-200 flex items-center justify-center flex-shrink-0">
<Lock className="w-4 h-4 text-amber-500" />
</div>
<div className="flex-1 min-w-0">
<p className="font-sans text-sm font-medium text-foreground">{secret.title}</p>
{secret.area && <p className="font-mono text-[10px] text-muted-foreground">{secret.area}</p>}
{revealed && value && (
<p className="font-mono text-xs text-foreground bg-muted px-2 py-1 rounded mt-1 break-all">{value}</p>
)}
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<button
onClick={reveal}
disabled={loading}
className="flex items-center gap-1 font-mono text-[10px] border border-border px-2 py-0.5 rounded hover:border-primary hover:text-primary transition"
title={revealed ? 'Hide' : 'Reveal'}
>
{loading ? '...' : revealed ? <EyeOff className="w-3 h-3" /> : <Eye className="w-3 h-3" />}
{revealed ? 'Hide' : 'Reveal'}
</button>
<button onClick={handleDelete} className="text-muted-foreground hover:text-red-500 transition p-1" title="Delete">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
)
}
// ─── KB Entry Row ──────────────────────────────────────────────────────────────
function KbEntryRow({
entry,
onEdit,
onDelete,
}: {
entry: KbEntry
onEdit: (e: KbEntry) => void
onDelete: (id: string) => void
}) {
async function handleDelete() {
if (!confirm(`Delete "${entry.title}"?`)) 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)
}
return (
<div className={`flex items-start gap-3 px-4 py-4 hover:bg-muted/20 transition group border-l-4 ${
entry.is_pinned
? entry.visibility === 'admin_only' ? 'border-l-red-400' : entry.visibility === 'board' ? 'border-l-amber-400' : 'border-l-primary'
: 'border-l-transparent'
}`}>
<div className="w-8 h-8 rounded bg-muted flex items-center justify-center flex-shrink-0 text-base">
{entry.icon || (
<svg className="w-4 h-4 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<p className="font-sans text-sm font-medium text-foreground">{entry.title}</p>
{entry.is_pinned && <Pin className="w-3 h-3 text-primary flex-shrink-0" />}
</div>
<p className="font-mono text-[10px] text-muted-foreground line-clamp-2">{entry.content?.slice(0, 120)}</p>
<p className="font-mono text-[10px] text-muted-foreground/60 mt-1">
{entry.area && <span>{entry.area} · </span>}
updated {new Date(entry.updated_at).toLocaleDateString()} by {entry.updated_by_name}
</p>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<span className={`font-mono text-[10px] px-2 py-0.5 rounded border whitespace-nowrap ${VISIBILITY_COLORS[entry.visibility] ?? 'text-muted-foreground bg-muted border-border'}`}>
{VISIBILITY_LABELS[entry.visibility] ?? entry.visibility}
</span>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
<button onClick={() => onEdit(entry)} className="text-muted-foreground hover:text-primary transition p-1" title="Edit">
<Pencil className="w-3.5 h-3.5" />
</button>
<button onClick={handleDelete} className="text-muted-foreground hover:text-red-500 transition p-1" title="Delete">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
)
}
// ─── Main Component ────────────────────────────────────────────────────────────
export function OpsClient({ member, spaceId, kbEntries: initial, areaLeads: initialLeads, secrets: initialSecrets, canSeeSecrets }: Props) {
const router = useRouter()
const [activeTab, setActiveTab] = useState<Tab>('kb')
const [search, setSearch] = useState('')
const [kbEntries, setKbEntries] = useState<KbEntry[]>(initial)
const [areaLeads, setAreaLeads] = useState<AreaLead[]>(initialLeads)
const [secrets, setSecrets] = useState<Secret[]>(initialSecrets)
// Modals
const [showKbModal, setShowKbModal] = useState(false)
const [editingKb, setEditingKb] = useState<KbEntry | null>(null)
const [showSecretModal, setShowSecretModal] = useState(false)
const [showLeadModal, setShowLeadModal] = useState(false)
const [editingLead, setEditingLead] = useState<AreaLead | null>(null)
const filteredKb = useMemo(() => {
const q = search.toLowerCase()
return kbEntries.filter(e =>
!e.tags?.includes('process') &&
(!q || e.title.toLowerCase().includes(q) || e.content?.toLowerCase().includes(q) || e.area?.toLowerCase().includes(q)),
)
}, [kbEntries, search])
const filteredProcesses = useMemo(() => {
const q = search.toLowerCase()
return kbEntries.filter(e =>
e.tags?.includes('process') &&
(!q || e.title.toLowerCase().includes(q) || e.content?.toLowerCase().includes(q) || e.area?.toLowerCase().includes(q)),
)
}, [kbEntries, search])
const filteredSecrets = useMemo(() => {
const q = search.toLowerCase()
return secrets.filter(s => !q || s.title.toLowerCase().includes(q) || s.area?.toLowerCase().includes(q))
}, [secrets, search])
const filteredLeads = useMemo(() => {
const q = search.toLowerCase()
return areaLeads.filter(l => !q || l.area_name.toLowerCase().includes(q) || l.member_name.toLowerCase().includes(q))
}, [areaLeads, search])
async function deleteLeadFn(id: string) {
const supabase = createClient()
const { error } = await supabase.from('area_leads').delete().eq('id', id)
if (error) { toast.error(error.message); return }
setAreaLeads(prev => prev.filter(l => l.id !== id))
toast.success('Area lead removed')
}
const pinnedKb = filteredKb.filter(e => e.is_pinned)
const unpinnedKb = filteredKb.filter(e => !e.is_pinned)
return (
<div className="min-h-screen bg-background">
{/* Header */}
<div className="bg-sidebar px-6 py-3 flex items-center justify-between">
<h1 className="text-white font-sans text-lg font-semibold">Ops & Facilities</h1>
<div className="flex items-center gap-2">
{(activeTab === 'kb' || activeTab === 'processes') && (
<button
onClick={() => { setEditingKb(null); setShowKbModal(true) }}
className="flex items-center gap-1.5 bg-primary text-white text-xs font-sans px-3 py-1.5 rounded hover:bg-primary/90 transition"
>
<Plus className="w-3.5 h-3.5" /> Add Entry
</button>
)}
{activeTab === 'secrets' && canSeeSecrets && (
<button
onClick={() => setShowSecretModal(true)}
className="flex items-center gap-1.5 bg-primary text-white text-xs font-sans px-3 py-1.5 rounded hover:bg-primary/90 transition"
>
<Plus className="w-3.5 h-3.5" /> Add Secret
</button>
)}
{activeTab === 'area-leads' && (
<button
onClick={() => { setEditingLead(null); setShowLeadModal(true) }}
className="flex items-center gap-1.5 bg-primary text-white text-xs font-sans px-3 py-1.5 rounded hover:bg-primary/90 transition"
>
<Users2 className="w-3.5 h-3.5" /> Add Area Lead
</button>
)}
</div>
</div>
{/* Tabs */}
<div className="bg-card border-b border-border px-6 flex gap-6">
{TABS.map(tab => (
<button
key={tab.id}
onClick={() => { setActiveTab(tab.id); setSearch('') }}
className={`font-sans text-sm py-3 border-b-2 transition whitespace-nowrap ${
activeTab === tab.id ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
{tab.label}
{tab.id === 'secrets' && !canSeeSecrets && <Lock className="w-3 h-3 inline ml-1" />}
</button>
))}
</div>
<div className="p-6 max-w-5xl">
{/* Search */}
{(activeTab === 'kb' || activeTab === 'processes' || activeTab === 'secrets') && (
<div className="relative mb-6">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={activeTab === 'kb' ? 'Search knowledge base...' : activeTab === 'processes' ? 'Search processes...' : 'Search credentials...'}
className="w-full bg-card border border-border rounded pl-9 pr-4 py-2.5 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition"
/>
</div>
)}
{/* ─── KB Tab ─── */}
{activeTab === 'kb' && (
<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={e => { setEditingKb(e); setShowKbModal(true) }}
onDelete={id => setKbEntries(prev => prev.filter(e => e.id !== id))}
/>
))}
</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={e => { setEditingKb(e); setShowKbModal(true) }}
onDelete={id => setKbEntries(prev => prev.filter(e => e.id !== id))}
/>
))}
</div>
</div>
)}
{filteredKb.length === 0 && (
<div className="bg-card rounded border border-dashed border-border p-12 text-center">
<p className="font-sans text-sm text-muted-foreground">
{search ? `No results for "${search}"` : 'No knowledge base entries yet'}
</p>
{!search && (
<button
onClick={() => { setEditingKb(null); setShowKbModal(true) }}
className="font-mono text-xs text-primary mt-2 block mx-auto hover:underline"
>
+ Add first entry
</button>
)}
</div>
)}
</div>
)}
{/* ─── Processes Tab ─── */}
{activeTab === 'processes' && (
<div className="space-y-4">
{filteredProcesses.length > 0 ? (
<div className="bg-card rounded border border-border divide-y divide-border">
{filteredProcesses.map(entry => (
<KbEntryRow
key={entry.id}
entry={entry}
onEdit={e => { setEditingKb(e); setShowKbModal(true) }}
onDelete={id => setKbEntries(prev => prev.filter(e => e.id !== id))}
/>
))}
</div>
) : (
<div className="bg-card rounded border border-dashed border-border p-12 text-center">
<p className="font-sans text-sm text-muted-foreground">
{search ? `No results for "${search}"` : 'No process entries yet'}
</p>
{!search && (
<button
onClick={() => { setEditingKb(null); setShowKbModal(true) }}
className="font-mono text-xs text-primary mt-2 block mx-auto hover:underline"
>
+ Add first process
</button>
)}
</div>
)}
</div>
)}
{/* ─── Secrets Tab ─── */}
{activeTab === 'secrets' && (
<div>
{!canSeeSecrets ? (
<div className="bg-card rounded border border-dashed border-border p-12 text-center">
<Lock className="w-8 h-8 text-muted-foreground mx-auto mb-3" />
<p className="font-sans text-sm text-muted-foreground">Admin or board access required to view secrets</p>
</div>
) : filteredSecrets.length > 0 ? (
<div className="bg-card rounded border border-border divide-y divide-border">
{filteredSecrets.map(s => (
<SecretRow key={s.id} secret={s} onDelete={id => setSecrets(prev => prev.filter(x => x.id !== id))} />
))}
</div>
) : (
<div className="bg-card rounded border border-dashed border-border p-12 text-center">
<Lock className="w-8 h-8 text-muted-foreground mx-auto mb-3" />
<p className="font-sans text-sm text-muted-foreground">
{search ? `No results for "${search}"` : 'No secrets stored yet'}
</p>
{!search && (
<button onClick={() => setShowSecretModal(true)} className="font-mono text-xs text-primary mt-2 block mx-auto hover:underline">
+ Add first secret
</button>
)}
</div>
)}
</div>
)}
{/* ─── Area Leads Tab ─── */}
{activeTab === 'area-leads' && (
<div>
{filteredLeads.length > 0 ? (
<div className="bg-card rounded border border-border divide-y divide-border">
{filteredLeads.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.member_name}</p>
{lead.contact_info && (
<p className="font-mono text-[10px] text-muted-foreground/60 mt-0.5">{lead.contact_info}</p>
)}
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
<button
onClick={() => { setEditingLead(lead); setShowLeadModal(true) }}
className="text-muted-foreground hover:text-primary transition p-1"
title="Edit"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => deleteLeadFn(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>
) : (
<div className="bg-card rounded border border-dashed border-border p-12 text-center">
<p className="font-sans text-sm text-muted-foreground">No area leads assigned yet</p>
<button
onClick={() => { setEditingLead(null); setShowLeadModal(true) }}
className="font-mono text-xs text-primary mt-2 block mx-auto hover:underline"
>
+ Assign first area lead
</button>
</div>
)}
</div>
)}
</div>
{/* ─── Modals ─── */}
{showKbModal && (
<KbModal
entry={editingKb}
onClose={() => { setShowKbModal(false); setEditingKb(null) }}
onSaved={saved => {
setKbEntries(prev =>
editingKb
? prev.map(e => e.id === saved.id ? saved : e)
: [saved, ...prev],
)
}}
/>
)}
{showSecretModal && (
<AddSecretModal
onClose={() => setShowSecretModal(false)}
onSaved={s => setSecrets(prev => [s, ...prev])}
spaceId={spaceId}
/>
)}
{showLeadModal && (
<AreaLeadModal
lead={editingLead}
onClose={() => { setShowLeadModal(false); setEditingLead(null) }}
onSaved={saved => {
setAreaLeads(prev =>
editingLead
? prev.map(l => l.id === saved.id ? saved : l)
: [...prev, saved].sort((a, b) => a.area_name.localeCompare(b.area_name)),
)
}}
spaceId={spaceId}
/>
)}
</div>
)
}
+17 -179
View File
@@ -1,6 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import Link from 'next/link'
import { Plus, Lock } from 'lucide-react'
import { OpsClient } from './ops-client'
export default async function OpsPage() {
const supabase = await createClient()
@@ -8,188 +7,27 @@ export default async function OpsPage() {
if (!user) return null
const { data: member } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
.from('space_members').select('space_id, role, display_name, user_id').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return null
const [{ data: kbEntries }, { data: areaLeads }, { data: secrets }] = await Promise.all([
supabase.from('knowledge_base').select('*').eq('space_id', member!.space_id).order('is_pinned', { ascending: false }).order('created_at', { ascending: false }).limit(20),
supabase.from('area_leads').select('*').eq('space_id', member!.space_id).order('area_name'),
member?.role === 'admin' || member?.role === 'board'
? supabase.from('secrets').select('id, title, area').eq('space_id', member!.space_id)
const canSeeSecrets = member.role === 'admin' || member.role === 'board'
const [{ data: kbEntries }, { data: areaLeads }, secretsResult] = await Promise.all([
supabase.from('knowledge_base').select('*').eq('space_id', member.space_id).order('is_pinned', { ascending: false }).order('created_at', { ascending: false }),
supabase.from('area_leads').select('*').eq('space_id', member.space_id).order('area_name'),
canSeeSecrets
? supabase.from('secrets').select('id, title, area, created_at').eq('space_id', member.space_id)
: Promise.resolve({ data: [] }),
])
const pinned = kbEntries?.filter(e => e.is_pinned) ?? []
const byArea = kbEntries?.filter(e => !e.is_pinned) ?? []
const accessColors: Record<string, string> = {
admin_only: 'text-red-600 bg-red-50 border-red-200',
board: 'text-amber-600 bg-amber-50 border-amber-200',
all_members: 'text-primary bg-primary/5 border-primary/20',
}
return (
<div className="min-h-screen bg-background">
<div className="bg-sidebar px-6 py-3 flex items-center justify-between">
<h1 className="text-white font-sans text-lg font-semibold">Ops & Facilities</h1>
<Link href="/ops/new" className="flex items-center gap-1.5 bg-primary text-white text-xs font-sans px-3 py-1.5 rounded hover:bg-primary/90 transition">
<Plus className="w-3.5 h-3.5" /> Add Entry
</Link>
</div>
{/* Tabs */}
<div className="bg-card border-b border-border px-6 flex gap-6">
{['Knowledge Base', 'Processes', 'Secrets & Credentials', 'Area Leads'].map((tab, i) => (
<button key={tab} className={`font-sans text-sm py-3 border-b-2 transition ${
i === 0 ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'
}`}>
{tab}
</button>
))}
</div>
<div className="p-6 grid lg:grid-cols-[1fr_280px] gap-6">
<div className="space-y-6">
{/* Search */}
<div className="relative">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
placeholder="Search knowledge base..."
className="w-full bg-card border border-border rounded pl-9 pr-4 py-2.5 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition"
/>
</div>
{/* Pinned / Critical */}
{pinned.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">
{pinned.map(entry => (
<Link key={entry.id} href={`/ops/${entry.id}`}>
<div className={`flex items-start gap-3 px-4 py-4 hover:bg-muted/30 transition border-l-4 ${
entry.visibility === 'admin_only' ? 'border-l-red-400' :
entry.visibility === 'board' ? 'border-l-amber-400' : 'border-l-primary'
}`}>
<div className="w-8 h-8 rounded bg-muted flex items-center justify-center flex-shrink-0">
{entry.icon || <Lock className="w-4 h-4 text-muted-foreground" />}
</div>
<div className="flex-1 min-w-0">
<p className="font-sans text-sm font-medium text-foreground">{entry.title}</p>
<p className="font-mono text-[10px] text-muted-foreground mt-0.5">{entry.content?.slice(0, 100)}</p>
<p className="font-mono text-[10px] text-muted-foreground/60 mt-1">
updated {new Date(entry.updated_at).toLocaleDateString()} by {entry.updated_by_name}
</p>
</div>
<span className={`font-mono text-[10px] px-2 py-0.5 rounded border whitespace-nowrap flex-shrink-0 ${
accessColors[entry.visibility] ?? 'text-muted-foreground bg-muted border-border'
}`}>
{entry.visibility?.toUpperCase().replace('_', ' ') ?? 'ALL MEMBERS'}
</span>
</div>
</Link>
))}
</div>
</div>
)}
{/* By Area */}
{byArea.length > 0 && (
<div>
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">By Area</p>
<div className="bg-card rounded border border-border divide-y divide-border">
{byArea.map(entry => (
<Link key={entry.id} href={`/ops/${entry.id}`}>
<div className="flex items-start gap-3 px-4 py-4 hover:bg-muted/30 transition">
<div className="w-8 h-8 rounded bg-muted flex items-center justify-center flex-shrink-0">
<svg className="w-4 h-4 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div className="flex-1 min-w-0">
<p className="font-sans text-sm font-medium text-foreground">{entry.title}</p>
<p className="font-mono text-[10px] text-muted-foreground mt-0.5">{entry.content?.slice(0, 100)}</p>
<p className="font-mono text-[10px] text-muted-foreground/60 mt-1">
{entry.area} · updated {new Date(entry.updated_at).toLocaleDateString()}
</p>
</div>
</div>
</Link>
))}
</div>
</div>
)}
{kbEntries?.length === 0 && (
<div className="bg-card rounded border border-dashed border-border p-12 text-center">
<p className="font-sans text-sm text-muted-foreground">No knowledge base entries yet</p>
<Link href="/ops/new" className="font-mono text-xs text-primary mt-2 block hover:underline">+ Add first entry</Link>
</div>
)}
</div>
{/* Right panel */}
<div className="space-y-6">
<div>
<div className="flex items-center justify-between mb-3">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Area Leads</p>
<button className="font-mono text-[10px] border border-border px-2 py-0.5 rounded hover:border-primary hover:text-primary transition">
Manage
</button>
</div>
<div className="bg-card rounded border border-border divide-y divide-border">
{areaLeads && areaLeads.length > 0 ? areaLeads.map(lead => (
<div key={lead.id} className="flex items-center gap-3 px-3 py-3">
<div className="w-8 h-8 rounded bg-primary/10 flex items-center justify-center text-[10px] 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-xs font-medium text-foreground truncate">{lead.area_name}</p>
<p className="font-mono text-[10px] text-muted-foreground truncate">
{lead.lead_handle ? `lead: ${lead.lead_handle}` : 'unassigned'}
</p>
</div>
<span className={`font-mono text-[10px] px-1.5 py-0.5 rounded border flex-shrink-0 ${
lead.status === 'active' ? 'text-primary border-primary/30 bg-primary/5' :
lead.status === 'vacant' ? 'text-muted-foreground border-border bg-muted' :
'text-amber-600 border-amber-300 bg-amber-50'
}`}>
{lead.status?.toUpperCase()}
</span>
</div>
)) : (
<div className="px-3 py-6 text-center text-muted-foreground font-sans text-xs">No area leads configured</div>
)}
</div>
</div>
{/* Secrets Vault */}
<div>
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">Secrets Vault</p>
<div className="bg-card rounded border border-border p-4">
<p className="font-sans text-xs text-muted-foreground mb-3 leading-relaxed">
Credentials are encrypted and role-scoped. Only designated leads can access their area.
</p>
{secrets && secrets.length > 0 ? (
<div className="space-y-2">
{secrets.map(s => (
<div key={s.id} className="flex items-center gap-2">
<Lock className="w-3 h-3 text-muted-foreground flex-shrink-0" />
<span className="font-mono text-xs text-foreground truncate">{s.title}</span>
</div>
))}
</div>
) : (
<p className="font-mono text-xs text-muted-foreground">No secrets stored yet</p>
)}
</div>
</div>
</div>
</div>
</div>
<OpsClient
member={member}
spaceId={member.space_id}
kbEntries={kbEntries ?? []}
areaLeads={areaLeads ?? []}
secrets={(secretsResult as any)?.data ?? []}
canSeeSecrets={canSeeSecrets}
/>
)
}
+34 -4
View File
@@ -1,7 +1,8 @@
'use client'
import { useState } from 'react'
import { X } from 'lucide-react'
import { X, RefreshCcw } from 'lucide-react'
import { toast } from 'sonner'
import { logCashPayment, linkPaymentToMember } from '@/lib/actions'
interface Payment {
@@ -59,6 +60,26 @@ export function PaymentsClient({ payments: initialPayments, members, integration
const unlinkedCount = payments.filter(p => p.link_status === 'unlinked').length
const [syncing, setSyncing] = useState(false)
async function handlePayPalSync() {
setSyncing(true)
try {
const res = await fetch('/api/paypal/sync', { method: 'POST' })
const data = await res.json()
if (!res.ok) { toast.error(data.error ?? 'Sync failed'); return }
toast.success(`PayPal sync complete: ${data.imported} transactions imported`)
if (data.imported > 0) {
// Reload page to show new transactions
window.location.reload()
}
} catch {
toast.error('PayPal sync failed')
} finally {
setSyncing(false)
}
}
async function handleLogCash(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
@@ -137,9 +158,18 @@ export function PaymentsClient({ payments: initialPayments, members, integration
<span className={`font-mono text-[10px] px-1.5 py-0.5 rounded border ${
integration?.is_connected ? 'text-primary border-primary/30 bg-primary/5' : 'text-muted-foreground border-border bg-muted'
}`}>
{integration?.is_connected ? 'LIVE' : 'NOT CONNECTED'}
</span>
)}
{integration?.is_connected ? 'LIVE' : 'NOT CONNECTED'}
</span>
)}
{platform === 'paypal' && integration?.is_connected && canEdit(currentRole) && (
<button
onClick={handlePayPalSync}
disabled={syncing}
className="flex items-center gap-1 font-mono text-[10px] border border-blue-300 text-blue-600 bg-blue-50 px-2 py-0.5 rounded hover:bg-blue-100 transition disabled:opacity-50"
>
<RefreshCcw className={`w-2.5 h-2.5 ${syncing ? 'animate-spin' : ''}`} />
{syncing ? 'Syncing...' : 'Sync Now'}
</button>
</div>
<p className="text-2xl font-sans font-bold text-foreground">${total.toFixed(0)}</p>
<p className="font-mono text-[10px] text-muted-foreground mt-1">
+13 -11
View File
@@ -54,10 +54,12 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
const [formLoading, setFormLoading] = useState(false)
// Ongoing = tasks with a recurrence (not 'none')
const chores = tasks.filter(t => (t.type === 'chore' || t.task_type === 'chore') && t.status !== 'done' && (!t.recurrence || t.recurrence === 'none'))
const ongoing = tasks.filter(t => t.recurrence && t.recurrence !== 'none' && t.status !== 'done')
const mine = tasks.filter(t => (t.claimed_by === currentUserId || t.assigned_to === currentUserId) && t.status !== 'done')
const done = tasks.filter(t => t.status === 'done')
const isDone = (t: any) => t.status === 'done' || t.status === 'completed'
const isOpen = (t: any) => !isDone(t)
const chores = tasks.filter(t => (t.type === 'chore' || t.task_type === 'chore') && isOpen(t) && (!t.recurrence || t.recurrence === 'none'))
const ongoing = tasks.filter(t => t.recurrence && t.recurrence !== 'none' && isOpen(t))
const mine = tasks.filter(t => (t.claimed_by === currentUserId || t.assigned_to === currentUserId) && isOpen(t))
const done = tasks.filter(isDone)
const filtered = (list: Task[]) => filterArea ? list.filter(t => t.area === filterArea) : list
@@ -100,7 +102,7 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
async function handleComplete(taskId: string) {
const result = await completeTask(taskId)
if (!result.error) {
setTasks(prev => prev.map(t => t.id === taskId ? { ...t, status: 'done' } : t))
setTasks(prev => prev.map(t => t.id === taskId ? { ...t, status: 'completed' } : t))
}
}
@@ -123,7 +125,7 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
<div className="bg-sidebar px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-white font-sans text-lg font-semibold">Tasks & Chores</h1>
<span className="font-mono text-xs text-white/50">{tasks.filter(t => t.status !== 'done').length} open</span>
<span className="font-mono text-xs text-white/50">{tasks.filter(t => !isDone(t)).length} open</span>
</div>
<div className="flex items-center gap-2">
<select
@@ -168,12 +170,12 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
<div className="bg-card rounded border border-border divide-y divide-border">
{currentList.length > 0 ? currentList.map(task => (
<div key={task.id} className={`flex items-center gap-3 px-4 py-3 ${
task.status === 'done' ? 'opacity-60' : ''
task.status === 'done' || task.status === 'completed' ? 'opacity-60' : ''
}`}>
<div className={`w-4 h-4 rounded flex-shrink-0 flex items-center justify-center ${
task.status === 'done' ? 'bg-primary' : 'border-2 border-border'
isDone(task) ? 'bg-primary' : 'border-2 border-border'
}`}>
{task.status === 'done' && (
{isDone(task) && (
<svg className="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
@@ -181,7 +183,7 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
</div>
<div className="flex-1 min-w-0">
<p className={`font-sans text-sm ${task.status === 'done' ? 'line-through text-muted-foreground' : 'text-foreground'}`}>
<p className={`font-sans text-sm ${isDone(task) ? 'line-through text-muted-foreground' : 'text-foreground'}`}>
{task.title}
</p>
<p className="font-mono text-[10px] text-muted-foreground mt-0.5">
@@ -199,7 +201,7 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
task.status === 'claimed' ? 'text-primary bg-primary/10' :
task.status === 'in_progress' ? 'text-blue-600 bg-blue-50' :
task.status === 'blocked' ? 'text-orange-600 bg-orange-50' :
task.status === 'done' ? 'text-muted-foreground bg-muted' :
isDone(task) ? 'text-muted-foreground bg-muted' :
'text-muted-foreground bg-muted'
}`}>
{task.status.toUpperCase().replace('_', ' ')}
+144
View File
@@ -0,0 +1,144 @@
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
// PayPal Transaction Search API (v1)
// Docs: https://developer.paypal.com/docs/api/transaction-search/v1/
async function getPayPalToken(clientId: string, clientSecret: string, sandbox = false): Promise<string> {
const baseUrl = sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'
const res = await fetch(`${baseUrl}/v1/oauth2/token`, {
method: 'POST',
headers: {
'Authorization': `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'grant_type=client_credentials',
})
if (!res.ok) {
const text = await res.text()
throw new Error(`PayPal auth failed: ${res.status} ${text}`)
}
const data = await res.json()
return data.access_token as string
}
async function fetchPayPalTransactions(
token: string,
startDate: string,
endDate: string,
sandbox = false,
): Promise<any[]> {
const baseUrl = sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'
const params = new URLSearchParams({
start_date: startDate,
end_date: endDate,
transaction_status: 'S', // S = Success
fields: 'transaction_info,payer_info,shipping_info',
page_size: '500',
page: '1',
})
const res = await fetch(`${baseUrl}/v1/reporting/transactions?${params}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) {
const text = await res.text()
throw new Error(`PayPal API error: ${res.status} ${text}`)
}
const data = await res.json()
return (data.transaction_details ?? []) as any[]
}
export async function POST(req: NextRequest) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: member } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
if (!member || !['admin', 'board', 'treasurer'].includes(member.role)) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
// Get PayPal integration config for this space
const { data: integration } = await supabase
.from('integrations')
.select('config, is_connected')
.eq('space_id', member.space_id)
.eq('platform', 'paypal')
.single()
if (!integration?.is_connected || !integration.config) {
return NextResponse.json({ error: 'PayPal not connected' }, { status: 400 })
}
const { client_id, client_secret, sandbox } = integration.config as Record<string, string>
if (!client_id || !client_secret) {
return NextResponse.json({ error: 'PayPal credentials incomplete' }, { status: 400 })
}
try {
const isSandbox = sandbox === 'true'
const token = await getPayPalToken(client_id, client_secret, isSandbox)
// Sync last 30 days
const endDate = new Date().toISOString()
const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
const transactions = await fetchPayPalTransactions(token, startDate, endDate, isSandbox)
if (transactions.length === 0) {
return NextResponse.json({ imported: 0, message: 'No transactions found in the last 30 days' })
}
// Map PayPal transactions to our schema
const rows = transactions
.filter(tx => {
const info = tx.transaction_info
const amount = parseFloat(info?.transaction_amount?.value ?? '0')
return amount > 0 // Only incoming payments
})
.map(tx => {
const info = tx.transaction_info
const payer = tx.payer_info
const amount = parseFloat(info.transaction_amount?.value ?? '0')
const txDate = info.transaction_initiation_date ?? info.transaction_updated_date
return {
space_id: member.space_id,
platform: 'paypal' as const,
amount,
from_identifier: payer?.email_address ?? payer?.payer_name?.alternate_full_name ?? 'PayPal User',
from_note: info.transaction_note ?? info.transaction_subject ?? null,
transaction_date: new Date(txDate).toISOString(),
link_status: 'unlinked' as const,
external_id: info.transaction_id,
}
})
if (rows.length === 0) {
return NextResponse.json({ imported: 0, message: 'No incoming payments found' })
}
// Upsert — skip rows that already exist by external_id
const { data: imported, error: insertError } = await supabase
.from('payments')
.upsert(rows, { onConflict: 'external_id', ignoreDuplicates: true })
.select()
if (insertError) {
// If external_id column doesn't exist yet, fall back to plain insert
const { data: fallback, error: fallbackError } = await supabase
.from('payments')
.insert(rows.map(({ external_id, ...rest }) => rest))
.select()
if (fallbackError) throw new Error(fallbackError.message)
return NextResponse.json({ imported: fallback?.length ?? 0 })
}
return NextResponse.json({ imported: imported?.length ?? rows.length })
} catch (err: any) {
console.error('[PayPal sync error]', err)
return NextResponse.json({ error: err.message ?? 'Sync failed' }, { status: 500 })
}
}