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,
|
FlaskConical,
|
||||||
BrainCircuit,
|
BrainCircuit,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
|
Building2,
|
||||||
|
LifeBuoy,
|
||||||
|
Coins,
|
||||||
|
ListOrdered,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAuth } from '@/lib/auth-context';
|
import { useAuth } from '@/lib/auth-context';
|
||||||
@ -65,6 +69,10 @@ const navItems = [
|
|||||||
{ href: '/flags', label: 'Feature Flags', icon: Settings },
|
{ href: '/flags', label: 'Feature Flags', icon: Settings },
|
||||||
{ href: '/audit', label: 'Audit Log', icon: ScrollText },
|
{ href: '/audit', label: 'Audit Log', icon: ScrollText },
|
||||||
{ href: '/actiontrail', label: 'ActionTrail', icon: Crosshair },
|
{ 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: '/experiments', label: 'Experiments', icon: FlaskConical },
|
||||||
{ href: '/predictive/at-risk', label: 'Predictive', icon: TrendingDown },
|
{ href: '/predictive/at-risk', label: 'Predictive', icon: TrendingDown },
|
||||||
{ href: '/ops', label: 'Mission Control', icon: Activity },
|
{ href: '/ops', label: 'Mission Control', icon: Activity },
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user