feat(admin-web): Phase 1 — organizations, support, AI budgets, waitlist pages
This commit is contained in:
parent
d1d01727e4
commit
aa33b12f8c
462
dashboards/admin-web/src/app/(dashboard)/ai-budgets/page.tsx
Normal file
462
dashboards/admin-web/src/app/(dashboard)/ai-budgets/page.tsx
Normal file
@ -0,0 +1,462 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Coins,
|
||||
Plus,
|
||||
MoreHorizontal,
|
||||
Search,
|
||||
AlertTriangle,
|
||||
TrendingUp,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
interface BudgetPolicy {
|
||||
id: string;
|
||||
name: string;
|
||||
scope: string;
|
||||
period: string;
|
||||
limitAmount: number;
|
||||
currentSpend?: number;
|
||||
alertThresholdPercent: number;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface BudgetAlert {
|
||||
id: string;
|
||||
policyId: string;
|
||||
policyName?: string;
|
||||
type: string;
|
||||
message: string;
|
||||
currentSpend: number;
|
||||
limitAmount: number;
|
||||
createdAt: string;
|
||||
acknowledged: boolean;
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number) {
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
const res = await fetch(`/api/ai-budgets/${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...opts,
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export default function AIBudgetsPage() {
|
||||
const [policies, setPolicies] = useState<BudgetPolicy[]>([]);
|
||||
const [alerts, setAlerts] = useState<BudgetAlert[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tab, setTab] = useState<'policies' | 'alerts'>('policies');
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// Create form
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newScope, setNewScope] = useState('global');
|
||||
const [newPeriod, setNewPeriod] = useState('monthly');
|
||||
const [newLimit, setNewLimit] = useState('100');
|
||||
const [newThreshold, setNewThreshold] = useState('80');
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const [pData, aData] = await Promise.all([apiFetch('policies'), apiFetch('alerts')]);
|
||||
setPolicies(
|
||||
Array.isArray(pData?.policies) ? pData.policies : Array.isArray(pData) ? pData : []
|
||||
);
|
||||
setAlerts(Array.isArray(aData?.alerts) ? aData.alerts : Array.isArray(aData) ? aData : []);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
async function handleCreate() {
|
||||
setCreating(true);
|
||||
await apiFetch('policies', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: newName,
|
||||
scope: newScope,
|
||||
period: newPeriod,
|
||||
limitAmount: parseFloat(newLimit) || 100,
|
||||
alertThresholdPercent: parseInt(newThreshold) || 80,
|
||||
}),
|
||||
});
|
||||
setCreating(false);
|
||||
setShowCreate(false);
|
||||
setNewName('');
|
||||
setNewScope('global');
|
||||
setNewPeriod('monthly');
|
||||
setNewLimit('100');
|
||||
setNewThreshold('80');
|
||||
loadData();
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm('Delete this budget policy?')) return;
|
||||
await apiFetch(`policies/${id}`, { method: 'DELETE' });
|
||||
loadData();
|
||||
}
|
||||
|
||||
const totalSpend = policies.reduce((sum, p) => sum + (p.currentSpend ?? 0), 0);
|
||||
const totalBudget = policies.reduce((sum, p) => sum + p.limitAmount, 0);
|
||||
const unacknowledgedAlerts = alerts.filter(a => !a.acknowledged).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">AI Budgets & Cost Management</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Monitor AI spend, set budget policies, and manage cost alerts
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreate(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" /> Create Policy
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Total Spend</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{formatCurrency(totalSpend)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Total Budget
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{formatCurrency(totalBudget)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Utilization</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-2xl font-bold">
|
||||
{totalBudget > 0 ? Math.round((totalSpend / totalBudget) * 100) : 0}%
|
||||
</div>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Active Alerts
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-2xl font-bold text-amber-600">{unacknowledgedAlerts}</div>
|
||||
{unacknowledgedAlerts > 0 && <AlertTriangle className="h-5 w-5 text-amber-500" />}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={tab === 'policies' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTab('policies')}
|
||||
>
|
||||
Policies ({policies.length})
|
||||
</Button>
|
||||
<Button
|
||||
variant={tab === 'alerts' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTab('alerts')}
|
||||
>
|
||||
Alerts ({alerts.length})
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{tab === 'policies' && (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-muted-foreground">Loading...</div>
|
||||
) : policies.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
<Coins className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||
No budget policies yet. Create one to start tracking AI spend.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Policy</TableHead>
|
||||
<TableHead>Scope</TableHead>
|
||||
<TableHead>Period</TableHead>
|
||||
<TableHead>Spend / Limit</TableHead>
|
||||
<TableHead>Utilization</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-[50px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{policies.map(p => {
|
||||
const util =
|
||||
p.limitAmount > 0
|
||||
? Math.round(((p.currentSpend ?? 0) / p.limitAmount) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="font-medium">{p.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{p.scope}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="capitalize">{p.period}</TableCell>
|
||||
<TableCell>
|
||||
{formatCurrency(p.currentSpend ?? 0)} / {formatCurrency(p.limitAmount)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-20 rounded-full bg-gray-100">
|
||||
<div
|
||||
className={`h-2 rounded-full ${
|
||||
util >= 90
|
||||
? 'bg-red-500'
|
||||
: util >= 70
|
||||
? 'bg-amber-500'
|
||||
: 'bg-emerald-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(util, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{util}%</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
p.status === 'active'
|
||||
? 'bg-emerald-50 text-emerald-700 border-0'
|
||||
: 'bg-gray-50 text-gray-600 border-0'
|
||||
}
|
||||
>
|
||||
{p.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDelete(p.id)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{tab === 'alerts' && (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{alerts.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
<AlertTriangle className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||
No budget alerts.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Alert</TableHead>
|
||||
<TableHead>Spend / Limit</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{alerts.map(a => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-sm">{a.message}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{a.policyName || a.policyId}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatCurrency(a.currentSpend)} / {formatCurrency(a.limitAmount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{formatDate(a.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
a.acknowledged
|
||||
? 'bg-gray-50 text-gray-600 border-0'
|
||||
: 'bg-amber-50 text-amber-700 border-0'
|
||||
}
|
||||
>
|
||||
{a.acknowledged ? 'Acknowledged' : 'Active'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Create Policy Dialog */}
|
||||
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Budget Policy</DialogTitle>
|
||||
<DialogDescription>
|
||||
Set a spending limit and alert threshold for AI operations.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Policy Name</Label>
|
||||
<Input
|
||||
placeholder="e.g. Monthly GPT-4 Budget"
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Scope</Label>
|
||||
<Select value={newScope} onValueChange={setNewScope}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="global">Global</SelectItem>
|
||||
<SelectItem value="product">Per Product</SelectItem>
|
||||
<SelectItem value="user">Per User</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Period</Label>
|
||||
<Select value={newPeriod} onValueChange={setNewPeriod}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="daily">Daily</SelectItem>
|
||||
<SelectItem value="monthly">Monthly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Limit (USD)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
step="0.01"
|
||||
value={newLimit}
|
||||
onChange={e => setNewLimit(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Alert Threshold (%)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={newThreshold}
|
||||
onChange={e => setNewThreshold(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCreate(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={creating || !newName.trim()}>
|
||||
{creating ? 'Creating...' : 'Create Policy'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
409
dashboards/admin-web/src/app/(dashboard)/organizations/page.tsx
Normal file
409
dashboards/admin-web/src/app/(dashboard)/organizations/page.tsx
Normal file
@ -0,0 +1,409 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Building2, Plus, MoreHorizontal, Users, FolderOpen, Search, Trash2 } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
|
||||
interface Org {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
status: string;
|
||||
memberCount?: number;
|
||||
workspaceCount?: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
userId: string;
|
||||
role: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
const res = await fetch(`/api/orgs/${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...opts,
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export default function OrganizationsPage() {
|
||||
const [orgs, setOrgs] = useState<Org[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newSlug, setNewSlug] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// Detail drawer
|
||||
const [selectedOrg, setSelectedOrg] = useState<Org | null>(null);
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
const loadOrgs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const qs = search ? `?search=${encodeURIComponent(search)}` : '';
|
||||
const data = await apiFetch(`list${qs}`);
|
||||
if (data?.organizations) {
|
||||
setOrgs(data.organizations);
|
||||
setTotal(data.total ?? data.organizations.length);
|
||||
} else if (Array.isArray(data)) {
|
||||
setOrgs(data);
|
||||
setTotal(data.length);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadOrgs();
|
||||
}, [loadOrgs]);
|
||||
|
||||
async function handleCreate() {
|
||||
setCreating(true);
|
||||
await apiFetch('create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: newName, slug: newSlug || undefined }),
|
||||
});
|
||||
setCreating(false);
|
||||
setShowCreate(false);
|
||||
setNewName('');
|
||||
setNewSlug('');
|
||||
loadOrgs();
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm('Delete this organization? This cannot be undone.')) return;
|
||||
await apiFetch(`${id}/delete`, { method: 'DELETE' });
|
||||
loadOrgs();
|
||||
if (selectedOrg?.id === id) setSelectedOrg(null);
|
||||
}
|
||||
|
||||
async function openDetail(org: Org) {
|
||||
setSelectedOrg(org);
|
||||
setDetailLoading(true);
|
||||
const [membersData, wsData] = await Promise.all([
|
||||
apiFetch(`${org.id}/members`),
|
||||
apiFetch(`${org.id}/workspaces`),
|
||||
]);
|
||||
setMembers(
|
||||
Array.isArray(membersData?.memberships)
|
||||
? membersData.memberships
|
||||
: Array.isArray(membersData)
|
||||
? membersData
|
||||
: []
|
||||
);
|
||||
setWorkspaces(
|
||||
Array.isArray(wsData?.workspaces) ? wsData.workspaces : Array.isArray(wsData) ? wsData : []
|
||||
);
|
||||
setDetailLoading(false);
|
||||
}
|
||||
|
||||
const activeOrgs = orgs.filter(o => o.status === 'active');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Organizations</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage organizations, workspaces, and memberships
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreate(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" /> Create Organization
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Total Organizations
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{total}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Active</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{activeOrgs.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Total Members
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{orgs.reduce((sum, o) => sum + (o.memberCount ?? 0), 0)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search organizations..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-muted-foreground">Loading...</div>
|
||||
) : orgs.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
<Building2 className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||
No organizations yet. Create one to get started.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Organization</TableHead>
|
||||
<TableHead>Slug</TableHead>
|
||||
<TableHead>Members</TableHead>
|
||||
<TableHead>Workspaces</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="w-[50px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{orgs.map(org => (
|
||||
<TableRow key={org.id} className="cursor-pointer" onClick={() => openDetail(org)}>
|
||||
<TableCell className="font-medium">{org.name}</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs text-muted-foreground">{org.slug}</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{org.memberCount ?? 0}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{org.workspaceCount ?? 0}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
org.status === 'active'
|
||||
? 'bg-emerald-50 text-emerald-700 border-0'
|
||||
: 'bg-gray-50 text-gray-600 border-0'
|
||||
}
|
||||
>
|
||||
{org.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs">
|
||||
{formatDate(org.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell onClick={e => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => openDetail(org)}>
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDelete(org.id)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create Dialog */}
|
||||
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Organization</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new organization with workspaces and members.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Name</Label>
|
||||
<Input
|
||||
placeholder="e.g. Acme Corp"
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Slug (optional — auto-generated)</Label>
|
||||
<Input
|
||||
placeholder="e.g. acme-corp"
|
||||
value={newSlug}
|
||||
onChange={e => setNewSlug(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCreate(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={creating || !newName.trim()}>
|
||||
{creating ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Detail Sheet */}
|
||||
<Sheet open={!!selectedOrg} onOpenChange={open => !open && setSelectedOrg(null)}>
|
||||
<SheetContent className="w-[480px] sm:w-[540px] overflow-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{selectedOrg?.name}</SheetTitle>
|
||||
<SheetDescription>
|
||||
<code className="text-xs">{selectedOrg?.id}</code>
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
{detailLoading ? (
|
||||
<div className="py-12 text-center text-muted-foreground">Loading...</div>
|
||||
) : (
|
||||
<div className="space-y-6 mt-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<Users className="h-4 w-4" /> Members ({members.length})
|
||||
</h3>
|
||||
{members.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No members yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{members.map(m => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{m.name || m.email || m.userId}</p>
|
||||
<p className="text-xs text-muted-foreground">{m.email}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{m.role}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<FolderOpen className="h-4 w-4" /> Workspaces ({workspaces.length})
|
||||
</h3>
|
||||
{workspaces.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No workspaces yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{workspaces.map(ws => (
|
||||
<div
|
||||
key={ws.id}
|
||||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{ws.name}</p>
|
||||
<code className="text-xs text-muted-foreground">{ws.slug}</code>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDate(ws.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
371
dashboards/admin-web/src/app/(dashboard)/support/page.tsx
Normal file
371
dashboards/admin-web/src/app/(dashboard)/support/page.tsx
Normal file
@ -0,0 +1,371 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
LifeBuoy,
|
||||
Plus,
|
||||
MoreHorizontal,
|
||||
Search,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
XCircle,
|
||||
ArrowUpRight,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
interface SupportCase {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
requesterUserId: string;
|
||||
assignedTo?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const statusConfig: Record<string, { label: string; color: string; icon: typeof Clock }> = {
|
||||
open: { label: 'Open', color: 'bg-blue-50 text-blue-700', icon: Clock },
|
||||
in_progress: { label: 'In Progress', color: 'bg-amber-50 text-amber-700', icon: ArrowUpRight },
|
||||
waiting_on_customer: { label: 'Waiting', color: 'bg-purple-50 text-purple-700', icon: Clock },
|
||||
resolved: { label: 'Resolved', color: 'bg-emerald-50 text-emerald-700', icon: CheckCircle2 },
|
||||
closed: { label: 'Closed', color: 'bg-gray-50 text-gray-600', icon: XCircle },
|
||||
};
|
||||
|
||||
const priorityConfig: Record<string, { label: string; color: string }> = {
|
||||
low: { label: 'Low', color: 'bg-gray-50 text-gray-600' },
|
||||
medium: { label: 'Medium', color: 'bg-blue-50 text-blue-700' },
|
||||
high: { label: 'High', color: 'bg-amber-50 text-amber-700' },
|
||||
critical: { label: 'Critical', color: 'bg-red-50 text-red-700' },
|
||||
};
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
const res = await fetch(`/api/support/${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...opts,
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export default function SupportCasesPage() {
|
||||
const [cases, setCases] = useState<SupportCase[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// Create form
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
const [newDescription, setNewDescription] = useState('');
|
||||
const [newPriority, setNewPriority] = useState('medium');
|
||||
|
||||
const loadCases = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
if (statusFilter !== 'all') params.set('status', statusFilter);
|
||||
if (search) params.set('search', search);
|
||||
const qs = params.toString() ? `?${params.toString()}` : '';
|
||||
const data = await apiFetch(`cases${qs}`);
|
||||
if (data?.cases) {
|
||||
setCases(data.cases);
|
||||
setTotal(data.total ?? data.cases.length);
|
||||
} else if (Array.isArray(data)) {
|
||||
setCases(data);
|
||||
setTotal(data.length);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [statusFilter, search]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCases();
|
||||
}, [loadCases]);
|
||||
|
||||
async function handleCreate() {
|
||||
setCreating(true);
|
||||
await apiFetch('cases', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: newTitle,
|
||||
description: newDescription,
|
||||
priority: newPriority,
|
||||
}),
|
||||
});
|
||||
setCreating(false);
|
||||
setShowCreate(false);
|
||||
setNewTitle('');
|
||||
setNewDescription('');
|
||||
setNewPriority('medium');
|
||||
loadCases();
|
||||
}
|
||||
|
||||
async function handleStatusChange(id: string, status: string) {
|
||||
await apiFetch(`cases/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
loadCases();
|
||||
}
|
||||
|
||||
const openCount = cases.filter(c => c.status === 'open' || c.status === 'in_progress').length;
|
||||
const criticalCount = cases.filter(
|
||||
c => c.priority === 'critical' && c.status !== 'closed' && c.status !== 'resolved'
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Support Cases</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage customer support cases, notes, and escalations
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreate(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" /> New Case
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Total Cases</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{total}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Open</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-blue-600">{openCount}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Critical</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-2xl font-bold text-red-600">{criticalCount}</div>
|
||||
{criticalCount > 0 && <AlertTriangle className="h-5 w-5 text-red-500" />}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Resolved</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-emerald-600">
|
||||
{cases.filter(c => c.status === 'resolved' || c.status === 'closed').length}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search cases..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Filter by status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
<SelectItem value="open">Open</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="waiting_on_customer">Waiting</SelectItem>
|
||||
<SelectItem value="resolved">Resolved</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-muted-foreground">Loading...</div>
|
||||
) : cases.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
<LifeBuoy className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||
No support cases found.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Priority</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Assigned To</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="w-[50px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{cases.map(c => {
|
||||
const sCfg = statusConfig[c.status] || statusConfig.open;
|
||||
const pCfg = priorityConfig[c.priority] || priorityConfig.medium;
|
||||
const StatusIcon = sCfg.icon;
|
||||
return (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{c.title}</div>
|
||||
<p className="text-xs text-muted-foreground truncate max-w-[300px]">
|
||||
{c.description}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`${pCfg.color} border-0`}>{pCfg.label}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`${sCfg.color} border-0 gap-1`}>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{sCfg.label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{c.assignedTo || '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs">
|
||||
{formatDate(c.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStatusChange(c.id, 'in_progress')}
|
||||
>
|
||||
Mark In Progress
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleStatusChange(c.id, 'resolved')}>
|
||||
Resolve
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleStatusChange(c.id, 'closed')}>
|
||||
Close
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create Dialog */}
|
||||
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Support Case</DialogTitle>
|
||||
<DialogDescription>Open a new support case for a customer issue.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Title</Label>
|
||||
<Input
|
||||
placeholder="Brief description of the issue"
|
||||
value={newTitle}
|
||||
onChange={e => setNewTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Description</Label>
|
||||
<Textarea
|
||||
placeholder="Detailed description..."
|
||||
value={newDescription}
|
||||
onChange={e => setNewDescription(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Priority</Label>
|
||||
<Select value={newPriority} onValueChange={setNewPriority}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
<SelectItem value="critical">Critical</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCreate(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={creating || !newTitle.trim()}>
|
||||
{creating ? 'Creating...' : 'Create Case'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
356
dashboards/admin-web/src/app/(dashboard)/waitlist/page.tsx
Normal file
356
dashboards/admin-web/src/app/(dashboard)/waitlist/page.tsx
Normal file
@ -0,0 +1,356 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
ListOrdered,
|
||||
Plus,
|
||||
MoreHorizontal,
|
||||
Search,
|
||||
Download,
|
||||
Send,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Mail,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
interface WaitlistEntry {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
position: number;
|
||||
status: string;
|
||||
referralCode?: string;
|
||||
referredBy?: string;
|
||||
invitedAt?: string;
|
||||
convertedAt?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const statusConfig: Record<string, { label: string; color: string; icon: typeof Clock }> = {
|
||||
waiting: { label: 'Waiting', color: 'bg-blue-50 text-blue-700', icon: Clock },
|
||||
invited: { label: 'Invited', color: 'bg-amber-50 text-amber-700', icon: Mail },
|
||||
converted: { label: 'Converted', color: 'bg-emerald-50 text-emerald-700', icon: CheckCircle2 },
|
||||
unsubscribed: { label: 'Unsubscribed', color: 'bg-gray-50 text-gray-600', icon: Clock },
|
||||
};
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
const res = await fetch(`/api/waitlist/${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...opts,
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export default function WaitlistPage() {
|
||||
const [entries, setEntries] = useState<WaitlistEntry[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [inviting, setInviting] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const loadEntries = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
if (statusFilter !== 'all') params.set('status', statusFilter);
|
||||
if (search) params.set('search', search);
|
||||
const qs = params.toString() ? `?${params.toString()}` : '';
|
||||
const data = await apiFetch(`list${qs}`);
|
||||
if (data?.entries) {
|
||||
setEntries(data.entries);
|
||||
setTotal(data.total ?? data.entries.length);
|
||||
} else if (Array.isArray(data)) {
|
||||
setEntries(data);
|
||||
setTotal(data.length);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [statusFilter, search]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadEntries();
|
||||
}, [loadEntries]);
|
||||
|
||||
function toggleSelect(id: string) {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
if (selected.size === entries.length) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(entries.map(e => e.id)));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchInvite() {
|
||||
if (selected.size === 0) return;
|
||||
setInviting(true);
|
||||
await apiFetch('invite', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ entryIds: Array.from(selected) }),
|
||||
});
|
||||
setInviting(false);
|
||||
setSelected(new Set());
|
||||
loadEntries();
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
setExporting(true);
|
||||
try {
|
||||
const res = await fetch('/api/waitlist/export', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (res.ok) {
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `waitlist-export-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setExporting(false);
|
||||
}
|
||||
|
||||
async function handleStatusChange(id: string, status: string) {
|
||||
await apiFetch(`${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
loadEntries();
|
||||
}
|
||||
|
||||
const waitingCount = entries.filter(e => e.status === 'waiting').length;
|
||||
const invitedCount = entries.filter(e => e.status === 'invited').length;
|
||||
const convertedCount = entries.filter(e => e.status === 'converted').length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Waitlist Management</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage pre-launch signups, send invitations, and track conversions
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleExport} disabled={exporting}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{exporting ? 'Exporting...' : 'CSV Export'}
|
||||
</Button>
|
||||
{selected.size > 0 && (
|
||||
<Button onClick={handleBatchInvite} disabled={inviting}>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
{inviting ? 'Inviting...' : `Invite ${selected.size} Selected`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Total Signups
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{total}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Waiting</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-blue-600">{waitingCount}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Invited</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-amber-600">{invitedCount}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Converted</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-2xl font-bold text-emerald-600">{convertedCount}</div>
|
||||
{total > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({Math.round((convertedCount / total) * 100)}%)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by email or name..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Filter by status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
<SelectItem value="waiting">Waiting</SelectItem>
|
||||
<SelectItem value="invited">Invited</SelectItem>
|
||||
<SelectItem value="converted">Converted</SelectItem>
|
||||
<SelectItem value="unsubscribed">Unsubscribed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-muted-foreground">Loading...</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
<ListOrdered className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||
No waitlist entries found.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.size === entries.length && entries.length > 0}
|
||||
onChange={toggleAll}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>#</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Referral</TableHead>
|
||||
<TableHead>Signed Up</TableHead>
|
||||
<TableHead className="w-[50px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map(entry => {
|
||||
const cfg = statusConfig[entry.status] || statusConfig.waiting;
|
||||
const StatusIcon = cfg.icon;
|
||||
return (
|
||||
<TableRow key={entry.id}>
|
||||
<TableCell onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(entry.id)}
|
||||
onChange={() => toggleSelect(entry.id)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground font-mono text-xs">
|
||||
{entry.position}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{entry.email}</TableCell>
|
||||
<TableCell className="text-sm">{entry.name || '—'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`${cfg.color} border-0 gap-1`}>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{cfg.label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{entry.referralCode ? <code>{entry.referralCode}</code> : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs">
|
||||
{formatDate(entry.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{entry.status === 'waiting' && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStatusChange(entry.id, 'invited')}
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" /> Send Invite
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStatusChange(entry.id, 'unsubscribed')}
|
||||
>
|
||||
Unsubscribe
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
/**
|
||||
* AI Budgets API proxy — forwards requests to platform-service.
|
||||
*
|
||||
* GET/POST/PATCH/DELETE /api/ai-budgets/* → platform-service /api/ai-budgets/*
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth-server';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
const PLATFORM_URL = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003';
|
||||
|
||||
async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
try {
|
||||
const caller = await getCurrentUser(req.headers.get('authorization'));
|
||||
if (!caller) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { path } = await params;
|
||||
const targetPath = `/api/ai-budgets/${path.join('/')}`;
|
||||
const qs = new URL(req.url).search;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-request-id': req.headers.get('x-request-id') || crypto.randomUUID(),
|
||||
'x-user-id': caller.id,
|
||||
'x-product-id': req.headers.get('x-product-id') || process.env.PRODUCT_ID || 'lysnrai',
|
||||
};
|
||||
|
||||
const fetchOptions: RequestInit = { method: req.method, headers };
|
||||
if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
|
||||
fetchOptions.body = await req.text();
|
||||
}
|
||||
|
||||
const res = await fetch(`${PLATFORM_URL}${targetPath}${qs}`, fetchOptions);
|
||||
const data = await res.json().catch(() => null);
|
||||
return NextResponse.json(data ?? { error: res.statusText }, { status: res.status });
|
||||
} catch (error) {
|
||||
logError('AI Budgets proxy error', error);
|
||||
return NextResponse.json({ error: 'Service unavailable' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, ctx);
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, ctx);
|
||||
}
|
||||
export async function PATCH(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, ctx);
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, ctx);
|
||||
}
|
||||
59
dashboards/admin-web/src/app/api/orgs/[...path]/route.ts
Normal file
59
dashboards/admin-web/src/app/api/orgs/[...path]/route.ts
Normal file
@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Organizations API proxy — forwards requests to platform-service.
|
||||
*
|
||||
* GET/POST/PATCH/DELETE /api/orgs/* → platform-service /api/orgs/*
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth-server';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
const PLATFORM_URL = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003';
|
||||
|
||||
async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
try {
|
||||
const caller = await getCurrentUser(req.headers.get('authorization'));
|
||||
if (!caller) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { path } = await params;
|
||||
const targetPath = `/api/orgs/${path.join('/')}`;
|
||||
const qs = new URL(req.url).search;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-request-id': req.headers.get('x-request-id') || crypto.randomUUID(),
|
||||
'x-user-id': caller.id,
|
||||
'x-product-id': req.headers.get('x-product-id') || process.env.PRODUCT_ID || 'lysnrai',
|
||||
};
|
||||
|
||||
const fetchOptions: RequestInit = { method: req.method, headers };
|
||||
if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
|
||||
fetchOptions.body = await req.text();
|
||||
}
|
||||
|
||||
const res = await fetch(`${PLATFORM_URL}${targetPath}${qs}`, fetchOptions);
|
||||
const data = await res.json().catch(() => null);
|
||||
return NextResponse.json(data ?? { error: res.statusText }, { status: res.status });
|
||||
} catch (error) {
|
||||
logError('Orgs proxy error', error);
|
||||
return NextResponse.json({ error: 'Service unavailable' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, ctx);
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, ctx);
|
||||
}
|
||||
export async function PATCH(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, ctx);
|
||||
}
|
||||
export async function PUT(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, ctx);
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, ctx);
|
||||
}
|
||||
56
dashboards/admin-web/src/app/api/support/[...path]/route.ts
Normal file
56
dashboards/admin-web/src/app/api/support/[...path]/route.ts
Normal file
@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Support Cases API proxy — forwards requests to platform-service.
|
||||
*
|
||||
* GET/POST/PATCH /api/support/* → platform-service /api/support/*
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth-server';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
const PLATFORM_URL = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003';
|
||||
|
||||
async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
try {
|
||||
const caller = await getCurrentUser(req.headers.get('authorization'));
|
||||
if (!caller) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { path } = await params;
|
||||
const targetPath = `/api/support/${path.join('/')}`;
|
||||
const qs = new URL(req.url).search;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-request-id': req.headers.get('x-request-id') || crypto.randomUUID(),
|
||||
'x-user-id': caller.id,
|
||||
'x-product-id': req.headers.get('x-product-id') || process.env.PRODUCT_ID || 'lysnrai',
|
||||
};
|
||||
|
||||
const fetchOptions: RequestInit = { method: req.method, headers };
|
||||
if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
|
||||
fetchOptions.body = await req.text();
|
||||
}
|
||||
|
||||
const res = await fetch(`${PLATFORM_URL}${targetPath}${qs}`, fetchOptions);
|
||||
const data = await res.json().catch(() => null);
|
||||
return NextResponse.json(data ?? { error: res.statusText }, { status: res.status });
|
||||
} catch (error) {
|
||||
logError('Support proxy error', error);
|
||||
return NextResponse.json({ error: 'Service unavailable' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, ctx);
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, ctx);
|
||||
}
|
||||
export async function PATCH(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, ctx);
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, ctx);
|
||||
}
|
||||
56
dashboards/admin-web/src/app/api/waitlist/[...path]/route.ts
Normal file
56
dashboards/admin-web/src/app/api/waitlist/[...path]/route.ts
Normal file
@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Waitlist API proxy — forwards requests to platform-service.
|
||||
*
|
||||
* GET/POST/PUT/DELETE /api/waitlist/* → platform-service /api/waitlist/*
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth-server';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
const PLATFORM_URL = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003';
|
||||
|
||||
async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
try {
|
||||
const caller = await getCurrentUser(req.headers.get('authorization'));
|
||||
if (!caller) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { path } = await params;
|
||||
const targetPath = `/api/waitlist/${path.join('/')}`;
|
||||
const qs = new URL(req.url).search;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-request-id': req.headers.get('x-request-id') || crypto.randomUUID(),
|
||||
'x-user-id': caller.id,
|
||||
'x-product-id': req.headers.get('x-product-id') || process.env.PRODUCT_ID || 'lysnrai',
|
||||
};
|
||||
|
||||
const fetchOptions: RequestInit = { method: req.method, headers };
|
||||
if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
|
||||
fetchOptions.body = await req.text();
|
||||
}
|
||||
|
||||
const res = await fetch(`${PLATFORM_URL}${targetPath}${qs}`, fetchOptions);
|
||||
const data = await res.json().catch(() => null);
|
||||
return NextResponse.json(data ?? { error: res.statusText }, { status: res.status });
|
||||
} catch (error) {
|
||||
logError('Waitlist proxy error', error);
|
||||
return NextResponse.json({ error: 'Service unavailable' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, ctx);
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, ctx);
|
||||
}
|
||||
export async function PUT(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, ctx);
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, ctx);
|
||||
}
|
||||
@ -39,6 +39,10 @@ import {
|
||||
FlaskConical,
|
||||
BrainCircuit,
|
||||
TrendingDown,
|
||||
Building2,
|
||||
LifeBuoy,
|
||||
Coins,
|
||||
ListOrdered,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
@ -65,6 +69,10 @@ const navItems = [
|
||||
{ href: '/flags', label: 'Feature Flags', icon: Settings },
|
||||
{ href: '/audit', label: 'Audit Log', icon: ScrollText },
|
||||
{ href: '/actiontrail', label: 'ActionTrail', icon: Crosshair },
|
||||
{ href: '/organizations', label: 'Organizations', icon: Building2 },
|
||||
{ href: '/support', label: 'Support Cases', icon: LifeBuoy },
|
||||
{ href: '/ai-budgets', label: 'AI Budgets', icon: Coins },
|
||||
{ href: '/waitlist', label: 'Waitlist', icon: ListOrdered },
|
||||
{ href: '/experiments', label: 'Experiments', icon: FlaskConical },
|
||||
{ href: '/predictive/at-risk', label: 'Predictive', icon: TrendingDown },
|
||||
{ href: '/ops', label: 'Mission Control', icon: Activity },
|
||||
|
||||
Loading…
Reference in New Issue
Block a user