feat: mobile app — scaffold, ticket dashboard, finance AR overview, invoice drill-down
This commit is contained in:
parent
c1351fc6ca
commit
0f1083f5b6
10 changed files with 1195 additions and 0 deletions
87
app/api/mobile/dashboard/route.ts
Normal file
87
app/api/mobile/dashboard/route.ts
Normal file
|
|
@ -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<number, string> = {
|
||||
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<number, string> = { 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),
|
||||
},
|
||||
});
|
||||
}
|
||||
85
app/api/mobile/finance/route.ts
Normal file
85
app/api/mobile/finance/route.ts
Normal file
|
|
@ -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),
|
||||
})),
|
||||
});
|
||||
}
|
||||
78
app/api/mobile/tickets/[id]/timeline/route.ts
Normal file
78
app/api/mobile/tickets/[id]/timeline/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
60
app/api/mobile/tickets/route.ts
Normal file
60
app/api/mobile/tickets/route.ts
Normal file
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
173
app/mobile/dashboard/page.tsx
Normal file
173
app/mobile/dashboard/page.tsx
Normal file
|
|
@ -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<number, string> = {
|
||||
1: 'bg-red-500', 2: 'bg-orange-400', 3: 'bg-yellow-400', 4: 'bg-slate-300',
|
||||
};
|
||||
const PRIORITY_TEXT: Record<number, string> = {
|
||||
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<DashboardData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
if (error) return (
|
||||
<div className="p-4 text-sm text-destructive">{error}</div>
|
||||
);
|
||||
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 (
|
||||
<div className="p-4 space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">Ticket Dashboard</h1>
|
||||
<button onClick={load} className="p-2 rounded-full hover:bg-accent">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Open total */}
|
||||
<div className="rounded-2xl border bg-primary/5 p-5 flex items-center gap-4">
|
||||
<div className="rounded-xl bg-primary/10 p-3">
|
||||
<AlertCircle className="w-7 h-7 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-4xl font-bold">{data.open_total}</p>
|
||||
<p className="text-sm text-muted-foreground">Open tickets</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority breakdown */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">By Priority</p>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{data.by_priority.map(p => (
|
||||
<div key={p.priority} className="rounded-xl border p-3 text-center">
|
||||
<div className={`w-2 h-2 rounded-full mx-auto mb-1.5 ${PRIORITY_COLOR[p.priority] ?? 'bg-slate-400'}`} />
|
||||
<p className={`text-xl font-bold ${PRIORITY_TEXT[p.priority] ?? ''}`}>{p.count}</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">{p.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SLA */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">SLA — last 30 days</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ 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 => (
|
||||
<div key={s.label} className="rounded-xl border p-4">
|
||||
<p className={`text-2xl font-bold ${s.p >= 80 ? 'text-green-600' : s.p >= 60 ? 'text-yellow-600' : 'text-red-600'}`}>
|
||||
{s.p}%
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{s.label}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{s.met} / {s.total}</p>
|
||||
<div className="mt-2 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div className={`h-full rounded-full ${s.p >= 80 ? 'bg-green-500' : s.p >= 60 ? 'bg-yellow-400' : 'bg-red-500'}`}
|
||||
style={{ width: `${s.p}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* By queue */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">By Queue</p>
|
||||
<div className="rounded-xl border divide-y overflow-hidden">
|
||||
{data.by_queue.map(q => (
|
||||
<div key={q.queue_id} className="flex items-center px-4 py-3 gap-3">
|
||||
<p className="flex-1 text-sm font-medium">{q.label}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-24 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div className="h-full rounded-full bg-primary"
|
||||
style={{ width: `${pct(q.count, data.open_total)}%` }} />
|
||||
</div>
|
||||
<span className="text-sm font-bold w-8 text-right">{q.count}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent activity */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Recent Activity</p>
|
||||
<Link href="/mobile/tickets" className="text-xs text-primary">View all</Link>
|
||||
</div>
|
||||
<div className="rounded-xl border divide-y overflow-hidden">
|
||||
{data.recent.map(t => (
|
||||
<Link key={t.id} href={`/mobile/tickets/${t.id}`}
|
||||
className="flex items-start gap-3 px-4 py-3 hover:bg-accent transition-colors">
|
||||
<div className={`mt-1 w-2 h-2 rounded-full shrink-0 ${PRIORITY_COLOR[t.priority] ?? 'bg-slate-400'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{t.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{t.company_name} · {t.queue_label ?? '—'}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="w-3 h-3" />
|
||||
{t.last_activity_date ? relTime(t.last_activity_date) : '—'}
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground ml-auto mt-1" />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
248
app/mobile/finance/page.tsx
Normal file
248
app/mobile/finance/page.tsx
Normal file
|
|
@ -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<FinanceData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
if (error) return <div className="p-4 text-sm text-destructive">{error}</div>;
|
||||
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 (
|
||||
<div className="p-4 space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">Finance</h1>
|
||||
<button onClick={load} className="p-2 rounded-full hover:bg-accent">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── AR Hero ─────────────────────────────────────────── */}
|
||||
<div className="rounded-2xl border bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 p-5">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1">Total AR Outstanding</p>
|
||||
<p className="text-4xl font-bold tabular-nums">{fmt$(summary.total_ar)}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{summary.total_ar_count} open invoices</p>
|
||||
|
||||
{/* Current vs Overdue split bar */}
|
||||
<div className="mt-4 h-2 rounded-full bg-blue-100 overflow-hidden">
|
||||
<div className="h-full rounded-full bg-red-500 transition-all"
|
||||
style={{ width: `${overduePercent}%` }} />
|
||||
</div>
|
||||
<div className="flex justify-between mt-1.5">
|
||||
<span className="text-xs text-blue-600 font-medium">
|
||||
Current {fmt$(summary.current_balance)} ({summary.current_count})
|
||||
</span>
|
||||
<span className="text-xs text-red-600 font-medium">
|
||||
Overdue {fmt$(summary.overdue_balance)} ({summary.overdue_count})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Aging buckets ───────────────────────────────────── */}
|
||||
{summary.overdue_balance > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Overdue Aging</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<div key={label} className={`rounded-xl border p-3 ${color}`}>
|
||||
<p className="text-base font-bold">{fmt$(bucket.balance)}</p>
|
||||
<p className="text-[10px] mt-0.5">{label}</p>
|
||||
<p className="text-[10px] opacity-70">{bucket.count} inv</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Top customers with AR ───────────────────────────── */}
|
||||
{top_customers.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Top AR by Customer</p>
|
||||
<div className="rounded-xl border divide-y overflow-hidden">
|
||||
{top_customers.map(c => (
|
||||
<div key={c.customer_ref_name} className="flex items-center px-4 py-3 gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{c.customer_ref_name}</p>
|
||||
<p className="text-xs text-muted-foreground">{c.invoice_count} invoice{c.invoice_count !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<p className="text-sm font-bold">{fmt$(c.balance)}</p>
|
||||
<div className="w-16 h-1 rounded-full bg-muted overflow-hidden mt-1">
|
||||
<div className="h-full rounded-full bg-primary"
|
||||
style={{ width: `${Math.round((c.balance / summary.total_ar) * 100)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Revenue KPIs ────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-2xl border bg-green-500/5 p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-600" />
|
||||
<p className="text-xs text-muted-foreground">Collected MTD</p>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-green-600">{fmt$(summary.paid_mtd)}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border bg-blue-500/5 p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<TrendingUp className="w-4 h-4 text-blue-600" />
|
||||
<p className="text-xs text-muted-foreground">Revenue YTD</p>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-blue-600">{fmt$(summary.paid_ytd)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Revenue chart ───────────────────────────────────── */}
|
||||
{monthly_revenue.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Revenue — last 12 months</p>
|
||||
<div className="rounded-xl border p-4">
|
||||
<div className="flex items-end gap-1" style={{ height: '96px' }}>
|
||||
{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 (
|
||||
<div key={m.month} className="flex-1 flex flex-col items-center justify-end gap-1 h-full group">
|
||||
<div className="w-full rounded-t bg-primary/70 group-hover:bg-primary transition-colors"
|
||||
style={{ height: `${h}%` }} title={`${mo}: ${fmt$(m.revenue)}`} />
|
||||
<p className="text-[9px] text-muted-foreground shrink-0">{mo}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Invoice drill-down (collapsible) ────────────────── */}
|
||||
<div className="rounded-xl border overflow-hidden">
|
||||
<button onClick={() => setInvoicesOpen(o => !o)}
|
||||
className="w-full flex items-center justify-between px-4 py-3.5 hover:bg-accent transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">Open Invoices</span>
|
||||
<span className="text-xs bg-muted rounded-full px-2 py-0.5">{open_invoices.length}</span>
|
||||
</div>
|
||||
{invoicesOpen ? <ChevronDown className="w-4 h-4 text-muted-foreground" /> : <ChevronRight className="w-4 h-4 text-muted-foreground" />}
|
||||
</button>
|
||||
|
||||
{invoicesOpen && (
|
||||
<>
|
||||
<div className="flex border-t border-b">
|
||||
{(['overdue', 'open'] as const).map(t => (
|
||||
<button key={t} onClick={() => setTab(t)}
|
||||
className={`flex-1 text-xs py-2 font-medium border-b-2 transition-colors ${
|
||||
tab === t ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
|
||||
}`}>
|
||||
{t === 'overdue' ? `Overdue (${overdueInvoices.length})` : `Current (${currentInvoices.length})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="divide-y max-h-80 overflow-y-auto">
|
||||
{(tab === 'overdue' ? overdueInvoices : currentInvoices).map(inv => (
|
||||
<div key={inv.id} className="px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{inv.customer_ref_name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
#{inv.doc_number} · Due {fmtDate(inv.due_date)}
|
||||
{inv.days_overdue > 0 && <span className="text-red-500 ml-1">({inv.days_overdue}d)</span>}
|
||||
</p>
|
||||
</div>
|
||||
<p className={`text-sm font-bold shrink-0 ${inv.status === 'Overdue' ? 'text-red-600' : ''}`}>
|
||||
{fmt$(inv.balance)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Recent payments (collapsible) ───────────────────── */}
|
||||
<div className="rounded-xl border overflow-hidden">
|
||||
<button onClick={() => setPaymentsOpen(o => !o)}
|
||||
className="w-full flex items-center justify-between px-4 py-3.5 hover:bg-accent transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">Recent Payments</span>
|
||||
</div>
|
||||
{paymentsOpen ? <ChevronDown className="w-4 h-4 text-muted-foreground" /> : <ChevronRight className="w-4 h-4 text-muted-foreground" />}
|
||||
</button>
|
||||
{paymentsOpen && (
|
||||
<div className="divide-y border-t max-h-64 overflow-y-auto">
|
||||
{recent_payments.map(p => (
|
||||
<div key={p.id} className="px-4 py-3 flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{p.customer_ref_name}</p>
|
||||
<p className="text-xs text-muted-foreground">{fmtDate(p.txn_date)}</p>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-green-600 shrink-0">{fmt$(p.total_amt)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
app/mobile/layout.tsx
Normal file
48
app/mobile/layout.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex flex-col min-h-screen bg-background max-w-lg mx-auto">
|
||||
{/* Top bar */}
|
||||
<header className="sticky top-0 z-20 bg-background border-b px-4 py-3 flex items-center justify-between">
|
||||
<span className="font-bold text-lg tracking-tight">Pulse</span>
|
||||
<span className="text-xs text-muted-foreground">Wulf Consulting</span>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 overflow-y-auto pb-20">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* Bottom nav */}
|
||||
<nav className="fixed bottom-0 left-0 right-0 z-20 border-t bg-background max-w-lg mx-auto">
|
||||
<div className="flex">
|
||||
{NAV.map(({ href, label, icon: Icon }) => {
|
||||
const active = pathname.startsWith(href);
|
||||
return (
|
||||
<Link key={href} href={href}
|
||||
className={`flex-1 flex flex-col items-center justify-center gap-0.5 py-2.5 text-xs transition-colors
|
||||
${active ? 'text-primary' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
app/mobile/page.tsx
Normal file
9
app/mobile/page.tsx
Normal file
|
|
@ -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;
|
||||
}
|
||||
243
app/mobile/tickets/[id]/page.tsx
Normal file
243
app/mobile/tickets/[id]/page.tsx
Normal file
|
|
@ -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<number, string> = { 1: 'Critical', 2: 'High', 3: 'Medium', 4: 'Low' };
|
||||
const PRIORITY_COLOR: Record<number, string> = {
|
||||
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<number, string> = {
|
||||
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 (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<FileText className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div className="w-px flex-1 bg-border mt-1" />
|
||||
</div>
|
||||
<div className="pb-4 flex-1 min-w-0">
|
||||
<p className="text-xs text-muted-foreground mb-0.5">{fmtDate(item.ts)}</p>
|
||||
<p className="text-sm font-medium">Ticket created</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.kind === 'resolved') {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-8 h-8 rounded-full bg-green-100 flex items-center justify-center shrink-0">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="pb-4 flex-1">
|
||||
<p className="text-xs text-muted-foreground mb-0.5">{fmtDate(item.ts)}</p>
|
||||
<p className="text-sm font-medium text-green-600">Ticket resolved</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.kind === 'time') {
|
||||
const te = item.data;
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-50 flex items-center justify-center shrink-0">
|
||||
<Timer className="w-4 h-4 text-blue-500" />
|
||||
</div>
|
||||
<div className="w-px flex-1 bg-border mt-1" />
|
||||
</div>
|
||||
<div className="pb-4 flex-1 min-w-0">
|
||||
<p className="text-xs text-muted-foreground mb-0.5">{fmtDate(item.ts)}</p>
|
||||
<button onClick={() => setOpen(o => !o)}
|
||||
className="w-full text-left flex items-center gap-2 group">
|
||||
<p className="text-sm font-medium flex-1">
|
||||
Time entry — {fmtHours(te.hours_worked)}
|
||||
{te.billable ? '' : <span className="ml-1 text-xs text-muted-foreground">(non-bill)</span>}
|
||||
</p>
|
||||
{open ? <ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" /> : <ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="mt-2 rounded-lg bg-blue-50 border border-blue-100 p-3 space-y-1">
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<User className="w-3 h-3" />{te.resource_name}
|
||||
</p>
|
||||
{te.notes && <p className="text-sm">{stripHtml(te.notes)}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// note
|
||||
const note = item.data;
|
||||
const isInternal = note.publish === 1;
|
||||
const body = stripHtml(note.description ?? '');
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${isInternal ? 'bg-slate-100' : 'bg-amber-50'}`}>
|
||||
<FileText className={`w-4 h-4 ${isInternal ? 'text-slate-500' : 'text-amber-500'}`} />
|
||||
</div>
|
||||
<div className="w-px flex-1 bg-border mt-1" />
|
||||
</div>
|
||||
<div className="pb-4 flex-1 min-w-0">
|
||||
<p className="text-xs text-muted-foreground mb-0.5">{fmtDate(item.ts)}</p>
|
||||
<button onClick={() => setOpen(o => !o)} className="w-full text-left">
|
||||
<div className="flex items-start gap-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium line-clamp-1">
|
||||
{note.title || (isInternal ? 'Internal Note' : 'Customer Note')}
|
||||
</p>
|
||||
{!open && body && <p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">{body}</p>}
|
||||
</div>
|
||||
{open ? <ChevronDown className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" /> : <ChevronRight className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" />}
|
||||
</div>
|
||||
</button>
|
||||
{open && (
|
||||
<div className={`mt-2 rounded-lg border p-3 ${isInternal ? 'bg-slate-50 border-slate-200' : 'bg-amber-50 border-amber-100'}`}>
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1 mb-1">
|
||||
<User className="w-3 h-3" />{note.author} · {isInternal ? 'Internal' : 'Client-visible'}
|
||||
</p>
|
||||
<p className="text-sm whitespace-pre-wrap">{body}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(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 (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
if (error || !data) return <div className="p-4 text-sm text-destructive">{error ?? 'Not found'}</div>;
|
||||
|
||||
const { ticket, timeline, total_hours } = data;
|
||||
const priClass = PRIORITY_COLOR[ticket.priority] ?? 'text-slate-600 bg-slate-50 border-slate-200';
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Ticket header */}
|
||||
<div className="px-4 pt-4 pb-3 border-b">
|
||||
<button onClick={() => router.back()} className="flex items-center gap-1 text-sm text-muted-foreground mb-3 hover:text-foreground">
|
||||
<ArrowLeft className="w-4 h-4" /> Back
|
||||
</button>
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<span className={`shrink-0 text-xs font-medium px-2 py-0.5 rounded-full border ${priClass}`}>
|
||||
{PRIORITY_LABEL[ticket.priority] ?? `P${ticket.priority}`}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full font-mono">
|
||||
{ticket.ticket_number}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
|
||||
{STATUS_LABEL[ticket.status] ?? `Status ${ticket.status}`}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-base font-bold leading-snug">{ticket.title}</h1>
|
||||
<div className="mt-2 space-y-1">
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<Briefcase className="w-3.5 h-3.5" />{ticket.company_name}
|
||||
</p>
|
||||
{ticket.assigned_to && (
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<User className="w-3.5 h-3.5" />{ticket.assigned_to} · {ticket.queue_label ?? '—'}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<Clock className="w-3.5 h-3.5" />Created {fmtDate(ticket.create_date)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-3 gap-2 mt-3">
|
||||
<div className="rounded-lg bg-muted/50 p-2 text-center">
|
||||
<p className="text-base font-bold">{timeline.filter(i => i.kind === 'note').length}</p>
|
||||
<p className="text-[10px] text-muted-foreground">Notes</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 p-2 text-center">
|
||||
<p className="text-base font-bold">{timeline.filter(i => i.kind === 'time').length}</p>
|
||||
<p className="text-[10px] text-muted-foreground">Time entries</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 p-2 text-center">
|
||||
<p className="text-base font-bold">{total_hours}h</p>
|
||||
<p className="text-[10px] text-muted-foreground">Hours logged</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="px-4 pt-5">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-4">Timeline</p>
|
||||
{timeline.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">No activity recorded</p>
|
||||
) : (
|
||||
timeline.map((item, i) => (
|
||||
<TimelineCard key={i} item={item} defaultOpen={item.kind === 'note' && i === timeline.length - 1} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
164
app/mobile/tickets/page.tsx
Normal file
164
app/mobile/tickets/page.tsx
Normal file
|
|
@ -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<number, string> = {
|
||||
1: 'bg-red-500', 2: 'bg-orange-400', 3: 'bg-yellow-400', 4: 'bg-slate-300',
|
||||
};
|
||||
const PRIORITY_LABEL: Record<number, string> = {
|
||||
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<Ticket[]>([]);
|
||||
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 (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Search + filter bar */}
|
||||
<div className="px-4 pt-4 pb-3 space-y-2 sticky top-0 bg-background z-10 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tickets, company…"
|
||||
value={search}
|
||||
onChange={e => 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 && (
|
||||
<button onClick={() => setSearch('')} className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<X className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 overflow-x-auto pb-0.5 scrollbar-none">
|
||||
{(['', '1', '2', '3', '4'] as const).map(p => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPriority(p)}
|
||||
className={`shrink-0 px-3 py-1 rounded-full text-xs font-medium border transition-colors ${
|
||||
priority === p
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'border-border hover:bg-accent'
|
||||
}`}
|
||||
>
|
||||
{p === '' ? 'All' : PRIORITY_LABEL[parseInt(p)]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{total} open tickets</p>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-40">
|
||||
<RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : tickets.length === 0 ? (
|
||||
<div className="text-center py-16 text-sm text-muted-foreground">No tickets found</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="divide-y">
|
||||
{tickets.map(t => (
|
||||
<Link key={t.id} href={`/mobile/tickets/${t.id}`}
|
||||
className="flex items-start gap-3 px-4 py-3.5 hover:bg-accent transition-colors active:bg-accent">
|
||||
<div className={`mt-1.5 w-2.5 h-2.5 rounded-full shrink-0 ${PRIORITY_DOT[t.priority] ?? 'bg-slate-400'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-semibold leading-snug line-clamp-2">{t.title}</p>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">{t.company_name}</p>
|
||||
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
||||
<span className="text-[10px] bg-muted rounded px-1.5 py-0.5 font-mono">{t.ticket_number}</span>
|
||||
{t.queue_label && (
|
||||
<span className="text-[10px] text-muted-foreground">{t.queue_label}</span>
|
||||
)}
|
||||
<span className="text-[10px] text-muted-foreground flex items-center gap-0.5 ml-auto">
|
||||
<Clock className="w-3 h-3" />
|
||||
{relTime(t.last_activity_date)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<div className="p-4">
|
||||
<button
|
||||
onClick={() => load(page + 1, true)}
|
||||
disabled={loadingMore}
|
||||
className="w-full py-3 rounded-xl border text-sm font-medium hover:bg-accent transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loadingMore ? 'Loading…' : `Load more (${total - tickets.length} remaining)`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue