diff --git a/src/app/(dashboard)/[portSlug]/admin/ports/page.tsx b/src/app/(dashboard)/[portSlug]/admin/ports/page.tsx index e827340..e0acb7e 100644 --- a/src/app/(dashboard)/[portSlug]/admin/ports/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/ports/page.tsx @@ -1,16 +1,5 @@ +import { PortList } from '@/components/admin/ports/port-list'; + export default function PortManagementPage() { - return ( -
-
-

Port Management

-

Manage port locations and configurations

-
-
-

Coming in Layer 2

-

- This feature will be implemented in the next phase. -

-
-
- ); + return ; } diff --git a/src/app/(dashboard)/[portSlug]/admin/settings/page.tsx b/src/app/(dashboard)/[portSlug]/admin/settings/page.tsx index 52503c1..10bddcb 100644 --- a/src/app/(dashboard)/[portSlug]/admin/settings/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/settings/page.tsx @@ -1,16 +1,5 @@ +import { SettingsManager } from '@/components/admin/settings/settings-manager'; + export default function SystemSettingsPage() { - return ( -
-
-

System Settings

-

Configure system-wide settings

-
-
-

Coming in Layer 2

-

- This feature will be implemented in the next phase. -

-
-
- ); + return ; } diff --git a/src/app/api/v1/admin/ports/[id]/route.ts b/src/app/api/v1/admin/ports/[id]/route.ts new file mode 100644 index 0000000..db221de --- /dev/null +++ b/src/app/api/v1/admin/ports/[id]/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { getPort, updatePort } from '@/lib/services/ports.service'; +import { updatePortSchema } from '@/lib/validators/ports'; +import { errorResponse } from '@/lib/errors'; + +export const GET = withAuth( + withPermission('admin', 'manage_settings', async (_req, _ctx, params) => { + try { + const data = await getPort(params.id!); + return NextResponse.json({ data }); + } catch (error) { + return errorResponse(error); + } + }), +); + +export const PATCH = withAuth( + withPermission('admin', 'manage_settings', async (req, ctx, params) => { + try { + const body = await parseBody(req, updatePortSchema); + const data = await updatePort(params.id!, body, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/admin/ports/route.ts b/src/app/api/v1/admin/ports/route.ts new file mode 100644 index 0000000..881f5f9 --- /dev/null +++ b/src/app/api/v1/admin/ports/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { listPorts, createPort } from '@/lib/services/ports.service'; +import { createPortSchema } from '@/lib/validators/ports'; +import { errorResponse } from '@/lib/errors'; + +export const GET = withAuth( + withPermission('admin', 'manage_settings', async () => { + try { + const data = await listPorts(); + return NextResponse.json({ data }); + } catch (error) { + return errorResponse(error); + } + }), +); + +export const POST = withAuth( + withPermission('admin', 'manage_settings', async (req, ctx) => { + try { + const body = await parseBody(req, createPortSchema); + const data = await createPort(body, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data }, { status: 201 }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/admin/settings/route.ts b/src/app/api/v1/admin/settings/route.ts new file mode 100644 index 0000000..4e5d9ad --- /dev/null +++ b/src/app/api/v1/admin/settings/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { listSettings, upsertSetting, deleteSetting } from '@/lib/services/settings.service'; +import { upsertSettingSchema, deleteSettingSchema } from '@/lib/validators/settings'; +import { errorResponse } from '@/lib/errors'; + +export const GET = withAuth( + withPermission('admin', 'manage_settings', async (_req, ctx) => { + try { + const data = await listSettings(ctx.portId); + return NextResponse.json({ data }); + } catch (error) { + return errorResponse(error); + } + }), +); + +export const PUT = withAuth( + withPermission('admin', 'manage_settings', async (req, ctx) => { + try { + const { key, value } = await parseBody(req, upsertSettingSchema); + const data = await upsertSetting(key, value, ctx.portId, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data }); + } catch (error) { + return errorResponse(error); + } + }), +); + +export const DELETE = withAuth( + withPermission('admin', 'manage_settings', async (req, ctx) => { + try { + const { key } = await parseBody(req, deleteSettingSchema); + await deleteSetting(key, ctx.portId, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ success: true }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/components/admin/ports/port-form.tsx b/src/components/admin/ports/port-form.tsx new file mode 100644 index 0000000..31c8192 --- /dev/null +++ b/src/components/admin/ports/port-form.tsx @@ -0,0 +1,209 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet'; +import { apiFetch } from '@/lib/api/client'; + +interface PortFormProps { + open: boolean; + onOpenChange: (open: boolean) => void; + port?: { + id: string; + name: string; + slug: string; + logoUrl: string | null; + primaryColor: string | null; + defaultCurrency: string; + timezone: string; + isActive: boolean; + } | null; + onSuccess: () => void; +} + +export function PortForm({ open, onOpenChange, port, onSuccess }: PortFormProps) { + const [name, setName] = useState(''); + const [slug, setSlug] = useState(''); + const [primaryColor, setPrimaryColor] = useState('#0F4C81'); + const [defaultCurrency, setDefaultCurrency] = useState('USD'); + const [timezone, setTimezone] = useState('America/Anguilla'); + const [isActive, setIsActive] = useState(true); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const isEdit = !!port; + + useEffect(() => { + if (open) { + if (port) { + setName(port.name); + setSlug(port.slug); + setPrimaryColor(port.primaryColor ?? '#0F4C81'); + setDefaultCurrency(port.defaultCurrency); + setTimezone(port.timezone); + setIsActive(port.isActive); + } else { + setName(''); + setSlug(''); + setPrimaryColor('#0F4C81'); + setDefaultCurrency('USD'); + setTimezone('America/Anguilla'); + setIsActive(true); + } + setError(null); + } + }, [open, port]); + + function handleNameChange(value: string) { + setName(value); + if (!isEdit) { + setSlug( + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''), + ); + } + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setLoading(true); + + try { + if (isEdit) { + await apiFetch(`/api/v1/admin/ports/${port.id}`, { + method: 'PATCH', + body: { name, slug, primaryColor, defaultCurrency, timezone, isActive }, + }); + } else { + await apiFetch('/api/v1/admin/ports', { + method: 'POST', + body: { name, slug, primaryColor, defaultCurrency, timezone }, + }); + } + onSuccess(); + onOpenChange(false); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Something went wrong'; + setError(message); + } finally { + setLoading(false); + } + } + + return ( + + + + {isEdit ? 'Edit Port' : 'New Port'} + + +
+
+ + handleNameChange(e.target.value)} + placeholder="Port Nimara" + required + /> +
+ +
+ + setSlug(e.target.value)} + placeholder="port-nimara" + pattern="^[a-z0-9-]+$" + required + /> +

+ Used in URLs. Lowercase letters, numbers, and hyphens only. +

+
+ +
+ +
+ setPrimaryColor(e.target.value)} + className="h-9 w-9 rounded border cursor-pointer" + /> + setPrimaryColor(e.target.value)} + placeholder="#0F4C81" + className="w-28 font-mono text-sm" + maxLength={7} + /> +
+
+ +
+
+ + setDefaultCurrency(e.target.value.toUpperCase())} + placeholder="USD" + maxLength={3} + required + /> +
+ +
+ + setTimezone(e.target.value)} + placeholder="America/Anguilla" + required + /> +
+
+ + {isEdit && ( +
+
+ +

+ Inactive ports are hidden from users +

+
+ +
+ )} + + {error &&

{error}

} + + + + + +
+
+
+ ); +} diff --git a/src/components/admin/ports/port-list.tsx b/src/components/admin/ports/port-list.tsx new file mode 100644 index 0000000..d48aea3 --- /dev/null +++ b/src/components/admin/ports/port-list.tsx @@ -0,0 +1,147 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { type ColumnDef } from '@tanstack/react-table'; +import { Pencil, Plus } from 'lucide-react'; + +import { DataTable } from '@/components/shared/data-table'; +import { PageHeader } from '@/components/shared/page-header'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { apiFetch } from '@/lib/api/client'; +import { PortForm } from './port-form'; + +interface PortRow { + id: string; + name: string; + slug: string; + logoUrl: string | null; + primaryColor: string | null; + defaultCurrency: string; + timezone: string; + isActive: boolean; + settings: Record; + createdAt: string; +} + +export function PortList() { + const [ports, setPorts] = useState([]); + const [loading, setLoading] = useState(true); + const [formOpen, setFormOpen] = useState(false); + const [editingPort, setEditingPort] = useState(null); + + const fetchPorts = useCallback(async () => { + setLoading(true); + try { + const res = await apiFetch<{ data: PortRow[] }>('/api/v1/admin/ports'); + setPorts(res.data); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void fetchPorts(); + }, [fetchPorts]); + + function handleNewPort() { + setEditingPort(null); + setFormOpen(true); + } + + function handleEditPort(port: PortRow) { + setEditingPort(port); + setFormOpen(true); + } + + const columns: ColumnDef[] = [ + { + accessorKey: 'name', + header: 'Name', + cell: ({ row }) => ( +
+ {row.original.primaryColor && ( + + )} + {row.original.name} +
+ ), + }, + { + accessorKey: 'slug', + header: 'Slug', + cell: ({ row }) => ( + {row.original.slug} + ), + }, + { + accessorKey: 'defaultCurrency', + header: 'Currency', + }, + { + accessorKey: 'timezone', + header: 'Timezone', + }, + { + accessorKey: 'isActive', + header: 'Status', + cell: ({ row }) => + row.original.isActive ? ( + + Active + + ) : ( + Inactive + ), + }, + { + id: 'actions', + header: '', + cell: ({ row }) => ( + + ), + enableSorting: false, + size: 60, + }, + ]; + + return ( +
+ + + New Port + + } + /> + + row.id} + emptyState={ +
+

No ports configured.

+
+ } + /> + + +
+ ); +} diff --git a/src/components/admin/settings/settings-manager.tsx b/src/components/admin/settings/settings-manager.tsx new file mode 100644 index 0000000..3f630d7 --- /dev/null +++ b/src/components/admin/settings/settings-manager.tsx @@ -0,0 +1,340 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { Trash2, Plus, Save } from 'lucide-react'; + +import { PageHeader } from '@/components/shared/page-header'; +import { ConfirmationDialog } from '@/components/shared/confirmation-dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { apiFetch } from '@/lib/api/client'; + +interface Setting { + key: string; + value: unknown; + portId: string | null; + updatedBy: string | null; + updatedAt: string; +} + +/** Well-known settings with their display metadata */ +const KNOWN_SETTINGS: Array<{ + key: string; + label: string; + description: string; + type: 'boolean' | 'number' | 'json'; + defaultValue: unknown; +}> = [ + { + key: 'ai_interest_scoring', + label: 'AI Interest Scoring', + description: 'Enable AI-powered interest scoring based on engagement signals', + type: 'boolean', + defaultValue: false, + }, + { + key: 'ai_email_drafts', + label: 'AI Email Drafts', + description: 'Enable AI-assisted email draft generation', + type: 'boolean', + defaultValue: false, + }, + { + key: 'invoice_net10_discount', + label: 'Net-10 Invoice Discount (%)', + description: 'Discount percentage applied when payment terms are net-10', + type: 'number', + defaultValue: 2, + }, + { + key: 'pipeline_weights', + label: 'Pipeline Stage Weights', + description: 'Probability weights for revenue forecast by pipeline stage (JSON)', + type: 'json', + defaultValue: { + open: 0.05, + details_sent: 0.1, + in_communication: 0.2, + signed_eoi_nda: 0.4, + deposit_10pct: 0.6, + contract: 0.8, + completed: 1.0, + }, + }, + { + key: 'berth_rules', + label: 'Berth Status Rules', + description: 'Auto/suggest/off rules for berth status transitions (JSON)', + type: 'json', + defaultValue: [], + }, +]; + +export function SettingsManager() { + const [portSettings, setPortSettings] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(null); + const [values, setValues] = useState>({}); + const [customKey, setCustomKey] = useState(''); + const [customValue, setCustomValue] = useState(''); + + const fetchSettings = useCallback(async () => { + setLoading(true); + try { + const res = await apiFetch<{ data: { portSettings: Setting[]; globalSettings: Setting[] } }>( + '/api/v1/admin/settings', + ); + setPortSettings(res.data.portSettings); + + // Build values map from existing settings + const vals: Record = {}; + for (const s of res.data.portSettings) { + vals[s.key] = s.value; + } + setValues(vals); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void fetchSettings(); + }, [fetchSettings]); + + async function saveSetting(key: string, value: unknown) { + setSaving(key); + try { + await apiFetch('/api/v1/admin/settings', { + method: 'PUT', + body: { key, value }, + }); + await fetchSettings(); + } finally { + setSaving(null); + } + } + + async function handleDeleteSetting(key: string) { + await apiFetch('/api/v1/admin/settings', { + method: 'DELETE', + body: { key }, + }); + await fetchSettings(); + } + + async function handleAddCustom() { + if (!customKey.trim()) return; + let parsed: unknown; + try { + parsed = JSON.parse(customValue); + } catch { + parsed = customValue; + } + await saveSetting(customKey, parsed); + setCustomKey(''); + setCustomValue(''); + } + + function getEffectiveValue(key: string, defaultValue: unknown): unknown { + return values[key] ?? defaultValue; + } + + if (loading) { + return ( +
+ +
+ Loading... +
+
+ ); + } + + // Custom settings = port settings that aren't in KNOWN_SETTINGS + const knownKeys = new Set(KNOWN_SETTINGS.map((s) => s.key)); + const customSettings = portSettings.filter((s) => !knownKeys.has(s.key)); + + return ( +
+ + +
+ {/* Feature Flags */} + + + Feature Flags + Enable or disable optional features + + + {KNOWN_SETTINGS.filter((s) => s.type === 'boolean').map((setting) => ( +
+
+ +

{setting.description}

+
+ saveSetting(setting.key, checked)} + /> +
+ ))} +
+
+ + {/* Numeric Settings */} + + + Business Rules + Configure financial and operational parameters + + + {KNOWN_SETTINGS.filter((s) => s.type === 'number').map((setting) => ( +
+
+ +

{setting.description}

+
+
+ + setValues((prev) => ({ + ...prev, + [setting.key]: parseFloat(e.target.value) || 0, + })) + } + /> + +
+
+ ))} +
+
+ + {/* JSON Settings */} + + + Advanced Configuration + + JSON-based settings for pipeline weights and berth rules + + + + {KNOWN_SETTINGS.filter((s) => s.type === 'json').map((setting) => { + const currentValue = getEffectiveValue(setting.key, setting.defaultValue); + const jsonStr = + values[`${setting.key}_edit`] !== undefined + ? String(values[`${setting.key}_edit`]) + : JSON.stringify(currentValue, null, 2); + return ( +
+ +

{setting.description}

+