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