From 26bfd503f10a54addd24df177fc5ae1f489403fd Mon Sep 17 00:00:00 2001 From: root Date: Tue, 3 Feb 2026 08:32:42 -0500 Subject: [PATCH] feat: add kiosk settings UI and co-managed client filtering - Created kiosk_settings table for configuration storage - Added API endpoints for kiosk settings (GET/POST) - Filter out co-managed clients (configurable company exclusions) - Built comprehensive settings UI at /kiosk/settings - Allow excluding specific companies from kiosk display - Configurable cycle interval, refresh interval, and RMM alert toggle - Updated dashboard link to point to settings page - Applied company exclusion filter to all ticket queries and activity feed - Default excludes Thrasher Group (ID: 29861361) --- app/api/kiosk/activity/route.ts | 22 +- app/api/kiosk/settings/route.ts | 74 ++++++ app/api/kiosk/stats/route.ts | 47 +++- app/dashboard/page.tsx | 6 +- app/kiosk/settings/page.tsx | 318 +++++++++++++++++++++++ migrations/019_create_kiosk_settings.sql | 23 ++ 6 files changed, 478 insertions(+), 12 deletions(-) create mode 100644 app/api/kiosk/settings/route.ts create mode 100644 app/kiosk/settings/page.tsx create mode 100644 migrations/019_create_kiosk_settings.sql diff --git a/app/api/kiosk/activity/route.ts b/app/api/kiosk/activity/route.ts index bf6db7e..29f51e5 100644 --- a/app/api/kiosk/activity/route.ts +++ b/app/api/kiosk/activity/route.ts @@ -1,9 +1,28 @@ import { NextRequest, NextResponse } from 'next/server'; import { postgresClient } from '@/lib/services/postgres-client'; +async function getExcludedCompanyIds(): Promise { + try { + const result = await postgresClient.query( + `SELECT setting_value FROM kiosk_settings WHERE setting_key = 'excluded_company_ids'` + ); + const value = result.rows[0]?.setting_value || ''; + return value ? value.split(',').map((id: string) => parseInt(id.trim())).filter((id: number) => !isNaN(id)) : []; + } catch (error) { + console.error('Error fetching excluded company IDs:', error); + return []; + } +} + export async function GET(request: NextRequest) { try { - // Get recent ticket activity for ticker feed - exclude RMM alerts (source = 8) + // Get excluded company IDs (co-managed clients) + const excludedCompanyIds = await getExcludedCompanyIds(); + const excludeCompanyFilter = excludedCompanyIds.length > 0 + ? `AND t.company_id NOT IN (${excludedCompanyIds.join(',')})` + : ''; + + // Get recent ticket activity for ticker feed - exclude RMM alerts (source = 8) and co-managed clients const activityResult = await postgresClient.query( `SELECT t.ticket_number, @@ -19,6 +38,7 @@ export async function GET(request: NextRequest) { LEFT JOIN statuses s ON t.status = s.value WHERE t.completed_date IS NULL AND (t.source IS NULL OR t.source != 8) + ${excludeCompanyFilter} ORDER BY t.last_activity_date DESC NULLS LAST LIMIT 50` ); diff --git a/app/api/kiosk/settings/route.ts b/app/api/kiosk/settings/route.ts new file mode 100644 index 0000000..f4ba6f8 --- /dev/null +++ b/app/api/kiosk/settings/route.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { postgresClient } from '@/lib/services/postgres-client'; + +export async function GET(request: NextRequest) { + try { + const result = await postgresClient.query( + 'SELECT setting_key, setting_value, description FROM kiosk_settings' + ); + + const settings: Record = {}; + result.rows.forEach((row: any) => { + let value = row.setting_value; + + // Parse specific settings + if (row.setting_key === 'excluded_company_ids') { + value = value ? value.split(',').map((id: string) => id.trim()).filter(Boolean) : []; + } else if (row.setting_key === 'show_rmm_alerts') { + value = value === 'true'; + } else if (['cycle_interval', 'refresh_interval'].includes(row.setting_key)) { + value = parseInt(value || '0'); + } + + settings[row.setting_key] = value; + }); + + return NextResponse.json(settings); + } catch (error) { + console.error('Error fetching kiosk settings:', error); + return NextResponse.json( + { error: 'Failed to fetch kiosk settings' }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { setting_key, setting_value } = body; + + if (!setting_key) { + return NextResponse.json( + { error: 'setting_key is required' }, + { status: 400 } + ); + } + + // Convert arrays and booleans to strings for storage + let valueToStore = setting_value; + if (Array.isArray(setting_value)) { + valueToStore = setting_value.join(','); + } else if (typeof setting_value === 'boolean') { + valueToStore = setting_value.toString(); + } else if (typeof setting_value === 'number') { + valueToStore = setting_value.toString(); + } + + await postgresClient.query( + `INSERT INTO kiosk_settings (setting_key, setting_value, updated_at) + VALUES ($1, $2, CURRENT_TIMESTAMP) + ON CONFLICT (setting_key) + DO UPDATE SET setting_value = EXCLUDED.setting_value, updated_at = CURRENT_TIMESTAMP`, + [setting_key, valueToStore] + ); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error updating kiosk settings:', error); + return NextResponse.json( + { error: 'Failed to update kiosk settings' }, + { status: 500 } + ); + } +} diff --git a/app/api/kiosk/stats/route.ts b/app/api/kiosk/stats/route.ts index 50a07e5..89b6d2a 100644 --- a/app/api/kiosk/stats/route.ts +++ b/app/api/kiosk/stats/route.ts @@ -1,15 +1,35 @@ import { NextRequest, NextResponse } from 'next/server'; import { postgresClient } from '@/lib/services/postgres-client'; +async function getExcludedCompanyIds(): Promise { + try { + const result = await postgresClient.query( + `SELECT setting_value FROM kiosk_settings WHERE setting_key = 'excluded_company_ids'` + ); + const value = result.rows[0]?.setting_value || ''; + return value ? value.split(',').map((id: string) => parseInt(id.trim())).filter((id: number) => !isNaN(id)) : []; + } catch (error) { + console.error('Error fetching excluded company IDs:', error); + return []; + } +} + export async function GET(request: NextRequest) { try { - // Critical tickets (Priority 1-3) - exclude RMM alerts (source = 8) + // Get excluded company IDs (co-managed clients) + const excludedCompanyIds = await getExcludedCompanyIds(); + const excludeCompanyFilter = excludedCompanyIds.length > 0 + ? `AND company_id NOT IN (${excludedCompanyIds.join(',')})` + : ''; + + // Critical tickets (Priority 1-3) - exclude RMM alerts (source = 8) and co-managed clients const criticalTicketsResult = await postgresClient.query( `SELECT COUNT(*) as count FROM tickets WHERE completed_date IS NULL AND priority <= 3 - AND (source IS NULL OR source != 8)` + AND (source IS NULL OR source != 8) + ${excludeCompanyFilter}` ); // Top 3 critical tickets @@ -20,6 +40,7 @@ export async function GET(request: NextRequest) { WHERE t.completed_date IS NULL AND t.priority <= 3 AND (t.source IS NULL OR t.source != 8) + ${excludeCompanyFilter} ORDER BY t.priority ASC, t.create_date ASC LIMIT 3` ); @@ -30,7 +51,8 @@ export async function GET(request: NextRequest) { FROM tickets WHERE completed_date IS NULL AND status IN (21, 9, 19) - AND (source IS NULL OR source != 8)` + AND (source IS NULL OR source != 8) + ${excludeCompanyFilter}` ); // Top 3 waiting tickets @@ -41,6 +63,7 @@ export async function GET(request: NextRequest) { WHERE t.completed_date IS NULL AND t.status IN (21, 9, 19) AND (t.source IS NULL OR t.source != 8) + ${excludeCompanyFilter} ORDER BY t.last_activity_date ASC NULLS FIRST LIMIT 3` ); @@ -51,7 +74,8 @@ export async function GET(request: NextRequest) { FROM tickets WHERE completed_date IS NULL AND last_activity_date < NOW() - INTERVAL '7 days' - AND (source IS NULL OR source != 8)` + AND (source IS NULL OR source != 8) + ${excludeCompanyFilter}` ); // Top 3 stale tickets (oldest activity first) @@ -62,6 +86,7 @@ export async function GET(request: NextRequest) { WHERE t.completed_date IS NULL AND t.last_activity_date < NOW() - INTERVAL '7 days' AND (t.source IS NULL OR t.source != 8) + ${excludeCompanyFilter} ORDER BY t.last_activity_date ASC NULLS FIRST LIMIT 3` ); @@ -72,7 +97,8 @@ export async function GET(request: NextRequest) { FROM tickets WHERE completed_date IS NULL AND due_date_time < NOW() - AND (source IS NULL OR source != 8)` + AND (source IS NULL OR source != 8) + ${excludeCompanyFilter}` ); // Top 3 overdue tickets (most overdue first) @@ -83,6 +109,7 @@ export async function GET(request: NextRequest) { WHERE t.completed_date IS NULL AND t.due_date_time < NOW() AND (t.source IS NULL OR t.source != 8) + ${excludeCompanyFilter} ORDER BY t.due_date_time ASC LIMIT 3` ); @@ -92,7 +119,8 @@ export async function GET(request: NextRequest) { `SELECT COUNT(*) as count FROM tickets WHERE completed_date IS NULL - AND (source IS NULL OR source != 8)` + AND (source IS NULL OR source != 8) + ${excludeCompanyFilter}` ); // Tickets closed today - exclude RMM alerts @@ -100,7 +128,8 @@ export async function GET(request: NextRequest) { `SELECT COUNT(*) as count FROM tickets WHERE DATE(completed_date) = CURRENT_DATE - AND (source IS NULL OR source != 8)` + AND (source IS NULL OR source != 8) + ${excludeCompanyFilter}` ); // Top 3 recently closed tickets @@ -110,6 +139,7 @@ export async function GET(request: NextRequest) { LEFT JOIN companies c ON t.company_id = c.id WHERE DATE(t.completed_date) = CURRENT_DATE AND (t.source IS NULL OR t.source != 8) + ${excludeCompanyFilter} ORDER BY t.completed_date DESC LIMIT 3` ); @@ -120,7 +150,8 @@ export async function GET(request: NextRequest) { FROM tickets WHERE completed_date >= NOW() - INTERVAL '30 days' AND completed_date IS NOT NULL - AND (source IS NULL OR source != 8)` + AND (source IS NULL OR source != 8) + ${excludeCompanyFilter}` ); // Time entries this week diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 7045910..5797415 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -103,11 +103,11 @@ export default function DashboardPage() { }, { title: 'Kiosk Display', - description: 'Full-screen executive dashboard for TV display', - href: '/kiosk', + description: 'Configure and view executive dashboard for TV', + href: '/kiosk/settings', icon: Activity, color: 'blue', - stats: 'Live metrics display' + stats: 'Settings & display' }, { title: 'Sync Management', diff --git a/app/kiosk/settings/page.tsx b/app/kiosk/settings/page.tsx new file mode 100644 index 0000000..01b6dae --- /dev/null +++ b/app/kiosk/settings/page.tsx @@ -0,0 +1,318 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { ArrowLeft, Save, Trash2 } from 'lucide-react'; + +interface Company { + id: number; + companyName: string; +} + +interface KioskSettings { + excluded_company_ids: number[]; + cycle_interval: number; + refresh_interval: number; + show_rmm_alerts: boolean; +} + +export default function KioskSettingsPage() { + const router = useRouter(); + const [settings, setSettings] = useState({ + excluded_company_ids: [], + cycle_interval: 7, + refresh_interval: 60, + show_rmm_alerts: false, + }); + const [companies, setCompanies] = useState([]); + const [selectedCompanyId, setSelectedCompanyId] = useState(''); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + useEffect(() => { + fetchSettings(); + fetchCompanies(); + }, []); + + const fetchSettings = async () => { + try { + const res = await fetch('/api/kiosk/settings'); + if (res.ok) { + const data = await res.json(); + setSettings(data); + } + } catch (error) { + console.error('Error fetching settings:', error); + } finally { + setLoading(false); + } + }; + + const fetchCompanies = async () => { + try { + const res = await fetch('/api/companies'); + if (res.ok) { + const data = await res.json(); + setCompanies(data.sort((a: Company, b: Company) => + a.companyName.localeCompare(b.companyName) + )); + } + } catch (error) { + console.error('Error fetching companies:', error); + } + }; + + const handleAddExcludedCompany = () => { + if (selectedCompanyId && !settings.excluded_company_ids.includes(parseInt(selectedCompanyId))) { + setSettings({ + ...settings, + excluded_company_ids: [...settings.excluded_company_ids, parseInt(selectedCompanyId)], + }); + setSelectedCompanyId(''); + } + }; + + const handleRemoveExcludedCompany = (companyId: number) => { + setSettings({ + ...settings, + excluded_company_ids: settings.excluded_company_ids.filter(id => id !== companyId), + }); + }; + + const handleSave = async () => { + setSaving(true); + try { + // Save each setting + await Promise.all([ + fetch('/api/kiosk/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + setting_key: 'excluded_company_ids', + setting_value: settings.excluded_company_ids, + }), + }), + fetch('/api/kiosk/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + setting_key: 'cycle_interval', + setting_value: settings.cycle_interval, + }), + }), + fetch('/api/kiosk/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + setting_key: 'refresh_interval', + setting_value: settings.refresh_interval, + }), + }), + fetch('/api/kiosk/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + setting_key: 'show_rmm_alerts', + setting_value: settings.show_rmm_alerts, + }), + }), + ]); + + alert('Settings saved successfully!'); + } catch (error) { + console.error('Error saving settings:', error); + alert('Failed to save settings'); + } finally { + setSaving(false); + } + }; + + const getCompanyName = (companyId: number) => { + return companies.find(c => c.id === companyId)?.companyName || `Company ${companyId}`; + }; + + if (loading) { + return ( +
+
Loading settings...
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+
+ +

Kiosk Display Settings

+
+ +
+ +
+ {/* Excluded Companies Section */} +
+

Excluded Companies (Co-Managed Clients)

+

+ Tickets from these companies will not appear in the kiosk display. +

+ + {/* Add Company */} +
+ + +
+ + {/* Excluded Companies List */} +
+ {settings.excluded_company_ids.length === 0 ? ( +
+ No companies excluded +
+ ) : ( + settings.excluded_company_ids.map(companyId => ( +
+ {getCompanyName(companyId)} + +
+ )) + )} +
+
+ + {/* Display Settings */} +
+

Display Settings

+ +
+ {/* Cycle Interval */} +
+ + setSettings({ ...settings, cycle_interval: parseInt(e.target.value) })} + className="w-full" + /> +
+ 3s (Fast) + 15s (Slow) +
+
+ + {/* Refresh Interval */} +
+ + setSettings({ ...settings, refresh_interval: parseInt(e.target.value) })} + className="w-full" + /> +
+ 30s + 5 min +
+
+
+
+ + {/* Filter Settings */} +
+

Ticket Filters

+ +
+
+
+
Show RMM Alert Tickets
+
+ Include automated monitoring alerts (Veeam, WAN circuits, server alerts) +
+
+ +
+
+
+ + {/* Preview Link */} +
+

Kiosk Display

+

+ Open the kiosk display in full-screen mode for your TV. +

+ +
+
+
+
+ ); +} diff --git a/migrations/019_create_kiosk_settings.sql b/migrations/019_create_kiosk_settings.sql new file mode 100644 index 0000000..82e8c89 --- /dev/null +++ b/migrations/019_create_kiosk_settings.sql @@ -0,0 +1,23 @@ +-- Create kiosk_settings table to store kiosk configuration +CREATE TABLE IF NOT EXISTS kiosk_settings ( + id SERIAL PRIMARY KEY, + setting_key VARCHAR(255) UNIQUE NOT NULL, + setting_value TEXT, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Create index on setting_key for faster lookups +CREATE INDEX IF NOT EXISTS idx_kiosk_settings_key ON kiosk_settings(setting_key); + +-- Insert default settings +INSERT INTO kiosk_settings (setting_key, setting_value, description) VALUES + ('excluded_company_ids', '29861361', 'Comma-separated list of company IDs to exclude from kiosk (co-managed clients)'), + ('cycle_interval', '7', 'Default cycle interval in seconds (3-15)'), + ('refresh_interval', '60', 'Data refresh interval in seconds'), + ('show_rmm_alerts', 'false', 'Whether to show RMM alert tickets') +ON CONFLICT (setting_key) DO NOTHING; + +-- Add comment to table +COMMENT ON TABLE kiosk_settings IS 'Configuration settings for the executive kiosk dashboard';