From a98c0daf152c9472e9183b495b04bc277522f12f Mon Sep 17 00:00:00 2001 From: lorentz Date: Mon, 30 Mar 2026 14:56:02 -0400 Subject: [PATCH] feat: classification labels in data browser + kiosk recurring revenue filter - data-browser/companies: resolve classification picklist IDs to labels in table column and detail modal; also added to DetailModal COMPANY_GROUPS - DetailModal: add 'classification' FieldType with color-coded badge map - kiosk stats + activity: switch from label-based exclusion to ID-based allowlist (included_classifications). Only shows companies with classification IN (15,16,17,18,203,205,206,207,202,5,12) = managed service / recurring revenue tiers only --- app/admin/data-browser/companies/page.tsx | 29 ++++++++++++++++++++- app/api/kiosk/activity/route.ts | 31 +++++++++++------------ app/api/kiosk/stats/route.ts | 31 +++++++++++------------ components/admin/DetailModal.tsx | 27 +++++++++++++++++++- 4 files changed, 84 insertions(+), 34 deletions(-) diff --git a/app/admin/data-browser/companies/page.tsx b/app/admin/data-browser/companies/page.tsx index 6f41117..3ba644f 100644 --- a/app/admin/data-browser/companies/page.tsx +++ b/app/admin/data-browser/companies/page.tsx @@ -9,6 +9,32 @@ import { Badge } from '@/components/ui/badge'; import { ArrowLeft, Users } from 'lucide-react'; import Link from 'next/link'; +const CLASSIFICATION_LABELS: Record = { + 5: 'Block Hour', + 9: 'Canceled', + 202: 'Co-Managed', + 16: 'Gold (Legacy)', + 206: 'IT Complete w/ Gold Security', + 207: 'IT Core / Silver Security', + 203: 'IT Foundation / Bronze Security', + 205: 'IT Premier / Platinum Security', + 14: 'Jeopardy Company', + 201: 'Partner', + 15: 'Platinum (Legacy)', + 13: 'Residential (no-pay)', + 17: 'Silver (Legacy)', + 12: 'T&M', + 7: 'Target', + 200: 'Tools Only', + 18: 'Bronze (Legacy)', +}; + +function classificationLabel(val: any): string { + if (val === null || val === undefined || val === '') return '—'; + const num = parseInt(String(val), 10); + return CLASSIFICATION_LABELS[num] ?? String(val); +} + export default function CompaniesBrowserPage() { const [companies, setCompanies] = useState([]); const [totalCount, setTotalCount] = useState(0); @@ -87,6 +113,7 @@ export default function CompaniesBrowserPage() { key: 'classification', label: 'Classification', sortable: true, + render: (value: any) => classificationLabel(value), }, { key: 'is_active', @@ -124,7 +151,7 @@ export default function CompaniesBrowserPage() { { key: 'state', label: 'State' }, { key: 'postal_code', label: 'Postal Code' }, { key: 'country', label: 'Country' }, - { key: 'classification', label: 'Classification' }, + { key: 'classification', label: 'Classification', render: (v: any) => classificationLabel(v) }, { key: 'company_type', label: 'Company Type' }, { key: 'company_category_id', label: 'Company Category ID' }, { key: 'owner_resource_id', label: 'Owner Resource ID' }, diff --git a/app/api/kiosk/activity/route.ts b/app/api/kiosk/activity/route.ts index d6c9e68..bd3a217 100644 --- a/app/api/kiosk/activity/route.ts +++ b/app/api/kiosk/activity/route.ts @@ -14,15 +14,15 @@ async function getExcludedCompanyIds(): Promise { } } -async function getExcludedClassifications(): Promise { +async function getIncludedClassificationIds(): Promise { try { const result = await postgresClient.query( - `SELECT setting_value FROM kiosk_settings WHERE setting_key = 'excluded_classifications'` + `SELECT setting_value FROM kiosk_settings WHERE setting_key = 'included_classifications'` ); const value = result.rows[0]?.setting_value || ''; - return value ? value.split(',').map((c: string) => c.trim()).filter(Boolean) : []; + return value ? value.split(',').map((c: string) => parseInt(c.trim(), 10)).filter((n: number) => !isNaN(n)) : []; } catch (error) { - console.error('Error fetching excluded classifications:', error); + console.error('Error fetching included classifications:', error); return []; } } @@ -31,19 +31,18 @@ export async function GET(request: NextRequest) { try { // Get excluded company IDs (co-managed clients) and classifications const excludedCompanyIds = await getExcludedCompanyIds(); - const excludedClassifications = await getExcludedClassifications(); - - // Build company exclusion filter + const includedClassificationIds = await getIncludedClassificationIds(); + + // Build company filter: only show recurring revenue classification clients let excludeCompanyFilter = ''; - if (excludedCompanyIds.length > 0 || excludedClassifications.length > 0) { - const conditions = []; - if (excludedCompanyIds.length > 0) { - conditions.push(`t.company_id NOT IN (${excludedCompanyIds.join(',')})`); - } - if (excludedClassifications.length > 0) { - const classificationList = excludedClassifications.map(c => `'${c.replace(/'/g, "''")}'`).join(','); - conditions.push(`t.company_id NOT IN (SELECT id FROM companies WHERE classification IN (${classificationList}))`); - } + const conditions: string[] = []; + if (excludedCompanyIds.length > 0) { + conditions.push(`t.company_id NOT IN (${excludedCompanyIds.join(',')})`); + } + if (includedClassificationIds.length > 0) { + conditions.push(`t.company_id IN (SELECT id FROM companies WHERE classification::integer IN (${includedClassificationIds.join(',')}))`); + } + if (conditions.length > 0) { excludeCompanyFilter = `AND (${conditions.join(' AND ')})`; } diff --git a/app/api/kiosk/stats/route.ts b/app/api/kiosk/stats/route.ts index 0926f31..a174571 100644 --- a/app/api/kiosk/stats/route.ts +++ b/app/api/kiosk/stats/route.ts @@ -14,15 +14,15 @@ async function getExcludedCompanyIds(): Promise { } } -async function getExcludedClassifications(): Promise { +async function getIncludedClassificationIds(): Promise { try { const result = await postgresClient.query( - `SELECT setting_value FROM kiosk_settings WHERE setting_key = 'excluded_classifications'` + `SELECT setting_value FROM kiosk_settings WHERE setting_key = 'included_classifications'` ); const value = result.rows[0]?.setting_value || ''; - return value ? value.split(',').map((c: string) => c.trim()).filter(Boolean) : []; + return value ? value.split(',').map((c: string) => parseInt(c.trim(), 10)).filter((n: number) => !isNaN(n)) : []; } catch (error) { - console.error('Error fetching excluded classifications:', error); + console.error('Error fetching included classifications:', error); return []; } } @@ -31,19 +31,18 @@ export async function GET(request: NextRequest) { try { // Get excluded company IDs (co-managed clients) const excludedCompanyIds = await getExcludedCompanyIds(); - const excludedClassifications = await getExcludedClassifications(); - - // Build company exclusion filter + const includedClassificationIds = await getIncludedClassificationIds(); + + // Build company filter: only show recurring revenue classification clients let excludeCompanyFilter = ''; - if (excludedCompanyIds.length > 0 || excludedClassifications.length > 0) { - const conditions = []; - if (excludedCompanyIds.length > 0) { - conditions.push(`company_id NOT IN (${excludedCompanyIds.join(',')})`); - } - if (excludedClassifications.length > 0) { - const classificationList = excludedClassifications.map(c => `'${c.replace(/'/g, "''")}'`).join(','); - conditions.push(`company_id NOT IN (SELECT id FROM companies WHERE classification IN (${classificationList}))`); - } + const conditions: string[] = []; + if (excludedCompanyIds.length > 0) { + conditions.push(`company_id NOT IN (${excludedCompanyIds.join(',')})`); + } + if (includedClassificationIds.length > 0) { + conditions.push(`company_id IN (SELECT id FROM companies WHERE classification::integer IN (${includedClassificationIds.join(',')}))`); + } + if (conditions.length > 0) { excludeCompanyFilter = `AND (${conditions.join(' AND ')})`; } diff --git a/components/admin/DetailModal.tsx b/components/admin/DetailModal.tsx index 6f2fcde..7376784 100644 --- a/components/admin/DetailModal.tsx +++ b/components/admin/DetailModal.tsx @@ -49,6 +49,26 @@ const SOURCE_MAP: Record = { 36: 'DeskDirector', 38: 'Huntress', 39: 'Blumira', 40: 'SentinelOne', }; +const CLASSIFICATION_MAP: Record = { + 5: { label: 'Block Hour', cls: 'bg-sky-500/15 text-sky-600 border border-sky-500/30' }, + 9: { label: 'Canceled', cls: 'bg-slate-500/15 text-slate-500 border border-slate-500/30' }, + 202: { label: 'Co-Managed', cls: 'bg-cyan-500/15 text-cyan-600 border border-cyan-500/30' }, + 16: { label: 'Gold (Legacy)', cls: 'bg-yellow-500/15 text-yellow-600 border border-yellow-500/30' }, + 206: { label: 'IT Complete w/ Gold Security', cls: 'bg-amber-500/15 text-amber-600 border border-amber-500/30' }, + 207: { label: 'IT Core / Silver Security', cls: 'bg-blue-500/15 text-blue-600 border border-blue-500/30' }, + 203: { label: 'IT Foundation / Bronze Security', cls: 'bg-orange-500/15 text-orange-600 border border-orange-500/30' }, + 205: { label: 'IT Premier / Platinum Security', cls: 'bg-violet-500/15 text-violet-600 border border-violet-500/30' }, + 14: { label: 'Jeopardy Company', cls: 'bg-red-500/15 text-red-600 border border-red-500/30' }, + 201: { label: 'Partner', cls: 'bg-purple-500/15 text-purple-600 border border-purple-500/30' }, + 15: { label: 'Platinum (Legacy)', cls: 'bg-violet-500/15 text-violet-600 border border-violet-500/30' }, + 13: { label: 'Residential (no-pay)', cls: 'bg-slate-500/15 text-slate-500 border border-slate-500/30' }, + 17: { label: 'Silver (Legacy)', cls: 'bg-zinc-500/15 text-zinc-600 border border-zinc-500/30' }, + 12: { label: 'T&M', cls: 'bg-teal-500/15 text-teal-600 border border-teal-500/30' }, + 7: { label: 'Target', cls: 'bg-emerald-500/15 text-emerald-600 border border-emerald-500/30' }, + 200: { label: 'Tools Only', cls: 'bg-slate-500/15 text-slate-500 border border-slate-500/30' }, + 18: { label: 'Bronze (Legacy)', cls: 'bg-orange-500/15 text-orange-600 border border-orange-500/30' }, +}; + const COMPANY_TYPE_MAP: Record = { 1: { label: 'Customer', cls: 'bg-green-500/15 text-green-600 border border-green-500/30' }, 2: { label: 'Lead', cls: 'bg-blue-500/15 text-blue-600 border border-blue-500/30' }, @@ -73,7 +93,7 @@ interface Lookups { // ── Field metadata for formatted view ───────────────────────────────────────── -type FieldType = 'date' | 'bool' | 'status' | 'priority' | 'source' | 'queue' | 'company_type' | 'url' | 'phone' | 'hours' | 'id' | 'resource' | 'company' | 'issue_type' | 'sub_issue_type' | 'config_item'; +type FieldType = 'date' | 'bool' | 'status' | 'priority' | 'source' | 'queue' | 'company_type' | 'classification' | 'url' | 'phone' | 'hours' | 'id' | 'resource' | 'company' | 'issue_type' | 'sub_issue_type' | 'config_item'; type FieldGroup = { label: string; @@ -130,6 +150,7 @@ const COMPANY_GROUPS: FieldGroup[] = [ { key: 'company_name', label: 'Company Name' }, { key: 'company_number', label: 'Company #' }, { key: 'company_type', label: 'Type', type: 'company_type' }, + { key: 'classification', label: 'Classification', type: 'classification' }, { key: 'is_active', label: 'Active', type: 'bool' }, ], }, @@ -219,6 +240,10 @@ function resolveLabel(key: string, value: any, type: FieldType | undefined, look const ct = COMPANY_TYPE_MAP[Number(value)]; return { display: {ct?.label ?? `Type ${value}`}, isEmpty: false }; } + case 'classification': { + const cl = CLASSIFICATION_MAP[Number(value)]; + return { display: {cl?.label ?? `Classification ${value}`}, isEmpty: false }; + } case 'resource': { const name = lookups.resources[Number(value)]; return {