first commit
This commit is contained in:
13
.dockerignore
Normal file
13
.dockerignore
Normal 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
24
.gitignore
vendored
Normal 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
8
.oxlintrc.json
Normal 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
32
Dockerfile
Normal 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
117
README.md
Normal 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
19
index.html
Normal 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
45
nginx.conf
Normal 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
4004
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
package.json
Normal file
52
package.json
Normal 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
BIN
public/assets/krow-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
BIN
public/assets/owliver-orig.png
Normal file
BIN
public/assets/owliver-orig.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
BIN
public/assets/owliver.png
Normal file
BIN
public/assets/owliver.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
65
src/App.tsx
Normal file
65
src/App.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
253
src/components/home/CentralOwliver.tsx
Normal file
253
src/components/home/CentralOwliver.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
286
src/components/home/GensparkCommandCapsule.tsx
Normal file
286
src/components/home/GensparkCommandCapsule.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
273
src/components/home/GensparkSuiteBar.tsx
Normal file
273
src/components/home/GensparkSuiteBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
110
src/components/home/GensparkSuperCard.tsx
Normal file
110
src/components/home/GensparkSuperCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
228
src/components/home/ShiftDispatchConsole.tsx
Normal file
228
src/components/home/ShiftDispatchConsole.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
421
src/components/home/WorkforceBento.tsx
Normal file
421
src/components/home/WorkforceBento.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
81
src/components/owliver/OwliverComposer.tsx
Normal file
81
src/components/owliver/OwliverComposer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
217
src/components/owliver/OwliverPanel.tsx
Normal file
217
src/components/owliver/OwliverPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
196
src/components/shared/DataTable.tsx
Normal file
196
src/components/shared/DataTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
112
src/components/shared/MetricCard.tsx
Normal file
112
src/components/shared/MetricCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
70
src/components/shared/Page.tsx
Normal file
70
src/components/shared/Page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
73
src/components/shared/StatusBadge.tsx
Normal file
73
src/components/shared/StatusBadge.tsx
Normal 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}`} />
|
||||
}
|
||||
55
src/components/shared/Toolbar.tsx
Normal file
55
src/components/shared/Toolbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
178
src/components/shell/AppShell.tsx
Normal file
178
src/components/shell/AppShell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
405
src/components/shell/Sidebar.tsx
Normal file
405
src/components/shell/Sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
159
src/components/shell/TopHeader.tsx
Normal file
159
src/components/shell/TopHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
src/components/ui/avatar.tsx
Normal file
34
src/components/ui/avatar.tsx
Normal 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"
|
||||
36
src/components/ui/badge.tsx
Normal file
36
src/components/ui/badge.tsx
Normal 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 }
|
||||
52
src/components/ui/button.tsx
Normal file
52
src/components/ui/button.tsx
Normal 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 }
|
||||
56
src/components/ui/card.tsx
Normal file
56
src/components/ui/card.tsx
Normal 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"
|
||||
5
src/components/ui/collapsible.tsx
Normal file
5
src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
Root as Collapsible,
|
||||
CollapsibleTrigger,
|
||||
CollapsibleContent,
|
||||
} from "@radix-ui/react-collapsible"
|
||||
71
src/components/ui/dropdown-menu.tsx
Normal file
71
src/components/ui/dropdown-menu.tsx
Normal 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"
|
||||
20
src/components/ui/input.tsx
Normal file
20
src/components/ui/input.tsx
Normal 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"
|
||||
29
src/components/ui/progress.tsx
Normal file
29
src/components/ui/progress.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
73
src/components/ui/select.tsx
Normal file
73
src/components/ui/select.tsx
Normal 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"
|
||||
22
src/components/ui/separator.tsx
Normal file
22
src/components/ui/separator.tsx
Normal 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"
|
||||
52
src/components/ui/sheet.tsx
Normal file
52
src/components/ui/sheet.tsx
Normal 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"
|
||||
23
src/components/ui/switch.tsx
Normal file
23
src/components/ui/switch.tsx
Normal 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"
|
||||
75
src/components/ui/table.tsx
Normal file
75
src/components/ui/table.tsx
Normal 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"
|
||||
50
src/components/ui/tabs.tsx
Normal file
50
src/components/ui/tabs.tsx
Normal 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"
|
||||
26
src/components/ui/tooltip.tsx
Normal file
26
src/components/ui/tooltip.tsx
Normal 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
205
src/config/navigation.ts
Normal 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
329
src/data/mockData.ts
Normal 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
135
src/data/selectors.ts
Normal 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
91
src/data/store.ts
Normal 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
204
src/data/types.ts
Normal 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
|
||||
}
|
||||
37
src/hooks/useLayoutMode.ts
Normal file
37
src/hooks/useLayoutMode.ts
Normal 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
|
||||
}
|
||||
26
src/hooks/useOwliverContext.ts
Normal file
26
src/hooks/useOwliverContext.ts
Normal 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
308
src/index.css
Normal 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
6
src/lib/utils.ts
Normal 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
14
src/main.tsx
Normal 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>
|
||||
)
|
||||
310
src/owliver/OwliverProvider.tsx
Normal file
310
src/owliver/OwliverProvider.tsx
Normal 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
|
||||
}
|
||||
344
src/owliver/blocks/BlockRenderer.tsx
Normal file
344
src/owliver/blocks/BlockRenderer.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
95
src/owliver/blocks/ChartBlock.tsx
Normal file
95
src/owliver/blocks/ChartBlock.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
80
src/owliver/blocks/FlowDiagram.tsx
Normal file
80
src/owliver/blocks/FlowDiagram.tsx
Normal 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
2134
src/owliver/service.ts
Normal file
File diff suppressed because it is too large
Load Diff
97
src/owliver/types.ts
Normal file
97
src/owliver/types.ts
Normal 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"
|
||||
191
src/pages/AvailabilityPage.tsx
Normal file
191
src/pages/AvailabilityPage.tsx
Normal 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
204
src/pages/BenefitsPage.tsx
Normal 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
215
src/pages/ClientsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
294
src/pages/CompliancePage.tsx
Normal file
294
src/pages/CompliancePage.tsx
Normal 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
40
src/pages/HomePage.tsx
Normal 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
200
src/pages/InvoicesPage.tsx
Normal 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
200
src/pages/MessagesPage.tsx
Normal 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
319
src/pages/OrdersPage.tsx
Normal 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
269
src/pages/PayrollPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
241
src/pages/PerformancePage.tsx
Normal file
241
src/pages/PerformancePage.tsx
Normal 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
237
src/pages/ReportsPage.tsx
Normal 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
470
src/pages/SettingsPage.tsx
Normal 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
245
src/pages/StaffPage.tsx
Normal 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
249
src/pages/TasksPage.tsx
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
21
tsconfig.app.json
Normal file
21
tsconfig.app.json
Normal 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
1
tsconfig.app.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
15
tsconfig.node.json
Normal file
15
tsconfig.node.json
Normal 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"]
|
||||
}
|
||||
1
tsconfig.node.tsbuildinfo
Normal file
1
tsconfig.node.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
14
vite.config.ts
Normal file
14
vite.config.ts
Normal 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)),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user