first commit

This commit is contained in:
2026-09-16 20:11:27 +05:30
commit 87808621b9
81 changed files with 15969 additions and 0 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
node_modules
dist
dist-ssr
.git
.gitignore
*.tsbuildinfo
*.log
.DS_Store
.vscode
.idea
Dockerfile
.dockerignore
README.md

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

8
.oxlintrc.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

32
Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
# ---- Stage 1: build the Vite app ----
FROM node:22-alpine AS build
WORKDIR /app
# Install dependencies from the lockfile first so this layer is cached
# as long as package.json / package-lock.json are unchanged.
COPY package.json package-lock.json ./
RUN npm ci
# Build the static bundle into /app/dist
COPY . .
RUN npm run build
# ---- Stage 2: serve with Nginx ----
FROM nginx:alpine
# Drop Nginx's default "Welcome" page
RUN rm -rf /usr/share/nginx/html/*
# Vite outputs to "dist", not "build"
COPY --from=build /app/dist /usr/share/nginx/html
# Files under public/ keep their source permissions through the build, so
# normalize them: anything not world-readable is a 403 from the nginx worker.
RUN chmod -R a+rX /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80 3000
CMD ["nginx", "-g", "daemon off;"]

117
README.md Normal file
View File

@@ -0,0 +1,117 @@
# KROW Workforce — redesign demo
A complete redesign prototype of the KROW Workforce product with Owliver, the
workforce-intelligence panel, built in.
Everything is local. There is no backend, API, database, authentication or
outbound network call — all figures come from a single typed mock-data module.
## Running it
```bash
npm install
npm run dev # http://localhost:5173
npm run build # production build
npm run typecheck # tsc --noEmit
npm run lint # oxlint
```
## Stack
Vite · React 19 · TypeScript · Tailwind CSS v4 · shadcn/ui-style components on
Radix · Lucide · Recharts · TanStack Table v9 · React Router.
## Layout
One CSS grid (`.app-shell` in `src/index.css`):
```
┌──────────────────────────────────────────────┐
│ sidebar │ GLOBAL HEADER (64px) │
│ (full ├────────────────────┬─────────────┤
│ height) │ MAIN CONTENT │ OWLIVER │
└───────────┴────────────────────┴─────────────┘
```
The header spans main + Owliver only; the sidebar owns the full viewport
height and all navigation. Owliver sits in its own rail on row 2, so it can
never overlap or be clipped by the header.
**Breakpoints** — desktop ≥1280: all three columns. Tablet 768–1279: icon rail
plus main, Owliver becomes an overlay. Mobile <768: sidebar becomes a drawer
(with the complete navigation, nothing removed), Owliver a bottom sheet.
## Structure
```
src/
config/navigation.ts information architecture (mirrors the vendor workspace)
data/ types, mockData, selectors, store
owliver/ block types, response engine, block renderers
components/shell/ AppShell, Sidebar, TopHeader
components/owliver/ panel + composer
components/shared/ MetricCard, DataTable, Page, Toolbar, StatusBadge
components/ui/ Radix-based primitives
pages/ the fourteen product pages
```
`src/data/mockData.ts` is the single source of truth and `selectors.ts` derives
every computed figure, so a number on a KPI card and the same number quoted by
Owliver cannot disagree.
## Home
Home is an AI workspace rather than a dashboard. The hierarchy is greeting →
central Owliver card → quick actions → compact metrics → Today.
Performance content (utilisation, trends, attendance, performance health)
deliberately does **not** appear on Home — it lives on the Performance page,
which remains complete.
## Owliver
**One Owliver per page, never two.** Home shows the central card and no right
rail; every other page shows the right rail and no central card. On Home the
grid drops to two columns, so main reflows into the space rather than leaving an
empty rail behind — and the rail stays hidden on Home even if it was left open
elsewhere.
That means the central card owns the whole interaction on Home: it holds the
conversation, the composer and the suggestions, and an answer appears inside
that thread. Replies are typed block lists rendered as real components — text,
metric cards, alerts, tables, charts, a staff-allocation flow diagram,
recommendations, expandable details and working action buttons.
Home's thread starts empty (an invitation to ask). Every other page opens with a
greeting, its own brief and one seeded exchange, so the You / Owliver
distinction is legible straight away.
State lives in `OwliverProvider`, above the panel, which gives two properties
the panel could not provide on its own:
- **History survives closing.** Collapsing the rail, or the tablet/mobile sheet
unmounting, no longer discards the conversation.
- **Each page keeps its own thread.** Navigating switches context instead of
leaving Home's answers on the Payroll page; returning to a page restores what
was asked there.
Responses are deterministic and delayed 500–900 ms behind a typing indicator.
**Resolve Understaffed Shifts** mutates the shared data, so the greeting, the
metrics and the sidebar badges all update behind the panel.
## Shell controls
The sidebar collapses to a 72px icon rail (labels become tooltips; active state
and badges are preserved) and Owliver closes so main reclaims its column. Both
are driven by `data-sidebar` / `data-owliver` attributes on the grid container,
so the layout reflows rather than overlaying.
## Notes for future work
Two bugs worth remembering, both fixed here:
- Passing a fresh `state` / `initialState` object to TanStack Table v9's
`useTable` on every render makes its store notify → re-render → notify.
Both are memoised in `DataTable.tsx`.
- Loading the whole app in one chunk blocked first paint long enough to look
like a hang on heavier routes. Pages are lazy-loaded in `App.tsx`.

19
index.html Normal file
View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="icon" type="image/png" href="/assets/owliver.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<title>KROW Workforce</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

45
nginx.conf Normal file
View File

@@ -0,0 +1,45 @@
worker_processes auto;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
tcp_nopush on;
keepalive_timeout 65;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_types text/plain text/css text/javascript application/javascript application/json image/svg+xml font/woff2;
server {
listen 80;
listen 3000;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Hashed build output is immutable, so cache it hard.
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# Never cache the shell: it points at the current hashed assets.
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# Hand unknown paths to React Router.
location / {
try_files $uri $uri/ /index.html;
}
}
}

4004
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

52
package.json Normal file
View File

@@ -0,0 +1,52 @@
{
"name": "krow-workforce-demo",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "oxlint",
"preview": "vite preview",
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.2.6",
"@radix-ui/react-checkbox": "^1.3.11",
"@radix-ui/react-collapsible": "^1.1.20",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-dropdown-menu": "^2.1.24",
"@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-popover": "^1.1.23",
"@radix-ui/react-progress": "^1.1.16",
"@radix-ui/react-scroll-area": "^1.2.18",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-separator": "^1.1.15",
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-switch": "^1.3.7",
"@radix-ui/react-tabs": "^1.1.21",
"@radix-ui/react-tooltip": "^1.2.16",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-table": "^9.2.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^13.3.0",
"lucide-react": "^1.46.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.4",
"recharts": "^3.10.1",
"tailwind-merge": "^3.7.0",
"tailwindcss": "^4.3.3",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@types/node": "^22.20.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"oxlint": "^1.81.0",
"typescript": "^7.0.2",
"vite": "^8.3.0"
}
}

BIN
public/assets/krow-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

BIN
public/assets/owliver.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

65
src/App.tsx Normal file
View File

@@ -0,0 +1,65 @@
import { Suspense, lazy } from "react"
import { Navigate, Route, Routes } from "react-router-dom"
import { AppShell } from "@/components/shell/AppShell"
/* Each page is its own chunk, so a cold load only parses the shell plus the
route being opened — not all fourteen pages, Recharts and TanStack Table. */
const HomePage = lazy(() => import("@/pages/HomePage").then((m) => ({ default: m.HomePage })))
const PerformancePage = lazy(() => import("@/pages/PerformancePage").then((m) => ({ default: m.PerformancePage })))
const OrdersPage = lazy(() => import("@/pages/OrdersPage").then((m) => ({ default: m.OrdersPage })))
const AvailabilityPage = lazy(() => import("@/pages/AvailabilityPage").then((m) => ({ default: m.AvailabilityPage })))
const TasksPage = lazy(() => import("@/pages/TasksPage").then((m) => ({ default: m.TasksPage })))
const StaffPage = lazy(() => import("@/pages/StaffPage").then((m) => ({ default: m.StaffPage })))
const CompliancePage = lazy(() => import("@/pages/CompliancePage").then((m) => ({ default: m.CompliancePage })))
const BenefitsPage = lazy(() => import("@/pages/BenefitsPage").then((m) => ({ default: m.BenefitsPage })))
const ClientsPage = lazy(() => import("@/pages/ClientsPage").then((m) => ({ default: m.ClientsPage })))
const InvoicesPage = lazy(() => import("@/pages/InvoicesPage").then((m) => ({ default: m.InvoicesPage })))
const PayrollPage = lazy(() => import("@/pages/PayrollPage").then((m) => ({ default: m.PayrollPage })))
const ReportsPage = lazy(() => import("@/pages/ReportsPage").then((m) => ({ default: m.ReportsPage })))
const MessagesPage = lazy(() => import("@/pages/MessagesPage").then((m) => ({ default: m.MessagesPage })))
const SettingsPage = lazy(() => import("@/pages/SettingsPage").then((m) => ({ default: m.SettingsPage })))
function PageFallback() {
return (
<div className="p-5">
<div className="h-6 w-48 animate-pulse rounded-md bg-surface-sunk" />
<div className="mt-4 grid gap-2.5 grid-cols-2 lg:grid-cols-4">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="h-[84px] animate-pulse rounded-[12px] bg-surface-sunk" />
))}
</div>
<div className="mt-4 h-[280px] animate-pulse rounded-[14px] bg-surface-sunk" />
</div>
)
}
export default function App() {
return (
<Routes>
<Route
element={
<Suspense fallback={<PageFallback />}>
<AppShell />
</Suspense>
}
>
<Route path="/" element={<HomePage />} />
<Route path="/performance" element={<PerformancePage />} />
<Route path="/orders" element={<OrdersPage />} />
<Route path="/availability" element={<AvailabilityPage />} />
<Route path="/tasks" element={<TasksPage />} />
<Route path="/staff" element={<StaffPage />} />
<Route path="/compliance" element={<CompliancePage />} />
<Route path="/benefits" element={<BenefitsPage />} />
<Route path="/clients" element={<ClientsPage />} />
<Route path="/invoices" element={<InvoicesPage />} />
<Route path="/payroll" element={<PayrollPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/messages" element={<MessagesPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
)
}

View File

@@ -0,0 +1,253 @@
import {
AlertTriangle,
ArrowRight,
Banknote,
CornerDownLeft,
SendHorizonal,
ShieldCheck,
Sparkles,
Zap,
} from "lucide-react"
import { useEffect, useLayoutEffect, useRef, useState } from "react"
import { cn } from "@/lib/utils"
import { useOwliver } from "@/owliver/OwliverProvider"
import { BlockView } from "@/owliver/blocks/BlockRenderer"
const CATEGORY_PROMPTS = [
{
icon: Zap,
text: "Which orders are understaffed?",
tone: "text-warn bg-warn-soft border-warn-edge",
},
{
icon: AlertTriangle,
text: "What needs attention today?",
tone: "text-bad bg-bad-soft border-bad-edge",
},
{
icon: ShieldCheck,
text: "Show compliance risks",
tone: "text-[#6366F1] bg-[#EEF2FF] border-[#C7D2FE]",
},
{
icon: Banknote,
text: "Summarise payroll this week",
tone: "text-good bg-good-soft border-good-edge",
},
]
export function CentralOwliver() {
const { messages, typing, send, runAction } = useOwliver()
const [value, setValue] = useState("")
const inputRef = useRef<HTMLTextAreaElement>(null)
const threadRef = useRef<HTMLDivElement>(null)
const hasConversation = messages.length > 0
useEffect(() => {
const el = inputRef.current
if (!el) return
el.style.height = "auto"
el.style.height = `${Math.min(el.scrollHeight, 120)}px`
}, [value])
useLayoutEffect(() => {
const el = threadRef.current
if (el) el.scrollTop = el.scrollHeight
}, [messages, typing])
const canSend = value.trim().length > 0 && !typing
function submit(text?: string) {
const payload = (text ?? value).trim()
if (!payload || typing) return
send(payload)
setValue("")
}
return (
<section
aria-label="Ask Owliver"
className={cn(
"ambient-glow relative mx-auto w-full overflow-hidden rounded-[24px] border transition-all duration-300",
hasConversation
? "max-w-[840px] border-line bg-surface shadow-md"
: "max-w-[780px] border-line/80 bg-surface/90 shadow-sm backdrop-blur-md"
)}
>
{/* Ambient gradient illumination on top border */}
<div className="pointer-events-none absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-primary via-[#6366F1] to-[#A855F7] opacity-80" />
{/* ------------------------------------------------------------ Header */}
<header className="flex items-center justify-between border-b border-line-soft px-5 py-3.5">
<div className="flex items-center gap-3">
<div className="relative">
<img
src="/assets/owliver.png"
alt="Owliver"
className="size-9 rounded-[12px] border border-line-soft bg-surface-soft p-0.5 shadow-xs"
/>
<span className="absolute -bottom-0.5 -right-0.5 flex size-2.5">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-good opacity-75" />
<span className="relative inline-flex size-2.5 rounded-full bg-good-dot border border-surface" />
</span>
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-[15px] font-bold text-ink">Owliver AI</h2>
</div>
<p className="text-[11.5px] font-medium text-ink-3">
Workforce Intelligence · Real-time Operational Dispatch
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="hidden items-center gap-1 text-[11.5px] font-medium text-ink-3 sm:flex">
<Sparkles className="size-3 text-primary" />
Active Core 2.0
</span>
</div>
</header>
{/* -------------------------------------------------- Conversation Thread */}
{hasConversation && (
<div
ref={threadRef}
className="scroll-slim max-h-[46vh] min-h-0 overflow-y-auto bg-surface-soft/40 px-5 py-4"
aria-live="polite"
aria-label="Conversation with Owliver"
>
<div className="space-y-4">
{messages.map((m) =>
m.author === "user" ? (
<div key={m.id} className="flex flex-col items-end gap-1">
<span className="pr-1 text-[10.5px] font-bold uppercase tracking-[0.1em] text-ink-3">
You
</span>
<div className="max-w-[80%] rounded-[16px] rounded-br-[4px] bg-gradient-to-r from-primary to-[#2563EB] px-4 py-2.5 text-[13.5px] font-medium text-white shadow-xs">
{m.text}
</div>
</div>
) : (
<div key={m.id} className="animate-rise flex flex-col gap-1.5">
<span className="flex items-center gap-1.5 text-[10.5px] font-bold uppercase tracking-[0.1em] text-primary">
<Sparkles className="size-3" />
Owliver Dispatch
</span>
<div className="space-y-2.5">
{m.blocks?.map((b, i) => (
<BlockView key={i} block={b} onAction={runAction} />
))}
</div>
</div>
)
)}
{typing && (
<div className="flex flex-col gap-1.5">
<span className="flex items-center gap-1.5 text-[10.5px] font-bold uppercase tracking-[0.1em] text-primary">
<Sparkles className="size-3" />
Owliver is analyzing
</span>
<div className="flex items-center gap-1.5 px-1 py-1" aria-label="Owliver is typing">
{[0, 1, 2].map((i) => (
<span
key={i}
className="size-2 animate-typing rounded-full bg-primary"
style={{ animationDelay: `${i * 0.15}s` }}
/>
))}
</div>
</div>
)}
</div>
</div>
)}
{/* ------------------------------------------------ Spotlight Composer */}
<div className={cn("p-4 sm:p-5", hasConversation && "border-t border-line-soft bg-surface")}>
<div
className={cn(
"flex items-end gap-2.5 rounded-[18px] border border-line bg-surface px-4 py-3 shadow-xs",
"transition-all duration-200 focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/10"
)}
>
<Sparkles className="mb-1.5 size-4 text-primary shrink-0 opacity-70" />
<textarea
ref={inputRef}
rows={1}
value={value}
disabled={typing}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
submit()
}
}}
placeholder="Ask Owliver anything (e.g., 'Resolve understaffed shifts', 'Show compliance')..."
aria-label="Ask Owliver anything about your workforce"
className={cn(
"max-h-[120px] min-h-[26px] flex-1 resize-none bg-transparent py-0.5 text-[14px] leading-snug text-ink",
"placeholder:text-ink-3 focus:outline-none disabled:opacity-60"
)}
/>
<div className="flex items-center gap-1.5 shrink-0">
<span className="hidden items-center gap-1 text-[11px] font-medium text-ink-3 sm:inline-flex">
<CornerDownLeft className="size-3 text-ink-3" />
</span>
<button
type="button"
onClick={() => submit()}
disabled={!canSend}
aria-label="Send to Owliver"
className={cn(
"grid size-8 place-items-center rounded-[10px] transition-all",
canSend
? "bg-primary text-white shadow-xs hover:bg-[#1a45c0] hover:scale-105"
: "bg-surface-sunk text-ink-3"
)}
>
<SendHorizonal className="size-4" />
</button>
</div>
</div>
{/* --------------------------------------------- Curated Prompt Chips */}
<div className="mt-3.5">
<div className="flex flex-wrap items-center justify-center gap-2 sm:justify-start">
{CATEGORY_PROMPTS.map(({ icon: Icon, text, tone }) => (
<button
key={text}
type="button"
onClick={() => submit(text)}
className={cn(
"group inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-[12px] font-medium transition-all",
"bg-surface shadow-2xs hover:shadow-xs hover:border-primary-edge hover:scale-[1.02]",
"text-ink-2 hover:text-ink"
)}
>
<span className={cn("grid size-4 place-items-center rounded-full border text-[10px]", tone)}>
<Icon className="size-2.5" />
</span>
<span>{text}</span>
<ArrowRight className="size-3 text-ink-3 opacity-0 transition-all group-hover:opacity-100 group-hover:translate-x-0.5" />
</button>
))}
</div>
{!hasConversation && (
<div className="mt-3 flex items-center justify-between text-[11px] text-ink-3 px-1">
<span>Simulated intelligence running on live local state</span>
<span className="hidden sm:inline">100% private · zero external API egress</span>
</div>
)}
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,286 @@
import {
History,
Mic,
Plus,
RotateCcw,
SendHorizonal,
Sparkles,
} from "lucide-react"
import { useEffect, useLayoutEffect, useRef, useState } from "react"
import { cn } from "@/lib/utils"
import { useOwliver } from "@/owliver/OwliverProvider"
import { BlockView } from "@/owliver/blocks/BlockRenderer"
export function GensparkCommandCapsule() {
const {
messages,
typing,
suggestions,
hasConversation: hasThread,
history,
send,
runAction,
reset,
openHistoryEntry,
} = useOwliver()
const [historyOpen, setHistoryOpen] = useState(false)
const [value, setValue] = useState("")
const inputRef = useRef<HTMLTextAreaElement>(null)
const threadRef = useRef<HTMLDivElement>(null)
const hasConversation = hasThread || historyOpen
useEffect(() => {
const el = inputRef.current
if (!el) return
el.style.height = "auto"
el.style.height = `${Math.min(el.scrollHeight, 120)}px`
}, [value])
useLayoutEffect(() => {
const el = threadRef.current
if (el) el.scrollTop = el.scrollHeight
}, [messages, typing])
const canSend = value.trim().length > 0 && !typing
function submit(text?: string) {
const payload = (text ?? value).trim()
if (!payload || typing) return
send(payload)
setValue("")
}
return (
<div
className={cn(
"genspark-capsule mx-auto w-full transition-all duration-300",
hasConversation
? "max-w-[840px] rounded-[24px] p-5"
: "max-w-[760px] rounded-[22px] p-4"
)}
>
{/* Conversation Thread (when conversation has started) */}
{hasConversation && (
<div className="mb-4">
<div className="flex items-center justify-between border-b border-line pb-3 text-[13px] text-ink-2">
<div className="flex items-center gap-2">
<span className="size-2 rounded-full bg-[#10B981] animate-pulse" />
<span className="font-semibold text-ink">Owliver AI Core</span>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setHistoryOpen((v) => !v)}
aria-label="Conversation history"
aria-pressed={historyOpen}
className="grid size-6 place-items-center rounded-[7px] text-ink-3 transition-colors hover:bg-surface-sunk hover:text-ink"
>
<History className="size-3.5" />
</button>
<button
type="button"
onClick={() => {
setHistoryOpen(false)
reset()
}}
disabled={!hasThread}
aria-label="Reset conversation"
className="grid size-6 place-items-center rounded-[7px] text-ink-3 transition-colors hover:bg-surface-sunk hover:text-ink disabled:opacity-40"
>
<RotateCcw className="size-3.5" />
</button>
<span className="ml-1 text-[11.5px] text-ink-3">Workspace Mode</span>
</div>
</div>
<div
ref={threadRef}
className="scroll-slim max-h-[46vh] min-h-0 overflow-y-auto space-y-4 py-3"
aria-live="polite"
>
{historyOpen && (
<div className="space-y-2">
<div className="px-0.5 text-[10.5px] font-bold uppercase tracking-[0.1em] text-ink-3">
Conversation history
</div>
{history.length === 0 ? (
<p className="rounded-[10px] border border-line bg-surface-soft px-3 py-4 text-center text-[12.5px] text-ink-3">
No saved conversations yet.
</p>
) : (
history.map((h) => (
<button
key={h.id}
type="button"
onClick={() => {
setHistoryOpen(false)
openHistoryEntry(h.id)
}}
className="w-full rounded-[10px] border border-line bg-surface px-3 py-2 text-left transition-colors hover:border-primary-edge hover:bg-primary-soft"
>
<div className="truncate text-[12.5px] font-semibold text-ink">{h.title}</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-ink-3">
<span className="capitalize">{h.context}</span>
<span>·</span>
<span>
{new Date(h.at).toLocaleString(undefined, {
day: "numeric",
month: "short",
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
</button>
))
)}
</div>
)}
{!historyOpen && (<>
{messages.map((m) =>
m.author === "user" ? (
<div key={m.id} className="flex flex-col items-end gap-1">
<span className="pr-1 text-[10.5px] font-bold uppercase tracking-[0.1em] text-ink-3">
You
</span>
<div
style={{ color: "#ffffff" }}
className="max-w-[80%] rounded-[16px] rounded-br-[4px] bg-gradient-to-r from-[#3B82F6] to-[#1D4ED8] px-4 py-2.5 text-[14px] font-medium !text-white shadow-xs"
>
{m.text}
</div>
</div>
) : (
<div key={m.id} className="animate-rise flex flex-col gap-1.5">
<span className="flex items-center gap-1.5 text-[10.5px] font-bold uppercase tracking-[0.1em] text-primary">
<img src="/assets/owliver.png" alt="" className="size-3.5 rounded-[3px] object-cover" />
Owliver
</span>
<div className="space-y-2.5 rounded-[16px] bg-surface-soft p-3.5 border border-line">
{m.blocks?.map((b, i) => (
<BlockView key={i} block={b} onAction={runAction} />
))}
</div>
</div>
)
)}
{typing && (
<div className="flex items-center gap-2 px-1 text-[12px] text-primary">
<Sparkles className="size-3.5 animate-spin text-primary" />
<span>Owliver is analyzing workspace state...</span>
</div>
)}
</>)}
</div>
</div>
)}
{/* Main Input Textarea */}
<div className="relative">
<textarea
ref={inputRef}
rows={hasConversation ? 1 : 2}
value={value}
disabled={typing}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
submit()
}
}}
placeholder={
hasConversation
? "Ask follow-up..."
: "Ask anything, create anything"
}
aria-label="Ask anything, create anything"
className="w-full resize-none border-0 bg-transparent pt-1 text-[15px] font-normal leading-relaxed text-ink placeholder:text-ink-3 outline-none focus:outline-none focus:ring-0"
/>
</div>
{/* Bottom Toolbar */}
<div className="mt-3.5 flex items-center justify-between gap-2.5 pt-1">
{/* Left Toolbar Items: [+] */}
<div className="flex items-center gap-2">
{/* Plus button */}
<button
type="button"
className="grid size-7.5 place-items-center rounded-full bg-surface-sunk text-ink-2 border border-line transition-colors hover:border-primary-edge hover:text-primary"
title="Attach or Add Context"
>
<Plus className="size-4" />
</button>
</div>
{/* Right Toolbar Items: [Mic] [||| Speak] */}
<div className="flex items-center gap-2">
{/* Mic icon */}
<button
type="button"
className="grid size-8 place-items-center rounded-full text-ink-3 transition-colors hover:bg-surface-sunk hover:text-primary"
title="Voice input"
>
<Mic className="size-4" />
</button>
{/* Speak / Submit Pill Button with blue theme color */}
<button
type="button"
onClick={() => submit()}
style={{ color: "#ffffff" }}
className={cn(
"inline-flex items-center gap-2 rounded-full px-4 py-1.5 text-[13px] font-semibold !text-white transition-all shadow-xs hover:scale-105",
"bg-gradient-to-r from-[#3B82F6] to-[#1D4ED8] hover:opacity-95 shadow-sm"
)}
>
{canSend ? (
<>
<span className="!text-white" style={{ color: "#ffffff" }}>Send</span>
<SendHorizonal className="size-3.5 !text-white stroke-white" style={{ color: "#ffffff", stroke: "#ffffff" }} />
</>
) : (
<>
<span className="flex items-center gap-0.5">
<span className="h-2.5 w-0.5 rounded-full bg-white" style={{ backgroundColor: "#ffffff" }} />
<span className="h-4 w-0.5 rounded-full bg-white" style={{ backgroundColor: "#ffffff" }} />
<span className="h-2 w-0.5 rounded-full bg-white" style={{ backgroundColor: "#ffffff" }} />
</span>
<span className="!text-white font-medium" style={{ color: "#ffffff" }}>Speak</span>
</>
)}
</button>
</div>
</div>
{/* ------------------------------------------------ Suggested prompts.
Visible immediately on load — the empty state is where they matter
most — and kept below the latest answer once a thread is running.
Copy comes from the Owliver service so there is one source of truth. */}
<div className="mt-3 border-t border-line-soft pt-3">
<div className="flex flex-wrap gap-2">
{suggestions.map((prompt) => (
<button
key={prompt}
type="button"
onClick={() => submit(prompt)}
disabled={typing}
className={cn(
"cursor-pointer rounded-full border border-line bg-surface px-3 py-1.5",
"text-[12.5px] font-medium leading-none text-ink",
"transition-colors hover:border-primary-edge hover:bg-primary-soft hover:text-primary",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30",
"disabled:cursor-not-allowed disabled:opacity-50"
)}
>
{prompt}
</button>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,273 @@
import {
Building2,
Calendar,
ChevronDown,
ClipboardList,
Clock,
Coins,
FileSpreadsheet,
FileText,
Gift,
ListTodo,
ShieldCheck,
Sparkles,
Users,
} from "lucide-react"
import { useNavigate } from "react-router-dom"
import { STAFF_COUNTS } from "@/data/mockData"
import { complianceFlagCount, understaffedShifts } from "@/data/selectors"
export function GensparkSuiteBar() {
const navigate = useNavigate()
const shortCount = understaffedShifts().length
const flagCount = complianceFlagCount()
return (
<div className="mx-auto w-full max-w-[1140px] overflow-x-auto py-2">
<div className="flex min-w-max items-start justify-center gap-6 px-4">
{/* ======================================================== SUITE 1: OPERATIONS */}
<div className="flex flex-col gap-2.5">
<div className="flex items-center gap-1 text-[11px] font-bold uppercase tracking-[0.08em] text-ink-3">
<span>Operations</span>
</div>
<div className="flex items-center gap-5">
{/* Orders */}
<button
type="button"
onClick={() => navigate("/orders")}
className="group flex flex-col items-center gap-1.5 transition-transform hover:scale-105"
>
<div className="grid size-10 place-items-center rounded-[12px] bg-[#FFF4ED] text-[#FF6B35] border border-[#FFD8C7] transition-colors group-hover:border-[#FF6B35]">
<ClipboardList className="size-5" />
</div>
<span className="text-[12px] font-semibold text-ink-2 group-hover:text-ink">
Orders
</span>
{shortCount > 0 ? (
<span className="rounded-full bg-[#EF4444] px-1.5 py-0.2 text-[9.5px] font-bold text-white leading-tight">
{shortCount} short
</span>
) : (
<span className="rounded-full bg-[#10B981] px-1.5 py-0.2 text-[9.5px] font-bold text-white leading-tight">
Ready
</span>
)}
</button>
{/* Shift Dispatch */}
<button
type="button"
onClick={() => navigate("/orders")}
className="group flex flex-col items-center gap-1.5 transition-transform hover:scale-105"
>
<div className="grid size-10 place-items-center rounded-[12px] bg-[#EFF6FF] text-[#3B82F6] border border-[#BFDBFE] transition-colors group-hover:border-[#3B82F6]">
<Clock className="size-5" />
</div>
<span className="text-[12px] font-semibold text-ink-2 group-hover:text-ink">
Dispatch
</span>
</button>
{/* Task Board */}
<button
type="button"
onClick={() => navigate("/tasks")}
className="group flex flex-col items-center gap-1.5 transition-transform hover:scale-105"
>
<div className="grid size-10 place-items-center rounded-[12px] bg-[#FAF5FF] text-[#A855F7] border border-[#E9D5FF] transition-colors group-hover:border-[#A855F7]">
<ListTodo className="size-5" />
</div>
<span className="text-[12px] font-semibold text-ink-2 group-hover:text-ink">
Task Board
</span>
</button>
</div>
</div>
{/* Divider */}
<div className="h-16 w-px bg-line self-center" />
{/* ======================================================== SUITE 2: WORKFORCE */}
<div className="flex flex-col gap-2.5">
<div className="flex items-center gap-1 text-[11px] font-bold uppercase tracking-[0.08em] text-ink-3">
<span>Workforce Suite</span>
<ChevronDown className="size-3 text-ink-3" />
</div>
<div className="flex items-center gap-5">
{/* Staff Directory */}
<button
type="button"
onClick={() => navigate("/staff")}
className="group flex flex-col items-center gap-1.5 transition-transform hover:scale-105"
>
<div className="grid size-10 place-items-center rounded-[12px] bg-[#ECFDF5] text-[#10B981] border border-[#A7F3D0] transition-colors group-hover:border-[#10B981]">
<Users className="size-5" />
</div>
<span className="text-[12px] font-semibold text-ink-2 group-hover:text-ink">
Staff Directory
</span>
</button>
{/* Availability */}
<button
type="button"
onClick={() => navigate("/availability")}
className="group flex flex-col items-center gap-1.5 transition-transform hover:scale-105"
>
<div className="grid size-10 place-items-center rounded-[12px] bg-[#ECFEFF] text-[#06B6D4] border border-[#A5F3FC] transition-colors group-hover:border-[#06B6D4]">
<Calendar className="size-5" />
</div>
<span className="text-[12px] font-semibold text-ink-2 group-hover:text-ink">
Availability
</span>
<span className="rounded-full bg-surface-sunk border border-line px-1.5 py-0.2 text-[9.5px] font-bold text-primary leading-tight">
{STAFF_COUNTS.active} active
</span>
</button>
{/* Compliance */}
<button
type="button"
onClick={() => navigate("/compliance")}
className="group flex flex-col items-center gap-1.5 transition-transform hover:scale-105"
>
<div className="grid size-10 place-items-center rounded-[12px] bg-[#FFFBEB] text-[#F59E0B] border border-[#FDE68A] transition-colors group-hover:border-[#F59E0B]">
<ShieldCheck className="size-5" />
</div>
<span className="text-[12px] font-semibold text-ink-2 group-hover:text-ink">
Compliance
</span>
{flagCount > 0 && (
<span className="rounded-full bg-[#EF4444] px-1.5 py-0.2 text-[9.5px] font-bold text-white leading-tight">
{flagCount} risks
</span>
)}
</button>
{/* Benefits */}
<button
type="button"
onClick={() => navigate("/benefits")}
className="group flex flex-col items-center gap-1.5 transition-transform hover:scale-105"
>
<div className="grid size-10 place-items-center rounded-[12px] bg-[#FDF2F8] text-[#EC4899] border border-[#FBCFE8] transition-colors group-hover:border-[#EC4899]">
<Gift className="size-5" />
</div>
<span className="text-[12px] font-semibold text-ink-2 group-hover:text-ink">
Benefits
</span>
<span className="rounded-full bg-[#EF4444] px-1.5 py-0.2 text-[9.5px] font-bold text-white leading-tight">
Free
</span>
</button>
</div>
</div>
{/* Divider */}
<div className="h-16 w-px bg-line self-center" />
{/* ======================================================== SUITE 3: FINANCE */}
<div className="flex flex-col gap-2.5">
<div className="flex items-center gap-1 text-[11px] font-bold uppercase tracking-[0.08em] text-ink-3">
<span>Finance & CRM</span>
<ChevronDown className="size-3 text-ink-3" />
</div>
<div className="flex items-center gap-5">
{/* Invoices */}
<button
type="button"
onClick={() => navigate("/invoices")}
className="group flex flex-col items-center gap-1.5 transition-transform hover:scale-105"
>
<div className="grid size-10 place-items-center rounded-[12px] bg-[#EFF6FF] text-[#3B82F6] border border-[#BFDBFE] transition-colors group-hover:border-[#3B82F6]">
<FileText className="size-5" />
</div>
<span className="text-[12px] font-semibold text-ink-2 group-hover:text-ink">
Invoices
</span>
<span className="rounded-full bg-[#10B981] px-1.5 py-0.2 text-[9.5px] font-bold text-white leading-tight">
Ready
</span>
</button>
{/* Payroll */}
<button
type="button"
onClick={() => navigate("/payroll")}
className="group flex flex-col items-center gap-1.5 transition-transform hover:scale-105"
>
<div className="grid size-10 place-items-center rounded-[12px] bg-[#ECFDF5] text-[#10B981] border border-[#A7F3D0] transition-colors group-hover:border-[#10B981]">
<Coins className="size-5" />
</div>
<span className="text-[12px] font-semibold text-ink-2 group-hover:text-ink">
Payroll
</span>
<span className="rounded-full bg-surface-sunk border border-line px-1.5 py-0.2 text-[9.5px] font-bold text-good leading-tight">
$18.5k
</span>
</button>
{/* Clients */}
<button
type="button"
onClick={() => navigate("/clients")}
className="group flex flex-col items-center gap-1.5 transition-transform hover:scale-105"
>
<div className="grid size-10 place-items-center rounded-[12px] bg-[#F5F3FF] text-[#8B5CF6] border border-[#DDD6FE] transition-colors group-hover:border-[#8B5CF6]">
<Building2 className="size-5" />
</div>
<span className="text-[12px] font-semibold text-ink-2 group-hover:text-ink">
Clients
</span>
</button>
</div>
</div>
{/* Divider */}
<div className="h-16 w-px bg-line self-center" />
{/* ======================================================== SUITE 4: TOOLS */}
<div className="flex flex-col gap-2.5">
<div className="flex items-center gap-1 text-[11px] font-bold uppercase tracking-[0.08em] text-ink-3">
<span>AI Tools</span>
</div>
<div className="flex items-center gap-5">
{/* AI Notes / Reports */}
<button
type="button"
onClick={() => navigate("/reports")}
className="group flex flex-col items-center gap-1.5 transition-transform hover:scale-105"
>
<div className="grid size-10 place-items-center rounded-[12px] bg-[#F0FDFA] text-[#14B8A6] border border-[#99F6E4] transition-colors group-hover:border-[#14B8A6]">
<FileSpreadsheet className="size-5" />
</div>
<span className="text-[12px] font-semibold text-ink-2 group-hover:text-ink">
AI Reports
</span>
</button>
{/* All Agents */}
<button
type="button"
onClick={() => navigate("/performance")}
className="group flex flex-col items-center gap-1.5 transition-transform hover:scale-105"
>
<div className="grid size-10 place-items-center rounded-[12px] bg-surface-soft text-ink border border-line transition-colors group-hover:border-primary-edge">
<Sparkles className="size-5 text-primary" />
</div>
<span className="text-[12px] font-semibold text-ink-2 group-hover:text-ink">
All Agents
</span>
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,110 @@
import {
CheckCircle2,
Zap,
} from "lucide-react"
import { useState } from "react"
import { useNavigate } from "react-router-dom"
import { PAYROLL_SUMMARY } from "@/data/mockData"
import {
grossMargin,
money,
staffCoverage,
totalPlacesShort,
understaffedShifts,
} from "@/data/selectors"
import { resolveAllShortages } from "@/data/store"
import { cn } from "@/lib/utils"
export function GensparkSuperCard() {
const navigate = useNavigate()
const [justResolved, setJustResolved] = useState(false)
const shortShifts = understaffedShifts()
const placesShort = totalPlacesShort()
const coverage = staffCoverage()
const margin = grossMargin()
const handleResolve = () => {
resolveAllShortages()
setJustResolved(true)
setTimeout(() => setJustResolved(false), 3000)
}
return (
<div className="mx-auto w-full max-w-[840px] pt-4">
{/* Featured Bottom Card matching Image 2 */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 rounded-[22px] border border-line bg-surface p-4 sm:p-5 shadow-xs">
{/* Left: Icon Badge with unread count "3" */}
<div className="flex items-center gap-4 w-full sm:w-auto">
<div className="relative shrink-0">
<div className="grid size-12 place-items-center overflow-hidden rounded-[14px] border border-primary-edge bg-[#FFC450] shadow-xs">
<img src="/assets/owliver.png" alt="Owliver" className="size-full object-cover scale-105" />
</div>
{shortShifts.length > 0 && (
<span className="absolute -top-1.5 -right-1.5 flex size-5 items-center justify-center rounded-full bg-[#EF4444] text-[11px] font-bold text-white border-2 border-surface">
{shortShifts.length}
</span>
)}
</div>
<div className="min-w-0 flex-1">
<h4 className="text-[15px] font-bold text-ink tracking-tight">
Owliver is here.
</h4>
<p className="mt-0.5 text-[12.5px] text-ink-2 leading-relaxed">
Super Agent, Live Dispatch, and Compliance Sentinel in one native workspace.
</p>
</div>
</div>
{/* Right: Quick Action Button */}
<div className="flex items-center gap-3 w-full sm:w-auto justify-end">
{shortShifts.length > 0 ? (
<button
type="button"
onClick={handleResolve}
style={{ color: "#ffffff" }}
className={cn(
"inline-flex items-center gap-2 rounded-full px-4 py-2 text-[13px] font-semibold !text-white transition-all shadow-xs hover:scale-105",
"bg-gradient-to-r from-[#3B82F6] to-[#1D4ED8] hover:opacity-95 shadow-sm"
)}
>
<Zap className="size-3.5 !text-white stroke-white fill-transparent" style={{ color: "#ffffff", stroke: "#ffffff" }} />
<span className="!text-white font-medium" style={{ color: "#ffffff" }}>
{justResolved ? "Allocating Roster..." : `Auto-Assign Gaps (${placesShort} short)`}
</span>
</button>
) : (
<button
type="button"
onClick={() => navigate("/orders")}
className="inline-flex items-center gap-2 rounded-full bg-good-soft px-4 py-2 text-[13px] font-semibold text-good border border-good-edge transition-all hover:bg-good-soft/80"
>
<CheckCircle2 className="size-4" />
<span>All Shifts Covered</span>
</button>
)}
</div>
</div>
{/* Live Operational Metrics Footer */}
<div className="mt-3.5 flex flex-wrap items-center justify-center gap-4 text-[12px] text-ink-3">
<div className="flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-good-dot" />
<span>Workforce: <strong className="text-ink font-semibold">{coverage}% Active</strong></span>
</div>
<span>·</span>
<div className="flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-primary" />
<span>Gross Margin: <strong className="text-ink font-semibold">{margin}%</strong></span>
</div>
<span>·</span>
<div className="flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-[#8B5CF6]" />
<span>Billing Accrual: <strong className="text-ink font-semibold">{money(PAYROLL_SUMMARY.revenue)}</strong></span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,228 @@
import {
AlertTriangle,
ArrowRight,
CheckCircle2,
Clock,
MapPin,
} from "lucide-react"
import { useState } from "react"
import { useNavigate } from "react-router-dom"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { SCHEDULE } from "@/data/mockData"
import { cn } from "@/lib/utils"
type FilterType = "all" | "short" | "filled"
export function ShiftDispatchConsole() {
const navigate = useNavigate()
const [filter, setFilter] = useState<FilterType>("all")
const rows = [...SCHEDULE].sort((a, b) => {
const gap = b.required - b.filled - (a.required - a.filled)
return gap !== 0 ? gap : a.time.localeCompare(b.time)
})
const shortRows = rows.filter((r) => r.filled < r.required)
const filledRows = rows.filter((r) => r.filled >= r.required)
const displayedRows =
filter === "short" ? shortRows : filter === "filled" ? filledRows : rows
return (
<div className="glass-card overflow-hidden rounded-[20px] shadow-xs">
{/* Console Header */}
<div className="flex flex-col gap-3 border-b border-line p-5 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-2">
<span className="flex size-2 rounded-full bg-primary animate-pulse" />
<span className="text-[11px] font-bold uppercase tracking-[0.1em] text-ink-3">
Real-Time Dispatch Console
</span>
</div>
<h3 className="mt-0.5 text-[18px] font-bold text-ink">
Live Shift Fulfillment & Roster
</h3>
<p className="text-[13px] text-ink-2">
Monitoring today's 5 operational shifts across 4 active partner venues
</p>
</div>
{/* Filter Pills */}
<div className="flex items-center gap-1.5 rounded-[12px] border border-line bg-surface-soft p-1">
<button
type="button"
onClick={() => setFilter("all")}
className={cn(
"rounded-[9px] px-3 py-1.5 text-[12px] font-semibold transition-all",
filter === "all"
? "bg-surface text-ink shadow-xs"
: "text-ink-2 hover:text-ink"
)}
>
All Shifts ({rows.length})
</button>
<button
type="button"
onClick={() => setFilter("short")}
className={cn(
"flex items-center gap-1.5 rounded-[9px] px-3 py-1.5 text-[12px] font-semibold transition-all",
filter === "short"
? "bg-surface text-warn shadow-xs"
: "text-ink-2 hover:text-ink"
)}
>
<span className="size-1.5 rounded-full bg-warn" />
Understaffed ({shortRows.length})
</button>
<button
type="button"
onClick={() => setFilter("filled")}
className={cn(
"flex items-center gap-1.5 rounded-[9px] px-3 py-1.5 text-[12px] font-semibold transition-all",
filter === "filled"
? "bg-surface text-good shadow-xs"
: "text-ink-2 hover:text-ink"
)}
>
<span className="size-1.5 rounded-full bg-good" />
Covered ({filledRows.length})
</button>
</div>
</div>
{/* Shifts List */}
<div className="divide-y divide-line">
{displayedRows.map((shift) => {
const gap = shift.required - shift.filled
const isShort = gap > 0
const pct = Math.round((shift.filled / shift.required) * 100)
return (
<div
key={shift.ref}
className={cn(
"flex flex-col gap-4 p-4 transition-colors sm:flex-row sm:items-center sm:justify-between",
isShort ? "bg-warn-soft/20 hover:bg-warn-soft/40" : "hover:bg-surface-soft"
)}
>
{/* Left Column: Venue & Details */}
<div className="flex items-start gap-3.5">
<div
className={cn(
"grid size-11 shrink-0 place-items-center rounded-[12px] border",
isShort
? "border-warn-edge bg-warn-soft text-warn"
: "border-primary-edge bg-primary-soft text-primary"
)}
>
<Clock className="size-5" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-[15px] font-bold text-ink">
{shift.time}
</span>
<span className="text-[12px] text-ink-3">·</span>
<span className="rounded-[6px] border border-line bg-surface px-1.5 py-0.5 text-[11px] font-bold text-primary">
{shift.ref}
</span>
</div>
<div className="mt-1 flex items-center gap-1.5 text-[13px] font-medium text-ink-2">
<MapPin className="size-3.5 text-ink-3" />
<span>{shift.location}</span>
</div>
</div>
</div>
{/* Middle Column: Visual Staffing Slots */}
<div className="flex-1 max-w-[280px] sm:px-4">
<div className="flex items-center justify-between text-[11.5px] font-semibold">
<span className={isShort ? "text-warn" : "text-good"}>
{shift.filled} of {shift.required} Staffed
</span>
<span className="text-ink-3">{pct}%</span>
</div>
{/* Visual Slot Pills */}
<div className="mt-1.5 flex items-center gap-1.5">
{Array.from({ length: shift.required }).map((_, i) => {
const isFilledSlot = i < shift.filled
return (
<div
key={i}
className={cn(
"h-2 flex-1 rounded-full transition-all",
isFilledSlot
? isShort
? "bg-primary"
: "bg-good"
: "border border-dashed border-warn/60 bg-warn-soft/50 animate-pulse"
)}
title={isFilledSlot ? "Assigned staff" : "Unassigned slot"}
/>
)
})}
</div>
</div>
{/* Right Column: Status & Direct Action */}
<div className="flex items-center justify-between gap-3 sm:justify-end">
{isShort ? (
<>
<Badge tone="warn" size="sm" className="gap-1 font-semibold">
<AlertTriangle className="size-3" />
{gap} Short
</Badge>
<Button
size="sm"
variant="outline"
onClick={() => navigate("/orders")}
className="border-warn-edge text-warn hover:bg-warn-soft"
>
<span>Assign</span>
<ArrowRight className="size-3.5 ml-1" />
</Button>
</>
) : (
<>
<Badge tone="good" size="sm" className="gap-1 font-semibold">
<CheckCircle2 className="size-3" />
100% Ready
</Badge>
<Button
size="sm"
variant="ghost"
onClick={() => navigate("/orders")}
className="text-ink-2 hover:text-ink"
>
<span>Details</span>
<ArrowRight className="size-3.5 ml-1" />
</Button>
</>
)}
</div>
</div>
)
})}
</div>
{/* Console Footer */}
<div className="flex items-center justify-between border-t border-line bg-surface-soft px-5 py-3 text-[12.5px]">
<span className="text-ink-3">
Shift times shown in local venue timezone. All active workers checked against WTD compliance.
</span>
<button
type="button"
onClick={() => navigate("/orders")}
className="inline-flex items-center gap-1 font-semibold text-primary hover:underline"
>
View Full Shift Board <ArrowRight className="size-3.5" />
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,421 @@
import {
AlertTriangle,
ArrowRight,
Banknote,
CheckCircle2,
ChevronRight,
Percent,
ShieldAlert,
Zap,
} from "lucide-react"
import { useState } from "react"
import { useNavigate } from "react-router-dom"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
COMPLIANCE_RISKS,
PAYROLL_SUMMARY,
STAFF_COUNTS,
} from "@/data/mockData"
import {
averageHourlyCost,
complianceFlagCount,
grossMargin,
money,
num,
staffCoverage,
totalPlacesShort,
understaffedShifts,
} from "@/data/selectors"
import { resolveAllShortages } from "@/data/store"
import { cn } from "@/lib/utils"
export function WorkforceBento() {
const navigate = useNavigate()
const [justResolved, setJustResolved] = useState(false)
const coverage = staffCoverage()
const shortShifts = understaffedShifts()
const placesShort = totalPlacesShort()
const flags = complianceFlagCount()
const margin = grossMargin()
const hourlyCost = averageHourlyCost()
const handleQuickResolve = () => {
resolveAllShortages()
setJustResolved(true)
setTimeout(() => setJustResolved(false), 3000)
}
// Calculate SVG arc for radial gauge (perimeter ~ 238.76 for r=38)
const radius = 38
const circumference = 2 * Math.PI * radius
const strokeDashoffset = circumference - (coverage / 100) * circumference
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-12">
{/* ------------------------------------------------ CARD 1: Capacity & Allocation Radar (7 cols) */}
<div className="glass-card glass-card-interactive relative overflow-hidden rounded-[20px] p-5 shadow-xs xl:col-span-7">
{/* Subtle decorative gradient orb */}
<div className="pointer-events-none absolute -right-12 -top-12 size-48 rounded-full bg-primary/5 blur-2xl" />
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<span className="flex size-2 rounded-full bg-good animate-pulse" />
<span className="text-[11px] font-bold uppercase tracking-[0.1em] text-ink-3">
Live Workforce Capacity
</span>
</div>
<h3 className="mt-1 text-[19px] font-bold tracking-tight text-ink">
Operational Staffing Radar
</h3>
<p className="text-[13px] text-ink-2">
{STAFF_COUNTS.active} of {STAFF_COUNTS.total} team members deployed on active shifts
</p>
</div>
<Badge tone="good" size="sm" className="gap-1.5 px-2.5 py-1">
<span className="size-1.5 rounded-full bg-good-dot" />
92% Target Pace
</Badge>
</div>
<div className="mt-5 grid grid-cols-1 items-center gap-6 sm:grid-cols-12">
{/* Circular Capacity Gauge */}
<div className="flex items-center justify-center sm:col-span-5">
<div className="relative grid size-32 place-items-center">
<svg className="size-full -rotate-90" viewBox="0 0 96 96">
<circle
cx="48"
cy="48"
r={radius}
className="stroke-line-soft fill-none"
strokeWidth="9"
/>
<circle
cx="48"
cy="48"
r={radius}
className="stroke-primary fill-none transition-all duration-1000 ease-out"
strokeWidth="9"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
/>
</svg>
<div className="absolute text-center">
<span className="text-[28px] font-extrabold leading-none tracking-tight text-ink">
{coverage}%
</span>
<span className="mt-0.5 block text-[11px] font-medium text-ink-3">
Active
</span>
</div>
</div>
</div>
{/* Allocation Breakdown Pills & Dept Distribution */}
<div className="space-y-3 sm:col-span-7">
<div className="grid grid-cols-3 gap-2">
<div className="rounded-[12px] border border-line bg-surface-soft p-2.5 text-center">
<span className="text-[11px] font-semibold text-good">● Active</span>
<p className="mt-0.5 text-[16px] font-bold text-ink">{STAFF_COUNTS.active}</p>
</div>
<div className="rounded-[12px] border border-line bg-surface-soft p-2.5 text-center">
<span className="text-[11px] font-semibold text-warn">● Break</span>
<p className="mt-0.5 text-[16px] font-bold text-ink">{STAFF_COUNTS.onBreak}</p>
</div>
<div className="rounded-[12px] border border-line bg-surface-soft p-2.5 text-center">
<span className="text-[11px] font-semibold text-ink-3">● Off-duty</span>
<p className="mt-0.5 text-[16px] font-bold text-ink">{STAFF_COUNTS.unavailable}</p>
</div>
</div>
{/* Department mini distribution bar */}
<div className="rounded-[12px] border border-line/60 bg-surface/80 p-2.5">
<div className="flex items-center justify-between text-[11px] font-medium text-ink-3">
<span>Department Deployment</span>
<button
type="button"
onClick={() => navigate("/staff")}
className="inline-flex items-center gap-1 text-primary hover:underline"
>
Roster <ChevronRight className="size-3" />
</button>
</div>
<div className="mt-2 flex h-2 w-full overflow-hidden rounded-full bg-surface-sunk">
<div className="h-full bg-primary" style={{ width: "42%" }} title="Events: 42%" />
<div className="h-full bg-[#6366F1]" style={{ width: "34%" }} title="Logistics: 34%" />
<div className="h-full bg-[#10B981]" style={{ width: "24%" }} title="Facilities: 24%" />
</div>
<div className="mt-1.5 flex justify-between text-[10px] text-ink-3">
<span className="flex items-center gap-1">
<span className="size-1.5 rounded-full bg-primary" /> Events (42%)
</span>
<span className="flex items-center gap-1">
<span className="size-1.5 rounded-full bg-[#6366F1]" /> Logistics (34%)
</span>
<span className="flex items-center gap-1">
<span className="size-1.5 rounded-full bg-[#10B981]" /> Facilities (24%)
</span>
</div>
</div>
</div>
</div>
</div>
{/* ------------------------------------------------ CARD 2: Urgent Dispatch & Shortage Radar (5 cols) */}
<div
className={cn(
"glass-card glass-card-interactive relative flex flex-col justify-between overflow-hidden rounded-[20px] p-5 shadow-xs xl:col-span-5",
shortShifts.length > 0
? "border-warn-edge/70 bg-gradient-to-br from-warn-soft/60 via-surface to-surface"
: "border-good-edge/70 bg-gradient-to-br from-good-soft/50 via-surface to-surface"
)}
>
<div>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<span
className={cn(
"flex size-2 rounded-full",
shortShifts.length > 0 ? "bg-warn animate-ping" : "bg-good"
)}
/>
<span
className={cn(
"text-[11px] font-bold uppercase tracking-[0.1em]",
shortShifts.length > 0 ? "text-warn" : "text-good"
)}
>
{shortShifts.length > 0 ? "Dispatch Alert" : "Fulfillment Status"}
</span>
</div>
{shortShifts.length > 0 ? (
<Badge tone="warn" size="sm" className="gap-1 px-2">
<AlertTriangle className="size-3" />
Action Required
</Badge>
) : (
<Badge tone="good" size="sm" className="gap-1 px-2">
<CheckCircle2 className="size-3" />
100% Fulfilled
</Badge>
)}
</div>
<div className="mt-3">
{shortShifts.length > 0 ? (
<>
<div className="flex items-baseline gap-2">
<span className="text-[28px] font-black tracking-tight text-ink">
{shortShifts.length} Shifts Short
</span>
<span className="text-[13px] font-semibold text-warn">
({placesShort} open slots)
</span>
</div>
<p className="mt-1 text-[12.5px] leading-relaxed text-ink-2">
Staffing gaps detected in upcoming shifts today. Auto-assign pulls certified, available workers.
</p>
{/* Micro preview of affected venues */}
<div className="mt-3 space-y-1.5">
{shortShifts.slice(0, 2).map((s) => (
<div
key={s.ref}
className="flex items-center justify-between rounded-[8px] bg-surface/80 px-2.5 py-1.5 text-[12px] border border-line-soft"
>
<span className="font-medium text-ink truncate max-w-[180px]">
{s.location}
</span>
<span className="font-semibold text-warn">
{s.required - s.filled} needed by {s.time}
</span>
</div>
))}
</div>
</>
) : (
<>
<div className="flex items-baseline gap-2">
<span className="text-[26px] font-black tracking-tight text-good">
All Shifts Covered
</span>
</div>
<p className="mt-1 text-[13px] leading-relaxed text-ink-2">
Every active order has met its client required staffing quota. Operations running smoothly.
</p>
<div className="mt-3 flex items-center gap-2 rounded-[10px] bg-good-soft/70 p-2.5 text-[12px] text-good font-medium">
<CheckCircle2 className="size-4 shrink-0" />
<span>Next scheduled handover at 22:00 (Castleford Logistics)</span>
</div>
</>
)}
</div>
</div>
{/* Action Button at bottom of card */}
<div className="mt-4 pt-3 border-t border-line-soft/60">
{shortShifts.length > 0 ? (
<Button
className="w-full bg-gradient-to-r from-primary to-[#4338CA] text-white shadow-sm hover:opacity-95"
onClick={handleQuickResolve}
>
<Zap className="size-4 text-warn-dot" />
{justResolved ? "Allocating Staff..." : "⚡ Auto-Assign Gaps with Owliver"}
</Button>
) : (
<Button
variant="outline"
className="w-full justify-between"
onClick={() => navigate("/orders")}
>
<span>Inspect Full Schedule</span>
<ArrowRight className="size-4 text-ink-3" />
</Button>
)}
</div>
</div>
{/* ------------------------------------------------ CARD 3: Financial Stream & Gross Margin (6 cols) */}
<div className="glass-card glass-card-interactive relative overflow-hidden rounded-[20px] p-5 shadow-xs xl:col-span-6">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<span className="grid size-7 place-items-center rounded-[8px] bg-primary-soft text-primary">
<Banknote className="size-4" />
</span>
<div>
<span className="text-[11px] font-bold uppercase tracking-[0.1em] text-ink-3">
Live Labour Margin
</span>
<h4 className="text-[16px] font-bold text-ink">Payroll & Billing Stream</h4>
</div>
</div>
<Badge tone="good" size="sm" className="gap-1 font-semibold">
<Percent className="size-3" />
{margin}% Margin
</Badge>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
<div className="rounded-[14px] border border-line bg-surface-soft p-3">
<span className="text-[11px] font-medium text-ink-3">Billing Value</span>
<p className="mt-0.5 text-[20px] font-extrabold text-ink">
{money(PAYROLL_SUMMARY.revenue)}
</p>
<span className="text-[10.5px] text-good font-medium">Ready to invoice</span>
</div>
<div className="rounded-[14px] border border-line bg-surface-soft p-3">
<span className="text-[11px] font-medium text-ink-3">Payroll Accrual</span>
<p className="mt-0.5 text-[20px] font-extrabold text-ink">
{money(PAYROLL_SUMMARY.total)}
</p>
<span className="text-[10.5px] text-ink-3">{num(PAYROLL_SUMMARY.workedHours)} hrs</span>
</div>
<div className="rounded-[14px] border border-line bg-surface-soft p-3 col-span-2 sm:col-span-1">
<span className="text-[11px] font-medium text-ink-3">Labour Cost/Hr</span>
<p className="mt-0.5 text-[20px] font-extrabold text-primary">
${hourlyCost.toFixed(2)}
</p>
<span className="text-[10.5px] text-ink-3">Blended avg</span>
</div>
</div>
<div className="mt-4 flex items-center justify-between pt-2 border-t border-line-soft">
<div className="flex items-center gap-2 text-[12px] text-ink-2">
<span className="size-2 rounded-full bg-primary" />
<span>3 departmental cost centres active</span>
</div>
<button
type="button"
onClick={() => navigate("/payroll")}
className="inline-flex items-center gap-1 text-[12.5px] font-semibold text-primary hover:underline"
>
Payroll Breakdown <ArrowRight className="size-3.5" />
</button>
</div>
</div>
{/* ------------------------------------------------ CARD 4: Compliance & Risk Sentinel (6 cols) */}
<div className="glass-card glass-card-interactive relative overflow-hidden rounded-[20px] p-5 shadow-xs xl:col-span-6">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<span className="grid size-7 place-items-center rounded-[8px] bg-warn-soft text-warn">
<ShieldAlert className="size-4" />
</span>
<div>
<span className="text-[11px] font-bold uppercase tracking-[0.1em] text-ink-3">
Risk Sentinel
</span>
<h4 className="text-[16px] font-bold text-ink">Staff Compliance & Safety</h4>
</div>
</div>
<Badge tone={flags > 0 ? "warn" : "good"} size="sm">
{flags} Signals
</Badge>
</div>
<div className="mt-4 space-y-2">
{COMPLIANCE_RISKS.map((risk) => (
<div
key={risk.risk}
className="flex items-center justify-between rounded-[12px] border border-line bg-surface-soft px-3 py-2 text-[12.5px]"
>
<div className="flex items-center gap-2">
<span
className={cn(
"size-2 rounded-full",
risk.severity === "High"
? "bg-bad"
: risk.severity === "Medium"
? "bg-warn"
: "bg-primary"
)}
/>
<span className="font-semibold text-ink">{risk.risk}</span>
</div>
<div className="flex items-center gap-3">
<span className="text-ink-2 font-medium">
{risk.staffCount} staff affected
</span>
<span
className={cn(
"rounded-[6px] px-2 py-0.5 text-[10.5px] font-bold uppercase tracking-[0.05em]",
risk.severity === "High"
? "bg-bad-soft text-bad"
: risk.severity === "Medium"
? "bg-warn-soft text-warn"
: "bg-primary-soft text-primary"
)}
>
{risk.severity}
</span>
</div>
</div>
))}
</div>
<div className="mt-4 flex items-center justify-between pt-2 border-t border-line-soft">
<span className="text-[12px] text-ink-3">
All high-priority risks must be cleared before 18:00
</span>
<button
type="button"
onClick={() => navigate("/compliance")}
className="inline-flex items-center gap-1 text-[12.5px] font-semibold text-primary hover:underline"
>
Review Audit Trail <ArrowRight className="size-3.5" />
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,81 @@
import { SendHorizonal } from "lucide-react"
import { useEffect, useRef, useState } from "react"
import { cn } from "@/lib/utils"
/* ===========================================================================
Section 24 — the composer.
Enter sends, Shift+Enter adds a newline, send is disabled when empty, and the
textarea grows with the content up to a cap.
=========================================================================== */
export function OwliverComposer({
onSend,
disabled,
}: {
onSend: (text: string) => void
disabled?: boolean
}) {
const [value, setValue] = useState("")
const ref = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
const el = ref.current
if (!el) return
el.style.height = "auto"
el.style.height = `${Math.min(el.scrollHeight, 120)}px`
}, [value])
const canSend = value.trim().length > 0 && !disabled
function submit() {
if (!canSend) return
onSend(value.trim())
setValue("")
}
return (
<div className="owliver-composer border-t border-line bg-surface p-2.5">
<div
className={cn(
"flex items-end gap-1.5 rounded-[14px] border border-line bg-surface-soft px-2.5 py-1.5",
"transition-colors focus-within:border-primary focus-within:bg-surface"
)}
>
<textarea
ref={ref}
rows={1}
value={value}
disabled={disabled}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
submit()
}
}}
placeholder="Ask Owliver anything..."
aria-label="Ask Owliver anything"
className={cn(
"max-h-[120px] min-h-[24px] flex-1 resize-none bg-transparent py-1 text-[13px] leading-snug text-ink",
"placeholder:text-ink-3 focus:outline-none disabled:opacity-60"
)}
/>
<button
type="button"
onClick={submit}
disabled={!canSend}
aria-label="Send message"
className={cn(
"mb-0.5 grid size-7 shrink-0 place-items-center rounded-[9px] transition-colors",
canSend
? "bg-primary text-white hover:bg-[#1a45c0]"
: "bg-surface-sunk text-ink-3"
)}
>
<SendHorizonal className="size-3.5" />
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,217 @@
import { History, Maximize2, Minimize2, RotateCcw, Sparkles, X } from "lucide-react"
import { useLayoutEffect, useRef, useState } from "react"
import { cn } from "@/lib/utils"
import { useOwliver } from "@/owliver/OwliverProvider"
import { BlockView } from "@/owliver/blocks/BlockRenderer"
import { OwliverComposer } from "./OwliverComposer"
/* ===========================================================================
Sections 17 & 20 — the panel is presentation only.
All conversation state lives in OwliverProvider, so this component can be
unmounted (closed, or a sheet closing) without losing history.
=========================================================================== */
export function OwliverPanel({
onClose,
expanded,
onToggleExpand,
className,
}: {
onClose?: () => void
expanded?: boolean
onToggleExpand?: () => void
className?: string
}) {
const {
messages,
typing,
suggestions,
hasConversation,
history,
send,
runAction,
reset,
openHistoryEntry,
} = useOwliver()
const [historyOpen, setHistoryOpen] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
const el = scrollRef.current
if (el) el.scrollTop = el.scrollHeight
}, [messages, typing])
return (
<section className={cn("owliver", className)} aria-label="Owliver workforce intelligence">
{/* ------------------------------------------------- header (never scrolls) */}
<header className="owliver-header flex items-center gap-2 border-b border-line bg-surface px-3 py-2.5">
<img
src="/assets/owliver.png"
alt=""
width={32}
height={32}
className="size-8 shrink-0 rounded-[9px]"
/>
<div className="min-w-0 flex-1">
<div className="truncate text-[14px] font-bold leading-tight text-ink">Owliver</div>
<div className="truncate text-[11px] leading-tight text-ink-3">
Workforce Intelligence
</div>
</div>
<div className="flex shrink-0 items-center">
<button
type="button"
onClick={() => setHistoryOpen((v) => !v)}
aria-label="Conversation history"
aria-pressed={historyOpen}
className="grid size-6 place-items-center rounded-[7px] text-ink-3 transition-colors hover:bg-surface-sunk hover:text-ink"
>
<History className="size-3.5" />
</button>
<button
type="button"
onClick={() => {
setHistoryOpen(false)
reset()
}}
disabled={!hasConversation}
aria-label="Reset conversation"
className="grid size-6 place-items-center rounded-[7px] text-ink-3 transition-colors hover:bg-surface-sunk hover:text-ink disabled:opacity-40"
>
<RotateCcw className="size-3.5" />
</button>
{onToggleExpand && (
<button
type="button"
onClick={onToggleExpand}
aria-label={expanded ? "Collapse panel" : "Expand panel"}
className="grid size-6 place-items-center rounded-[7px] text-ink-3 transition-colors hover:bg-surface-sunk hover:text-ink"
>
{expanded ? <Minimize2 className="size-3.5" /> : <Maximize2 className="size-3.5" />}
</button>
)}
{onClose && (
<button
type="button"
onClick={onClose}
aria-label="Close Owliver"
className="grid size-6 place-items-center rounded-[7px] text-ink-3 transition-colors hover:bg-surface-sunk hover:text-ink"
>
<X className="size-3.5" />
</button>
)}
</div>
</header>
{/* ----------------------------------------- conversation (the only scroller) */}
<div ref={scrollRef} className="owliver-content scroll-slim px-3 py-3">
{historyOpen ? (
<div className="space-y-2">
<div className="px-0.5 text-[10.5px] font-bold uppercase tracking-[0.1em] text-ink-3">
Conversation history
</div>
{history.length === 0 ? (
<p className="rounded-[10px] border border-line bg-surface-soft px-3 py-4 text-center text-[12.5px] text-ink-3">
No saved conversations yet.
</p>
) : (
history.map((h) => (
<button
key={h.id}
type="button"
onClick={() => {
setHistoryOpen(false)
openHistoryEntry(h.id)
}}
className="w-full rounded-[10px] border border-line bg-surface px-3 py-2 text-left transition-colors hover:border-primary-edge hover:bg-primary-soft"
>
<div className="truncate text-[12.5px] font-semibold text-ink">{h.title}</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-ink-3">
<span className="capitalize">{h.context}</span>
<span>·</span>
<span>
{new Date(h.at).toLocaleString(undefined, {
day: "numeric",
month: "short",
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
</button>
))
)}
</div>
) : (
<div className="space-y-3">
{messages.map((m) =>
m.author === "user" ? (
<div key={m.id} className="flex flex-col items-end gap-1">
<span className="pr-1 text-[9.5px] font-bold uppercase tracking-[0.1em] text-ink-3">
You
</span>
<div className="max-w-[85%] rounded-[14px] rounded-br-[5px] bg-primary px-3 py-2 text-[13px] font-medium text-white">
{m.text}
</div>
</div>
) : (
<div key={m.id} className="animate-rise flex flex-col gap-1.5">
<span className="flex items-center gap-1.5 text-[9.5px] font-bold uppercase tracking-[0.1em] text-primary">
<Sparkles className="size-2.5" />
Owliver
</span>
<div className="space-y-2.5">
{m.blocks?.map((b, i) => (
<BlockView key={i} block={b} onAction={runAction} />
))}
</div>
</div>
)
)}
{typing && (
<div className="flex flex-col gap-1.5">
<span className="flex items-center gap-1.5 text-[9.5px] font-bold uppercase tracking-[0.1em] text-primary">
<Sparkles className="size-2.5" />
Owliver
</span>
<div className="flex items-center gap-1.5 px-1" aria-label="Owliver is typing">
{[0, 1, 2].map((i) => (
<span
key={i}
className="size-1.5 animate-typing rounded-full bg-primary"
style={{ animationDelay: `${i * 0.15}s` }}
/>
))}
</div>
</div>
)}
</div>
)}
</div>
{/* ------------------------------------ suggested prompts + fixed composer */}
<div className="owliver-composer shrink-0">
<div className="flex flex-wrap gap-1.5 border-t border-line-soft px-2.5 py-2">
{suggestions.map((p) => (
<button
key={p}
type="button"
onClick={() => send(p)}
className={cn(
"rounded-full border border-line bg-surface px-2.5 py-1 text-[11.5px] font-medium text-ink-2",
"transition-colors hover:border-primary-edge hover:bg-primary-soft hover:text-primary"
)}
>
{p}
</button>
))}
</div>
<OwliverComposer onSend={send} disabled={typing} />
</div>
</section>
)
}

View File

@@ -0,0 +1,196 @@
import {
columnFilteringFeature,
columnVisibilityFeature,
createCoreRowModel,
createFilteredRowModel,
createPaginatedRowModel,
createSortedRowModel,
filterFn_includesString,
flexRender,
globalFilteringFeature,
rowPaginationFeature,
rowSortingFeature,
sortFn_alphanumeric,
sortFn_basic,
sortFn_text,
tableFeatures,
useTable,
type ColumnDef,
type RowData,
type SortingState,
} from "@tanstack/react-table"
import { ArrowUpDown, ChevronLeft, ChevronRight } from "lucide-react"
import { useMemo, useState } from "react"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { cn } from "@/lib/utils"
/* ===========================================================================
A sortable, filterable, paginated table built on TanStack Table v9.
Horizontal overflow is contained by the <Table> wrapper, so a wide table
never makes the application itself scroll sideways (Section 21).
=========================================================================== */
const features = tableFeatures({
columnFilteringFeature,
columnVisibilityFeature,
globalFilteringFeature,
rowSortingFeature,
rowPaginationFeature,
coreRowModel: createCoreRowModel(),
sortedRowModel: createSortedRowModel(),
filteredRowModel: createFilteredRowModel(),
paginatedRowModel: createPaginatedRowModel(),
filterFns: { includesString: filterFn_includesString },
sortFns: {
alphanumeric: sortFn_alphanumeric,
basic: sortFn_basic,
text: sortFn_text,
},
})
type Features = typeof features
/** Column definition bound to this demo's feature set. */
export type DemoColumnDef<T extends RowData> = ColumnDef<Features, T>
export function DataTable<T extends RowData>({
columns,
data,
globalFilter,
pageSize = 8,
emptyMessage = "Nothing to show.",
onRowClick,
}: {
columns: DemoColumnDef<T>[]
data: T[]
globalFilter?: string
pageSize?: number
emptyMessage?: string
onRowClick?: (row: T) => void
}) {
const [sorting, setSorting] = useState<SortingState>([])
// Stable identities: a fresh `state`/`initialState` object on every render
// makes the table store notify, re-render, and notify again.
const state = useMemo(
() => ({ sorting, globalFilter: globalFilter ?? "" }),
[sorting, globalFilter]
)
const initialState = useMemo(
() => ({ pagination: { pageIndex: 0, pageSize } }),
[pageSize]
)
const table = useTable({
features,
data,
columns,
state,
onSortingChange: setSorting,
initialState,
globalFilterFn: "includesString",
})
const rows = table.getRowModel().rows
const pageCount = table.getPageCount()
const pageIndex = table.state.pagination?.pageIndex ?? 0
return (
<div>
<Table>
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id} className="hover:bg-surface-soft">
{hg.headers.map((header) => {
const sortable = header.column.getCanSort()
return (
<TableHead key={header.id}>
{header.isPlaceholder ? null : sortable ? (
<button
type="button"
onClick={header.column.getToggleSortingHandler()}
className="inline-flex items-center gap-1 transition-colors hover:text-ink-2"
>
{flexRender(header.column.columnDef.header, header.getContext())}
<ArrowUpDown
className={cn(
"size-3",
header.column.getIsSorted() ? "text-primary" : "text-ink-3/60"
)}
/>
</button>
) : (
flexRender(header.column.columnDef.header, header.getContext())
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{rows.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length} className="py-8 text-center text-ink-3">
{emptyMessage}
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow
key={row.id}
onClick={onRowClick ? () => onRowClick(row.original) : undefined}
className={cn(onRowClick && "cursor-pointer")}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
{pageCount > 1 && (
<div className="flex items-center justify-between gap-3 border-t border-line-soft px-3 py-2.5">
<p className="text-[12px] text-ink-3">
Page {pageIndex + 1} of {pageCount} ·{" "}
{table.getFilteredRowModel().rows.length} records
</p>
<div className="flex gap-1.5">
<Button
size="icon-sm"
variant="outline"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
aria-label="Previous page"
>
<ChevronLeft />
</Button>
<Button
size="icon-sm"
variant="outline"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
aria-label="Next page"
>
<ChevronRight />
</Button>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,112 @@
import type { LucideIcon } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress"
import type { Tone } from "@/data/types"
import { cn } from "@/lib/utils"
/* ===========================================================================
The KPI card from IMAGE 1: uppercase label, status chip, icon, headline
figure, supporting line and an optional progress bar.
=========================================================================== */
const SURFACE: Record<Tone, string> = {
neutral: "border-line bg-surface",
primary: "border-line bg-surface",
good: "border-line bg-surface",
warn: "border-warn-edge bg-warn-soft",
bad: "border-bad-edge bg-bad-soft",
}
const VALUE: Record<Tone, string> = {
neutral: "text-ink",
primary: "text-ink",
good: "text-ink",
warn: "text-ink",
bad: "text-bad",
}
const ICON_WRAP: Record<Tone, string> = {
neutral: "bg-surface-sunk text-ink-3",
primary: "bg-primary-soft text-primary",
good: "bg-good-soft text-good",
warn: "bg-warn-soft text-warn",
bad: "bg-bad-soft text-bad",
}
export interface MetricCardProps {
label: string
value: string
sub: string
icon: LucideIcon
badge?: string
badgeTone?: Tone
tone?: Tone
progress?: number
/** Compact form used on Home (section 10): no progress bar, tighter type. */
compact?: boolean
}
export function MetricCard({
label,
value,
sub,
icon: Icon,
badge,
badgeTone = "neutral",
tone = "neutral",
progress,
compact = false,
}: MetricCardProps) {
return (
<div
className={cn(
"flex min-w-0 flex-col rounded-[12px] border shadow-xs transition-shadow hover:shadow-sm",
compact ? "p-2 sm:p-2.5" : "p-2.5 sm:p-3",
SURFACE[tone]
)}
>
<div className="flex items-start justify-between gap-1.5">
<span className="min-w-0 truncate text-[10px] font-bold uppercase leading-tight tracking-[0.08em] text-ink-3">
{label}
</span>
<div className="flex shrink-0 items-center gap-1">
{badge && !compact && (
<Badge tone={badgeTone} size="sm" className="px-1.5 py-0 text-[9px] font-bold h-4">
{badge}
</Badge>
)}
<span
className={cn(
"grid size-5 shrink-0 place-items-center rounded-[6px]",
ICON_WRAP[tone === "neutral" ? "primary" : tone]
)}
>
<Icon className="size-3" />
</span>
</div>
</div>
<div
className={cn(
"mt-1.5 truncate font-bold leading-none",
compact ? "text-[18px]" : "text-[21px]",
VALUE[tone]
)}
>
{value}
</div>
<p
className={cn(
"truncate leading-tight text-ink-2",
compact ? "mt-0.5 text-[11px]" : "mt-1 text-[11.5px]"
)}
>
{sub}
</p>
{progress !== undefined && !compact && <Progress value={progress} className="mt-2 h-1" />}
</div>
)
}

View File

@@ -0,0 +1,70 @@
import type { ReactNode } from "react"
import { cn } from "@/lib/utils"
/* Page gutter. Its right edge is what separates MAIN from the Owliver rail. */
export function Page({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return (
<div className={cn("space-y-4 p-4 lg:p-5", className)}>{children}</div>
)
}
export function PageIntro({
title,
description,
actions,
}: {
title: string
description: string
actions?: ReactNode
}) {
return (
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-[19px] font-bold leading-tight text-ink">{title}</h2>
<p className="mt-0.5 text-[13px] text-ink-2">{description}</p>
</div>
{actions && <div className="flex flex-none flex-nowrap items-center gap-2">{actions}</div>}
</div>
)
}
export function SectionCard({
title,
icon: Icon,
action,
children,
className,
bodyClassName,
}: {
title: string
icon?: React.ComponentType<{ className?: string }>
action?: ReactNode
children: ReactNode
className?: string
bodyClassName?: string
}) {
return (
<section
className={cn(
"overflow-hidden rounded-[14px] border border-line bg-surface shadow-xs",
className
)}
>
<div className="flex items-center justify-between gap-3 border-b border-line-soft px-4 py-3">
<h3 className="flex min-w-0 items-center gap-2 text-[14px] font-semibold text-ink">
{Icon && <Icon className="size-4 shrink-0 text-primary" />}
<span className="truncate">{title}</span>
</h3>
{action && <div className="shrink-0">{action}</div>}
</div>
<div className={cn("p-4", bodyClassName)}>{children}</div>
</section>
)
}

View File

@@ -0,0 +1,73 @@
import { Badge } from "@/components/ui/badge"
import type { Tone } from "@/data/types"
/* One place that decides what colour a status is, so the whole product agrees. */
const TONES: Record<string, Tone> = {
// orders
Understaffed: "bad",
"In Progress": "primary",
Confirmed: "good",
Completed: "neutral",
Draft: "neutral",
// staff
Active: "good",
"On Break": "warn",
Unavailable: "bad",
// compliance
Verified: "good",
Warning: "warn",
Expired: "bad",
// tasks
"To Do": "neutral",
Blocked: "bad",
// severity
High: "bad",
Medium: "warn",
Low: "primary",
// invoices
Sent: "primary",
Paid: "good",
Overdue: "bad",
// payroll
Pending: "warn",
Approved: "primary",
// clients
"At Risk": "bad",
"On Hold": "warn",
Prospect: "primary",
// benefits
Open: "good",
Closed: "neutral",
Review: "warn",
// generic
Filled: "good",
}
export function StatusBadge({
value,
size,
}: {
value: string
size?: "sm" | "default"
}) {
return (
<Badge tone={TONES[value] ?? "neutral"} size={size}>
{value}
</Badge>
)
}
export function StatusDot({ value }: { value: string }) {
const tone = TONES[value] ?? "neutral"
const cls =
tone === "good"
? "bg-good-dot"
: tone === "warn"
? "bg-warn-dot"
: tone === "bad"
? "bg-bad-dot"
: tone === "primary"
? "bg-primary"
: "bg-ink-3"
return <span className={`size-2 shrink-0 rounded-full ${cls}`} />
}

View File

@@ -0,0 +1,55 @@
import { Search } from "lucide-react"
import type { ReactNode } from "react"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
/** Search + filter row shared by every directory page. */
export function Toolbar({
query,
onQuery,
placeholder = "Search…",
filters,
children,
}: {
query: string
onQuery: (v: string) => void
placeholder?: string
filters?: { value: string; onChange: (v: string) => void; options: string[]; label: string }[]
children?: ReactNode
}) {
return (
<div className="flex flex-wrap items-center gap-2 border-b border-line-soft px-4 py-3">
<div className="relative min-w-[180px] flex-1">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-ink-3" />
<Input
value={query}
onChange={(e) => onQuery(e.target.value)}
placeholder={placeholder}
className="pl-8"
/>
</div>
{filters?.map((f) => (
<Select key={f.label} value={f.value} onValueChange={f.onChange}>
<SelectTrigger className="min-w-[132px]">
<SelectValue placeholder={f.label} />
</SelectTrigger>
<SelectContent>
{f.options.map((o) => (
<SelectItem key={o} value={o}>
{o === "all" ? f.label : o}
</SelectItem>
))}
</SelectContent>
</Select>
))}
{children}
</div>
)
}

View File

@@ -0,0 +1,178 @@
import { useCallback, useEffect, useState } from "react"
import { Suspense } from "react"
import { Outlet, useLocation } from "react-router-dom"
import { OwliverPanel } from "@/components/owliver/OwliverPanel"
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
import { TooltipProvider } from "@/components/ui/tooltip"
import { useLayoutMode } from "@/hooks/useLayoutMode"
import { useOwliverContext } from "@/hooks/useOwliverContext"
import { cn } from "@/lib/utils"
import { OwliverProvider } from "@/owliver/OwliverProvider"
import { Sidebar } from "./Sidebar"
import { TopHeader } from "./TopHeader"
/* ===========================================================================
Section 18 — the app shell.
One CSS grid. Sidebar width and the presence of the Owliver column are both
driven by data attributes on the grid container, so collapsing the sidebar or
closing Owliver reflows the layout instead of overlaying it. No negative
margins, no fixed offsets.
=========================================================================== */
function RouteSkeleton() {
return (
<div className="p-4 lg:p-5">
<div className="h-6 w-56 animate-pulse rounded-md bg-surface-sunk" />
<div className="mt-4 grid gap-2.5 grid-cols-2 lg:grid-cols-4">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="h-[84px] animate-pulse rounded-[12px] bg-surface-sunk" />
))}
</div>
<div className="mt-4 h-[320px] animate-pulse rounded-[14px] bg-surface-sunk" />
</div>
)
}
function Shell() {
const layout = useLayoutMode()
const { pathname } = useLocation()
const [navOpen, setNavOpen] = useState(false)
const [owliverSheet, setOwliverSheet] = useState(false)
const [collapsed, setCollapsed] = useState(false)
const [owliverOpen, setOwliverOpen] = useState(true)
const [expanded, setExpanded] = useState(false)
const isDesktop = layout === "desktop"
/* Section 10 — never two Owlivers at once. Home is the central card's page,
so the rail (and its trigger) simply do not exist there; every other page
is the rail's. `data-owliver="closed"` drops the third grid column, so main
reflows into the space rather than leaving an empty rail behind. */
const isHome = pathname === "/"
const railVisible = isDesktop && owliverOpen && !isHome
useEffect(() => {
setNavOpen(false)
}, [pathname])
useEffect(() => {
if (!isDesktop) {
setExpanded(false)
} else {
setOwliverSheet(false)
}
}, [isDesktop])
// On desktop the rail is inline; elsewhere Owliver opens as an overlay.
const openOwliver = useCallback(() => {
if (isDesktop) setOwliverOpen(true)
else setOwliverSheet(true)
}, [isDesktop])
// Home has no Owliver overlay either — the central card is the whole surface.
useEffect(() => {
if (isHome) setOwliverSheet(false)
}, [isHome])
const sidebarCollapsed = isDesktop ? collapsed : layout === "tablet"
return (
<TooltipProvider delayDuration={200}>
<div
className="app-shell"
data-layout={layout}
data-sidebar={isDesktop && collapsed ? "collapsed" : "expanded"}
data-owliver={railVisible ? "open" : "closed"}
style={
expanded ? ({ "--owliver-width": "520px" } as React.CSSProperties) : undefined
}
>
{layout !== "mobile" && (
<aside className="app-sidebar">
<Sidebar
collapsed={sidebarCollapsed}
canToggle={isDesktop}
onToggle={() => setCollapsed((v) => !v)}
onOpenOwliver={railVisible || isHome ? undefined : openOwliver}
/>
</aside>
)}
<TopHeader
layout={layout}
onOpenNav={() => setNavOpen(true)}
onOpenOwliver={openOwliver}
owliverHidden={!railVisible && !isHome}
/>
<main className="app-main scroll-slim">
<Suspense fallback={<RouteSkeleton />}>
<Outlet />
</Suspense>
</main>
{railVisible && (
<div className="owliver-rail">
<OwliverPanel
expanded={expanded}
onToggleExpand={() => setExpanded((v) => !v)}
onClose={() => setOwliverOpen(false)}
/>
</div>
)}
</div>
{/* mobile navigation drawer — the same complete navigation */}
<Sheet open={navOpen} onOpenChange={setNavOpen}>
<SheetContent side="left" className="p-0" hideClose>
<SheetTitle className="sr-only">Workforce navigation</SheetTitle>
<Sidebar
onNavigate={() => setNavOpen(false)}
onOpenOwliver={
isHome
? undefined
: () => {
setNavOpen(false)
openOwliver()
}
}
/>
</SheetContent>
</Sheet>
{/* tablet panel / mobile bottom sheet — never on Home */}
<Sheet open={owliverSheet && !isHome} onOpenChange={setOwliverSheet}>
<SheetContent
side={layout === "mobile" ? "bottom" : "right"}
hideClose
className={cn(
"overflow-hidden border-0 bg-transparent p-0 shadow-none",
layout === "mobile" ? "h-[88dvh]" : "w-[400px] max-w-[94vw] p-2"
)}
>
<SheetTitle className="sr-only">Owliver workforce intelligence</SheetTitle>
<OwliverPanel
onClose={() => setOwliverSheet(false)}
className={cn(layout === "mobile" && "rounded-b-none border-x-0 border-b-0")}
/>
</SheetContent>
</Sheet>
</TooltipProvider>
)
}
export function AppShell() {
const context = useOwliverContext()
// The provider sits above the panel so closing Owliver — or the sheet
// unmounting on tablet/mobile — never discards the conversation.
return (
<OwliverProvider context={context}>
<Shell />
</OwliverProvider>
)
}

View File

@@ -0,0 +1,405 @@
import {
Check,
ChevronRight,
ChevronsUpDown,
PanelLeftClose,
PanelLeftOpen,
} from "lucide-react"
import { useState } from "react"
import { NavLink, useLocation, useNavigate } from "react-router-dom"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { NAV_GROUPS, SETTINGS_NAV, type NavItem } from "@/config/navigation"
import { TEAMS } from "@/data/mockData"
import { attentionCount, complianceFlagCount, unreadMessages } from "@/data/selectors"
import { useWorkforceRevision } from "@/data/store"
import { cn } from "@/lib/utils"
/* ===========================================================================
The complete Workforce navigation (Section 4 / 9 / 29).
Nothing is dropped at smaller sizes: the rail keeps every item behind an
icon + tooltip, and the mobile drawer renders this exact component.
=========================================================================== */
function useBadgeValue(kind: NavItem["badge"]) {
useWorkforceRevision()
if (kind === "unread") return unreadMessages()
if (kind === "attention") return attentionCount()
if (kind === "compliance") return complianceFlagCount()
return 0
}
function ItemBadge({ kind }: { kind: NavItem["badge"] }) {
const value = useBadgeValue(kind)
if (!value) return null
return (
<span
className={cn(
"ml-auto grid h-[18px] min-w-[18px] shrink-0 place-items-center rounded-full px-1 text-[10.5px] font-bold",
kind === "unread"
? "bg-primary text-white"
: "bg-warn-soft text-warn border border-warn-edge"
)}
>
{value}
</span>
)
}
const itemClasses = (active: boolean) =>
cn(
"group flex w-full items-center gap-2.5 rounded-[10px] border px-2.5 py-2 text-[13.5px] font-medium transition-colors",
active
? "border-primary bg-primary text-white shadow-xs"
: "border-transparent text-ink-2 hover:bg-surface-sunk hover:text-ink"
)
function NavEntry({
item,
collapsed,
onNavigate,
}: {
item: NavItem
collapsed: boolean
onNavigate?: () => void
}) {
const location = useLocation()
const active =
item.href === "/"
? location.pathname === "/"
: location.pathname.startsWith(item.href)
const [open, setOpen] = useState(false)
const Icon = item.icon
if (collapsed) {
return (
<Tooltip>
<TooltipTrigger asChild>
<NavLink
to={item.href}
onClick={onNavigate}
aria-label={item.label}
className={cn(
"relative mx-auto grid size-10 place-items-center rounded-[10px] border transition-colors",
active
? "border-primary bg-primary text-white"
: "border-transparent text-ink-2 hover:bg-surface-sunk hover:text-ink"
)}
>
<Icon className="size-[18px]" />
{item.badge && <RailDot kind={item.badge} />}
</NavLink>
</TooltipTrigger>
<TooltipContent side="right">{item.label}</TooltipContent>
</Tooltip>
)
}
if (!item.children) {
return (
<NavLink to={item.href} onClick={onNavigate} className={({ isActive }) => itemClasses(isActive)}>
<Icon className={cn("size-[17px] shrink-0", active ? "text-white" : "text-ink-3")} />
<span className="truncate">{item.label}</span>
<ItemBadge kind={item.badge} />
</NavLink>
)
}
return (
<Collapsible open={open} onOpenChange={setOpen}>
<div className={cn(itemClasses(active), "gap-0 p-0 pr-1")}>
<NavLink
to={item.href}
onClick={onNavigate}
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-[10px] px-2.5 py-2"
>
<Icon className={cn("size-[17px] shrink-0", active ? "text-white" : "text-ink-3")} />
<span className="truncate">{item.label}</span>
<ItemBadge kind={item.badge} />
</NavLink>
<CollapsibleTrigger asChild>
<button
type="button"
aria-label={`${open ? "Collapse" : "Expand"} ${item.label}`}
className={cn(
"grid size-6 shrink-0 place-items-center rounded-[6px] transition-colors",
active ? "text-white/80 hover:bg-white/15" : "text-ink-3 hover:bg-line-soft"
)}
>
<ChevronRight className={cn("size-3.5 transition-transform", open && "rotate-90")} />
</button>
</CollapsibleTrigger>
</div>
<CollapsibleContent className="overflow-hidden">
<div className="mt-0.5 ml-[18px] space-y-0.5 border-l border-line pl-2.5">
{item.children.map((child) => (
<NavLink
key={child.href}
to={child.href}
onClick={onNavigate}
className={({ isActive }) =>
cn(
"flex items-center gap-2 rounded-[8px] px-2 py-1.5 text-[12.5px] font-medium transition-colors",
isActive && location.search === new URL(child.href, "http://x").search
? "bg-primary-soft text-primary"
: "text-ink-3 hover:bg-surface-sunk hover:text-ink-2"
)
}
>
<child.icon className="size-3.5 shrink-0" />
<span className="truncate">{child.label}</span>
</NavLink>
))}
</div>
</CollapsibleContent>
</Collapsible>
)
}
function RailDot({ kind }: { kind: NonNullable<NavItem["badge"]> }) {
const value = useBadgeValue(kind)
if (!value) return null
return (
<span
className={cn(
"absolute -right-1 -top-1 grid h-[16px] min-w-[16px] place-items-center rounded-full px-1",
"text-[9.5px] font-bold ring-2 ring-surface",
kind === "unread"
? "bg-primary text-white"
: "bg-warn-dot text-[#5c3a00]"
)}
>
{value}
</span>
)
}
function TeamSwitcher({ collapsed }: { collapsed: boolean }) {
const [teamId, setTeamId] = useState(TEAMS[0].id)
const team = TEAMS.find((t) => t.id === teamId) ?? TEAMS[0]
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="Switch team"
className={cn(
"flex w-full items-center gap-2.5 rounded-[10px] border border-line bg-surface p-2 text-left transition-colors hover:bg-surface-sunk",
collapsed && "justify-center p-1.5"
)}
>
<span className="grid size-8 shrink-0 place-items-center rounded-[8px] border border-line bg-surface-sunk text-[11px] font-bold text-ink-2">
{team.initials}
</span>
{!collapsed && (
<>
<span className="min-w-0 flex-1">
<span className="block text-[10.5px] font-medium text-ink-3">Team</span>
<span className="block truncate text-[12.5px] font-semibold text-ink">
{team.name}
</span>
</span>
<ChevronsUpDown className="size-3.5 shrink-0 text-ink-3" />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="top" className="w-[248px]">
<DropdownMenuLabel>Team access</DropdownMenuLabel>
{TEAMS.map((t) => (
<DropdownMenuItem key={t.id} onSelect={() => setTeamId(t.id)}>
<span className="grid size-7 shrink-0 place-items-center rounded-[7px] border border-line bg-surface-sunk text-[10.5px] font-bold text-ink-2">
{t.initials}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold text-ink">{t.name}</span>
<span className="block text-[11px] text-ink-3">{t.members} members</span>
</span>
{t.id === teamId && <Check className="size-3.5 text-primary" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
export function Sidebar({
collapsed = false,
canToggle = false,
onToggle,
onNavigate,
onOpenOwliver,
}: {
collapsed?: boolean
canToggle?: boolean
onToggle?: () => void
onNavigate?: () => void
onOpenOwliver?: () => void
}) {
const navigate = useNavigate()
return (
<div className="flex h-full min-h-0 flex-col border-r border-line bg-surface">
{/* brand */}
<div
className={cn(
"flex h-16 shrink-0 items-center border-b border-line",
collapsed ? "justify-center px-2" : "justify-between gap-2.5 px-4"
)}
>
<button
type="button"
onClick={() => {
navigate("/")
onNavigate?.()
}}
className={cn(
"flex min-w-0 items-center",
collapsed ? "justify-center" : "gap-2.5"
)}
aria-label="KROW home"
>
<img
src="/assets/krow-logo.png"
alt="KROW"
className={cn(
"shrink-0 object-contain",
collapsed ? "h-[17px] max-w-[52px] w-auto" : "h-[22px] w-auto"
)}
/>
</button>
{canToggle && !collapsed && (
<button
type="button"
onClick={onToggle}
aria-label="Collapse sidebar"
aria-expanded
className="grid size-7 shrink-0 place-items-center rounded-[8px] text-ink-3 transition-colors hover:bg-surface-sunk hover:text-ink"
>
<PanelLeftClose className="size-4" />
</button>
)}
</div>
{canToggle && collapsed && (
<div className="flex shrink-0 justify-center border-b border-line-soft py-2">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onToggle}
aria-label="Expand sidebar"
aria-expanded={false}
className="grid size-9 place-items-center rounded-[10px] text-ink-3 transition-colors hover:bg-surface-sunk hover:text-ink"
>
<PanelLeftOpen className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="right">Expand sidebar</TooltipContent>
</Tooltip>
</div>
)}
{/* navigation */}
<nav
className={cn(
"scroll-slim min-h-0 flex-1 overflow-y-auto px-3 py-3",
collapsed && "px-2"
)}
aria-label="Workforce navigation"
>
<div className="space-y-4">
{NAV_GROUPS.map((group) => (
<div key={group.title}>
{!collapsed && (
<div className="px-2.5 pb-1.5 text-[10px] font-bold uppercase tracking-[0.13em] text-ink-3">
{group.title}
</div>
)}
{collapsed && <div className="mx-auto mb-2 h-px w-6 bg-line-soft" />}
<div className={cn("space-y-0.5", collapsed && "space-y-1.5")}>
{group.items.map((item) => (
<NavEntry
key={item.href}
item={item}
collapsed={collapsed}
onNavigate={onNavigate}
/>
))}
</div>
</div>
))}
{/* settings sits at the end of the scroll area, as in production */}
<div>
{!collapsed && (
<div className="px-2.5 pb-1.5 text-[10px] font-bold uppercase tracking-[0.13em] text-ink-3">
Settings
</div>
)}
{collapsed && <div className="mx-auto mb-2 h-px w-6 bg-line-soft" />}
<NavEntry item={SETTINGS_NAV} collapsed={collapsed} onNavigate={onNavigate} />
</div>
</div>
</nav>
{/* Owliver + team switcher + legal footer */}
<div className={cn("shrink-0 border-t border-line p-3", collapsed && "p-2")}>
{onOpenOwliver &&
(collapsed ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onOpenOwliver}
aria-label="Open Owliver"
className="mx-auto mb-2 grid size-10 place-items-center rounded-[10px] border border-primary-edge bg-primary-soft transition-colors hover:bg-primary-mid"
>
<img src="/assets/owliver.png" alt="" className="size-5 rounded-[6px]" />
</button>
</TooltipTrigger>
<TooltipContent side="right">Owliver</TooltipContent>
</Tooltip>
) : (
<button
type="button"
onClick={onOpenOwliver}
className="mb-2 flex w-full items-center gap-2.5 rounded-[10px] border border-primary-edge bg-primary-soft px-2.5 py-2 text-left transition-colors hover:bg-primary-mid"
>
<img src="/assets/owliver.png" alt="" className="size-5 shrink-0 rounded-[6px]" />
<span className="min-w-0 flex-1 truncate text-[13.5px] font-semibold text-primary">
Owliver
</span>
<span className="shrink-0 text-[10px] font-bold uppercase tracking-[0.1em] text-primary/60">
AI
</span>
</button>
))}
<TeamSwitcher collapsed={collapsed} />
{!collapsed && (
<p className="mt-2.5 px-1 text-[10.5px] text-ink-3">
Terms · Privacy · © 2026 KROW
</p>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,159 @@
import { Bell, ChevronDown, LogOut, Menu, Search, Settings, User } from "lucide-react"
import { useLocation, useNavigate } from "react-router-dom"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { PAGE_TITLES } from "@/config/navigation"
import { TEAMS, TODAY, USER } from "@/data/mockData"
import { attentionCount } from "@/data/selectors"
import { useWorkforceRevision } from "@/data/store"
import type { LayoutMode } from "@/hooks/useLayoutMode"
import { cn } from "@/lib/utils"
/* ===========================================================================
Section 8 — the global header.
Deliberately NOT a second navigation bar. It carries context on the left and
account controls on the right; the sidebar owns navigation.
=========================================================================== */
export function TopHeader({
layout,
onOpenNav,
onOpenOwliver,
owliverHidden,
}: {
layout: LayoutMode
onOpenNav: () => void
onOpenOwliver: () => void
owliverHidden: boolean
}) {
useWorkforceRevision()
const { pathname } = useLocation()
const navigate = useNavigate()
const title = PAGE_TITLES[pathname] ?? "Home"
const alerts = attentionCount()
return (
<header className="app-header flex h-16 items-center gap-3 border-b border-line bg-surface px-4">
{layout !== "desktop" && (
<button
type="button"
onClick={onOpenNav}
aria-label="Open navigation"
className="grid size-9 shrink-0 place-items-center rounded-[10px] border border-line text-ink-2 transition-colors hover:bg-surface-sunk hover:text-ink lg:hidden"
>
<Menu className="size-4" />
</button>
)}
{/* ------------------------------------------------ left: page context */}
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="min-w-0">
<h1 className="truncate text-[17px] font-bold leading-tight text-ink">
{title}
</h1>
<p className="truncate text-[11.5px] leading-tight text-ink-3">
{TEAMS[0].name} · {TODAY.label}
</p>
</div>
</div>
{/* --------------------------------------------------- right: controls */}
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
aria-label="Search"
className={cn(
"hidden h-9 w-64 items-center justify-between rounded-[10px] border border-line bg-surface-soft px-3 text-ink-3 transition-colors md:flex lg:w-72",
"hover:border-primary-edge hover:text-ink-2"
)}
>
<div className="flex items-center gap-2">
<Search className="size-3.5 shrink-0" />
<span className="text-[12.5px]">Search</span>
</div>
<kbd className="rounded-[5px] border border-line bg-surface px-1.5 py-0.5 text-[10px] font-semibold text-ink-3">
⌘K
</kbd>
</button>
{owliverHidden && (
<button
type="button"
onClick={onOpenOwliver}
aria-label="Open Owliver"
className="flex items-center gap-1.5 rounded-[10px] border border-primary-edge bg-primary-soft px-2.5 py-1.5 text-[12.5px] font-semibold text-primary transition-colors hover:bg-primary-mid"
>
<img src="/assets/owliver.png" alt="" className="size-4 rounded-[5px]" />
<span className="hidden sm:inline">Owliver</span>
</button>
)}
<button
type="button"
aria-label={`Notifications: ${alerts} needing attention`}
className="relative grid size-9 place-items-center rounded-[10px] border border-line text-ink-2 transition-colors hover:bg-surface-sunk hover:text-ink"
>
<Bell className="size-4" />
{alerts > 0 && (
<span className="absolute right-1.5 top-1.5 size-2 rounded-full bg-primary ring-2 ring-surface" />
)}
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex items-center gap-2 rounded-[10px] border border-line py-1 pl-1 pr-2 transition-colors hover:bg-surface-sunk"
aria-label="Account menu"
>
<Avatar>
<AvatarFallback>{USER.initials}</AvatarFallback>
</Avatar>
<span className="hidden text-[13px] font-semibold text-ink sm:inline">
{USER.name}
</span>
<ChevronDown className="size-3.5 shrink-0 text-ink-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[240px]">
<div className="flex items-center gap-2.5 px-2.5 py-2">
<Avatar className="size-9">
<AvatarFallback>{USER.initials}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="truncate text-[13px] font-semibold text-ink">
{USER.fullName}
</div>
<div className="truncate text-[11px] text-ink-3">{USER.email}</div>
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuLabel>Account</DropdownMenuLabel>
<DropdownMenuItem onSelect={() => navigate("/settings?tab=general")}>
<User />
Profile
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => navigate("/settings")}>
<Settings />
Workspace settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<LogOut />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
)
}

View File

@@ -0,0 +1,34 @@
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import * as React from "react"
import { cn } from "@/lib/utils"
export const Avatar = React.forwardRef<
React.ComponentRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = "Avatar"
export const AvatarFallback = React.forwardRef<
React.ComponentRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex size-full items-center justify-center rounded-full bg-primary text-[11px] font-bold text-white",
className
)}
{...props}
/>
))
AvatarFallback.displayName = "AvatarFallback"

View File

@@ -0,0 +1,36 @@
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center gap-1.5 rounded-full border font-semibold whitespace-nowrap",
{
variants: {
tone: {
neutral: "bg-surface-sunk border-line text-ink-2",
primary: "bg-primary-soft border-primary-edge text-primary",
good: "bg-good-soft border-good-edge text-good",
warn: "bg-warn-soft border-warn-edge text-warn",
bad: "bg-bad-soft border-bad-edge text-bad",
},
size: {
sm: "px-2 py-0.5 text-[10.5px]",
default: "px-2.5 py-0.5 text-[11.5px]",
},
},
defaultVariants: { tone: "neutral", size: "default" },
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof badgeVariants> {}
export function Badge({ className, tone, size, ...props }: BadgeProps) {
return (
<span className={cn(badgeVariants({ tone, size }), className)} {...props} />
)
}
export { badgeVariants }

View File

@@ -0,0 +1,52 @@
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[10px] text-sm font-semibold transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-white hover:bg-[#1a45c0] shadow-xs",
outline:
"border border-line bg-surface text-ink hover:bg-surface-sunk hover:border-primary-edge",
soft: "bg-primary-soft text-primary border border-primary-edge hover:bg-primary-mid",
ghost: "text-ink-2 hover:bg-surface-sunk hover:text-ink",
danger: "bg-bad text-white hover:bg-[#a51919]",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
sm: "h-8 px-3 text-[13px] [&_svg]:size-3.5",
default: "h-9 px-4 [&_svg]:size-4",
lg: "h-11 px-5 [&_svg]:size-4",
icon: "h-9 w-9 [&_svg]:size-4",
"icon-sm": "h-8 w-8 [&_svg]:size-4",
},
},
defaultVariants: { variant: "default", size: "default" },
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { buttonVariants }

View File

@@ -0,0 +1,56 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-[14px] border border-line bg-surface shadow-xs",
className
)}
{...props}
/>
))
Card.displayName = "Card"
export const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"flex items-center justify-between gap-3 px-4 py-3 border-b border-line-soft",
className
)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
export const CardTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"flex items-center gap-2 text-[14px] font-semibold text-ink",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
export const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-4", className)} {...props} />
))
CardContent.displayName = "CardContent"

View File

@@ -0,0 +1,5 @@
export {
Root as Collapsible,
CollapsibleTrigger,
CollapsibleContent,
} from "@radix-ui/react-collapsible"

View File

@@ -0,0 +1,71 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import * as React from "react"
import { cn } from "@/lib/utils"
export const DropdownMenu = DropdownMenuPrimitive.Root
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
export const DropdownMenuGroup = DropdownMenuPrimitive.Group
export const DropdownMenuContent = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 6, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-100 min-w-[200px] overflow-hidden rounded-[12px] border border-line bg-surface p-1.5 shadow-lg",
"data-[state=open]:animate-rise",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = "DropdownMenuContent"
export const DropdownMenuItem = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center gap-2.5 rounded-[8px] px-2.5 py-2 text-[13px] font-medium text-ink-2 outline-none",
"focus:bg-surface-sunk focus:text-ink data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"[&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-ink-3",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = "DropdownMenuItem"
export const DropdownMenuLabel = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2.5 py-1.5 text-[10.5px] font-bold uppercase tracking-[0.13em] text-ink-3",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = "DropdownMenuLabel"
export const DropdownMenuSeparator = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1.5 my-1.5 h-px bg-line-soft", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = "DropdownMenuSeparator"

View File

@@ -0,0 +1,20 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export const Input = React.forwardRef<
HTMLInputElement,
React.InputHTMLAttributes<HTMLInputElement>
>(({ className, ...props }, ref) => (
<input
ref={ref}
className={cn(
"h-9 w-full rounded-[10px] border border-line bg-surface px-3 text-[13px] text-ink",
"placeholder:text-ink-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 focus-visible:border-primary",
"disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
))
Input.displayName = "Input"

View File

@@ -0,0 +1,29 @@
import { cn } from "@/lib/utils"
export function Progress({
value,
className,
barClassName,
}: {
value: number
className?: string
barClassName?: string
}) {
return (
<div
role="progressbar"
aria-valuenow={Math.round(value)}
aria-valuemin={0}
aria-valuemax={100}
className={cn("h-1.5 w-full overflow-hidden rounded-full bg-surface-sunk", className)}
>
<div
className={cn(
"h-full rounded-full bg-primary motion-safe:transition-[width] motion-safe:duration-500",
barClassName
)}
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
/>
</div>
)
}

View File

@@ -0,0 +1,73 @@
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
export const Select = SelectPrimitive.Root
export const SelectValue = SelectPrimitive.Value
export const SelectTrigger = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 items-center justify-between gap-2 rounded-[10px] border border-line bg-surface px-3 text-[13px] font-medium text-ink",
"hover:border-primary-edge focus:outline-none focus:ring-2 focus:ring-primary/30 data-[placeholder]:text-ink-3",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="size-3.5 shrink-0 text-ink-3" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = "SelectTrigger"
export const SelectContent = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
position={position}
className={cn(
"z-100 min-w-[var(--radix-select-trigger-width)] overflow-hidden rounded-[12px] border border-line bg-surface p-1.5 shadow-lg",
position === "popper" && "translate-y-1",
className
)}
{...props}
>
<SelectPrimitive.Viewport>{children}</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = "SelectContent"
export const SelectItem = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center gap-2 rounded-[8px] py-2 pl-2.5 pr-8 text-[13px] font-medium text-ink-2 outline-none",
"focus:bg-surface-sunk focus:text-ink data-[state=checked]:text-primary",
className
)}
{...props}
>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
<span className="absolute right-2.5 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="size-3.5" />
</SelectPrimitive.ItemIndicator>
</span>
</SelectPrimitive.Item>
))
SelectItem.displayName = "SelectItem"

View File

@@ -0,0 +1,22 @@
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import * as React from "react"
import { cn } from "@/lib/utils"
export const Separator = React.forwardRef<
React.ComponentRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-line",
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
className
)}
{...props}
/>
))
Separator.displayName = "Separator"

View File

@@ -0,0 +1,52 @@
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
export const Sheet = DialogPrimitive.Root
export const SheetTrigger = DialogPrimitive.Trigger
export const SheetClose = DialogPrimitive.Close
export const SheetTitle = DialogPrimitive.Title
export const SheetDescription = DialogPrimitive.Description
const sheetVariants = cva(
"fixed z-100 bg-surface shadow-lg transition-transform duration-300 ease-out flex flex-col",
{
variants: {
side: {
left: "inset-y-0 left-0 w-[276px] border-r border-line data-[state=closed]:-translate-x-full",
right:
"inset-y-0 right-0 w-[380px] max-w-[92vw] border-l border-line data-[state=closed]:translate-x-full",
bottom:
"inset-x-0 bottom-0 h-[88dvh] rounded-t-[20px] border-t border-line data-[state=closed]:translate-y-full",
},
},
defaultVariants: { side: "left" },
}
)
export const SheetContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> &
VariantProps<typeof sheetVariants> & { hideClose?: boolean }
>(({ className, children, side, hideClose, ...props }, ref) => (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-100 bg-[#111c44]/35 backdrop-blur-[2px]" />
<DialogPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
{!hideClose && (
<DialogPrimitive.Close className="absolute right-3 top-3 grid size-8 place-items-center rounded-[9px] text-ink-3 transition-colors hover:bg-surface-sunk hover:text-ink">
<X className="size-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
))
SheetContent.displayName = "SheetContent"

View File

@@ -0,0 +1,23 @@
import * as SwitchPrimitive from "@radix-ui/react-switch"
import * as React from "react"
import { cn } from "@/lib/utils"
export const Switch = React.forwardRef<
React.ComponentRef<typeof SwitchPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitive.Root
ref={ref}
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30",
"data-[state=checked]:bg-primary data-[state=unchecked]:bg-[#cbd8ea]",
className
)}
{...props}
>
<SwitchPrimitive.Thumb className="pointer-events-none block size-4 rounded-full bg-white shadow-sm transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0" />
</SwitchPrimitive.Root>
))
Switch.displayName = "Switch"

View File

@@ -0,0 +1,75 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="w-full overflow-x-auto scroll-slim">
<table
ref={ref}
className={cn("w-full caption-bottom border-collapse text-[13px]", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
export const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("bg-surface-soft", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
export const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody ref={ref} className={cn("", className)} {...props} />
))
TableBody.displayName = "TableBody"
export const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b border-line-soft last:border-0 transition-colors hover:bg-surface-soft",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
export const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-9 whitespace-nowrap px-3 text-left align-middle text-[11.5px] font-semibold uppercase tracking-[0.06em] text-ink-3",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
export const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("px-3 py-2.5 align-middle text-ink-2", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"

View File

@@ -0,0 +1,50 @@
import * as TabsPrimitive from "@radix-ui/react-tabs"
import * as React from "react"
import { cn } from "@/lib/utils"
export const Tabs = TabsPrimitive.Root
export const TabsList = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex items-center gap-1 rounded-[10px] border border-line bg-surface-sunk p-1",
className
)}
{...props}
/>
))
TabsList.displayName = "TabsList"
export const TabsTrigger = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center gap-1.5 whitespace-nowrap rounded-[7px] px-3 py-1.5 text-[13px] font-semibold text-ink-2 transition-colors",
"hover:text-ink data-[state=active]:bg-surface data-[state=active]:text-primary data-[state=active]:shadow-xs",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30",
className
)}
{...props}
/>
))
TabsTrigger.displayName = "TabsTrigger"
export const TabsContent = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn("mt-4 focus-visible:outline-none", className)}
{...props}
/>
))
TabsContent.displayName = "TabsContent"

View File

@@ -0,0 +1,26 @@
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import * as React from "react"
import { cn } from "@/lib/utils"
export const TooltipProvider = TooltipPrimitive.Provider
export const Tooltip = TooltipPrimitive.Root
export const TooltipTrigger = TooltipPrimitive.Trigger
export const TooltipContent = React.forwardRef<
React.ComponentRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 8, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-100 rounded-[8px] bg-ink px-2.5 py-1.5 text-[12px] font-medium text-white shadow-md",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = "TooltipContent"

205
src/config/navigation.ts Normal file
View File

@@ -0,0 +1,205 @@
import {
Activity,
BarChart3,
Banknote,
Bell,
BriefcaseBusiness,
Building2,
CalendarDays,
CheckSquare,
ClipboardList,
CreditCard,
DollarSign,
FileSpreadsheet,
FileStack,
FolderKanban,
Gift,
LayoutDashboard,
LifeBuoy,
MessageSquare,
Settings,
Shirt,
ShieldCheck,
SlidersHorizontal,
Store,
UserRoundPlus,
Users,
type LucideIcon,
} from "lucide-react"
/* ===========================================================================
Workforce information architecture.
Mirrors the `vendor` workspace in the production portal so nothing from the
live product is lost. Sub-items deliberately resolve to a tab on the parent
page rather than to a separate product area.
=========================================================================== */
export interface NavChild {
label: string
href: string
icon: LucideIcon
}
export interface NavItem {
label: string
href: string
icon: LucideIcon
children?: NavChild[]
badge?: "unread" | "attention" | "compliance"
}
export interface NavGroup {
title: string
items: NavItem[]
}
export const NAV_GROUPS: NavGroup[] = [
{
title: "Workspace",
items: [
{ label: "Home", href: "/", icon: LayoutDashboard },
{ label: "Performance", href: "/performance", icon: BarChart3 },
],
},
{
title: "Operations",
items: [
{
label: "Orders",
href: "/orders",
icon: ClipboardList,
badge: "attention",
children: [
{ label: "All Orders", href: "/orders?tab=all", icon: ClipboardList },
{ label: "Create Order", href: "/orders?tab=create", icon: UserRoundPlus },
{ label: "Quotes", href: "/orders?tab=quotes", icon: FileStack },
],
},
{ label: "Staff Availability", href: "/availability", icon: Users },
{ label: "Task Board", href: "/tasks", icon: CheckSquare },
],
},
{
title: "Workforce",
items: [
{
label: "Staff Directory",
href: "/staff",
icon: Users,
children: [
{ label: "Staff", href: "/staff?tab=staff", icon: Users },
{ label: "Teams", href: "/staff?tab=teams", icon: BriefcaseBusiness },
],
},
{
label: "Compliance",
href: "/compliance",
icon: ShieldCheck,
badge: "compliance",
children: [
{ label: "Staff Certificates", href: "/compliance?tab=certificates", icon: ShieldCheck },
{ label: "Employee Documents", href: "/compliance?tab=documents", icon: FolderKanban },
{ label: "Attire Review", href: "/compliance?tab=attire", icon: Shirt },
{ label: "Compliance Catalog", href: "/compliance?tab=catalog", icon: FileStack },
],
},
{ label: "Benefits", href: "/benefits", icon: Gift },
],
},
{
title: "Business",
items: [
{
label: "Clients",
href: "/clients",
icon: Store,
children: [
{ label: "Client Accounts", href: "/clients?tab=accounts", icon: Store },
{ label: "Service Rates", href: "/clients?tab=rates", icon: DollarSign },
],
},
],
},
{
title: "Finance",
items: [
{ label: "Invoices", href: "/invoices", icon: FileSpreadsheet },
{ label: "Payroll", href: "/payroll", icon: Banknote },
],
},
{
title: "Insights",
items: [
{
label: "Reports",
href: "/reports",
icon: BarChart3,
children: [
{ label: "Report Library", href: "/reports?tab=library", icon: BarChart3 },
{ label: "Audit Trail", href: "/reports?tab=audit", icon: Activity },
],
},
],
},
{
title: "Communication",
items: [
{ label: "Messages", href: "/messages", icon: MessageSquare, badge: "unread" },
],
},
]
export const SETTINGS_NAV: NavItem = {
label: "Settings",
href: "/settings",
icon: Settings,
children: [
{ label: "General", href: "/settings?tab=general", icon: SlidersHorizontal },
{ label: "Members", href: "/settings?tab=members", icon: Users },
{ label: "Hubs", href: "/settings?tab=hubs", icon: Building2 },
{ label: "Notifications", href: "/settings?tab=notifications", icon: Bell },
{ label: "Billing", href: "/settings?tab=billing", icon: CreditCard },
{ label: "Integrations", href: "/settings?tab=integrations", icon: Store },
{ label: "Workforce", href: "/settings?tab=workforce", icon: BriefcaseBusiness },
{ label: "Support", href: "/settings?tab=support", icon: LifeBuoy },
],
}
/** Page titles for the global header, keyed by pathname. */
export const PAGE_TITLES: Record<string, string> = {
"/": "Home",
"/performance": "Performance",
"/orders": "Orders",
"/availability": "Staff Availability",
"/tasks": "Task Board",
"/staff": "Staff Directory",
"/compliance": "Compliance",
"/benefits": "Benefits",
"/clients": "Clients",
"/invoices": "Invoices",
"/payroll": "Payroll",
"/reports": "Reports",
"/messages": "Messages",
"/settings": "Settings",
}
/** Breadcrumb group for each page, matching the sidebar section it lives in. */
export const PAGE_SECTIONS: Record<string, string> = {
"/": "Workspace",
"/performance": "Workspace",
"/orders": "Operations",
"/availability": "Operations",
"/tasks": "Operations",
"/staff": "Workforce",
"/compliance": "Workforce",
"/benefits": "Workforce",
"/clients": "Business",
"/invoices": "Finance",
"/payroll": "Finance",
"/reports": "Insights",
"/messages": "Communication",
"/settings": "Settings",
}
export const CALENDAR_ICON = CalendarDays

