Adds company-level opt-out scoping so white-label / subcontract clients (TTG, LEC, PER, VCF, Trivium Packaging, TNT Pizza, etc.) can be excluded from Wulf's own dashboard KPIs and ticket analytics without affecting per-company drill-down views. - migration 082: company_scope table (opt-out; absent row = in scope) - GET/PATCH /api/admin/company-scope[/companyId] — list + upsert - /admin/client-scope — searchable company list with Switch per row, type filter, and in/out scope filter; excluded rows are dimmed - dashboard overview KPIs now exclude out-of-scope company tickets - analyzer /tickets query excludes out-of-scope when no specific client is selected (explicit per-company selection still works) - "Client Scope" tile added to admin Tools & Data section Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
243 lines
9 KiB
TypeScript
243 lines
9 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState, useCallback } from 'react';
|
|
import { toast } from 'sonner';
|
|
import { PageHeader } from '@/components/navigation/page-header';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { Search, RefreshCw, Building2, EyeOff } from 'lucide-react';
|
|
|
|
interface Company {
|
|
id: string;
|
|
companyName: string;
|
|
companyType: number | null;
|
|
companyTypeLabel: string | null;
|
|
inScope: boolean;
|
|
}
|
|
|
|
type ScopeFilter = 'all' | 'in' | 'out';
|
|
|
|
export default function ClientScopePage() {
|
|
const [companies, setCompanies] = useState<Company[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [excluded, setExcluded] = useState(0);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [search, setSearch] = useState('');
|
|
const [typeFilter, setTypeFilter] = useState<string>('all');
|
|
const [scopeFilter, setScopeFilter] = useState<ScopeFilter>('all');
|
|
const [toggling, setToggling] = useState<string | null>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const params = new URLSearchParams();
|
|
if (search) params.set('search', search);
|
|
if (typeFilter !== 'all') params.set('type', typeFilter);
|
|
const res = await fetch(`/api/admin/company-scope?${params}`);
|
|
if (!res.ok) {
|
|
const d = await res.json().catch(() => ({})) as { error?: string };
|
|
throw new Error(d.error ?? `Request failed: ${res.status}`);
|
|
}
|
|
const data = await res.json() as { companies: Company[]; total: number; excluded: number };
|
|
setCompanies(data.companies);
|
|
setTotal(data.total);
|
|
setExcluded(data.excluded);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [search, typeFilter]);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
async function toggle(company: Company, next: boolean) {
|
|
setToggling(company.id);
|
|
try {
|
|
const res = await fetch(`/api/admin/company-scope/${company.id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ inScope: next }),
|
|
});
|
|
if (!res.ok) {
|
|
const d = await res.json().catch(() => ({})) as { error?: string };
|
|
throw new Error(d.error ?? `Failed: ${res.status}`);
|
|
}
|
|
setCompanies((prev) =>
|
|
prev.map((c) => (c.id === company.id ? { ...c, inScope: next } : c))
|
|
);
|
|
setExcluded((n) => (next ? Math.max(0, n - 1) : n + 1));
|
|
toast.success(`${company.companyName} ${next ? 'included in' : 'excluded from'} scope`);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Toggle failed');
|
|
} finally {
|
|
setToggling(null);
|
|
}
|
|
}
|
|
|
|
const visible = companies.filter((c) => {
|
|
if (scopeFilter === 'in') return c.inScope;
|
|
if (scopeFilter === 'out') return !c.inScope;
|
|
return true;
|
|
});
|
|
|
|
const uniqueTypes = Array.from(new Set(companies.map((c) => c.companyType).filter(Boolean))).sort() as number[];
|
|
|
|
return (
|
|
<>
|
|
<PageHeader
|
|
title="Client scope"
|
|
description="Mark which companies count as Wulf recurring-revenue clients. Excluded companies are hidden from dashboard KPIs and ticket analytics."
|
|
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Client scope' }]}
|
|
accent
|
|
actions={
|
|
<Button variant="outline" size="sm" onClick={() => void load()} disabled={loading}>
|
|
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
|
Refresh
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
<div className="container mx-auto px-6 py-6 max-w-5xl space-y-6">
|
|
{/* Summary */}
|
|
<div className="flex items-center gap-6 text-sm">
|
|
<div className="flex items-center gap-2">
|
|
<Building2 className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-muted-foreground">Total companies:</span>
|
|
<span className="font-semibold num">{total}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<EyeOff className="h-4 w-4 text-amber-500" />
|
|
<span className="text-muted-foreground">Excluded from analytics:</span>
|
|
<span className="font-semibold num text-amber-600 dark:text-amber-400">{excluded}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<Card>
|
|
{/* Filters */}
|
|
<div className="flex flex-wrap items-center gap-3 p-4 border-b">
|
|
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search companies…"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="pl-8"
|
|
/>
|
|
</div>
|
|
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
|
<SelectTrigger className="w-[150px]">
|
|
<SelectValue placeholder="All types" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All types</SelectItem>
|
|
{uniqueTypes.map((t) => (
|
|
<SelectItem key={t} value={String(t)}>
|
|
Type {t}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Select value={scopeFilter} onValueChange={(v) => setScopeFilter(v as ScopeFilter)}>
|
|
<SelectTrigger className="w-[160px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All companies</SelectItem>
|
|
<SelectItem value="in">In scope only</SelectItem>
|
|
<SelectItem value="out">Excluded only</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<span className="text-xs text-muted-foreground ml-auto">
|
|
{visible.length} shown
|
|
</span>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="p-4">
|
|
<Alert variant="destructive">
|
|
<AlertTitle>Failed to load</AlertTitle>
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
</div>
|
|
)}
|
|
|
|
<CardContent className="p-0">
|
|
{loading ? (
|
|
<div className="p-6 space-y-3">
|
|
{[1, 2, 3, 4, 5].map((i) => (
|
|
<Skeleton key={i} className="h-10 w-full" />
|
|
))}
|
|
</div>
|
|
) : visible.length === 0 ? (
|
|
<div className="py-12 text-center text-sm text-muted-foreground">
|
|
No companies match the current filters.
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Company</TableHead>
|
|
<TableHead className="w-32">Type</TableHead>
|
|
<TableHead className="w-40 text-right">Include in analytics</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{visible.map((company) => (
|
|
<TableRow
|
|
key={company.id}
|
|
className={!company.inScope ? 'opacity-50' : undefined}
|
|
>
|
|
<TableCell className="font-medium">{company.companyName}</TableCell>
|
|
<TableCell>
|
|
{company.companyTypeLabel ? (
|
|
<Badge variant="outline" className="text-xs">
|
|
{company.companyTypeLabel}
|
|
</Badge>
|
|
) : (
|
|
<span className="text-muted-foreground text-xs">—</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<Switch
|
|
checked={company.inScope}
|
|
disabled={toggling === company.id}
|
|
onCheckedChange={(next) => void toggle(company, next)}
|
|
aria-label={`Include ${company.companyName} in analytics`}
|
|
/>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|