feat(admin): client scope — filter analytics to recurring-revenue companies

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>
This commit is contained in:
lorentz 2026-05-03 11:40:47 -04:00
parent 2d4a546a8f
commit db375fb0e6
7 changed files with 414 additions and 1 deletions

View file

@ -0,0 +1,243 @@
'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>
</>
);
}

View file

@ -30,6 +30,7 @@ import {
Shield, Shield,
DollarSign, DollarSign,
CalendarClock, CalendarClock,
Building2,
} from 'lucide-react'; } from 'lucide-react';
interface AdminCounts { interface AdminCounts {
@ -237,6 +238,12 @@ export default function AdminIndexPage() {
{ {
title: 'Tools & Data', title: 'Tools & Data',
tiles: [ tiles: [
{
title: 'Client Scope',
href: '/admin/client-scope',
icon: Building2,
description: 'Control which companies appear in dashboard KPIs and ticket analytics',
},
{ {
title: 'RMM Overshell', title: 'RMM Overshell',
href: '/admin/rmm-overshell', href: '/admin/rmm-overshell',

View file

@ -0,0 +1,58 @@
/**
* PATCH /api/admin/company-scope/[companyId]
* Upsert a company's in_scope flag.
* Body: { inScope: boolean }
*
* DELETE /api/admin/company-scope/[companyId]
* Remove the explicit override company reverts to implicitly in scope.
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/auth-utils';
import postgresClient from '@/lib/services/postgres-client';
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ companyId: string }> }
) {
const { session, error } = await requireAdmin();
if (error) return error;
const { companyId } = await params;
const id = parseInt(companyId, 10);
if (isNaN(id)) return NextResponse.json({ error: 'Invalid companyId' }, { status: 400 });
const body = await request.json().catch(() => null);
if (body == null || typeof body.inScope !== 'boolean') {
return NextResponse.json({ error: 'body.inScope (boolean) required' }, { status: 400 });
}
const userEmail = (session?.user as any)?.email ?? null;
await postgresClient.query(
`INSERT INTO company_scope (company_id, in_scope, updated_by, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (company_id)
DO UPDATE SET in_scope = EXCLUDED.in_scope,
updated_by = EXCLUDED.updated_by,
updated_at = NOW()`,
[id, body.inScope, userEmail]
);
return NextResponse.json({ ok: true, companyId: id, inScope: body.inScope });
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ companyId: string }> }
) {
const { error } = await requireAdmin();
if (error) return error;
const { companyId } = await params;
const id = parseInt(companyId, 10);
if (isNaN(id)) return NextResponse.json({ error: 'Invalid companyId' }, { status: 400 });
await postgresClient.query(`DELETE FROM company_scope WHERE company_id = $1`, [id]);
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,75 @@
/**
* GET /api/admin/company-scope
* Returns all active companies with their current in_scope status.
* Companies without a company_scope row are implicitly in scope (true).
*
* Query params:
* search filter by company name (ILIKE)
* type filter by company_type integer
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/auth-utils';
import postgresClient from '@/lib/services/postgres-client';
interface CompanyRow {
id: string;
company_name: string;
company_type: number | null;
in_scope: boolean;
}
const COMPANY_TYPE_LABELS: Record<number, string> = {
1: 'Customer',
2: 'Canceled',
3: 'Cold',
4: 'Dead',
5: 'Warm',
6: 'Vendor',
7: 'Partner',
8: 'Prospect',
};
export async function GET(request: NextRequest) {
const { error } = await requireAdmin();
if (error) return error;
const url = request.nextUrl;
const search = url.searchParams.get('search')?.trim() || null;
const typeParam = url.searchParams.get('type');
const typeFilter = typeParam ? parseInt(typeParam, 10) : null;
const params: unknown[] = [];
const conditions = ['c.is_active = true', 'c.is_deleted = false'];
if (search) {
params.push(`%${search}%`);
conditions.push(`c.company_name ILIKE $${params.length}`);
}
if (typeFilter !== null && !isNaN(typeFilter)) {
params.push(typeFilter);
conditions.push(`c.company_type = $${params.length}`);
}
const result = await postgresClient.query<CompanyRow>(
`SELECT c.id::text, c.company_name, c.company_type,
COALESCE(cs.in_scope, true) AS in_scope
FROM companies c
LEFT JOIN company_scope cs ON cs.company_id = c.id
WHERE ${conditions.join(' AND ')}
ORDER BY c.company_name`,
params
);
const companies = result.rows.map((r) => ({
id: r.id,
companyName: r.company_name,
companyType: r.company_type,
companyTypeLabel: r.company_type != null ? (COMPANY_TYPE_LABELS[r.company_type] ?? `Type ${r.company_type}`) : null,
inScope: r.in_scope,
}));
const excluded = companies.filter((c) => !c.inScope).length;
return NextResponse.json({ companies, total: companies.length, excluded });
}

View file

@ -215,6 +215,8 @@ export async function GET(request: NextRequest) {
if (clientIds) { if (clientIds) {
params.push(clientIds); params.push(clientIds);
where.push(`t.company_id = ANY($${params.length}::bigint[])`); where.push(`t.company_id = ANY($${params.length}::bigint[])`);
} else {
where.push(`t.company_id NOT IN (SELECT company_id FROM company_scope WHERE in_scope = false)`);
} }
if (issueTypes) { if (issueTypes) {
params.push(issueTypes); params.push(issueTypes);

View file

@ -52,7 +52,8 @@ export async function GET() {
AND due_date_time < NOW() AND due_date_time < NOW()
)::text AS sla_breaches )::text AS sla_breaches
FROM tickets FROM tickets
WHERE is_deleted = false OR is_deleted IS NULL WHERE (is_deleted = false OR is_deleted IS NULL)
AND company_id NOT IN (SELECT company_id FROM company_scope WHERE in_scope = false)
`), `),
/* yesterday's opened count for the today-vs-yesterday delta */ /* yesterday's opened count for the today-vs-yesterday delta */
postgresClient.query<{ count: string }>(` postgresClient.query<{ count: string }>(`
@ -60,6 +61,7 @@ export async function GET() {
FROM tickets FROM tickets
WHERE create_date::date = CURRENT_DATE - INTERVAL '1 day' WHERE create_date::date = CURRENT_DATE - INTERVAL '1 day'
AND (is_deleted = false OR is_deleted IS NULL) AND (is_deleted = false OR is_deleted IS NULL)
AND company_id NOT IN (SELECT company_id FROM company_scope WHERE in_scope = false)
`), `),
/* 7-day average resolved (excluding today) for the resolved delta */ /* 7-day average resolved (excluding today) for the resolved delta */
postgresClient.query<{ avg_resolved: string }>(` postgresClient.query<{ avg_resolved: string }>(`
@ -70,6 +72,7 @@ export async function GET() {
WHERE completed_date >= CURRENT_DATE - INTERVAL '7 days' WHERE completed_date >= CURRENT_DATE - INTERVAL '7 days'
AND completed_date < CURRENT_DATE AND completed_date < CURRENT_DATE
AND (is_deleted = false OR is_deleted IS NULL) AND (is_deleted = false OR is_deleted IS NULL)
AND company_id NOT IN (SELECT company_id FROM company_scope WHERE in_scope = false)
GROUP BY completed_date::date GROUP BY completed_date::date
) sub ) sub
`), `),

View file

@ -0,0 +1,25 @@
-- =============================================================================
-- Company scope table
-- =============================================================================
-- Controls which companies are included in Wulf's own analytics (dashboard
-- KPIs, analyzer ticket views, etc.). Opt-out model: companies without a row
-- here are implicitly in scope. Only rows with in_scope=false are excluded.
--
-- Use case: white-label / subcontract clients (TTG, LEC, PER, VCF, TNT Pizza,
-- Trivium Packaging, etc.) have dedicated branded queues and should not pollute
-- Wulf recurring-revenue metrics. Mark them in_scope=false via /admin/client-scope.
-- =============================================================================
CREATE TABLE IF NOT EXISTS company_scope (
company_id BIGINT PRIMARY KEY REFERENCES companies(id) ON DELETE CASCADE,
in_scope BOOLEAN NOT NULL DEFAULT true,
updated_by TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_company_scope_in_scope
ON company_scope(in_scope)
WHERE in_scope = false;
COMMENT ON TABLE company_scope IS
'Opt-out scope filter for analytics. Absent row = in scope. in_scope=false = excluded from dashboard KPIs and ticket views.';