fix(04-01): restore phase 2/3 work lost by worktree soft-reset
The soft reset to 77073ba inadvertently staged deletions of all phase 2
and 3 artifacts. This commit restores them from their source commits so
subsequent task commits build on the complete prior-phase foundation:
- components/mobile/{BottomNav,HeaderBar,KpiCardMobile,MoreDrawer,NeedsAttentionStrip,WorkerStatusRow}
- app/mobile/layout.tsx, dashboard/page.tsx, analyzer/page.tsx
- app/api/mobile/dashboard/route.ts
- All .planning/** files from phases 01-04
- CLAUDE.md, app/layout.tsx, app/styles/brand.css, public/manifest.json
This commit is contained in:
parent
6268d1fe37
commit
9658640c04
50 changed files with 9587 additions and 395 deletions
|
|
@ -1,115 +1,214 @@
|
|||
/**
|
||||
* GET /api/mobile/dashboard
|
||||
* Single round-trip returning the three sections consumed by the new mobile
|
||||
* dashboard layout: 4 KPIs, 3 Needs Attention items, 3 worker/backup status
|
||||
* entries.
|
||||
*
|
||||
* All ticket counts exclude out-of-scope companies (company_scope filter,
|
||||
* same idiom as /api/dashboard/overview).
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
async function getMobileClassFilter(): Promise<string> {
|
||||
try {
|
||||
const result = await postgresClient.query(
|
||||
`SELECT setting_key, setting_value FROM kiosk_settings WHERE setting_key IN ('mobile_company_category_ids', 'mobile_excluded_company_ids')`
|
||||
);
|
||||
const map: Record<string, string> = {};
|
||||
result.rows.forEach((r: any) => { map[r.setting_key] = r.setting_value || ''; });
|
||||
// ─── Response shape ──────────────────────────────────────────────────────────
|
||||
|
||||
const catIds = (map['mobile_company_category_ids'] || '1')
|
||||
.split(',').map((s: string) => parseInt(s.trim(), 10)).filter((n: number) => !isNaN(n));
|
||||
const exclIds = (map['mobile_excluded_company_ids'] || '')
|
||||
.split(',').map((s: string) => parseInt(s.trim(), 10)).filter((n: number) => !isNaN(n));
|
||||
|
||||
const catCond = catIds.length > 0 ? `c.company_category_id IN (${catIds.join(',')})` : 'true';
|
||||
const exclCond = exclIds.length > 0 ? `c.id NOT IN (${exclIds.join(',')})` : '';
|
||||
return [catCond, exclCond].filter(Boolean).join(' AND ');
|
||||
} catch (error) {
|
||||
console.error('Error fetching mobile company filter:', error);
|
||||
return 'c.company_category_id = 1';
|
||||
}
|
||||
export interface KpiResponse {
|
||||
id: 'open_total' | 'opened_today' | 'resolved_today' | 'sla_breaches';
|
||||
label: string;
|
||||
value: number;
|
||||
caption?: string;
|
||||
tone?: 'default' | 'attention';
|
||||
}
|
||||
|
||||
export interface AttentionResponse {
|
||||
id: 'overdue_tickets' | 'failed_backups' | 'stalled_workflows';
|
||||
label: string;
|
||||
count: number;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface WorkerResponse {
|
||||
id: 'analyzer' | 'rmm' | 'backup_success_rate';
|
||||
label: string;
|
||||
value: string;
|
||||
status: 'ok' | 'warn' | 'down';
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface MobileDashboardResponse {
|
||||
kpis: KpiResponse[];
|
||||
needsAttention: AttentionResponse[];
|
||||
workers: WorkerResponse[];
|
||||
}
|
||||
|
||||
// ─── Handler ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function GET() {
|
||||
const classFilter = await getMobileClassFilter();
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const [byStatus, byQueue, byPriority, recentActivity, sla] = await Promise.all([
|
||||
postgresClient.query(`
|
||||
SELECT t.status, COUNT(*) as count
|
||||
FROM tickets t
|
||||
INNER JOIN companies c ON c.id = t.company_id AND ${classFilter}
|
||||
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
|
||||
INNER JOIN companies c ON c.id = t.company_id AND ${classFilter}
|
||||
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, p.label, COUNT(*) as count
|
||||
FROM tickets t
|
||||
INNER JOIN companies c ON c.id = t.company_id AND ${classFilter}
|
||||
LEFT JOIN priorities p ON p.value = t.priority
|
||||
WHERE t.status != 5 AND t.is_deleted = false
|
||||
GROUP BY t.priority, p.label ORDER BY count DESC
|
||||
`),
|
||||
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
|
||||
INNER JOIN companies c ON c.id = t.company_id AND ${classFilter}
|
||||
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 t.first_response_date_time IS NOT NULL
|
||||
AND EXTRACT(EPOCH FROM (t.first_response_date_time - t.create_date))/3600 <= 1) as resp_met,
|
||||
COUNT(*) FILTER (WHERE t.first_response_date_time IS NOT NULL) as resp_total,
|
||||
COUNT(*) FILTER (WHERE t.resolved_date_time IS NOT NULL
|
||||
AND EXTRACT(EPOCH FROM (t.resolved_date_time - t.create_date))/3600 <= 24) as res_met,
|
||||
COUNT(*) FILTER (WHERE t.resolved_date_time IS NOT NULL) as res_total
|
||||
FROM tickets t
|
||||
INNER JOIN companies c ON c.id = t.company_id AND ${classFilter}
|
||||
WHERE t.create_date >= NOW() - INTERVAL '30 days' AND t.is_deleted = false
|
||||
`),
|
||||
]);
|
||||
try {
|
||||
const [
|
||||
kpiRes,
|
||||
failedBackupsRes,
|
||||
stalledWorkflowsRes,
|
||||
analyzerRes,
|
||||
rmmRes,
|
||||
backupSuccessRes,
|
||||
] = await Promise.all([
|
||||
/* 1. KPI snapshot — four counts in one row, scoped companies excluded */
|
||||
postgresClient.query<{
|
||||
open_total: string;
|
||||
opened_today: string;
|
||||
resolved_today: string;
|
||||
sla_breaches: string;
|
||||
}>(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE completed_date IS NULL)::text AS open_total,
|
||||
COUNT(*) FILTER (WHERE create_date::date = CURRENT_DATE)::text AS opened_today,
|
||||
COUNT(*) FILTER (WHERE completed_date::date = CURRENT_DATE)::text AS resolved_today,
|
||||
COUNT(*) FILTER (
|
||||
WHERE completed_date IS NULL
|
||||
AND due_date_time IS NOT NULL
|
||||
AND due_date_time < NOW()
|
||||
)::text AS sla_breaches
|
||||
FROM tickets
|
||||
WHERE (is_deleted = false OR is_deleted IS NULL)
|
||||
AND company_id NOT IN (SELECT company_id FROM company_scope WHERE in_scope = 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',
|
||||
};
|
||||
/* 2. Failed backups in the last 24 hours (Needs Attention) */
|
||||
postgresClient.query<{ count: string }>(`
|
||||
SELECT COUNT(*)::text AS count FROM (
|
||||
SELECT 1 FROM veeam_backup_jobs
|
||||
WHERE last_run >= NOW() - INTERVAL '24 hours'
|
||||
AND is_enabled = true AND status = 'Failed'
|
||||
UNION ALL
|
||||
SELECT 1 FROM veeam_backup_agent_jobs
|
||||
WHERE last_run >= NOW() - INTERVAL '24 hours'
|
||||
AND is_enabled = true AND status = 'Failed'
|
||||
) f
|
||||
`),
|
||||
|
||||
const slaRow = sla.rows[0];
|
||||
/* 3. Stalled workflow executions — pending > 5 minutes (Needs Attention) */
|
||||
postgresClient.query<{ count: string }>(`
|
||||
SELECT COUNT(*)::text AS count
|
||||
FROM workflow_executions
|
||||
WHERE status = 'pending' AND created_at < NOW() - INTERVAL '5 minutes'
|
||||
`),
|
||||
|
||||
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: r.label ?? `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),
|
||||
},
|
||||
});
|
||||
/* 4. Analyzer worker — in-flight + recent failures */
|
||||
postgresClient.query<{ in_flight: string; fail_1h: string }>(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (
|
||||
WHERE status IN ('queued','fetching','triaging','itglue','analyzing','deep_review')
|
||||
)::text AS in_flight,
|
||||
COUNT(*) FILTER (WHERE status = 'failed' AND finished_at >= NOW() - INTERVAL '1 hour')::text AS fail_1h
|
||||
FROM analyzer_jobs
|
||||
`),
|
||||
|
||||
/* 5. RMM worker — in-flight + recent failures */
|
||||
postgresClient.query<{ in_flight: string; fail_1h: string }>(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE status IN ('queued','running'))::text AS in_flight,
|
||||
COUNT(*) FILTER (WHERE status IN ('failed','timeout') AND completed_at >= NOW() - INTERVAL '1 hour')::text AS fail_1h
|
||||
FROM rmm_executions
|
||||
`),
|
||||
|
||||
/* 6. Backup success rate (24h) — same calculation as /api/veeam/backup-status */
|
||||
postgresClient.query<{ success: string; total: string }>(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE status = 'Success')::text AS success,
|
||||
COUNT(*)::text AS total
|
||||
FROM (
|
||||
SELECT status FROM veeam_backup_jobs
|
||||
WHERE last_run >= NOW() - INTERVAL '24 hours' AND is_enabled = true
|
||||
UNION ALL
|
||||
SELECT status FROM veeam_backup_agent_jobs
|
||||
WHERE last_run >= NOW() - INTERVAL '24 hours' AND is_enabled = true
|
||||
) j
|
||||
`),
|
||||
]);
|
||||
|
||||
// ── Build KPIs ────────────────────────────────────────────────────────────
|
||||
const kpiRow = kpiRes.rows[0];
|
||||
const openTotal = parseInt(kpiRow?.open_total ?? '0', 10);
|
||||
const openedToday = parseInt(kpiRow?.opened_today ?? '0', 10);
|
||||
const resolvedToday = parseInt(kpiRow?.resolved_today ?? '0', 10);
|
||||
const slaBreaches = parseInt(kpiRow?.sla_breaches ?? '0', 10);
|
||||
|
||||
const kpis: KpiResponse[] = [
|
||||
{ id: 'open_total', label: 'Open total', value: openTotal, tone: 'default' },
|
||||
{ id: 'opened_today', label: 'Opened today', value: openedToday, tone: 'default' },
|
||||
{ id: 'resolved_today', label: 'Resolved today', value: resolvedToday, tone: 'default' },
|
||||
{ id: 'sla_breaches', label: 'SLA breaches', value: slaBreaches, tone: slaBreaches > 0 ? 'attention' : 'default' },
|
||||
];
|
||||
|
||||
// ── Build Needs Attention ─────────────────────────────────────────────────
|
||||
const failedBackups = parseInt(failedBackupsRes.rows[0]?.count ?? '0', 10);
|
||||
const stalledWorkflows = parseInt(stalledWorkflowsRes.rows[0]?.count ?? '0', 10);
|
||||
|
||||
const needsAttention: AttentionResponse[] = [
|
||||
{ id: 'overdue_tickets', label: 'Overdue tickets', count: slaBreaches, href: '/tickets?overdue=true' },
|
||||
{ id: 'failed_backups', label: 'Failed backups (24h)', count: failedBackups, href: '/backup-status' },
|
||||
{ id: 'stalled_workflows', label: 'Stalled workflows', count: stalledWorkflows, href: '/admin/workflow' },
|
||||
];
|
||||
|
||||
// ── Build Workers ─────────────────────────────────────────────────────────
|
||||
const aRow = analyzerRes.rows[0];
|
||||
const analyzerInFlight = parseInt(aRow?.in_flight ?? '0', 10);
|
||||
const analyzerFail1h = parseInt(aRow?.fail_1h ?? '0', 10);
|
||||
let analyzerStatus: 'ok' | 'warn' | 'down' = 'ok';
|
||||
if (analyzerFail1h > 0 && analyzerInFlight === 0) analyzerStatus = 'down';
|
||||
else if (analyzerFail1h > 0) analyzerStatus = 'warn';
|
||||
|
||||
const rRow = rmmRes.rows[0];
|
||||
const rmmInFlight = parseInt(rRow?.in_flight ?? '0', 10);
|
||||
const rmmFail1h = parseInt(rRow?.fail_1h ?? '0', 10);
|
||||
let rmmStatus: 'ok' | 'warn' | 'down' = 'ok';
|
||||
if (rmmFail1h > 0 && rmmInFlight === 0) rmmStatus = 'down';
|
||||
else if (rmmFail1h > 0) rmmStatus = 'warn';
|
||||
|
||||
const bRow = backupSuccessRes.rows[0];
|
||||
const bSuccess = parseInt(bRow?.success ?? '0', 10);
|
||||
const bTotal = parseInt(bRow?.total ?? '0', 10);
|
||||
const backupPct = bTotal > 0 ? Math.round((bSuccess / bTotal) * 1000) / 10 : 100;
|
||||
let backupStatus: 'ok' | 'warn' | 'down' = 'ok';
|
||||
if (backupPct < 80) backupStatus = 'down';
|
||||
else if (backupPct < 95) backupStatus = 'warn';
|
||||
|
||||
const workers: WorkerResponse[] = [
|
||||
{
|
||||
id: 'analyzer',
|
||||
label: 'Analyzer',
|
||||
value: `${analyzerInFlight} in flight`,
|
||||
status: analyzerStatus,
|
||||
href: '/admin/analytics',
|
||||
},
|
||||
{
|
||||
id: 'rmm',
|
||||
label: 'RMM Overshell',
|
||||
value: `${rmmInFlight} in flight`,
|
||||
status: rmmStatus,
|
||||
href: '/admin/rmm-overshell',
|
||||
},
|
||||
{
|
||||
id: 'backup_success_rate',
|
||||
label: 'Backup success (24h)',
|
||||
value: `${backupPct}%`,
|
||||
status: backupStatus,
|
||||
href: '/backup-status',
|
||||
},
|
||||
];
|
||||
|
||||
return NextResponse.json<MobileDashboardResponse>({ kpis, needsAttention, workers });
|
||||
} catch (e) {
|
||||
console.error('[/api/mobile/dashboard] failed:', e);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to load dashboard', message: e instanceof Error ? e.message : 'Unknown error' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { IBM_Plex_Sans, IBM_Plex_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
|
|
@ -28,6 +28,7 @@ const plexMono = IBM_Plex_Mono({
|
|||
export const metadata: Metadata = {
|
||||
title: "Pulse · Operations console",
|
||||
description: "Wulf Consulting operations console — tickets, RMM, IT Glue, backups, and analytics in one place.",
|
||||
manifest: "/manifest.json",
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/favicon.png", sizes: "any" },
|
||||
|
|
@ -38,6 +39,16 @@ export const metadata: Metadata = {
|
|||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#FFFFFF" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#0A0A0A" },
|
||||
],
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
|
|
|
|||
33
app/mobile/analyzer/page.tsx
Normal file
33
app/mobile/analyzer/page.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/* Placeholder for /mobile/analyzer.
|
||||
*
|
||||
* Phase 02 only adds the Analyzer tab to the bottom nav — the real feed
|
||||
* lands in Phase 6 (`docs/superpowers/specs/2026-05-03-mobile-shell-design.md`
|
||||
* §6.4). This file exists so tapping the Analyzer tab resolves to a real
|
||||
* route instead of 404. Phase 6 will replace this file with the actual
|
||||
* read-only feed page.
|
||||
*
|
||||
* DO NOT add features, data fetching, or UI beyond the "Coming soon"
|
||||
* card here — Phase 6 owns the real implementation. */
|
||||
|
||||
import { Sparkles } from 'lucide-react';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Analyzer · Pulse',
|
||||
};
|
||||
|
||||
export default function MobileAnalyzerPlaceholder() {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="rounded-2xl border bg-card p-6 flex flex-col items-center text-center gap-3">
|
||||
<div className="h-12 w-12 rounded-2xl bg-primary/10 text-primary flex items-center justify-center">
|
||||
<Sparkles className="h-6 w-6" />
|
||||
</div>
|
||||
<h1 className="text-lg font-semibold">Analyzer feed coming soon</h1>
|
||||
<p className="text-sm text-muted-foreground max-w-xs">
|
||||
The mobile Analyzer feed is on its way. Until then, view full
|
||||
analyses on the desktop Analyzer.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,191 +1,118 @@
|
|||
'use client';
|
||||
|
||||
/* /mobile/dashboard — phase 03 (DASH-01..04).
|
||||
*
|
||||
* Three sections, top-to-bottom:
|
||||
* 1. 2×2 KPI grid (DASH-01)
|
||||
* 2. Needs Attention (DASH-02)
|
||||
* 3. Worker/backup row (DASH-03)
|
||||
*
|
||||
* No charts on phone widths (DASH-04). Header + bottom nav are provided
|
||||
* by app/mobile/layout.tsx; this page only renders the H1 and body. */
|
||||
|
||||
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-slate-300', // Standard
|
||||
2: 'bg-slate-300', // Medium
|
||||
3: 'bg-slate-300', // Standard
|
||||
4: 'bg-red-500', // Critical
|
||||
6: 'bg-orange-400', // High
|
||||
7: 'bg-purple-500', // Security Event
|
||||
8: 'bg-yellow-400', // Minor Service
|
||||
9: 'bg-orange-500', // Major Service
|
||||
10: 'bg-blue-400', // Installation
|
||||
11: 'bg-pink-400', // Fast Track
|
||||
};
|
||||
const PRIORITY_TEXT: Record<number, string> = {
|
||||
1: 'text-slate-500', // Standard
|
||||
2: 'text-slate-500', // Medium
|
||||
3: 'text-slate-500', // Standard
|
||||
4: 'text-red-600', // Critical
|
||||
6: 'text-orange-500', // High
|
||||
7: 'text-purple-600', // Security Event
|
||||
8: 'text-yellow-600', // Minor Service
|
||||
9: 'text-orange-600', // Major Service
|
||||
10: 'text-blue-600', // Installation
|
||||
11: 'text-pink-600', // Fast Track
|
||||
};
|
||||
|
||||
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`;
|
||||
}
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { KpiCardMobile } from '@/components/mobile/KpiCardMobile';
|
||||
import { NeedsAttentionStrip } from '@/components/mobile/NeedsAttentionStrip';
|
||||
import { WorkerStatusRow } from '@/components/mobile/WorkerStatusRow';
|
||||
import type { MobileDashboardResponse } from '@/app/api/mobile/dashboard/route';
|
||||
|
||||
export default function MobileDashboard() {
|
||||
const [data, setData] = useState<DashboardData | null>(null);
|
||||
const [data, setData] = useState<MobileDashboardResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true); setError(null);
|
||||
async function load() {
|
||||
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); }
|
||||
};
|
||||
if (!r.ok) {
|
||||
const body = (await r.json().catch(() => ({}))) as { error?: string; message?: string };
|
||||
throw new Error(body.message ?? body.error ?? `HTTP ${r.status}`);
|
||||
}
|
||||
setData((await r.json()) as MobileDashboardResponse);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unknown error');
|
||||
} 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);
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
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" />
|
||||
<h1 className="text-xl font-bold">Dashboard</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
aria-label="Refresh dashboard"
|
||||
className="p-2 rounded-full hover:bg-accent disabled:opacity-40"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</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" />
|
||||
{error && !loading && (
|
||||
<div className="rounded-xl border border-destructive/50 bg-destructive/5 p-4">
|
||||
<p className="text-sm font-medium text-destructive">Failed to load</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={load}
|
||||
className="mt-3 text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</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>
|
||||
))}
|
||||
{loading && !data && (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</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>
|
||||
{data && (
|
||||
<>
|
||||
{/* DASH-01: 2×2 KPI grid */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{data.kpis.map(kpi => (
|
||||
<KpiCardMobile
|
||||
key={kpi.id}
|
||||
label={kpi.label}
|
||||
value={kpi.value}
|
||||
caption={kpi.caption}
|
||||
tone={kpi.tone ?? 'default'}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
{/* DASH-02: Needs Attention horizontal strip */}
|
||||
<NeedsAttentionStrip
|
||||
items={data.needsAttention.map(a => ({
|
||||
id: a.id,
|
||||
label: a.label,
|
||||
count: a.count,
|
||||
href: a.href,
|
||||
}))}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
{/* DASH-03: Worker/backup status row */}
|
||||
<WorkerStatusRow
|
||||
entries={data.workers.map(w => ({
|
||||
id: w.id,
|
||||
label: w.label,
|
||||
value: w.value,
|
||||
status: w.status,
|
||||
href: w.href,
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,50 +1,37 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { LayoutDashboard, Ticket, DollarSign, Menu } from 'lucide-react';
|
||||
/* Mobile shell — phase 02 (SHELL-01, SHELL-05).
|
||||
*
|
||||
* Header: <HeaderBar /> (sticky, brand + Bell + avatar)
|
||||
* Body: <main> (scrollable, padded so content clears the bottom nav)
|
||||
* Foot: <BottomNav /> (fixed, 4 tabs + More)
|
||||
* Drawer: <MoreDrawer /> opened from BOTH the header avatar and the More cell.
|
||||
*
|
||||
* The drawer's open state lives here so a single Sheet instance is shared
|
||||
* between the two triggers — no duplicate Sheets, no prop-drilling sagas. */
|
||||
|
||||
const NAV = [
|
||||
{ href: '/mobile/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ href: '/mobile/tickets', label: 'Tickets', icon: Ticket },
|
||||
{ href: '/mobile/finance', label: 'Finance', icon: DollarSign },
|
||||
];
|
||||
import { useState } from 'react';
|
||||
import { HeaderBar } from '@/components/mobile/HeaderBar';
|
||||
import { BottomNav } from '@/components/mobile/BottomNav';
|
||||
import { MoreDrawer } from '@/components/mobile/MoreDrawer';
|
||||
|
||||
export default function MobileLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
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">
|
||||
<Link href="/mobile" className="font-bold text-lg tracking-tight">Pulse</Link>
|
||||
<Link href="/mobile/nav" className="p-1.5 rounded-lg hover:bg-accent transition-colors" aria-label="Navigation menu">
|
||||
<Menu className="w-5 h-5" />
|
||||
</Link>
|
||||
</header>
|
||||
<HeaderBar onAvatarClick={() => setDrawerOpen(true)} />
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 overflow-y-auto pb-20">
|
||||
{/* SHELL-05: scrollable content area; bottom padding = bottom-nav (h-16
|
||||
= 64px = pb-16) plus the device safe-area inset, so content never
|
||||
hides under the bar. */}
|
||||
<main className="flex-1 overflow-y-auto pb-[calc(theme(spacing.16)+env(safe-area-inset-bottom))]">
|
||||
{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>
|
||||
<BottomNav onMoreClick={() => setDrawerOpen(true)} />
|
||||
|
||||
<MoreDrawer open={drawerOpen} onOpenChange={setDrawerOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,91 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
LayoutDashboard, Ticket, DollarSign, ArrowLeft,
|
||||
ExternalLink, Settings, LogOut, ChevronRight,
|
||||
FileText, Server, HardDrive, Users, BarChart3,
|
||||
} from 'lucide-react';
|
||||
import { signOut } from '@/lib/auth-client';
|
||||
|
||||
const MAIN_SECTIONS = [
|
||||
{ href: '/mobile/dashboard', label: 'Dashboard', icon: LayoutDashboard, description: 'Overview & stats', color: 'bg-blue-500/10 text-blue-600 dark:text-blue-400' },
|
||||
{ href: '/mobile/tickets', label: 'Tickets', icon: Ticket, description: 'Open service tickets', color: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' },
|
||||
{ href: '/mobile/finance', label: 'Finance', icon: DollarSign, description: 'AR, invoices & payments', color: 'bg-violet-500/10 text-violet-600 dark:text-violet-400' },
|
||||
];
|
||||
|
||||
const DESKTOP_LINKS = [
|
||||
{ href: '/quotes', label: 'Quotes', icon: FileText },
|
||||
{ href: '/configuration-items', label: 'Configuration Items', icon: Server },
|
||||
{ href: '/backup-status', label: 'Backup Status', icon: HardDrive },
|
||||
{ href: '/engagement', label: 'Engagement', icon: Users },
|
||||
{ href: '/admin/ticket-digest', label: 'Ticket Digest', icon: BarChart3 },
|
||||
{ href: '/admin/sync', label: 'Admin / Sync', icon: Settings },
|
||||
];
|
||||
|
||||
export default function MobileNav() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-6 pb-24">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="p-2 rounded-xl hover:bg-accent transition-colors"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<h1 className="text-lg font-bold">Navigation</h1>
|
||||
</div>
|
||||
|
||||
{/* Main mobile sections */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Mobile Views</p>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{MAIN_SECTIONS.map(({ href, label, icon: Icon, description, color }) => (
|
||||
<Link key={href} href={href}
|
||||
className="flex items-center gap-4 p-4 rounded-2xl border bg-card hover:bg-accent transition-colors active:scale-[0.98]">
|
||||
<div className={`w-11 h-11 rounded-xl flex items-center justify-center shrink-0 ${color.split(' ')[0]}`}>
|
||||
<Icon className={`w-5 h-5 ${color.split(' ').slice(1).join(' ')}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold">{label}</p>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop links */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Full Site</p>
|
||||
<div className="rounded-2xl border divide-y overflow-hidden">
|
||||
{DESKTOP_LINKS.map(({ href, label, icon: Icon }) => (
|
||||
<Link key={href} href={href}
|
||||
className="flex items-center gap-3 px-4 py-3.5 hover:bg-accent transition-colors active:bg-accent">
|
||||
<Icon className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm flex-1">{label}</span>
|
||||
<ExternalLink className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sign out */}
|
||||
<div className="rounded-2xl border overflow-hidden">
|
||||
<button
|
||||
onClick={() => signOut().then(() => router.push('/auth/sign-in'))}
|
||||
className="w-full flex items-center gap-3 px-4 py-3.5 hover:bg-destructive/10 text-destructive transition-colors"
|
||||
>
|
||||
<LogOut className="w-4 h-4 shrink-0" />
|
||||
<span className="text-sm">Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -139,6 +139,29 @@
|
|||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* === Safe-area insets =================================================
|
||||
*
|
||||
* Opt-in padding helpers for sticky top / fixed bottom bars on devices
|
||||
* with notches, dynamic islands, or gesture home indicators. Pair with
|
||||
* the viewport-fit=cover viewport meta (set in app/layout.tsx) — without
|
||||
* that, env(safe-area-inset-*) resolves to 0 and these utilities are
|
||||
* no-ops, which is the desired fallback on non-PWA / non-mobile contexts.
|
||||
*
|
||||
* Usage:
|
||||
* <header class="sticky top-0 pt-safe ..."> // header clears notch
|
||||
* <nav class="fixed bottom-0 pb-safe ..."> // bottom bar clears home bar
|
||||
*
|
||||
* Closes PWA-04 (REQUIREMENTS.md) and ROADMAP Phase 1 SC #3.
|
||||
* ==================================================================== */
|
||||
|
||||
@utility pt-safe {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
@utility pb-safe {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
/* === Wolf-mark watermark ============================================
|
||||
*
|
||||
* Apply .has-mark-watermark to a positioned container; place a child
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue