From db375fb0e664e65bcf42bff1d1ce92033071a3f2 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sun, 3 May 2026 11:40:47 -0400 Subject: [PATCH] =?UTF-8?q?feat(admin):=20client=20scope=20=E2=80=94=20fil?= =?UTF-8?q?ter=20analytics=20to=20recurring-revenue=20companies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/admin/client-scope/page.tsx | 243 ++++++++++++++++++ app/admin/page.tsx | 7 + .../admin/company-scope/[companyId]/route.ts | 58 +++++ app/api/admin/company-scope/route.ts | 75 ++++++ app/api/analyzer/tickets/route.ts | 2 + app/api/dashboard/overview/route.ts | 5 +- migrations/082_company_scope.sql | 25 ++ 7 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 app/admin/client-scope/page.tsx create mode 100644 app/api/admin/company-scope/[companyId]/route.ts create mode 100644 app/api/admin/company-scope/route.ts create mode 100644 migrations/082_company_scope.sql diff --git a/app/admin/client-scope/page.tsx b/app/admin/client-scope/page.tsx new file mode 100644 index 0000000..867a7d9 --- /dev/null +++ b/app/admin/client-scope/page.tsx @@ -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([]); + const [total, setTotal] = useState(0); + const [excluded, setExcluded] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [search, setSearch] = useState(''); + const [typeFilter, setTypeFilter] = useState('all'); + const [scopeFilter, setScopeFilter] = useState('all'); + const [toggling, setToggling] = useState(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 ( + <> + void load()} disabled={loading}> + + Refresh + + } + /> + +
+ {/* Summary */} +
+
+ + Total companies: + {total} +
+
+ + Excluded from analytics: + {excluded} +
+
+ + + {/* Filters */} +
+
+ + setSearch(e.target.value)} + className="pl-8" + /> +
+ + + + {visible.length} shown + +
+ + {error && ( +
+ + Failed to load + {error} + +
+ )} + + + {loading ? ( +
+ {[1, 2, 3, 4, 5].map((i) => ( + + ))} +
+ ) : visible.length === 0 ? ( +
+ No companies match the current filters. +
+ ) : ( + + + + Company + Type + Include in analytics + + + + {visible.map((company) => ( + + {company.companyName} + + {company.companyTypeLabel ? ( + + {company.companyTypeLabel} + + ) : ( + + )} + + + void toggle(company, next)} + aria-label={`Include ${company.companyName} in analytics`} + /> + + + ))} + +
+ )} +
+
+
+ + ); +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 5e9f330..f7435b0 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -30,6 +30,7 @@ import { Shield, DollarSign, CalendarClock, + Building2, } from 'lucide-react'; interface AdminCounts { @@ -237,6 +238,12 @@ export default function AdminIndexPage() { { title: 'Tools & Data', tiles: [ + { + title: 'Client Scope', + href: '/admin/client-scope', + icon: Building2, + description: 'Control which companies appear in dashboard KPIs and ticket analytics', + }, { title: 'RMM Overshell', href: '/admin/rmm-overshell', diff --git a/app/api/admin/company-scope/[companyId]/route.ts b/app/api/admin/company-scope/[companyId]/route.ts new file mode 100644 index 0000000..f18b9a7 --- /dev/null +++ b/app/api/admin/company-scope/[companyId]/route.ts @@ -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 }); +} diff --git a/app/api/admin/company-scope/route.ts b/app/api/admin/company-scope/route.ts new file mode 100644 index 0000000..1526a5a --- /dev/null +++ b/app/api/admin/company-scope/route.ts @@ -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 = { + 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( + `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 }); +} diff --git a/app/api/analyzer/tickets/route.ts b/app/api/analyzer/tickets/route.ts index 1c55837..77bdc2b 100644 --- a/app/api/analyzer/tickets/route.ts +++ b/app/api/analyzer/tickets/route.ts @@ -215,6 +215,8 @@ export async function GET(request: NextRequest) { if (clientIds) { params.push(clientIds); 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) { params.push(issueTypes); diff --git a/app/api/dashboard/overview/route.ts b/app/api/dashboard/overview/route.ts index 6c10f22..9188fe0 100644 --- a/app/api/dashboard/overview/route.ts +++ b/app/api/dashboard/overview/route.ts @@ -52,7 +52,8 @@ export async function GET() { AND due_date_time < NOW() )::text AS sla_breaches 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 */ postgresClient.query<{ count: string }>(` @@ -60,6 +61,7 @@ export async function GET() { FROM tickets WHERE create_date::date = CURRENT_DATE - INTERVAL '1 day' 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 */ postgresClient.query<{ avg_resolved: string }>(` @@ -70,6 +72,7 @@ export async function GET() { WHERE completed_date >= CURRENT_DATE - INTERVAL '7 days' AND completed_date < CURRENT_DATE 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 ) sub `), diff --git a/migrations/082_company_scope.sql b/migrations/082_company_scope.sql new file mode 100644 index 0000000..2fc300a --- /dev/null +++ b/migrations/082_company_scope.sql @@ -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.';