329
src/data/mockData.ts Normal file
View File

@@ -0,0 +1,329 @@
import type {
AvailabilitySlot,
BenefitPlan,
Client,
ComplianceRisk,
Conversation,
Invoice,
Order,
PayrollLine,
PayrollRun,
ReportCard,
ScheduleRow,
StaffCounts,
StaffMember,
Task,
Team,
TrendPoint,
User,
} from "./types"
/* ===========================================================================
The single source of truth for the whole demo.
Every page, every chart and every Owliver reply reads from this module, so a
figure on a KPI card and the same figure quoted in the conversation can never
disagree. Change a number here and it updates everywhere.
=========================================================================== */
export const ORG = "Legendary Staffing Group"
export const TEAMS: Team[] = [
{ id: "TEAM-A", name: "Legendary Staffing Pool A", initials: "LS", members: 46 },
{ id: "TEAM-B", name: "Legendary Staffing Pool B", initials: "LB", members: 31 },
{ id: "TEAM-N", name: "Northgate Events Crew", initials: "NE", members: 18 },
]
export const USER: User = {
name: "Alex",
fullName: "Alex Rivera",
initials: "AR",
role: "Operations Lead",
email: "alex.rivera@legendarystaffing.com",
}
/** Scripted date and time of day, so the demo never drifts with the clock. */
export const TODAY = {
label: "Tuesday, 15 September 2025",
short: "15 Sep 2025",
partOfDay: "afternoon",
}
export const STAFF_COUNTS: StaffCounts = {
active: 38,
onBreak: 4,
unavailable: 4,
total: 46,
}
export const PAYROLL_SUMMARY = {
total: 18475,
workedHours: 1284.5,
revenue: 32120,
overtimeSpend: 1340,
breakdown: [
{ group: "Event & Hospitality", hours: 512.0, cost: 7320 },
{ group: "Warehouse & Logistics", hours: 438.5, cost: 6410 },
{ group: "Facilities & Cleaning", hours: 334.0, cost: 4745 },
],
}
export const ORDER_SUMMARY = {
today: 12,
inProgress: 7,
startingSoon: 2,
}
export const PERFORMANCE_HEALTH = 90
export const CLIENT_RISK_COUNT = 1
/** Today's shifts. `required` vs `filled` drives every "short" figure. */
export const SCHEDULE: ScheduleRow[] = [
{ time: "08:00", location: "Riverside Conference", ref: "ORD-4836", required: 5, filled: 5 },
{ time: "14:00", location: "Harbour Point Hotel", ref: "ORD-4825", required: 6, filled: 6 },
{ time: "17:00", location: "Northgate Arena", ref: "ORD-4821", required: 5, filled: 2 },
{ time: "18:30", location: "The Lantern Rooms", ref: "ORD-4834", required: 3, filled: 1 },
{ time: "22:00", location: "Castleford Logistics", ref: "ORD-4830", required: 4, filled: 1 },
]
export const UPCOMING_SHIFTS = [
{ count: 2, when: "Starting in 2 hours", location: "Northgate Arena", ref: "ORD-4821" },
{ count: 1, when: "Starting in 3 hours", location: "The Lantern Rooms", ref: "ORD-4834" },
{ count: 3, when: "Starting in 7 hours", location: "Castleford Logistics", ref: "ORD-4830" },
]
export const TOMORROW = [
{ name: "Tomas Ferreira", role: "Forklift Driver", shift: "06:00", status: "Confirmed" },
{ name: "Leila Haddad", role: "Housekeeper", shift: "07:30", status: "Confirmed" },
{ name: "Jonah Whitfield", role: "Driver", shift: "08:00", status: "Unavailable" },
{ name: "Priya Raman", role: "Event Steward", shift: "16:00", status: "Unconfirmed" },
]
export const ORDERS: Order[] = [
{ id: "ORD-4821", client: "Northgate Entertainment", venue: "Northgate Arena", department: "Event & Hospitality", date: "15 Sep 2025", shift: "17:00 – 23:30", required: 5, filled: 2, value: 4850, status: "Understaffed", leadContact: "Sarah Jenkins", priority: "High" },
{ id: "ORD-4825", client: "Harbour Hospitality Group", venue: "Harbour Point Hotel", department: "Facilities & Cleaning", date: "15 Sep 2025", shift: "14:00 – 22:00", required: 6, filled: 6, value: 5920, status: "In Progress", leadContact: "David Chen", priority: "Medium" },
{ id: "ORD-4830", client: "Castleford Freight Ltd", venue: "Castleford Logistics Hub", department: "Warehouse & Logistics", date: "15 Sep 2025", shift: "22:00 – 06:00", required: 4, filled: 1, value: 6200, status: "Understaffed", leadContact: "Marcus Vance", priority: "High" },
{ id: "ORD-4834", client: "Lantern Events UK", venue: "The Lantern Rooms", department: "Event & Hospitality", date: "15 Sep 2025", shift: "18:30 – 01:00", required: 3, filled: 1, value: 3400, status: "Understaffed", leadContact: "Chloe Bennett", priority: "High" },
{ id: "ORD-4836", client: "Riverside Corporate", venue: "Riverside Conference Hall", department: "Facilities & Cleaning", date: "15 Sep 2025", shift: "08:00 – 16:30", required: 5, filled: 5, value: 4250, status: "In Progress", leadContact: "Elena Rostova", priority: "Low" },
{ id: "ORD-4840", client: "Metro Retail Logistics", venue: "Metro Distribution Depot", department: "Warehouse & Logistics", date: "15 Sep 2025", shift: "12:00 – 20:00", required: 4, filled: 4, value: 3800, status: "In Progress", leadContact: "Tomas Ferreira", priority: "Medium" },
{ id: "ORD-4842", client: "Grand Hotel & Suites", venue: "Grand Ballroom", department: "Event & Hospitality", date: "16 Sep 2025", shift: "19:00 – 02:00", required: 3, filled: 3, value: 3700, status: "Confirmed", leadContact: "Aisha Patel", priority: "Medium" },
{ id: "ORD-4845", client: "Northgate Entertainment", venue: "Northgate Arena", department: "Event & Hospitality", date: "16 Sep 2025", shift: "16:00 – 23:00", required: 8, filled: 6, value: 7600, status: "Confirmed", leadContact: "Sarah Jenkins", priority: "Medium" },
{ id: "ORD-4848", client: "Castleford Freight Ltd", venue: "Castleford Logistics Hub", department: "Warehouse & Logistics", date: "17 Sep 2025", shift: "22:00 – 06:00", required: 6, filled: 0, value: 8100, status: "Draft", leadContact: "Marcus Vance", priority: "Medium" },
{ id: "ORD-4812", client: "Harbour Hospitality Group", venue: "Harbour Point Hotel", department: "Facilities & Cleaning", date: "14 Sep 2025", shift: "14:00 – 22:00", required: 6, filled: 6, value: 5920, status: "Completed", leadContact: "David Chen", priority: "Low" },
{ id: "ORD-4808", client: "Riverside Corporate", venue: "Riverside Conference Hall", department: "Facilities & Cleaning", date: "13 Sep 2025", shift: "08:00 – 16:30", required: 5, filled: 5, value: 4250, status: "Completed", leadContact: "Elena Rostova", priority: "Low" },
]
export const TASKS: Task[] = [
{ id: "TSK-101", title: "Assign 3 forklift drivers to Castleford Logistics", orderRef: "ORD-4830", priority: "High", due: "20:00 Today", category: "Staffing", status: "To Do", assignee: "Alex Rivera", assigneeInitials: "AR" },
{ id: "TSK-102", title: "Fill 3 event stewards for Northgate Arena shift", orderRef: "ORD-4821", priority: "High", due: "16:00 Today", category: "Staffing", status: "To Do", assignee: "Alex Rivera", assigneeInitials: "AR" },
{ id: "TSK-107", title: "Verify right-to-work documents for 2 temporary recruits", orderRef: "HR Audit", priority: "Medium", due: "Tomorrow 12:00", category: "Compliance", status: "To Do", assignee: "HR Team", assigneeInitials: "HR" },
{ id: "TSK-108", title: "Chase signed service agreement from Lantern Events UK", orderRef: "CLI-0042", priority: "Low", due: "Thu 17:00", category: "Client", status: "To Do", assignee: "Chloe Bennett", assigneeInitials: "CB" },
{ id: "TSK-103", title: "Review Tomas Ferreira forklift certification renewal", orderRef: "Staff Compliance", priority: "High", due: "17:30 Today", category: "Compliance", status: "In Progress", assignee: "HR & Compliance", assigneeInitials: "HC" },
{ id: "TSK-105", title: "Approve overtime rate adjustment for Harbour Point Hotel", orderRef: "ORD-4825", priority: "Medium", due: "Tomorrow 09:00", category: "Payroll", status: "In Progress", assignee: "Finance Lead", assigneeInitials: "FL" },
{ id: "TSK-104", title: "Confirm catering & safety briefing at The Lantern Rooms", orderRef: "ORD-4834", priority: "Medium", due: "18:00 Today", category: "Operations", status: "Blocked", assignee: "Chloe Bennett", assigneeInitials: "CB" },
{ id: "TSK-109", title: "Resolve access badge issue at Metro Distribution Depot", orderRef: "ORD-4840", priority: "Medium", due: "19:00 Today", category: "Operations", status: "Blocked", assignee: "Marcus Vance", assigneeInitials: "MV" },
{ id: "TSK-106", title: "Conduct morning shift handover for Riverside Conference", orderRef: "ORD-4836", priority: "Low", due: "Completed 08:15", category: "Operations", status: "Completed", assignee: "Elena Rostova", assigneeInitials: "ER" },
{ id: "TSK-110", title: "Publish week 38 availability request to all pools", orderRef: "Scheduling", priority: "Low", due: "Completed 07:40", category: "Staffing", status: "Completed", assignee: "Alex Rivera", assigneeInitials: "AR" },
]
export const STAFF: StaffMember[] = [
{ id: "STF-001", name: "Tomas Ferreira", initials: "TF", role: "Forklift Driver", department: "Warehouse & Logistics", status: "Active", shift: "06:00 – 14:00", nextShift: "Tomorrow 06:00", location: "Metro Distribution", hourlyRate: 17.5, hoursThisWeek: 38.5, utilisation: 96, rating: 4.6, compliance: "Warning", complianceNote: "Forklift cert expires in 2 days", benefitsTier: "Standard", phone: "+44 7700 900234", email: "t.ferreira@legendarystaffing.com", team: "Legendary Staffing Pool A" },
{ id: "STF-002", name: "Leila Haddad", initials: "LH", role: "Head Housekeeper", department: "Facilities & Cleaning", status: "Active", shift: "07:30 – 15:30", nextShift: "Tomorrow 07:30", location: "Harbour Point Hotel", hourlyRate: 16.0, hoursThisWeek: 36.0, utilisation: 90, rating: 4.8, compliance: "Verified", benefitsTier: "Enhanced", phone: "+44 7700 900345", email: "l.haddad@legendarystaffing.com", team: "Legendary Staffing Pool A" },
{ id: "STF-003", name: "Jonah Whitfield", initials: "JW", role: "Transport Driver", department: "Warehouse & Logistics", status: "Unavailable", shift: "Off duty", nextShift: "Thu 08:00", location: "Standby Depot", hourlyRate: 18.0, hoursThisWeek: 24.0, utilisation: 60, rating: 4.2, compliance: "Verified", benefitsTier: "Standard", phone: "+44 7700 900456", email: "j.whitfield@legendarystaffing.com", team: "Legendary Staffing Pool B" },
{ id: "STF-004", name: "Marcus Vance", initials: "MV", role: "Shift Supervisor", department: "Warehouse & Logistics", status: "Active", shift: "14:00 – 22:00", nextShift: "Today 14:00", location: "Castleford Logistics", hourlyRate: 21.0, hoursThisWeek: 41.5, utilisation: 104, rating: 4.9, compliance: "Verified", benefitsTier: "Enhanced", phone: "+44 7700 900567", email: "m.vance@legendarystaffing.com", team: "Legendary Staffing Pool A" },
{ id: "STF-005", name: "Elena Rostova", initials: "ER", role: "Event Coordinator", department: "Event & Hospitality", status: "Active", shift: "08:00 – 16:30", nextShift: "Tomorrow 08:00", location: "Riverside Conference", hourlyRate: 19.5, hoursThisWeek: 37.0, utilisation: 92, rating: 4.7, compliance: "Verified", benefitsTier: "Enhanced", phone: "+44 7700 900678", email: "e.rostova@legendarystaffing.com", team: "Northgate Events Crew" },
{ id: "STF-006", name: "Chloe Bennett", initials: "CB", role: "Hospitality Lead", department: "Event & Hospitality", status: "On Break", shift: "12:00 – 20:00", nextShift: "Today 18:30", location: "The Lantern Rooms", hourlyRate: 17.0, hoursThisWeek: 32.0, utilisation: 80, rating: 4.5, compliance: "Verified", benefitsTier: "Standard", phone: "+44 7700 900789", email: "c.bennett@legendarystaffing.com", team: "Northgate Events Crew" },
{ id: "STF-007", name: "David Kim", initials: "DK", role: "Cleaning Specialist", department: "Facilities & Cleaning", status: "Active", shift: "14:00 – 22:00", nextShift: "Today 14:00", location: "Harbour Point Hotel", hourlyRate: 15.5, hoursThisWeek: 35.0, utilisation: 88, rating: 4.4, compliance: "Verified", benefitsTier: "Standard", phone: "+44 7700 900890", email: "d.kim@legendarystaffing.com", team: "Legendary Staffing Pool A" },
{ id: "STF-008", name: "Aisha Patel", initials: "AP", role: "Event Steward", department: "Event & Hospitality", status: "Active", shift: "17:00 – 23:30", nextShift: "Today 17:00", location: "Northgate Arena", hourlyRate: 16.5, hoursThisWeek: 34.0, utilisation: 85, rating: 4.6, compliance: "Warning", complianceNote: "Missing signed policy acknowledgement", benefitsTier: "Standard", phone: "+44 7700 900901", email: "a.patel@legendarystaffing.com", team: "Northgate Events Crew" },
{ id: "STF-009", name: "Priya Raman", initials: "PR", role: "Event Steward", department: "Event & Hospitality", status: "On Break", shift: "10:00 – 18:00", nextShift: "Tomorrow 16:00", location: "Northgate Arena", hourlyRate: 16.5, hoursThisWeek: 30.5, utilisation: 76, rating: 4.3, compliance: "Verified", benefitsTier: "Standard", phone: "+44 7700 901012", email: "p.raman@legendarystaffing.com", team: "Northgate Events Crew" },
{ id: "STF-010", name: "Samuel Okoro", initials: "SO", role: "Warehouse Operative", department: "Warehouse & Logistics", status: "Unavailable", shift: "Off duty", nextShift: "Wed 22:00", location: "Standby Depot", hourlyRate: 15.0, hoursThisWeek: 16.0, utilisation: 40, rating: 4.1, compliance: "Expired", complianceNote: "Manual handling certificate expired", benefitsTier: "Basic", phone: "+44 7700 901123", email: "s.okoro@legendarystaffing.com", team: "Legendary Staffing Pool B" },
{ id: "STF-011", name: "Grace Mbeki", initials: "GM", role: "Housekeeper", department: "Facilities & Cleaning", status: "Active", shift: "09:00 – 17:00", nextShift: "Tomorrow 09:00", location: "Harbour Point Hotel", hourlyRate: 15.5, hoursThisWeek: 37.5, utilisation: 94, rating: 4.7, compliance: "Verified", benefitsTier: "Enhanced", phone: "+44 7700 901234", email: "g.mbeki@legendarystaffing.com", team: "Legendary Staffing Pool A" },
{ id: "STF-012", name: "Liam O'Connor", initials: "LO", role: "Bar Supervisor", department: "Event & Hospitality", status: "On Break", shift: "16:00 – 00:00", nextShift: "Today 16:00", location: "Grand Ballroom", hourlyRate: 18.5, hoursThisWeek: 33.0, utilisation: 82, rating: 4.5, compliance: "Verified", benefitsTier: "Standard", phone: "+44 7700 901345", email: "l.oconnor@legendarystaffing.com", team: "Northgate Events Crew" },
{ id: "STF-013", name: "Nadia Suleiman", initials: "NS", role: "Cleaning Operative", department: "Facilities & Cleaning", status: "Unavailable", shift: "Off duty", nextShift: "Fri 09:00", location: "Standby", hourlyRate: 15.0, hoursThisWeek: 12.0, utilisation: 30, rating: 4.0, compliance: "Verified", benefitsTier: "Basic", phone: "+44 7700 901456", email: "n.suleiman@legendarystaffing.com", team: "Legendary Staffing Pool B" },
{ id: "STF-014", name: "Owen Bradley", initials: "OB", role: "Forklift Driver", department: "Warehouse & Logistics", status: "Active", shift: "22:00 – 06:00", nextShift: "Today 22:00", location: "Castleford Logistics", hourlyRate: 17.5, hoursThisWeek: 39.0, utilisation: 98, rating: 4.6, compliance: "Verified", benefitsTier: "Standard", phone: "+44 7700 901567", email: "o.bradley@legendarystaffing.com", team: "Legendary Staffing Pool A" },
{ id: "STF-015", name: "Rosa Delgado", initials: "RD", role: "Event Steward", department: "Event & Hospitality", status: "Unavailable", shift: "Off duty", nextShift: "Wed 17:00", location: "Standby", hourlyRate: 16.5, hoursThisWeek: 20.0, utilisation: 50, rating: 4.2, compliance: "Warning", complianceNote: "Right-to-work document pending upload", benefitsTier: "Basic", phone: "+44 7700 901678", email: "r.delgado@legendarystaffing.com", team: "Northgate Events Crew" },
{ id: "STF-016", name: "Henry Adeyemi", initials: "HA", role: "Logistics Coordinator", department: "Warehouse & Logistics", status: "Active", shift: "08:00 – 16:00", nextShift: "Tomorrow 08:00", location: "Metro Distribution", hourlyRate: 20.0, hoursThisWeek: 38.0, utilisation: 95, rating: 4.8, compliance: "Verified", benefitsTier: "Enhanced", phone: "+44 7700 901789", email: "h.adeyemi@legendarystaffing.com", team: "Legendary Staffing Pool A" },
]
export const COMPLIANCE_RISKS: ComplianceRisk[] = [
{ id: "CMP-01", risk: "Forklift certification expiry", category: "Certification", staffCount: 2, staffNames: ["Tomas Ferreira", "Samuel Okoro"], severity: "High", expires: "17 Sep 2025", action: "Review" },
{ id: "CMP-02", risk: "Right-to-work document missing", category: "Document", staffCount: 1, staffNames: ["Rosa Delgado"], severity: "Medium", expires: "22 Sep 2025", action: "Upload" },
{ id: "CMP-03", risk: "Policy acknowledgement outstanding", category: "Policy", staffCount: 1, staffNames: ["Aisha Patel"], severity: "Low", expires: "30 Sep 2025", action: "Remind" },
{ id: "CMP-04", risk: "Attire review overdue", category: "Attire", staffCount: 3, staffNames: ["Priya Raman", "Liam O'Connor", "Rosa Delgado"], severity: "Low", expires: "05 Oct 2025", action: "Schedule" },
]
export const BENEFITS: BenefitPlan[] = [
{ id: "BEN-01", name: "Private Health Cover", category: "Health", enrolled: 28, eligible: 41, monthlyCost: 4620, status: "Open", renewal: "01 Jan 2026" },
{ id: "BEN-02", name: "Workplace Pension", category: "Retirement", enrolled: 41, eligible: 46, monthlyCost: 7380, status: "Open", renewal: "06 Apr 2026" },
{ id: "BEN-03", name: "Enhanced Holiday Accrual", category: "Leave", enrolled: 19, eligible: 32, monthlyCost: 2140, status: "Review", renewal: "31 Dec 2025" },
{ id: "BEN-04", name: "Employee Assistance Programme", category: "Wellbeing", enrolled: 46, eligible: 46, monthlyCost: 920, status: "Open", renewal: "01 Mar 2026" },
{ id: "BEN-05", name: "Cycle to Work", category: "Wellbeing", enrolled: 7, eligible: 46, monthlyCost: 310, status: "Open", renewal: "01 Jun 2026" },
{ id: "BEN-06", name: "PPE & Uniform Allowance", category: "Equipment", enrolled: 44, eligible: 46, monthlyCost: 1580, status: "Closed", renewal: "01 Nov 2025" },
]
export const CLIENTS: Client[] = [
{ id: "CLI-0018", name: "Northgate Entertainment", venues: ["Northgate Arena", "Northgate Pavilion"], activeOrders: 2, staffDemand: 13, revenueYtd: 184300, accountStatus: "Active", owner: "Sarah Jenkins", since: "Mar 2022", rate: 24.5 },
{ id: "CLI-0024", name: "Harbour Hospitality Group", venues: ["Harbour Point Hotel"], activeOrders: 1, staffDemand: 6, revenueYtd: 142800, accountStatus: "Active", owner: "David Chen", since: "Jul 2021", rate: 22.0 },
{ id: "CLI-0031", name: "Castleford Freight Ltd", venues: ["Castleford Logistics Hub"], activeOrders: 2, staffDemand: 10, revenueYtd: 128650, accountStatus: "At Risk", owner: "Marcus Vance", since: "Jan 2023", rate: 26.0 },
{ id: "CLI-0042", name: "Lantern Events UK", venues: ["The Lantern Rooms"], activeOrders: 1, staffDemand: 3, revenueYtd: 68400, accountStatus: "Active", owner: "Chloe Bennett", since: "Sep 2023", rate: 25.0 },
{ id: "CLI-0047", name: "Riverside Corporate", venues: ["Riverside Conference Hall"], activeOrders: 1, staffDemand: 5, revenueYtd: 96200, accountStatus: "Active", owner: "Elena Rostova", since: "Nov 2022", rate: 21.5 },
{ id: "CLI-0053", name: "Metro Retail Logistics", venues: ["Metro Distribution Depot"], activeOrders: 1, staffDemand: 4, revenueYtd: 74900, accountStatus: "Active", owner: "Tomas Ferreira", since: "Apr 2024", rate: 23.0 },
{ id: "CLI-0061", name: "Grand Hotel & Suites", venues: ["Grand Ballroom", "Grand Terrace"], activeOrders: 1, staffDemand: 3, revenueYtd: 51200, accountStatus: "Prospect", owner: "Aisha Patel", since: "Feb 2025", rate: 25.5 },
{ id: "CLI-0066", name: "Seaview Retail Park", venues: ["Seaview Centre"], activeOrders: 0, staffDemand: 0, revenueYtd: 18400, accountStatus: "On Hold", owner: "David Chen", since: "Jun 2024", rate: 20.0 },
]
export const INVOICES: Invoice[] = [
{ id: "INV-2098", client: "Northgate Entertainment", orderRefs: ["ORD-4821"], issued: "15 Sep 2025", due: "29 Sep 2025", amount: 4850, status: "Draft" },
{ id: "INV-2097", client: "Castleford Freight Ltd", orderRefs: ["ORD-4830"], issued: "15 Sep 2025", due: "29 Sep 2025", amount: 6200, status: "Draft" },
{ id: "INV-2096", client: "Harbour Hospitality Group", orderRefs: ["ORD-4825", "ORD-4812"], issued: "14 Sep 2025", due: "28 Sep 2025", amount: 11840, status: "Sent" },
{ id: "INV-2095", client: "Riverside Corporate", orderRefs: ["ORD-4836"], issued: "13 Sep 2025", due: "27 Sep 2025", amount: 4250, status: "Sent" },
{ id: "INV-2094", client: "Metro Retail Logistics", orderRefs: ["ORD-4840"], issued: "12 Sep 2025", due: "26 Sep 2025", amount: 3800, status: "Sent" },
{ id: "INV-2089", client: "Lantern Events UK", orderRefs: ["ORD-4790"], issued: "29 Aug 2025", due: "12 Sep 2025", amount: 3180, status: "Overdue" },
{ id: "INV-2086", client: "Castleford Freight Ltd", orderRefs: ["ORD-4776"], issued: "25 Aug 2025", due: "08 Sep 2025", amount: 5940, status: "Overdue" },
{ id: "INV-2081", client: "Northgate Entertainment", orderRefs: ["ORD-4758"], issued: "18 Aug 2025", due: "01 Sep 2025", amount: 7320, status: "Paid" },
{ id: "INV-2078", client: "Riverside Corporate", orderRefs: ["ORD-4741"], issued: "11 Aug 2025", due: "25 Aug 2025", amount: 4100, status: "Paid" },
{ id: "INV-2075", client: "Harbour Hospitality Group", orderRefs: ["ORD-4729"], issued: "04 Aug 2025", due: "18 Aug 2025", amount: 5610, status: "Paid" },
]
export const PAYROLL_RUNS: PayrollRun[] = [
{ id: "PR-38", period: "Week 38 · 15–21 Sep 2025", staffCount: 46, hours: 1284.5, gross: 18475, status: "Pending", paidOn: "—" },
{ id: "PR-37", period: "Week 37 · 08–14 Sep 2025", staffCount: 45, hours: 1246.0, gross: 17920, status: "Approved", paidOn: "19 Sep 2025" },
{ id: "PR-36", period: "Week 36 · 01–07 Sep 2025", staffCount: 44, hours: 1198.5, gross: 17240, status: "Paid", paidOn: "12 Sep 2025" },
{ id: "PR-35", period: "Week 35 · 25–31 Aug 2025", staffCount: 44, hours: 1301.0, gross: 19080, status: "Paid", paidOn: "05 Sep 2025" },
]
export const PAYROLL_LINES: PayrollLine[] = [
{ staffId: "STF-004", name: "Marcus Vance", role: "Shift Supervisor", hours: 41.5, overtime: 1.5, rate: 21.0, gross: 919.5, anomaly: "Over 40h — overtime approval required" },
{ staffId: "STF-014", name: "Owen Bradley", role: "Forklift Driver", hours: 39.0, overtime: 0, rate: 17.5, gross: 682.5 },
{ staffId: "STF-001", name: "Tomas Ferreira", role: "Forklift Driver", hours: 38.5, overtime: 0, rate: 17.5, gross: 673.75 },
{ staffId: "STF-016", name: "Henry Adeyemi", role: "Logistics Coordinator", hours: 38.0, overtime: 0, rate: 20.0, gross: 760.0 },
{ staffId: "STF-011", name: "Grace Mbeki", role: "Housekeeper", hours: 37.5, overtime: 0, rate: 15.5, gross: 581.25 },
{ staffId: "STF-005", name: "Elena Rostova", role: "Event Coordinator", hours: 37.0, overtime: 0, rate: 19.5, gross: 721.5 },
{ staffId: "STF-002", name: "Leila Haddad", role: "Head Housekeeper", hours: 36.0, overtime: 0, rate: 16.0, gross: 576.0 },
{ staffId: "STF-007", name: "David Kim", role: "Cleaning Specialist", hours: 35.0, overtime: 0, rate: 15.5, gross: 542.5 },
{ staffId: "STF-013", name: "Nadia Suleiman", role: "Cleaning Operative", hours: 12.0, overtime: 0, rate: 15.0, gross: 180.0, anomaly: "Under 16h — below contracted minimum" },
{ staffId: "STF-010", name: "Samuel Okoro", role: "Warehouse Operative", hours: 16.0, overtime: 0, rate: 15.0, gross: 240.0, anomaly: "Unavailable 3 consecutive shifts" },
]
export const AVAILABILITY: AvailabilitySlot[] = STAFF.map((s) => ({
staffId: s.id,
name: s.name,
role: s.role,
status: s.status,
from: s.shift === "Off duty" ? "—" : s.shift.split(" – ")[0],
to: s.shift === "Off duty" ? "—" : s.shift.split(" – ")[1],
assignedTo: s.status === "Unavailable" ? null : s.location,
note: s.status === "Unavailable" ? `Next available ${s.nextShift}` : undefined,
}))
export const CONVERSATIONS: Conversation[] = [
{
id: "CNV-1", name: "Marcus Vance", role: "Shift Supervisor · Castleford", initials: "MV", unread: 2, lastAt: "14:32",
messages: [
{ id: "m1", from: "them", text: "Night shift is still 3 short for the 22:00 start.", at: "14:12" },
{ id: "m2", from: "me", text: "Owliver flagged it. Pulling from Pool B standby now.", at: "14:20" },
{ id: "m3", from: "them", text: "Owen Bradley confirmed. Need two more.", at: "14:31" },
{ id: "m4", from: "them", text: "Can you approve the overtime rate for the extra cover?", at: "14:32" },
],
},
{
id: "CNV-2", name: "Sarah Jenkins", role: "Client Lead · Northgate", initials: "SJ", unread: 1, lastAt: "13:58",
messages: [
{ id: "m1", from: "them", text: "Doors open 17:00 — are we at full steward count?", at: "13:40" },
{ id: "m2", from: "me", text: "2 of 5 filled, working on it. Will confirm by 16:00.", at: "13:52" },
{ id: "m3", from: "them", text: "Understood. Please prioritise — it's a sold-out event.", at: "13:58" },
],
},
{
id: "CNV-3", name: "HR & Compliance", role: "Internal team", initials: "HC", unread: 0, lastAt: "11:24",
messages: [
{ id: "m1", from: "them", text: "Tomas Ferreira's forklift cert expires 17 Sep.", at: "11:02" },
{ id: "m2", from: "me", text: "Booked the renewal assessment for Wednesday.", at: "11:18" },
{ id: "m3", from: "them", text: "Thanks — I'll hold him off Castleford until it clears.", at: "11:24" },
],
},
{
id: "CNV-4", name: "Chloe Bennett", role: "Hospitality Lead · Lantern", initials: "CB", unread: 0, lastAt: "Yesterday",
messages: [
{ id: "m1", from: "them", text: "Safety briefing deck is ready for the 18:30 shift.", at: "Yesterday" },
{ id: "m2", from: "me", text: "Perfect, thank you.", at: "Yesterday" },
],
},
{
id: "CNV-5", name: "Finance Lead", role: "Internal team", initials: "FL", unread: 0, lastAt: "Mon",
messages: [
{ id: "m1", from: "them", text: "Two invoices are now overdue — Lantern and Castleford.", at: "Mon" },
{ id: "m2", from: "me", text: "I'll raise both with the account owners this week.", at: "Mon" },
],
},
]
export const REPORTS: ReportCard[] = [
{ id: "RPT-01", title: "Daily Staffing Coverage", description: "Required versus filled places across every live order, by venue and shift.", category: "Operational", updated: "Today 14:00", format: "CSV · PDF" },
{ id: "RPT-02", title: "Order Fulfilment Summary", description: "Fill rate, lead time and cancellation trend for the trailing 30 days.", category: "Operational", updated: "Today 06:00", format: "CSV · XLSX" },
{ id: "RPT-03", title: "Shift Attendance & Punctuality", description: "Clock-in accuracy, no-shows and late starts per staff member.", category: "Workforce", updated: "Today 06:00", format: "CSV · PDF" },
{ id: "RPT-04", title: "Workforce Utilisation", description: "Hours worked against contracted availability, split by department.", category: "Workforce", updated: "Yesterday 18:00", format: "CSV · XLSX" },
{ id: "RPT-05", title: "Compliance & Certification Register", description: "Every certificate, document and policy acknowledgement with expiry dates.", category: "Workforce", updated: "Today 09:15", format: "PDF" },
{ id: "RPT-06", title: "Payroll Cost Breakdown", description: "Gross pay, overtime and employer cost by pool and pay period.", category: "Financial", updated: "Today 12:30", format: "CSV · XLSX" },
{ id: "RPT-07", title: "Client Revenue & Margin", description: "Billable revenue against staffing cost for each client account.", category: "Financial", updated: "Yesterday 20:00", format: "XLSX · PDF" },
{ id: "RPT-08", title: "Invoice Ageing", description: "Outstanding balances bucketed by days overdue, with account owners.", category: "Financial", updated: "Today 08:00", format: "CSV · PDF" },
]
export const TRENDS: TrendPoint[] = [
{ label: "Mon", utilisation: 84, attendance: 96, fillRate: 91, hours: 236, revenue: 5820, cost: 3410 },
{ label: "Tue", utilisation: 88, attendance: 97, fillRate: 93, hours: 248, revenue: 6140, cost: 3560 },
{ label: "Wed", utilisation: 91, attendance: 95, fillRate: 89, hours: 261, revenue: 6480, cost: 3720 },
{ label: "Thu", utilisation: 87, attendance: 94, fillRate: 88, hours: 244, revenue: 6020, cost: 3480 },
{ label: "Fri", utilisation: 94, attendance: 98, fillRate: 95, hours: 276, revenue: 7150, cost: 4020 },
{ label: "Sat", utilisation: 96, attendance: 93, fillRate: 86, hours: 289, revenue: 7640, cost: 4310 },
{ label: "Sun", utilisation: 79, attendance: 92, fillRate: 90, hours: 198, revenue: 4870, cost: 2980 },
]
export const DEPARTMENT_METRICS = [
{ name: "Event & Hospitality", revenue: 52400, cost: 29800, hours: 2040, margin: 43.1, headcount: 18 },
{ name: "Warehouse & Logistics", revenue: 48600, cost: 27900, hours: 1820, margin: 42.5, headcount: 16 },
{ name: "Facilities & Cleaning", revenue: 27450, cost: 16500, hours: 1350, margin: 39.8, headcount: 12 },
]
export const REPORTS_SUMMARY = {
monthlyRevenue: 128450,
monthlyPayroll: 74200,
grossProfit: 54250,
marginPct: 42.2,
billableUtilisation: 94.6,
overtimeSpend: 3120,
}
/** Workspace configuration status, so Owliver never invents setup numbers. */
export const SETTINGS_SUMMARY = {
members: [
{ name: "Alex Rivera", role: "Operations Lead", access: "Admin" },
{ name: "Sarah Jenkins", role: "Client Lead", access: "Manager" },
{ name: "Marcus Vance", role: "Shift Supervisor", access: "Manager" },
{ name: "Finance Lead", role: "Finance", access: "Billing" },
{ name: "HR & Compliance", role: "Compliance", access: "Manager" },
],
integrations: [
{ name: "Xero", category: "Accounting", connected: true },
{ name: "Slack", category: "Communication", connected: true },
{ name: "Google Calendar", category: "Scheduling", connected: true },
{ name: "BambooHR", category: "People", connected: false },
{ name: "Stripe", category: "Payments", connected: false },
],
notifications: [
{ label: "Understaffed order alerts", on: true },
{ label: "Shift starting soon", on: true },
{ label: "Compliance expiry warnings", on: true },
{ label: "Payroll anomalies", on: true },
{ label: "Invoice overdue reminders", on: false },
{ label: "Weekly performance digest", on: false },
],
workspace: {
timeZone: "Europe/London",
weekStartsOn: "Monday",
plan: "Workforce Pro",
maxWeeklyHours: 40,
overtimeMultiplier: "1.5×",
complianceWindow: "14 days",
},
}

135
src/data/selectors.ts Normal file
View File

@@ -0,0 +1,135 @@
import {
AVAILABILITY,
CLIENTS,
COMPLIANCE_RISKS,
CONVERSATIONS,
INVOICES,
ORDERS,
ORDER_SUMMARY,
PAYROLL_LINES,
PAYROLL_SUMMARY,
SCHEDULE,
STAFF,
STAFF_COUNTS,
TASKS,
} from "./mockData"
import type { ScheduleRow } from "./types"
/* ===========================================================================
Selectors — every derived figure lives here so no number is written twice.
=========================================================================== */
/** Share of the tracked workforce, as a whole percent. */
export const percentOfStaff = (count: number) =>
Math.round((count / STAFF_COUNTS.total) * 100)
/** Percentage of tracked staff currently active. */
export const staffCoverage = () => percentOfStaff(STAFF_COUNTS.active)
/** Shifts where fewer staff are filled than the client required. */
export const understaffedShifts = (): ScheduleRow[] =>
SCHEDULE.filter((row) => row.filled < row.required)
/** Orders (not just today's shifts) that are short of required staff. */
export const understaffedOrders = () =>
ORDERS.filter((o) => o.status === "Understaffed")
/** Total unfilled places across every understaffed shift today. */
export const totalPlacesShort = () =>
understaffedShifts().reduce((sum, row) => sum + (row.required - row.filled), 0)
/** Open compliance flags, counted from the risk rows themselves. */
export const complianceFlagCount = () =>
COMPLIANCE_RISKS.reduce((sum, row) => sum + row.staffCount, 0)
/** Items needing attention: understaffed shifts plus those starting soon. */
export const attentionCount = () =>
understaffedShifts().length + ORDER_SUMMARY.startingSoon
/** Blended labour cost per worked hour. */
export const averageHourlyCost = () =>
PAYROLL_SUMMARY.total / PAYROLL_SUMMARY.workedHours
/** Gross margin of billable revenue against payroll, as a whole percent. */
export const grossMargin = () =>
Math.round((1 - PAYROLL_SUMMARY.total / PAYROLL_SUMMARY.revenue) * 100)
/** Shifts that are fully staffed and therefore clean to invoice. */
export const filledShifts = () =>
SCHEDULE.filter((row) => row.filled >= row.required)
export const liveOrders = () =>
ORDERS.filter((o) => o.status === "Understaffed" || o.status === "In Progress")
export const orderFillRate = () => {
const live = liveOrders()
const required = live.reduce((s, o) => s + o.required, 0)
const filled = live.reduce((s, o) => s + o.filled, 0)
return required === 0 ? 100 : Math.round((filled / required) * 100)
}
export const pipelineValue = () =>
ORDERS.filter((o) => o.status !== "Completed").reduce((s, o) => s + o.value, 0)
export const openTasks = () => TASKS.filter((t) => t.status !== "Completed")
export const highPriorityTasks = () =>
TASKS.filter((t) => t.priority === "High" && t.status !== "Completed")
export const unavailableStaff = () =>
STAFF.filter((s) => s.status === "Unavailable")
export const availableNow = () =>
AVAILABILITY.filter((a) => a.status === "Active").length
export const staffWithComplianceIssues = () =>
STAFF.filter((s) => s.compliance !== "Verified")
export const invoiceTotals = () => {
const by = (status: string) =>
INVOICES.filter((i) => i.status === status).reduce((s, i) => s + i.amount, 0)
return {
draft: by("Draft"),
sent: by("Sent"),
paid: by("Paid"),
overdue: by("Overdue"),
outstanding: by("Sent") + by("Overdue"),
total: INVOICES.reduce((s, i) => s + i.amount, 0),
}
}
export const overdueInvoices = () =>
INVOICES.filter((i) => i.status === "Overdue")
/** Revenue sitting in draft invoices, ready for billing review. */
export const invoiceSignal = () => PAYROLL_SUMMARY.revenue
export const payrollAnomalies = () => PAYROLL_LINES.filter((l) => l.anomaly)
export const clientsAtRisk = () =>
CLIENTS.filter((c) => c.accountStatus === "At Risk" || c.accountStatus === "On Hold")
export const unreadMessages = () =>
CONVERSATIONS.reduce((s, c) => s + c.unread, 0)
export const averageUtilisation = () =>
Math.round(STAFF.reduce((s, m) => s + m.utilisation, 0) / STAFF.length)
export const averageRating = () =>
(STAFF.reduce((s, m) => s + m.rating, 0) / STAFF.length).toFixed(1)
/* ============================== formatting ============================== */
/** Formats a whole amount, e.g. `$18,475`. */
export const money = (amount: number) =>
`$${Math.round(amount).toLocaleString("en-US")}`
/** Formats an amount with cents, e.g. `$919.50`. */
export const money2 = (amount: number) =>
`$${amount.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`
/** Formats a number with thousands separators, e.g. `1,284.5`. */
export const num = (value: number) => value.toLocaleString("en-US")

91
src/data/store.ts Normal file
View File

@@ -0,0 +1,91 @@
import { useSyncExternalStore } from "react"
import { ORDERS, SCHEDULE, STAFF_COUNTS, TASKS } from "./mockData"
/* ===========================================================================
A deliberately tiny store.
The mock data is mutable, but React needs to be told when it changes. This
bumps a revision counter and notifies subscribers, so an action taken inside
Owliver updates the dashboard behind it.
=========================================================================== */
let revision = 0
const listeners = new Set<() => void>()
function emit() {
revision += 1
listeners.forEach((l) => l())
}
function subscribe(listener: () => void) {
listeners.add(listener)
return () => listeners.delete(listener)
}
const getSnapshot = () => revision
/** Re-renders the calling component whenever the workforce data mutates. */
export function useWorkforceRevision() {
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
}
export interface ResolvedShift {
ref: string
location: string
added: number
total: number
}
/**
* Fills every understaffed shift to its required headcount, moving the staff
* from the unavailable pool. Mirrors the change across today's schedule, the
* order book and the linked staffing tasks.
*/
export function resolveAllShortages(): ResolvedShift[] {
const resolved: ResolvedShift[] = []
SCHEDULE.forEach((shift) => {
if (shift.filled < shift.required) {
const added = shift.required - shift.filled
resolved.push({
ref: shift.ref,
location: shift.location,
added,
total: shift.required,
})
shift.filled = shift.required
}
})
ORDERS.forEach((order) => {
if (order.status === "Understaffed") {
order.filled = order.required
order.status = "Confirmed"
}
})
// The staffing tasks that tracked those gaps are now done.
TASKS.forEach((task) => {
if (task.category === "Staffing" && task.status === "To Do") {
task.status = "Completed"
task.due = "Completed just now"
}
})
// Staff drawn from the unavailable pool are now on shift.
const pulled = Math.min(
STAFF_COUNTS.unavailable,
resolved.reduce((s, r) => s + r.added, 0)
)
STAFF_COUNTS.unavailable -= pulled
STAFF_COUNTS.active += pulled
emit()
return resolved
}
/** True once every live shift is fully staffed. */
export function allShiftsResolved() {
return SCHEDULE.every((s) => s.filled >= s.required)
}

204
src/data/types.ts Normal file
View File

@@ -0,0 +1,204 @@
/* ===========================================================================
Domain types for the KROW Workforce demo.
Every page, chart and Owliver reply is typed against these.
=========================================================================== */
export type Severity = "High" | "Medium" | "Low"
export type Tone = "neutral" | "primary" | "good" | "warn" | "bad"
export type StaffStatus = "Active" | "On Break" | "Unavailable"
export type ComplianceState = "Verified" | "Warning" | "Expired"
export type OrderStatus =
| "Understaffed"
| "In Progress"
| "Confirmed"
| "Completed"
| "Draft"
export type TaskStatus = "To Do" | "In Progress" | "Blocked" | "Completed"
export type InvoiceStatus = "Draft" | "Sent" | "Paid" | "Overdue"
export type PayrollStatus = "Pending" | "Approved" | "Paid"
export type User = {
name: string
fullName: string
initials: string
role: string
email: string
}
export type Team = {
id: string
name: string
initials: string
members: number
}
export type StaffCounts = {
active: number
onBreak: number
unavailable: number
total: number
}
export type ScheduleRow = {
time: string
location: string
ref: string
required: number
filled: number
}
export type Order = {
id: string
client: string
venue: string
department: string
date: string
shift: string
required: number
filled: number
value: number
status: OrderStatus
leadContact: string
priority: Severity
}
export type Task = {
id: string
title: string
orderRef: string
priority: Severity
due: string
category: "Staffing" | "Compliance" | "Operations" | "Payroll" | "Client"
status: TaskStatus
assignee: string
assigneeInitials: string
}
export type StaffMember = {
id: string
name: string
initials: string
role: string
department: string
status: StaffStatus
shift: string
nextShift: string
location: string
hourlyRate: number
hoursThisWeek: number
utilisation: number
rating: number
compliance: ComplianceState
complianceNote?: string
benefitsTier: string
phone: string
email: string
team: string
}
export type ComplianceRisk = {
id: string
risk: string
category: "Certification" | "Document" | "Policy" | "Attire"
staffCount: number
staffNames: string[]
severity: Severity
expires: string
action: string
}
export type BenefitPlan = {
id: string
name: string
category: "Health" | "Retirement" | "Leave" | "Wellbeing" | "Equipment"
enrolled: number
eligible: number
monthlyCost: number
status: "Open" | "Closed" | "Review"
renewal: string
}
export type Client = {
id: string
name: string
venues: string[]
activeOrders: number
staffDemand: number
revenueYtd: number
accountStatus: "Active" | "At Risk" | "On Hold" | "Prospect"
owner: string
since: string
rate: number
}
export type Invoice = {
id: string
client: string
orderRefs: string[]
issued: string
due: string
amount: number
status: InvoiceStatus
}
export type PayrollRun = {
id: string
period: string
staffCount: number
hours: number
gross: number
status: PayrollStatus
paidOn: string
}
export type PayrollLine = {
staffId: string
name: string
role: string
hours: number
overtime: number
rate: number
gross: number
anomaly?: string
}
export type AvailabilitySlot = {
staffId: string
name: string
role: string
status: StaffStatus
from: string
to: string
assignedTo: string | null
note?: string
}
export type Conversation = {
id: string
name: string
role: string
initials: string
unread: number
lastAt: string
messages: { id: string; from: "them" | "me"; text: string; at: string }[]
}
export type ReportCard = {
id: string
title: string
description: string
category: "Operational" | "Workforce" | "Financial"
updated: string
format: string
}
export type TrendPoint = {
label: string
utilisation: number
attendance: number
fillRate: number
hours: number
revenue: number
cost: number
}

View File

@@ -0,0 +1,37 @@
import { useEffect, useState } from "react"
/* ===========================================================================
Section 25 — breakpoints.
desktop >= 1280 sidebar + main + Owliver, all visible
tablet 768–1279 compact rail + main, Owliver overlays
mobile < 768 sidebar drawer, Owliver bottom sheet
=========================================================================== */
export type LayoutMode = "desktop" | "tablet" | "mobile"
function read(): LayoutMode {
if (typeof window === "undefined") return "desktop"
const w = window.innerWidth
if (w >= 1280) return "desktop"
if (w >= 768) return "tablet"
return "mobile"
}
export function useLayoutMode(): LayoutMode {
const [mode, setMode] = useState<LayoutMode>(read)
useEffect(() => {
let frame = 0
const onResize = () => {
cancelAnimationFrame(frame)
frame = requestAnimationFrame(() => setMode(read()))
}
window.addEventListener("resize", onResize)
return () => {
cancelAnimationFrame(frame)
window.removeEventListener("resize", onResize)
}
}, [])
return mode
}

View File

@@ -0,0 +1,26 @@
import { useLocation } from "react-router-dom"
import type { OwliverContext } from "@/owliver/types"
const BY_PATH: Record<string, OwliverContext> = {
"/": "home",
"/performance": "performance",
"/orders": "orders",
"/availability": "availability",
"/tasks": "tasks",
"/staff": "staff",
"/compliance": "compliance",
"/benefits": "benefits",
"/clients": "clients",
"/invoices": "invoices",
"/payroll": "payroll",
"/reports": "reports",
"/messages": "messages",
"/settings": "settings",
}
/** Maps the active route to the context Owliver should brief itself on. */
export function useOwliverContext(): OwliverContext {
const { pathname } = useLocation()
return BY_PATH[pathname] ?? "home"
}

308
src/index.css Normal file
View File

@@ -0,0 +1,308 @@
@import "tailwindcss";
/* ===========================================================================
KROW design tokens.
Mirrors the production system (apps/web/packages/core/web-ui/src/styles.css)
so the demo reads as KROW without importing from the read-only repo.
=========================================================================== */
@theme {
--color-ground: #f5f8fc;
--color-surface: #ffffff;
--color-surface-soft: #f8fafe;
--color-surface-sunk: #f1f5fc;
--color-ink: #111c44;
--color-ink-2: #4a5878;
--color-ink-3: #8b99b8;
--color-line: #e4ebf6;
--color-line-soft: #eef3fa;
--color-primary: #1d4ed8;
--color-primary-ink: #ffffff;
--color-primary-soft: #eef4ff;
--color-primary-mid: #dce8fe;
--color-primary-edge: #c5d8fb;
--color-good: #15803d;
--color-good-soft: #eaf7ef;
--color-good-edge: #bfe5cc;
--color-good-dot: #22a957;
--color-warn: #b45309;
--color-warn-soft: #fef6e7;
--color-warn-edge: #f7e0b5;
--color-warn-dot: #f0b429;
--color-bad: #b91c1c;
--color-bad-soft: #fdeeee;
--color-bad-edge: #f5cfcf;
--color-bad-dot: #e4544f;
--font-sans: "Instrument Sans", ui-sans-serif, system-ui, -apple-system,
"Segoe UI", sans-serif;
--shadow-xs: 0 1px 2px rgba(17, 28, 68, 0.04);
--shadow-sm: 0 1px 3px rgba(17, 28, 68, 0.06), 0 1px 2px rgba(17, 28, 68, 0.03);
--shadow-md: 0 4px 14px rgba(17, 28, 68, 0.07);
--shadow-lg: 0 20px 52px rgba(17, 28, 68, 0.18);
--radius-card: 14px;
--radius-owliver: 20px;
--animate-typing: typing 1.1s ease-in-out infinite;
--animate-rise: rise 0.22s ease-out;
--animate-pulse-glow: pulse-glow 2.5s ease-in-out infinite;
@keyframes typing {
0%, 60%, 100% { opacity: 0.25; transform: translateY(0); }
30% { opacity: 1; transform: translateY(-3px); }
}
@keyframes rise {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse-glow {
0%, 100% { opacity: 0.4; transform: scale(1); }
50% { opacity: 0.85; transform: scale(1.05); }
}
}
/* ===========================================================================
Shell geometry (Section 7 / 15 / 16 / 30).
=========================================================================== */
:root {
--header-height: 64px;
--sidebar-width: 244px;
--sidebar-collapsed-width: 72px;
--sidebar-rail-width: 72px;
--owliver-width: 380px;
--owliver-gap: 16px;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
background: var(--color-ground);
color: var(--color-ink);
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
/* Section 25: never allow horizontal page overflow. */
overflow-x: hidden;
}
@layer base {
button {
font: inherit;
}
}
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-radius: 8px;
}
/* --- app shell grid ------------------------------------------------------ */
.app-shell {
display: grid;
grid-template-columns:
var(--sidebar-width)
minmax(0, 1fr)
var(--owliver-width);
grid-template-rows:
var(--header-height)
minmax(0, 1fr);
width: 100%;
height: 100vh;
height: 100dvh;
overflow: hidden;
}
/* Sidebar collapsed to the icon rail (desktop). */
.app-shell[data-sidebar="collapsed"] {
grid-template-columns:
var(--sidebar-collapsed-width)
minmax(0, 1fr)
var(--owliver-width);
}
/* Owliver closed: main reclaims the third column. */
.app-shell[data-owliver="closed"] {
grid-template-columns:
var(--sidebar-width)
minmax(0, 1fr);
}
.app-shell[data-sidebar="collapsed"][data-owliver="closed"] {
grid-template-columns:
var(--sidebar-collapsed-width)
minmax(0, 1fr);
}
/* Tablet: compact rail, Owliver becomes an overlay (no third column). */
.app-shell[data-layout="tablet"] {
grid-template-columns: var(--sidebar-rail-width) minmax(0, 1fr);
}
/* Mobile: sidebar is a drawer, Owliver is a bottom sheet. */
.app-shell[data-layout="mobile"] {
grid-template-columns: minmax(0, 1fr);
}
/* Sidebar owns the full viewport height (Section 8). */
.app-sidebar {
grid-column: 1;
grid-row: 1 / 3;
min-height: 0;
min-width: 0;
}
/* Header spans MAIN + OWLIVER only (Section 8). */
.app-header {
grid-column: 2 / -1;
grid-row: 1;
min-width: 0;
}
.app-main {
grid-column: 2;
grid-row: 2;
min-width: 0;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
/* Section 16: the breathing space between MAIN and OWLIVER comes from the
page's own right-hand padding, so the gap matches the page gutter exactly
and never doubles up into a chasm. */
padding-right: 0;
}
.app-shell[data-layout="tablet"] .app-main,
.app-shell[data-layout="mobile"] .app-main {
padding-right: 0;
}
.app-shell[data-layout="mobile"] .app-header {
grid-column: 1 / -1;
}
.app-shell[data-layout="mobile"] .app-main {
grid-column: 1;
}
/* --- Owliver shell (Section 15) ------------------------------------------ */
.owliver-rail {
grid-column: 3;
grid-row: 2;
min-width: 0;
min-height: 0;
/* top breathing room, right gutter, bottom gutter, zero left (gap is on main) */
padding: 12px 16px 16px 0;
}
.owliver {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: var(--radius-owliver);
background: var(--color-surface);
border: 1px solid var(--color-line);
box-shadow: var(--shadow-sm);
}
/* Section 17: only the conversation scrolls. */
.owliver-header {
flex-shrink: 0;
}
.owliver-content {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
}
.owliver-composer {
flex-shrink: 0;
}
/* --- scrollbars ---------------------------------------------------------- */
/* Standard properties only. Custom ::-webkit-scrollbar rules on nested
scrollers can drive Chrome into a layout loop. */
.scroll-slim {
scrollbar-width: thin;
scrollbar-color: #cbd8ea transparent;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
}
/* --- Ambient Command Center Utilities --- */
.ambient-glow {
position: relative;
}
.ambient-glow::before {
content: "";
position: absolute;
inset: -1px;
background: linear-gradient(135deg, rgba(29, 78, 216, 0.18), rgba(99, 102, 241, 0.12), rgba(168, 85, 247, 0.08));
border-radius: inherit;
z-index: -1;
filter: blur(12px);
pointer-events: none;
}
.glass-card {
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(228, 235, 246, 0.95);
}
.glass-card-interactive {
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.glass-card-interactive:hover {
transform: translateY(-2px);
box-shadow: 0 10px 24px -4px rgba(17, 28, 68, 0.08), 0 2px 6px -1px rgba(17, 28, 68, 0.04);
border-color: rgba(197, 216, 251, 0.9);
}
/* --- Genspark Workspace 6.0 Tokens (Light Theme) --- */
.genspark-workspace {
background-color: var(--color-ground);
color: var(--color-ink);
}
.genspark-capsule {
background: var(--color-surface);
border: 1px solid var(--color-line);
box-shadow: 0 4px 20px -2px rgba(17, 28, 68, 0.08), 0 2px 6px -1px rgba(17, 28, 68, 0.04);
}
.genspark-capsule:focus-within {
border-color: var(--color-primary-edge);
box-shadow: 0 8px 28px -4px rgba(29, 78, 216, 0.14), 0 0 0 2px rgba(29, 78, 216, 0.08);
}
.genspark-badge-glow {
background: linear-gradient(135deg, rgba(37, 99, 235, 0.08), rgba(29, 78, 216, 0.12));
border: 1px solid rgba(59, 130, 246, 0.3);
box-shadow: 0 2px 12px rgba(37, 99, 235, 0.15);
}

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

14
src/main.tsx Normal file
View File

@@ -0,0 +1,14 @@
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { BrowserRouter } from "react-router-dom"
import App from "./App"
import "./index.css"
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
)

View File

@@ -0,0 +1,310 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import type { ReactNode } from "react"
import { useNavigate } from "react-router-dom"
import { resolveAllShortages } from "@/data/store"
import { QUICK_PROMPTS, answer, followUpsFor, resolvedReply } from "./service"
import type { ActionId, Block, OwliverContext, OwliverMessage } from "./types"
/* ===========================================================================
Owliver conversation state.
Rules this implements:
· A page always opens EMPTY — no greeting, no seeded exchange. The only
thing offered before the user acts is that page's initial suggestions.
· Navigating resets the ACTIVE conversation but archives it to History.
· Reload clears the active conversation and returns to the empty state.
· History is local only (localStorage), and reopening an entry restores it
in its original page context.
=========================================================================== */
export interface HistoryEntry {
id: string
context: OwliverContext
title: string
at: string
messages: OwliverMessage[]
followUps: string[]
}
interface OwliverState {
context: OwliverContext
messages: OwliverMessage[]
typing: boolean
/** Initial suggestions while empty; follow-ups once a conversation exists. */
suggestions: string[]
hasConversation: boolean
history: HistoryEntry[]
send: (text: string) => void
runAction: (action: ActionId) => void
reset: () => void
openHistoryEntry: (id: string) => void
clearHistory: () => void
}
const OwliverStateContext = createContext<OwliverState | null>(null)
const ACTION_ROUTES: Partial<Record<ActionId, string>> = {
"open-orders": "/orders",
"open-compliance": "/compliance",
"open-availability": "/availability",
"open-payroll": "/payroll",
"open-invoices": "/invoices",
"open-reports": "/reports",
"open-tasks": "/tasks",
"open-staff": "/staff",
"open-clients": "/clients",
}
/** Where each context lives, so a restored conversation reopens in its page. */
const CONTEXT_ROUTES: Record<OwliverContext, string> = {
home: "/",
performance: "/performance",
orders: "/orders",
availability: "/availability",
tasks: "/tasks",
staff: "/staff",
compliance: "/compliance",
benefits: "/benefits",
clients: "/clients",
invoices: "/invoices",
payroll: "/payroll",
reports: "/reports",
messages: "/messages",
settings: "/settings",
}
const HISTORY_KEY = "krow-owliver-history"
const HISTORY_LIMIT = 30
function readHistory(): HistoryEntry[] {
try {
const raw = localStorage.getItem(HISTORY_KEY)
return raw ? (JSON.parse(raw) as HistoryEntry[]) : []
} catch {
return []
}
}
function writeHistory(entries: HistoryEntry[]) {
try {
localStorage.setItem(HISTORY_KEY, JSON.stringify(entries.slice(0, HISTORY_LIMIT)))
} catch {
/* storage unavailable (private window, blocked) — History stays in memory */
}
}
let seq = 0
const nextId = () => `owl-${Date.now().toString(36)}-${++seq}`
export function OwliverProvider({
context,
children,
}: {
context: OwliverContext
children: ReactNode
}) {
const navigate = useNavigate()
const [messages, setMessages] = useState<OwliverMessage[]>([])
const [followUps, setFollowUps] = useState<string[]>([])
const [typing, setTyping] = useState(false)
const [history, setHistory] = useState<HistoryEntry[]>(() => readHistory())
const timers = useRef<number[]>([])
const prevContext = useRef<OwliverContext>(context)
// Mirrors of the live conversation so the navigation effect can archive it
// without needing them in its dependency list.
const liveMessages = useRef<OwliverMessage[]>([])
const liveFollowUps = useRef<string[]>([])
const restoringId = useRef<string | null>(null)
liveMessages.current = messages
liveFollowUps.current = followUps
useEffect(() => {
const t = timers.current
return () => t.forEach(clearTimeout)
}, [])
/** Puts a finished conversation into History (most recent first). */
const archive = useCallback(
(ctx: OwliverContext, msgs: OwliverMessage[], ups: string[]) => {
if (msgs.length === 0) return
const firstUser = msgs.find((m) => m.author === "user")
const entry: HistoryEntry = {
id: nextId(),
context: ctx,
title: firstUser?.text ?? "Owliver conversation",
at: new Date().toISOString(),
messages: msgs,
followUps: ups,
}
setHistory((prev) => {
const next = [entry, ...prev].slice(0, HISTORY_LIMIT)
writeHistory(next)
return next
})
},
[]
)
/* Navigation: archive what was on the previous page, then start fresh. */
useEffect(() => {
if (prevContext.current === context) return
const from = prevContext.current
prevContext.current = context
// A History restore navigates on purpose — don't wipe what it just loaded.
if (restoringId.current) return
archive(from, liveMessages.current, liveFollowUps.current)
setMessages([])
setFollowUps([])
setTyping(false)
}, [context, archive])
const reply = useCallback((blocks: Block[], ups: string[]) => {
setTyping(true)
const message: OwliverMessage = { id: nextId(), author: "owliver", blocks }
const t = window.setTimeout(
() => {
setTyping(false)
setMessages((prev) => [...prev, message])
// Follow-ups appear with the answer, not on some later action.
setFollowUps(ups)
},
500 + Math.round(Math.random() * 400)
)
timers.current.push(t)
}, [])
/** The single pipeline for clicked suggestions and typed questions alike. */
const send = useCallback(
(text: string) => {
const trimmed = text.trim()
if (!trimmed || typing) return
const userMessage: OwliverMessage = { id: nextId(), author: "user", text: trimmed }
setMessages((prev) => [...prev, userMessage])
setFollowUps([])
const { blocks, followUps: ups } = answer(trimmed, context)
reply(blocks, ups)
},
[context, reply, typing]
)
const runAction = useCallback(
(action: ActionId) => {
if (action === "resolve-understaffed") {
const resolved = resolveAllShortages()
const blocks: Block[] =
resolved.length === 0
? [
{
type: "say",
lines: ["Every live shift is already fully staffed — nothing left to resolve."],
},
]
: resolvedReply(resolved)
reply(blocks, followUpsFor("understaffed shifts resolved", context))
return
}
const route = ACTION_ROUTES[action]
if (route) navigate(route)
},
[context, navigate, reply]
)
/** Reload: clear the active conversation only. History is untouched. */
const reset = useCallback(() => {
archive(context, liveMessages.current, liveFollowUps.current)
timers.current.forEach(clearTimeout)
timers.current = []
setMessages([])
setFollowUps([])
setTyping(false)
}, [archive, context])
const openHistoryEntry = useCallback(
(id: string) => {
const entry = history.find((h) => h.id === id)
if (!entry) return
archive(context, liveMessages.current, liveFollowUps.current)
setTyping(false)
if (entry.context !== context) {
// Restore inside its own page so the conversation keeps its scope.
restoringId.current = entry.id
prevContext.current = entry.context
navigate(CONTEXT_ROUTES[entry.context])
}
setMessages(entry.messages)
setFollowUps(
entry.followUps.length > 0
? entry.followUps
: QUICK_PROMPTS[entry.context].slice(0, 3)
)
window.setTimeout(() => {
restoringId.current = null
}, 0)
},
[archive, context, history, navigate]
)
const clearHistory = useCallback(() => {
setHistory([])
writeHistory([])
}, [])
const hasConversation = messages.length > 0
const value = useMemo<OwliverState>(
() => ({
context,
messages,
typing,
// Initial suggestions before any interaction; follow-ups afterwards.
suggestions: hasConversation ? followUps : QUICK_PROMPTS[context],
hasConversation,
history,
send,
runAction,
reset,
openHistoryEntry,
clearHistory,
}),
[
context,
messages,
typing,
hasConversation,
followUps,
history,
send,
runAction,
reset,
openHistoryEntry,
clearHistory,
]
)
return (
<OwliverStateContext.Provider value={value}>{children}</OwliverStateContext.Provider>
)
}
export function useOwliver() {
const value = useContext(OwliverStateContext)
if (!value) throw new Error("useOwliver must be used inside an OwliverProvider")
return value
}

View File

@@ -0,0 +1,344 @@
import {
AlertTriangle,
Building2,
BarChart3,
CheckCircle2,
ChevronDown,
ClipboardList,
Clock,
FileText,
Network,
Banknote,
ShieldCheck,
Sparkles,
Users,
type LucideIcon,
} from "lucide-react"
import { Suspense, lazy, useState } from "react"
import type { Tone } from "@/data/types"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { FlowDiagram } from "./FlowDiagram"
import type { ActionId, Block, BlockIcon, CellValue } from "../types"
/* ===========================================================================
Renders Owliver's typed blocks as real UI.
Section 21: tables must fit the panel width. Every table scrolls inside its
own container, so the application itself never scrolls sideways.
=========================================================================== */
const ICONS: Record<BlockIcon, LucideIcon> = {
users: Users,
flow: Network,
alert: AlertTriangle,
orders: ClipboardList,
clock: Clock,
shield: ShieldCheck,
money: Banknote,
chart: BarChart3,
check: CheckCircle2,
sparkles: Sparkles,
file: FileText,
building: Building2,
}
const DOT: Record<Tone, string> = {
neutral: "bg-ink-3",
primary: "bg-primary",
good: "bg-good-dot",
warn: "bg-warn-dot",
bad: "bg-bad-dot",
}
const BADGE: Record<Tone, string> = {
neutral: "bg-surface-sunk border-line text-ink-2",
primary: "bg-primary-soft border-primary-edge text-primary",
good: "bg-good-soft border-good-edge text-good",
warn: "bg-warn-soft border-warn-edge text-warn",
bad: "bg-bad-soft border-bad-edge text-bad",
}
const ALERT_SURFACE: Record<Tone, string> = {
neutral: "border-line bg-surface-soft",
primary: "border-primary-edge bg-primary-soft",
good: "border-good-edge bg-good-soft",
warn: "border-warn-edge bg-warn-soft",
bad: "border-bad-edge bg-bad-soft",
}
const ALERT_TEXT: Record<Tone, string> = {
neutral: "text-ink",
primary: "text-primary",
good: "text-good",
warn: "text-warn",
bad: "text-bad",
}
/** Renders `**bold**` segments without pulling in a markdown dependency. */
function RichText({ text }: { text: string }) {
const parts = text.split(/(\*\*[^*]+\*\*)/g)
return (
<>
{parts.map((part, i) =>
part.startsWith("**") && part.endsWith("**") ? (
<strong key={i} className="font-semibold text-ink">
{part.slice(2, -2)}
</strong>
) : (
<span key={i}>{part}</span>
)
)}
</>
)
}
function Cell({ value }: { value: CellValue }) {
if (typeof value === "object" && "dot" in value) {
return (
<span className="flex max-w-[118px] items-center gap-1.5">
<span className={cn("size-1.5 shrink-0 rounded-full", DOT[value.dot])} />
<span className="truncate">{value.label}</span>
</span>
)
}
if (typeof value === "object" && "badge" in value) {
return (
<span
className={cn(
"inline-flex whitespace-nowrap rounded-full border px-1.5 py-0.5 text-[10px] font-semibold",
BADGE[value.tone]
)}
>
{value.badge}
</span>
)
}
return <span className="block max-w-[118px] truncate">{value}</span>
}
const LazyChart = lazy(() =>
import("./ChartBlock").then((m) => ({ default: m.ChartBlock }))
)
function ChartBlock({ block }: { block: Extract<Block, { type: "chart" }> }) {
return (
<Suspense
fallback={
<div
style={{ height: block.height ?? 132 }}
className="w-full animate-pulse rounded-[10px] bg-surface-sunk"
/>
}
>
<LazyChart block={block} />
</Suspense>
)
}
function Details({ block, onAction }: { block: Extract<Block, { type: "details" }>; onAction: (a: ActionId) => void }) {
const [open, setOpen] = useState(false)
return (
<div className="rounded-[10px] border border-line bg-surface-soft">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex w-full items-center justify-between gap-2 px-2.5 py-2 text-left text-[12.5px] font-semibold text-ink-2 transition-colors hover:text-ink"
>
{block.summary}
<ChevronDown
className={cn("size-3.5 shrink-0 text-ink-3 transition-transform", open && "rotate-180")}
/>
</button>
{open && (
<div className="animate-rise space-y-2 border-t border-line-soft p-2.5 pt-2">
{block.body.map((b, i) => (
<BlockView key={i} block={b} onAction={onAction} />
))}
</div>
)}
</div>
)
}
export function BlockView({
block,
onAction,
}: {
block: Block
onAction: (action: ActionId) => void
}) {
switch (block.type) {
case "say":
return (
<div className="space-y-1 text-[13px] leading-[1.55] text-ink-2">
{block.lines.map((line, i) => (
<p key={i}>
<RichText text={line} />
</p>
))}
</div>
)
case "card": {
const Icon = ICONS[block.icon]
return (
<div className="overflow-hidden rounded-[12px] border border-line bg-surface">
<div className="flex items-center gap-2 border-b border-line-soft bg-surface-soft px-2.5 py-2">
<Icon className="size-3.5 shrink-0 text-primary" />
<span className="truncate text-[12.5px] font-semibold text-ink">
{block.title}
</span>
</div>
<div className="space-y-2.5 p-2.5">
{block.body.map((b, i) => (
<BlockView key={i} block={b} onAction={onAction} />
))}
</div>
</div>
)
}
case "metrics":
return (
<div className="grid grid-cols-3 gap-1.5">
{block.items.map((m, i) => (
<div
key={i}
className={cn(
"min-w-0 rounded-[10px] border p-2",
ALERT_SURFACE[m.tone ?? "neutral"]
)}
>
<div className="truncate text-[9.5px] font-bold uppercase tracking-[0.08em] text-ink-3">
{m.label}
</div>
<div
className={cn(
"truncate text-[15px] font-bold leading-tight",
ALERT_TEXT[m.tone ?? "neutral"]
)}
>
{m.value}
</div>
{m.sub && (
<div className="truncate text-[10px] text-ink-3">{m.sub}</div>
)}
</div>
))}
</div>
)
case "table":
return (
<div className="overflow-x-auto scroll-slim rounded-[10px] border border-line">
<table className="w-full border-collapse text-[12px]">
<thead>
<tr className="bg-surface-soft">
{block.columns.map((c) => (
<th
key={c}
className="whitespace-nowrap px-2 py-1.5 text-left text-[10px] font-bold uppercase tracking-[0.06em] text-ink-3"
>
{c}
</th>
))}
</tr>
</thead>
<tbody>
{block.rows.map((row, i) => (
<tr
key={i}
className={cn(
"border-t border-line-soft",
row.summary && "bg-surface-soft font-semibold text-ink"
)}
>
{row.cells.map((cell, j) => (
<td
key={j}
className={cn(
"px-2 py-1.5 align-middle",
row.summary ? "text-ink" : "text-ink-2"
)}
>
<Cell value={cell} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
case "alerts":
return (
<div className="space-y-1.5">
{block.items.map((a, i) => {
const Icon = ICONS[a.icon]
return (
<div
key={i}
className={cn(
"flex items-start gap-2 rounded-[10px] border p-2",
ALERT_SURFACE[a.tone]
)}
>
<Icon className={cn("mt-0.5 size-3.5 shrink-0", ALERT_TEXT[a.tone])} />
<div className="min-w-0 flex-1">
<div className={cn("text-[12.5px] font-semibold", ALERT_TEXT[a.tone])}>
{a.text}
</div>
<div className="text-[11px] leading-snug text-ink-2">{a.note}</div>
</div>
</div>
)
})}
</div>
)
case "flow":
return <FlowDiagram />
case "chart":
return <ChartBlock block={block} />
case "actions":
return (
<div className="flex flex-wrap gap-1.5">
{block.items.map((a, i) => (
<Button
key={i}
size="sm"
variant={a.variant ?? "default"}
onClick={() => onAction(a.action)}
>
{a.label}
</Button>
))}
</div>
)
case "recommend":
return (
<div className="flex items-start gap-2 rounded-[10px] border border-primary-edge bg-primary-soft p-2.5">
<Sparkles className="mt-0.5 size-3.5 shrink-0 text-primary" />
<p className="text-[12.5px] leading-snug text-ink-2">
<span className="font-semibold text-primary">Recommendation · </span>
{block.text}
</p>
</div>
)
case "details":
return <Details block={block} onAction={onAction} />
default:
return null
}
}

View File

@@ -0,0 +1,95 @@
import {
Bar,
BarChart,
Cell as RechartsCell,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts"
import type { Tone } from "@/data/types"
import type { Block } from "../types"
const CHART_TONE: Record<Tone, string> = {
neutral: "#8b99b8",
primary: "#1d4ed8",
good: "#22a957",
warn: "#f0b429",
bad: "#e4544f",
}
const tooltipStyle = {
borderRadius: 10,
border: "1px solid #e4ebf6",
fontSize: 12,
boxShadow: "0 4px 14px rgba(17,28,68,.07)",
}
/** Owliver's inline charts. Loaded on demand so Recharts stays out of the shell. */
export function ChartBlock({ block }: { block: Extract<Block, { type: "chart" }> }) {
const height = block.height ?? 132
const fmt = (v: number) =>
block.unit === "$" ? `$${(v / 1000).toFixed(1)}k` : `${v}${block.unit ?? ""}`
return (
<div style={{ height }} className="w-full">
<ResponsiveContainer width="100%" height="100%">
{block.kind === "line" ? (
<LineChart data={block.data} margin={{ top: 6, right: 6, bottom: 0, left: -22 }}>
<XAxis
dataKey="label"
tick={{ fontSize: 10, fill: "#8b99b8" }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 10, fill: "#8b99b8" }}
axisLine={false}
tickLine={false}
width={44}
/>
<Tooltip formatter={(v) => fmt(Number(v))} contentStyle={tooltipStyle} />
<Line
type="monotone"
dataKey="value"
stroke="#1d4ed8"
strokeWidth={2}
dot={{ r: 2.5, fill: "#1d4ed8" }}
activeDot={{ r: 4 }}
/>
</LineChart>
) : (
<BarChart data={block.data} margin={{ top: 6, right: 6, bottom: 0, left: -22 }}>
<XAxis
dataKey="label"
tick={{ fontSize: 10, fill: "#8b99b8" }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 10, fill: "#8b99b8" }}
axisLine={false}
tickLine={false}
width={44}
tickFormatter={(v) => (block.unit === "$" ? `${v / 1000}k` : String(v))}
/>
<Tooltip
cursor={{ fill: "rgba(29,78,216,.06)" }}
formatter={(v) => fmt(Number(v))}
contentStyle={tooltipStyle}
/>
<Bar dataKey="value" radius={[5, 5, 0, 0]} maxBarSize={38}>
{block.data.map((d, i) => (
<RechartsCell key={i} fill={CHART_TONE[d.tone ?? "primary"]} />
))}
</Bar>
</BarChart>
)}
</ResponsiveContainer>
</div>
)
}

View File

@@ -0,0 +1,80 @@
import { STAFF_COUNTS } from "@/data/mockData"
import { useWorkforceRevision } from "@/data/store"
import { percentOfStaff } from "@/data/selectors"
import { cn } from "@/lib/utils"
/* ===========================================================================
Staff allocation flow (Section 20).
A real React component, drawn with layout and an SVG connector — not an
image, and not a pre-rendered picture of a chart.
=========================================================================== */
const BRANCHES = [
{ key: "active", label: "Active", tone: "good" },
{ key: "onBreak", label: "On Break", tone: "warn" },
{ key: "unavailable", label: "Unavailable", tone: "bad" },
] as const
const toneClasses = {
good: "border-good-edge bg-good-soft text-good",
warn: "border-warn-edge bg-warn-soft text-warn",
bad: "border-bad-edge bg-bad-soft text-bad",
}
export function FlowDiagram() {
useWorkforceRevision()
const total = STAFF_COUNTS.total
return (
<div className="flex flex-col items-center">
{/* root */}
<div className="rounded-[10px] border border-primary-edge bg-primary-soft px-3 py-1.5 text-center">
<div className="text-[9.5px] font-bold uppercase tracking-[0.12em] text-primary/70">
Total Staff
</div>
<div className="text-[17px] font-bold leading-tight text-primary">{total}</div>
</div>
{/* connector */}
<svg
viewBox="0 0 300 30"
preserveAspectRatio="none"
className="h-[30px] w-full text-[#c5d8fb]"
aria-hidden="true"
>
<path
d="M150 0 V12 M18 12 H282 M18 12 V30 M150 12 V30 M282 12 V30"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
vectorEffect="non-scaling-stroke"
/>
</svg>
{/* branches */}
<div className="grid w-full grid-cols-3 gap-1.5">
{BRANCHES.map((b) => {
const value = STAFF_COUNTS[b.key]
return (
<div
key={b.key}
className={cn(
"rounded-[10px] border px-1.5 py-1.5 text-center",
toneClasses[b.tone]
)}
>
<div className="truncate text-[9.5px] font-bold uppercase tracking-[0.08em] opacity-75">
{b.label}
</div>
<div className="text-[15px] font-bold leading-tight">{value}</div>
<div className="text-[10.5px] font-semibold opacity-75">
{percentOfStaff(value)}%
</div>
</div>
)
})}
</div>
</div>
)
}

2134
src/owliver/service.ts Normal file

File diff suppressed because it is too large Load Diff

97
src/owliver/types.ts Normal file
View File

@@ -0,0 +1,97 @@
import type { Tone } from "@/data/types"
/* ===========================================================================
Owliver's structured response format.
A reply is a list of typed blocks, not a paragraph. Each block maps to a real
React component, which is how Owliver answers with tables, alerts, charts,
flow diagrams and working action buttons.
=========================================================================== */
export type BlockIcon =
| "users"
| "flow"
| "alert"
| "orders"
| "clock"
| "shield"
| "money"
| "chart"
| "check"
| "sparkles"
| "file"
| "building"
export type CellValue =
| string
| number
| { dot: Tone; label: string }
| { badge: string; tone: Tone }
export interface TableDataRow {
cells: CellValue[]
summary?: boolean
}
export type ActionId =
| "resolve-understaffed"
| "open-orders"
| "open-compliance"
| "open-availability"
| "open-payroll"
| "open-invoices"
| "open-reports"
| "open-tasks"
| "open-staff"
| "open-clients"
export type Block =
| { type: "say"; lines: string[] }
| { type: "card"; icon: BlockIcon; title: string; body: Block[] }
| {
type: "metrics"
items: { label: string; value: string; sub?: string; tone?: Tone }[]
}
| { type: "table"; columns: string[]; rows: TableDataRow[]; compact?: boolean }
| {
type: "alerts"
items: { icon: BlockIcon; tone: Tone; text: string; note: string }[]
}
| { type: "flow" }
| {
type: "chart"
kind: "bar" | "line" | "donut"
data: { label: string; value: number; tone?: Tone }[]
unit?: string
height?: number
}
| {
type: "actions"
items: { label: string; action: ActionId; variant?: "default" | "outline" }[]
}
| { type: "recommend"; text: string }
| { type: "details"; summary: string; body: Block[] }
export interface OwliverMessage {
id: string
author: "owliver" | "user"
text?: string
blocks?: Block[]
}
/** The pages Owliver tailors itself to (Section 22). */
export type OwliverContext =
| "home"
| "performance"
| "orders"
| "availability"
| "tasks"
| "staff"
| "compliance"
| "benefits"
| "clients"
| "invoices"
| "payroll"
| "reports"
| "messages"
| "settings"

View File

@@ -0,0 +1,191 @@
import { CalendarClock, CircleSlash, Coffee, UserCheck, Users } from "lucide-react"
import { useMemo, useState } from "react"
import { MetricCard } from "@/components/shared/MetricCard"
import { Page, PageIntro, SectionCard } from "@/components/shared/Page"
import { StatusBadge, StatusDot } from "@/components/shared/StatusBadge"
import { Toolbar } from "@/components/shared/Toolbar"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { AVAILABILITY, STAFF_COUNTS, TOMORROW, UPCOMING_SHIFTS } from "@/data/mockData"
import { percentOfStaff, staffCoverage } from "@/data/selectors"
import { useWorkforceRevision } from "@/data/store"
export function AvailabilityPage() {
useWorkforceRevision()
const [query, setQuery] = useState("")
const [status, setStatus] = useState("all")
const rows = useMemo(
() =>
AVAILABILITY.filter(
(a) =>
(status === "all" || a.status === status) &&
(query === "" ||
`${a.name} ${a.role} ${a.assignedTo ?? ""}`
.toLowerCase()
.includes(query.toLowerCase()))
),
[query, status]
)
return (
<Page>
<PageIntro
title="Staff Availability"
description="Who is on shift, who is on break and who can still be allocated today."
actions={<Button variant="outline">Request availability</Button>}
/>
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
<MetricCard
label="Active Now"
value={String(STAFF_COUNTS.active)}
sub={`${staffCoverage()}% of tracked staff`}
icon={UserCheck}
badge="On shift"
badgeTone="good"
tone="primary"
progress={staffCoverage()}
/>
<MetricCard
label="On Break"
value={String(STAFF_COUNTS.onBreak)}
sub={`${percentOfStaff(STAFF_COUNTS.onBreak)}% of tracked staff`}
icon={Coffee}
badge="Returning"
badgeTone="warn"
tone="primary"
/>
<MetricCard
label="Unavailable"
value={String(STAFF_COUNTS.unavailable)}
sub={`${percentOfStaff(STAFF_COUNTS.unavailable)}% of tracked staff`}
icon={CircleSlash}
badge="Off duty"
badgeTone="bad"
tone={STAFF_COUNTS.unavailable > 0 ? "warn" : "primary"}
/>
<MetricCard
label="Total Roster"
value={String(STAFF_COUNTS.total)}
sub="Across 3 staffing pools"
icon={Users}
tone="primary"
/>
</div>
<SectionCard
title="Availability & Allocation"
icon={Users}
bodyClassName="p-0"
>
<Toolbar
query={query}
onQuery={setQuery}
placeholder="Search staff, role or location…"
filters={[
{
label: "All statuses",
value: status,
onChange: setStatus,
options: ["all", "Active", "On Break", "Unavailable"],
},
]}
/>
<Table>
<TableHeader>
<TableRow className="hover:bg-surface-soft">
<TableHead>Staff</TableHead>
<TableHead>Role</TableHead>
<TableHead>Available</TableHead>
<TableHead>Allocated to</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((a) => (
<TableRow key={a.staffId}>
<TableCell className="font-medium text-ink">{a.name}</TableCell>
<TableCell>{a.role}</TableCell>
<TableCell className="whitespace-nowrap">
{a.from === "—" ? (
<span className="text-ink-3">{a.note}</span>
) : (
`${a.from} – ${a.to}`
)}
</TableCell>
<TableCell>
{a.assignedTo ?? <span className="text-ink-3">Unallocated</span>}
</TableCell>
<TableCell>
<span className="flex items-center gap-2">
<StatusDot value={a.status} />
<StatusBadge value={a.status} size="sm" />
</span>
</TableCell>
</TableRow>
))}
{rows.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="py-8 text-center text-ink-3">
No staff match those filters.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</SectionCard>
<div className="grid gap-3 md:grid-cols-2">
<SectionCard title="Upcoming Shifts" icon={CalendarClock} bodyClassName="p-3">
<div className="space-y-2">
{UPCOMING_SHIFTS.map((s) => (
<div
key={s.ref}
className="flex items-center gap-3 rounded-[10px] border border-line-soft bg-surface-soft px-3 py-2.5"
>
<span className="grid size-9 shrink-0 place-items-center rounded-[9px] border border-primary-edge bg-primary-soft text-[14px] font-bold text-primary">
{s.count}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-semibold text-ink">{s.when}</div>
<div className="truncate text-[12px] text-ink-3">{s.location}</div>
</div>
</div>
))}
</div>
</SectionCard>
<SectionCard title="Tomorrow's Roster" icon={CalendarClock} bodyClassName="p-3">
<div className="space-y-2">
{TOMORROW.map((t) => (
<div
key={t.name}
className="flex items-center gap-2.5 rounded-[10px] border border-line-soft bg-surface-soft px-3 py-2"
>
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-semibold text-ink">{t.name}</div>
<div className="truncate text-[12px] text-ink-3">
{t.role} · {t.shift}
</div>
</div>
<StatusBadge
value={t.status === "Unconfirmed" ? "Review" : t.status}
size="sm"
/>
</div>
))}
</div>
</SectionCard>
</div>
</Page>
)
}

204
src/pages/BenefitsPage.tsx Normal file
View File

@@ -0,0 +1,204 @@
import { Banknote, CalendarClock, Gift, UserCheck } from "lucide-react"
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts"
import { MetricCard } from "@/components/shared/MetricCard"
import { Page, PageIntro, SectionCard } from "@/components/shared/Page"
import { StatusBadge } from "@/components/shared/StatusBadge"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { BENEFITS, STAFF, STAFF_COUNTS } from "@/data/mockData"
import { money } from "@/data/selectors"
const TIER_ORDER = ["Enhanced", "Standard", "Basic"]
export function BenefitsPage() {
const enrolled = BENEFITS.reduce((s, b) => s + b.enrolled, 0)
const eligible = BENEFITS.reduce((s, b) => s + b.eligible, 0)
const cost = BENEFITS.reduce((s, b) => s + b.monthlyCost, 0)
const inReview = BENEFITS.filter((b) => b.status === "Review")
const SHORT_NAME: Record<string, string> = {
"BEN-01": "Health",
"BEN-02": "Pension",
"BEN-03": "Holiday",
"BEN-04": "EAP",
"BEN-05": "Cycle",
"BEN-06": "PPE",
}
const chartData = BENEFITS.map((b) => ({
name: SHORT_NAME[b.id] ?? b.name,
Enrolled: b.enrolled,
Eligible: b.eligible,
}))
return (
<Page>
<PageIntro
title="Benefits"
description="Plan enrolment, eligibility and employer cost across the workforce."
actions={<Button variant="outline">Manage plans</Button>}
/>
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
<MetricCard
label="Total Enrolments"
value={String(enrolled)}
sub={`Across ${BENEFITS.length} active plans`}
icon={UserCheck}
badge="Live"
badgeTone="good"
tone="primary"
/>
<MetricCard
label="Eligibility Take-up"
value={`${Math.round((enrolled / eligible) * 100)}%`}
sub={`${enrolled} of ${eligible} eligible places`}
icon={Gift}
tone="primary"
progress={Math.round((enrolled / eligible) * 100)}
/>
<MetricCard
label="Monthly Cost"
value={money(cost)}
sub="Employer funded contribution"
icon={Banknote}
badge="Recurring"
badgeTone="neutral"
tone="primary"
/>
<MetricCard
label="Plans in Review"
value={String(inReview.length)}
sub="Renewal decisions pending"
icon={CalendarClock}
badge={inReview.length ? "Review" : "Clear"}
badgeTone={inReview.length ? "warn" : "good"}
tone={inReview.length ? "warn" : "primary"}
/>
</div>
<div className="grid gap-3 xl:grid-cols-3">
<SectionCard title="Enrolment vs Eligibility" icon={Gift} className="xl:col-span-2">
<div className="h-[240px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 8, right: 8, bottom: 0, left: -20 }}>
<CartesianGrid stroke="#eef3fa" vertical={false} />
<XAxis
dataKey="name"
tick={{ fontSize: 10.5, fill: "#8b99b8" }}
axisLine={false}
tickLine={false}
interval={0}
/>
<YAxis
tick={{ fontSize: 11, fill: "#8b99b8" }}
axisLine={false}
tickLine={false}
/>
<Tooltip
cursor={{ fill: "rgba(29,78,216,.06)" }}
contentStyle={{
borderRadius: 10,
border: "1px solid #e4ebf6",
fontSize: 12,
}}
/>
<Bar dataKey="Eligible" fill="#dce8fe" radius={[5, 5, 0, 0]} maxBarSize={30} />
<Bar dataKey="Enrolled" fill="#1d4ed8" radius={[5, 5, 0, 0]} maxBarSize={30} />
</BarChart>
</ResponsiveContainer>
</div>
</SectionCard>
<SectionCard title="Benefit Tiers" icon={UserCheck} bodyClassName="p-4">
<div className="space-y-3.5">
{TIER_ORDER.map((tier) => {
const count = STAFF.filter((s) => s.benefitsTier === tier).length
const pct = Math.round((count / STAFF.length) * 100)
return (
<div key={tier}>
<div className="mb-1.5 flex items-baseline justify-between gap-2">
<span className="text-[13px] font-semibold text-ink">{tier}</span>
<span className="text-[12px] text-ink-3">
{count} staff · {pct}%
</span>
</div>
<Progress value={pct} />
</div>
)
})}
<p className="border-t border-line-soft pt-3 text-[12px] text-ink-3">
Tier is assigned on contract type and length of service across the{" "}
{STAFF_COUNTS.total}-person roster.
</p>
</div>
</SectionCard>
</div>
<SectionCard title="Benefit Plans" icon={Gift} bodyClassName="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-surface-soft">
<TableHead>Plan</TableHead>
<TableHead>Category</TableHead>
<TableHead>Enrolled</TableHead>
<TableHead>Eligible</TableHead>
<TableHead>Take-up</TableHead>
<TableHead>Monthly cost</TableHead>
<TableHead>Renewal</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{BENEFITS.map((b) => {
const pct = Math.round((b.enrolled / b.eligible) * 100)
return (
<TableRow key={b.id}>
<TableCell className="font-medium text-ink">{b.name}</TableCell>
<TableCell>
<Badge tone="neutral" size="sm">
{b.category}
</Badge>
</TableCell>
<TableCell className="font-semibold text-ink">{b.enrolled}</TableCell>
<TableCell>{b.eligible}</TableCell>
<TableCell>
<div className="w-[84px]">
<div className="mb-1 text-[12px] font-semibold text-ink">{pct}%</div>
<Progress value={pct} className="h-1" />
</div>
</TableCell>
<TableCell className="font-semibold text-ink">
{money(b.monthlyCost)}
</TableCell>
<TableCell className="whitespace-nowrap text-ink-3">{b.renewal}</TableCell>
<TableCell>
<StatusBadge value={b.status} size="sm" />
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</SectionCard>
</Page>
)
}

215
src/pages/ClientsPage.tsx Normal file
View File

@@ -0,0 +1,215 @@
import { AlertTriangle, Banknote, DollarSign, Store, Users } from "lucide-react"
import { useMemo, useState } from "react"
import { useSearchParams } from "react-router-dom"
import { DataTable, type DemoColumnDef } from "@/components/shared/DataTable"
import { MetricCard } from "@/components/shared/MetricCard"
import { Page, PageIntro, SectionCard } from "@/components/shared/Page"
import { StatusBadge } from "@/components/shared/StatusBadge"
import { Toolbar } from "@/components/shared/Toolbar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { CLIENTS } from "@/data/mockData"
import { clientsAtRisk, money } from "@/data/selectors"
import type { Client } from "@/data/types"
const RATE_CARD = [
{ role: "Event Steward", standard: 24.5, overtime: 32.0, night: 28.5 },
{ role: "Bar Supervisor", standard: 27.0, overtime: 35.5, night: 31.0 },
{ role: "Housekeeper", standard: 22.0, overtime: 29.0, night: 25.5 },
{ role: "Cleaning Operative", standard: 21.5, overtime: 28.0, night: 24.5 },
{ role: "Forklift Driver", standard: 26.0, overtime: 34.0, night: 30.0 },
{ role: "Warehouse Operative", standard: 23.0, overtime: 30.0, night: 26.5 },
{ role: "Shift Supervisor", standard: 29.5, overtime: 38.5, night: 33.5 },
]
export function ClientsPage() {
const [params, setParams] = useSearchParams()
const tab = params.get("tab") ?? "accounts"
const [query, setQuery] = useState("")
const [status, setStatus] = useState("all")
const filtered = useMemo(
() => CLIENTS.filter((c) => status === "all" || c.accountStatus === status),
[status]
)
const totalRevenue = CLIENTS.reduce((s, c) => s + c.revenueYtd, 0)
const totalDemand = CLIENTS.reduce((s, c) => s + c.staffDemand, 0)
const activeOrders = CLIENTS.reduce((s, c) => s + c.activeOrders, 0)
const columns = useMemo<DemoColumnDef<Client>[]>(
() => [
{
accessorKey: "name",
header: "Client",
cell: (c) => (
<div className="min-w-0">
<div className="truncate font-semibold text-ink">{c.row.original.name}</div>
<div className="truncate text-[12px] text-ink-3">
{c.row.original.id} · since {c.row.original.since}
</div>
</div>
),
},
{
accessorKey: "venues",
header: "Venues",
cell: (c) => (
<div className="flex w-[176px] flex-wrap gap-1">
{c.row.original.venues.map((v) => (
<Badge key={v} tone="neutral" size="sm">
{v}
</Badge>
))}
</div>
),
},
{
accessorKey: "activeOrders",
header: "Active orders",
cell: (c) => (
<span className="font-semibold text-ink">{c.getValue() as number}</span>
),
},
{
accessorKey: "staffDemand",
header: "Staff demand",
cell: (c) => `${c.getValue() as number} places`,
},
{
accessorKey: "revenueYtd",
header: "Revenue YTD",
cell: (c) => (
<span className="font-semibold text-ink">{money(c.getValue() as number)}</span>
),
},
{
accessorKey: "accountStatus",
header: "Status",
cell: (c) => <StatusBadge value={c.getValue() as string} size="sm" />,
},
{ accessorKey: "owner", header: "Account owner" },
],
[]
)
return (
<Page>
<PageIntro
title="Clients"
description="Account health, venues, staffing demand and revenue for every client."
actions={<Button variant="outline">Add client</Button>}
/>
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
<MetricCard
label="Client Accounts"
value={String(CLIENTS.length)}
sub={`${CLIENTS.reduce((s, c) => s + c.venues.length, 0)} venues served`}
icon={Store}
tone="primary"
/>
<MetricCard
label="Revenue YTD"
value={money(totalRevenue)}
sub="Across all accounts"
icon={Banknote}
badge="Gross"
badgeTone="good"
tone="primary"
/>
<MetricCard
label="Staffing Demand"
value={String(totalDemand)}
sub={`${activeOrders} active orders`}
icon={Users}
tone="primary"
/>
<MetricCard
label="Accounts at Risk"
value={String(clientsAtRisk().length)}
sub="At risk or on hold"
icon={AlertTriangle}
badge="Review"
badgeTone="warn"
tone={clientsAtRisk().length ? "warn" : "primary"}
/>
</div>
<Tabs value={tab} onValueChange={(v) => setParams({ tab: v })}>
<TabsList>
<TabsTrigger value="accounts">
<Store className="size-3.5" />
Client Accounts
</TabsTrigger>
<TabsTrigger value="rates">
<DollarSign className="size-3.5" />
Service Rates
</TabsTrigger>
</TabsList>
<TabsContent value="accounts">
<SectionCard title="Client Directory" icon={Store} bodyClassName="p-0">
<Toolbar
query={query}
onQuery={setQuery}
placeholder="Search clients, venues or owners…"
filters={[
{
label: "All statuses",
value: status,
onChange: setStatus,
options: ["all", "Active", "At Risk", "On Hold", "Prospect"],
},
]}
/>
<DataTable columns={columns} data={filtered} globalFilter={query} pageSize={8} />
</SectionCard>
</TabsContent>
<TabsContent value="rates">
<SectionCard title="Service Rate Card" icon={DollarSign} bodyClassName="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-surface-soft">
<TableHead>Role</TableHead>
<TableHead className="text-right">Standard</TableHead>
<TableHead className="text-right">Night</TableHead>
<TableHead className="text-right">Overtime</TableHead>
<TableHead className="text-right">Uplift</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{RATE_CARD.map((r) => (
<TableRow key={r.role}>
<TableCell className="font-medium text-ink">{r.role}</TableCell>
<TableCell className="text-right font-semibold text-ink">
${r.standard.toFixed(2)}
</TableCell>
<TableCell className="text-right">${r.night.toFixed(2)}</TableCell>
<TableCell className="text-right">${r.overtime.toFixed(2)}</TableCell>
<TableCell className="text-right">
<Badge tone="primary" size="sm">
+{Math.round((r.overtime / r.standard - 1) * 100)}%
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SectionCard>
</TabsContent>
</Tabs>
</Page>
)
}

View File

@@ -0,0 +1,294 @@
import {
AlertTriangle,
FileStack,
FolderKanban,
ShieldAlert,
ShieldCheck,
Shirt,
} from "lucide-react"
import { useSearchParams } from "react-router-dom"
import { MetricCard } from "@/components/shared/MetricCard"
import { Page, PageIntro, SectionCard } from "@/components/shared/Page"
import { StatusBadge } from "@/components/shared/StatusBadge"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { COMPLIANCE_RISKS, STAFF } from "@/data/mockData"
import { complianceFlagCount, staffWithComplianceIssues } from "@/data/selectors"
import { useWorkforceRevision } from "@/data/store"
const CATALOG = [
{ name: "Right to Work", required: "All staff", holders: 44, of: 46, renewal: "On hire" },
{ name: "Manual Handling", required: "Warehouse & Logistics", holders: 15, of: 16, renewal: "2 years" },
{ name: "Forklift Licence", required: "Forklift Drivers", holders: 3, of: 4, renewal: "3 years" },
{ name: "Food Hygiene L2", required: "Event & Hospitality", holders: 17, of: 18, renewal: "3 years" },
{ name: "Personal Licence", required: "Bar Supervisors", holders: 2, of: 2, renewal: "10 years" },
{ name: "COSHH Awareness", required: "Facilities & Cleaning", holders: 11, of: 12, renewal: "1 year" },
]
const DOCUMENTS = [
{ staff: "Rosa Delgado", doc: "Right to Work", status: "Expired", updated: "Pending upload" },
{ staff: "Aisha Patel", doc: "Policy Acknowledgement", status: "Warning", updated: "Awaiting signature" },
{ staff: "Samuel Okoro", doc: "Manual Handling Certificate", status: "Expired", updated: "Expired 02 Sep 2025" },
{ staff: "Tomas Ferreira", doc: "Forklift Licence", status: "Warning", updated: "Expires 17 Sep 2025" },
{ staff: "Leila Haddad", doc: "Right to Work", status: "Verified", updated: "Verified 12 Jul 2025" },
{ staff: "Marcus Vance", doc: "Supervisor Training", status: "Verified", updated: "Verified 03 Aug 2025" },
]
const ATTIRE = [
{ staff: "Priya Raman", item: "Event uniform · size exchange", due: "05 Oct 2025", status: "Review" },
{ staff: "Liam O'Connor", item: "Bar tunic · replacement", due: "05 Oct 2025", status: "Review" },
{ staff: "Rosa Delgado", item: "Hi-vis & safety boots", due: "05 Oct 2025", status: "Review" },
{ staff: "Owen Bradley", item: "Hi-vis · annual check", due: "18 Nov 2025", status: "Verified" },
]
export function CompliancePage() {
useWorkforceRevision()
const [params, setParams] = useSearchParams()
const tab = params.get("tab") ?? "certificates"
const high = COMPLIANCE_RISKS.filter((r) => r.severity === "High")
const expiringSoon = STAFF.filter((s) => s.compliance === "Warning")
return (
<Page>
<PageIntro
title="Compliance"
description="Certificates, documents, policies and attire checks across the workforce."
actions={<Button variant="outline">Export register</Button>}
/>
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
<MetricCard
label="Open Flags"
value={String(complianceFlagCount())}
sub={`Across ${COMPLIANCE_RISKS.length} risk categories`}
icon={ShieldAlert}
badge="Review"
badgeTone="warn"
tone="warn"
/>
<MetricCard
label="High Severity"
value={String(high.reduce((s, r) => s + r.staffCount, 0))}
sub="Block assignment until cleared"
icon={AlertTriangle}
badge="Urgent"
badgeTone="bad"
tone="bad"
/>
<MetricCard
label="Staff Affected"
value={String(staffWithComplianceIssues().length)}
sub={`of ${STAFF.length} tracked staff`}
icon={ShieldCheck}
tone="primary"
/>
<MetricCard
label="Expiring Soon"
value={String(expiringSoon.length)}
sub="Within the next 14 days"
icon={FileStack}
badge="Watch"
badgeTone="warn"
tone="primary"
/>
</div>
<SectionCard title="Compliance Risk Overview" icon={ShieldAlert} bodyClassName="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-surface-soft">
<TableHead>Risk</TableHead>
<TableHead>Category</TableHead>
<TableHead>Affected staff</TableHead>
<TableHead>Severity</TableHead>
<TableHead>Expires</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{COMPLIANCE_RISKS.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium text-ink">{r.risk}</TableCell>
<TableCell>
<Badge tone="neutral" size="sm">
{r.category}
</Badge>
</TableCell>
<TableCell>
<div className="w-[168px]" title={r.staffNames.join(", ")}>
<span className="font-semibold text-ink">{r.staffCount}</span>
<div className="truncate text-[12px] text-ink-3">
{r.staffNames.join(", ")}
</div>
</div>
</TableCell>
<TableCell>
<StatusBadge value={r.severity} size="sm" />
</TableCell>
<TableCell className="whitespace-nowrap">{r.expires.replace(" 2025", "")}</TableCell>
<TableCell className="text-right">
<Button size="sm" variant="outline">
{r.action}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SectionCard>
<Tabs value={tab} onValueChange={(v) => setParams({ tab: v })}>
<TabsList>
<TabsTrigger value="certificates">
<ShieldCheck className="size-3.5" />
Staff Certificates
</TabsTrigger>
<TabsTrigger value="documents">
<FolderKanban className="size-3.5" />
Employee Documents
</TabsTrigger>
<TabsTrigger value="attire">
<Shirt className="size-3.5" />
Attire Review
</TabsTrigger>
<TabsTrigger value="catalog">
<FileStack className="size-3.5" />
Compliance Catalog
</TabsTrigger>
</TabsList>
<TabsContent value="certificates">
<SectionCard title="Staff Certificates" icon={ShieldCheck} bodyClassName="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-surface-soft">
<TableHead>Staff</TableHead>
<TableHead>Role</TableHead>
<TableHead>Standing</TableHead>
<TableHead>Note</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{STAFF.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium text-ink">{s.name}</TableCell>
<TableCell>{s.role}</TableCell>
<TableCell>
<StatusBadge value={s.compliance} size="sm" />
</TableCell>
<TableCell className="text-ink-3">
{s.complianceNote ?? "All certificates current"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SectionCard>
</TabsContent>
<TabsContent value="documents">
<SectionCard title="Employee Documents" icon={FolderKanban} bodyClassName="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-surface-soft">
<TableHead>Staff</TableHead>
<TableHead>Document</TableHead>
<TableHead>Status</TableHead>
<TableHead>Last update</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{DOCUMENTS.map((d) => (
<TableRow key={`${d.staff}-${d.doc}`}>
<TableCell className="font-medium text-ink">{d.staff}</TableCell>
<TableCell>{d.doc}</TableCell>
<TableCell>
<StatusBadge value={d.status} size="sm" />
</TableCell>
<TableCell className="text-ink-3">{d.updated}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SectionCard>
</TabsContent>
<TabsContent value="attire">
<SectionCard title="Attire Review" icon={Shirt} bodyClassName="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-surface-soft">
<TableHead>Staff</TableHead>
<TableHead>Item</TableHead>
<TableHead>Due</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ATTIRE.map((a) => (
<TableRow key={`${a.staff}-${a.item}`}>
<TableCell className="font-medium text-ink">{a.staff}</TableCell>
<TableCell>{a.item}</TableCell>
<TableCell className="whitespace-nowrap">{a.due}</TableCell>
<TableCell>
<StatusBadge value={a.status} size="sm" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SectionCard>
</TabsContent>
<TabsContent value="catalog">
<SectionCard title="Compliance Catalog" icon={FileStack} bodyClassName="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-surface-soft">
<TableHead>Requirement</TableHead>
<TableHead>Applies to</TableHead>
<TableHead>Coverage</TableHead>
<TableHead>Renewal</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{CATALOG.map((c) => {
const complete = c.holders === c.of
return (
<TableRow key={c.name}>
<TableCell className="font-medium text-ink">{c.name}</TableCell>
<TableCell>{c.required}</TableCell>
<TableCell>
<span className="flex items-center gap-2">
<span className="font-semibold text-ink">
{c.holders}/{c.of}
</span>
<StatusBadge
value={complete ? "Verified" : "Warning"}
size="sm"
/>
</span>
</TableCell>
<TableCell className="text-ink-3">{c.renewal}</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</SectionCard>
</TabsContent>
</Tabs>
</Page>
)
}

40
src/pages/HomePage.tsx Normal file
View File

@@ -0,0 +1,40 @@
import { GensparkCommandCapsule } from "@/components/home/GensparkCommandCapsule"
import { GensparkSuiteBar } from "@/components/home/GensparkSuiteBar"
import { GensparkSuperCard } from "@/components/home/GensparkSuperCard"
import { useWorkforceRevision } from "@/data/store"
export function HomePage() {
useWorkforceRevision()
return (
<div className="relative min-h-[calc(100vh-64px)] w-full overflow-hidden px-4 py-8 sm:px-6 sm:py-12 lg:px-8">
{/* ------------------------------------------------ Center Title */}
<div className="pt-2 pb-6 text-center sm:pt-4 sm:pb-8">
<div className="inline-flex items-center gap-2.5 text-[24px] font-bold tracking-tight text-ink sm:gap-3 sm:text-[32px]">
<img
src="/assets/owliver.png"
alt="Owliver Logo"
className="size-8 rounded-[9px] object-cover shadow-xs border border-line sm:size-10 sm:rounded-[12px]"
/>
<span>Owliver AI</span>
</div>
</div>
{/* ------------------------------------------------ Center Search / Command Capsule (Matching Image 2) */}
<div className="pb-8 sm:pb-10">
<GensparkCommandCapsule />
</div>
{/* ------------------------------------------------ Horizontal Suites Bar (Matching Image 2) */}
<div className="pb-8 sm:pb-12">
<GensparkSuiteBar />
</div>
{/* ------------------------------------------------ Bottom Super App Card (Matching Image 2) */}
<div className="pb-6">
<GensparkSuperCard />
</div>
</div>
)
}

200
src/pages/InvoicesPage.tsx Normal file
View File

@@ -0,0 +1,200 @@
import {
AlertTriangle,
Banknote,
Download,
FileSpreadsheet,
Plus,
Send,
} from "lucide-react"
import { useMemo, useState } from "react"
import { DataTable, type DemoColumnDef } from "@/components/shared/DataTable"
import { MetricCard } from "@/components/shared/MetricCard"
import { Page, PageIntro, SectionCard } from "@/components/shared/Page"
import { StatusBadge } from "@/components/shared/StatusBadge"
import { Toolbar } from "@/components/shared/Toolbar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { INVOICES } from "@/data/mockData"
import { invoiceTotals, money, overdueInvoices } from "@/data/selectors"
import type { Invoice } from "@/data/types"
export function InvoicesPage() {
const [query, setQuery] = useState("")
const [status, setStatus] = useState("all")
const totals = invoiceTotals()
const filtered = useMemo(
() => INVOICES.filter((i) => status === "all" || i.status === status),
[status]
)
const columns = useMemo<DemoColumnDef<Invoice>[]>(
() => [
{
accessorKey: "id",
header: "Invoice",
cell: (c) => (
<span className="font-semibold text-primary">{c.getValue() as string}</span>
),
},
{
accessorKey: "client",
header: "Client",
cell: (c) => (
<div className="min-w-0">
<div className="truncate font-medium text-ink">{c.row.original.client}</div>
<div className="truncate text-[12px] text-ink-3">
{c.row.original.orderRefs.join(", ")}
</div>
</div>
),
},
{ accessorKey: "issued", header: "Issued" },
{
accessorKey: "due",
header: "Due",
cell: (c) => (
<span
className={
c.row.original.status === "Overdue"
? "font-semibold text-bad"
: "text-ink-2"
}
>
{c.getValue() as string}
</span>
),
},
{
accessorKey: "amount",
header: "Amount",
cell: (c) => (
<span className="font-semibold text-ink">{money(c.getValue() as number)}</span>
),
},
{
accessorKey: "status",
header: "Status",
cell: (c) => <StatusBadge value={c.getValue() as string} size="sm" />,
},
{
id: "actions",
header: "",
cell: (c) => (
<div className="flex justify-end gap-1.5">
{c.row.original.status === "Draft" ? (
<Button size="sm" variant="soft">
<Send />
Send
</Button>
) : (
<Button size="sm" variant="ghost">
<Download />
PDF
</Button>
)}
</div>
),
},
],
[]
)
return (
<Page>
<PageIntro
title="Invoices"
description="Billing status across every client account, from draft through to settled."
actions={
<>
<Button variant="outline">
<Download />
Export
</Button>
<Button>
<Plus />
Create Invoice
</Button>
</>
}
/>
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
<MetricCard
label="Outstanding"
value={money(totals.outstanding)}
sub="Sent and overdue combined"
icon={FileSpreadsheet}
badge="Open"
badgeTone="warn"
tone="primary"
/>
<MetricCard
label="Overdue"
value={money(totals.overdue)}
sub={`${overdueInvoices().length} invoices past due date`}
icon={AlertTriangle}
badge="Chase"
badgeTone="bad"
tone="bad"
/>
<MetricCard
label="Draft"
value={money(totals.draft)}
sub="Ready for billing review"
icon={Send}
badge="Ready"
badgeTone="good"
tone="primary"
/>
<MetricCard
label="Collected"
value={money(totals.paid)}
sub="Settled this quarter"
icon={Banknote}
badge="Paid"
badgeTone="good"
tone="primary"
/>
</div>
{overdueInvoices().length > 0 && (
<div className="flex flex-wrap items-center gap-3 rounded-[14px] border border-bad-edge bg-bad-soft px-4 py-3">
<AlertTriangle className="size-4 shrink-0 text-bad" />
<p className="min-w-0 flex-1 text-[13px] text-ink-2">
<span className="font-semibold text-bad">
{overdueInvoices().length} invoices are overdue
</span>{" "}
— {overdueInvoices().map((i) => i.client).join(" and ")} owe a combined{" "}
{money(totals.overdue)}.
</p>
<Button size="sm" variant="outline">
Chase payment
</Button>
</div>
)}
<SectionCard title="Invoice Register" icon={FileSpreadsheet} bodyClassName="p-0">
<Toolbar
query={query}
onQuery={setQuery}
placeholder="Search invoices, clients or order references…"
filters={[
{
label: "All statuses",
value: status,
onChange: setStatus,
options: ["all", "Draft", "Sent", "Paid", "Overdue"],
},
]}
>
<Badge tone="neutral">
{filtered.length} of {INVOICES.length}
</Badge>
</Toolbar>
<DataTable columns={columns} data={filtered} globalFilter={query} pageSize={8} />
</SectionCard>
</Page>
)
}

200
src/pages/MessagesPage.tsx Normal file
View File

@@ -0,0 +1,200 @@
import { MessageSquare, Search, SendHorizonal } from "lucide-react"
import { useLayoutEffect, useRef, useState } from "react"
import { Page } from "@/components/shared/Page"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { CONVERSATIONS } from "@/data/mockData"
import { cn } from "@/lib/utils"
import type { Conversation } from "@/data/types"
/* ===========================================================================
Messages — a real two-pane inbox. Sending is local state only.
=========================================================================== */
export function MessagesPage() {
const [threads, setThreads] = useState<Conversation[]>(() =>
CONVERSATIONS.map((c) => ({ ...c, messages: [...c.messages] }))
)
const [activeId, setActiveId] = useState(threads[0].id)
const [query, setQuery] = useState("")
const [draft, setDraft] = useState("")
const endRef = useRef<HTMLDivElement>(null)
const active = threads.find((t) => t.id === activeId) ?? threads[0]
const visible = threads.filter((t) =>
`${t.name} ${t.role}`.toLowerCase().includes(query.toLowerCase())
)
useLayoutEffect(() => {
endRef.current?.scrollIntoView({ block: "end" })
}, [active.messages.length, activeId])
/** Opening a thread marks it read. */
function openThread(id: string) {
setActiveId(id)
setThreads((prev) =>
prev.map((t) => (t.id === id && t.unread > 0 ? { ...t, unread: 0 } : t))
)
}
function send() {
const text = draft.trim()
if (!text) return
setThreads((prev) =>
prev.map((t) =>
t.id === activeId
? {
...t,
lastAt: "Now",
messages: [
...t.messages,
{ id: `local-${t.messages.length}`, from: "me" as const, text, at: "Now" },
],
}
: t
)
)
setDraft("")
}
return (
<Page className="h-full">
<div className="grid h-[calc(100dvh-8rem)] min-h-[420px] grid-cols-1 overflow-hidden rounded-[14px] border border-line bg-surface shadow-xs md:grid-cols-[280px_minmax(0,1fr)]">
{/* ------------------------------------------------- conversations */}
<aside className="flex min-h-0 flex-col border-b border-line md:border-b-0 md:border-r">
<div className="shrink-0 border-b border-line-soft p-3">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-ink-3" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search conversations…"
className="pl-8"
/>
</div>
</div>
<div className="scroll-slim min-h-0 flex-1 overflow-y-auto">
{visible.map((t) => (
<button
key={t.id}
type="button"
onClick={() => openThread(t.id)}
className={cn(
"flex w-full items-start gap-2.5 border-b border-line-soft px-3 py-2.5 text-left transition-colors",
t.id === activeId ? "bg-primary-soft" : "hover:bg-surface-soft"
)}
>
<span className="grid size-9 shrink-0 place-items-center rounded-full bg-surface-sunk text-[11px] font-bold text-ink-2">
{t.initials}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="min-w-0 flex-1 truncate text-[13px] font-semibold text-ink">
{t.name}
</span>
<span className="shrink-0 text-[11px] text-ink-3">{t.lastAt}</span>
</div>
<div className="truncate text-[11.5px] text-ink-3">{t.role}</div>
<div className="mt-0.5 flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-[12px] text-ink-2">
{t.messages[t.messages.length - 1].text}
</span>
{t.unread > 0 && (
<span className="grid size-[17px] shrink-0 place-items-center rounded-full bg-primary text-[10px] font-bold text-white">
{t.unread}
</span>
)}
</div>
</div>
</button>
))}
{visible.length === 0 && (
<p className="p-6 text-center text-[12.5px] text-ink-3">
No conversations match that search.
</p>
)}
</div>
</aside>
{/* ------------------------------------------------- active thread */}
<section className="flex min-h-0 flex-col">
<header className="flex shrink-0 items-center gap-2.5 border-b border-line px-4 py-3">
<span className="grid size-9 shrink-0 place-items-center rounded-full bg-primary-soft text-[11px] font-bold text-primary">
{active.initials}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-[14px] font-semibold text-ink">{active.name}</div>
<div className="truncate text-[11.5px] text-ink-3">{active.role}</div>
</div>
<Badge tone="good" size="sm">
<MessageSquare className="size-2.5" />
Active
</Badge>
</header>
<div className="scroll-slim min-h-0 flex-1 space-y-2.5 overflow-y-auto p-4">
{active.messages.map((m) => (
<div
key={m.id}
className={cn("flex", m.from === "me" ? "justify-end" : "justify-start")}
>
<div
className={cn(
"max-w-[76%] rounded-[14px] px-3 py-2 text-[13px] leading-snug",
m.from === "me"
? "rounded-br-[5px] bg-primary text-white"
: "rounded-bl-[5px] border border-line bg-surface-soft text-ink-2"
)}
>
<p>{m.text}</p>
<p
className={cn(
"mt-1 text-[10.5px]",
m.from === "me" ? "text-white/70" : "text-ink-3"
)}
>
{m.at}
</p>
</div>
</div>
))}
<div ref={endRef} />
</div>
<div className="shrink-0 border-t border-line p-3">
<div className="flex items-end gap-2 rounded-[14px] border border-line bg-surface-soft px-3 py-2 transition-colors focus-within:border-primary focus-within:bg-surface">
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
send()
}
}}
placeholder={`Message ${active.name.split(" ")[0]}…`}
aria-label="Message"
className="min-w-0 flex-1 bg-transparent text-[13px] text-ink placeholder:text-ink-3 focus:outline-none"
/>
<button
type="button"
onClick={send}
disabled={!draft.trim()}
aria-label="Send message"
className={cn(
"grid size-7 shrink-0 place-items-center rounded-[9px] transition-colors",
draft.trim()
? "bg-primary text-white hover:bg-[#1a45c0]"
: "bg-surface-sunk text-ink-3"
)}
>
<SendHorizonal className="size-3.5" />
</button>
</div>
</div>
</section>
</div>
</Page>
)
}

319
src/pages/OrdersPage.tsx Normal file
View File

@@ -0,0 +1,319 @@
import {
AlertTriangle,
Banknote,
ClipboardList,
FileStack,
Plus,
UserRoundPlus,
} from "lucide-react"
import { useMemo, useState } from "react"
import { useSearchParams } from "react-router-dom"
import { DataTable, type DemoColumnDef } from "@/components/shared/DataTable"
import { MetricCard } from "@/components/shared/MetricCard"
import { Page, PageIntro, SectionCard } from "@/components/shared/Page"
import { StatusBadge } from "@/components/shared/StatusBadge"
import { Toolbar } from "@/components/shared/Toolbar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { CLIENTS, ORDERS, ORDER_SUMMARY } from "@/data/mockData"
import {
money,
orderFillRate,
pipelineValue,
totalPlacesShort,
understaffedOrders,
} from "@/data/selectors"
import { useWorkforceRevision } from "@/data/store"
/* Quotes are shown as a tab on Orders rather than a separate product area. */
const QUOTES = [
{ id: "QTE-311", client: "Grand Hotel & Suites", venue: "Grand Terrace", staff: 6, value: 5400, status: "Sent", expires: "22 Sep 2025" },
{ id: "QTE-309", client: "Seaview Retail Park", venue: "Seaview Centre", staff: 4, value: 3120, status: "Draft", expires: "25 Sep 2025" },
{ id: "QTE-305", client: "Northgate Entertainment", venue: "Northgate Pavilion", staff: 10, value: 9800, status: "Accepted", expires: "—" },
{ id: "QTE-298", client: "Lantern Events UK", venue: "The Lantern Rooms", staff: 3, value: 2850, status: "Expired", expires: "01 Sep 2025" },
]
export function OrdersPage() {
useWorkforceRevision()
const [params, setParams] = useSearchParams()
const tab = params.get("tab") ?? "all"
const [query, setQuery] = useState("")
const [status, setStatus] = useState("all")
const [department, setDepartment] = useState("all")
const filtered = useMemo(
() =>
ORDERS.filter(
(o) =>
(status === "all" || o.status === status) &&
(department === "all" || o.department === department)
),
[status, department]
)
const columns = useMemo<DemoColumnDef<(typeof ORDERS)[number]>[]>(
() => [
{
accessorKey: "id",
header: "Order",
cell: (c) => (
<span className="font-semibold text-primary">{c.getValue() as string}</span>
),
},
{
accessorKey: "client",
header: "Client / Venue",
cell: (c) => (
<div className="min-w-0">
<div className="truncate font-medium text-ink">{c.row.original.client}</div>
<div className="truncate text-[12px] text-ink-3">{c.row.original.venue}</div>
</div>
),
},
{ accessorKey: "shift", header: "Shift" },
{
id: "staffing",
header: "Staffing",
cell: (c) => {
const { required, filled } = c.row.original
const gap = required - filled
return (
<div className="flex items-center gap-2">
<span className="font-semibold text-ink">
{filled}/{required}
</span>
{gap > 0 && (
<Badge tone="warn" size="sm">
<AlertTriangle className="size-2.5" />
{gap} short
</Badge>
)}
</div>
)
},
},
{
accessorKey: "value",
header: "Value",
cell: (c) => (
<span className="font-semibold text-ink">{money(c.getValue() as number)}</span>
),
},
{
accessorKey: "status",
header: "Status",
cell: (c) => <StatusBadge value={c.getValue() as string} />,
},
{ accessorKey: "leadContact", header: "Lead" },
],
[]
)
return (
<Page>
<PageIntro
title="Orders"
description="Every staffing order, its fill status and the revenue riding on it."
actions={
<Button onClick={() => setParams({ tab: "create" })}>
<Plus />
One-time Order
</Button>
}
/>
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
<MetricCard
label="Orders Today"
value={String(ORDER_SUMMARY.today)}
sub={`${ORDER_SUMMARY.inProgress} in progress`}
icon={ClipboardList}
badge="Live"
badgeTone="primary"
tone="primary"
/>
<MetricCard
label="Understaffed"
value={String(understaffedOrders().length)}
sub={`${totalPlacesShort()} places short today`}
icon={AlertTriangle}
badge={understaffedOrders().length ? "Action" : "Clear"}
badgeTone={understaffedOrders().length ? "warn" : "good"}
tone={understaffedOrders().length ? "warn" : "primary"}
/>
<MetricCard
label="Fill Rate"
value={`${orderFillRate()}%`}
sub="Across live orders"
icon={UserRoundPlus}
tone="primary"
progress={orderFillRate()}
/>
<MetricCard
label="Pipeline Value"
value={money(pipelineValue())}
sub="Open and confirmed orders"
icon={Banknote}
badge="Gross"
badgeTone="neutral"
tone="primary"
/>
</div>
<Tabs value={tab} onValueChange={(v) => setParams({ tab: v })}>
<TabsList>
<TabsTrigger value="all">
<ClipboardList className="size-3.5" />
All Orders
</TabsTrigger>
<TabsTrigger value="create">
<UserRoundPlus className="size-3.5" />
Create Order
</TabsTrigger>
<TabsTrigger value="quotes">
<FileStack className="size-3.5" />
Quotes
</TabsTrigger>
</TabsList>
{/* ------------------------------------------------- order directory */}
<TabsContent value="all">
<SectionCard title="Order Directory" icon={ClipboardList} bodyClassName="p-0">
<Toolbar
query={query}
onQuery={setQuery}
placeholder="Search orders, clients or venues…"
filters={[
{
label: "All statuses",
value: status,
onChange: setStatus,
options: ["all", "Understaffed", "In Progress", "Confirmed", "Draft", "Completed"],
},
{
label: "All departments",
value: department,
onChange: setDepartment,
options: [
"all",
"Event & Hospitality",
"Warehouse & Logistics",
"Facilities & Cleaning",
],
},
]}
/>
<DataTable columns={columns} data={filtered} globalFilter={query} pageSize={8} />
</SectionCard>
</TabsContent>
{/* ---------------------------------------------------- create order */}
<TabsContent value="create">
<SectionCard title="Create a One-time Order" icon={UserRoundPlus}>
<form
className="grid gap-4 md:grid-cols-2"
onSubmit={(e) => e.preventDefault()}
>
<label className="space-y-1.5">
<span className="text-[12.5px] font-semibold text-ink">Client</span>
<Select defaultValue={CLIENTS[0].name}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CLIENTS.map((c) => (
<SelectItem key={c.id} value={c.name}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
<label className="space-y-1.5">
<span className="text-[12.5px] font-semibold text-ink">Department</span>
<Select defaultValue="Event & Hospitality">
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{["Event & Hospitality", "Warehouse & Logistics", "Facilities & Cleaning"].map(
(d) => (
<SelectItem key={d} value={d}>
{d}
</SelectItem>
)
)}
</SelectContent>
</Select>
</label>
<label className="space-y-1.5">
<span className="text-[12.5px] font-semibold text-ink">Venue</span>
<Input defaultValue="Northgate Arena" />
</label>
<label className="space-y-1.5">
<span className="text-[12.5px] font-semibold text-ink">Date</span>
<Input defaultValue="16 Sep 2025" />
</label>
<label className="space-y-1.5">
<span className="text-[12.5px] font-semibold text-ink">Shift start</span>
<Input defaultValue="17:00" />
</label>
<label className="space-y-1.5">
<span className="text-[12.5px] font-semibold text-ink">Shift end</span>
<Input defaultValue="23:30" />
</label>
<label className="space-y-1.5">
<span className="text-[12.5px] font-semibold text-ink">Staff required</span>
<Input type="number" defaultValue={5} />
</label>
<label className="space-y-1.5">
<span className="text-[12.5px] font-semibold text-ink">Charge rate (per hour)</span>
<Input defaultValue="$24.50" />
</label>
<div className="flex gap-2 md:col-span-2">
<Button type="submit">Create order</Button>
<Button type="button" variant="outline" onClick={() => setParams({ tab: "all" })}>
Cancel
</Button>
</div>
<p className="text-[12px] text-ink-3 md:col-span-2">
Demo form — submitting does not persist anything.
</p>
</form>
</SectionCard>
</TabsContent>
{/* --------------------------------------------------------- quotes */}
<TabsContent value="quotes">
<SectionCard title="Quotes" icon={FileStack} bodyClassName="p-0">
<div className="divide-y divide-line-soft">
{QUOTES.map((q) => (
<div key={q.id} className="flex flex-wrap items-center gap-3 px-4 py-3">
<span className="w-[82px] shrink-0 font-semibold text-primary">{q.id}</span>
<div className="min-w-[160px] flex-1">
<div className="truncate text-[13px] font-medium text-ink">{q.client}</div>
<div className="truncate text-[12px] text-ink-3">{q.venue}</div>
</div>
<span className="text-[13px] text-ink-2">{q.staff} staff</span>
<span className="text-[13px] font-semibold text-ink">{money(q.value)}</span>
<span className="text-[12px] text-ink-3">Expires {q.expires}</span>
<StatusBadge value={q.status === "Accepted" ? "Confirmed" : q.status} />
</div>
))}
</div>
</SectionCard>
</TabsContent>
</Tabs>
</Page>
)
}

269
src/pages/PayrollPage.tsx Normal file
View File

@@ -0,0 +1,269 @@
import { AlertTriangle, Banknote, Clock, Download, TrendingUp } from "lucide-react"
import { useState } from "react"
import {
Bar,
BarChart,
CartesianGrid,
Cell,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts"
import { MetricCard } from "@/components/shared/MetricCard"
import { Page, PageIntro, SectionCard } from "@/components/shared/Page"
import { StatusBadge } from "@/components/shared/StatusBadge"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { PAYROLL_LINES, PAYROLL_RUNS, PAYROLL_SUMMARY } from "@/data/mockData"
import { averageHourlyCost, money, money2, num, payrollAnomalies } from "@/data/selectors"
export function PayrollPage() {
const [period, setPeriod] = useState(PAYROLL_RUNS[0].id)
const run = PAYROLL_RUNS.find((r) => r.id === period) ?? PAYROLL_RUNS[0]
const anomalies = payrollAnomalies()
const chartData = PAYROLL_SUMMARY.breakdown.map((b) => ({
name: b.group.split(" & ")[0],
cost: b.cost,
hours: b.hours,
}))
return (
<Page>
<PageIntro
title="Payroll"
description="Worked hours, staff cost and approval status for each pay period."
actions={
<>
<Button variant="outline">
<Download />
Export
</Button>
<Button>Approve run</Button>
</>
}
/>
<Tabs value={period} onValueChange={setPeriod}>
<TabsList className="flex w-full flex-wrap">
{PAYROLL_RUNS.map((r) => (
<TabsTrigger key={r.id} value={r.id}>
{r.period.split(" · ")[0]}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
<MetricCard
label="Payroll Total"
value={money(run.gross)}
sub={run.period.split(" · ")[1]}
icon={Banknote}
badge={run.status}
badgeTone={
run.status === "Paid" ? "good" : run.status === "Approved" ? "primary" : "warn"
}
tone="primary"
/>
<MetricCard
label="Worked Hours"
value={num(run.hours)}
sub={`${run.staffCount} staff paid`}
icon={Clock}
tone="primary"
/>
<MetricCard
label="Cost per Hour"
value={money2(run.gross / run.hours)}
sub="Blended labour rate"
icon={TrendingUp}
badge="Blended"
badgeTone="neutral"
tone="primary"
/>
<MetricCard
label="Anomalies"
value={String(anomalies.length)}
sub="Lines needing review"
icon={AlertTriangle}
badge={anomalies.length ? "Review" : "Clear"}
badgeTone={anomalies.length ? "warn" : "good"}
tone={anomalies.length ? "warn" : "primary"}
/>
</div>
<div className="grid gap-3 xl:grid-cols-3">
<SectionCard title="Cost by Department" icon={Banknote} className="xl:col-span-2">
<div className="h-[220px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 8, right: 8, bottom: 0, left: -6 }}>
<CartesianGrid stroke="#eef3fa" vertical={false} />
<XAxis
dataKey="name"
tick={{ fontSize: 11, fill: "#8b99b8" }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 11, fill: "#8b99b8" }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `${v / 1000}k`}
/>
<Tooltip
cursor={{ fill: "rgba(29,78,216,.06)" }}
contentStyle={{ borderRadius: 10, border: "1px solid #e4ebf6", fontSize: 12 }}
formatter={(v) => money(Number(v))}
/>
<Bar dataKey="cost" radius={[6, 6, 0, 0]} maxBarSize={56}>
{chartData.map((_, i) => (
<Cell key={i} fill={["#1d4ed8", "#4f7ee6", "#94b5f2"][i]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</SectionCard>
<SectionCard title="Period Breakdown" icon={Clock} bodyClassName="p-4">
<div className="space-y-3.5">
{PAYROLL_SUMMARY.breakdown.map((b) => {
const pct = Math.round((b.cost / PAYROLL_SUMMARY.total) * 100)
return (
<div key={b.group}>
<div className="mb-1.5 flex items-baseline justify-between gap-2">
<span className="min-w-0 truncate text-[13px] font-semibold text-ink">
{b.group}
</span>
<span className="shrink-0 text-[12px] text-ink-3">
{b.hours}h · {money(b.cost)}
</span>
</div>
<Progress value={pct} />
</div>
)
})}
<div className="space-y-1.5 border-t border-line-soft pt-3 text-[12.5px]">
<div className="flex justify-between">
<span className="text-ink-3">Overtime spend</span>
<span className="font-semibold text-ink">
{money(PAYROLL_SUMMARY.overtimeSpend)}
</span>
</div>
<div className="flex justify-between">
<span className="text-ink-3">Blended cost / hour</span>
<span className="font-semibold text-ink">{money2(averageHourlyCost())}</span>
</div>
</div>
</div>
</SectionCard>
</div>
{anomalies.length > 0 && (
<SectionCard title="Payroll Anomalies" icon={AlertTriangle} bodyClassName="p-3">
<div className="space-y-2">
{anomalies.map((a) => (
<div
key={a.staffId}
className="flex flex-wrap items-center gap-3 rounded-[10px] border border-warn-edge bg-warn-soft px-3 py-2.5"
>
<AlertTriangle className="size-4 shrink-0 text-warn" />
<div className="min-w-[160px] flex-1">
<div className="text-[13px] font-semibold text-ink">{a.name}</div>
<div className="text-[12px] text-ink-2">{a.anomaly}</div>
</div>
<span className="text-[12.5px] text-ink-2">{a.hours}h</span>
<span className="text-[13px] font-semibold text-ink">{money2(a.gross)}</span>
<Button size="sm" variant="outline">
Review
</Button>
</div>
))}
</div>
</SectionCard>
)}
<SectionCard title="Pay Lines" icon={Banknote} bodyClassName="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-surface-soft">
<TableHead>Staff</TableHead>
<TableHead>Role</TableHead>
<TableHead className="text-right">Hours</TableHead>
<TableHead className="text-right">Overtime</TableHead>
<TableHead className="text-right">Rate</TableHead>
<TableHead className="text-right">Gross</TableHead>
<TableHead>Flag</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{PAYROLL_LINES.map((l) => (
<TableRow key={l.staffId}>
<TableCell className="font-medium text-ink">{l.name}</TableCell>
<TableCell>{l.role}</TableCell>
<TableCell className="text-right">{l.hours}</TableCell>
<TableCell className="text-right">
{l.overtime > 0 ? `${l.overtime}h` : "—"}
</TableCell>
<TableCell className="text-right">${l.rate.toFixed(2)}</TableCell>
<TableCell className="text-right font-semibold text-ink">
{money2(l.gross)}
</TableCell>
<TableCell>
{l.anomaly ? (
<StatusBadge value="Review" size="sm" />
) : (
<StatusBadge value="Verified" size="sm" />
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SectionCard>
<SectionCard title="Pay Run History" icon={Clock} bodyClassName="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-surface-soft">
<TableHead>Period</TableHead>
<TableHead className="text-right">Staff</TableHead>
<TableHead className="text-right">Hours</TableHead>
<TableHead className="text-right">Gross</TableHead>
<TableHead>Status</TableHead>
<TableHead>Paid on</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{PAYROLL_RUNS.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium text-ink">{r.period}</TableCell>
<TableCell className="text-right">{r.staffCount}</TableCell>
<TableCell className="text-right">{num(r.hours)}</TableCell>
<TableCell className="text-right font-semibold text-ink">
{money(r.gross)}
</TableCell>
<TableCell>
<StatusBadge value={r.status} size="sm" />
</TableCell>
<TableCell className="text-ink-3">{r.paidOn}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SectionCard>
</Page>
)
}

View File

@@ -0,0 +1,241 @@
import { Activity, BarChart3, Star, TrendingUp, Users } from "lucide-react"
import { useState } from "react"
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts"
import { MetricCard } from "@/components/shared/MetricCard"
import { Page, PageIntro, SectionCard } from "@/components/shared/Page"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Progress } from "@/components/ui/progress"
import { DEPARTMENT_METRICS, STAFF, TRENDS } from "@/data/mockData"
import {
averageRating,
averageUtilisation,
money,
num,
orderFillRate,
} from "@/data/selectors"
import { useWorkforceRevision } from "@/data/store"
const RANGES = ["7 days", "30 days", "Quarter"] as const
const axis = { fontSize: 11, fill: "#8b99b8" }
const tooltipStyle = {
borderRadius: 10,
border: "1px solid #e4ebf6",
fontSize: 12,
boxShadow: "0 4px 14px rgba(17,28,68,.07)",
}
export function PerformancePage() {
useWorkforceRevision()
const [range, setRange] = useState<string>(RANGES[0])
const attendance = Math.round(
TRENDS.reduce((s, t) => s + t.attendance, 0) / TRENDS.length
)
const hours = TRENDS.reduce((s, t) => s + t.hours, 0)
const topPerformers = [...STAFF].sort((a, b) => b.rating - a.rating).slice(0, 5)
return (
<Page>
<PageIntro
title="Workforce Performance"
description="Utilisation, attendance and fill rate across every pool and department."
actions={
<Tabs value={range} onValueChange={setRange}>
<TabsList>
{RANGES.map((r) => (
<TabsTrigger key={r} value={r}>
{r}
</TabsTrigger>
))}
</TabsList>
</Tabs>
}
/>
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
<MetricCard
label="Utilisation"
value={`${averageUtilisation()}%`}
sub="Hours worked vs available"
icon={TrendingUp}
badge="+4.2%"
badgeTone="good"
tone="primary"
progress={averageUtilisation()}
/>
<MetricCard
label="Attendance"
value={`${attendance}%`}
sub="Clock-in accuracy this week"
icon={Activity}
badge="Stable"
badgeTone="good"
tone="primary"
progress={attendance}
/>
<MetricCard
label="Fill Rate"
value={`${orderFillRate()}%`}
sub="Required places filled"
icon={Users}
badge={orderFillRate() < 90 ? "Watch" : "Good"}
badgeTone={orderFillRate() < 90 ? "warn" : "good"}
tone={orderFillRate() < 90 ? "warn" : "primary"}
progress={orderFillRate()}
/>
<MetricCard
label="Avg Rating"
value={averageRating()}
sub={`${num(hours)} hours delivered`}
icon={Star}
badge="of 5.0"
badgeTone="neutral"
tone="primary"
/>
</div>
<div className="grid gap-3 xl:grid-cols-2">
<SectionCard title="Utilisation & Attendance Trend" icon={TrendingUp}>
<div className="h-[240px] w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={TRENDS} margin={{ top: 8, right: 8, bottom: 0, left: -18 }}>
<CartesianGrid stroke="#eef3fa" vertical={false} />
<XAxis dataKey="label" tick={axis} axisLine={false} tickLine={false} />
<YAxis tick={axis} axisLine={false} tickLine={false} domain={[70, 100]} />
<Tooltip contentStyle={tooltipStyle} formatter={(v) => `${v}%`} />
<Legend wrapperStyle={{ fontSize: 12 }} iconType="circle" iconSize={7} />
<Line
name="Utilisation"
type="monotone"
dataKey="utilisation"
stroke="#1d4ed8"
strokeWidth={2}
dot={{ r: 3, fill: "#1d4ed8" }}
/>
<Line
name="Attendance"
type="monotone"
dataKey="attendance"
stroke="#22a957"
strokeWidth={2}
dot={{ r: 3, fill: "#22a957" }}
/>
<Line
name="Fill rate"
type="monotone"
dataKey="fillRate"
stroke="#f0b429"
strokeWidth={2}
dot={{ r: 3, fill: "#f0b429" }}
/>
</LineChart>
</ResponsiveContainer>
</div>
</SectionCard>
<SectionCard title="Staffing Hours Delivered" icon={BarChart3}>
<div className="h-[240px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={TRENDS} margin={{ top: 8, right: 8, bottom: 0, left: -12 }}>
<defs>
<linearGradient id="hoursFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#1d4ed8" stopOpacity={0.28} />
<stop offset="100%" stopColor="#1d4ed8" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid stroke="#eef3fa" vertical={false} />
<XAxis dataKey="label" tick={axis} axisLine={false} tickLine={false} />
<YAxis tick={axis} axisLine={false} tickLine={false} />
<Tooltip contentStyle={tooltipStyle} formatter={(v) => `${v} h`} />
<Area
type="monotone"
dataKey="hours"
stroke="#1d4ed8"
strokeWidth={2}
fill="url(#hoursFill)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</SectionCard>
</div>
<div className="grid gap-3 xl:grid-cols-3">
<SectionCard title="Revenue vs Cost by Department" icon={BarChart3} className="xl:col-span-2">
<div className="h-[220px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={DEPARTMENT_METRICS.map((d) => ({ ...d, name: d.name.split(" & ")[0] }))}
margin={{ top: 8, right: 8, bottom: 0, left: -6 }}
>
<CartesianGrid stroke="#eef3fa" vertical={false} />
<XAxis dataKey="name" tick={axis} axisLine={false} tickLine={false} />
<YAxis
tick={axis}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `${v / 1000}k`}
/>
<Tooltip contentStyle={tooltipStyle} formatter={(v) => money(Number(v))} />
<Legend wrapperStyle={{ fontSize: 12 }} iconType="circle" iconSize={7} />
<Bar name="Revenue" dataKey="revenue" fill="#1d4ed8" radius={[5, 5, 0, 0]} maxBarSize={44} />
<Bar name="Cost" dataKey="cost" fill="#c5d8fb" radius={[5, 5, 0, 0]} maxBarSize={44} />
</BarChart>
</ResponsiveContainer>
</div>
</SectionCard>
<SectionCard title="Top Performers" icon={Star} bodyClassName="p-3">
<div className="space-y-2">
{topPerformers.map((s) => (
<div key={s.id} className="flex items-center gap-2.5">
<span className="grid size-8 shrink-0 place-items-center rounded-full bg-primary-soft text-[11px] font-bold text-primary">
{s.initials}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-semibold text-ink">{s.name}</div>
<div className="truncate text-[11.5px] text-ink-3">{s.role}</div>
</div>
<span className="shrink-0 text-[13px] font-bold text-ink">{s.rating}</span>
</div>
))}
</div>
</SectionCard>
</div>
<SectionCard title="Department Utilisation" icon={Users} bodyClassName="p-4">
<div className="space-y-3.5">
{DEPARTMENT_METRICS.map((d) => {
const util = Math.round((d.hours / 2200) * 100)
return (
<div key={d.name}>
<div className="mb-1.5 flex flex-wrap items-baseline justify-between gap-2">
<span className="text-[13px] font-semibold text-ink">{d.name}</span>
<span className="text-[12px] text-ink-3">
{d.headcount} staff · {num(d.hours)} h · {money(d.revenue)} · {d.margin}% margin
</span>
</div>
<Progress value={util} />
</div>
)
})}
</div>
</SectionCard>
</Page>
)
}

237
src/pages/ReportsPage.tsx Normal file
View File

@@ -0,0 +1,237 @@
import {
Activity,
BarChart3,
Banknote,
Download,
FileSpreadsheet,
TrendingUp,
Users,
} from "lucide-react"
import { useSearchParams } from "react-router-dom"
import { MetricCard } from "@/components/shared/MetricCard"
import { Page, PageIntro, SectionCard } from "@/components/shared/Page"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { DEPARTMENT_METRICS, REPORTS, REPORTS_SUMMARY } from "@/data/mockData"
import { money, num } from "@/data/selectors"
import type { ReportCard as Report } from "@/data/types"
const AUDIT = [
{ at: "Today 14:32", actor: "Alex Rivera", action: "Resolved understaffed shift", target: "ORD-4830" },
{ at: "Today 13:58", actor: "Sarah Jenkins", action: "Updated order requirement", target: "ORD-4821" },
{ at: "Today 12:30", actor: "Finance Lead", action: "Generated payroll report", target: "PR-38" },
{ at: "Today 11:24", actor: "HR & Compliance", action: "Flagged certificate expiry", target: "STF-001" },
{ at: "Today 09:15", actor: "System", action: "Compliance register refreshed", target: "RPT-05" },
{ at: "Today 08:00", actor: "System", action: "Invoice ageing recalculated", target: "RPT-08" },
{ at: "Yesterday 20:00", actor: "Alex Rivera", action: "Exported client margin report", target: "RPT-07" },
{ at: "Yesterday 18:00", actor: "System", action: "Utilisation report generated", target: "RPT-04" },
]
const CATEGORY_ICON = {
Operational: BarChart3,
Workforce: Users,
Financial: Banknote,
} as const
function ReportTile({ report }: { report: Report }) {
const Icon = CATEGORY_ICON[report.category]
return (
<article className="flex flex-col rounded-[14px] border border-line bg-surface p-4 shadow-xs transition-[transform,box-shadow,border-color,background-color] hover:-translate-y-px hover:border-primary-edge hover:shadow-sm">
<div className="flex items-start justify-between gap-2">
<span className="grid size-8 shrink-0 place-items-center rounded-[9px] bg-primary-soft text-primary">
<Icon className="size-4" />
</span>
<Badge tone="neutral" size="sm">
{report.category}
</Badge>
</div>
<h3 className="mt-2.5 text-[14px] font-semibold leading-snug text-ink">
{report.title}
</h3>
<p className="mt-1 flex-1 text-[12.5px] leading-snug text-ink-2">
{report.description}
</p>
<div className="mt-3 flex items-center justify-between gap-2 border-t border-line-soft pt-2.5">
<span className="truncate text-[11.5px] text-ink-3">
{report.updated} · {report.format}
</span>
<Button size="sm" variant="ghost">
<Download />
Export
</Button>
</div>
</article>
)
}
export function ReportsPage() {
const [params, setParams] = useSearchParams()
const tab = params.get("tab") ?? "library"
const byCategory = (c: Report["category"]) =>
REPORTS.filter((r) => r.category === c)
return (
<Page>
<PageIntro
title="Reports"
description="Operational, workforce and financial reporting across the whole product."
actions={
<Button variant="outline">
<Download />
Export all
</Button>
}
/>
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
<MetricCard
label="Monthly Revenue"
value={money(REPORTS_SUMMARY.monthlyRevenue)}
sub="Billable across all clients"
icon={Banknote}
badge="+6.4%"
badgeTone="good"
tone="primary"
/>
<MetricCard
label="Gross Profit"
value={money(REPORTS_SUMMARY.grossProfit)}
sub={`${REPORTS_SUMMARY.marginPct}% margin`}
icon={TrendingUp}
badge="Healthy"
badgeTone="good"
tone="primary"
progress={REPORTS_SUMMARY.marginPct}
/>
<MetricCard
label="Billable Utilisation"
value={`${REPORTS_SUMMARY.billableUtilisation}%`}
sub="Hours billed vs worked"
icon={Activity}
tone="primary"
progress={REPORTS_SUMMARY.billableUtilisation}
/>
<MetricCard
label="Overtime Spend"
value={money(REPORTS_SUMMARY.overtimeSpend)}
sub="This month"
icon={FileSpreadsheet}
badge="Watch"
badgeTone="warn"
tone="warn"
/>
</div>
<Tabs value={tab} onValueChange={(v) => setParams({ tab: v })}>
<TabsList>
<TabsTrigger value="library">
<BarChart3 className="size-3.5" />
Report Library
</TabsTrigger>
<TabsTrigger value="audit">
<Activity className="size-3.5" />
Audit Trail
</TabsTrigger>
</TabsList>
<TabsContent value="library" className="space-y-4">
{(["Operational", "Workforce", "Financial"] as const).map((category) => (
<div key={category}>
<h3 className="mb-2.5 text-[11px] font-bold uppercase tracking-[0.12em] text-ink-3">
{category} Reports
</h3>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{byCategory(category).map((r) => (
<ReportTile key={r.id} report={r} />
))}
</div>
</div>
))}
<SectionCard title="Department Summary" icon={BarChart3} bodyClassName="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-surface-soft">
<TableHead>Department</TableHead>
<TableHead className="text-right">Headcount</TableHead>
<TableHead className="text-right">Hours</TableHead>
<TableHead className="text-right">Revenue</TableHead>
<TableHead className="text-right">Cost</TableHead>
<TableHead className="text-right">Margin</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{DEPARTMENT_METRICS.map((d) => (
<TableRow key={d.name}>
<TableCell className="font-medium text-ink">{d.name}</TableCell>
<TableCell className="text-right">{d.headcount}</TableCell>
<TableCell className="text-right">{num(d.hours)}</TableCell>
<TableCell className="text-right">{money(d.revenue)}</TableCell>
<TableCell className="text-right">{money(d.cost)}</TableCell>
<TableCell className="text-right font-semibold text-ink">
{d.margin}%
</TableCell>
</TableRow>
))}
<TableRow className="bg-surface-soft">
<TableCell className="font-bold text-ink">Total</TableCell>
<TableCell className="text-right font-bold text-ink">
{DEPARTMENT_METRICS.reduce((s, d) => s + d.headcount, 0)}
</TableCell>
<TableCell className="text-right font-bold text-ink">
{num(DEPARTMENT_METRICS.reduce((s, d) => s + d.hours, 0))}
</TableCell>
<TableCell className="text-right font-bold text-ink">
{money(DEPARTMENT_METRICS.reduce((s, d) => s + d.revenue, 0))}
</TableCell>
<TableCell className="text-right font-bold text-ink">
{money(DEPARTMENT_METRICS.reduce((s, d) => s + d.cost, 0))}
</TableCell>
<TableCell className="text-right font-bold text-ink">
{REPORTS_SUMMARY.marginPct}%
</TableCell>
</TableRow>
</TableBody>
</Table>
</SectionCard>
</TabsContent>
<TabsContent value="audit">
<SectionCard title="Audit Trail" icon={Activity} bodyClassName="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-surface-soft">
<TableHead>When</TableHead>
<TableHead>Actor</TableHead>
<TableHead>Action</TableHead>
<TableHead>Target</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{AUDIT.map((a, i) => (
<TableRow key={i}>
<TableCell className="whitespace-nowrap text-ink-3">{a.at}</TableCell>
<TableCell className="font-medium text-ink">{a.actor}</TableCell>
<TableCell>{a.action}</TableCell>
<TableCell className="font-medium text-primary">{a.target}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SectionCard>
</TabsContent>
</Tabs>
</Page>
)
}

470
src/pages/SettingsPage.tsx Normal file
View File

@@ -0,0 +1,470 @@
import {
Bell,
BriefcaseBusiness,
Building2,
CreditCard,
LifeBuoy,
Palette,
SlidersHorizontal,
Store,
Users,
} from "lucide-react"
import { useState } from "react"
import { useSearchParams } from "react-router-dom"
import { Page, PageIntro, SectionCard } from "@/components/shared/Page"
import { StatusBadge } from "@/components/shared/StatusBadge"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { ORG, STAFF, TEAMS, USER } from "@/data/mockData"
import { cn } from "@/lib/utils"
const NOTIFICATIONS = [
{ id: "understaffed", label: "Understaffed order alerts", note: "When an order drops below required headcount", on: true },
{ id: "starting", label: "Shift starting soon", note: "Two hours before an unfilled shift begins", on: true },
{ id: "compliance", label: "Compliance expiry warnings", note: "Fourteen days before a certificate lapses", on: true },
{ id: "payroll", label: "Payroll anomalies", note: "Overtime and under-contract hour flags", on: true },
{ id: "invoice", label: "Invoice overdue reminders", note: "When a client invoice passes its due date", on: false },
{ id: "digest", label: "Weekly performance digest", note: "Monday morning summary of the prior week", on: false },
]
const MEMBERS = [
{ name: "Alex Rivera", email: "alex.rivera@legendarystaffing.com", role: "Operations Lead", access: "Admin" },
{ name: "Sarah Jenkins", email: "s.jenkins@legendarystaffing.com", role: "Client Lead", access: "Manager" },
{ name: "Marcus Vance", email: "m.vance@legendarystaffing.com", role: "Shift Supervisor", access: "Manager" },
{ name: "Finance Lead", email: "finance@legendarystaffing.com", role: "Finance", access: "Billing" },
{ name: "HR & Compliance", email: "hr@legendarystaffing.com", role: "Compliance", access: "Manager" },
]
const HUBS = [
{ name: "Northgate Hub", region: "North", venues: 2, staff: 18 },
{ name: "Harbour Hub", region: "Central", venues: 1, staff: 14 },
{ name: "Castleford Hub", region: "East", venues: 1, staff: 14 },
]
const INTEGRATIONS = [
{ name: "Xero", category: "Accounting", status: "Connected" },
{ name: "Slack", category: "Communication", status: "Connected" },
{ name: "Google Calendar", category: "Scheduling", status: "Connected" },
{ name: "BambooHR", category: "People", status: "Not connected" },
{ name: "Stripe", category: "Payments", status: "Not connected" },
]
const TABS = [
{ value: "general", label: "General", icon: SlidersHorizontal },
{ value: "members", label: "Members", icon: Users },
{ value: "hubs", label: "Hubs", icon: Building2 },
{ value: "notifications", label: "Notifications", icon: Bell },
{ value: "billing", label: "Billing", icon: CreditCard },
{ value: "integrations", label: "Integrations", icon: Store },
{ value: "workforce", label: "Workforce", icon: BriefcaseBusiness },
{ value: "support", label: "Support", icon: LifeBuoy },
]
function Field({
label,
children,
hint,
}: {
label: string
children: React.ReactNode
hint?: string
}) {
return (
<label className="space-y-1.5">
<span className="block text-[12.5px] font-semibold text-ink">{label}</span>
{children}
{hint && <span className="block text-[11.5px] text-ink-3">{hint}</span>}
</label>
)
}
function ToggleRow({
label,
note,
defaultOn,
}: {
label: string
note: string
defaultOn: boolean
}) {
const [on, setOn] = useState(defaultOn)
return (
<div className="flex items-start justify-between gap-4 rounded-[10px] border border-line-soft bg-surface-soft px-3 py-2.5">
<div className="min-w-0">
<div className="text-[13px] font-semibold text-ink">{label}</div>
<div className="text-[12px] text-ink-3">{note}</div>
</div>
<Switch checked={on} onCheckedChange={setOn} aria-label={label} />
</div>
)
}
export function SettingsPage() {
const [params, setParams] = useSearchParams()
const tab = params.get("tab") ?? "general"
const [accent, setAccent] = useState("KROW Blue")
return (
<Page>
<PageIntro
title="Settings"
description={`Workspace configuration for ${ORG}.`}
actions={<Button>Save changes</Button>}
/>
<Tabs value={tab} onValueChange={(v) => setParams({ tab: v })}>
<TabsList className="max-w-full overflow-x-auto scroll-slim">
{TABS.map((t) => (
<TabsTrigger key={t.value} value={t.value}>
<t.icon className="size-3.5" />
{t.label}
</TabsTrigger>
))}
</TabsList>
{/* -------------------------------------------------------- general */}
<TabsContent value="general" className="space-y-3">
<SectionCard title="Workspace" icon={SlidersHorizontal}>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Organisation name">
<Input defaultValue={ORG} />
</Field>
<Field label="Default team">
<Select defaultValue={TEAMS[0].name}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TEAMS.map((t) => (
<SelectItem key={t.id} value={t.name}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="Time zone">
<Select defaultValue="Europe/London">
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{["Europe/London", "Europe/Dublin", "America/New_York"].map((z) => (
<SelectItem key={z} value={z}>
{z}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="Week starts on">
<Select defaultValue="Monday">
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{["Monday", "Sunday"].map((d) => (
<SelectItem key={d} value={d}>
{d}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
</SectionCard>
<SectionCard title="Account" icon={Users}>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Full name">
<Input defaultValue={USER.fullName} />
</Field>
<Field label="Email">
<Input defaultValue={USER.email} />
</Field>
<Field label="Role">
<Input defaultValue={USER.role} readOnly className="bg-surface-sunk" />
</Field>
<Field label="Two-factor authentication" hint="Demo only — nothing is stored.">
<div className="flex h-9 items-center gap-2">
<Switch defaultChecked aria-label="Two-factor authentication" />
<span className="text-[12.5px] text-ink-2">Enabled</span>
</div>
</Field>
</div>
</SectionCard>
<SectionCard title="Appearance" icon={Palette}>
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
{["KROW Blue", "Deep Navy", "Slate"].map((a) => (
<button
key={a}
type="button"
onClick={() => setAccent(a)}
className={cn(
"flex items-center gap-2 rounded-[10px] border px-3 py-2 text-[12.5px] font-medium transition-colors",
accent === a
? "border-primary bg-primary-soft text-primary"
: "border-line text-ink-2 hover:bg-surface-sunk"
)}
>
<span
className="size-3.5 rounded-full"
style={{
background:
a === "KROW Blue" ? "#1d4ed8" : a === "Deep Navy" ? "#111c44" : "#64748b",
}}
/>
{a}
</button>
))}
</div>
<ToggleRow
label="Compact density"
note="Reduce padding across tables and cards"
defaultOn={false}
/>
<ToggleRow
label="Keep Owliver open"
note="Show the intelligence panel by default on desktop"
defaultOn
/>
</div>
</SectionCard>
</TabsContent>
{/* -------------------------------------------------------- members */}
<TabsContent value="members">
<SectionCard
title="Team Members"
icon={Users}
bodyClassName="p-0"
action={<Button size="sm" variant="outline">Invite member</Button>}
>
<Table>
<TableHeader>
<TableRow className="hover:bg-surface-soft">
<TableHead>Member</TableHead>
<TableHead>Role</TableHead>
<TableHead>Access</TableHead>
<TableHead className="text-right">Manage</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{MEMBERS.map((m) => (
<TableRow key={m.email}>
<TableCell>
<div className="min-w-0">
<div className="truncate font-semibold text-ink">{m.name}</div>
<div className="truncate text-[12px] text-ink-3">{m.email}</div>
</div>
</TableCell>
<TableCell>{m.role}</TableCell>
<TableCell>
<Badge tone={m.access === "Admin" ? "primary" : "neutral"} size="sm">
{m.access}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button size="sm" variant="ghost">
Edit
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SectionCard>
</TabsContent>
{/* ----------------------------------------------------------- hubs */}
<TabsContent value="hubs">
<div className="grid gap-3 md:grid-cols-3">
{HUBS.map((h) => (
<SectionCard key={h.name} title={h.name} icon={Building2}>
<dl className="space-y-2 text-[13px]">
<div className="flex justify-between">
<dt className="text-ink-3">Region</dt>
<dd className="font-semibold text-ink">{h.region}</dd>
</div>
<div className="flex justify-between">
<dt className="text-ink-3">Venues</dt>
<dd className="font-semibold text-ink">{h.venues}</dd>
</div>
<div className="flex justify-between">
<dt className="text-ink-3">Staff assigned</dt>
<dd className="font-semibold text-ink">{h.staff}</dd>
</div>
</dl>
</SectionCard>
))}
</div>
</TabsContent>
{/* -------------------------------------------------- notifications */}
<TabsContent value="notifications">
<SectionCard title="Notification Preferences" icon={Bell}>
<div className="space-y-2">
{NOTIFICATIONS.map((n) => (
<ToggleRow key={n.id} label={n.label} note={n.note} defaultOn={n.on} />
))}
</div>
</SectionCard>
</TabsContent>
{/* -------------------------------------------------------- billing */}
<TabsContent value="billing" className="space-y-3">
<SectionCard title="Plan" icon={CreditCard}>
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<span className="text-[18px] font-bold text-ink">Workforce Pro</span>
<Badge tone="good" size="sm">
Active
</Badge>
</div>
<p className="mt-1 text-[12.5px] text-ink-2">
{STAFF.length} tracked staff · billed monthly · renews 01 Oct 2025
</p>
</div>
<div className="text-right">
<div className="text-[22px] font-bold leading-none text-ink">$489</div>
<div className="text-[12px] text-ink-3">per month</div>
</div>
</div>
</SectionCard>
<SectionCard title="Payment Method" icon={CreditCard}>
<div className="flex flex-wrap items-center gap-3">
<span className="grid h-9 w-14 place-items-center rounded-[8px] border border-line bg-surface-sunk text-[11px] font-bold text-ink-2">
VISA
</span>
<div className="min-w-0 flex-1">
<div className="text-[13px] font-semibold text-ink">•••• •••• •••• 4821</div>
<div className="text-[12px] text-ink-3">Expires 09 / 2027</div>
</div>
<Button size="sm" variant="outline">
Update
</Button>
</div>
</SectionCard>
</TabsContent>
{/* --------------------------------------------------- integrations */}
<TabsContent value="integrations">
<SectionCard title="Integrations" icon={Store} bodyClassName="p-0">
<div className="divide-y divide-line-soft">
{INTEGRATIONS.map((i) => (
<div key={i.name} className="flex flex-wrap items-center gap-3 px-4 py-3">
<span className="grid size-9 shrink-0 place-items-center rounded-[9px] bg-surface-sunk text-[11px] font-bold text-ink-2">
{i.name.slice(0, 2).toUpperCase()}
</span>
<div className="min-w-[140px] flex-1">
<div className="text-[13px] font-semibold text-ink">{i.name}</div>
<div className="text-[12px] text-ink-3">{i.category}</div>
</div>
<StatusBadge
value={i.status === "Connected" ? "Verified" : "Closed"}
size="sm"
/>
<Button size="sm" variant={i.status === "Connected" ? "ghost" : "outline"}>
{i.status === "Connected" ? "Manage" : "Connect"}
</Button>
</div>
))}
</div>
</SectionCard>
</TabsContent>
{/* ------------------------------------------------------ workforce */}
<TabsContent value="workforce">
<SectionCard title="Workforce Rules" icon={BriefcaseBusiness}>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Maximum weekly hours" hint="Triggers a payroll anomaly when exceeded.">
<Input defaultValue="40" />
</Field>
<Field label="Minimum contracted hours" hint="Flags under-utilised staff.">
<Input defaultValue="16" />
</Field>
<Field label="Overtime multiplier">
<Input defaultValue="1.5×" />
</Field>
<Field label="Compliance warning window">
<Select defaultValue="14 days">
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{["7 days", "14 days", "30 days"].map((d) => (
<SelectItem key={d} value={d}>
{d}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
<div className="mt-4 space-y-2">
<ToggleRow
label="Block assignment on expired compliance"
note="Prevent rostering staff with a lapsed certificate"
defaultOn
/>
<ToggleRow
label="Auto-fill from standby pool"
note="Let Owliver propose replacements for unfilled places"
defaultOn
/>
</div>
</SectionCard>
</TabsContent>
{/* -------------------------------------------------------- support */}
<TabsContent value="support">
<SectionCard title="Support" icon={LifeBuoy}>
<div className="space-y-3">
<p className="text-[13px] text-ink-2">
This is a demo prototype. Nothing here connects to a backend, an API or a
database, and no data leaves the browser.
</p>
<div className="grid gap-3 md:grid-cols-3">
{[
{ label: "Documentation", note: "Product guides and release notes" },
{ label: "Contact support", note: "Weekdays, 08:00 – 18:00" },
{ label: "Service status", note: "All systems operational" },
].map((c) => (
<div
key={c.label}
className="rounded-[10px] border border-line-soft bg-surface-soft px-3 py-2.5"
>
<div className="text-[13px] font-semibold text-ink">{c.label}</div>
<div className="text-[12px] text-ink-3">{c.note}</div>
</div>
))}
</div>
</div>
</SectionCard>
</TabsContent>
</Tabs>
</Page>
)
}

245
src/pages/StaffPage.tsx Normal file
View File

@@ -0,0 +1,245 @@
import { BriefcaseBusiness, ShieldCheck, Star, UserCheck, Users } from "lucide-react"
import { useMemo, useState } from "react"
import { useSearchParams } from "react-router-dom"
import { DataTable, type DemoColumnDef } from "@/components/shared/DataTable"
import { MetricCard } from "@/components/shared/MetricCard"
import { Page, PageIntro, SectionCard } from "@/components/shared/Page"
import { StatusBadge, StatusDot } from "@/components/shared/StatusBadge"
import { Toolbar } from "@/components/shared/Toolbar"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { STAFF, STAFF_COUNTS, TEAMS } from "@/data/mockData"
import {
averageRating,
averageUtilisation,
staffCoverage,
staffWithComplianceIssues,
} from "@/data/selectors"
import { useWorkforceRevision } from "@/data/store"
import type { StaffMember } from "@/data/types"
export function StaffPage() {
useWorkforceRevision()
const [params, setParams] = useSearchParams()
const tab = params.get("tab") ?? "staff"
const [query, setQuery] = useState("")
const [status, setStatus] = useState("all")
const [department, setDepartment] = useState("all")
const filtered = useMemo(
() =>
STAFF.filter(
(s) =>
(status === "all" || s.status === status) &&
(department === "all" || s.department === department)
),
[status, department]
)
const columns = useMemo<DemoColumnDef<StaffMember>[]>(
() => [
{
accessorKey: "name",
header: "Name",
cell: (c) => (
<div className="flex min-w-0 items-center gap-2.5">
<span className="grid size-8 shrink-0 place-items-center rounded-full bg-primary-soft text-[10.5px] font-bold text-primary">
{c.row.original.initials}
</span>
<div className="min-w-0">
<div className="truncate font-semibold text-ink">{c.row.original.name}</div>
<div className="truncate text-[12px] text-ink-3">{c.row.original.email}</div>
</div>
</div>
),
},
{
accessorKey: "role",
header: "Role",
cell: (c) => (
<div className="min-w-0">
<div className="truncate text-ink-2">{c.row.original.role}</div>
<div className="truncate text-[12px] text-ink-3">{c.row.original.department}</div>
</div>
),
},
{
accessorKey: "status",
header: "Status",
cell: (c) => (
<span className="flex items-center gap-2">
<StatusDot value={c.getValue() as string} />
<StatusBadge value={c.getValue() as string} size="sm" />
</span>
),
},
{
accessorKey: "utilisation",
header: "Utilisation",
cell: (c) => (
<div className="w-[92px]">
<div className="mb-1 text-[12px] font-semibold text-ink">
{c.getValue() as number}%
</div>
<Progress value={c.getValue() as number} className="h-1" />
</div>
),
},
{
accessorKey: "compliance",
header: "Compliance",
cell: (c) => (
<div className="min-w-0">
<StatusBadge value={c.getValue() as string} size="sm" />
{c.row.original.complianceNote && (
<div className="mt-0.5 truncate text-[11.5px] text-ink-3">
{c.row.original.complianceNote}
</div>
)}
</div>
),
},
{
accessorKey: "nextShift",
header: "Next shift",
cell: (c) => (
<span className="whitespace-nowrap text-ink-2">{c.getValue() as string}</span>
),
},
],
[]
)
return (
<Page>
<PageIntro
title="Staff Directory"
description="The full roster with availability, utilisation and compliance standing."
actions={<Button variant="outline">Invite staff</Button>}
/>
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
<MetricCard
label="Total Staff"
value={String(STAFF_COUNTS.total)}
sub={`${TEAMS.length} staffing pools`}
icon={Users}
tone="primary"
/>
<MetricCard
label="Active Today"
value={String(STAFF_COUNTS.active)}
sub={`${staffCoverage()}% coverage`}
icon={UserCheck}
badge={`${staffCoverage()}%`}
badgeTone="good"
tone="primary"
progress={staffCoverage()}
/>
<MetricCard
label="Avg Utilisation"
value={`${averageUtilisation()}%`}
sub="Hours vs availability"
icon={Star}
badge={`${averageRating()} rating`}
badgeTone="neutral"
tone="primary"
progress={averageUtilisation()}
/>
<MetricCard
label="Compliance Issues"
value={String(staffWithComplianceIssues().length)}
sub="Staff needing review"
icon={ShieldCheck}
badge="Review"
badgeTone="warn"
tone={staffWithComplianceIssues().length ? "warn" : "primary"}
/>
</div>
<Tabs value={tab} onValueChange={(v) => setParams({ tab: v })}>
<TabsList>
<TabsTrigger value="staff">
<Users className="size-3.5" />
Staff
</TabsTrigger>
<TabsTrigger value="teams">
<BriefcaseBusiness className="size-3.5" />
Teams
</TabsTrigger>
</TabsList>
<TabsContent value="staff">
<SectionCard title="Staff" icon={Users} bodyClassName="p-0">
<Toolbar
query={query}
onQuery={setQuery}
placeholder="Search by name, role or location…"
filters={[
{
label: "All statuses",
value: status,
onChange: setStatus,
options: ["all", "Active", "On Break", "Unavailable"],
},
{
label: "All departments",
value: department,
onChange: setDepartment,
options: [
"all",
"Event & Hospitality",
"Warehouse & Logistics",
"Facilities & Cleaning",
],
},
]}
/>
<DataTable columns={columns} data={filtered} globalFilter={query} pageSize={8} />
</SectionCard>
</TabsContent>
<TabsContent value="teams">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{TEAMS.map((team) => {
const members = STAFF.filter((s) => s.team === team.name)
const active = members.filter((s) => s.status === "Active").length
return (
<SectionCard key={team.id} title={team.name} icon={BriefcaseBusiness}>
<div className="mb-3 flex items-baseline gap-2">
<span className="text-[24px] font-bold leading-none text-ink">
{team.members}
</span>
<span className="text-[12.5px] text-ink-3">members</span>
</div>
<Progress
value={members.length ? (active / members.length) * 100 : 0}
className="mb-3"
/>
<div className="space-y-1.5">
{members.slice(0, 5).map((m) => (
<div key={m.id} className="flex items-center gap-2">
<StatusDot value={m.status} />
<span className="min-w-0 flex-1 truncate text-[12.5px] text-ink-2">
{m.name}
</span>
<span className="shrink-0 text-[11.5px] text-ink-3">{m.role}</span>
</div>
))}
{members.length === 0 && (
<p className="text-[12.5px] text-ink-3">
No tracked members in this pool.
</p>
)}
</div>
</SectionCard>
)
})}
</div>
</TabsContent>
</Tabs>
</Page>
)
}

249
src/pages/TasksPage.tsx Normal file
View File

@@ -0,0 +1,249 @@
import {
CheckSquare,
CircleDot,
GripVertical,
ListTodo,
MoreHorizontal,
OctagonAlert,
} from "lucide-react"
import { useEffect, useState } from "react"
import { MetricCard } from "@/components/shared/MetricCard"
import { Page, PageIntro } from "@/components/shared/Page"
import { StatusBadge } from "@/components/shared/StatusBadge"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { TASKS } from "@/data/mockData"
import { useWorkforceRevision } from "@/data/store"
import type { Task, TaskStatus } from "@/data/types"
import { cn } from "@/lib/utils"
const COLUMNS: { status: TaskStatus; label: string; accent: string }[] = [
{ status: "To Do", label: "To Do", accent: "bg-ink-3" },
{ status: "In Progress", label: "In Progress", accent: "bg-primary" },
{ status: "Blocked", label: "Blocked", accent: "bg-bad-dot" },
{ status: "Completed", label: "Completed", accent: "bg-good-dot" },
]
function TaskCard({
task,
onDragStart,
onDragEnd,
onMove,
dragging,
}: {
task: Task
onDragStart: (id: string) => void
onDragEnd: () => void
onMove: (id: string, status: TaskStatus) => void
dragging: boolean
}) {
return (
<article
draggable
onDragStart={(e) => {
// Required for the drag to register in Firefox and to set a drag image.
e.dataTransfer.setData("text/plain", task.id)
e.dataTransfer.effectAllowed = "move"
onDragStart(task.id)
}}
onDragEnd={onDragEnd}
className={cn(
"group cursor-grab rounded-[12px] border border-line bg-surface p-3 shadow-xs transition-[transform,box-shadow,border-color,background-color] active:cursor-grabbing",
"hover:-translate-y-px hover:border-primary-edge hover:shadow-sm",
dragging && "opacity-40"
)}
>
<div className="flex items-start gap-1.5">
<GripVertical className="mt-0.5 size-3.5 shrink-0 text-ink-3 opacity-0 transition-opacity group-hover:opacity-100" />
<div className="min-w-0 flex-1">
<p className="text-[12.5px] font-semibold leading-snug text-ink">
{task.title}
</p>
<p className="mt-1 truncate text-[11px] text-ink-3">{task.orderRef}</p>
</div>
</div>
<div className="mt-2 flex flex-wrap items-center gap-1">
<StatusBadge value={task.priority} size="sm" />
<Badge tone="neutral" size="sm">
{task.category}
</Badge>
</div>
<div className="mt-2 flex items-center justify-between gap-1.5 border-t border-line-soft pt-2">
<span className="min-w-0 flex-1 truncate text-[11px] text-ink-3">{task.due}</span>
<span
title={task.assignee}
className="grid size-6 shrink-0 place-items-center rounded-full bg-primary-soft text-[9.5px] font-bold text-primary"
>
{task.assigneeInitials}
</span>
{/* Keyboard- and click-accessible equivalent of the drag interaction. */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={`Move ${task.title}`}
className="grid size-6 shrink-0 place-items-center rounded-[7px] text-ink-3 transition-colors hover:bg-surface-sunk hover:text-ink"
>
<MoreHorizontal className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Move to</DropdownMenuLabel>
{COLUMNS.filter((c) => c.status !== task.status).map((c) => (
<DropdownMenuItem key={c.status} onSelect={() => onMove(task.id, c.status)}>
<span className={cn("size-2 shrink-0 rounded-full", c.accent)} />
{c.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</article>
)
}
export function TasksPage() {
const revision = useWorkforceRevision()
// Seeded from the shared data, then owned locally so cards can be moved.
const [tasks, setTasks] = useState<Task[]>(() => TASKS.map((t) => ({ ...t })))
const [dragId, setDragId] = useState<string | null>(null)
const [overColumn, setOverColumn] = useState<TaskStatus | null>(null)
// Owliver's resolve action completes the staffing tasks — reflect that here.
useEffect(() => {
setTasks(TASKS.map((t) => ({ ...t })))
}, [revision])
function move(id: string, status: TaskStatus) {
setTasks((prev) => prev.map((t) => (t.id === id ? { ...t, status } : t)))
}
function drop(status: TaskStatus) {
if (dragId) move(dragId, status)
setDragId(null)
setOverColumn(null)
}
/** Always clear the drag state, even when the card is dropped nowhere. */
function endDrag() {
setDragId(null)
setOverColumn(null)
}
const open = tasks.filter((t) => t.status !== "Completed")
const blocked = tasks.filter((t) => t.status === "Blocked")
const high = open.filter((t) => t.priority === "High")
const done = tasks.filter((t) => t.status === "Completed")
return (
<Page>
<PageIntro
title="Task Board"
description="Operational work in flight. Drag a card between columns to change its status."
actions={<Button variant="outline">New task</Button>}
/>
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
<MetricCard
label="Open Tasks"
value={String(open.length)}
sub="Across all categories"
icon={ListTodo}
tone="primary"
/>
<MetricCard
label="High Priority"
value={String(high.length)}
sub="Need action today"
icon={CircleDot}
badge={high.length ? "Urgent" : "Clear"}
badgeTone={high.length ? "bad" : "good"}
tone={high.length ? "warn" : "primary"}
/>
<MetricCard
label="Blocked"
value={String(blocked.length)}
sub="Waiting on a dependency"
icon={OctagonAlert}
badge={blocked.length ? "Review" : "Clear"}
badgeTone={blocked.length ? "warn" : "good"}
tone={blocked.length ? "warn" : "primary"}
/>
<MetricCard
label="Completed"
value={String(done.length)}
sub="Closed today"
icon={CheckSquare}
badge="Done"
badgeTone="good"
tone="primary"
/>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{COLUMNS.map((col) => {
const items = tasks.filter((t) => t.status === col.status)
return (
<section
key={col.status}
onDragOver={(e) => {
e.preventDefault()
e.dataTransfer.dropEffect = "move"
setOverColumn(col.status)
}}
onDragLeave={() => setOverColumn((c) => (c === col.status ? null : c))}
onDrop={(e) => {
e.preventDefault()
drop(col.status)
}}
className={cn(
"flex min-h-[220px] flex-col rounded-[14px] border bg-surface-soft p-2.5 transition-colors",
overColumn === col.status
? "border-primary bg-primary-soft"
: "border-line"
)}
>
<header className="mb-2.5 flex items-center gap-2 px-1">
<span className={cn("size-2 shrink-0 rounded-full", col.accent)} />
<h3 className="min-w-0 flex-1 truncate text-[12.5px] font-bold uppercase tracking-[0.06em] text-ink-2">
{col.label}
</h3>
<span className="grid h-[19px] min-w-[19px] shrink-0 place-items-center rounded-full border border-line bg-surface px-1 text-[11px] font-bold text-ink-2">
{items.length}
</span>
</header>
<div className="flex-1 space-y-2">
{items.map((t) => (
<TaskCard
key={t.id}
task={t}
dragging={dragId === t.id}
onDragStart={setDragId}
onDragEnd={endDrag}
onMove={move}
/>
))}
{items.length === 0 && (
<p className="rounded-[10px] border border-dashed border-line px-3 py-6 text-center text-[12px] text-ink-3">
Drop a task here
</p>
)}
</div>
</section>
)
})}
</div>
</Page>
)
}

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

21
tsconfig.app.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": { "@/*": ["./src/*"] }
},
"include": ["src"]
}

1
tsconfig.app.tsbuildinfo Normal file

File diff suppressed because one or more lines are too long

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

15
tsconfig.node.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true
},
"include": ["vite.config.ts"]
}

File diff suppressed because one or more lines are too long

14
vite.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { fileURLToPath, URL } from "node:url"
import tailwindcss from "@tailwindcss/vite"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
})