Initial scaffold: wages app
Full wage cost reporting app — weekly/monthly views, rolling 12-week/12-month history, budget management, Workforce API sync with SSE backfill, net sales via forecasting public API, department filter, CSV export. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
2e0592eb90
37 changed files with 3078 additions and 0 deletions
13
frontend/Dockerfile
Normal file
13
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
RUN npm install
|
||||
COPY . .
|
||||
ARG VITE_HOTEL_NAME
|
||||
ENV VITE_HOTEL_NAME=$VITE_HOTEL_NAME
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html/wages
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#065f46" />
|
||||
<title>Wage Costs</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
55
frontend/nginx.conf
Normal file
55
frontend/nginx.conf
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
server {
|
||||
location = /wages/manifest.webmanifest {
|
||||
default_type application/manifest+json;
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /wages/sw.js {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /wages/registerSW.js {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
location /wages/api/auth/ {
|
||||
proxy_pass http://10.10.10.101:3001/api/auth/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location /wages/api/ {
|
||||
proxy_pass http://backend:3001/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location /wages/health {
|
||||
proxy_pass http://backend:3001/health;
|
||||
}
|
||||
|
||||
location ~* /wages/.*\.(js|css|png|ico|svg|woff2?)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /wages/ {
|
||||
add_header Cache-Control "no-cache" always;
|
||||
try_files $uri $uri/ /wages/index.html;
|
||||
}
|
||||
|
||||
location = / {
|
||||
return 301 /wages/;
|
||||
}
|
||||
}
|
||||
25
frontend/package.json
Normal file
25
frontend/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "hnf-wages-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"recharts": "^3.9.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.5",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
}
|
||||
}
|
||||
77
frontend/src/App.tsx
Normal file
77
frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { useState } from 'react'
|
||||
import { DollarSign, CalendarDays, TrendingUp, BarChart3, Wallet, Settings } from 'lucide-react'
|
||||
import AuthGate, { useAuth } from './components/AuthGate'
|
||||
import { UpdateBanner } from './components/UpdateBanner'
|
||||
import { useVersionCheck } from './hooks/useVersionCheck'
|
||||
import { can } from './types'
|
||||
import Weekly from './pages/Weekly'
|
||||
import Monthly from './pages/Monthly'
|
||||
import Rolling12Weeks from './pages/Rolling12Weeks'
|
||||
import Rolling12Months from './pages/Rolling12Months'
|
||||
import Budgets from './pages/Budgets'
|
||||
import SettingsPage from './pages/Settings'
|
||||
|
||||
type Page = 'weekly' | 'monthly' | 'rolling-weeks' | 'rolling-months' | 'budgets' | 'settings'
|
||||
|
||||
const NAV: { id: Page; label: string; icon: React.ElementType; cap?: string }[] = [
|
||||
{ id: 'weekly', label: 'Weekly', icon: CalendarDays },
|
||||
{ id: 'monthly', label: 'Monthly', icon: TrendingUp },
|
||||
{ id: 'rolling-weeks', label: '12 Weeks', icon: BarChart3 },
|
||||
{ id: 'rolling-months', label: '12 Months', icon: BarChart3 },
|
||||
{ id: 'budgets', label: 'Budgets', icon: Wallet, cap: 'budget' },
|
||||
{ id: 'settings', label: 'Settings', icon: Settings, cap: 'settings' },
|
||||
]
|
||||
|
||||
function Shell() {
|
||||
const { user } = useAuth()
|
||||
const [page, setPage] = useState<Page>('weekly')
|
||||
|
||||
const hotelName = import.meta.env.VITE_HOTEL_NAME || 'Hotel'
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<nav className="sidebar">
|
||||
<div className="sidebar-logo">
|
||||
<DollarSign size={16} strokeWidth={1.75} />
|
||||
Wage Costs
|
||||
</div>
|
||||
<div className="sidebar-nav">
|
||||
{NAV.filter(n => !n.cap || can(user, n.cap)).map(n => (
|
||||
<div
|
||||
key={n.id}
|
||||
className={`nav-item${page === n.id ? ' active' : ''}`}
|
||||
onClick={() => setPage(n.id)}
|
||||
>
|
||||
<n.icon size={16} strokeWidth={1.75} />
|
||||
{n.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ padding: '12px 16px', fontSize: '11px', color: 'rgba(255,255,255,0.3)' }}>
|
||||
{hotelName}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="content">
|
||||
{page === 'weekly' && <Weekly />}
|
||||
{page === 'monthly' && <Monthly />}
|
||||
{page === 'rolling-weeks' && <Rolling12Weeks />}
|
||||
{page === 'rolling-months' && <Rolling12Months />}
|
||||
{page === 'budgets' && <Budgets />}
|
||||
{page === 'settings' && <SettingsPage />}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const updateAvailable = useVersionCheck('/wages/health')
|
||||
return (
|
||||
<>
|
||||
<AuthGate>
|
||||
<Shell />
|
||||
</AuthGate>
|
||||
<UpdateBanner visible={updateAvailable} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
71
frontend/src/api.ts
Normal file
71
frontend/src/api.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import type { DeptActuals, DeptScheduled, NetSalesDay, WageBudget, AppSetting } from './types'
|
||||
|
||||
const BASE = '/wages/api'
|
||||
|
||||
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...opts.headers },
|
||||
...opts,
|
||||
})
|
||||
if (res.status === 401) {
|
||||
;(window.top ?? window).location.href = '/login'
|
||||
throw new Error('Unauthenticated')
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||
throw new Error((err as { error?: string }).error || `Request failed: ${res.status}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export function getActuals(from: string, to: string): Promise<{ departments: DeptActuals[]; show_oncosts: boolean }> {
|
||||
return request(`/actuals?from=${from}&to=${to}`)
|
||||
}
|
||||
|
||||
export function getScheduled(from: string, to: string): Promise<{ departments: DeptScheduled[] }> {
|
||||
return request(`/scheduled?from=${from}&to=${to}`)
|
||||
}
|
||||
|
||||
export function getNetSales(from: string, to: string): Promise<{ days: NetSalesDay[] }> {
|
||||
return request(`/net-sales?from=${from}&to=${to}`)
|
||||
}
|
||||
|
||||
export function getBudgets(): Promise<{ budgets: WageBudget[] }> {
|
||||
return request('/budgets')
|
||||
}
|
||||
|
||||
export function saveBudget(month: string, budget_amount: number): Promise<{ ok: boolean }> {
|
||||
return request(`/budgets/${month}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ budget_amount }),
|
||||
})
|
||||
}
|
||||
|
||||
export function triggerSync(): Promise<{ ok: boolean; actual_rows: number; scheduled_rows: number }> {
|
||||
return request('/sync', { method: 'POST' })
|
||||
}
|
||||
|
||||
export function getSyncStatus(): Promise<{ sync_last_at: string | null; backfill_last_at: string | null; backfill_running: boolean }> {
|
||||
return request('/sync/status')
|
||||
}
|
||||
|
||||
export function cancelBackfill(): Promise<{ ok: boolean }> {
|
||||
return request('/sync/backfill/cancel', { method: 'POST' })
|
||||
}
|
||||
|
||||
export function getDepartments(): Promise<{ departments: { id: string; name: string }[] }> {
|
||||
return request('/departments')
|
||||
}
|
||||
|
||||
export function getSettings(): Promise<{ settings: AppSetting[] }> {
|
||||
return request('/settings')
|
||||
}
|
||||
|
||||
export function saveSettings(settings: { key: string; value: string }[]): Promise<{ ok: boolean }> {
|
||||
return request('/settings', { method: 'PUT', body: JSON.stringify({ settings }) })
|
||||
}
|
||||
|
||||
export function downloadExport(view: string, from: string, to: string): void {
|
||||
window.open(`${BASE}/export?view=${view}&from=${from}&to=${to}`, '_blank')
|
||||
}
|
||||
41
frontend/src/components/AuthGate.tsx
Normal file
41
frontend/src/components/AuthGate.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { useEffect, useState, createContext, useContext } from 'react'
|
||||
import type { User } from '../types'
|
||||
|
||||
interface AuthCtx { user: User }
|
||||
const Ctx = createContext<AuthCtx | null>(null)
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(Ctx)
|
||||
if (!ctx) throw new Error('useAuth must be used inside AuthGate')
|
||||
return ctx
|
||||
}
|
||||
|
||||
export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/wages/api/auth/verify?app=wages', { credentials: 'include' })
|
||||
.then(r => {
|
||||
if (!r.ok) {
|
||||
;(window.top ?? window).location.href = '/login'
|
||||
return null
|
||||
}
|
||||
return r.json()
|
||||
})
|
||||
.then(data => { if (data) setUser(data) })
|
||||
.catch(() => { ;(window.top ?? window).location.href = '/login' })
|
||||
}, [])
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
height: '100vh', color: '#6b7280',
|
||||
}}>
|
||||
Loading…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <Ctx.Provider value={{ user }}>{children}</Ctx.Provider>
|
||||
}
|
||||
28
frontend/src/components/UpdateBanner.tsx
Normal file
28
frontend/src/components/UpdateBanner.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { RefreshCw } from 'lucide-react'
|
||||
|
||||
export function UpdateBanner({ visible }: { visible: boolean }) {
|
||||
if (!visible) return null
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 9999,
|
||||
background: 'var(--sidebar)', color: 'var(--text-light)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
gap: '12px', padding: '10px 16px', fontSize: '14px',
|
||||
boxShadow: '0 -2px 8px rgba(0,0,0,0.3)',
|
||||
}}>
|
||||
<span>A new version is available.</span>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
background: 'var(--accent)', color: 'var(--sidebar)',
|
||||
border: 'none', borderRadius: '4px', padding: '6px 14px',
|
||||
fontWeight: 600, cursor: 'pointer', fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={14} strokeWidth={1.75} />
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
frontend/src/hooks/useVersionCheck.ts
Normal file
31
frontend/src/hooks/useVersionCheck.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
const POLL_MS = 2 * 60 * 1000
|
||||
|
||||
export function useVersionCheck(healthUrl: string) {
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let seenVersion: string | null = null
|
||||
|
||||
async function check() {
|
||||
try {
|
||||
const res = await fetch(healthUrl, { cache: 'no-store' })
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
const v: string | undefined = data.version
|
||||
if (!v) return
|
||||
if (seenVersion === null) { seenVersion = v }
|
||||
else if (v !== seenVersion) { setUpdateAvailable(true) }
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
check()
|
||||
const interval = setInterval(check, POLL_MS)
|
||||
const onVisible = () => { if (document.visibilityState === 'visible') check() }
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
return () => { clearInterval(interval); document.removeEventListener('visibilitychange', onVisible) }
|
||||
}, [healthUrl])
|
||||
|
||||
return updateAvailable
|
||||
}
|
||||
325
frontend/src/index.css
Normal file
325
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
/* Stack design system tokens */
|
||||
:root {
|
||||
--navy: #1a1a2e;
|
||||
--gold: #c9a84c;
|
||||
--body-bg: #f4f5f7;
|
||||
--card-bg: #ffffff;
|
||||
--text-primary: #1a1a2e;
|
||||
--text-muted: #6b7280;
|
||||
--border: #e5e7eb;
|
||||
--radius: 8px;
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
|
||||
--shadow-md: 0 4px 12px rgba(0,0,0,0.12);
|
||||
|
||||
/* App theme — dark green for finance */
|
||||
--app-primary: #065f46;
|
||||
--app-primary-light: #059669;
|
||||
--app-primary-dark: #064e3b;
|
||||
|
||||
/* UpdateBanner aliases */
|
||||
--sidebar: var(--navy);
|
||||
--text-light: #ffffff;
|
||||
--accent: var(--gold);
|
||||
|
||||
/* Layout */
|
||||
--sidebar-w: 200px;
|
||||
--topbar-h: 56px;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--body-bg);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||
|
||||
/* ── App shell ──────────────────────────────────────────────────── */
|
||||
.app-shell {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Sidebar ────────────────────────────────────────────────────── */
|
||||
.sidebar {
|
||||
width: var(--sidebar-w);
|
||||
background: var(--navy);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
padding: 20px 16px 12px;
|
||||
color: var(--gold);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
padding: 8px 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
color: rgba(255,255,255,0.65);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
border-left: 3px solid transparent;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: rgba(255,255,255,0.9);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: rgba(201,168,76,0.12);
|
||||
color: var(--gold);
|
||||
border-left-color: var(--gold);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Content area ───────────────────────────────────────────────── */
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* ── Cards ──────────────────────────────────────────────────────── */
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
/* ── Summary cards ──────────────────────────────────────────────── */
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.summary-card .label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.summary-card .value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.summary-card .sub {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── Tables ─────────────────────────────────────────────────────── */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-table th.right,
|
||||
.data-table td.right { text-align: right; }
|
||||
|
||||
.data-table td {
|
||||
padding: 9px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.data-table tr:last-child td { border-bottom: none; }
|
||||
|
||||
.data-table tr.total-row td {
|
||||
font-weight: 700;
|
||||
border-top: 2px solid var(--border);
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.data-table tr:hover:not(.total-row) td {
|
||||
background: var(--body-bg);
|
||||
}
|
||||
|
||||
/* ── Traffic lights ─────────────────────────────────────────────── */
|
||||
.pct-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.pct-green { background: #d1fae5; color: #065f46; }
|
||||
.pct-amber { background: #fef3c7; color: #92400e; }
|
||||
.pct-red { background: #fee2e2; color: #991b1b; }
|
||||
|
||||
.variance-over { color: #dc2626; font-weight: 600; }
|
||||
.variance-under { color: #059669; font-weight: 600; }
|
||||
|
||||
/* ── Partial / forecast ─────────────────────────────────────────── */
|
||||
.partial-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
background: var(--body-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
margin-left: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ── Buttons ────────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn:hover:not(:disabled) { opacity: 0.88; }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--app-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: var(--body-bg);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.btn-gold {
|
||||
background: var(--gold);
|
||||
color: var(--navy);
|
||||
}
|
||||
|
||||
/* ── Page header ────────────────────────────────────────────────── */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Week / month selector ──────────────────────────────────────── */
|
||||
.period-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.period-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
min-width: 150px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Form elements ──────────────────────────────────────────────── */
|
||||
input[type="number"], input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
input[type="number"]:focus,
|
||||
input[type="text"]:focus {
|
||||
outline: 2px solid var(--app-primary-light);
|
||||
border-color: var(--app-primary-light);
|
||||
}
|
||||
|
||||
/* ── Footnote ───────────────────────────────────────────────────── */
|
||||
.footnote {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 8px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Loading/error states ───────────────────────────────────────── */
|
||||
.state-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
||||
174
frontend/src/pages/Budgets.tsx
Normal file
174
frontend/src/pages/Budgets.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import { getBudgets, saveBudget } from '../api'
|
||||
import type { WageBudget } from '../types'
|
||||
|
||||
function daysInMonth(y: number, m: number): number { return new Date(y, m, 0).getDate() }
|
||||
|
||||
function getMonthRange(): { year: number; month: number }[] {
|
||||
const today = new Date()
|
||||
const months: { year: number; month: number }[] = []
|
||||
for (let i = -3; i <= 3; i++) {
|
||||
let m = today.getMonth() + 1 + i
|
||||
let y = today.getFullYear()
|
||||
while (m <= 0) { m += 12; y-- }
|
||||
while (m > 12) { m -= 12; y++ }
|
||||
months.push({ year: y, month: m })
|
||||
}
|
||||
return months
|
||||
}
|
||||
|
||||
const MONTH_LABELS = ['January','February','March','April','May','June','July','August','September','October','November','December']
|
||||
|
||||
export default function Budgets() {
|
||||
const [budgets, setBudgets] = useState<Record<string, number>>({})
|
||||
const [editing, setEditing] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState<Record<string, boolean>>({})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const inputRefs = useRef<Record<string, HTMLInputElement | null>>({})
|
||||
|
||||
const months = getMonthRange()
|
||||
|
||||
useEffect(() => {
|
||||
getBudgets()
|
||||
.then(res => {
|
||||
const map: Record<string, number> = {}
|
||||
for (const b of res.budgets as WageBudget[]) {
|
||||
const key = b.month.slice(0, 7) // YYYY-MM
|
||||
map[key] = b.budget_amount
|
||||
}
|
||||
setBudgets(map)
|
||||
})
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const monthKey = (y: number, m: number) => `${y}-${String(m).padStart(2, '0')}`
|
||||
|
||||
const handleFocus = (key: string) => {
|
||||
const current = budgets[key]
|
||||
setEditing(e => ({ ...e, [key]: current != null ? String(current) : '' }))
|
||||
}
|
||||
|
||||
const handleChange = (key: string, val: string) => {
|
||||
setEditing(e => ({ ...e, [key]: val }))
|
||||
}
|
||||
|
||||
const handleSave = async (key: string) => {
|
||||
const raw = editing[key]?.trim()
|
||||
if (raw === '') {
|
||||
setEditing(e => { const n = { ...e }; delete n[key]; return n })
|
||||
return
|
||||
}
|
||||
const amount = parseFloat(raw)
|
||||
if (isNaN(amount)) {
|
||||
setEditing(e => { const n = { ...e }; delete n[key]; return n })
|
||||
return
|
||||
}
|
||||
setSaving(s => ({ ...s, [key]: true }))
|
||||
try {
|
||||
await saveBudget(key, amount)
|
||||
setBudgets(b => ({ ...b, [key]: amount }))
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Save failed')
|
||||
} finally {
|
||||
setSaving(s => { const n = { ...s }; delete n[key]; return n })
|
||||
setEditing(e => { const n = { ...e }; delete n[key]; return n })
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (key: string, e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSave(key)
|
||||
// Tab focus to next
|
||||
const keys = months.map(m => monthKey(m.year, m.month))
|
||||
const idx = keys.indexOf(key)
|
||||
if (idx >= 0 && idx < keys.length - 1) {
|
||||
setTimeout(() => inputRefs.current[keys[idx + 1]]?.focus(), 50)
|
||||
}
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setEditing(e2 => { const n = { ...e2 }; delete n[key]; return n })
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="state-center">Loading…</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Wage Budgets</h1>
|
||||
</div>
|
||||
|
||||
{error && <div style={{ color: '#dc2626', marginBottom: 16 }}>{error}</div>}
|
||||
|
||||
<div className="card">
|
||||
<p style={{ color: 'var(--text-muted)', marginTop: 0, fontSize: 13 }}>
|
||||
Enter the total monthly wages budget (FD figure). Click a cell to edit, press Enter or Tab to save.
|
||||
</p>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Month</th>
|
||||
<th className="right">Budget</th>
|
||||
<th className="right" style={{ color: 'var(--text-muted)', fontWeight: 400 }}>Weekly equiv.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{months.map(({ year, month }) => {
|
||||
const key = monthKey(year, month)
|
||||
const current = budgets[key]
|
||||
const isEditing = key in editing
|
||||
const dim = daysInMonth(year, month)
|
||||
const weekly = current != null ? (current * 7 / dim) : null
|
||||
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td style={{ fontWeight: 500 }}>
|
||||
{MONTH_LABELS[month - 1]} {year}
|
||||
</td>
|
||||
<td className="right" style={{ width: 160 }}>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
ref={el => { inputRefs.current[key] = el }}
|
||||
value={editing[key]}
|
||||
onChange={e => handleChange(key, e.target.value)}
|
||||
onBlur={() => handleSave(key)}
|
||||
onKeyDown={e => handleKeyDown(key, e)}
|
||||
style={{ width: 140, textAlign: 'right' }}
|
||||
autoFocus
|
||||
min={0}
|
||||
step={100}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onClick={() => handleFocus(key)}
|
||||
style={{
|
||||
cursor: 'text',
|
||||
display: 'inline-block',
|
||||
minWidth: 100,
|
||||
padding: '4px 8px',
|
||||
borderRadius: 4,
|
||||
border: '1px dashed var(--border)',
|
||||
textAlign: 'right',
|
||||
color: current != null ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
}}
|
||||
>
|
||||
{saving[key] ? 'Saving…' : current != null ? `£${current.toLocaleString('en-GB')}` : 'Click to set'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="right" style={{ color: 'var(--text-muted)' }}>
|
||||
{weekly != null ? `£${Math.round(weekly).toLocaleString('en-GB')}` : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
274
frontend/src/pages/Monthly.tsx
Normal file
274
frontend/src/pages/Monthly.tsx
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { ChevronLeft, ChevronRight, Download } from 'lucide-react'
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, Cell,
|
||||
} from 'recharts'
|
||||
import { getActuals, getScheduled, getNetSales, getBudgets, downloadExport } from '../api'
|
||||
import type { DeptActuals, WageBudget } from '../types'
|
||||
|
||||
function fmt(d: Date): string { return d.toISOString().slice(0, 10) }
|
||||
function addDays(d: Date, n: number): Date { const r = new Date(d); r.setDate(r.getDate() + n); return r }
|
||||
function daysInMonth(y: number, m: number): number { return new Date(y, m, 0).getDate() }
|
||||
function fmtMoney(n: number): string { return `£${Math.round(n).toLocaleString('en-GB')}` }
|
||||
function pctClass(pct: number): string { return pct <= 100 ? 'pct-green' : pct <= 110 ? 'pct-amber' : 'pct-red' }
|
||||
|
||||
const DEPT_COLORS = ['#065f46','#059669','#0891b2','#7c3aed','#c2410c','#b45309','#0f766e','#4338ca','#be185d','#15803d']
|
||||
|
||||
export default function Monthly() {
|
||||
const today = new Date()
|
||||
const [year, setYear] = useState(today.getFullYear())
|
||||
const [month, setMonth] = useState(today.getMonth() + 1) // 1-based
|
||||
|
||||
const [depts, setDepts] = useState<DeptActuals[]>([])
|
||||
const [scheduled, setScheduled] = useState<Record<string, Record<string, number>>>({}) // dept_id → date → cost
|
||||
const [netSales, setNetSales] = useState(0)
|
||||
const [budget, setBudget] = useState<number | null>(null)
|
||||
const [showOncosts, setShowOncosts] = useState(true)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const dim = daysInMonth(year, month)
|
||||
const monthStr = `${year}-${String(month).padStart(2, '0')}`
|
||||
const fromStr = `${monthStr}-01`
|
||||
const toStr = `${monthStr}-${String(dim).padStart(2, '0')}`
|
||||
const todayStr = fmt(today)
|
||||
const isCurrentMonth = year === today.getFullYear() && month === today.getMonth() + 1
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError(null)
|
||||
try {
|
||||
const [actRes, schRes, salesRes, budRes] = await Promise.all([
|
||||
getActuals(fromStr, toStr),
|
||||
getScheduled(todayStr, toStr),
|
||||
getNetSales(fromStr, todayStr),
|
||||
getBudgets(),
|
||||
])
|
||||
setDepts(actRes.departments)
|
||||
setShowOncosts(actRes.show_oncosts)
|
||||
|
||||
// Build scheduled map
|
||||
const schMap: Record<string, Record<string, number>> = {}
|
||||
for (const dep of schRes.departments) {
|
||||
schMap[dep.department_id] = {}
|
||||
for (const [date, val] of Object.entries(dep.days)) {
|
||||
schMap[dep.department_id][date] = val.cost
|
||||
}
|
||||
}
|
||||
setScheduled(schMap)
|
||||
|
||||
setNetSales(salesRes.days.reduce((s, d) => s + d.net_sales, 0))
|
||||
|
||||
const bRow = budRes.budgets.find(b => b.month === `${fromStr}`)
|
||||
setBudget(bRow ? bRow.budget_amount : null)
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [fromStr, toStr, todayStr])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const prev = () => { if (month === 1) { setYear(y => y - 1); setMonth(12) } else { setMonth(m => m - 1) } }
|
||||
const next = () => { if (month === 12) { setYear(y => y + 1); setMonth(1) } else { setMonth(m => m + 1) } }
|
||||
|
||||
// Build dept summary: actual MTD + forecast EOM
|
||||
const deptSummary = depts.map((dep, idx) => {
|
||||
const actualMTD = Object.entries(dep.days)
|
||||
.filter(([d]) => d <= todayStr)
|
||||
.reduce((s, [, v]) => s + v.cost, 0)
|
||||
|
||||
// Forecast remaining days
|
||||
let forecastRem = 0
|
||||
for (let day = 1; day <= dim; day++) {
|
||||
const dateStr = `${monthStr}-${String(day).padStart(2, '0')}`
|
||||
if (dateStr <= todayStr) continue
|
||||
|
||||
// Priority: rota → prior week same DoW actual
|
||||
const rotaCost = schMap(dep.department_id, dateStr)
|
||||
if (rotaCost != null) {
|
||||
forecastRem += rotaCost
|
||||
continue
|
||||
}
|
||||
const priorDate = addDays(new Date(dateStr + 'T00:00:00'), -7)
|
||||
const priorStr = fmt(priorDate)
|
||||
const priorCost = dep.days[priorStr]?.cost
|
||||
if (priorCost != null) forecastRem += priorCost
|
||||
}
|
||||
|
||||
return {
|
||||
department_id: dep.department_id,
|
||||
department_name: dep.department_name,
|
||||
actual_mtd: actualMTD,
|
||||
forecast_eom: actualMTD + forecastRem,
|
||||
color: DEPT_COLORS[idx % DEPT_COLORS.length],
|
||||
}
|
||||
}).sort((a, b) => b.forecast_eom - a.forecast_eom)
|
||||
|
||||
function schMap(deptId: string, date: string): number | null {
|
||||
return scheduled[deptId]?.[date] ?? null
|
||||
}
|
||||
|
||||
const totalActual = deptSummary.reduce((s, d) => s + d.actual_mtd, 0)
|
||||
const totalForecast = deptSummary.reduce((s, d) => s + d.forecast_eom, 0)
|
||||
const pctBudget = budget != null && budget > 0 ? (totalForecast / budget) * 100 : null
|
||||
const pctSales = netSales > 0 ? (totalActual / netSales) * 100 : null
|
||||
const variance = budget != null ? totalForecast - budget : null
|
||||
|
||||
// Build chart data: group by week
|
||||
const weeks: { label: string; actual: number; forecast: number; isPast: boolean }[] = []
|
||||
for (let w = 0; w * 7 < dim; w++) {
|
||||
const wStart = w * 7 + 1
|
||||
const wEnd = Math.min(wStart + 6, dim)
|
||||
const wEndDate = new Date(`${monthStr}-${String(wEnd).padStart(2, '0')}T00:00:00`)
|
||||
const isPast = wEndDate < today
|
||||
|
||||
let actual = 0, forecast = 0
|
||||
for (let day = wStart; day <= wEnd; day++) {
|
||||
const dateStr = `${monthStr}-${String(day).padStart(2, '0')}`
|
||||
const isActual = dateStr <= todayStr
|
||||
const total = deptSummary.reduce((s, dep) => {
|
||||
if (isActual) return s + (depts.find(d => d.department_id === dep.department_id)?.days[dateStr]?.cost ?? 0)
|
||||
const rota = schMap(dep.department_id, dateStr)
|
||||
if (rota != null) return s + rota
|
||||
const prior = depts.find(d => d.department_id === dep.department_id)?.days[fmt(addDays(new Date(dateStr + 'T00:00:00'), -7))]?.cost ?? 0
|
||||
return s + prior
|
||||
}, 0)
|
||||
if (isActual) actual += total; else forecast += total
|
||||
}
|
||||
|
||||
weeks.push({ label: `W${w + 1}`, actual, forecast, isPast })
|
||||
}
|
||||
|
||||
const monthLabel = new Date(year, month - 1, 1).toLocaleDateString('en-GB', { month: 'long', year: 'numeric' })
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Monthly View</h1>
|
||||
<button className="btn btn-secondary" onClick={() => downloadExport('monthly', fromStr, toStr)}>
|
||||
<Download size={14} strokeWidth={1.75} /> CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="period-nav" style={{ marginBottom: 20 }}>
|
||||
<button className="btn btn-secondary" onClick={prev}><ChevronLeft size={16} strokeWidth={1.75} /></button>
|
||||
<span className="period-label">{monthLabel}</span>
|
||||
<button className="btn btn-secondary" onClick={next} disabled={isCurrentMonth}><ChevronRight size={16} strokeWidth={1.75} /></button>
|
||||
</div>
|
||||
|
||||
<div className="summary-grid">
|
||||
<div className="summary-card">
|
||||
<div className="label">Actual MTD</div>
|
||||
<div className="value">{fmtMoney(totalActual)}</div>
|
||||
</div>
|
||||
<div className="summary-card">
|
||||
<div className="label">Forecast EOM</div>
|
||||
<div className="value">{fmtMoney(totalForecast)}</div>
|
||||
<div className="sub">rota + prior-week actual</div>
|
||||
</div>
|
||||
<div className="summary-card">
|
||||
<div className="label">Monthly Budget</div>
|
||||
<div className="value">{budget != null ? fmtMoney(budget) : '—'}</div>
|
||||
</div>
|
||||
<div className="summary-card">
|
||||
<div className="label">% Budget (Forecast)</div>
|
||||
<div className="value">
|
||||
{pctBudget != null
|
||||
? <span className={`pct-badge ${pctClass(pctBudget)}`}>{pctBudget.toFixed(1)}%</span>
|
||||
: '—'}
|
||||
</div>
|
||||
{variance != null && (
|
||||
<div className={`sub ${variance > 0 ? 'variance-over' : 'variance-under'}`}>
|
||||
{variance > 0 ? `+${fmtMoney(variance)} over` : `${fmtMoney(Math.abs(variance))} under`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="summary-card">
|
||||
<div className="label">Net Sales MTD</div>
|
||||
<div className="value">{fmtMoney(netSales)}</div>
|
||||
</div>
|
||||
<div className="summary-card">
|
||||
<div className="label">% Net Sales</div>
|
||||
<div className="value">{pctSales != null ? `${pctSales.toFixed(1)}%` : '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && <div className="state-center">Loading…</div>}
|
||||
{error && <div className="state-center" style={{ color: '#dc2626' }}>{error}</div>}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{/* Stacked bar chart */}
|
||||
<div className="card">
|
||||
<div className="card-title">Weekly Breakdown</div>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={weeks} margin={{ top: 4, right: 16, left: 16, bottom: 0 }}>
|
||||
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
|
||||
<YAxis tickFormatter={v => `£${Math.round(v / 1000)}k`} tick={{ fontSize: 11 }} width={55} />
|
||||
<Tooltip formatter={(v: number) => fmtMoney(v)} />
|
||||
<Legend />
|
||||
{deptSummary.map(dep => (
|
||||
<Bar key={dep.department_id} dataKey={dep.department_name} stackId="a" fill={dep.color}>
|
||||
{weeks.map((w, i) => (
|
||||
<Cell key={i} fill={dep.color} opacity={w.isPast ? 1 : 0.4} />
|
||||
))}
|
||||
</Bar>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Dept breakdown table */}
|
||||
<div className="card">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Department</th>
|
||||
<th className="right">Actual MTD</th>
|
||||
<th className="right">Forecast → EOM</th>
|
||||
<th className="right">Budget</th>
|
||||
<th className="right">% Budget</th>
|
||||
<th className="right">Variance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{deptSummary.map(dep => {
|
||||
const dp = budget != null && budget > 0 ? (dep.forecast_eom / budget) * 100 : null
|
||||
const dv = budget != null ? dep.forecast_eom - budget : null
|
||||
return (
|
||||
<tr key={dep.department_id}>
|
||||
<td><span style={{ display: 'inline-block', width: 10, height: 10, borderRadius: '50%', background: dep.color, marginRight: 8 }} />{dep.department_name}</td>
|
||||
<td className="right">{fmtMoney(dep.actual_mtd)}</td>
|
||||
<td className="right">{fmtMoney(dep.forecast_eom)}</td>
|
||||
<td className="right">—</td>
|
||||
<td className="right">
|
||||
{dp != null ? <span className={`pct-badge ${pctClass(dp)}`}>{dp.toFixed(1)}%</span> : '—'}
|
||||
</td>
|
||||
<td className="right">
|
||||
{dv != null && <span className={dv > 0 ? 'variance-over' : 'variance-under'}>{dv > 0 ? '+' : ''}{fmtMoney(dv)}</span>}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
<tr className="total-row">
|
||||
<td>Total</td>
|
||||
<td className="right">{fmtMoney(totalActual)}</td>
|
||||
<td className="right">{fmtMoney(totalForecast)}</td>
|
||||
<td className="right">{budget != null ? fmtMoney(budget) : '—'}</td>
|
||||
<td className="right">
|
||||
{pctBudget != null ? <span className={`pct-badge ${pctClass(pctBudget)}`}>{pctBudget.toFixed(1)}%</span> : '—'}
|
||||
</td>
|
||||
<td className="right">
|
||||
{variance != null && <span className={variance > 0 ? 'variance-over' : 'variance-under'}>{variance > 0 ? '+' : ''}{fmtMoney(variance)}</span>}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{showOncosts && <p className="footnote">Includes estimated employer on-costs. Final payroll figures are in Sage.</p>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
198
frontend/src/pages/Rolling12Months.tsx
Normal file
198
frontend/src/pages/Rolling12Months.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Download } from 'lucide-react'
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import { getActuals, getNetSales, getBudgets, downloadExport } from '../api'
|
||||
import type { WageBudget } from '../types'
|
||||
|
||||
function fmt(d: Date): string { return d.toISOString().slice(0, 10) }
|
||||
function fmtMoney(n: number): string { return `£${Math.round(n).toLocaleString('en-GB')}` }
|
||||
function pctClass(p: number): string { return p <= 100 ? 'pct-green' : p <= 110 ? 'pct-amber' : 'pct-red' }
|
||||
function daysInMonth(y: number, m: number): number { return new Date(y, m, 0).getDate() }
|
||||
|
||||
const DEPT_COLORS = ['#065f46','#059669','#0891b2','#7c3aed','#c2410c','#b45309','#0f766e','#4338ca','#be185d','#15803d']
|
||||
const MONTH_LABELS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
|
||||
|
||||
export default function Rolling12Months() {
|
||||
const [rows, setRows] = useState<{ label: string; wages: number; budget: number | null; sales: number; pySales: number; partial: boolean }[]>([])
|
||||
const [deptCols, setDeptCols] = useState<{ id: string; name: string; color: string }[]>([])
|
||||
const [chartData, setChartData] = useState<Record<string, number | string>[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
setLoading(true); setError(null)
|
||||
try {
|
||||
const today = new Date()
|
||||
const curY = today.getFullYear()
|
||||
const curM = today.getMonth() + 1 // 1-based
|
||||
|
||||
// 13 months: 12 complete + current partial
|
||||
const months: { year: number; month: number }[] = []
|
||||
for (let i = 12; i >= 0; i--) {
|
||||
let m = curM - i
|
||||
let y = curY
|
||||
while (m <= 0) { m += 12; y-- }
|
||||
months.push({ year: y, month: m })
|
||||
}
|
||||
|
||||
const rangeFrom = `${months[0].year}-${String(months[0].month).padStart(2, '0')}-01`
|
||||
const lastMon = months[months.length - 1]
|
||||
const lastDim = daysInMonth(lastMon.year, lastMon.month)
|
||||
const rangeTo = `${lastMon.year}-${String(lastMon.month).padStart(2, '0')}-${String(lastDim).padStart(2, '0')}`
|
||||
|
||||
const [actRes, salesRes, budRes] = await Promise.all([
|
||||
getActuals(rangeFrom, rangeTo),
|
||||
getNetSales(rangeFrom, rangeTo),
|
||||
getBudgets(),
|
||||
])
|
||||
|
||||
const salesByDate: Record<string, { sales: number; py: number }> = {}
|
||||
for (const d of salesRes.days) salesByDate[d.date] = { sales: d.net_sales, py: d.py_sales }
|
||||
|
||||
const budgetMap: Record<string, number> = {}
|
||||
for (const b of budRes.budgets as WageBudget[]) budgetMap[b.month] = b.budget_amount
|
||||
|
||||
const depts = actRes.departments
|
||||
const cols = depts.map((d, i) => ({ id: d.department_id, name: d.department_name, color: DEPT_COLORS[i % DEPT_COLORS.length] }))
|
||||
setDeptCols(cols)
|
||||
|
||||
const tableRows: typeof rows = []
|
||||
const cData: Record<string, number | string>[] = []
|
||||
const todayStr = fmt(today)
|
||||
|
||||
for (const { year, month } of months) {
|
||||
const dim = daysInMonth(year, month)
|
||||
const monthStr = `${year}-${String(month).padStart(2, '0')}`
|
||||
const monthFrom = `${monthStr}-01`
|
||||
const monthTo = `${monthStr}-${String(dim).padStart(2, '0')}`
|
||||
const isCurrentMonth = year === curY && month === curM
|
||||
const effectiveTo = isCurrentMonth ? todayStr : monthTo
|
||||
|
||||
let wages = 0
|
||||
const deptWages: Record<string, number> = {}
|
||||
for (const dep of depts) {
|
||||
let dCost = 0
|
||||
for (const [date, val] of Object.entries(dep.days)) {
|
||||
if (date >= monthFrom && date <= effectiveTo) dCost += val.cost
|
||||
}
|
||||
wages += dCost
|
||||
deptWages[dep.department_id] = dCost
|
||||
}
|
||||
|
||||
let sales = 0, pySales = 0
|
||||
for (const [date, val] of Object.entries(salesByDate)) {
|
||||
if (date >= monthFrom && date <= effectiveTo) { sales += val.sales; pySales += val.py }
|
||||
}
|
||||
|
||||
const monKey = `${monthFrom}`
|
||||
const budget = budgetMap[monKey] ?? null
|
||||
const label = `${MONTH_LABELS[month - 1]}-${String(year).slice(2)}`
|
||||
|
||||
tableRows.push({ label, wages, budget, sales, pySales, partial: isCurrentMonth })
|
||||
|
||||
const cdRow: Record<string, number | string> = { label }
|
||||
for (const dep of depts) cdRow[dep.department_name] = deptWages[dep.department_id] ?? 0
|
||||
cData.push(cdRow)
|
||||
}
|
||||
|
||||
setRows(tableRows)
|
||||
setChartData(cData)
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const today = new Date()
|
||||
const curY = today.getFullYear()
|
||||
const curM = today.getMonth() + 1
|
||||
let fromY = curY, fromM = curM - 12
|
||||
while (fromM <= 0) { fromM += 12; fromY-- }
|
||||
const rangeFrom = `${fromY}-${String(fromM).padStart(2, '0')}-01`
|
||||
const rangeTo = `${curY}-${String(curM).padStart(2, '0')}-${String(daysInMonth(curY, curM)).padStart(2, '0')}`
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Rolling 12 Months</h1>
|
||||
<button className="btn btn-secondary" onClick={() => downloadExport('rolling-months', rangeFrom, rangeTo)}>
|
||||
<Download size={14} strokeWidth={1.75} /> CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && <div className="state-center">Loading…</div>}
|
||||
{error && <div className="state-center" style={{ color: '#dc2626' }}>{error}</div>}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<div className="card">
|
||||
<div className="card-title">Wages by Department (monthly)</div>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={chartData} margin={{ top: 4, right: 16, left: 16, bottom: 0 }}>
|
||||
<XAxis dataKey="label" tick={{ fontSize: 10 }} />
|
||||
<YAxis tickFormatter={v => `£${Math.round(v / 1000)}k`} tick={{ fontSize: 11 }} width={55} />
|
||||
<Tooltip formatter={(v: number) => fmtMoney(v)} />
|
||||
<Legend />
|
||||
{deptCols.map(dep => (
|
||||
<Bar key={dep.id} dataKey={dep.name} stackId="a" fill={dep.color} />
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Month</th>
|
||||
<th className="right">Total Wages</th>
|
||||
<th className="right">Budget</th>
|
||||
<th className="right">Var vs Budget</th>
|
||||
<th className="right">% Budget</th>
|
||||
<th className="right">Net Sales</th>
|
||||
<th className="right">% Net Sales</th>
|
||||
<th className="right">PY Net Sales</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => {
|
||||
const pctB = r.budget != null && r.budget > 0 ? (r.wages / r.budget) * 100 : null
|
||||
const pctS = r.sales > 0 ? (r.wages / r.sales) * 100 : null
|
||||
const vari = r.budget != null ? r.wages - r.budget : null
|
||||
return (
|
||||
<tr key={i} style={r.partial ? { opacity: 0.7 } : {}}>
|
||||
<td>
|
||||
{r.label}
|
||||
{r.partial && <span className="partial-badge">current</span>}
|
||||
</td>
|
||||
<td className="right">{fmtMoney(r.wages)}</td>
|
||||
<td className="right">{r.budget != null ? fmtMoney(r.budget) : '—'}</td>
|
||||
<td className="right">
|
||||
{vari != null && (
|
||||
<span className={vari > 0 ? 'variance-over' : 'variance-under'}>
|
||||
{vari > 0 ? '+' : ''}{fmtMoney(vari)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="right">
|
||||
{pctB != null ? <span className={`pct-badge ${pctClass(pctB)}`}>{pctB.toFixed(1)}%</span> : '—'}
|
||||
</td>
|
||||
<td className="right">{r.sales > 0 ? fmtMoney(r.sales) : '—'}</td>
|
||||
<td className="right">{pctS != null ? `${pctS.toFixed(1)}%` : '—'}</td>
|
||||
<td className="right">{r.pySales > 0 ? fmtMoney(r.pySales) : '—'}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
205
frontend/src/pages/Rolling12Weeks.tsx
Normal file
205
frontend/src/pages/Rolling12Weeks.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Download } from 'lucide-react'
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer,
|
||||
LineChart, Line, CartesianGrid, ReferenceLine,
|
||||
} from 'recharts'
|
||||
import { getActuals, getNetSales, getBudgets, downloadExport } from '../api'
|
||||
import type { WageBudget } from '../types'
|
||||
|
||||
function fmt(d: Date): string { return d.toISOString().slice(0, 10) }
|
||||
function addDays(d: Date, n: number): Date { const r = new Date(d); r.setDate(r.getDate() + n); return r }
|
||||
function startOfWeek(d: Date): Date {
|
||||
const day = d.getDay()
|
||||
const r = new Date(d)
|
||||
r.setDate(d.getDate() + (day === 0 ? -6 : 1 - day))
|
||||
r.setHours(0, 0, 0, 0)
|
||||
return r
|
||||
}
|
||||
function daysInMonth(d: Date): number { return new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate() }
|
||||
function fmtMoney(n: number): string { return `£${Math.round(n).toLocaleString('en-GB')}` }
|
||||
function pctClass(p: number): string { return p <= 100 ? 'pct-green' : p <= 110 ? 'pct-amber' : 'pct-red' }
|
||||
|
||||
const DEPT_COLORS = ['#065f46','#059669','#0891b2','#7c3aed','#c2410c','#b45309','#0f766e','#4338ca','#be185d','#15803d']
|
||||
|
||||
export default function Rolling12Weeks() {
|
||||
const [rows, setRows] = useState<{ label: string; wages: number; budget: number | null; sales: number; pySales: number; partial: boolean }[]>([])
|
||||
const [deptCols, setDeptCols] = useState<{ id: string; name: string; color: string }[]>([])
|
||||
const [chartData, setChartData] = useState<Record<string, number | string>[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
setLoading(true); setError(null)
|
||||
try {
|
||||
const today = new Date()
|
||||
const thisMonday = startOfWeek(today)
|
||||
|
||||
// 13 weeks back from Monday = 12 complete weeks + current (partial)
|
||||
const rangeStart = addDays(thisMonday, -12 * 7)
|
||||
const rangeEnd = addDays(thisMonday, 6) // end of current week
|
||||
|
||||
const [actRes, salesRes, budRes] = await Promise.all([
|
||||
getActuals(fmt(rangeStart), fmt(rangeEnd)),
|
||||
getNetSales(fmt(rangeStart), fmt(rangeEnd)),
|
||||
getBudgets(),
|
||||
])
|
||||
|
||||
const salesByDate: Record<string, { sales: number; py: number }> = {}
|
||||
for (const d of salesRes.days) {
|
||||
salesByDate[d.date] = { sales: d.net_sales, py: d.py_sales }
|
||||
}
|
||||
|
||||
const budgetMap: Record<string, number> = {}
|
||||
for (const b of budRes.budgets as WageBudget[]) {
|
||||
budgetMap[b.month] = b.budget_amount
|
||||
}
|
||||
|
||||
// Dept lookup
|
||||
const depts = actRes.departments
|
||||
const cols = depts.map((d, i) => ({ id: d.department_id, name: d.department_name, color: DEPT_COLORS[i % DEPT_COLORS.length] }))
|
||||
setDeptCols(cols)
|
||||
|
||||
const tableRows: typeof rows = []
|
||||
const cData: Record<string, number | string>[] = []
|
||||
|
||||
for (let w = 0; w < 13; w++) {
|
||||
const wStart = addDays(rangeStart, w * 7)
|
||||
const wEnd = addDays(wStart, 6)
|
||||
const isPartial = wStart.toDateString() === thisMonday.toDateString()
|
||||
const effectiveEnd = isPartial ? today : wEnd
|
||||
|
||||
let wages = 0
|
||||
const deptWages: Record<string, number> = {}
|
||||
|
||||
for (const dep of depts) {
|
||||
let dCost = 0
|
||||
for (let i = 0; i <= 6; i++) {
|
||||
const d = addDays(wStart, i)
|
||||
if (d > effectiveEnd) break
|
||||
const ds = fmt(d)
|
||||
dCost += dep.days[ds]?.cost ?? 0
|
||||
}
|
||||
wages += dCost
|
||||
deptWages[dep.department_id] = dCost
|
||||
}
|
||||
|
||||
let sales = 0, pySales = 0
|
||||
for (let i = 0; i <= 6; i++) {
|
||||
const ds = fmt(addDays(wStart, i))
|
||||
sales += salesByDate[ds]?.sales ?? 0
|
||||
pySales += salesByDate[ds]?.py ?? 0
|
||||
}
|
||||
|
||||
// Pro-rata budget
|
||||
const monStr = `${wStart.getFullYear()}-${String(wStart.getMonth() + 1).padStart(2, '0')}-01`
|
||||
const monthBudget = budgetMap[monStr]
|
||||
const budget = monthBudget != null
|
||||
? monthBudget * (isPartial ? (Math.ceil((today.getTime() - wStart.getTime()) / 86_400_000) + 1) : 7) / daysInMonth(wStart)
|
||||
: null
|
||||
|
||||
const label = `w/e ${wEnd.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}`
|
||||
tableRows.push({ label, wages, budget, sales, pySales, partial: isPartial })
|
||||
|
||||
const cdRow: Record<string, number | string> = { label }
|
||||
for (const dep of depts) cdRow[dep.department_name] = deptWages[dep.department_id] ?? 0
|
||||
cdRow._wages = wages
|
||||
cdRow._budget = budget ?? 0
|
||||
cData.push(cdRow)
|
||||
}
|
||||
|
||||
setRows(tableRows)
|
||||
setChartData(cData)
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const today = new Date()
|
||||
const rangeStart = addDays(startOfWeek(today), -12 * 7)
|
||||
const rangeEnd = addDays(startOfWeek(today), 6)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Rolling 12 Weeks</h1>
|
||||
<button className="btn btn-secondary" onClick={() => downloadExport('rolling-weeks', fmt(rangeStart), fmt(rangeEnd))}>
|
||||
<Download size={14} strokeWidth={1.75} /> CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && <div className="state-center">Loading…</div>}
|
||||
{error && <div className="state-center" style={{ color: '#dc2626' }}>{error}</div>}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<div className="card">
|
||||
<div className="card-title">Wages by Department (weekly)</div>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={chartData} margin={{ top: 4, right: 16, left: 16, bottom: 0 }}>
|
||||
<XAxis dataKey="label" tick={{ fontSize: 10 }} />
|
||||
<YAxis tickFormatter={v => `£${Math.round(v / 1000)}k`} tick={{ fontSize: 11 }} width={55} />
|
||||
<Tooltip formatter={(v: number) => fmtMoney(v)} />
|
||||
<Legend />
|
||||
{deptCols.map(dep => (
|
||||
<Bar key={dep.id} dataKey={dep.name} stackId="a" fill={dep.color} />
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Week</th>
|
||||
<th className="right">Total Wages</th>
|
||||
<th className="right">Budget</th>
|
||||
<th className="right">Var vs Budget</th>
|
||||
<th className="right">% Budget</th>
|
||||
<th className="right">Net Sales</th>
|
||||
<th className="right">% Net Sales</th>
|
||||
<th className="right">PY Net Sales</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => {
|
||||
const pctB = r.budget != null && r.budget > 0 ? (r.wages / r.budget) * 100 : null
|
||||
const pctS = r.sales > 0 ? (r.wages / r.sales) * 100 : null
|
||||
const vari = r.budget != null ? r.wages - r.budget : null
|
||||
return (
|
||||
<tr key={i} style={r.partial ? { opacity: 0.7 } : {}}>
|
||||
<td>
|
||||
{r.label}
|
||||
{r.partial && <span className="partial-badge">current</span>}
|
||||
</td>
|
||||
<td className="right">{fmtMoney(r.wages)}</td>
|
||||
<td className="right">{r.budget != null ? fmtMoney(r.budget) : '—'}</td>
|
||||
<td className="right">
|
||||
{vari != null && (
|
||||
<span className={vari > 0 ? 'variance-over' : 'variance-under'}>
|
||||
{vari > 0 ? '+' : ''}{fmtMoney(vari)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="right">
|
||||
{pctB != null ? <span className={`pct-badge ${pctClass(pctB)}`}>{pctB.toFixed(1)}%</span> : '—'}
|
||||
</td>
|
||||
<td className="right">{r.sales > 0 ? fmtMoney(r.sales) : '—'}</td>
|
||||
<td className="right">{pctS != null ? `${pctS.toFixed(1)}%` : '—'}</td>
|
||||
<td className="right">{r.pySales > 0 ? fmtMoney(r.pySales) : '—'}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
275
frontend/src/pages/Settings.tsx
Normal file
275
frontend/src/pages/Settings.tsx
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { RefreshCw, Download, X, CheckSquare, Square } from 'lucide-react'
|
||||
import { getSettings, saveSettings, getDepartments, triggerSync, getSyncStatus, cancelBackfill } from '../api'
|
||||
import type { AppSetting, Department } from '../types'
|
||||
|
||||
function fmtDate(iso: string | null): string {
|
||||
if (!iso) return 'Never'
|
||||
return new Date(iso).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [settings, setSettings] = useState<Record<string, string>>({})
|
||||
const [depts, setDepts] = useState<Department[]>([])
|
||||
const [syncStatus, setSyncStatus] = useState<{ sync_last_at: string | null; backfill_last_at: string | null; backfill_running: boolean } | null>(null)
|
||||
const [backfillProg, setBackfillProg] = useState<{ processed: number; total: number; current: string } | null>(null)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [fetchingDepts, setFetchingDepts] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([getSettings(), getSyncStatus()])
|
||||
.then(([settRes, statusRes]) => {
|
||||
const map: Record<string, string> = {}
|
||||
for (const s of settRes.settings as AppSetting[]) map[s.key] = s.value
|
||||
setSettings(map)
|
||||
setSyncStatus(statusRes)
|
||||
|
||||
// Parse saved departments if present
|
||||
if (map.departments) {
|
||||
try { setDepts(JSON.parse(map.departments)) } catch { /* ignore */ }
|
||||
}
|
||||
})
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleChange = (key: string, value: string) => {
|
||||
setSettings(s => ({ ...s, [key]: value }))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setError(null)
|
||||
try {
|
||||
const deptsJson = depts.length > 0 ? JSON.stringify(depts) : ''
|
||||
await saveSettings([
|
||||
{ key: 'forecasting_url', value: settings.forecasting_url ?? '' },
|
||||
{ key: 'forecasting_api_key', value: settings.forecasting_api_key ?? '' },
|
||||
{ key: 'show_oncosts', value: settings.show_oncosts ?? 'true' },
|
||||
{ key: 'departments', value: deptsJson },
|
||||
])
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Save failed')
|
||||
}
|
||||
}
|
||||
|
||||
const handleFetchDepts = async () => {
|
||||
setFetchingDepts(true); setError(null)
|
||||
try {
|
||||
const res = await getDepartments()
|
||||
// Merge with existing enabled state
|
||||
const existing = Object.fromEntries(depts.map(d => [d.id, d.enabled]))
|
||||
const merged = res.departments.map(d => ({
|
||||
...d,
|
||||
enabled: existing[d.id] ?? true,
|
||||
}))
|
||||
setDepts(merged)
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to fetch departments')
|
||||
} finally {
|
||||
setFetchingDepts(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleDept = (id: string) => {
|
||||
setDepts(ds => ds.map(d => d.id === id ? { ...d, enabled: d.enabled === false } : d))
|
||||
}
|
||||
|
||||
const toggleAll = (enabled: boolean) => {
|
||||
setDepts(ds => ds.map(d => ({ ...d, enabled })))
|
||||
}
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncing(true); setError(null)
|
||||
try {
|
||||
const res = await triggerSync()
|
||||
setSyncStatus(s => s ? { ...s, sync_last_at: new Date().toISOString() } : s)
|
||||
alert(`Sync complete — ${res.actual_rows} actual rows, ${res.scheduled_rows} scheduled rows`)
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Sync failed')
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBackfill = async () => {
|
||||
if (!confirm('Start deep backfill? This will fetch ~13 months of Workforce data and may take a few minutes.')) return
|
||||
setBackfillProg({ processed: 0, total: 1, current: '…' })
|
||||
setError(null)
|
||||
|
||||
const es = new EventSource('/wages/api/sync/backfill', { withCredentials: true })
|
||||
|
||||
const doPost = () => {
|
||||
fetch('/wages/api/sync/backfill', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
}).catch(() => {})
|
||||
}
|
||||
doPost()
|
||||
|
||||
es.onmessage = (e) => {
|
||||
const data = JSON.parse(e.data)
|
||||
if (data.done) {
|
||||
es.close()
|
||||
setBackfillProg(null)
|
||||
setSyncStatus(s => s ? { ...s, backfill_last_at: new Date().toISOString() } : s)
|
||||
} else if (data.error) {
|
||||
es.close()
|
||||
setError(data.error)
|
||||
setBackfillProg(null)
|
||||
} else {
|
||||
setBackfillProg(data)
|
||||
}
|
||||
}
|
||||
es.onerror = () => { es.close(); setBackfillProg(null) }
|
||||
}
|
||||
|
||||
const handleCancelBackfill = async () => {
|
||||
await cancelBackfill()
|
||||
setBackfillProg(null)
|
||||
}
|
||||
|
||||
if (loading) return <div className="state-center">Loading…</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Settings</h1>
|
||||
<button className="btn btn-primary" onClick={handleSave}>
|
||||
{saved ? 'Saved!' : 'Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div style={{ color: '#dc2626', marginBottom: 16, padding: '8px 12px', background: '#fee2e2', borderRadius: 6 }}>{error}</div>}
|
||||
|
||||
{/* Forecasting API */}
|
||||
<div className="card">
|
||||
<div className="card-title">Net Sales — Forecasting API</div>
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, marginBottom: 4, color: 'var(--text-muted)' }}>
|
||||
Forecasting URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.forecasting_url ?? ''}
|
||||
onChange={e => handleChange('forecasting_url', e.target.value)}
|
||||
placeholder="http://10.10.10.113:3080"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, marginBottom: 4, color: 'var(--text-muted)' }}>
|
||||
API Key
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.forecasting_api_key ?? ''}
|
||||
onChange={e => handleChange('forecasting_api_key', e.target.value)}
|
||||
placeholder="fk_…"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* On-costs toggle */}
|
||||
<div className="card">
|
||||
<div className="card-title">Cost Display</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: 13 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.show_oncosts !== 'false'}
|
||||
onChange={e => handleChange('show_oncosts', e.target.checked ? 'true' : 'false')}
|
||||
/>
|
||||
Include estimated employer on-costs (NI) in displayed figures
|
||||
</label>
|
||||
<p style={{ margin: '8px 0 0', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
When enabled, all wage figures include Workforce-estimated employer contributions. Final payroll is in Sage.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Department filter */}
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<div className="card-title" style={{ margin: 0 }}>Department Filter</div>
|
||||
<button className="btn btn-secondary" onClick={handleFetchDepts} disabled={fetchingDepts}>
|
||||
<RefreshCw size={14} strokeWidth={1.75} />
|
||||
{fetchingDepts ? 'Fetching…' : 'Fetch from Workforce'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{depts.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>
|
||||
Click "Fetch from Workforce" to load departments. All will be enabled by default.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
<button className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => toggleAll(true)}>Select all</button>
|
||||
<button className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => toggleAll(false)}>Deselect all</button>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 6 }}>
|
||||
{depts.map(d => (
|
||||
<label key={d.id} style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13, padding: '6px 8px', borderRadius: 6, background: d.enabled !== false ? 'var(--body-bg)' : 'transparent' }}>
|
||||
<span onClick={() => toggleDept(d.id)}>
|
||||
{d.enabled !== false
|
||||
? <CheckSquare size={16} strokeWidth={1.75} color="var(--app-primary)" />
|
||||
: <Square size={16} strokeWidth={1.75} color="var(--text-muted)" />}
|
||||
</span>
|
||||
{d.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<p style={{ marginTop: 10, fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
Unticked departments are excluded from all reports and sync. Save Settings to apply.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sync */}
|
||||
<div className="card">
|
||||
<div className="card-title">Data Sync</div>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginBottom: 16 }}>
|
||||
<button className="btn btn-primary" onClick={handleSync} disabled={syncing || backfillProg != null}>
|
||||
<RefreshCw size={14} strokeWidth={1.75} />
|
||||
{syncing ? 'Syncing…' : 'Sync Now (35 days)'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={handleBackfill} disabled={syncing || backfillProg != null}>
|
||||
<Download size={14} strokeWidth={1.75} />
|
||||
Deep Backfill (13 months)
|
||||
</button>
|
||||
{backfillProg && (
|
||||
<button className="btn btn-secondary" onClick={handleCancelBackfill}>
|
||||
<X size={14} strokeWidth={1.75} /> Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{backfillProg && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
|
||||
<span>Fetching {backfillProg.current}…</span>
|
||||
<span>{backfillProg.processed} / {backfillProg.total} days</span>
|
||||
</div>
|
||||
<div style={{ height: 6, background: 'var(--border)', borderRadius: 3 }}>
|
||||
<div style={{ height: '100%', width: `${Math.min(100, (backfillProg.processed / backfillProg.total) * 100)}%`, background: 'var(--app-primary)', borderRadius: 3, transition: 'width 0.3s' }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', display: 'grid', gap: 4 }}>
|
||||
<div>Last sync: <strong>{fmtDate(syncStatus?.sync_last_at ?? null)}</strong></div>
|
||||
<div>Last backfill: <strong>{fmtDate(syncStatus?.backfill_last_at ?? null)}</strong></div>
|
||||
</div>
|
||||
<p style={{ marginTop: 8, fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
Sync Now pulls the last 35 days of timesheets + next 14 days of schedules. Auto-sync runs every hour.
|
||||
Deep Backfill fetches the full 13-month history at 250ms per week to avoid rate limits.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
206
frontend/src/pages/Weekly.tsx
Normal file
206
frontend/src/pages/Weekly.tsx
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { ChevronLeft, ChevronRight, Download } from 'lucide-react'
|
||||
import { getActuals, getNetSales, getBudgets, downloadExport } from '../api'
|
||||
import type { DeptActuals, WageBudget } from '../types'
|
||||
|
||||
function startOfWeek(d: Date): Date {
|
||||
const day = d.getDay()
|
||||
const diff = (day === 0 ? -6 : 1 - day) // Mon = start
|
||||
const r = new Date(d)
|
||||
r.setDate(d.getDate() + diff)
|
||||
r.setHours(0, 0, 0, 0)
|
||||
return r
|
||||
}
|
||||
|
||||
function addDays(d: Date, n: number): Date {
|
||||
const r = new Date(d)
|
||||
r.setDate(r.getDate() + n)
|
||||
return r
|
||||
}
|
||||
|
||||
function fmt(d: Date): string { return d.toISOString().slice(0, 10) }
|
||||
function fmtMoney(n: number): string { return `£${n.toLocaleString('en-GB', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}` }
|
||||
function daysInMonth(date: Date): number { return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate() }
|
||||
|
||||
function pctClass(pct: number | null): string {
|
||||
if (pct == null) return ''
|
||||
if (pct <= 100) return 'pct-green'
|
||||
if (pct <= 110) return 'pct-amber'
|
||||
return 'pct-red'
|
||||
}
|
||||
|
||||
export default function Weekly() {
|
||||
const [weekStart, setWeekStart] = useState<Date>(() => startOfWeek(new Date()))
|
||||
const [depts, setDepts] = useState<DeptActuals[]>([])
|
||||
const [netSales, setNetSales] = useState(0)
|
||||
const [pySales, setPySales] = useState(0)
|
||||
const [budget, setBudget] = useState<number | null>(null)
|
||||
const [showOncosts, setShowOncosts] = useState(true)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const weekEnd = addDays(weekStart, 6)
|
||||
const fromStr = fmt(weekStart)
|
||||
const toStr = fmt(weekEnd)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError(null)
|
||||
try {
|
||||
const [actRes, salesRes, budgetRes] = await Promise.all([
|
||||
getActuals(fromStr, toStr),
|
||||
getNetSales(fromStr, toStr),
|
||||
getBudgets(),
|
||||
])
|
||||
setDepts(actRes.departments)
|
||||
setShowOncosts(actRes.show_oncosts)
|
||||
|
||||
const totalSales = salesRes.days.reduce((s, d) => s + d.net_sales, 0)
|
||||
const totalPY = salesRes.days.reduce((s, d) => s + d.py_sales, 0)
|
||||
setNetSales(totalSales)
|
||||
setPySales(totalPY)
|
||||
|
||||
// Find budget for the month of weekStart
|
||||
const monthKey = `${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, '0')}-01`
|
||||
const bRow = (budgetRes.budgets as WageBudget[]).find(b => b.month === monthKey)
|
||||
if (bRow) {
|
||||
const dim = daysInMonth(weekStart)
|
||||
// Pro-rata: days in the selected week ÷ days in month
|
||||
const today = new Date()
|
||||
let weekDays = 7
|
||||
if (weekStart <= today && today <= weekEnd) {
|
||||
weekDays = Math.ceil((today.getTime() - weekStart.getTime()) / 86_400_000) + 1
|
||||
}
|
||||
setBudget(bRow.budget_amount * (weekDays / dim))
|
||||
} else {
|
||||
setBudget(null)
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [fromStr, toStr, weekStart, weekEnd])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const prev = () => setWeekStart(d => addDays(d, -7))
|
||||
const next = () => setWeekStart(d => addDays(d, 7))
|
||||
const isCurrentWeek = fmt(startOfWeek(new Date())) === fmt(weekStart)
|
||||
|
||||
// Totals
|
||||
const deptTotals = depts.map(dep => {
|
||||
const cost = Object.values(dep.days).reduce((s, d) => s + d.cost, 0)
|
||||
return { department_name: dep.department_name, cost }
|
||||
}).sort((a, b) => b.cost - a.cost)
|
||||
|
||||
const totalWages = deptTotals.reduce((s, d) => s + d.cost, 0)
|
||||
const pctBudget = budget != null && budget > 0 ? (totalWages / budget) * 100 : null
|
||||
const pctSales = netSales > 0 ? (totalWages / netSales) * 100 : null
|
||||
|
||||
const weekLabel = `${weekStart.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })} – ${weekEnd.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}`
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Weekly Wages</h1>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button className="btn btn-secondary" onClick={() => downloadExport('weekly', fromStr, toStr)}>
|
||||
<Download size={14} strokeWidth={1.75} /> CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="period-nav" style={{ marginBottom: 20 }}>
|
||||
<button className="btn btn-secondary" onClick={prev}><ChevronLeft size={16} strokeWidth={1.75} /></button>
|
||||
<span className="period-label">{weekLabel}</span>
|
||||
<button className="btn btn-secondary" onClick={next} disabled={isCurrentWeek}><ChevronRight size={16} strokeWidth={1.75} /></button>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="summary-grid">
|
||||
<div className="summary-card">
|
||||
<div className="label">Total Wages</div>
|
||||
<div className="value">{fmtMoney(totalWages)}</div>
|
||||
<div className="sub">{showOncosts ? 'incl. on-costs' : 'base cost'}</div>
|
||||
</div>
|
||||
<div className="summary-card">
|
||||
<div className="label">Pro-rata Budget</div>
|
||||
<div className="value">{budget != null ? fmtMoney(budget) : '—'}</div>
|
||||
<div className="sub">proportion of monthly</div>
|
||||
</div>
|
||||
<div className="summary-card">
|
||||
<div className="label">% vs Budget</div>
|
||||
<div className="value">
|
||||
{pctBudget != null
|
||||
? <span className={`pct-badge ${pctClass(pctBudget)}`}>{pctBudget.toFixed(1)}%</span>
|
||||
: '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="summary-card">
|
||||
<div className="label">Net Sales</div>
|
||||
<div className="value">{fmtMoney(netSales)}</div>
|
||||
{pySales > 0 && <div className="sub">PY {fmtMoney(pySales)}</div>}
|
||||
</div>
|
||||
<div className="summary-card">
|
||||
<div className="label">% of Net Sales</div>
|
||||
<div className="value">{pctSales != null ? `${pctSales.toFixed(1)}%` : '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && <div className="state-center">Loading…</div>}
|
||||
{error && <div className="state-center" style={{ color: '#dc2626' }}>{error}</div>}
|
||||
|
||||
{!loading && !error && (
|
||||
<div className="card">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Department</th>
|
||||
<th className="right">Wages</th>
|
||||
<th className="right">Budget (pro-rata)</th>
|
||||
<th className="right">% Budget</th>
|
||||
<th className="right">Net Sales</th>
|
||||
<th className="right">% Net Sales</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{deptTotals.map(dep => {
|
||||
const depPct = budget != null && budget > 0 ? (dep.cost / budget) * 100 : null
|
||||
const depSPct = netSales > 0 ? (dep.cost / netSales) * 100 : null
|
||||
return (
|
||||
<tr key={dep.department_name}>
|
||||
<td>{dep.department_name}</td>
|
||||
<td className="right">{fmtMoney(dep.cost)}</td>
|
||||
<td className="right">—</td>
|
||||
<td className="right">
|
||||
{depPct != null
|
||||
? <span className={`pct-badge ${pctClass(depPct)}`}>{depPct.toFixed(1)}%</span>
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="right">{fmtMoney(netSales)}</td>
|
||||
<td className="right">{depSPct != null ? `${depSPct.toFixed(1)}%` : '—'}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
<tr className="total-row">
|
||||
<td>Total</td>
|
||||
<td className="right">{fmtMoney(totalWages)}</td>
|
||||
<td className="right">{budget != null ? fmtMoney(budget) : '—'}</td>
|
||||
<td className="right">
|
||||
{pctBudget != null
|
||||
? <span className={`pct-badge ${pctClass(pctBudget)}`}>{pctBudget.toFixed(1)}%</span>
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="right">{fmtMoney(netSales)}</td>
|
||||
<td className="right">{pctSales != null ? `${pctSales.toFixed(1)}%` : '—'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{showOncosts && (
|
||||
<p className="footnote">Includes estimated employer on-costs. Final payroll figures are in Sage.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
49
frontend/src/types.ts
Normal file
49
frontend/src/types.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
export interface User {
|
||||
email: string
|
||||
name: string
|
||||
is_admin: boolean
|
||||
caps: string[]
|
||||
}
|
||||
|
||||
export function can(user: User, cap: string): boolean {
|
||||
return user.is_admin || user.caps.includes(cap)
|
||||
}
|
||||
|
||||
export interface DeptActuals {
|
||||
department_id: string
|
||||
department_name: string
|
||||
days: Record<string, { base_cost: number; total_cost: number; cost: number; shift_count: number }>
|
||||
}
|
||||
|
||||
export interface DeptScheduled {
|
||||
department_id: string
|
||||
department_name: string
|
||||
days: Record<string, { cost: number; shift_count: number }>
|
||||
}
|
||||
|
||||
export interface NetSalesDay {
|
||||
date: string
|
||||
net_sales: number
|
||||
py_sales: number
|
||||
accom: number
|
||||
dry: number
|
||||
wet: number
|
||||
is_past: boolean
|
||||
}
|
||||
|
||||
export interface WageBudget {
|
||||
month: string // 'YYYY-MM-DD' (first of month)
|
||||
budget_amount: number
|
||||
}
|
||||
|
||||
export interface Department {
|
||||
id: string
|
||||
name: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export interface AppSetting {
|
||||
key: string
|
||||
value: string
|
||||
updated_at: string
|
||||
}
|
||||
19
frontend/tsconfig.json
Normal file
19
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
31
frontend/vite.config.ts
Normal file
31
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
export default defineConfig({
|
||||
base: '/wages/',
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
manifest: {
|
||||
name: 'Wage Costs',
|
||||
short_name: 'Wages',
|
||||
start_url: '/wages/',
|
||||
scope: '/wages/',
|
||||
display: 'standalone',
|
||||
theme_color: '#065f46',
|
||||
background_color: '#065f46',
|
||||
icons: [
|
||||
{ src: '/wages/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: '/wages/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
navigateFallback: '/wages/index.html',
|
||||
navigateFallbackDenylist: [/\/api\//],
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue