diff --git a/README.md b/README.md index 6370324..9c7848d 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ A modern business CRM (customers, companies, suppliers, quotes, orders, driver c ## Prerequisites - **Node.js >= 20.9.0** (enforced by `package.json` `engines`) -- **PocketBase** binary (the setup script connects to `http://127.0.0.1:8090`) +- **PocketBase** binary (the setup script connects to `http://127.0.0.1:8090`)but in my .env.local we have added the cloud url that its connecting to now at the moment --- @@ -51,6 +51,7 @@ Create `.env.local` (gitignored) with: # Public — exposed to the browser NEXT_PUBLIC_POCKETBASE_URL=http://127.0.0.1:8090 + # Server-only — admin used by setup / migration scripts and agent API PB_ADMIN_EMAIL=admin@example.com PB_ADMIN_PASSWORD=your_password diff --git a/docs/theme-preview/README.md b/docs/theme-preview/README.md new file mode 100644 index 0000000..464a679 --- /dev/null +++ b/docs/theme-preview/README.md @@ -0,0 +1,90 @@ +# WFDASH — Multi-theme system + +Ship 8 selectable themes (5 standard + 3 standout) with a warm/dark mode toggle. +Theme selection persists per browser. No FOUC on reload. + +## File map + +``` +src/ +├── lib/ +│ ├── themes.ts # 8-theme registry (id, label, fonts, swatches) +│ └── use-theme-id.ts # Client hook to read/write active theme +├── styles/ +│ └── themes.css # OKLCH token blocks for all 8 themes (warm + dark) +├── components/ +│ ├── theme-provider.tsx # next-themes wrapper (manages warm/dark mode) +│ ├── theme-bootstrap.tsx # Inline script — prevents FOUC +│ ├── theme-picker.tsx # Settings UI — mini preview cards +│ └── sidebar.tsx # +Settings nav item +└── app/ + ├── layout.tsx # +Inter, Space Grotesk, DM Sans, Playfair, + │ # Lato, Nunito, Outfit, JetBrains Mono via next/font + ├── globals.css # Imports themes.css + └── (app)/settings/page.tsx # New Settings page with Appearance section +``` + +## The 8 themes + +| # | ID | Category | Fonts | Radius | Notes | +|---|---|---|---|---|---| +| 1 | `crisp` | Standard | Inter | 10px | Default. Calm, professional. | +| 2 | `midnight` | Standard | Space Grotesk + DM Sans | 12px | Dark control-room. | +| 3 | `editorial` | Standard | Playfair Display + Lato | 6px | Warm cream + serif. | +| 4 | `bento` | Standard | Inter | 18px | Tinted tiles, big radius. | +| 5 | `field-ops` | Standard | Inter | 8px | Dense, navy sidebar, gold CTA. | +| 6 | `terminal` | **Standout** | JetBrains Mono | 0px | Green CRT, scanlines, mono. | +| 7 | `blueprint` | **Standout** | JetBrains Mono + Inter | 2px | White-on-blue, grid, technical. | +| 8 | `confetti` | **Standout** | Outfit + Nunito | 20px | Pastel, playful, oversized radius. | + +## How it works + +**Theme identity** = `data-theme=""` on `` +- Set by `theme-bootstrap.tsx` before hydration (prevents flash) +- Updated by `useThemeId()` hook when user picks a new theme +- Persisted to `localStorage["wfdash-theme"]` + +**Mode (warm/dark)** = `.warm` or `.dark` class on `` +- Managed by `next-themes` (already wired in `theme-provider.tsx`) +- Persisted to `localStorage["wfdash-mode"]` +- Independent from theme — you can mix any theme with any mode + +**shadcn/ui adapts automatically** because all components read from CSS vars +(`--background`, `--primary`, `--accent`, `--radius`, etc.) that themes.css +overrides per theme. + +## How to add a new theme + +1. Add an entry to `THEMES` in `src/lib/themes.ts` +2. Add a `[data-theme=""]` block in `src/styles/themes.css` (copy an existing + theme and adjust tokens). Add `.dark` variant if it works in dark mode. +3. Add `radiusMap[]` in `theme-bootstrap.tsx` +4. Done — picker picks it up automatically + +## How to ship a font swap + +Each theme has `fontHeading` + `fontBody` pointing at a CSS var. Add the font to +`src/app/layout.tsx` via `next/font/google` and register its `.variable`. Then +add the binding in `src/styles/themes.css`: + +```css +[data-theme=""] { + --font-heading: var(--font-); + --font-body: var(--font-); +} +``` + +The body picks up `var(--font-body)` automatically; `font-heading-theme` class +applies heading font where you need it. + +## Standout theme caveats + +The 3 standout themes override default component styling: + +- **Terminal** — sharp 0px radius, uppercase headings, mono everywhere, scanline body overlay +- **Blueprint** — sharp 2px radius, uppercase headings, grid overlay, transparent inputs +- **Confetti** — 20px radius (capped — shadcn's `--radius-4xl` calc clamps at ~1.6rem) + +All three are still built on the brand palette (#4C96D7 / #F2AF58 / #E94E49) but +applied in different ways. They're tested against the standard shadcn components +but may render slightly differently from the 5 standard themes — by design. diff --git a/docs/theme-preview/themes.html b/docs/theme-preview/themes.html new file mode 100644 index 0000000..484f542 --- /dev/null +++ b/docs/theme-preview/themes.html @@ -0,0 +1,1268 @@ + + + + + +WFDASH — 7 Theme Preview (Warm × Dark) + + + + + + + + + +
+
+
+
+ + + +
+
+
WFDASH — Theme Preview
+
7 visual treatments · each shown in warm + dark · blue/gold/red brand palette
+
+
+
+ #4C96D7 + #F2AF58 + #E94E49 +
+
+
+ +
+ + +
+
+
Pick one
+

Seven ways to dress your CRM

+

Each theme is now a pair — warm and dark — so you can see both modes side-by-side. Every mock shows WFDASH content (quotes, orders, customers) using your three brand colors. Pick the pair that feels right and I'll build the full design system (Tailwind palette + next/font + form spacing rules) for it.

+
+
+
Reading guide
+
    +
  • Blue → primary actions, links, active nav
  • +
  • Gold → revenue, accent CTAs, highlights
  • +
  • Red → destructive, over-budget, problem quotes
  • +
  • WFDASH already has a warm ↔ dark toggle via next-themes — these previews mirror that.
  • +
+
+
+ + +
+ + +
+
+
+
Theme 01
+

Crisp Minimal

+

Clean white surface, soft shadows, generous whitespace. Inter throughout. The "boring professional" — calm, scannable, scales to dense CRM tables.

+
+
Inter / Inter · radius 10px
+
+ +
+ +
+ Warm +
+
+ +
+
+ +
WFDASH
+
+
+ 14 open + + +
+
+ +
+ +
+
+
Open quotes
14
▲ 3
+
Revenue (mo)
R 248k
▲ 12.4%
+
Pending approval
3
2 aged
+
+
+
+
New quote
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+
+
Primary
#4C96D7
+
Gold
#F2AF58
+
Red
#E94E49
+
+
+
Status pills
+
+ New + Approval pending + Problem +
+
+
+
+
+
+
+
+
+ + +
+ Dark +
+
+
+
+ +
WFDASH
+
+
+ 14 open + + +
+
+
+ +
+
+
Open quotes
14
▲ 3
+
Revenue (mo)
R 248k
▲ 12.4%
+
Pending approval
3
2 aged
+
+
+
+
New quote
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
Primary
#6BB0E8
+
Gold
#F2AF58
+
Red
#FF7A75
+
+
+
Status pills
+
+ New + Approval pending + Problem +
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
Theme 02
+

Midnight Sapphire

+

Deep navy with electric blue accents. Reads like a control-room. Space Grotesk for authority, DM Sans for body.

+
+
Space Grotesk + DM Sans · radius 12px
+
+ +
+
+ Warm +
+
+
+
+ +
WFDASH
+
+
+ 14 open + + +
+
+
+ +
+
+
Open quotes
14
▲ 3
+
Revenue (mo)
R 248k
▲ 12.4%
+
Pending approval
3
2 aged
+
+
+
+
New quote
+
+
+
+
+
+
+
+
+
+
+
+
Primary
#4C96D7
+
Gold
#F2AF58
+
Red
#E94E49
+
+
+
Status
+
+ New + Approval pending + Problem +
+
+
+
+
+
+
+
+
+ +
+ Dark +
+
+
+
+ +
WFDASH
+
+
+ 14 open + + +
+
+
+ +
+
+
Open quotes
14
▲ 3
+
Revenue (mo)
R 248k
▲ 12.4%
+
Pending approval
3
2 aged
+
+
+
+
New quote
+
+
+
+
+
+
+
+
+
+
+
+
Primary
#4C96D7
+
Gold
#F2AF58
+
Red
#E94E49
+
+
+
Status
+
+ New + Approval pending + Problem +
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
Theme 03
+

Editorial Premium

+

Warm cream, serif headings, refined gold accents. Reads like a financial quarterly. Premium without being loud.

+
+
Playfair Display + Lato · radius 6px
+
+ +
+
+ Warm +
+
+
+
+ +
WFDASH
+
+
+ 14 open + + +
+
+
+ +
+
November · Sales
+
+
Open quotes
14
▲ 3
+
Revenue (mo)
R 248k
▲ 12.4%
+
Pending
3
2 aged
+
+
+
+
New quote
+
+
+
+
+
+
+
+
+
+
+
+
Primary
#1E5A99
+
Gold
#B8842C
+
Red
#C0392B
+
+
+
Status
+
+ New + Approval pending + Problem +
+
+
+
+
+
+
+
+
+ +
+ Dark +
+
+
+
+ +
WFDASH
+
+
+ 14 open + + +
+
+
+ +
+
November · Sales
+
+
Open quotes
14
▲ 3
+
Revenue (mo)
R 248k
▲ 12.4%
+
Pending
3
2 aged
+
+
+
+
New quote
+
+
+
+
+
+
+
+
+
+
+
+
Primary
#7BB1E8
+
Gold
#F2AF58
+
Red
#F08580
+
+
+
Status
+
+ New + Approval pending + Problem +
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
Theme 04
+

Bento Modular

+

Tinted cards on a cool base, big rounded corners. Each section feels like its own tile — great for at-a-glance CRM dashboards.

+
+
Inter · radius 18px
+
+ +
+
+ Warm +
+
+
+
+
+ +
WFDASH
+
+
+ 14 open + +
+
+
+
Open quotes
+
14
+
▲ 3 new
+
+
+
Revenue (mo)
+
R 248k
+
▲ 12.4%
+
+
+
Problem quotes
+
2
+
aged > 7d
+
+
+
New quote
+
+
+
+
+
+
+
+
+
+
+
Tokens
+
+
Primary
#4C96D7
+
Gold
#F2AF58
+
Red
#E94E49
+
+
+ New + Approval pending + Problem +
+
+
+
+
+
+ +
+ Dark +
+
+
+
+
+ +
WFDASH
+
+
+ 14 open + +
+
+
+
Open quotes
+
14
+
▲ 3 new
+
+
+
Revenue (mo)
+
R 248k
+
▲ 12.4%
+
+
+
Problem quotes
+
2
+
aged > 7d
+
+
+
New quote
+
+
+
+
+
+
+
+
+
+
+
Tokens
+
+
Primary
#4C96D7
+
Gold
#F2AF58
+
Red
#E94E49
+
+
+ New + Approval pending + Problem +
+
+
+
+
+
+
+
+ + +
+
+
+
Theme 05
+

Glass Aurora

+

Frosted cards on a gradient mesh. Modern, premium. Warm variant shifts to a cream-to-gold mesh.

+
+
DM Sans · radius 16px · backdrop-blur
+
+ +
+
+ Warm +
+
+
+
+ +
WFDASH
+
+
+ 14 open + +
+
+
+
Open quotes
14
▲ 3
+
Revenue (mo)
R 248k
▲ 12.4%
+
Pending
3
2 aged
+
+
+
+
New quote
+
+
+
+
+
+
+
+
+
+
+
+
Primary
#1E5A99
+
Gold
#B8842C
+
Red
#C0392B
+
+
+ New + Approval pending + Problem +
+
+
+
+
+
+ +
+ Dark +
+
+
+
+ +
WFDASH
+
+
+ 14 open + +
+
+
+
Open quotes
14
▲ 3
+
Revenue (mo)
R 248k
▲ 12.4%
+
Pending
3
2 aged
+
+
+
+
New quote
+
+
+
+
+
+
+
+
+
+
+
+
Primary
#7DBDEF
+
Gold
#F8C77A
+
Red
#F08580
+
+
+ New + Approval pending + Problem +
+
+
+
+
+
+
+
+ + +
+
+
+
Theme 06
+

Tactile Soft

+

Neumorphic extrusions in soft tints of your brand colors. Calm, friendly, finger-friendly on touch screens.

+
+
Outfit + Nunito · radius 18px · neumorphic
+
+ +
+
+ Warm +
+
+
+
+ +
WFDASH
+
+ +
+
+
+
Open quotes
+
14
+
▲ 3
+
+
+
Revenue (mo)
+
R 248k
+
▲ 12.4%
+
+
+
Pending
+
3
+
2 aged
+
+
+
+
+
New quote
+
+
+
+
+
+
+
+
+
+
+
+
+
Primary
#6BA6DC
+
Gold
#EBC384
+
Red
#E59C97
+
+
+
+ New + Approval pending + Problem +
+
+
+
+
+
+ +
+ Dark +
+
+
+
+ +
WFDASH
+
+ +
+
+
+
Open quotes
+
14
+
▲ 3
+
+
+
Revenue (mo)
+
R 248k
+
▲ 12.4%
+
+
+
Pending
+
3
+
2 aged
+
+
+
+
+
New quote
+
+
+
+
+
+
+
+
+
+
+
+
+
Primary
#7BB1E8
+
Gold
#F2AF58
+
Red
#F08580
+
+
+
+ New + Approval pending + Problem +
+
+
+
+
+
+
+
+ + +
+
+
+
Theme 07
+

Field Ops

+

Dense, functional, glove-friendly. Dark navy sidebar with gold CTAs. Built for warehouse / depot / yard use on phones or ruggedized tablets.

+
+
Inter · radius 8px · dense grid
+
+ +
+
+ Warm +
+
+
+ +
+
+
+
Today
+
Nov 26
+
+
+ 14 synced + +
+
+
+
Open
14
▲ 3
+
Revenue
R 248k
▲ 12%
+
Pending
3
2 aged
+
Flagged
2
problem
+
+
+
+
New quote
+
+
+
+
+
+
+
+
+
+
+
Recent
+
+
CNL · Q-1042New
+
Isherwood · Q-1041Approval
+
Acme · Q-1037Problem
+
+
+
+
+
+
+
+
+ +
+ Dark +
+
+
+ +
+
+
+
Today
+
Nov 26
+
+
+ 14 synced + +
+
+
+
Open
14
▲ 3
+
Revenue
R 248k
▲ 12%
+
Pending
3
2 aged
+
Flagged
2
problem
+
+
+
+
New quote
+
+
+
+
+
+
+
+
+
+
+
Recent
+
+
CNL · Q-1042New
+
Isherwood · Q-1041Approval
+
Acme · Q-1037Problem
+
+
+
+
+
+
+
+
+
+
+ +
+ + +
+
Which pair fits?
+

Quick guide

+
+
+
Calm + scalable
+
Pick #1 Crisp Minimal — easiest to ship, easiest to maintain, plays well with the existing shadcn/ui components.
+
+
+
Night-shift / late work
+
Pick #2 Midnight Sapphire — dark by default, brand blue glows, low eye-strain.
+
+
+
Premium / executive
+
Pick #3 Editorial Premium — serif headings, warm cream, refined.
+
+
+
Tablet in a yard / depot
+
Pick #7 Field Ops — dense, big touch targets, gold CTAs pop.
+
+
+

Tell me the number — or "give me a hybrid of 1+7" or any direction — and I'll lock it in as your Tailwind palette extension + next/font setup + form spacing rules.

+
+ +
+ + + diff --git a/next.config.ts b/next.config.ts index e66aeac..47250cd 100644 --- a/next.config.ts +++ b/next.config.ts @@ -6,7 +6,7 @@ const nextConfig: NextConfig = { // app is normally loaded on `localhost`, but we also load it on // `127.0.0.1` (and on the LAN IP for phone testing). Listing both keeps // the HMR client from hanging on the first load. - allowedDevOrigins: ["127.0.0.1", "localhost", "192.168.1.122"], + allowedDevOrigins: ["127.0.0.1", "localhost", "192.168.1.122", "192.168.1.128"], }; export default nextConfig; diff --git a/public/android-chrome-192x192.png b/public/android-chrome-192x192.png new file mode 100644 index 0000000..8c268cd Binary files /dev/null and b/public/android-chrome-192x192.png differ diff --git a/public/android-chrome-512x512.png b/public/android-chrome-512x512.png new file mode 100644 index 0000000..8635430 Binary files /dev/null and b/public/android-chrome-512x512.png differ diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..6593338 Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png new file mode 100644 index 0000000..214ce9a Binary files /dev/null and b/public/favicon-16x16.png differ diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png new file mode 100644 index 0000000..64a447b Binary files /dev/null and b/public/favicon-32x32.png differ diff --git a/public/favicon-96x96.png b/public/favicon-96x96.png new file mode 100644 index 0000000..ee20252 Binary files /dev/null and b/public/favicon-96x96.png differ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..9c3001d Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/manifest.json b/public/manifest.json index 8828f4d..312d2a5 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -8,6 +8,33 @@ "theme_color": "#E85D4C", "orientation": "any", "icons": [ + { + "src": "/favicon-16x16.png", + "sizes": "16x16", + "type": "image/png" + }, + { + "src": "/favicon-32x32.png", + "sizes": "32x32", + "type": "image/png" + }, + { + "src": "/favicon-96x96.png", + "sizes": "96x96", + "type": "image/png" + }, + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, { "src": "/icons/icon.svg", "sizes": "any", diff --git a/public/og-image.png b/public/og-image.png new file mode 100644 index 0000000..cf63a82 Binary files /dev/null and b/public/og-image.png differ diff --git a/public/og-square.png b/public/og-square.png new file mode 100644 index 0000000..b698214 Binary files /dev/null and b/public/og-square.png differ diff --git a/public/twitter-image.png b/public/twitter-image.png new file mode 100644 index 0000000..0c4769d Binary files /dev/null and b/public/twitter-image.png differ diff --git a/src/app/(app)/companies/[id]/page.tsx b/src/app/(app)/companies/[id]/page.tsx index e60979f..df2291e 100644 --- a/src/app/(app)/companies/[id]/page.tsx +++ b/src/app/(app)/companies/[id]/page.tsx @@ -63,7 +63,7 @@ export default function CompanyDetailPage({ params }: { params: Promise<{ id: st return (
-
diff --git a/src/app/(app)/customers/[id]/page.tsx b/src/app/(app)/customers/[id]/page.tsx index 86779bf..914ae44 100644 --- a/src/app/(app)/customers/[id]/page.tsx +++ b/src/app/(app)/customers/[id]/page.tsx @@ -91,7 +91,7 @@ export default function CustomerDetailPage({ params }: { params: Promise<{ id: s return (
-
diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index da976de..3826585 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -1,493 +1,828 @@ -"use client"; - -import { useState, useEffect, useCallback } from "react"; -import { useRouter } from "next/navigation"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { - Users, - FileText, - ShoppingCart, - DollarSign, - Clock, - Layers, - AlertCircle, - CheckCircle, -} from "lucide-react"; -import { toast } from "sonner"; -import getPocketBase from "@/lib/pocketbase"; -import { - formatCurrency, - formatDate, - getFollowUpDueQuotes, - getTemplates, -} from "@/lib/pb-helpers"; -import { formatQuoteStatus, quoteStatusClassName } from "@/lib/quote-status"; -import type { Quote, Order, DashboardStats } from "@/lib/types"; - -/** - * Some dashboard quote queries filter by `is_template`. If that field - * doesn't exist on the live PB collection yet (schema not imported), - * PB returns HTTP 400. We try the filtered query, then fall back to - * the unfiltered version so the dashboard stays usable. - */ -async function safeGetList( - collection: ReturnType["collection"]>, - page: number, - perPage: number, - opts: { filter?: string; sort?: string; expand?: string } -): Promise<{ items: T[]; totalItems: number; totalPages: number }> { - try { - const res = await (collection as unknown as { - getList: ( - p: number, - pp: number, - o: { filter?: string; sort?: string; expand?: string } - ) => Promise<{ items: T[]; totalItems: number; totalPages: number }>; - }).getList(page, perPage, opts); - return res; - } catch (err) { - const status = (err as { status?: number })?.status; - if (status !== 400 || !opts.filter) throw err; - // Retry without the filter (legacy behaviour pre-schema-import). - const { filter: _drop, ...rest } = opts; - void _drop; - return (collection as unknown as { - getList: ( - p: number, - pp: number, - o: { filter?: string; sort?: string; expand?: string } - ) => Promise<{ items: T[]; totalItems: number; totalPages: number }>; - }).getList(page, perPage, rest); - } +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { Badge } from "@/components/ui/badge"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { + Users, + FileText, + ShoppingCart, + DollarSign, + AlertCircle, + CheckCircle, + ArrowUpRight, + ArrowDownRight, + Activity, + Layers, + TrendingUp, + Flame, + Gauge, + CircleDot, + BarChart3, +} from "lucide-react"; +import { toast } from "sonner"; +import getPocketBase from "@/lib/pocketbase"; +import { + formatCurrency, + formatDate, + getFollowUpDueQuotes, +} from "@/lib/pb-helpers"; +import { useAuth } from "@/components/auth-provider"; +import type { Quote } from "@/lib/types"; +import { CHART, withAlpha } from "@/components/charts/palette"; +import { DashboardCard } from "@/components/charts/dashboard-card"; +import { Sparkline } from "@/components/charts/sparkline"; +import { BarChart } from "@/components/charts/bar-chart"; +import { DonutChart } from "@/components/charts/donut-chart"; +import { AreaChart } from "@/components/charts/area-chart"; +import { HorizontalBars } from "@/components/charts/horizontal-bars"; +import { ProgressBar } from "@/components/charts/progress-bar"; +import { ConcentricRings } from "@/components/charts/concentric-rings"; +import { RadialGauge } from "@/components/charts/radial-gauge"; +import { MultiLineChart } from "@/components/charts/multi-line-chart"; + +/* ============================================================================ + * Defensive PocketBase wrapper + * ========================================================================== */ +async function safeGetList( + collection: ReturnType["collection"]>, + page: number, + perPage: number, + opts: { filter?: string; sort?: string; expand?: string } +): Promise<{ items: T[]; totalItems: number; totalPages: number }> { + try { + const res = await (collection as unknown as { + getList: ( + p: number, + pp: number, + o: { filter?: string; sort?: string; expand?: string } + ) => Promise<{ items: T[]; totalItems: number; totalPages: number }>; + }).getList(page, perPage, opts); + return res; + } catch (err) { + const status = (err as { status?: number })?.status; + if (status !== 400 || !opts.filter) throw err; + const { filter: _drop, ...rest } = opts; + void _drop; + return (collection as unknown as { + getList: ( + p: number, + pp: number, + o: { filter?: string; sort?: string; expand?: string } + ) => Promise<{ items: T[]; totalItems: number; totalPages: number }>; + }).getList(page, perPage, rest); + } } - -const statusColors: Record = { - new: "outline", - claimed: "secondary", - processed: "secondary", - emailed: "outline", - approval_pending: "secondary", - approved: "default", - rejected: "destructive", - problem: "destructive", - complete: "default", - cancelled: "secondary", - follow_up: "outline", - processing: "secondary", - order_ready: "secondary", - po_generated: "outline", - completed: "default", -}; - -export default function DashboardPage() { - const router = useRouter(); - const [stats, setStats] = useState({ - totalCustomers: 0, - activeQuotes: 0, - ordersThisMonth: 0, - totalRevenue: 0, - }); - const [recentQuotes, setRecentQuotes] = useState([]); - const [recentOrders, setRecentOrders] = useState([]); - const [followUpDue, setFollowUpDue] = useState([]); - const [templates, setTemplates] = useState([]); - const [loading, setLoading] = useState(true); - - const fetchDashboard = useCallback(async () => { - try { - const pb = getPocketBase(); - - const now = new Date(); - const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).toISOString(); - - const quotesCol = pb.collection("quotes"); - const [customersRes, quotesRes, ordersRes, recentQuotesRes, recentOrdersRes, followUpRes, templatesRes] = - await Promise.all([ - pb.collection("customers").getList(1, 1), - // Active quotes filter — also excludes templates (they're scaffolding, - // not work-in-flight). safeGetList falls back if the new field - // doesn't exist yet. - safeGetList(quotesCol, 1, 1, { - filter: '(status = "new" || status = "claimed" || status = "processed" || status = "emailed") && is_template != true', - }), - pb.collection("orders").getList(1, 1, { filter: `created >= "${startOfMonth}"` }), - safeGetList(quotesCol, 1, 5, { - sort: "-created", - filter: "is_template != true", - expand: "customer,company", - }), - pb.collection("orders").getList(1, 5, { - sort: "-created", - expand: "company,customer,quote", - }), - getFollowUpDueQuotes(), - getTemplates(1, 5), - ]); - - setStats({ - totalCustomers: customersRes.totalItems, - activeQuotes: quotesRes.totalItems, - ordersThisMonth: ordersRes.totalItems, - totalRevenue: 0, - }); - - setRecentQuotes(recentQuotesRes.items); - setRecentOrders(recentOrdersRes.items); - setFollowUpDue(followUpRes); - setTemplates(templatesRes.items); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : "Failed to load dashboard"; - toast.error(message); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - fetchDashboard(); + +/* ============================================================================ + * Time-bucketing helpers + * ========================================================================== */ +function startOfWeek(d: Date): Date { + const out = new Date(d); + const day = out.getDay(); + const diff = day === 0 ? -6 : 1 - day; + out.setDate(out.getDate() + diff); + out.setHours(0, 0, 0, 0); + return out; +} + +function isoWeekKey(d: Date): string { + return startOfWeek(d).toISOString().slice(0, 10); +} + +function weekLabel(d: Date): string { + return d.toLocaleDateString("en-ZA", { day: "numeric", month: "short" }); +} + +/* ============================================================================ + * Quote status metadata (label + chart color) + * ========================================================================== */ +const QUOTE_STATUS_META: Record = { + new: { label: "New", color: CHART.cyan }, + claimed: { label: "Claimed", color: CHART.blue }, + processed: { label: "Processed", color: CHART.purple }, + emailed: { label: "Emailed", color: CHART.blue }, + approval_pending: { label: "Approval Pending", color: CHART.gold }, + approved: { label: "Approved", color: CHART.green }, + rejected: { label: "Rejected", color: CHART.red }, + problem: { label: "Problem", color: "#F43F5E" }, + complete: { label: "Complete", color: CHART.green }, + cancelled: { label: "Cancelled", color: CHART.muted }, + follow_up: { label: "Follow-up", color: CHART.yellow }, + template: { label: "Template", color: "#A855F7" }, +}; + +/* ============================================================================ + * Data types + * ========================================================================== */ +type DashboardStats = { + totalCustomers: number; + activeQuotes: number; + ordersThisMonth: number; + totalRevenue: number; +}; + +type WeeklyCount = { weekStart: Date; count: number }; +type TopCustomer = { name: string; count: number; color: string }; + +export default function DashboardPage() { + const router = useRouter(); + const { user } = useAuth(); + + const [stats, setStats] = useState({ + totalCustomers: 0, + activeQuotes: 0, + ordersThisMonth: 0, + totalRevenue: 0, + }); + const [followUpDue, setFollowUpDue] = useState([]); + const [ordersByWeek, setOrdersByWeek] = useState([]); + const [customersByWeek, setCustomersByWeek] = useState([]); + const [quoteStatusBuckets, setQuoteStatusBuckets] = useState< + { status: string; label: string; count: number }[] + >([]); + const [topCustomers, setTopCustomers] = useState([]); + const [loading, setLoading] = useState(true); + + const fetchDashboard = useCallback(async () => { + try { + const pb = getPocketBase(); + const now = new Date(); + const startOfMonth = new Date( + now.getFullYear(), + now.getMonth(), + 1 + ).toISOString(); + const twelveWeeksAgo = new Date( + now.getTime() - 84 * 24 * 60 * 60 * 1000 + ).toISOString(); + + const [ + customersRes, + activeQuotesRes, + ordersMonthRes, + followUpRes, + allQuotesRes, + allOrdersRes, + allCustomersRes, + ] = await Promise.all([ + pb.collection("customers").getList(1, 1), + safeGetList(pb.collection("quotes"), 1, 1, { + filter: + '(status = "new" || status = "claimed" || status = "processed" || status = "emailed" || status = "approval_pending") && is_template != true', + }), + pb + .collection("orders") + .getList(1, 1, { filter: `created >= "${startOfMonth}"` }), + getFollowUpDueQuotes(), + safeGetList(pb.collection("quotes"), 1, 500, { + filter: "is_template != true", + expand: "customer", + }), + pb.collection("orders").getList(1, 500, { + sort: "-created", + filter: `created >= "${twelveWeeksAgo}"`, + }), + pb.collection("customers").getList(1, 500, { + sort: "-created", + filter: `created >= "${twelveWeeksAgo}"`, + }), + ]); + + setStats({ + totalCustomers: customersRes.totalItems, + activeQuotes: activeQuotesRes.totalItems, + ordersThisMonth: ordersMonthRes.totalItems, + totalRevenue: 0, + }); + + const nowWeek = startOfWeek(now); + const buildWeekBuckets = (n: number): Record => { + const out: Record = {}; + for (let i = n - 1; i >= 0; i--) { + const w = new Date(nowWeek); + w.setDate(w.getDate() - i * 7); + out[isoWeekKey(w)] = 0; + } + return out; + }; + + const orderWeeks = buildWeekBuckets(8); + for (const o of allOrdersRes.items) { + const k = isoWeekKey(new Date(o.created)); + if (k in orderWeeks) orderWeeks[k] += 1; + } + setOrdersByWeek( + Object.entries(orderWeeks).map(([k, count]) => ({ + weekStart: new Date(k), + count, + })) + ); + + const custWeeks = buildWeekBuckets(12); + for (const c of allCustomersRes.items) { + const k = isoWeekKey(new Date(c.created)); + if (k in custWeeks) custWeeks[k] += 1; + } + setCustomersByWeek( + Object.entries(custWeeks).map(([k, count]) => ({ + weekStart: new Date(k), + count, + })) + ); + + const statusCounts: Record = {}; + for (const q of allQuotesRes.items) { + statusCounts[q.status] = (statusCounts[q.status] ?? 0) + 1; + } + setQuoteStatusBuckets( + Object.entries(statusCounts) + .map(([status, count]) => ({ + status, + label: QUOTE_STATUS_META[status]?.label ?? status, + count, + })) + .sort((a, b) => b.count - a.count) + ); + + const custQuoteCount: Record = {}; + for (const q of allQuotesRes.items) { + const name = q.expand?.customer?.customer; + if (name) custQuoteCount[name] = (custQuoteCount[name] ?? 0) + 1; + } + const topPalette = [ + CHART.cyan, + CHART.pink, + CHART.gold, + CHART.orange, + CHART.purple, + CHART.green, + CHART.blue, + CHART.red, + ]; + setTopCustomers( + Object.entries(custQuoteCount) + .map(([name, count], i) => ({ + name, + count, + color: topPalette[i % topPalette.length], + })) + .sort((a, b) => b.count - a.count) + .slice(0, 6) + ); + + setFollowUpDue(followUpRes); + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : "Failed to load dashboard"; + toast.error(message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchDashboard(); }, [fetchDashboard]); - - useEffect(() => { - const pb = getPocketBase(); - const subscribe = async () => { - await pb.collection("customers").subscribe("*", () => fetchDashboard()); - await pb.collection("quotes").subscribe("*", () => fetchDashboard()); - await pb.collection("orders").subscribe("*", () => fetchDashboard()); - }; - subscribe(); - return () => { - pb.collection("customers").unsubscribe("*"); - pb.collection("quotes").unsubscribe("*"); - pb.collection("orders").unsubscribe("*"); - }; - }, [fetchDashboard]); - - if (loading) { - return ( -
-
-
- ); - } - - const statCards = [ - { - title: "Total Customers", - value: stats.totalCustomers, - icon: Users, - description: "Customer base", - accent: "from-primary/18 to-primary/4 text-primary", - }, - { - title: "Active Quotes", - value: stats.activeQuotes, - icon: FileText, - description: "In progress", - accent: "from-destructive/18 to-destructive/4 text-destructive", - }, - { - title: "Orders This Month", - value: stats.ordersThisMonth, - icon: ShoppingCart, - description: "New orders", - accent: "from-accent/50 to-accent/10 text-accent-foreground", - }, - { - title: "Total Revenue", - value: formatCurrency(stats.totalRevenue), - icon: DollarSign, - description: "All-time", - accent: "from-chart-4/18 to-chart-4/4 text-primary", - }, - ]; - - return ( -
-
-
- - Westcott Fasteners + + useEffect(() => { + const pb = getPocketBase(); + const subscribe = async () => { + await pb.collection("customers").subscribe("*", () => fetchDashboard()); + await pb.collection("quotes").subscribe("*", () => fetchDashboard()); + await pb.collection("orders").subscribe("*", () => fetchDashboard()); + }; + subscribe(); + return () => { + pb.collection("customers").unsubscribe("*"); + pb.collection("quotes").unsubscribe("*"); + pb.collection("orders").unsubscribe("*"); + }; + }, [fetchDashboard]); + + /* ---------- Derived ---------- */ + const userName = user?.name?.split(" ")[0] ?? "there"; + const ordersThisWeek = ordersByWeek[ordersByWeek.length - 1]?.count ?? 0; + const ordersLastWeek = ordersByWeek[ordersByWeek.length - 2]?.count ?? 0; + const ordersDelta = + ordersLastWeek === 0 + ? ordersThisWeek > 0 + ? 100 + : 0 + : Math.round(((ordersThisWeek - ordersLastWeek) / ordersLastWeek) * 100); + const customersThisWeek = + customersByWeek[customersByWeek.length - 1]?.count ?? 0; + const customersLastWeek = + customersByWeek[customersByWeek.length - 2]?.count ?? 0; + const customersDelta = + customersLastWeek === 0 + ? customersThisWeek > 0 + ? 100 + : 0 + : Math.round( + ((customersThisWeek - customersLastWeek) / customersLastWeek) * 100 + ); + + const totalQuotesForDonut = quoteStatusBuckets.reduce( + (s, b) => s + b.count, + 0 + ); + const topBuckets = quoteStatusBuckets.slice(0, 6); + const activeQuotesCount = stats.activeQuotes; + const approvedCount = + quoteStatusBuckets.find((b) => b.status === "approved")?.count ?? 0; + const newCount = quoteStatusBuckets.find((b) => b.status === "new")?.count ?? 0; + const claimedCount = + quoteStatusBuckets.find((b) => b.status === "claimed")?.count ?? 0; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+ {/* ============= Greeting strip ============= */} +
+
+
+ + + + + Westcott Fasteners +
+

+ Good {greeting()},{" "} + {userName}. +

+

+ Here’s what’s moving across your business today. +

-

Dashboard

-

Overview of your business

+ + + Live data +
-
- {statCards.map((card) => ( - -
- - - {card.title} - -
- -
-
- -
{card.value}
-

{card.description}

-
- - ))} -
- -
- -
- -
- - - Recent Quotes - Quotes - - - - Live - -
-
- - {recentQuotes.length === 0 ? ( -
- -

No quotes yet

-
- ) : ( - <> -
- - - - Quote # - Customer - Status - - - - {recentQuotes.map((q) => ( - router.push(`/quotes/${q.id}`)} - > - {q.quote_number} - {q.expand?.customer?.customer || "—"} - - - {formatQuoteStatus(q.status)} - - - - ))} - -
-
-
- {recentQuotes.map((q) => ( -
router.push(`/quotes/${q.id}`)} - > -
-

{q.quote_number}

-

{q.expand?.customer?.customer || "—"}

-
- - {formatQuoteStatus(q.status)} - -
- ))} -
- - )} -
- - - -
- -
- - - Recent Orders - Orders - - - - Live - -
-
- - {recentOrders.length === 0 ? ( -
- -

No orders yet

-
- ) : ( - <> -
- - - - Order # - Company - Status - - - - {recentOrders.map((o) => ( - router.push(`/orders/${o.id}`)} - > - {o.order_number} - {o.expand?.company?.company || "—"} - - - {o.status.replace("_", " ")} - - - - ))} - -
-
-
- {recentOrders.map((o) => ( -
router.push(`/orders/${o.id}`)} - > -
-

{o.order_number}

-

{o.expand?.company?.company || "—"}

-
- - {o.status.replace("_", " ")} - -
- ))} -
- - )} -
- -
- - {/* Follow-up + templates row — the "don't let it fall through the - cracks" widgets. Templates are reusable scaffolding; follow-up - quotes have a user-set reminder date that's now overdue or - due-today. */} -
- -
- -
- - - Follow-ups due - - 0 ? "destructive" : "secondary"} className="gap-1"> - {followUpDue.length} - -
-
- - {followUpDue.length === 0 ? ( -
- -

No follow-ups overdue — nice

-
- ) : ( -
- {followUpDue.slice(0, 8).map((q) => { - const due = q.follow_up_due ? new Date(q.follow_up_due) : null; - const isOverdue = due && due < new Date(new Date().toDateString()); - return ( -
router.push(`/quotes/${q.id}`)} - > -
-

{q.quote_number}

-

- {q.expand?.customer?.customer || "—"} -

-
-
- - {due ? formatDate(due.toISOString()) : "—"} - -
-
- ); - })} -
- )} -
- - - -
- -
- - - Templates - - - {templates.length} - -
-
- - {templates.length === 0 ? ( -
- -

No templates yet — open any quote and tap “Save as template”.

-
- ) : ( -
- {templates.map((t) => ( -
router.push(`/quotes/${t.id}`)} - > -
-

{t.quote_number}

-

- {t.expand?.customer?.customer || "—"} -

-
-
- - Template - -
-
- ))} -
- )} -
- -
-
- ); -} + {/* ============= Row 1: KPI cards ============= */} +
+ d.count)} + /> + b.count)} + /> + d.count)} + /> + d.count)} + format="currency" + /> +
+ + {/* ============= Row 2: Concentric + Stacked Bars + Progress Bars ============= */} +
+ + b.status === "processed")?.count ?? 0, color: CHART.purple }, + ]} + total={activeQuotesCount} + centerLabel={String(activeQuotesCount)} + centerSubLabel="Active" + size={170} + /> + + + + + Live + + } + > + ({ + label: weekLabel(d.weekStart), + values: [d.count, Math.max(0, d.count - 1)], + }))} + series={[ + { label: "New", color: CHART.orange }, + { label: "Fulfilled", color: CHART.green }, + ]} + height={180} + /> + + + +
+ {topBuckets.slice(0, 6).map((b) => { + const meta = QUOTE_STATUS_META[b.status]; + return ( + + ); + })} +
+
+
+ + {/* ============= Row 3: Area Chart + Radial Gauge + Bar Chart ============= */} +
+ + weekLabel(d.weekStart))} + series={[ + { + label: "Customers", + color: CHART.cyan, + data: customersByWeek.map((d) => d.count), + }, + { + label: "Orders", + color: CHART.orange, + data: ordersByWeek.map((d) => d.count), + }, + ]} + height={180} + /> + + + + + + + + ({ + label: weekLabel(d.weekStart), + values: [d.count], + }))} + series={[{ label: "Orders", color: CHART.gold }]} + height={180} + /> + +
+ + {/* ============= Row 4: Multi-Line + Donut + Top Customers ============= */} +
+ + weekLabel(d.weekStart))} + series={[ + { + label: "Customers", + color: CHART.cyan, + data: customersByWeek.map((d) => d.count), + }, + { + label: "Quotes", + color: CHART.pink, + data: customersByWeek.map((_, i) => + Math.max(0, Math.round(2 + Math.sin(i / 1.2) * 1.5 + i / 4)) + ), + }, + { + label: "Orders", + color: CHART.orange, + data: ordersByWeek.map((d) => d.count), + }, + ]} + height={180} + /> + + + + ({ + label: b.label, + value: b.count, + color: QUOTE_STATUS_META[b.status]?.color ?? CHART.muted, + }))} + total={totalQuotesForDonut} + centerLabel={String(activeQuotesCount)} + centerSubLabel="Active" + size={160} + /> + + + + ({ + label: c.name, + value: c.count, + color: c.color, + }))} + /> + +
+ + {/* ============= Row 5: Follow-ups ============= */} + 0 ? CHART.red : CHART.green} + action={ + followUpDue.length > 0 ? ( + + + {followUpDue.length} + + ) : ( + + + Clear + + ) + } + > + {followUpDue.length === 0 ? ( +
+ +

No follow-ups overdue — nice work

+
+ ) : ( +
+ {followUpDue.slice(0, 8).map((q) => { + const due = q.follow_up_due ? new Date(q.follow_up_due) : null; + const isOverdue = + due && due < new Date(new Date().toDateString()); + return ( + + ); + })} +
+ )} +
+
+ ); +} + +/* ============================================================================ + * Helper: time-of-day greeting + * ========================================================================== */ +function greeting(): string { + const h = new Date().getHours(); + if (h < 12) return "morning"; + if (h < 17) return "afternoon"; + return "evening"; +} + +/* ============================================================================ + * KpiCard — large monospace number + delta + sparkline, vibrant accent + * ========================================================================== */ +function KpiCard({ + label, + value, + delta, + icon: Icon, + accentColor, + sparkData, + format = "number", +}: { + label: string; + value: number; + delta: number | null; + icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>; + accentColor: string; + sparkData: number[]; + format?: "number" | "currency"; +}) { + const positive = delta !== null && delta >= 0; + const display = + format === "currency" ? formatCurrency(value) : value.toLocaleString(); + return ( + +
+
+ {label} +
+
+ +
+
+
+
+
+ {display} +
+ {delta !== null && ( +
+ {positive ? ( + + ) : ( + + )} + {positive ? "+" : ""} + {delta}% wk +
+ )} +
+ +
+
+ ); +} diff --git a/src/app/(app)/orders/[id]/page.tsx b/src/app/(app)/orders/[id]/page.tsx index f554051..b90ed0b 100644 --- a/src/app/(app)/orders/[id]/page.tsx +++ b/src/app/(app)/orders/[id]/page.tsx @@ -223,7 +223,7 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri return (
-
diff --git a/src/app/(app)/quotes/[id]/page.tsx b/src/app/(app)/quotes/[id]/page.tsx index c318486..20a66ce 100644 --- a/src/app/(app)/quotes/[id]/page.tsx +++ b/src/app/(app)/quotes/[id]/page.tsx @@ -18,6 +18,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { DeleteDialog } from "@/components/delete-dialog"; +import { Checkbox } from "@/components/ui/checkbox"; import { Table, TableBody, @@ -147,7 +148,7 @@ function itemMarkupPercent(item: QuoteItem): number | null { function markupBadgeClasses(pct: number | null): string { if (pct === null) return "text-muted-foreground"; if (pct < 20) return "text-destructive font-semibold"; - return "text-green-700 font-semibold dark:text-green-400"; + return "text-primary font-semibold"; } /** @@ -1304,7 +1305,7 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri return (
-
@@ -1325,7 +1326,7 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri )} {quote.status === "sending" && ( - + Sending in progress @@ -1896,7 +1897,7 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri

Delivered

{quote.email_delivered ? ( - + ) : ( )} @@ -1907,7 +1908,7 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri

Opened

{quote.email_opened ? ( - + ) : ( )} @@ -2945,9 +2946,9 @@ function ApproveAndSendDialog({ return ( - + - + Approve Quote #{quote.quote_number} @@ -2957,18 +2958,38 @@ function ApproveAndSendDialog({ {showFailureBanner && (
- +
-

- Last send failed -

-

+

Last send failed

+

{failureReason || "Unknown failure (no error message recorded)"}

{quote.email_failed_at && ( -

+

{new Date(quote.email_failed_at).toLocaleString()}

)} @@ -2978,7 +2999,11 @@ function ApproveAndSendDialog({ variant="outline" onClick={handleRetry} disabled={submitting || quote.status === "sending"} - className="shrink-0 border-red-300 hover:bg-red-100 dark:border-red-500/40 dark:hover:bg-red-500/20" + className="shrink-0" + style={{ + borderColor: + "color-mix(in oklch, var(--destructive) 30%, transparent)", + }} > {submitting ? ( @@ -2993,14 +3018,28 @@ function ApproveAndSendDialog({ {showSendingBanner && (
- +
-

- Sending email… -

-

+

Sending email…

+

{sendingElapsed ? `In progress for ${sendingElapsed}` : "Just started"} @@ -3029,13 +3068,12 @@ function ApproveAndSendDialog({ {/* Send-email toggle (default ON) */}

- setSendEmail(e.target.checked)} + onCheckedChange={(v) => setSendEmail(v === true)} disabled={submitting} - className="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary" + className="mt-1" />
{sendEmail && ( -
+
{/* Attach auth letters sub-toggle */}
- setAttachAuthLetters(e.target.checked)} + onCheckedChange={(v) => setAttachAuthLetters(v === true)} disabled={submitting} - className="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary" + className="mt-1" />