diff --git a/app/api/mobile/dashboard/route.ts b/app/api/mobile/dashboard/route.ts new file mode 100644 index 0000000..9ef8e0d --- /dev/null +++ b/app/api/mobile/dashboard/route.ts @@ -0,0 +1,87 @@ +import { NextResponse } from 'next/server'; +import { postgresClient } from '@/lib/services/postgres-client'; + +export async function GET() { + const [byStatus, byQueue, byPriority, recentActivity, sla] = await Promise.all([ + postgresClient.query(` + SELECT t.status, COUNT(*) as count + FROM tickets t + WHERE t.status != 5 AND t.is_deleted = false + GROUP BY t.status ORDER BY count DESC + `), + postgresClient.query(` + SELECT t.queue_id, q.label as queue_label, COUNT(*) as count + FROM tickets t + LEFT JOIN queues q ON q.value = t.queue_id + WHERE t.status != 5 AND t.is_deleted = false + GROUP BY t.queue_id, q.label ORDER BY count DESC LIMIT 8 + `), + postgresClient.query(` + SELECT t.priority, COUNT(*) as count + FROM tickets t + WHERE t.status != 5 AND t.is_deleted = false + GROUP BY t.priority ORDER BY t.priority + `), + postgresClient.query(` + SELECT t.id, t.ticket_number, t.title, t.status, t.priority, + t.last_activity_date, t.company_id, + c.company_name, q.label as queue_label, + r.first_name || ' ' || r.last_name as assigned_to + FROM tickets t + LEFT JOIN companies c ON c.id = t.company_id + LEFT JOIN queues q ON q.value = t.queue_id + LEFT JOIN resources r ON r.id = t.assigned_resource_id + WHERE t.status != 5 AND t.is_deleted = false AND t.last_activity_date IS NOT NULL + ORDER BY t.last_activity_date DESC LIMIT 10 + `), + postgresClient.query(` + SELECT + COUNT(*) FILTER (WHERE first_response_date_time IS NOT NULL + AND EXTRACT(EPOCH FROM (first_response_date_time - create_date))/3600 <= 1) as resp_met, + COUNT(*) FILTER (WHERE first_response_date_time IS NOT NULL) as resp_total, + COUNT(*) FILTER (WHERE resolved_date_time IS NOT NULL + AND EXTRACT(EPOCH FROM (resolved_date_time - create_date))/3600 <= 24) as res_met, + COUNT(*) FILTER (WHERE resolved_date_time IS NOT NULL) as res_total + FROM tickets + WHERE create_date >= NOW() - INTERVAL '30 days' AND is_deleted = false + `), + ]); + + const statusLabels: Record = { + 1: 'New', 5: 'Complete', 7: 'In Progress', 8: 'In Progress', + 9: 'Scheduled', 12: 'On Hold', 14: 'Waiting Customer', + 19: 'Waiting Materials', 21: 'Dispatched', 25: 'In Review', + 27: 'Pending Decision', 30: 'On Hold', 45: 'Escalated', 47: 'Waiting Customer', + 56: 'Waiting Vendor', 58: 'Waiting Parts', 59: 'Pending Approval', + 60: 'In Deployment', 66: 'Closed', 68: 'Resolved', 70: 'Customer Follow-Up', 71: 'Archived', + }; + const priorityLabels: Record = { 1: 'Critical', 2: 'High', 3: 'Medium', 4: 'Low' }; + + const slaRow = sla.rows[0]; + + return NextResponse.json({ + open_total: byStatus.rows.reduce((s, r) => s + parseInt(r.count), 0), + by_status: byStatus.rows.map(r => ({ + status: parseInt(r.status), + label: statusLabels[r.status] ?? `Status ${r.status}`, + count: parseInt(r.count), + })), + by_queue: byQueue.rows.map(r => ({ + queue_id: r.queue_id, + label: r.queue_label ?? `Queue ${r.queue_id}`, + count: parseInt(r.count), + })), + by_priority: byPriority.rows.map(r => ({ + priority: parseInt(r.priority), + label: priorityLabels[r.priority] ?? `P${r.priority}`, + count: parseInt(r.count), + })), + recent: recentActivity.rows, + sla: { + response_met: parseInt(slaRow.resp_met ?? 0), + response_total: parseInt(slaRow.resp_total ?? 0), + resolution_met: parseInt(slaRow.res_met ?? 0), + resolution_total: parseInt(slaRow.res_total ?? 0), + }, + }); +} diff --git a/app/api/mobile/finance/route.ts b/app/api/mobile/finance/route.ts new file mode 100644 index 0000000..12ddbe6 --- /dev/null +++ b/app/api/mobile/finance/route.ts @@ -0,0 +1,85 @@ +import { NextResponse } from 'next/server'; +import { postgresClient } from '@/lib/services/postgres-client'; + +export async function GET() { + const [summary, aging, topCustomers, overdueInvoices, recentPayments, monthlyRevenue] = await Promise.all([ + postgresClient.query(` + SELECT + SUM(balance) FILTER (WHERE status IN ('Open','Overdue')) as total_ar, + COUNT(*) FILTER (WHERE status IN ('Open','Overdue')) as total_ar_count, + SUM(balance) FILTER (WHERE status = 'Open') as current_balance, + COUNT(*) FILTER (WHERE status = 'Open') as current_count, + SUM(balance) FILTER (WHERE status = 'Overdue') as overdue_balance, + COUNT(*) FILTER (WHERE status = 'Overdue') as overdue_count, + SUM(total_amt) FILTER (WHERE status = 'Paid' AND txn_date >= DATE_TRUNC('month', NOW())) as paid_mtd, + SUM(total_amt) FILTER (WHERE status = 'Paid' AND txn_date >= DATE_TRUNC('year', NOW())) as paid_ytd + FROM qbo_invoices + `), + postgresClient.query(` + SELECT + SUM(balance) FILTER (WHERE status = 'Overdue' AND due_date >= CURRENT_DATE - 30) as days_1_30, + COUNT(*) FILTER (WHERE status = 'Overdue' AND due_date >= CURRENT_DATE - 30) as cnt_1_30, + SUM(balance) FILTER (WHERE status = 'Overdue' AND due_date < CURRENT_DATE - 30 + AND due_date >= CURRENT_DATE - 60) as days_31_60, + COUNT(*) FILTER (WHERE status = 'Overdue' AND due_date < CURRENT_DATE - 30 + AND due_date >= CURRENT_DATE - 60) as cnt_31_60, + SUM(balance) FILTER (WHERE status = 'Overdue' AND due_date < CURRENT_DATE - 60) as days_60_plus, + COUNT(*) FILTER (WHERE status = 'Overdue' AND due_date < CURRENT_DATE - 60) as cnt_60_plus + FROM qbo_invoices + `), + postgresClient.query(` + SELECT customer_ref_name, SUM(balance) as balance, COUNT(*) as invoice_count + FROM qbo_invoices + WHERE status IN ('Open','Overdue') + GROUP BY customer_ref_name + ORDER BY balance DESC LIMIT 8 + `), + postgresClient.query(` + SELECT id, doc_number, txn_date, due_date, customer_ref_name, total_amt, balance, status, + CURRENT_DATE - due_date::date as days_overdue + FROM qbo_invoices + WHERE status IN ('Open','Overdue') + ORDER BY status DESC, balance DESC LIMIT 30 + `), + postgresClient.query(` + SELECT id, txn_date, customer_ref_name, total_amt + FROM qbo_payments + ORDER BY txn_date DESC LIMIT 10 + `), + postgresClient.query(` + SELECT DATE_TRUNC('month', txn_date) as month, + SUM(total_amt) as revenue, COUNT(*) as invoice_count + FROM qbo_invoices + WHERE status = 'Paid' AND txn_date >= NOW() - INTERVAL '12 months' + GROUP BY 1 ORDER BY 1 ASC + `), + ]); + + const s = summary.rows[0]; + const a = aging.rows[0]; + return NextResponse.json({ + summary: { + total_ar: parseFloat(s.total_ar ?? 0), + total_ar_count: parseInt(s.total_ar_count ?? 0), + current_balance: parseFloat(s.current_balance ?? 0), + current_count: parseInt(s.current_count ?? 0), + overdue_balance: parseFloat(s.overdue_balance ?? 0), + overdue_count: parseInt(s.overdue_count ?? 0), + paid_mtd: parseFloat(s.paid_mtd ?? 0), + paid_ytd: parseFloat(s.paid_ytd ?? 0), + }, + aging: { + days_1_30: { balance: parseFloat(a.days_1_30 ?? 0), count: parseInt(a.cnt_1_30 ?? 0) }, + days_31_60: { balance: parseFloat(a.days_31_60 ?? 0), count: parseInt(a.cnt_31_60 ?? 0) }, + days_60_plus:{ balance: parseFloat(a.days_60_plus ?? 0),count: parseInt(a.cnt_60_plus ?? 0) }, + }, + top_customers: topCustomers.rows, + open_invoices: overdueInvoices.rows, + recent_payments: recentPayments.rows, + monthly_revenue: monthlyRevenue.rows.map(r => ({ + month: r.month, + revenue: parseFloat(r.revenue), + count: parseInt(r.invoice_count), + })), + }); +} diff --git a/app/api/mobile/tickets/[id]/timeline/route.ts b/app/api/mobile/tickets/[id]/timeline/route.ts new file mode 100644 index 0000000..1825229 --- /dev/null +++ b/app/api/mobile/tickets/[id]/timeline/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { postgresClient } from '@/lib/services/postgres-client'; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const ticketId = parseInt(id); + if (isNaN(ticketId)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); + + const [ticketRow, notes, timeEntries] = await Promise.all([ + postgresClient.query(` + SELECT t.id, t.ticket_number, t.title, t.description, t.status, t.priority, + t.create_date, t.last_activity_date, t.due_date_time, t.resolved_date_time, + t.queue_id, q.label as queue_label, + t.estimated_hours, t.resolution, + c.company_name, + r.first_name || ' ' || r.last_name as assigned_to + FROM tickets t + LEFT JOIN companies c ON c.id = t.company_id + LEFT JOIN queues q ON q.value = t.queue_id + LEFT JOIN resources r ON r.id = t.assigned_resource_id + WHERE t.id = $1 AND t.is_deleted = false + `, [ticketId]), + + postgresClient.query(` + SELECT n.id, n.title, n.description, n.note_type, n.publish, + n.create_date_time, + r.first_name || ' ' || r.last_name as author + FROM ticket_notes n + LEFT JOIN resources r ON r.id = n.creator_resource_id + WHERE n.ticket_id = $1 AND n.is_deleted = false + ORDER BY n.create_date_time ASC + `, [ticketId]), + + postgresClient.query(` + SELECT te.id, te.entry_date, te.hours_worked, te.notes, te.billable, + te.start_date_time, te.end_date_time, + r.first_name || ' ' || r.last_name as resource_name + FROM time_entries te + LEFT JOIN resources r ON r.id = te.resource_id + WHERE te.ticket_id = $1 + ORDER BY te.entry_date ASC, te.start_date_time ASC + `, [ticketId]), + ]); + + if (!ticketRow.rows[0]) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + + const ticket = ticketRow.rows[0]; + + // Merge notes and time entries into a single chronological timeline + type TimelineItem = + | { kind: 'created'; ts: string; ticket: typeof ticket } + | { kind: 'note'; ts: string; data: typeof notes.rows[0] } + | { kind: 'time'; ts: string; data: typeof timeEntries.rows[0] } + | { kind: 'resolved'; ts: string }; + + const timeline: TimelineItem[] = []; + + timeline.push({ kind: 'created', ts: ticket.create_date, ticket }); + + for (const n of notes.rows) { + timeline.push({ kind: 'note', ts: n.create_date_time, data: n }); + } + for (const te of timeEntries.rows) { + timeline.push({ kind: 'time', ts: te.entry_date, data: te }); + } + if (ticket.resolved_date_time) { + timeline.push({ kind: 'resolved', ts: ticket.resolved_date_time }); + } + + timeline.sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime()); + + const totalHours = timeEntries.rows.reduce((s, r) => s + parseFloat(r.hours_worked ?? 0), 0); + + return NextResponse.json({ ticket, timeline, total_hours: Math.round(totalHours * 10) / 10 }); +} diff --git a/app/api/mobile/tickets/route.ts b/app/api/mobile/tickets/route.ts new file mode 100644 index 0000000..e7be076 --- /dev/null +++ b/app/api/mobile/tickets/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { postgresClient } from '@/lib/services/postgres-client'; + +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl; + const search = searchParams.get('q') ?? ''; + const queue = searchParams.get('queue') ?? ''; + const priority = searchParams.get('priority') ?? ''; + const page = Math.max(1, parseInt(searchParams.get('page') ?? '1')); + const limit = 30; + const offset = (page - 1) * limit; + + const conditions: string[] = ['t.status != 5', 't.is_deleted = false']; + const params: unknown[] = []; + + if (search) { + params.push(`%${search}%`); + conditions.push(`(t.title ILIKE $${params.length} OR t.ticket_number ILIKE $${params.length} OR c.company_name ILIKE $${params.length})`); + } + if (queue) { + params.push(parseInt(queue)); + conditions.push(`t.queue_id = $${params.length}`); + } + if (priority) { + params.push(parseInt(priority)); + conditions.push(`t.priority = $${params.length}`); + } + + const where = conditions.join(' AND '); + + const [rows, countRow] = await Promise.all([ + postgresClient.query(` + SELECT t.id, t.ticket_number, t.title, t.status, t.priority, + t.create_date, t.last_activity_date, t.due_date_time, + t.queue_id, q.label as queue_label, + c.company_name, + r.first_name || ' ' || r.last_name as assigned_to + FROM tickets t + LEFT JOIN companies c ON c.id = t.company_id + LEFT JOIN queues q ON q.value = t.queue_id + LEFT JOIN resources r ON r.id = t.assigned_resource_id + WHERE ${where} + ORDER BY t.last_activity_date DESC NULLS LAST + LIMIT ${limit} OFFSET ${offset} + `, params), + postgresClient.query(` + SELECT COUNT(*) as total + FROM tickets t + LEFT JOIN companies c ON c.id = t.company_id + WHERE ${where} + `, params), + ]); + + return NextResponse.json({ + tickets: rows.rows, + total: parseInt(countRow.rows[0]?.total ?? '0'), + page, + limit, + }); +} diff --git a/app/mobile/dashboard/page.tsx b/app/mobile/dashboard/page.tsx new file mode 100644 index 0000000..b4c266d --- /dev/null +++ b/app/mobile/dashboard/page.tsx @@ -0,0 +1,173 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { RefreshCw, AlertCircle, Clock, CheckCircle2, ChevronRight } from 'lucide-react'; + +interface DashboardData { + open_total: number; + by_status: { status: number; label: string; count: number }[]; + by_queue: { queue_id: number; label: string; count: number }[]; + by_priority: { priority: number; label: string; count: number }[]; + recent: { + id: number; ticket_number: string; title: string; status: number; priority: number; + last_activity_date: string; company_name: string; queue_label: string; assigned_to: string; + }[]; + sla: { response_met: number; response_total: number; resolution_met: number; resolution_total: number }; +} + +const PRIORITY_COLOR: Record = { + 1: 'bg-red-500', 2: 'bg-orange-400', 3: 'bg-yellow-400', 4: 'bg-slate-300', +}; +const PRIORITY_TEXT: Record = { + 1: 'text-red-600', 2: 'text-orange-500', 3: 'text-yellow-600', 4: 'text-slate-500', +}; + +function pct(n: number, d: number) { + return d === 0 ? 0 : Math.round((n / d) * 100); +} +function relTime(ts: string) { + const diff = Date.now() - new Date(ts).getTime(); + const m = Math.floor(diff / 60000); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + return `${Math.floor(h / 24)}d ago`; +} + +export default function MobileDashboard() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = async () => { + setLoading(true); setError(null); + try { + const r = await fetch('/api/mobile/dashboard'); + if (!r.ok) throw new Error('Failed to load'); + setData(await r.json()); + } catch (e) { setError(String(e)); } + finally { setLoading(false); } + }; + + useEffect(() => { load(); }, []); + + if (loading) return ( +
+ +
+ ); + if (error) return ( +
{error}
+ ); + if (!data) return null; + + const respPct = pct(data.sla.response_met, data.sla.response_total); + const resPct = pct(data.sla.resolution_met, data.sla.resolution_total); + + return ( +
+ {/* Header */} +
+

Ticket Dashboard

+ +
+ + {/* Open total */} +
+
+ +
+
+

{data.open_total}

+

Open tickets

+
+
+ + {/* Priority breakdown */} +
+

By Priority

+
+ {data.by_priority.map(p => ( +
+
+

{p.count}

+

{p.label}

+
+ ))} +
+
+ + {/* SLA */} +
+

SLA — last 30 days

+
+ {[ + { label: 'First Response (≤1h)', met: data.sla.response_met, total: data.sla.response_total, p: respPct }, + { label: 'Resolution (≤24h)', met: data.sla.resolution_met, total: data.sla.resolution_total, p: resPct }, + ].map(s => ( +
+

= 80 ? 'text-green-600' : s.p >= 60 ? 'text-yellow-600' : 'text-red-600'}`}> + {s.p}% +

+

{s.label}

+

{s.met} / {s.total}

+
+
= 80 ? 'bg-green-500' : s.p >= 60 ? 'bg-yellow-400' : 'bg-red-500'}`} + style={{ width: `${s.p}%` }} /> +
+
+ ))} +
+
+ + {/* By queue */} +
+

By Queue

+
+ {data.by_queue.map(q => ( +
+

{q.label}

+
+
+
+
+ {q.count} +
+
+ ))} +
+
+ + {/* Recent activity */} +
+
+

Recent Activity

+ View all +
+
+ {data.recent.map(t => ( + +
+
+

{t.title}

+

{t.company_name} · {t.queue_label ?? '—'}

+
+
+
+ + {t.last_activity_date ? relTime(t.last_activity_date) : '—'} +
+ +
+ + ))} +
+
+
+ ); +} diff --git a/app/mobile/finance/page.tsx b/app/mobile/finance/page.tsx new file mode 100644 index 0000000..85708cd --- /dev/null +++ b/app/mobile/finance/page.tsx @@ -0,0 +1,248 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { RefreshCw, TrendingUp, AlertTriangle, CheckCircle2, ChevronDown, ChevronRight } from 'lucide-react'; + +interface AgingBucket { balance: number; count: number; } +interface FinanceData { + summary: { + total_ar: number; total_ar_count: number; + current_balance: number; current_count: number; + overdue_balance: number; overdue_count: number; + paid_mtd: number; paid_ytd: number; + }; + aging: { days_1_30: AgingBucket; days_31_60: AgingBucket; days_60_plus: AgingBucket }; + top_customers: { customer_ref_name: string; balance: number; invoice_count: number }[]; + open_invoices: { id: string; doc_number: string; txn_date: string; due_date: string; customer_ref_name: string; total_amt: number; balance: number; status: string; days_overdue: number }[]; + recent_payments: { id: string; txn_date: string; customer_ref_name: string; total_amt: number }[]; + monthly_revenue: { month: string; revenue: number; count: number }[]; +} + +function fmt$(n: number) { + return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n); +} +function fmtDate(ts: string) { + return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); +} + +export default function MobileFinance() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [invoicesOpen, setInvoicesOpen] = useState(false); + const [paymentsOpen, setPaymentsOpen] = useState(false); + const [tab, setTab] = useState<'open' | 'overdue'>('overdue'); + + const load = async () => { + setLoading(true); setError(null); + try { + const r = await fetch('/api/mobile/finance'); + if (!r.ok) throw new Error('Failed to load'); + setData(await r.json()); + } catch (e) { setError(String(e)); } + finally { setLoading(false); } + }; + + useEffect(() => { load(); }, []); + + if (loading) return ( +
+ +
+ ); + if (error) return
{error}
; + if (!data) return null; + + const { summary, aging, top_customers, open_invoices, recent_payments, monthly_revenue } = data; + const maxRev = Math.max(...monthly_revenue.map(m => m.revenue), 1); + const overdueInvoices = open_invoices.filter(i => i.status === 'Overdue'); + const currentInvoices = open_invoices.filter(i => i.status === 'Open'); + const overduePercent = summary.total_ar > 0 ? Math.round((summary.overdue_balance / summary.total_ar) * 100) : 0; + + return ( +
+
+

Finance

+ +
+ + {/* ── AR Hero ─────────────────────────────────────────── */} +
+

Total AR Outstanding

+

{fmt$(summary.total_ar)}

+

{summary.total_ar_count} open invoices

+ + {/* Current vs Overdue split bar */} +
+
+
+
+ + Current {fmt$(summary.current_balance)} ({summary.current_count}) + + + Overdue {fmt$(summary.overdue_balance)} ({summary.overdue_count}) + +
+
+ + {/* ── Aging buckets ───────────────────────────────────── */} + {summary.overdue_balance > 0 && ( +
+

Overdue Aging

+
+ {[ + { label: '1–30 days', bucket: aging.days_1_30, color: 'text-yellow-600 bg-yellow-50 border-yellow-200' }, + { label: '31–60 days', bucket: aging.days_31_60, color: 'text-orange-600 bg-orange-50 border-orange-200' }, + { label: '60+ days', bucket: aging.days_60_plus, color: 'text-red-600 bg-red-50 border-red-200' }, + ].map(({ label, bucket, color }) => ( +
+

{fmt$(bucket.balance)}

+

{label}

+

{bucket.count} inv

+
+ ))} +
+
+ )} + + {/* ── Top customers with AR ───────────────────────────── */} + {top_customers.length > 0 && ( +
+

Top AR by Customer

+
+ {top_customers.map(c => ( +
+
+

{c.customer_ref_name}

+

{c.invoice_count} invoice{c.invoice_count !== 1 ? 's' : ''}

+
+
+

{fmt$(c.balance)}

+
+
+
+
+
+ ))} +
+
+ )} + + {/* ── Revenue KPIs ────────────────────────────────────── */} +
+
+
+ +

Collected MTD

+
+

{fmt$(summary.paid_mtd)}

+
+
+
+ +

Revenue YTD

+
+

{fmt$(summary.paid_ytd)}

+
+
+ + {/* ── Revenue chart ───────────────────────────────────── */} + {monthly_revenue.length > 0 && ( +
+

Revenue — last 12 months

+
+
+ {monthly_revenue.map(m => { + const h = Math.max(4, Math.round((m.revenue / maxRev) * 100)); + const mo = new Date(m.month).toLocaleDateString('en-US', { month: 'short' }); + return ( +
+
+

{mo}

+
+ ); + })} +
+
+
+ )} + + {/* ── Invoice drill-down (collapsible) ────────────────── */} +
+ + + {invoicesOpen && ( + <> +
+ {(['overdue', 'open'] as const).map(t => ( + + ))} +
+
+ {(tab === 'overdue' ? overdueInvoices : currentInvoices).map(inv => ( +
+
+
+

{inv.customer_ref_name}

+

+ #{inv.doc_number} · Due {fmtDate(inv.due_date)} + {inv.days_overdue > 0 && ({inv.days_overdue}d)} +

+
+

+ {fmt$(inv.balance)} +

+
+
+ ))} +
+ + )} +
+ + {/* ── Recent payments (collapsible) ───────────────────── */} +
+ + {paymentsOpen && ( +
+ {recent_payments.map(p => ( +
+
+

{p.customer_ref_name}

+

{fmtDate(p.txn_date)}

+
+

{fmt$(p.total_amt)}

+
+ ))} +
+ )} +
+
+ ); +} diff --git a/app/mobile/layout.tsx b/app/mobile/layout.tsx new file mode 100644 index 0000000..72cf233 --- /dev/null +++ b/app/mobile/layout.tsx @@ -0,0 +1,48 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { LayoutDashboard, Ticket, DollarSign } from 'lucide-react'; + +const NAV = [ + { href: '/mobile/dashboard', label: 'Dashboard', icon: LayoutDashboard }, + { href: '/mobile/tickets', label: 'Tickets', icon: Ticket }, + { href: '/mobile/finance', label: 'Finance', icon: DollarSign }, +]; + +export default function MobileLayout({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + + return ( +
+ {/* Top bar */} +
+ Pulse + Wulf Consulting +
+ + {/* Page content */} +
+ {children} +
+ + {/* Bottom nav */} + +
+ ); +} diff --git a/app/mobile/page.tsx b/app/mobile/page.tsx new file mode 100644 index 0000000..67d5a05 --- /dev/null +++ b/app/mobile/page.tsx @@ -0,0 +1,9 @@ +'use client'; +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; + +export default function MobileRoot() { + const router = useRouter(); + useEffect(() => { router.replace('/mobile/dashboard'); }, [router]); + return null; +} diff --git a/app/mobile/tickets/[id]/page.tsx b/app/mobile/tickets/[id]/page.tsx new file mode 100644 index 0000000..9f891fa --- /dev/null +++ b/app/mobile/tickets/[id]/page.tsx @@ -0,0 +1,243 @@ +'use client'; + +import { useEffect, useState, use } from 'react'; +import { useRouter } from 'next/navigation'; +import { + ArrowLeft, RefreshCw, Clock, FileText, Timer, CheckCircle2, + ChevronDown, ChevronRight, User, Briefcase, AlertCircle, +} from 'lucide-react'; + +const PRIORITY_LABEL: Record = { 1: 'Critical', 2: 'High', 3: 'Medium', 4: 'Low' }; +const PRIORITY_COLOR: Record = { + 1: 'text-red-600 bg-red-50 border-red-200', + 2: 'text-orange-600 bg-orange-50 border-orange-200', + 3: 'text-yellow-600 bg-yellow-50 border-yellow-200', + 4: 'text-slate-600 bg-slate-50 border-slate-200', +}; +const STATUS_LABEL: Record = { + 1: 'New', 5: 'Complete', 7: 'In Progress', 8: 'In Progress', 9: 'Scheduled', + 12: 'On Hold', 14: 'Waiting Customer', 19: 'Waiting Materials', 25: 'In Review', + 47: 'Waiting Customer', 30: 'On Hold', 45: 'Escalated', +}; + +interface Ticket { + id: number; ticket_number: string; title: string; description: string; + status: number; priority: number; create_date: string; last_activity_date: string; + due_date_time: string | null; resolved_date_time: string | null; + queue_label: string; estimated_hours: string | null; resolution: string | null; + company_name: string; assigned_to: string; +} +type TimelineItem = + | { kind: 'created'; ts: string; ticket: Ticket } + | { kind: 'note'; ts: string; data: { id: number; title: string; description: string; note_type: number; publish: number; author: string } } + | { kind: 'time'; ts: string; data: { id: number; hours_worked: string; notes: string; billable: boolean; resource_name: string } } + | { kind: 'resolved'; ts: string }; + +function fmtDate(ts: string) { + return new Date(ts).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' }); +} +function fmtHours(h: string | number) { + const n = parseFloat(String(h)); + if (n < 1) return `${Math.round(n * 60)}m`; + return `${n.toFixed(1)}h`; +} +function stripHtml(s: string) { + return s?.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim() ?? ''; +} + +function TimelineCard({ item, defaultOpen = false }: { item: TimelineItem; defaultOpen?: boolean }) { + const [open, setOpen] = useState(defaultOpen); + + if (item.kind === 'created') { + return ( +
+
+
+ +
+
+
+
+

{fmtDate(item.ts)}

+

Ticket created

+
+
+ ); + } + + if (item.kind === 'resolved') { + return ( +
+
+
+ +
+
+
+

{fmtDate(item.ts)}

+

Ticket resolved

+
+
+ ); + } + + if (item.kind === 'time') { + const te = item.data; + return ( +
+
+
+ +
+
+
+
+

{fmtDate(item.ts)}

+ + {open && ( +
+

+ {te.resource_name} +

+ {te.notes &&

{stripHtml(te.notes)}

} +
+ )} +
+
+ ); + } + + // note + const note = item.data; + const isInternal = note.publish === 1; + const body = stripHtml(note.description ?? ''); + return ( +
+
+
+ +
+
+
+
+

{fmtDate(item.ts)}

+ + {open && ( +
+

+ {note.author} · {isInternal ? 'Internal' : 'Client-visible'} +

+

{body}

+
+ )} +
+
+ ); +} + +export default function TicketTimeline({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params); + const router = useRouter(); + const [data, setData] = useState<{ ticket: Ticket; timeline: TimelineItem[]; total_hours: number } | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + setLoading(true); + fetch(`/api/mobile/tickets/${id}/timeline`) + .then(r => r.json()) + .then(d => { setData(d); setLoading(false); }) + .catch(e => { setError(String(e)); setLoading(false); }); + }, [id]); + + if (loading) return ( +
+ +
+ ); + if (error || !data) return
{error ?? 'Not found'}
; + + const { ticket, timeline, total_hours } = data; + const priClass = PRIORITY_COLOR[ticket.priority] ?? 'text-slate-600 bg-slate-50 border-slate-200'; + + return ( +
+ {/* Ticket header */} +
+ +
+ + {PRIORITY_LABEL[ticket.priority] ?? `P${ticket.priority}`} + + + {ticket.ticket_number} + + + {STATUS_LABEL[ticket.status] ?? `Status ${ticket.status}`} + +
+

{ticket.title}

+
+

+ {ticket.company_name} +

+ {ticket.assigned_to && ( +

+ {ticket.assigned_to} · {ticket.queue_label ?? '—'} +

+ )} +

+ Created {fmtDate(ticket.create_date)} +

+
+ + {/* Stats row */} +
+
+

{timeline.filter(i => i.kind === 'note').length}

+

Notes

+
+
+

{timeline.filter(i => i.kind === 'time').length}

+

Time entries

+
+
+

{total_hours}h

+

Hours logged

+
+
+
+ + {/* Timeline */} +
+

Timeline

+ {timeline.length === 0 ? ( +

No activity recorded

+ ) : ( + timeline.map((item, i) => ( + + )) + )} +
+
+ ); +} diff --git a/app/mobile/tickets/page.tsx b/app/mobile/tickets/page.tsx new file mode 100644 index 0000000..8680fcc --- /dev/null +++ b/app/mobile/tickets/page.tsx @@ -0,0 +1,164 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import Link from 'next/link'; +import { Search, X, RefreshCw, ChevronRight, Clock } from 'lucide-react'; + +interface Ticket { + id: number; + ticket_number: string; + title: string; + status: number; + priority: number; + create_date: string; + last_activity_date: string; + due_date_time: string | null; + queue_id: number; + queue_label: string; + company_name: string; + assigned_to: string; +} + +const PRIORITY_DOT: Record = { + 1: 'bg-red-500', 2: 'bg-orange-400', 3: 'bg-yellow-400', 4: 'bg-slate-300', +}; +const PRIORITY_LABEL: Record = { + 1: 'Critical', 2: 'High', 3: 'Medium', 4: 'Low', +}; + +function relTime(ts: string | null) { + if (!ts) return '—'; + const diff = Date.now() - new Date(ts).getTime(); + const m = Math.floor(diff / 60000); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + return `${Math.floor(h / 24)}d ago`; +} + +export default function MobileTickets() { + const [tickets, setTickets] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const [priority, setPriority] = useState(''); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + + // Debounce search + useEffect(() => { + const t = setTimeout(() => setDebouncedSearch(search), 400); + return () => clearTimeout(t); + }, [search]); + + const load = useCallback(async (pg = 1, append = false) => { + if (pg === 1) setLoading(true); else setLoadingMore(true); + try { + const params = new URLSearchParams({ page: String(pg) }); + if (debouncedSearch) params.set('q', debouncedSearch); + if (priority) params.set('priority', priority); + const r = await fetch(`/api/mobile/tickets?${params}`); + const d = await r.json(); + setTickets(prev => append ? [...prev, ...d.tickets] : d.tickets); + setTotal(d.total); + setPage(pg); + } finally { + setLoading(false); setLoadingMore(false); + } + }, [debouncedSearch, priority]); + + useEffect(() => { load(1, false); }, [load]); + + const hasMore = tickets.length < total; + + return ( +
+ {/* Search + filter bar */} +
+
+ + setSearch(e.target.value)} + className="w-full pl-9 pr-9 py-2.5 rounded-xl border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30" + /> + {search && ( + + )} +
+
+ {(['', '1', '2', '3', '4'] as const).map(p => ( + + ))} +
+

{total} open tickets

+
+ + {/* List */} +
+ {loading ? ( +
+ +
+ ) : tickets.length === 0 ? ( +
No tickets found
+ ) : ( + <> +
+ {tickets.map(t => ( + +
+
+
+

{t.title}

+ +
+

{t.company_name}

+
+ {t.ticket_number} + {t.queue_label && ( + {t.queue_label} + )} + + + {relTime(t.last_activity_date)} + +
+
+ + ))} +
+ + {hasMore && ( +
+ +
+ )} + + )} +
+
+ ); +}