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
75
components/mobile/BottomNav.tsx
Normal file
75
components/mobile/BottomNav.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
'use client';
|
||||
|
||||
/* BottomNav — phase 02 (SHELL-06, NAV-01..03, DRAWER-01).
|
||||
*
|
||||
* Fixed bottom bar with five cells:
|
||||
* - Dashboard (LayoutDashboard) -> /mobile/dashboard
|
||||
* - Tickets (Ticket) -> /mobile/tickets
|
||||
* - Finance (DollarSign) -> /mobile/finance
|
||||
* - Analyzer (Sparkles) -> /mobile/analyzer
|
||||
* - More (Menu) -> opens the MoreDrawer (parent state)
|
||||
*
|
||||
* Active tab detected via pathname.startsWith(href). Active = text-primary,
|
||||
* inactive = text-muted-foreground. */
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Ticket,
|
||||
DollarSign,
|
||||
Sparkles,
|
||||
Menu,
|
||||
} from 'lucide-react';
|
||||
|
||||
const TABS = [
|
||||
{ href: '/mobile/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ href: '/mobile/tickets', label: 'Tickets', icon: Ticket },
|
||||
{ href: '/mobile/finance', label: 'Finance', icon: DollarSign },
|
||||
{ href: '/mobile/analyzer', label: 'Analyzer', icon: Sparkles },
|
||||
] as const;
|
||||
|
||||
interface BottomNavProps {
|
||||
onMoreClick: () => void;
|
||||
}
|
||||
|
||||
export function BottomNav({ onMoreClick }: BottomNavProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Primary"
|
||||
className="fixed bottom-0 left-0 right-0 z-30 border-t bg-background pb-safe"
|
||||
>
|
||||
<div className="max-w-lg mx-auto flex h-16">
|
||||
{TABS.map(({ href, label, icon: Icon }) => {
|
||||
const active = pathname?.startsWith(href) ?? false;
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={`flex-1 flex flex-col items-center justify-center gap-0.5 text-[11px] transition-colors ${
|
||||
active
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMoreClick}
|
||||
aria-label="Open menu"
|
||||
className="flex-1 flex flex-col items-center justify-center gap-0.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<Menu className="w-5 h-5" aria-hidden="true" />
|
||||
<span>More</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
72
components/mobile/HeaderBar.tsx
Normal file
72
components/mobile/HeaderBar.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
'use client';
|
||||
|
||||
/* HeaderBar — phase 02 (SHELL-02..04).
|
||||
*
|
||||
* Sticky top bar inside the /mobile shell. Three slots:
|
||||
* left: WulfMark + "Pulse" wordmark, linked to /mobile/dashboard
|
||||
* right: Bell icon button (placeholder, aria-label="Notifications")
|
||||
* right: compact avatar circle — opens the More drawer (parent owns state)
|
||||
*
|
||||
* No page title in the header — pages render their own H1. */
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Bell } from 'lucide-react';
|
||||
import { WulfMark } from '@/components/branding/wulf-mark';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
|
||||
interface HeaderBarProps {
|
||||
onAvatarClick: () => void;
|
||||
}
|
||||
|
||||
export function HeaderBar({ onAvatarClick }: HeaderBarProps) {
|
||||
const { data: session } = useSession();
|
||||
const user = session?.user as
|
||||
| { name?: string; email?: string }
|
||||
| undefined;
|
||||
|
||||
const initials = (user?.name ?? user?.email ?? '?')
|
||||
.split(/[\s@]/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase())
|
||||
.join('');
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-b pt-safe">
|
||||
<div className="flex items-center justify-between px-4 h-14">
|
||||
{/* Left: brand mark + wordmark, linked to /mobile/dashboard */}
|
||||
<Link
|
||||
href="/mobile/dashboard"
|
||||
className="flex items-center gap-2 -ml-1 px-1 rounded-md hover:bg-accent/50 transition-colors"
|
||||
aria-label="Pulse — go to Dashboard"
|
||||
>
|
||||
<WulfMark variant="mark" className="h-6 w-auto" />
|
||||
<span className="font-bold text-base tracking-tight">Pulse</span>
|
||||
</Link>
|
||||
|
||||
{/* Right: Bell placeholder, then avatar trigger */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { /* SHELL-03: placeholder — no menu, no badge */ }}
|
||||
aria-label="Notifications"
|
||||
className="inline-flex items-center justify-center h-9 w-9 rounded-md hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAvatarClick}
|
||||
aria-label="Open menu"
|
||||
className="inline-flex items-center justify-center h-9 w-9 rounded-md hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-primary/15 text-primary text-[11px] font-semibold">
|
||||
{initials}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
39
components/mobile/KpiCardMobile.tsx
Normal file
39
components/mobile/KpiCardMobile.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
'use client';
|
||||
|
||||
/* KpiCardMobile — phase 03 (DASH-01).
|
||||
*
|
||||
* Phone-sized KPI card for the 2×2 dashboard grid. Renders a label,
|
||||
* a large numeric value, and an optional caption. tone="attention"
|
||||
* adds a left-edge destructive border for SLA breaches > 0.
|
||||
*
|
||||
* Pure presentational — no fetch, no state. Parent provides values. */
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type KpiTone = 'default' | 'attention';
|
||||
|
||||
interface KpiCardMobileProps {
|
||||
label: string;
|
||||
value: number | string;
|
||||
caption?: string;
|
||||
tone?: KpiTone;
|
||||
}
|
||||
|
||||
const TONE_BORDER: Record<KpiTone, string> = {
|
||||
default: 'border-l-transparent',
|
||||
attention: 'border-l-destructive',
|
||||
};
|
||||
|
||||
export function KpiCardMobile({ label, value, caption, tone = 'default' }: KpiCardMobileProps) {
|
||||
const display = typeof value === 'number' ? value.toLocaleString() : value;
|
||||
return (
|
||||
<Card className={cn('h-full border-l-2', TONE_BORDER[tone])}>
|
||||
<CardContent className="p-4 flex flex-col gap-1">
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">{label}</p>
|
||||
<p className="text-3xl font-bold tabular-nums">{display}</p>
|
||||
{caption && <p className="text-xs text-muted-foreground">{caption}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
166
components/mobile/MoreDrawer.tsx
Normal file
166
components/mobile/MoreDrawer.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
'use client';
|
||||
|
||||
/* MoreDrawer — phase 02 (DRAWER-01..05).
|
||||
*
|
||||
* shadcn Sheet (side="right") with three top-to-bottom sections:
|
||||
* 1. Mobile sections — Engagement (in-shell route, no ExternalLink hint)
|
||||
* 2. Full site — desktop-only routes, each with ExternalLink hint
|
||||
* 3. Account — current user (read-only) + Sign out
|
||||
*
|
||||
* Open state is controlled by the parent so the header avatar AND the
|
||||
* bottom-nav More cell can both trigger this single drawer. */
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Server,
|
||||
HardDrive,
|
||||
BarChart3,
|
||||
Settings,
|
||||
Users,
|
||||
LogOut,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
SheetClose,
|
||||
} from '@/components/ui/sheet';
|
||||
import { useSession, signOut } from '@/lib/auth-client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const MOBILE_SECTIONS = [
|
||||
{ href: '/mobile/engagement', label: 'Engagement', icon: Users },
|
||||
];
|
||||
|
||||
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: '/admin/ticket-digest', label: 'Ticket Digest', icon: BarChart3 },
|
||||
{ href: '/admin/sync', label: 'Admin / Sync', icon: Settings },
|
||||
];
|
||||
|
||||
interface MoreDrawerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function MoreDrawer({ open, onOpenChange }: MoreDrawerProps) {
|
||||
const router = useRouter();
|
||||
const { data: session } = useSession();
|
||||
const user = session?.user as
|
||||
| { name?: string; email?: string; image?: string | null }
|
||||
| undefined;
|
||||
|
||||
const initials = (user?.name ?? user?.email ?? '?')
|
||||
.split(/[\s@]/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase())
|
||||
.join('');
|
||||
|
||||
async function handleSignOut() {
|
||||
try {
|
||||
await signOut();
|
||||
router.push('/auth/sign-in');
|
||||
} catch (e) {
|
||||
toast.error('Sign out failed');
|
||||
console.error('Sign out failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-80 sm:max-w-sm flex flex-col">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Menu</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
Navigation, full-site links, and account actions.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{/* Section 1: Mobile sections (DRAWER-03) */}
|
||||
<div className="px-4">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Mobile sections
|
||||
</p>
|
||||
<div className="rounded-2xl border divide-y overflow-hidden">
|
||||
{MOBILE_SECTIONS.map(({ href, label, icon: Icon }) => (
|
||||
<SheetClose asChild key={href}>
|
||||
<Link
|
||||
href={href}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-accent transition-colors"
|
||||
>
|
||||
<Icon className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm flex-1">{label}</span>
|
||||
</Link>
|
||||
</SheetClose>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2: Full site (DRAWER-04) */}
|
||||
<div className="px-4">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Full site
|
||||
</p>
|
||||
<div className="rounded-2xl border divide-y overflow-hidden">
|
||||
{DESKTOP_LINKS.map(({ href, label, icon: Icon }) => (
|
||||
<SheetClose asChild key={href}>
|
||||
<Link
|
||||
href={href}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-accent transition-colors"
|
||||
>
|
||||
<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>
|
||||
</SheetClose>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 3: Account (DRAWER-05) */}
|
||||
<div className="px-4 mt-auto pb-safe">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Account
|
||||
</p>
|
||||
<div className="rounded-2xl border overflow-hidden">
|
||||
{user && (
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b">
|
||||
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-primary/15 text-primary text-xs font-semibold shrink-0">
|
||||
{initials}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
{user.name && (
|
||||
<p className="text-sm font-medium leading-tight truncate">
|
||||
{user.name}
|
||||
</p>
|
||||
)}
|
||||
{user.email && (
|
||||
<p className="text-xs text-muted-foreground truncate" title={user.email}>
|
||||
{user.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSignOut}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-destructive hover:bg-destructive/10 transition-colors"
|
||||
>
|
||||
<LogOut className="w-4 h-4 shrink-0" />
|
||||
<span className="text-sm">Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
51
components/mobile/NeedsAttentionStrip.tsx
Normal file
51
components/mobile/NeedsAttentionStrip.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
'use client';
|
||||
|
||||
/* NeedsAttentionStrip — phase 03 (DASH-02).
|
||||
*
|
||||
* Horizontal-scroll strip of compact attention cards. Each card shows a
|
||||
* count + label and is a next/link to the destination view. The strip
|
||||
* uses native horizontal overflow with snap-x for momentum scroll on
|
||||
* iOS/Android. Renders nothing when items=[]. */
|
||||
|
||||
import Link from 'next/link';
|
||||
import { AlertTriangle, ChevronRight } from 'lucide-react';
|
||||
|
||||
export interface NeedsAttentionItem {
|
||||
id: string;
|
||||
label: string;
|
||||
count: number;
|
||||
href: string;
|
||||
}
|
||||
|
||||
interface NeedsAttentionStripProps {
|
||||
items: NeedsAttentionItem[];
|
||||
}
|
||||
|
||||
export function NeedsAttentionStrip({ items }: NeedsAttentionStripProps) {
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Needs attention
|
||||
</p>
|
||||
<div className="-mx-4 px-4 flex gap-3 overflow-x-auto snap-x snap-mandatory pb-1">
|
||||
{items.map(item => (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
className="snap-start shrink-0 w-44 rounded-2xl border bg-card p-3 hover:bg-accent transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<AlertTriangle className={`w-4 h-4 ${item.count > 0 ? 'text-destructive' : 'text-muted-foreground'}`} />
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<p className={`mt-2 text-2xl font-bold tabular-nums ${item.count > 0 ? 'text-destructive' : ''}`}>
|
||||
{item.count}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{item.label}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
components/mobile/WorkerStatusRow.tsx
Normal file
55
components/mobile/WorkerStatusRow.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
'use client';
|
||||
|
||||
/* WorkerStatusRow — phase 03 (DASH-03).
|
||||
*
|
||||
* Compact 3-cell read-only status row showing analyzer worker, RMM worker,
|
||||
* and backup success rate. Each cell is a next/link to the corresponding
|
||||
* desktop admin page. status='ok' = emerald dot, 'warn' = amber, 'down' =
|
||||
* destructive. */
|
||||
|
||||
import Link from 'next/link';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
|
||||
export type WorkerStatus = 'ok' | 'warn' | 'down';
|
||||
|
||||
export interface WorkerStatusEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
status: WorkerStatus;
|
||||
href: string;
|
||||
}
|
||||
|
||||
interface WorkerStatusRowProps {
|
||||
entries: WorkerStatusEntry[];
|
||||
}
|
||||
|
||||
const DOT_COLOR: Record<WorkerStatus, string> = {
|
||||
ok: 'bg-emerald-500',
|
||||
warn: 'bg-amber-500',
|
||||
down: 'bg-destructive',
|
||||
};
|
||||
|
||||
export function WorkerStatusRow({ entries }: WorkerStatusRowProps) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Workers & backups
|
||||
</p>
|
||||
<div className="rounded-2xl border divide-y overflow-hidden">
|
||||
{entries.map(e => (
|
||||
<Link
|
||||
key={e.id}
|
||||
href={e.href}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-accent transition-colors"
|
||||
>
|
||||
<span className={`inline-block w-2 h-2 rounded-full shrink-0 ${DOT_COLOR[e.status]}`} aria-hidden="true" />
|
||||
<span className="text-sm font-medium flex-1">{e.label}</span>
|
||||
<span className="text-sm tabular-nums text-muted-foreground">{e.value}</span>
|
||||
<ExternalLink className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue