Compare commits
No commits in common. "09a8158a1e854d6e5a864e959805322a26b30fa5" and "c7066521ff4a2425a827dfbcd0b845d8813a6197" have entirely different histories.
09a8158a1e
...
c7066521ff
17 changed files with 33 additions and 11170 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,3 +0,0 @@
|
|||
node_modules/
|
||||
frontend/node_modules/
|
||||
backend/node_modules/
|
||||
|
|
@ -58,7 +58,6 @@ export async function workforceRoutes(app) {
|
|||
await upsertWorkforceShifts(dailyRows)
|
||||
return { ok: true, from, to, dates_synced: dailyRows.length }
|
||||
} catch (err) {
|
||||
req.log.error({ err: err.message }, 'workforce sync failed')
|
||||
const status = err.message.includes('not configured') ? 503 : 502
|
||||
return reply.status(status).send({ error: err.message })
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
2
frontend/dist/index.html
vendored
2
frontend/dist/index.html
vendored
|
|
@ -5,7 +5,7 @@
|
|||
<meta name="viewport" content="width=1280" />
|
||||
<meta name="theme-color" content="#2d6a4f" />
|
||||
<title>HK Planner</title>
|
||||
<script type="module" crossorigin src="/hk-planner/assets/index-C1fxqzme.js"></script>
|
||||
<script type="module" crossorigin src="/hk-planner/assets/index-BM62SqjA.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/hk-planner/assets/index-B7_UXJgZ.css">
|
||||
<link rel="manifest" href="/hk-planner/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/hk-planner/registerSW.js"></script></head>
|
||||
<body>
|
||||
|
|
|
|||
2
frontend/dist/sw.js
vendored
2
frontend/dist/sw.js
vendored
|
|
@ -1 +1 @@
|
|||
if(!self.define){let e,i={};const n=(n,s)=>(n=new URL(n+".js",s).href,i[n]||new Promise(i=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=i,document.head.appendChild(e)}else e=n,importScripts(n),i()}).then(()=>{let e=i[n];if(!e)throw new Error(`Module ${n} didn’t register its module`);return e}));self.define=(s,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(i[o])return;let t={};const c=e=>n(e,o),d={module:{uri:o},exports:t,require:c};i[o]=Promise.all(s.map(e=>d[e]||c(e))).then(e=>(r(...e),t))}}define(["./workbox-9c191d2f"],function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"registerSW.js",revision:"a2c395d8c225f1b3ea12388f15189bce"},{url:"index.html",revision:"69c09c935ebcd7adcba85b212773b6f6"},{url:"icons/icon-512.png",revision:"c32202b9deed67ef38331f63dec9d1c8"},{url:"icons/icon-192.png",revision:"46ece317d50d10b8f5e225e073b3221d"},{url:"assets/index-C1fxqzme.js",revision:null},{url:"assets/index-B7_UXJgZ.css",revision:null},{url:"manifest.webmanifest",revision:"c2510de876adb84db0c4b300b71216fa"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/hk-planner/index.html"),{denylist:[/\/api\//]}))});
|
||||
if(!self.define){let e,i={};const n=(n,s)=>(n=new URL(n+".js",s).href,i[n]||new Promise(i=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=i,document.head.appendChild(e)}else e=n,importScripts(n),i()}).then(()=>{let e=i[n];if(!e)throw new Error(`Module ${n} didn’t register its module`);return e}));self.define=(s,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(i[o])return;let t={};const c=e=>n(e,o),l={module:{uri:o},exports:t,require:c};i[o]=Promise.all(s.map(e=>l[e]||c(e))).then(e=>(r(...e),t))}}define(["./workbox-9c191d2f"],function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"registerSW.js",revision:"a2c395d8c225f1b3ea12388f15189bce"},{url:"index.html",revision:"46079111fca52b3927026fe671b56a2f"},{url:"icons/icon-512.png",revision:"c32202b9deed67ef38331f63dec9d1c8"},{url:"icons/icon-192.png",revision:"46ece317d50d10b8f5e225e073b3221d"},{url:"assets/index-BM62SqjA.js",revision:null},{url:"assets/index-B7_UXJgZ.css",revision:null},{url:"manifest.webmanifest",revision:"c2510de876adb84db0c4b300b71216fa"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/hk-planner/index.html"),{denylist:[/\/api\//]}))});
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthGate } from './components/AuthGate';
|
||||
import { UpdateBanner } from './components/UpdateBanner';
|
||||
import { useVersionCheck } from './hooks/useVersionCheck';
|
||||
import { Layout } from './components/Layout';
|
||||
import { Planner } from './pages/Planner';
|
||||
import { Settings } from './pages/CategorySettings';
|
||||
import { can } from './types';
|
||||
function AppRoutes({ user }) {
|
||||
return (_jsx(Layout, { user: user, children: _jsxs(Routes, { children: [_jsx(Route, { path: "/", element: _jsx(Navigate, { to: "/planner", replace: true }) }), _jsx(Route, { path: "/planner", element: _jsx(Planner, {}) }), _jsx(Route, { path: "/settings", element: can(user, 'settings') ? _jsx(Settings, {}) : _jsx(Navigate, { to: "/planner", replace: true }) }), _jsx(Route, { path: "*", element: _jsx(Navigate, { to: "/planner", replace: true }) })] }) }));
|
||||
}
|
||||
export default function App() {
|
||||
const updateAvailable = useVersionCheck('/hk-planner/health');
|
||||
return (_jsxs(_Fragment, { children: [_jsx(BrowserRouter, { basename: "/hk-planner", children: _jsx(AuthGate, { children: user => _jsx(AppRoutes, { user: user }) }) }), _jsx(UpdateBanner, { visible: updateAvailable })] }));
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
const BASE = '/hk-planner/api';
|
||||
async function request(path, opts) {
|
||||
const res = await fetch(BASE + path, { credentials: 'include', ...opts });
|
||||
if (res.status === 401) {
|
||||
;
|
||||
(window.top ?? window).location.href = '/login';
|
||||
throw new Error('Unauthenticated');
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
export function getBookings(weekStart, lastViewed, forceRefresh = false) {
|
||||
const p = new URLSearchParams();
|
||||
if (weekStart)
|
||||
p.set('week_start', weekStart);
|
||||
if (lastViewed)
|
||||
p.set('last_viewed', lastViewed);
|
||||
if (forceRefresh)
|
||||
p.set('force_refresh', '1');
|
||||
return request(`/bookings?${p}`);
|
||||
}
|
||||
export function getConfig() {
|
||||
return request('/config');
|
||||
}
|
||||
export function putTimeReq(cat, action, value) {
|
||||
return request('/config/time-requirements', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cat, action, value }),
|
||||
});
|
||||
}
|
||||
export function putStaff(staff_data) {
|
||||
return request('/config/staff', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ staff_data }),
|
||||
});
|
||||
}
|
||||
export function putPickup(pickup_data) {
|
||||
return request('/config/pickup', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pickup_data }),
|
||||
});
|
||||
}
|
||||
export function putGeneralTasks(general_tasks) {
|
||||
return request('/config/general-tasks', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ general_tasks }),
|
||||
});
|
||||
}
|
||||
export function putLastReviewed(date) {
|
||||
return request('/config/last-reviewed', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ date }),
|
||||
});
|
||||
}
|
||||
export function getCategories() {
|
||||
return request('/categories');
|
||||
}
|
||||
export function putCategories(order, excluded) {
|
||||
return request('/categories', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ order, excluded }),
|
||||
});
|
||||
}
|
||||
export function testNewbook() {
|
||||
return request('/newbook/test', { method: 'POST' });
|
||||
}
|
||||
export function getWorkforceDepartments() {
|
||||
return request('/workforce/departments');
|
||||
}
|
||||
export function putWorkforceDepartments(dept_ids) {
|
||||
return request('/config/workforce-departments', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dept_ids }),
|
||||
});
|
||||
}
|
||||
export function syncWorkforce() {
|
||||
return request('/workforce/sync', { method: 'POST' });
|
||||
}
|
||||
export function getWorkforceShifts(start, end) {
|
||||
return request(`/workforce/shifts?start=${start}&end=${end}`);
|
||||
}
|
||||
export function getWorkforceStaff() {
|
||||
return request('/workforce/staff');
|
||||
}
|
||||
export function putAdjustments(adjustments) {
|
||||
return request('/config/adjustments', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ adjustments }),
|
||||
});
|
||||
}
|
||||
export function putWarningThresholds(thresholds) {
|
||||
return request('/config/warning-thresholds', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(thresholds),
|
||||
});
|
||||
}
|
||||
|
|
@ -2,22 +2,20 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|||
import { NavLink } from 'react-router-dom';
|
||||
import { CalendarClock, Settings, LogOut } from 'lucide-react';
|
||||
import { can } from '../types';
|
||||
import { useFrameViewport } from '../hooks/useFrameViewport';
|
||||
export function Layout({ user, children }) {
|
||||
useFrameViewport('desktop');
|
||||
async function logout() {
|
||||
await fetch('/hk-planner/api/auth/logout', { method: 'POST', credentials: 'include' });
|
||||
window.location.reload();
|
||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
|
||||
window.location.href = '/';
|
||||
}
|
||||
return (_jsxs("div", { style: { display: 'flex', height: '100dvh', overflow: 'hidden' }, children: [_jsxs("nav", { style: {
|
||||
width: '200px', flexShrink: 0, background: 'var(--navy)',
|
||||
display: 'flex', flexDirection: 'column', padding: '1rem 0',
|
||||
borderRight: '1px solid var(--surface-2)',
|
||||
}, children: [_jsx("div", { style: { padding: '0 1rem 1rem', borderBottom: '1px solid var(--surface-2)' }, children: _jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: '0.5rem' }, children: [_jsx(CalendarClock, { size: 20, strokeWidth: 1.75, color: "#74c69d" }), _jsx("span", { style: { color: '#74c69d', fontWeight: 700, fontSize: '0.95rem' }, children: "HK Planner" })] }) }), _jsxs("div", { className: "nav-scroll", style: { flex: 1, padding: '0.5rem 0', overflowY: 'auto' }, children: [_jsx(NavItem, { to: "/planner", icon: CalendarClock, label: "Planner" }), can(user, 'settings') && _jsx(NavItem, { to: "/settings", icon: Settings, label: "Settings" })] }), _jsxs("div", { style: { padding: '0.75rem 1rem', borderTop: '1px solid var(--surface-2)' }, children: [_jsxs("div", { style: { marginBottom: '0.5rem' }, children: [_jsx("div", { style: { color: 'var(--text)', fontSize: '0.8rem', fontWeight: 600 }, children: user.name }), _jsx("div", { style: { color: 'var(--text-muted)', fontSize: '0.72rem' }, children: user.email })] }), _jsxs("button", { onClick: logout, style: {
|
||||
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||||
background: 'none', border: 'none', color: 'var(--text-muted)',
|
||||
fontSize: '0.8rem', padding: '0.375rem 0', width: '100%', cursor: 'pointer',
|
||||
}, children: [_jsx(LogOut, { size: 14, strokeWidth: 1.75 }), "Sign out"] })] })] }), _jsx("main", { style: { flex: 1, overflow: 'auto', background: 'var(--body-bg)' }, children: children })] }));
|
||||
}, children: [_jsxs("div", { style: { padding: '0 1rem 1rem', borderBottom: '1px solid var(--surface-2)' }, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: '0.5rem' }, children: [_jsx(CalendarClock, { size: 20, strokeWidth: 1.75, color: "#74c69d" }), _jsx("span", { style: { color: '#74c69d', fontWeight: 700, fontSize: '0.95rem' }, children: "HK Planner" })] }), _jsx("p", { style: { color: 'var(--text-muted)', fontSize: '0.72rem', marginTop: '0.2rem' }, children: user.name })] }), _jsxs("div", { className: "nav-scroll", style: { flex: 1, padding: '0.5rem 0', overflowY: 'auto' }, children: [_jsx(NavItem, { to: "/planner", icon: CalendarClock, label: "Planner" }), can(user, 'settings') && _jsx(NavItem, { to: "/settings", icon: Settings, label: "Settings" })] }), _jsx("div", { style: { padding: '0.75rem 1rem', borderTop: '1px solid var(--surface-2)' }, children: _jsxs("button", { onClick: logout, style: {
|
||||
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||||
background: 'none', border: 'none', color: 'var(--text-muted)',
|
||||
fontSize: '0.875rem', padding: '0.375rem 0', width: '100%', cursor: 'pointer',
|
||||
}, children: [_jsx(LogOut, { size: 14, strokeWidth: 1.75 }), "Sign out"] }) })] }), _jsx("main", { style: { flex: 1, overflow: 'auto', background: 'var(--body-bg)' }, children: children })] }));
|
||||
}
|
||||
function NavItem({ to, icon: Icon, label }) {
|
||||
return (_jsxs(NavLink, { to: to, style: ({ isActive }) => ({
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
export function UpdateBanner({ visible }) {
|
||||
if (!visible)
|
||||
return null;
|
||||
return (_jsxs("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)',
|
||||
}, children: [_jsx("span", { children: "A new version is available." }), _jsxs("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',
|
||||
}, children: [_jsx(RefreshCw, { size: 14, strokeWidth: 1.75 }), "Reload"] })] }));
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { useEffect } from 'react';
|
||||
const DESKTOP = 'width=1280';
|
||||
const RESPONSIVE = 'width=device-width, initial-scale=1.0';
|
||||
function setMetaViewport(content) {
|
||||
const meta = document.querySelector('meta[name="viewport"]');
|
||||
if (meta)
|
||||
meta.content = content;
|
||||
}
|
||||
export function useFrameViewport(mode) {
|
||||
useEffect(() => {
|
||||
setMetaViewport(mode === 'desktop' ? DESKTOP : RESPONSIVE);
|
||||
window.parent.postMessage({ type: 'hnf:viewport', mode }, '*');
|
||||
return () => {
|
||||
setMetaViewport(RESPONSIVE);
|
||||
window.parent.postMessage({ type: 'hnf:viewport', mode: 'responsive' }, '*');
|
||||
};
|
||||
}, [mode]);
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
const POLL_MS = 2 * 60 * 1000;
|
||||
export function useVersionCheck(healthUrl) {
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
useEffect(() => {
|
||||
let seenVersion = null;
|
||||
async function check() {
|
||||
try {
|
||||
const res = await fetch(healthUrl, { cache: 'no-store' });
|
||||
if (!res.ok)
|
||||
return;
|
||||
const data = await res.json();
|
||||
const v = data.version;
|
||||
if (!v)
|
||||
return;
|
||||
if (seenVersion === null) {
|
||||
seenVersion = v;
|
||||
}
|
||||
else if (v !== seenVersion) {
|
||||
setUpdateAvailable(true);
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// network error — skip silently
|
||||
}
|
||||
}
|
||||
check();
|
||||
const interval = setInterval(check, POLL_MS);
|
||||
function onVisible() {
|
||||
if (document.visibilityState === 'visible')
|
||||
check();
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
};
|
||||
}, [healthUrl]);
|
||||
return updateAvailable;
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { jsx as _jsx } from "react/jsx-runtime";
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
// When opened from the portal's install button (?install=1), trigger the
|
||||
// PWA install prompt as soon as the browser offers it (Chrome/Edge only).
|
||||
if (new URLSearchParams(window.location.search).has('install')) {
|
||||
window.addEventListener('beforeinstallprompt', e => {
|
||||
e.preventDefault();
|
||||
e.prompt();
|
||||
}, { once: true });
|
||||
}
|
||||
createRoot(document.getElementById('root')).render(_jsx(StrictMode, { children: _jsx(App, {}) }));
|
||||
|
|
@ -1,263 +0,0 @@
|
|||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState, useEffect } from 'react';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
import { getCategories, putCategories, testNewbook, getConfig, getWorkforceDepartments, putWorkforceDepartments, putGeneralTasks, putTimeReq, putWarningThresholds, } from '../api';
|
||||
const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
export function Settings() {
|
||||
// ── Category state ─────────────────────────────────────────────────────────
|
||||
const [cats, setCats] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [msg, setMsg] = useState('');
|
||||
const [dragIdx, setDragIdx] = useState(null);
|
||||
// ── Workforce state ────────────────────────────────────────────────────────
|
||||
const [wfDepts, setWfDepts] = useState([]);
|
||||
const [wfSelected, setWfSelected] = useState([]);
|
||||
const [wfLoading, setWfLoading] = useState(true);
|
||||
const [wfError, setWfError] = useState('');
|
||||
const [wfNotConfigured, setWfNotConfigured] = useState(false);
|
||||
const [wfSaving, setWfSaving] = useState(false);
|
||||
const [wfMsg, setWfMsg] = useState('');
|
||||
// ── Recurring general tasks state ──────────────────────────────────────────
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [taskSaving, setTaskSaving] = useState(false);
|
||||
const [taskMsg, setTaskMsg] = useState('');
|
||||
const [taskError, setTaskError] = useState('');
|
||||
// ── Time requirements state ────────────────────────────────────────────────
|
||||
const [timeReqs, setTimeReqs] = useState({});
|
||||
const [timeMsg, setTimeMsg] = useState('');
|
||||
const [timeError, setTimeError] = useState('');
|
||||
// ── Warning threshold state ────────────────────────────────────────────────
|
||||
const [warnOverRed, setWarnOverRed] = useState(4);
|
||||
const [warnOverAmber, setWarnOverAmber] = useState(1);
|
||||
const [warnUnderAmber, setWarnUnderAmber] = useState(1);
|
||||
const [warnUnderRed, setWarnUnderRed] = useState(2);
|
||||
const [warnSaving, setWarnSaving] = useState(false);
|
||||
const [warnMsg, setWarnMsg] = useState('');
|
||||
const [warnError, setWarnError] = useState('');
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
getCategories(),
|
||||
getConfig(),
|
||||
getWorkforceDepartments().catch((e) => {
|
||||
if (e.message.includes('503') || e.message.toLowerCase().includes('not configured')) {
|
||||
setWfNotConfigured(true);
|
||||
}
|
||||
else {
|
||||
setWfError(e.message);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
]).then(([catResp, cfg, depts]) => {
|
||||
setCats(catResp.categories);
|
||||
setTasks(cfg.general_tasks || []);
|
||||
setTimeReqs(cfg.time_requirements || {});
|
||||
setWfSelected(cfg.workforce_departments || []);
|
||||
setWarnOverRed(cfg.warn_over_red_hrs ?? 4);
|
||||
setWarnOverAmber(cfg.warn_over_amber_hrs ?? 1);
|
||||
setWarnUnderAmber(cfg.warn_under_amber_hrs ?? 1);
|
||||
setWarnUnderRed(cfg.warn_under_red_hrs ?? 2);
|
||||
if (depts)
|
||||
setWfDepts(depts);
|
||||
setLoading(false);
|
||||
setWfLoading(false);
|
||||
}).catch(e => {
|
||||
setError(e.message);
|
||||
setLoading(false);
|
||||
setWfLoading(false);
|
||||
});
|
||||
}, []);
|
||||
// ── Category handlers ──────────────────────────────────────────────────────
|
||||
function toggleExcluded(i) {
|
||||
setCats(cats.map((c, idx) => idx === i ? { ...c, excluded: !c.excluded } : c));
|
||||
}
|
||||
function onDragStart(e, i) {
|
||||
setDragIdx(i);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
function onDragOver(e, i) {
|
||||
e.preventDefault();
|
||||
if (dragIdx === null || dragIdx === i)
|
||||
return;
|
||||
const next = [...cats];
|
||||
const [moved] = next.splice(dragIdx, 1);
|
||||
next.splice(i, 0, moved);
|
||||
setCats(next);
|
||||
setDragIdx(i);
|
||||
}
|
||||
function onDragEnd() { setDragIdx(null); }
|
||||
async function saveCats() {
|
||||
setSaving(true);
|
||||
setError('');
|
||||
setMsg('');
|
||||
try {
|
||||
await putCategories(cats.map(c => c.id), cats.filter(c => c.excluded).map(c => c.id));
|
||||
setMsg('Saved');
|
||||
setTimeout(() => setMsg(''), 2500);
|
||||
}
|
||||
catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Save failed');
|
||||
}
|
||||
finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
async function handleTest() {
|
||||
setTesting(true);
|
||||
setError('');
|
||||
setMsg('');
|
||||
try {
|
||||
const res = await testNewbook();
|
||||
setMsg(res.ok ? `Connection OK: ${res.message || ''}` : `Failed: ${res.error || 'unknown'}`);
|
||||
}
|
||||
catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Test failed');
|
||||
}
|
||||
finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
// ── Workforce handlers ─────────────────────────────────────────────────────
|
||||
function toggleWfDept(id) {
|
||||
setWfSelected(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
|
||||
}
|
||||
async function saveWfDepts() {
|
||||
setWfSaving(true);
|
||||
setWfError('');
|
||||
setWfMsg('');
|
||||
try {
|
||||
await putWorkforceDepartments(wfSelected);
|
||||
setWfMsg('Departments saved');
|
||||
setTimeout(() => setWfMsg(''), 2500);
|
||||
}
|
||||
catch (e) {
|
||||
setWfError(e instanceof Error ? e.message : 'Save failed');
|
||||
}
|
||||
finally {
|
||||
setWfSaving(false);
|
||||
}
|
||||
}
|
||||
// ── Recurring tasks handlers ───────────────────────────────────────────────
|
||||
function setTaskName(i, name) {
|
||||
setTasks(tasks.map((t, idx) => idx === i ? { ...t, name } : t));
|
||||
}
|
||||
function setTaskHours(i, day, val) {
|
||||
const mins = parseInt(val, 10);
|
||||
setTasks(tasks.map((t, idx) => {
|
||||
if (idx !== i)
|
||||
return t;
|
||||
return { ...t, hours: { ...t.hours, [day]: isNaN(mins) ? 0 : Math.max(0, mins) } };
|
||||
}));
|
||||
}
|
||||
function removeTask(i) {
|
||||
setTasks(tasks.filter((_, idx) => idx !== i));
|
||||
}
|
||||
async function saveTasks() {
|
||||
setTaskSaving(true);
|
||||
setTaskError('');
|
||||
setTaskMsg('');
|
||||
try {
|
||||
await putGeneralTasks(tasks);
|
||||
setTaskMsg('Saved');
|
||||
setTimeout(() => setTaskMsg(''), 2500);
|
||||
}
|
||||
catch (e) {
|
||||
setTaskError(e instanceof Error ? e.message : 'Save failed');
|
||||
}
|
||||
finally {
|
||||
setTaskSaving(false);
|
||||
}
|
||||
}
|
||||
// ── Time requirements handlers ─────────────────────────────────────────────
|
||||
async function handleTimeReqChange(catId, action, value) {
|
||||
const next = {
|
||||
...timeReqs,
|
||||
[catId]: { ...(timeReqs[catId] || { depart: 0, stay: 0, arrive: 0 }), [action]: value },
|
||||
};
|
||||
setTimeReqs(next);
|
||||
try {
|
||||
await putTimeReq(catId, action, value);
|
||||
setTimeMsg('Saved');
|
||||
setTimeout(() => setTimeMsg(''), 1500);
|
||||
}
|
||||
catch (e) {
|
||||
setTimeError(e instanceof Error ? e.message : 'Save failed');
|
||||
}
|
||||
}
|
||||
// ── Warning threshold handlers ─────────────────────────────────────────────
|
||||
async function saveWarnThresholds() {
|
||||
setWarnSaving(true);
|
||||
setWarnError('');
|
||||
setWarnMsg('');
|
||||
try {
|
||||
await putWarningThresholds({
|
||||
warn_over_red_hrs: warnOverRed,
|
||||
warn_over_amber_hrs: warnOverAmber,
|
||||
warn_under_amber_hrs: warnUnderAmber,
|
||||
warn_under_red_hrs: warnUnderRed,
|
||||
});
|
||||
setWarnMsg('Saved');
|
||||
setTimeout(() => setWarnMsg(''), 2500);
|
||||
}
|
||||
catch (e) {
|
||||
setWarnError(e instanceof Error ? e.message : 'Save failed');
|
||||
}
|
||||
finally {
|
||||
setWarnSaving(false);
|
||||
}
|
||||
}
|
||||
if (loading) {
|
||||
return _jsx("div", { style: { padding: '2rem', color: 'var(--text-mid)' }, children: "Loading\u2026" });
|
||||
}
|
||||
return (_jsxs("div", { style: { padding: '1.5rem', maxWidth: '680px' }, children: [_jsx("h1", { style: { fontSize: '1.1rem', fontWeight: 700, color: 'var(--text-dark)', marginBottom: '1.5rem' }, children: "Settings" }), _jsx(SectionHeading, { title: "Room Categories" }), _jsx("p", { style: { fontSize: '0.82rem', color: 'var(--text-mid)', marginBottom: '1rem' }, children: "Drag to reorder. Toggle to exclude categories from the planner." }), error && _jsx(Banner, { type: "error", children: error }), msg && _jsx(Banner, { type: "ok", children: msg }), _jsx("div", { style: { display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '1rem' }, children: cats.map((cat, i) => (_jsxs("div", { draggable: true, onDragStart: e => onDragStart(e, i), onDragOver: e => onDragOver(e, i), onDragEnd: onDragEnd, style: {
|
||||
display: 'flex', alignItems: 'center', gap: '0.75rem',
|
||||
padding: '0.625rem 0.875rem',
|
||||
border: '1px solid var(--card-border)', borderRadius: '8px',
|
||||
background: cat.excluded ? '#f8fafc' : 'var(--card-bg)',
|
||||
opacity: dragIdx === i ? 0.5 : 1,
|
||||
}, children: [_jsx(GripVertical, { size: 16, color: "var(--text-mid)", style: { cursor: 'grab', flexShrink: 0 } }), _jsx("span", { style: {
|
||||
flex: 1, fontSize: '0.9rem',
|
||||
color: cat.excluded ? 'var(--text-mid)' : 'var(--text-dark)',
|
||||
textDecoration: cat.excluded ? 'line-through' : 'none',
|
||||
}, children: cat.name }), _jsxs("span", { style: { fontSize: '0.75rem', color: 'var(--text-mid)', marginRight: '0.5rem' }, children: [cat.room_count, " rooms"] }), _jsxs("label", { style: { display: 'flex', alignItems: 'center', gap: '0.375rem', fontSize: '0.8rem', color: 'var(--text-mid)' }, children: [_jsx("input", { type: "checkbox", checked: !cat.excluded, onChange: () => toggleExcluded(i) }), "Active"] })] }, cat.id))) }), _jsxs("div", { style: { display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginBottom: '2.5rem' }, children: [_jsx(Btn, { onClick: saveCats, disabled: saving, primary: true, children: saving ? 'Saving…' : 'Save Order & Visibility' }), _jsx(Btn, { onClick: handleTest, disabled: testing, children: testing ? 'Testing…' : 'Test Newbook Connection' })] }), _jsx(Divider, {}), _jsx(SectionHeading, { title: "Workforce Departments" }), _jsx("p", { style: { fontSize: '0.82rem', color: 'var(--text-mid)', marginBottom: '1rem' }, children: "Select the department(s) whose shifts should appear in the HK staff rota." }), wfNotConfigured && (_jsxs("div", { style: { padding: '0.75rem', borderRadius: '8px', background: '#f8fafc', border: '1px solid var(--card-border)', color: 'var(--text-mid)', fontSize: '0.85rem', marginBottom: '2rem' }, children: ["Workforce integration not configured \u2014 add the bearer token in ", _jsx("strong", { children: "Settings \u2192 Integrations \u2192 Workforce" }), "."] })), !wfNotConfigured && wfLoading && (_jsx("div", { style: { color: 'var(--text-mid)', fontSize: '0.85rem', marginBottom: '2rem' }, children: "Loading departments\u2026" })), !wfNotConfigured && !wfLoading && (_jsxs("div", { style: { marginBottom: '2.5rem' }, children: [wfError && _jsx(Banner, { type: "error", children: wfError }), wfMsg && _jsx(Banner, { type: "ok", children: wfMsg }), wfDepts.length === 0 ? (_jsx("div", { style: { color: 'var(--text-mid)', fontSize: '0.85rem', marginBottom: '1rem' }, children: "No departments found for this location." })) : (_jsx("div", { style: { display: 'flex', flexDirection: 'column', gap: '0.4rem', marginBottom: '1rem' }, children: wfDepts.map(dept => (_jsxs("label", { style: {
|
||||
display: 'flex', alignItems: 'center', gap: '0.625rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
border: '1px solid var(--card-border)', borderRadius: '7px',
|
||||
background: wfSelected.includes(dept.id) ? 'rgba(42,100,72,0.05)' : 'var(--card-bg)',
|
||||
cursor: 'pointer', fontSize: '0.9rem', color: 'var(--text-dark)',
|
||||
}, children: [_jsx("input", { type: "checkbox", checked: wfSelected.includes(dept.id), onChange: () => toggleWfDept(dept.id) }), dept.name] }, dept.id))) })), wfSelected.length === 0 && wfDepts.length > 0 && (_jsx("div", { style: { marginBottom: '0.75rem', fontSize: '0.8rem', color: '#b45309' }, children: "Select at least one department to enable Workforce sync." })), _jsx(Btn, { onClick: saveWfDepts, disabled: wfSaving, primary: true, children: wfSaving ? 'Saving…' : 'Save Departments' })] })), _jsx(Divider, {}), _jsx(SectionHeading, { title: "Recurring General Tasks" }), _jsx("p", { style: { fontSize: '0.82rem', color: 'var(--text-mid)', marginBottom: '1rem' }, children: "Tasks that recur every week. Enter minutes per day. These add to the total required hours every week." }), taskError && _jsx(Banner, { type: "error", children: taskError }), taskMsg && _jsx(Banner, { type: "ok", children: taskMsg }), _jsx("div", { style: { overflowX: 'auto', marginBottom: '1rem' }, children: _jsxs("table", { className: "hk-table", style: { minWidth: '560px' }, children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { className: "col-label", children: "Task" }), WEEKDAYS.map(d => _jsx("th", { children: d }, d)), _jsx("th", { style: { width: '32px' } })] }) }), _jsxs("tbody", { children: [tasks.length === 0 && (_jsx("tr", { children: _jsx("td", { colSpan: 9, style: { color: 'var(--text-mid)', textAlign: 'center', padding: '1rem' }, children: "No tasks yet" }) })), tasks.map((task, i) => (_jsxs("tr", { children: [_jsx("td", { style: { padding: '0.3rem 0.5rem' }, children: _jsx("input", { className: "hk-text-input", value: task.name, placeholder: "Task name", onChange: e => setTaskName(i, e.target.value) }) }), WEEKDAYS.map(day => (_jsx("td", { style: { padding: '0.3rem 0.4rem' }, children: _jsx("input", { type: "number", className: "hk-num-input", min: 0, max: 999, value: task.hours[day] || '', placeholder: "0", onChange: e => setTaskHours(i, day, e.target.value) }) }, day))), _jsx("td", { children: _jsx("button", { onClick: () => removeTask(i), style: {
|
||||
background: 'none', border: 'none', color: 'var(--text-mid)', fontSize: '1rem', padding: '0.2rem 0.4rem',
|
||||
}, children: "\u00D7" }) })] }, i)))] })] }) }), _jsxs("div", { style: { display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginBottom: '2.5rem' }, children: [_jsx(Btn, { onClick: () => setTasks([...tasks, { name: '', hours: {} }]), children: "+ Add task" }), _jsx(Btn, { onClick: saveTasks, disabled: taskSaving, primary: true, children: taskSaving ? 'Saving…' : 'Save Tasks' })] }), _jsx(Divider, {}), _jsx(SectionHeading, { title: "Time Requirements (minutes per room)" }), _jsx("p", { style: { fontSize: '0.82rem', color: 'var(--text-mid)', marginBottom: '1rem' }, children: "How many minutes each room type takes depending on guest status." }), timeError && _jsx(Banner, { type: "error", children: timeError }), timeMsg && _jsx(Banner, { type: "ok", children: timeMsg }), _jsx("div", { style: { overflowX: 'auto', marginBottom: '2rem' }, children: _jsxs("table", { className: "hk-table", style: { maxWidth: '500px' }, children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { className: "col-label", children: "Category" }), _jsx("th", { children: "Depart (mins)" }), _jsx("th", { children: "Stay (mins)" }), _jsx("th", { children: "Arrive (mins)" })] }) }), _jsx("tbody", { children: cats.filter(c => !c.excluded).map(cat => {
|
||||
const req = timeReqs[cat.id] || { depart: 0, stay: 0, arrive: 0 };
|
||||
return (_jsxs("tr", { children: [_jsx("td", { className: "col-label", children: cat.name }), ['depart', 'stay', 'arrive'].map(action => (_jsx("td", { style: { padding: '0.3rem 0.4rem' }, children: _jsx("input", { type: "number", className: "hk-num-input", min: 0, max: 999, value: req[action] || '', placeholder: "0", onChange: e => handleTimeReqChange(cat.id, action, parseInt(e.target.value, 10) || 0), onBlur: e => handleTimeReqChange(cat.id, action, parseInt(e.target.value, 10) || 0) }) }, action)))] }, cat.id));
|
||||
}) })] }) }), _jsx(Divider, {}), _jsx(SectionHeading, { title: "Warning Thresholds" }), _jsx("p", { style: { fontSize: '0.82rem', color: 'var(--text-mid)', marginBottom: '1rem' }, children: "Controls the colour and icon shown on the \"with Adjustments\" row in the staff rota. All values are in hours." }), warnError && _jsx(Banner, { type: "error", children: warnError }), warnMsg && _jsx(Banner, { type: "ok", children: warnMsg }), _jsx("div", { style: { display: 'flex', flexDirection: 'column', gap: '0.625rem', marginBottom: '1.25rem', maxWidth: '420px' }, children: [
|
||||
{ label: '⚠ Red warning — spare over', value: warnOverRed, set: setWarnOverRed, help: 'default 4h' },
|
||||
{ label: '⚠ Amber warning — spare over', value: warnOverAmber, set: setWarnOverAmber, help: 'default 1h' },
|
||||
{ label: '✗ Amber cross — short over', value: warnUnderAmber, set: setWarnUnderAmber, help: 'default 1h' },
|
||||
{ label: '✗ Red cross — short over', value: warnUnderRed, set: setWarnUnderRed, help: 'default 2h' },
|
||||
].map(({ label, value, set, help }) => (_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: '0.75rem' }, children: [_jsx("span", { style: { flex: 1, fontSize: '0.875rem', color: 'var(--text-dark)' }, children: label }), _jsx("input", { type: "number", className: "hk-num-input", min: 0, max: 24, step: 0.5, value: value, onChange: e => set(parseFloat(e.target.value) || 0), style: { width: '68px' } }), _jsx("span", { style: { fontSize: '0.75rem', color: 'var(--text-mid)', minWidth: '52px' }, children: help })] }, label))) }), _jsx("p", { style: { fontSize: '0.78rem', color: 'var(--text-mid)', marginBottom: '1rem' }, children: "Green \u2713 is automatic \u2014 shown when the difference is within the amber spare and short thresholds above." }), _jsx("div", { style: { marginBottom: '2.5rem' }, children: _jsx(Btn, { onClick: saveWarnThresholds, disabled: warnSaving, primary: true, children: warnSaving ? 'Saving…' : 'Save Thresholds' }) })] }));
|
||||
}
|
||||
// ── Small reusable helpers ────────────────────────────────────────────────────
|
||||
function SectionHeading({ title }) {
|
||||
return (_jsx("h2", { style: { fontSize: '0.95rem', fontWeight: 700, color: 'var(--text-dark)', marginBottom: '0.25rem' }, children: title }));
|
||||
}
|
||||
function Divider() {
|
||||
return _jsx("div", { style: { height: '1px', background: 'var(--card-border)', margin: '0.5rem 0 2rem' } });
|
||||
}
|
||||
function Banner({ type, children }) {
|
||||
return (_jsx("div", { style: {
|
||||
marginBottom: '0.75rem', padding: '0.75rem', borderRadius: '8px', fontSize: '0.875rem',
|
||||
background: type === 'ok' ? '#dcfce7' : '#fee2e2',
|
||||
color: type === 'ok' ? 'var(--success)' : 'var(--danger)',
|
||||
}, children: children }));
|
||||
}
|
||||
function Btn({ children, onClick, disabled, primary }) {
|
||||
return (_jsx("button", { onClick: onClick, disabled: disabled, style: {
|
||||
background: primary ? 'var(--hk-green)' : 'var(--card-bg)',
|
||||
color: primary ? '#fff' : 'var(--text-dark)',
|
||||
border: `1px solid ${primary ? 'var(--hk-green)' : 'var(--card-border)'}`,
|
||||
borderRadius: '6px', padding: '0.5rem 1.25rem', fontSize: '0.875rem', fontWeight: 600,
|
||||
}, children: children }));
|
||||
}
|
||||
|
|
@ -1,538 +0,0 @@
|
|||
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { getBookings, getConfig, putStaff, putPickup, putLastReviewed, putAdjustments, syncWorkforce, getWorkforceShifts, getWorkforceStaff, } from '../api';
|
||||
// ── Date helpers ──────────────────────────────────────────────────────────────
|
||||
function todayStr() {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
function offsetDate(s, days) {
|
||||
const [y, m, d] = s.split('-').map(Number);
|
||||
const dt = new Date(y, m - 1, d);
|
||||
dt.setDate(dt.getDate() + days);
|
||||
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
function fmtDayHeader(date) {
|
||||
const d = new Date(date + 'T00:00:00');
|
||||
return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d.getDay()] + ' ' + d.getDate() + ' ' +
|
||||
['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][d.getMonth()];
|
||||
}
|
||||
function getDayName(date) {
|
||||
const d = new Date(date + 'T00:00:00');
|
||||
return ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'][(d.getDay() + 6) % 7];
|
||||
}
|
||||
function fullDayName(date) {
|
||||
const d = new Date(date + 'T00:00:00');
|
||||
return ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][d.getDay()];
|
||||
}
|
||||
function isWeekend(date) {
|
||||
const day = new Date(date + 'T00:00:00').getDay();
|
||||
return day === 0 || day === 6;
|
||||
}
|
||||
function priorLineFor(date, d) {
|
||||
const hint = d.pickup_hint ?? 0;
|
||||
const lead = d.pickup_lead ?? 0;
|
||||
const late = hint > 0
|
||||
? `${hint} room${hint === 1 ? '' : 's'} picked up within ${lead} day${lead === 1 ? '' : 's'} of arrival`
|
||||
: 'no late pickups';
|
||||
return `Last ${fullDayName(date)}: ${d.prior_occ} occ (${d.prior_vac} vac) — ${late}`;
|
||||
}
|
||||
function fmtDate(date) {
|
||||
return new Date(date + 'T00:00:00').toLocaleDateString('en-GB', {
|
||||
weekday: 'short', day: 'numeric', month: 'short',
|
||||
});
|
||||
}
|
||||
function fmtH(n) { return n.toFixed(2) + 'h'; }
|
||||
// ── Pickup calculation ────────────────────────────────────────────────────────
|
||||
function getDisplayPickup(pickup, catId, date, occupiedNow, totalRooms) {
|
||||
const vacant = Math.max(0, totalRooms - occupiedNow);
|
||||
const saved = pickup[catId]?.[date];
|
||||
if (!saved || !saved.count)
|
||||
return 0;
|
||||
const savedBooked = saved.total - saved.count;
|
||||
const count = occupiedNow > savedBooked
|
||||
? Math.max(0, saved.total - occupiedNow)
|
||||
: saved.count;
|
||||
return Math.min(count, vacant);
|
||||
}
|
||||
// ── Required hours calculation ────────────────────────────────────────────────
|
||||
function calcRequired(bookings, timeReqs, pickup, generalTasks, adjustments) {
|
||||
const result = {};
|
||||
for (const date of bookings.dates) {
|
||||
const dayName = getDayName(date);
|
||||
const genHrs = generalTasks.reduce((s, t) => s + ((t.hours[dayName] || 0) / 60), 0);
|
||||
const adjHrs = adjustments.reduce((s, a) => s + (a.hours[date] || 0), 0);
|
||||
result[date] = { booked: 0, pickup: 0, general: genHrs, adjustments: adjHrs, total: genHrs + adjHrs, by_cat: {}, pickup_by_cat: {} };
|
||||
}
|
||||
for (const cat of bookings.categories) {
|
||||
const req = timeReqs[cat.id] || { depart: 0, stay: 0, arrive: 0 };
|
||||
bookings.dates.forEach((date, di) => {
|
||||
const d = cat.days[date];
|
||||
const occupied = d.stays + d.arrivals;
|
||||
const pickupCount = getDisplayPickup(pickup, cat.id, date, occupied, cat.total_rooms);
|
||||
const bookedHrs = (d.departs * (req.depart || 0) + d.stays * (req.stay || 0) + d.arrivals * (req.arrive || 0)) / 60;
|
||||
const pickupArrHrs = pickupCount * (req.arrive || 0) / 60;
|
||||
result[date].by_cat[cat.id] = bookedHrs;
|
||||
result[date].pickup_by_cat[cat.id] = (result[date].pickup_by_cat[cat.id] || 0) + pickupArrHrs;
|
||||
result[date].booked += bookedHrs;
|
||||
result[date].pickup += pickupArrHrs;
|
||||
result[date].total += bookedHrs + pickupArrHrs;
|
||||
const nextDate = bookings.dates[di + 1];
|
||||
if (nextDate && pickupCount > 0) {
|
||||
const pickupDepHrs = pickupCount * (req.depart || 0) / 60;
|
||||
result[nextDate].pickup_by_cat[cat.id] = (result[nextDate].pickup_by_cat[cat.id] || 0) + pickupDepHrs;
|
||||
result[nextDate].pickup += pickupDepHrs;
|
||||
result[nextDate].total += pickupDepHrs;
|
||||
}
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// ── Main Planner component ────────────────────────────────────────────────────
|
||||
export function Planner() {
|
||||
const [bookings, setBookings] = useState(null);
|
||||
const [timeReqs, setTimeReqs] = useState({});
|
||||
const [staff, setStaff] = useState([]);
|
||||
const [pickup, setPickup] = useState({});
|
||||
const [generalTasks, setGeneralTasks] = useState([]);
|
||||
const [adjustments, setAdjustments] = useState([]);
|
||||
const [warnOverRed, setWarnOverRed] = useState(4);
|
||||
const [warnOverAmber, setWarnOverAmber] = useState(1);
|
||||
const [warnUnderAmber, setWarnUnderAmber] = useState(1);
|
||||
const [warnUnderRed, setWarnUnderRed] = useState(2);
|
||||
const [wfShifts, setWfShifts] = useState({});
|
||||
const [wfStaff, setWfStaff] = useState([]);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [weekStart, setWeekStart] = useState(null);
|
||||
const [lastViewed, setLastViewed] = useState('');
|
||||
const [savedLastReviewed, setSavedLastReviewed] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [saveMsg, setSaveMsg] = useState(null);
|
||||
const timers = useRef({});
|
||||
const saveMsgTimer = useRef(null);
|
||||
const wfStaffLoaded = useRef(false);
|
||||
const weekStartRef = useRef(null);
|
||||
weekStartRef.current = weekStart; // always current, updated every render
|
||||
function debounce(key, fn, delay = 400) {
|
||||
clearTimeout(timers.current[key]);
|
||||
timers.current[key] = setTimeout(fn, delay);
|
||||
}
|
||||
function flash(text, err = false) {
|
||||
if (saveMsgTimer.current)
|
||||
clearTimeout(saveMsgTimer.current);
|
||||
setSaveMsg({ text, err });
|
||||
saveMsgTimer.current = setTimeout(() => setSaveMsg(null), err ? 15000 : 2500);
|
||||
}
|
||||
function stampLastReviewed() {
|
||||
const today = todayStr();
|
||||
if (today === savedLastReviewed)
|
||||
return;
|
||||
setSavedLastReviewed(today);
|
||||
putLastReviewed(today).catch(() => { });
|
||||
}
|
||||
const loadAll = useCallback(async (force = false, lvArg) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const today = todayStr();
|
||||
const ws = weekStartRef.current || today;
|
||||
const wsEnd = offsetDate(ws, 6);
|
||||
const lv = lvArg !== undefined ? (lvArg || offsetDate(today, -1)) : (lastViewed || savedLastReviewed || offsetDate(today, -1));
|
||||
try {
|
||||
const [b, cfg, shifts] = await Promise.all([
|
||||
getBookings(ws, lv, force),
|
||||
getConfig(),
|
||||
getWorkforceShifts(ws, wsEnd).catch(() => ({})),
|
||||
]);
|
||||
setBookings(b);
|
||||
setTimeReqs(cfg.time_requirements || {});
|
||||
setStaff(cfg.staff_data || []);
|
||||
setPickup(cfg.pickup_data || {});
|
||||
setGeneralTasks(cfg.general_tasks || []);
|
||||
setAdjustments(cfg.adjustments || []);
|
||||
setWarnOverRed(cfg.warn_over_red_hrs ?? 4);
|
||||
setWarnOverAmber(cfg.warn_over_amber_hrs ?? 1);
|
||||
setWarnUnderAmber(cfg.warn_under_amber_hrs ?? 1);
|
||||
setWarnUnderRed(cfg.warn_under_red_hrs ?? 2);
|
||||
setWfShifts(shifts);
|
||||
if (cfg.last_reviewed) {
|
||||
setSavedLastReviewed(cfg.last_reviewed);
|
||||
if (!lastViewed)
|
||||
setLastViewed(cfg.last_reviewed);
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load data');
|
||||
}
|
||||
finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [lastViewed, savedLastReviewed]);
|
||||
useEffect(() => { loadAll(false); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
// Lazily load WF staff list for datalist once any shifts exist
|
||||
useEffect(() => {
|
||||
if (Object.keys(wfShifts).length > 0 && !wfStaffLoaded.current) {
|
||||
wfStaffLoaded.current = true;
|
||||
getWorkforceStaff().then(setWfStaff).catch(() => { });
|
||||
}
|
||||
}, [wfShifts]);
|
||||
// Keepalive save on unload (sendBeacon only supports POST but routes are PUT)
|
||||
useEffect(() => {
|
||||
function keepalivePut(path, body) {
|
||||
fetch(path, {
|
||||
method: 'PUT', keepalive: true, credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
function onUnload() {
|
||||
if (staff.length)
|
||||
keepalivePut('/hk-planner/api/config/staff', { staff_data: staff });
|
||||
if (Object.keys(pickup).length)
|
||||
keepalivePut('/hk-planner/api/config/pickup', { pickup_data: pickup });
|
||||
if (adjustments.length)
|
||||
keepalivePut('/hk-planner/api/config/adjustments', { adjustments });
|
||||
}
|
||||
window.addEventListener('beforeunload', onUnload);
|
||||
return () => window.removeEventListener('beforeunload', onUnload);
|
||||
}, [staff, pickup, adjustments]);
|
||||
function handleWeekOffset(days) {
|
||||
const today = todayStr();
|
||||
const newStart = offsetDate(weekStartRef.current || today, days);
|
||||
weekStartRef.current = newStart;
|
||||
setWeekStart(newStart);
|
||||
stampLastReviewed();
|
||||
loadAll(true);
|
||||
}
|
||||
function handleWeekToday() {
|
||||
weekStartRef.current = null;
|
||||
setWeekStart(null);
|
||||
stampLastReviewed();
|
||||
loadAll(true);
|
||||
}
|
||||
function handleRefresh() {
|
||||
stampLastReviewed();
|
||||
loadAll(true);
|
||||
}
|
||||
// ── Pickup ──────────────────────────────────────────────────────────────────
|
||||
function handlePickupChange(catId, date, delta, cat) {
|
||||
const d = cat.days[date];
|
||||
const occupied = d.stays + d.arrivals;
|
||||
const vacant = Math.max(0, cat.total_rooms - occupied);
|
||||
const current = getDisplayPickup(pickup, catId, date, occupied, cat.total_rooms);
|
||||
const newVal = Math.max(0, Math.min(current + delta, vacant));
|
||||
const next = {
|
||||
...pickup,
|
||||
[catId]: { ...(pickup[catId] || {}), [date]: { count: newVal, total: occupied + newVal } },
|
||||
};
|
||||
setPickup(next);
|
||||
stampLastReviewed();
|
||||
debounce('pickup', () => putPickup(next).then(() => flash('Pickup saved')).catch(e => flash(e.message, true)));
|
||||
}
|
||||
// ── Adjustments ─────────────────────────────────────────────────────────────
|
||||
function handleAdjustmentsChange(next) {
|
||||
setAdjustments(next);
|
||||
debounce('adjustments', () => putAdjustments(next).then(() => flash('Adjustments saved')).catch(e => flash(e.message, true)));
|
||||
}
|
||||
function addAdjustmentRow() {
|
||||
handleAdjustmentsChange([...adjustments, { label: '', hours: {} }]);
|
||||
}
|
||||
// ── Staff ────────────────────────────────────────────────────────────────────
|
||||
function handleStaffChange(next) {
|
||||
setStaff(next);
|
||||
stampLastReviewed();
|
||||
debounce('staff', () => putStaff(next).then(() => flash('Staff hours saved')).catch(e => flash(e.message, true)));
|
||||
}
|
||||
function addStaffRow() {
|
||||
handleStaffChange([...staff, { name: '', hours: {} }]);
|
||||
}
|
||||
// ── Workforce sync ───────────────────────────────────────────────────────────
|
||||
async function syncRota() {
|
||||
setSyncing(true);
|
||||
try {
|
||||
await syncWorkforce();
|
||||
if (bookings) {
|
||||
const shifts = await getWorkforceShifts(bookings.dates[0], bookings.dates[bookings.dates.length - 1]);
|
||||
setWfShifts(shifts);
|
||||
}
|
||||
flash('Rota synced from Workforce');
|
||||
}
|
||||
catch (e) {
|
||||
flash(e instanceof Error ? e.message : 'Sync failed', true);
|
||||
}
|
||||
finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
}
|
||||
function wfSyncLabel() {
|
||||
if (!bookings)
|
||||
return '';
|
||||
const viewDates = bookings.dates.filter(d => wfShifts[d]);
|
||||
if (!viewDates.length)
|
||||
return 'Not synced';
|
||||
const oldest = viewDates.reduce((min, d) => new Date(wfShifts[d].synced_at) < new Date(wfShifts[min].synced_at) ? d : min);
|
||||
const dt = new Date(wfShifts[oldest].synced_at);
|
||||
const day = dt.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' });
|
||||
const time = dt.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
||||
const missing = bookings.dates.filter(d => !wfShifts[d]).length;
|
||||
return missing ? `Partial sync — from ${day} ${time}` : `From ${day} ${time}`;
|
||||
}
|
||||
// ── Required hours (memoised on state changes) ────────────────────────────
|
||||
const required = bookings ? calcRequired(bookings, timeReqs, pickup, generalTasks, adjustments) : null;
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
const today = todayStr();
|
||||
const displayWeekStart = weekStart || today;
|
||||
return (_jsxs("div", { style: { padding: '1.5rem', maxWidth: '1600px' }, children: [_jsxs("div", { style: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '0.75rem', marginBottom: '1.25rem' }, children: [_jsxs("div", { children: [_jsx("h1", { style: { fontSize: '1.15rem', fontWeight: 700, color: 'var(--text-dark)' }, children: "Housekeeping Planner" }), bookings && (_jsxs("p", { style: { fontSize: '0.8rem', color: 'var(--text-mid)', marginTop: '0.1rem' }, children: [fmtDate(bookings.dates[0]), " \u2013 ", fmtDate(bookings.dates[bookings.dates.length - 1])] }))] }), _jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: '0.4rem', marginLeft: 'auto', flexWrap: 'wrap' }, children: [_jsx(CtrlBtn, { onClick: () => handleWeekOffset(-7), children: "\u2039 Prev" }), _jsx(CtrlBtn, { onClick: handleWeekToday, primary: true, children: "Today" }), _jsx(CtrlBtn, { onClick: () => handleWeekOffset(7), children: "Next \u203A" }), _jsx("input", { type: "date", value: displayWeekStart, onChange: e => { if (e.target.value) {
|
||||
const v = e.target.value;
|
||||
weekStartRef.current = v;
|
||||
setWeekStart(v);
|
||||
stampLastReviewed();
|
||||
debounce('weekStart', () => loadAll(true), 600);
|
||||
} }, style: { border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.35rem 0.5rem', fontSize: '0.82rem', color: 'var(--text-dark)' } }), _jsx("div", { style: { width: '1px', height: '24px', background: 'var(--card-border)' } }), _jsxs("label", { style: { fontSize: '0.78rem', color: 'var(--text-mid)', display: 'flex', alignItems: 'center', gap: '0.3rem' }, children: [_jsx("span", { children: "Since" }), _jsx("input", { type: "date", value: lastViewed, onChange: e => { if (e.target.value) {
|
||||
const v = e.target.value;
|
||||
setLastViewed(v);
|
||||
debounce('lastViewed', () => loadAll(true, v), 600);
|
||||
} }, style: { border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.3rem 0.5rem', fontSize: '0.78rem', color: 'var(--text-dark)' } })] }), _jsx(CtrlBtn, { onClick: () => { stampLastReviewed(); loadAll(true); }, children: "Update to now" }), _jsx("div", { style: { width: '1px', height: '24px', background: 'var(--card-border)' } }), _jsx("button", { onClick: handleRefresh, title: "Refresh", style: {
|
||||
background: 'var(--card-bg)', border: '1px solid var(--card-border)', borderRadius: '6px',
|
||||
padding: '0.35rem 0.5rem', display: 'flex', alignItems: 'center', gap: '0.3rem',
|
||||
fontSize: '0.82rem', color: 'var(--text-dark)',
|
||||
}, children: _jsx(RefreshCw, { size: 13, strokeWidth: 1.75, style: { animation: loading ? 'spin 1s linear infinite' : 'none' } }) })] })] }), _jsx("style", { children: `@keyframes spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }` }), saveMsg && (_jsx("div", { style: {
|
||||
display: 'inline-block', marginBottom: '0.75rem', padding: '0.35rem 0.75rem',
|
||||
borderRadius: '6px', fontSize: '0.8rem', fontWeight: 500,
|
||||
background: saveMsg.err ? '#fee2e2' : '#dcfce7',
|
||||
color: saveMsg.err ? 'var(--danger)' : 'var(--success)',
|
||||
}, children: saveMsg.text })), error && (_jsxs("div", { style: {
|
||||
marginBottom: '1rem', padding: '0.75rem 1rem', borderRadius: '8px',
|
||||
background: '#fee2e2', color: 'var(--danger)', fontSize: '0.875rem',
|
||||
}, children: ["Error: ", error] })), loading && !bookings && (_jsx("div", { style: { color: 'var(--text-mid)', padding: '2rem 0', textAlign: 'center' }, children: "Loading bookings\u2026" })), bookings && required && (_jsxs(_Fragment, { children: [_jsx(Section, { title: "7-Day Occupancy", children: _jsx("div", { className: "table-scroll", children: _jsx(SummaryTable, { bookings: bookings, pickup: pickup, onPickupChange: handlePickupChange }) }) }), _jsx(Section, { title: "Required Hours", children: _jsx("div", { className: "table-scroll", children: _jsx(RequiredTable, { bookings: bookings, required: required }) }) }), _jsx(Section, { title: "Adjustments", action: _jsx(CtrlBtn, { onClick: addAdjustmentRow, children: "+ Add adjustment" }), children: _jsx("div", { className: "table-scroll", children: _jsx(AdjustmentsTable, { bookings: bookings, adjustments: adjustments, onChange: handleAdjustmentsChange }) }) }), _jsx(Section, { title: "Staff Rota", action: _jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }, children: [_jsxs("button", { onClick: syncRota, disabled: syncing, style: {
|
||||
display: 'flex', alignItems: 'center', gap: '0.3rem',
|
||||
background: 'var(--card-bg)', color: 'var(--text-dark)',
|
||||
border: '1px solid var(--card-border)', borderRadius: '6px',
|
||||
padding: '0.3rem 0.65rem', fontSize: '0.78rem', fontWeight: 600,
|
||||
}, children: [_jsx(RefreshCw, { size: 11, strokeWidth: 1.75, style: { animation: syncing ? 'spin 1s linear infinite' : 'none' } }), syncing ? 'Syncing…' : 'Sync from Workforce'] }), _jsx("span", { style: { fontSize: '0.72rem', color: 'var(--text-mid)' }, children: wfSyncLabel() }), _jsx(CtrlBtn, { onClick: addStaffRow, children: "+ Add staff" })] }), children: _jsx("div", { className: "table-scroll", children: _jsx(StaffTable, { bookings: bookings, staff: staff, required: required, warnOverRed: warnOverRed, warnOverAmber: warnOverAmber, warnUnderAmber: warnUnderAmber, warnUnderRed: warnUnderRed, onChange: handleStaffChange, wfShifts: wfShifts, wfStaff: wfStaff }) }) })] }))] }));
|
||||
}
|
||||
// ── Section wrapper ───────────────────────────────────────────────────────────
|
||||
function Section({ title, children, action }) {
|
||||
return (_jsxs("div", { style: { marginBottom: '1.5rem' }, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem' }, children: [_jsx("h2", { style: { fontSize: '0.72rem', fontWeight: 700, color: 'var(--text-mid)', textTransform: 'uppercase', letterSpacing: '0.08em' }, children: title }), action] }), children] }));
|
||||
}
|
||||
function CtrlBtn({ children, onClick, primary }) {
|
||||
return (_jsx("button", { onClick: onClick, style: {
|
||||
background: primary ? 'var(--hk-green)' : 'var(--card-bg)',
|
||||
color: primary ? '#fff' : 'var(--text-dark)',
|
||||
border: `1px solid ${primary ? 'var(--hk-green)' : 'var(--card-border)'}`,
|
||||
borderRadius: '6px', padding: '0.35rem 0.75rem', fontSize: '0.82rem', fontWeight: 600,
|
||||
}, children: children }));
|
||||
}
|
||||
function SummaryTable({ bookings, pickup, onPickupChange }) {
|
||||
const { dates, categories: cats } = bookings;
|
||||
return (_jsxs("table", { className: "hk-table", children: [_jsxs("thead", { children: [_jsxs("tr", { children: [_jsx("th", { className: "col-label", rowSpan: 2, children: "Category" }), dates.map(d => (_jsx("th", { colSpan: 2, style: {
|
||||
borderLeft: '2px solid rgba(255,255,255,0.3)',
|
||||
...(isWeekend(d) ? { background: '#253555' } : {}),
|
||||
}, children: fmtDayHeader(d) }, d)))] }), _jsx("tr", { children: dates.map(d => (_jsxs(_Fragment, { children: [_jsx("th", { style: {
|
||||
fontSize: '0.72rem', fontWeight: 400, minWidth: '72px',
|
||||
background: isWeekend(d) ? '#2a3d5e' : '#1e2d42',
|
||||
borderLeft: '2px solid rgba(255,255,255,0.3)',
|
||||
}, children: "Rooms" }, d + '-r'), _jsx("th", { style: {
|
||||
fontSize: '0.72rem', fontWeight: 400, minWidth: '56px', textAlign: 'center',
|
||||
background: isWeekend(d) ? '#2a3d5e' : '#1e2d42',
|
||||
borderLeft: '1px solid rgba(255,255,255,0.1)',
|
||||
}, children: "D / S / A" }, d + '-d')] }))) })] }), _jsx("tbody", { children: cats.map((cat, ci) => {
|
||||
const isLast = ci === cats.length - 1;
|
||||
return (_jsx(CatRows, { cat: cat, dates: dates, pickup: pickup, onPickupChange: (date, delta) => onPickupChange(cat.id, date, delta, cat), isLast: isLast }, cat.id));
|
||||
}) }), _jsx("tfoot", { children: _jsx(TotalRows, { dates: dates, cats: cats, pickup: pickup }) })] }));
|
||||
}
|
||||
function CatRows({ cat, dates, pickup, onPickupChange, isLast }) {
|
||||
const bbStyle = isLast ? '2px solid var(--card-border)' : undefined;
|
||||
return (_jsxs("tr", { children: [_jsx("td", { className: "col-label", style: { borderBottom: bbStyle }, children: cat.name }), dates.map((date, di) => {
|
||||
const d = cat.days[date];
|
||||
const occupied = d.stays + d.arrivals;
|
||||
const vacant = Math.max(0, cat.total_rooms - occupied);
|
||||
const pu = getDisplayPickup(pickup, cat.id, date, occupied, cat.total_rooms);
|
||||
const prevDate = dates[di - 1];
|
||||
const prevOcc = prevDate ? (cat.days[prevDate].stays + cat.days[prevDate].arrivals) : 0;
|
||||
const prevPu = prevDate ? getDisplayPickup(pickup, cat.id, prevDate, prevOcc, cat.total_rooms) : 0;
|
||||
const dNew = d.delta_new || 0;
|
||||
const dCanc = d.delta_cancelled || 0;
|
||||
const wknd = isWeekend(date);
|
||||
const saved = pickup[cat.id]?.[date];
|
||||
const priorLine = priorLineFor(date, d);
|
||||
let occTooltip = '';
|
||||
if (saved && saved.count) {
|
||||
const savedBooked = saved.total - saved.count;
|
||||
const changeDesc = occupied > savedBooked ? 'changed' : 'unchanged';
|
||||
const pickupDesc = occupied > savedBooked ? 'decreased' : 'remains';
|
||||
occTooltip = `+${saved.count} pickup set when booked was ${savedBooked} (target ${saved.total}). Booked ${changeDesc}, so pickup ${pickupDesc}.`;
|
||||
}
|
||||
occTooltip = (occTooltip ? occTooltip + ' | ' : '') + priorLine;
|
||||
return (_jsxs(_Fragment, { children: [_jsxs("td", { style: {
|
||||
textAlign: 'center', verticalAlign: 'middle', borderBottom: bbStyle,
|
||||
padding: '0.4rem 0.3rem',
|
||||
borderLeft: '2px solid var(--card-border)',
|
||||
...(wknd ? { background: 'rgba(100,120,160,0.07)' } : {}),
|
||||
}, children: [(dNew > 0 || dCanc > 0) && (_jsxs("div", { style: { marginBottom: '2px', fontSize: '0.72rem' }, children: [dNew > 0 && _jsxs("span", { className: "delta-new", children: ["\u25B2", dNew] }), dCanc > 0 && _jsxs("span", { className: "delta-canc", children: [" \u25BC", dCanc] })] })), _jsxs("div", { style: { fontWeight: 700, fontSize: '1.1rem' }, title: occTooltip, children: [occupied, pu > 0 && _jsxs("span", { className: "pickup-tag", children: [" +", pu] })] }), _jsxs("div", { style: { fontSize: '0.72rem', color: 'var(--text-mid)', marginBottom: '4px' }, children: [vacant, " vac", pu > 0 && _jsxs("span", { className: "pickup-tag", children: [" (", vacant - pu, ")"] })] }), _jsxs("div", { className: "pickup-ctrl", children: [_jsx("button", { className: "pickup-btn", disabled: pu <= 0, onClick: () => onPickupChange(date, -1), children: "\u2212" }), _jsx("span", { className: "pickup-num", children: pu }), _jsx("button", { className: "pickup-btn", disabled: pu >= vacant, title: priorLine, onClick: () => onPickupChange(date, 1), children: "+" })] })] }, date + '-r'), _jsxs("td", { style: {
|
||||
verticalAlign: 'middle', borderLeft: '1px solid var(--card-border)',
|
||||
borderBottom: bbStyle, padding: '0.4rem 0.3rem', textAlign: 'center',
|
||||
...(wknd ? { background: 'rgba(100,120,160,0.07)' } : {}),
|
||||
}, children: [_jsxs("div", { style: { fontSize: '0.8rem', color: '#c2502e', fontWeight: 600 }, children: [d.departs, "d", prevPu > 0 && _jsxs("span", { className: "pickup-tag", children: [" +", prevPu] })] }), _jsxs("div", { style: { fontSize: '0.8rem', color: '#1d6fb8', fontWeight: 600 }, children: [d.stays, "s"] }), _jsxs("div", { style: { fontSize: '0.8rem', color: '#1a7a4a', fontWeight: 600 }, children: [d.arrivals, "a", pu > 0 && _jsxs("span", { className: "pickup-tag", children: [" +", pu] })] })] }, date + '-dsa')] }));
|
||||
})] }));
|
||||
}
|
||||
function TotalRows({ dates, cats, pickup }) {
|
||||
return (_jsxs("tr", { children: [_jsx("td", { className: "col-label", style: { fontStyle: 'italic' }, children: "Total" }), dates.map((date, di) => {
|
||||
let occupied = 0, vacant = 0, pu = 0, prevPu = 0, departs = 0, stays = 0, arrivals = 0;
|
||||
let dNew = 0, dCanc = 0;
|
||||
for (const cat of cats) {
|
||||
const d = cat.days[date];
|
||||
const occ = d.stays + d.arrivals;
|
||||
const vac = Math.max(0, cat.total_rooms - occ);
|
||||
const p = getDisplayPickup(pickup, cat.id, date, occ, cat.total_rooms);
|
||||
const prev = dates[di - 1];
|
||||
const pOcc = prev ? (cat.days[prev].stays + cat.days[prev].arrivals) : 0;
|
||||
const pPu = prev ? getDisplayPickup(pickup, cat.id, prev, pOcc, cat.total_rooms) : 0;
|
||||
occupied += occ;
|
||||
vacant += vac;
|
||||
pu += p;
|
||||
prevPu += pPu;
|
||||
departs += d.departs;
|
||||
stays += d.stays;
|
||||
arrivals += d.arrivals;
|
||||
dNew += d.delta_new || 0;
|
||||
dCanc += d.delta_cancelled || 0;
|
||||
}
|
||||
const wknd = isWeekend(date);
|
||||
return (_jsxs(_Fragment, { children: [_jsxs("td", { style: {
|
||||
textAlign: 'center', verticalAlign: 'middle', padding: '0.4rem 0.3rem',
|
||||
borderLeft: '2px solid var(--card-border)',
|
||||
...(wknd ? { background: 'rgba(100,120,160,0.07)' } : {}),
|
||||
}, children: [(dNew > 0 || dCanc > 0) && (_jsxs("div", { style: { marginBottom: '2px', fontSize: '0.72rem' }, children: [dNew > 0 && _jsxs("span", { className: "delta-new", children: ["\u25B2", dNew] }), dCanc > 0 && _jsxs("span", { className: "delta-canc", children: [" \u25BC", dCanc] })] })), _jsxs("div", { style: { fontWeight: 700, fontSize: '1.1rem' }, children: [occupied, pu > 0 && _jsxs("span", { className: "pickup-tag", children: [" +", pu] })] }), _jsxs("div", { style: { fontSize: '0.72rem', color: 'var(--text-mid)' }, children: [vacant, " vac", pu > 0 && _jsxs("span", { className: "pickup-tag", children: [" (", vacant - pu, ")"] })] })] }, date + '-r'), _jsxs("td", { style: {
|
||||
verticalAlign: 'middle', borderLeft: '1px solid var(--card-border)',
|
||||
padding: '0.4rem 0.3rem', textAlign: 'center',
|
||||
...(wknd ? { background: 'rgba(100,120,160,0.07)' } : {}),
|
||||
}, children: [_jsxs("div", { style: { fontSize: '0.8rem', color: '#c2502e', fontWeight: 600 }, children: [departs, "d", prevPu > 0 && _jsxs("span", { className: "pickup-tag", children: [" +", prevPu] })] }), _jsxs("div", { style: { fontSize: '0.8rem', color: '#1d6fb8', fontWeight: 600 }, children: [stays, "s"] }), _jsxs("div", { style: { fontSize: '0.8rem', color: '#1a7a4a', fontWeight: 600 }, children: [arrivals, "a", pu > 0 && _jsxs("span", { className: "pickup-tag", children: [" +", pu] })] })] }, date + '-dsa')] }));
|
||||
})] }));
|
||||
}
|
||||
// ── Required Hours Table ──────────────────────────────────────────────────────
|
||||
function RequiredTable({ bookings, required }) {
|
||||
const { dates, categories: cats } = bookings;
|
||||
return (_jsxs("table", { className: "hk-table", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { className: "col-label", children: "Category" }), dates.map(d => _jsx("th", { children: fmtDayHeader(d) }, d))] }) }), _jsx("tbody", { children: cats.map(cat => (_jsxs("tr", { children: [_jsx("td", { className: "col-label", children: cat.name }), dates.map(date => {
|
||||
const booked = required[date].by_cat[cat.id] || 0;
|
||||
const pu = required[date].pickup_by_cat[cat.id] || 0;
|
||||
return (_jsxs("td", { children: [booked === 0 && pu === 0 ? '—' : fmtH(booked), pu > 0.001 && _jsxs("span", { className: "pickup-tag", children: [" (+", fmtH(pu), ")"] })] }, date));
|
||||
})] }, cat.id))) }), _jsxs("tfoot", { children: [_jsxs("tr", { style: { background: '#f1f5f9' }, children: [_jsx("td", { className: "col-label", style: { color: 'var(--text-mid)', fontSize: '0.78rem' }, children: "Booked hrs" }), dates.map(d => _jsx("td", { children: fmtH(required[d].booked) }, d))] }), _jsxs("tr", { style: { background: '#f1f5f9' }, children: [_jsx("td", { className: "col-label", style: { color: 'var(--text-mid)', fontSize: '0.78rem' }, children: "Pickup hrs" }), dates.map(d => _jsx("td", { children: required[d].pickup > 0.001 ? fmtH(required[d].pickup) : '—' }, d))] }), _jsxs("tr", { style: { background: '#f1f5f9' }, children: [_jsx("td", { className: "col-label", style: { color: 'var(--text-mid)', fontSize: '0.78rem' }, children: "Recurring tasks" }), dates.map(d => _jsx("td", { children: required[d].general > 0.001 ? fmtH(required[d].general) : '—' }, d))] }), dates.some(d => required[d].adjustments !== 0) && (_jsxs("tr", { style: { background: '#f1f5f9' }, children: [_jsx("td", { className: "col-label", style: { color: 'var(--text-mid)', fontSize: '0.78rem' }, children: "Adjustments" }), dates.map(d => {
|
||||
const adj = required[d].adjustments;
|
||||
return (_jsx("td", { style: { color: adj < 0 ? 'var(--danger)' : adj > 0 ? '#1a7a4a' : 'var(--text-mid)' }, children: adj === 0 ? '—' : (adj > 0 ? '+' : '') + fmtH(adj) }, d));
|
||||
})] })), _jsxs("tr", { style: { background: '#e2e8f0' }, children: [_jsx("td", { className: "col-label", children: "Total Required" }), dates.map(d => (_jsxs("td", { style: { fontWeight: 700 }, children: [fmtH(required[d].total), required[d].pickup > 0.001 && (_jsxs("span", { className: "pickup-tag", style: { display: 'block', fontSize: '0.7rem' }, children: ["inc ", fmtH(required[d].pickup), " pickup"] }))] }, d)))] })] })] }));
|
||||
}
|
||||
// ── Staff Table ───────────────────────────────────────────────────────────────
|
||||
function pivotWfShifts(wfShifts, dates) {
|
||||
const members = {};
|
||||
for (const date of dates) {
|
||||
const day = wfShifts[date];
|
||||
if (!day)
|
||||
continue;
|
||||
for (const s of day.staff) {
|
||||
if (!members[s.id])
|
||||
members[s.id] = { id: s.id, name: s.name, days: {} };
|
||||
members[s.id].days[date] = { hours: s.hours, times: s.times };
|
||||
}
|
||||
}
|
||||
return Object.values(members).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
function StaffTable({ bookings, staff, required, warnOverRed, warnOverAmber, warnUnderAmber, warnUnderRed, onChange, wfShifts, wfStaff }) {
|
||||
const { dates } = bookings;
|
||||
function setMemberName(i, name) {
|
||||
const next = staff.map((m, idx) => idx === i ? { ...m, name } : m);
|
||||
onChange(next);
|
||||
}
|
||||
function setMemberHours(i, date, val) {
|
||||
const hrs = parseFloat(val);
|
||||
const next = staff.map((m, idx) => {
|
||||
if (idx !== i)
|
||||
return m;
|
||||
const hours = { ...m.hours };
|
||||
if (!isNaN(hrs) && hrs >= 0)
|
||||
hours[date] = hrs;
|
||||
else
|
||||
delete hours[date];
|
||||
return { ...m, hours };
|
||||
});
|
||||
onChange(next);
|
||||
}
|
||||
function removeRow(i) {
|
||||
onChange(staff.filter((_, idx) => idx !== i));
|
||||
}
|
||||
const rotaMembers = pivotWfShifts(wfShifts, dates);
|
||||
return (_jsxs("table", { className: "hk-table", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { className: "col-label", children: "Staff Member" }), dates.map(d => _jsx("th", { children: fmtDayHeader(d) }, d)), _jsx("th", { style: { width: '32px' } })] }) }), _jsxs("tbody", { children: [rotaMembers.map(member => (_jsxs("tr", { style: { background: 'rgba(42,100,72,0.05)' }, children: [_jsxs("td", { className: "col-label", children: [_jsx("span", { style: {
|
||||
display: 'inline-block', fontSize: '0.67rem', fontWeight: 700,
|
||||
background: 'rgba(42,100,72,0.18)', color: '#1a7a4a',
|
||||
borderRadius: '3px', padding: '0 4px', marginRight: '0.4rem', lineHeight: '1.5',
|
||||
}, children: "WF" }), member.name] }), dates.map(date => {
|
||||
const shift = member.days[date];
|
||||
return (_jsx("td", { style: { textAlign: 'center', padding: '0.3rem 0.4rem', verticalAlign: 'middle' }, children: shift ? (_jsxs(_Fragment, { children: [_jsx("div", { style: { fontSize: '0.68rem', color: 'var(--text-mid)', lineHeight: 1.25 }, children: shift.times }), _jsx("div", { style: { fontWeight: 600 }, children: fmtH(shift.hours) })] })) : _jsx("span", { style: { color: 'var(--text-mid)' }, children: "\u2014" }) }, date));
|
||||
}), _jsx("td", {})] }, 'wf-' + member.id))), wfStaff.length > 0 && (_jsx("datalist", { id: "wf-staff-datalist", children: wfStaff.map(s => _jsx("option", { value: s.name }, s.id)) })), staff.map((member, i) => (_jsxs("tr", { children: [_jsx("td", { className: "col-label", style: { padding: '0.3rem 0.5rem' }, children: _jsx("input", { className: "hk-text-input", value: member.name, placeholder: "Staff name", list: wfStaff.length > 0 ? 'wf-staff-datalist' : undefined, onChange: e => setMemberName(i, e.target.value) }) }), dates.map(date => (_jsx("td", { style: { padding: '0.3rem 0.4rem' }, children: _jsx("input", { type: "number", className: "hk-num-input", min: 0, max: 24, step: 0.5, value: member.hours[date] ?? '', placeholder: "0", onChange: e => setMemberHours(i, date, e.target.value) }) }, date))), _jsx("td", { children: _jsx("button", { onClick: () => removeRow(i), style: {
|
||||
background: 'none', border: 'none', color: 'var(--text-mid)',
|
||||
fontSize: '1rem', padding: '0.2rem 0.4rem',
|
||||
}, children: "\u00D7" }) })] }, i)))] }), _jsx("tfoot", { children: (() => {
|
||||
const avail = {};
|
||||
for (const date of dates) {
|
||||
const rotaHrs = rotaMembers.reduce((s, m) => s + (m.days[date]?.hours || 0), 0);
|
||||
const manualHrs = staff.reduce((s, m) => s + (m.hours[date] || 0), 0);
|
||||
avail[date] = rotaHrs + manualHrs;
|
||||
}
|
||||
return (_jsxs(_Fragment, { children: [_jsxs("tr", { children: [_jsx("td", { className: "col-label", style: { fontSize: '0.78rem', color: 'var(--text-mid)' }, children: "Total Available" }), dates.map(date => _jsx("td", { children: fmtH(avail[date]) }, date)), _jsx("td", {})] }), _jsxs("tr", { children: [_jsx("td", { className: "col-label", style: { fontSize: '0.72rem', color: 'var(--text-mid)' }, children: "vs Booked" }), dates.map(date => (_jsx("td", { children: _jsx(PlainDiffCell, { available: avail[date], required: required[date].booked, warnOverAmber: warnOverAmber, warnUnderAmber: warnUnderAmber }) }, date))), _jsx("td", {})] }), _jsxs("tr", { children: [_jsx("td", { className: "col-label", style: { fontSize: '0.72rem', color: 'var(--text-mid)' }, children: "with Recurring Tasks" }), dates.map(date => (_jsx("td", { children: _jsx(PlainDiffCell, { available: avail[date], required: required[date].booked + required[date].general, warnOverAmber: warnOverAmber, warnUnderAmber: warnUnderAmber }) }, date))), _jsx("td", {})] }), _jsxs("tr", { children: [_jsx("td", { className: "col-label", style: { fontSize: '0.72rem', color: 'var(--text-mid)' }, children: "with Pickup" }), dates.map(date => (_jsx("td", { children: _jsx(PlainDiffCell, { available: avail[date], required: required[date].booked + required[date].general + required[date].pickup, warnOverAmber: warnOverAmber, warnUnderAmber: warnUnderAmber }) }, date))), _jsx("td", {})] }), _jsxs("tr", { style: { background: '#e2e8f0' }, children: [_jsx("td", { className: "col-label", style: { fontWeight: 700 }, children: "with Adjustments" }), dates.map(date => (_jsx("td", { children: _jsx(DiffCell, { available: avail[date], required: required[date].total, warnOverRed: warnOverRed, warnOverAmber: warnOverAmber, warnUnderAmber: warnUnderAmber, warnUnderRed: warnUnderRed }) }, date))), _jsx("td", {})] })] }));
|
||||
})() })] }));
|
||||
}
|
||||
function PlainDiffCell({ available, required, warnOverAmber, warnUnderAmber }) {
|
||||
if (available === 0 && required === 0)
|
||||
return _jsx("span", { style: { color: 'var(--text-mid)' }, children: "\u2014" });
|
||||
const diff = available - required;
|
||||
const icon = diff > warnOverAmber ? '⚠' : diff < -warnUnderAmber ? '✗' : '✓';
|
||||
return _jsxs("span", { style: { color: 'var(--text-mid)', fontSize: '0.82rem' }, children: [icon, " ", diff >= 0 ? '+' : '', fmtH(diff)] });
|
||||
}
|
||||
function DiffCell({ available, required, warnOverRed, warnOverAmber, warnUnderAmber, warnUnderRed }) {
|
||||
if (available === 0 && required === 0)
|
||||
return _jsx("span", { style: { color: 'var(--text-mid)' }, children: "\u2014" });
|
||||
const diff = available - required;
|
||||
if (diff > warnOverRed)
|
||||
return _jsxs("span", { className: "diff-over-red", children: ["\u26A0 ", fmtH(diff), " spare"] });
|
||||
if (diff > warnOverAmber)
|
||||
return _jsxs("span", { className: "diff-over", children: ["\u26A0 ", fmtH(diff), " spare"] });
|
||||
if (diff < -warnUnderRed)
|
||||
return _jsxs("span", { className: "diff-under", children: ["\u2717 ", fmtH(Math.abs(diff)), " short"] });
|
||||
if (diff < -warnUnderAmber)
|
||||
return _jsxs("span", { className: "diff-under-amber", children: ["\u2717 ", fmtH(Math.abs(diff)), " short"] });
|
||||
return _jsxs("span", { className: "diff-ok", children: ["\u2713 ", diff >= 0 ? '+' : '', fmtH(diff)] });
|
||||
}
|
||||
// ── Adjustments Table ─────────────────────────────────────────────────────────
|
||||
function AdjustmentsTable({ bookings, adjustments, onChange }) {
|
||||
const { dates } = bookings;
|
||||
function setLabel(i, label) {
|
||||
onChange(adjustments.map((a, idx) => idx === i ? { ...a, label } : a));
|
||||
}
|
||||
function setHours(i, date, val) {
|
||||
const hrs = parseFloat(val);
|
||||
onChange(adjustments.map((a, idx) => {
|
||||
if (idx !== i)
|
||||
return a;
|
||||
const hours = { ...a.hours };
|
||||
if (!isNaN(hrs))
|
||||
hours[date] = hrs;
|
||||
else
|
||||
delete hours[date];
|
||||
return { ...a, hours };
|
||||
}));
|
||||
}
|
||||
function removeRow(i) {
|
||||
onChange(adjustments.filter((_, idx) => idx !== i));
|
||||
}
|
||||
if (adjustments.length === 0) {
|
||||
return (_jsx("p", { style: { color: 'var(--text-mid)', fontSize: '0.82rem', padding: '0.5rem 0' }, children: "No adjustments \u2014 use \"+ Add adjustment\" above to add a one-off hour offset for a specific date." }));
|
||||
}
|
||||
return (_jsxs("table", { className: "hk-table", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { className: "col-label", children: "Label" }), dates.map(d => _jsx("th", { children: fmtDayHeader(d) }, d)), _jsx("th", { style: { width: '32px' } })] }) }), _jsx("tbody", { children: adjustments.map((adj, i) => (_jsxs("tr", { children: [_jsx("td", { className: "col-label", style: { padding: '0.3rem 0.5rem' }, children: _jsx("input", { className: "hk-text-input", value: adj.label, placeholder: "e.g. Rooms from Sunday", onChange: e => setLabel(i, e.target.value) }) }), dates.map(date => (_jsx("td", { style: { padding: '0.3rem 0.4rem' }, children: _jsx("input", { type: "number", className: "hk-num-input", step: 0.25, value: adj.hours[date] ?? '', placeholder: "0", onChange: e => setHours(i, date, e.target.value) }) }, date))), _jsx("td", { children: _jsx("button", { onClick: () => removeRow(i), style: {
|
||||
background: 'none', border: 'none', color: 'var(--text-mid)',
|
||||
fontSize: '1rem', padding: '0.2rem 0.4rem',
|
||||
}, children: "\u00D7" }) })] }, i))) }), _jsx("tfoot", { children: _jsxs("tr", { children: [_jsx("td", { className: "col-label", style: { fontSize: '0.78rem', color: 'var(--text-mid)' }, children: "Total" }), dates.map(date => {
|
||||
const total = adjustments.reduce((s, a) => s + (a.hours[date] || 0), 0);
|
||||
return (_jsx("td", { style: { color: total < 0 ? 'var(--danger)' : total > 0 ? '#1a7a4a' : 'var(--text-mid)', fontWeight: total !== 0 ? 600 : undefined }, children: total === 0 ? '—' : (total > 0 ? '+' : '') + fmtH(total) }, date));
|
||||
}), _jsx("td", {})] }) })] }));
|
||||
}
|
||||
|
|
@ -157,7 +157,7 @@ export function Planner() {
|
|||
function flash(text: string, err = false) {
|
||||
if (saveMsgTimer.current) clearTimeout(saveMsgTimer.current)
|
||||
setSaveMsg({ text, err })
|
||||
saveMsgTimer.current = setTimeout(() => setSaveMsg(null), err ? 15000 : 2500)
|
||||
saveMsgTimer.current = setTimeout(() => setSaveMsg(null), err ? 5000 : 2500)
|
||||
}
|
||||
|
||||
function stampLastReviewed() {
|
||||
|
|
@ -212,19 +212,18 @@ export function Planner() {
|
|||
}
|
||||
}, [wfShifts])
|
||||
|
||||
// Keepalive save on unload (sendBeacon only supports POST but routes are PUT)
|
||||
// Beacon save on unload
|
||||
useEffect(() => {
|
||||
function keepalivePut(path: string, body: unknown) {
|
||||
fetch(path, {
|
||||
method: 'PUT', keepalive: true, credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
function onUnload() {
|
||||
if (staff.length) keepalivePut('/hk-planner/api/config/staff', { staff_data: staff })
|
||||
if (Object.keys(pickup).length) keepalivePut('/hk-planner/api/config/pickup', { pickup_data: pickup })
|
||||
if (adjustments.length) keepalivePut('/hk-planner/api/config/adjustments', { adjustments })
|
||||
if (staff.length) {
|
||||
navigator.sendBeacon('/hk-planner/api/config/staff', JSON.stringify({ staff_data: staff }))
|
||||
}
|
||||
if (Object.keys(pickup).length) {
|
||||
navigator.sendBeacon('/hk-planner/api/config/pickup', JSON.stringify({ pickup_data: pickup }))
|
||||
}
|
||||
if (adjustments.length) {
|
||||
navigator.sendBeacon('/hk-planner/api/config/adjustments', JSON.stringify({ adjustments }))
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', onUnload)
|
||||
return () => window.removeEventListener('beforeunload', onUnload)
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
export function can(user, cap) {
|
||||
return user.is_admin || user.caps.includes(cap);
|
||||
}
|
||||
10096
workforce-api-docs.md
10096
workforce-api-docs.md
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue