diff --git a/app/(app)/comms/comms-client.tsx b/app/(app)/comms/comms-client.tsx index 360a25a..a090d17 100644 --- a/app/(app)/comms/comms-client.tsx +++ b/app/(app)/comms/comms-client.tsx @@ -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) { + 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) { }`} > - + {channel.name} @@ -161,12 +230,12 @@ export default function CommsClient({ member, space, channels }: Props) { - {/* Messages area */} + {/* Main chat area */} {selectedChannel ? ( -
+
{/* Channel header */} -
- +
+

{selectedChannel.name}

{selectedChannel.description && ( @@ -181,33 +250,41 @@ export default function CommsClient({ member, space, channels }: Props) {
{/* Messages */} -
- {messages.map((msg, i) => { - const showAvatar = i === 0 || messages[i - 1]?.user_id !== msg.user_id +
+ {messages.length === 0 && ( +
+

No messages yet. Say hello!

+
+ )} + {messages.map(msg => { + const isMe = msg.user_id === member.user_id + const isTemp = typeof msg.id === 'string' && msg.id.startsWith('temp-') return ( -
- {showAvatar && ( -
- {(msg.display_name || 'U').split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2)} -
- )} -
- {showAvatar && ( -
- {msg.display_name || 'Unknown'} - {msg.handle && @{msg.handle}} - - {new Date(msg.created_at).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })} +
+
+ {(msg.display_name || msg.handle || '?').slice(0, 2).toUpperCase()} +
+
+
+ {!isMe && ( + + {msg.display_name || msg.handle} -
- )} -

- {(msg.content || '').split(/(@\w+|#\w+)/g).map((part: string, j: number) => { - if (part.startsWith('@')) return {part} - if (part.startsWith('#')) return {part} - return part - })} -

+ )} + + {new Date(msg.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + {isTemp && ' · sending...'} + +
+
+ {msg.content} +
) @@ -215,29 +292,35 @@ export default function CommsClient({ member, space, channels }: Props) {
- {/* Input */} -
+ {/* Message input */} +
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} />
+

+ Press Enter to send +

) : ( -
-

Select a channel to view messages

+
+

Select a channel to start messaging

)}
diff --git a/app/(app)/import/import-client.tsx b/app/(app)/import/import-client.tsx new file mode 100644 index 0000000..311e18f --- /dev/null +++ b/app/(app)/import/import-client.tsx @@ -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 { + const map: Record = {} + 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 ( +
+ {steps.map((label, i) => ( +
+
+
+ {i < current ? : i + 1} +
+ {label} +
+ {i < 3 && —} +
+ ))} +
+ ) +} + +// ─── 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('members') + const [step, setStep] = useState(0) + const [file, setFile] = useState(null) + const [headers, setHeaders] = useState([]) + const [rows, setRows] = useState([]) + const [columnMap, setColumnMap] = useState>({}) + const [importing, setImporting] = useState(false) + const [result, setResult] = useState(null) + const inputRef = useRef(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 = {} + 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 = {} + 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 = {} + 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 ( +
+ {/* Mode selector */} +
+

Import type:

+ {(['members', 'payments'] as ImportMode[]).map(m => ( + + ))} +
+ + + + {/* ─── Step 0: Upload ─── */} + {step === 0 && ( +
+
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" + > + +

Drop your CSV file here

+

or click to browse · CSV format

+
+ { const f = e.target.files?.[0]; if (f) handleFile(f) }} /> + +
+

+ Expected columns for {mode} import +

+

+ {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'} +

+
+
+ )} + + {/* ─── Step 1: Map Columns ─── */} + {step === 1 && ( +
+
+
+
+

Map Columns to App Fields

+

{file?.name} · {rows.length} rows

+
+ +
+
+ {headers.map(header => ( +
+
+ {header} + {rows[0] && ( + — e.g. {rows[0][headers.indexOf(header)] || '(empty)'} + )} +
+ + +
+ ))} +
+
+ + {missingRequired.length > 0 && ( +
+ +

+ Required fields not yet mapped: {missingRequired.join(', ')} +

+
+ )} + +
+ + +
+
+ )} + + {/* ─── Step 2: Preview ─── */} + {step === 2 && ( +
+
+
+

+ Preview (first 5 of {rows.length} rows) +

+
+
+ + + + {Object.keys(previewRows[0] ?? {}).map(k => ( + + ))} + + + + {previewRows.map((row, i) => ( + + {Object.values(row).map((v, j) => ( + + ))} + + ))} + +
{k}
{v || —}
+
+
+ +
+ +
+

{rows.length} rows ready to import

+

into {mode === 'members' ? 'Space Members' : 'Payments'} table

+
+
+ +
+ + +
+
+ )} + + {/* ─── Step 3: Results ─── */} + {step === 3 && result && ( +
+
+ {result.failed === 0 + ? + : + } +
+

+ {result.success} imported successfully{result.failed > 0 ? `, ${result.failed} failed` : ''} +

+ {result.errors.length > 0 && ( +
    + {result.errors.slice(0, 10).map((e, i) => ( +
  • {e}
  • + ))} + {result.errors.length > 10 && ( +
  • ...and {result.errors.length - 10} more
  • + )} +
+ )} +
+
+ +
+ )} +
+ ) +} diff --git a/app/(app)/import/page.tsx b/app/(app)/import/page.tsx index 9ca59f9..29fb7b3 100644 --- a/app/(app)/import/page.tsx +++ b/app/(app)/import/page.tsx @@ -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 (
-

Admin access required

+

Admin or Treasurer access required

) } return (
-
+

Import & Sync

+

CSV import · member & payment data

- - {/* Tabs */} -
- {['CSV / Excel', 'Database Connect', 'Webhooks & API', 'Integrations'].map((tab, i) => ( - - ))} -
- -
- {/* Steps */} -
- {[{ 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) => ( -
-
-
- {i + 1} -
- {step.label} -
- {i < 3 && —} -
- ))} -
- - {/* Upload area */} -
-
- - - -

Drop your CSV or Excel file here

-

or click to browse

-
-
- - {/* Column mapping preview */} -
-
-

- Map Columns to App Fields -

-

Upload a file to begin mapping

-
-
- {[ - ['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]) => ( -
-
- {col} -
- - - -
- {field} -
-
- ))} -
-
- - -
-
- - {/* Database Connector */} -
-

- Database Connector (Advanced) -

-

- Connect directly to your existing member database. Supports PostgreSQL, MySQL, SQLite, and MongoDB. - Credentials are stored encrypted and never shared. -

-
-
- - -
-
- - -
-
-
- - -
-
-
+
) } diff --git a/app/(app)/ops/[id]/kb-entry-editor.tsx b/app/(app)/ops/[id]/kb-entry-editor.tsx new file mode 100644 index 0000000..66713bb --- /dev/null +++ b/app/(app)/ops/[id]/kb-entry-editor.tsx @@ -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 ( +
+
+ + + +

{isEdit ? 'Edit Entry' : 'New KB Entry'}

+
+ +
+
+
+
+ + 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 + /> +
+
+ +