Move general tasks + time reqs to Settings; add date adjustments
- Rename 'Category Settings' to 'Settings' (nav, page title, component) - Move Recurring General Tasks and Time Requirements editing to Settings page - Planner still loads both for calcRequired calculations - Add date-specific Adjustments section to Planner: per-date +/- hour offsets keyed by YYYY-MM-DD (non-recurring); feed into Required Hours total - Adjustments row shown in Required Hours tfoot when non-zero Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b803b01ae1
commit
c0b1d2a62e
7 changed files with 416 additions and 238 deletions
|
|
@ -8,7 +8,7 @@ export async function configRoutes(app) {
|
||||||
// ── GET /api/config — all settings at once ──────────────────────────────
|
// ── GET /api/config — all settings at once ──────────────────────────────
|
||||||
|
|
||||||
app.get('/api/config', async (req) => {
|
app.get('/api/config', async (req) => {
|
||||||
const [timeReqs, staffData, pickupData, generalTasks, lastReviewed, toleranceMins, workforceRota, workforceDepts] =
|
const [timeReqs, staffData, pickupData, generalTasks, lastReviewed, toleranceMins, workforceRota, workforceDepts, adjustments] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
getConfig('time_requirements', {}),
|
getConfig('time_requirements', {}),
|
||||||
getConfig('staff_data', []),
|
getConfig('staff_data', []),
|
||||||
|
|
@ -18,6 +18,7 @@ export async function configRoutes(app) {
|
||||||
getConfig('tolerance_minutes', 30),
|
getConfig('tolerance_minutes', 30),
|
||||||
getConfig('workforce_rota', null),
|
getConfig('workforce_rota', null),
|
||||||
getConfig('workforce_departments', []),
|
getConfig('workforce_departments', []),
|
||||||
|
getConfig('adjustments', []),
|
||||||
])
|
])
|
||||||
|
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
|
|
@ -35,6 +36,7 @@ export async function configRoutes(app) {
|
||||||
tolerance_minutes: toleranceMins != null ? toleranceMins : 30,
|
tolerance_minutes: toleranceMins != null ? toleranceMins : 30,
|
||||||
workforce_rota: workforceRota || null,
|
workforce_rota: workforceRota || null,
|
||||||
workforce_departments: workforceDepts || [],
|
workforce_departments: workforceDepts || [],
|
||||||
|
adjustments: adjustments || [],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -138,6 +140,27 @@ export async function configRoutes(app) {
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── PUT /api/config/adjustments ──────────────────────────────────────────
|
||||||
|
|
||||||
|
app.put('/api/config/adjustments', async (req, reply) => {
|
||||||
|
const { adjustments } = req.body || {}
|
||||||
|
if (!Array.isArray(adjustments)) return reply.status(400).send({ error: 'Invalid data' })
|
||||||
|
|
||||||
|
const clean = adjustments
|
||||||
|
.filter(a => a && typeof a.label === 'string')
|
||||||
|
.map(a => ({
|
||||||
|
label: a.label.trim(),
|
||||||
|
hours: Object.fromEntries(
|
||||||
|
Object.entries(a.hours || {})
|
||||||
|
.filter(([k]) => /^\d{4}-\d{2}-\d{2}$/.test(k))
|
||||||
|
.map(([k, v]) => [k, parseFloat(v) || 0])
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
await setConfig('adjustments', clean)
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
|
||||||
// ── GET /api/categories — settings cap required ──────────────────────────
|
// ── GET /api/categories — settings cap required ──────────────────────────
|
||||||
|
|
||||||
app.get('/api/categories', { preHandler: requireCap('settings') }, async (req, reply) => {
|
app.get('/api/categories', { preHandler: requireCap('settings') }, async (req, reply) => {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { AuthGate } from './components/AuthGate'
|
import { AuthGate } from './components/AuthGate'
|
||||||
import { Layout } from './components/Layout'
|
import { Layout } from './components/Layout'
|
||||||
import { Planner } from './pages/Planner'
|
import { Planner } from './pages/Planner'
|
||||||
import { CategorySettings } from './pages/CategorySettings'
|
import { Settings } from './pages/CategorySettings'
|
||||||
import { can } from './types'
|
import { can } from './types'
|
||||||
import type { User } from './types'
|
import type { User } from './types'
|
||||||
|
|
||||||
|
|
@ -12,7 +12,7 @@ function AppRoutes({ user }: { user: User }) {
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/planner" replace />} />
|
<Route path="/" element={<Navigate to="/planner" replace />} />
|
||||||
<Route path="/planner" element={<Planner />} />
|
<Route path="/planner" element={<Planner />} />
|
||||||
<Route path="/settings" element={can(user, 'settings') ? <CategorySettings /> : <Navigate to="/planner" replace />} />
|
<Route path="/settings" element={can(user, 'settings') ? <Settings /> : <Navigate to="/planner" replace />} />
|
||||||
<Route path="*" element={<Navigate to="/planner" replace />} />
|
<Route path="*" element={<Navigate to="/planner" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { BookingsData, StaffMember, GeneralTask, PickupData, TimeReqs, WorkforceRota } from './types'
|
import type { BookingsData, StaffMember, GeneralTask, PickupData, TimeReqs, WorkforceRota, Adjustment } from './types'
|
||||||
|
|
||||||
const BASE = '/hk-planner/api'
|
const BASE = '/hk-planner/api'
|
||||||
|
|
||||||
|
|
@ -11,6 +11,7 @@ export interface ConfigData {
|
||||||
tolerance_minutes: number
|
tolerance_minutes: number
|
||||||
workforce_rota: WorkforceRota | null
|
workforce_rota: WorkforceRota | null
|
||||||
workforce_departments: string[]
|
workforce_departments: string[]
|
||||||
|
adjustments: Adjustment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CategoryConfig {
|
export interface CategoryConfig {
|
||||||
|
|
@ -116,3 +117,11 @@ export function syncWorkforceRota(start: string, end: string): Promise<Workforce
|
||||||
export function getWorkforceStaff(): Promise<{ id: string; name: string }[]> {
|
export function getWorkforceStaff(): Promise<{ id: string; name: string }[]> {
|
||||||
return request('/workforce/staff')
|
return request('/workforce/staff')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function putAdjustments(adjustments: Adjustment[]): Promise<{ ok: boolean }> {
|
||||||
|
return request('/config/adjustments', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ adjustments }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export function Layout({ user, children }: Props) {
|
||||||
|
|
||||||
<div style={{ flex: 1, padding: '0.5rem 0', overflowY: 'auto' }}>
|
<div style={{ flex: 1, padding: '0.5rem 0', overflowY: 'auto' }}>
|
||||||
<NavItem to="/planner" icon={CalendarClock} label="Planner" />
|
<NavItem to="/planner" icon={CalendarClock} label="Planner" />
|
||||||
{can(user, 'settings') && <NavItem to="/settings" icon={Settings} label="Category Settings" />}
|
{can(user, 'settings') && <NavItem to="/settings" icon={Settings} label="Settings" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ padding: '0.75rem 1rem', borderTop: '1px solid var(--surface-2)' }}>
|
<div style={{ padding: '0.75rem 1rem', borderTop: '1px solid var(--surface-2)' }}>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,17 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { GripVertical } from 'lucide-react'
|
import { GripVertical } from 'lucide-react'
|
||||||
import { getCategories, putCategories, testNewbook, getConfig, getWorkforceDepartments, putWorkforceDepartments } from '../api'
|
import {
|
||||||
|
getCategories, putCategories, testNewbook,
|
||||||
|
getConfig, getWorkforceDepartments, putWorkforceDepartments,
|
||||||
|
putGeneralTasks, putTimeReq,
|
||||||
|
} from '../api'
|
||||||
import type { CategoryConfig } from '../api'
|
import type { CategoryConfig } from '../api'
|
||||||
|
import type { GeneralTask, TimeReqs } from '../types'
|
||||||
|
|
||||||
export function CategorySettings() {
|
const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||||
|
|
||||||
|
export function Settings() {
|
||||||
|
// ── Category state ─────────────────────────────────────────────────────────
|
||||||
const [cats, setCats] = useState<CategoryConfig[]>([])
|
const [cats, setCats] = useState<CategoryConfig[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
@ -12,7 +20,7 @@ export function CategorySettings() {
|
||||||
const [msg, setMsg] = useState('')
|
const [msg, setMsg] = useState('')
|
||||||
const [dragIdx, setDragIdx] = useState<number | null>(null)
|
const [dragIdx, setDragIdx] = useState<number | null>(null)
|
||||||
|
|
||||||
// Workforce department state
|
// ── Workforce state ────────────────────────────────────────────────────────
|
||||||
const [wfDepts, setWfDepts] = useState<{ id: string; name: string }[]>([])
|
const [wfDepts, setWfDepts] = useState<{ id: string; name: string }[]>([])
|
||||||
const [wfSelected, setWfSelected] = useState<string[]>([])
|
const [wfSelected, setWfSelected] = useState<string[]>([])
|
||||||
const [wfLoading, setWfLoading] = useState(true)
|
const [wfLoading, setWfLoading] = useState(true)
|
||||||
|
|
@ -21,14 +29,21 @@ export function CategorySettings() {
|
||||||
const [wfSaving, setWfSaving] = useState(false)
|
const [wfSaving, setWfSaving] = useState(false)
|
||||||
const [wfMsg, setWfMsg] = useState('')
|
const [wfMsg, setWfMsg] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
// ── Recurring general tasks state ──────────────────────────────────────────
|
||||||
getCategories()
|
const [tasks, setTasks] = useState<GeneralTask[]>([])
|
||||||
.then(r => { setCats(r.categories); setLoading(false) })
|
const [taskSaving, setTaskSaving] = useState(false)
|
||||||
.catch(e => { setError(e.message); setLoading(false) })
|
const [taskMsg, setTaskMsg] = useState('')
|
||||||
|
const [taskError, setTaskError] = useState('')
|
||||||
|
|
||||||
// Load selected dept IDs from config and dept list in parallel
|
// ── Time requirements state ────────────────────────────────────────────────
|
||||||
|
const [timeReqs, setTimeReqs] = useState<TimeReqs>({})
|
||||||
|
const [timeMsg, setTimeMsg] = useState('')
|
||||||
|
const [timeError, setTimeError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
getConfig().then(c => c.workforce_departments),
|
getCategories(),
|
||||||
|
getConfig(),
|
||||||
getWorkforceDepartments().catch((e: Error) => {
|
getWorkforceDepartments().catch((e: Error) => {
|
||||||
if (e.message.includes('503') || e.message.toLowerCase().includes('not configured')) {
|
if (e.message.includes('503') || e.message.toLowerCase().includes('not configured')) {
|
||||||
setWfNotConfigured(true)
|
setWfNotConfigured(true)
|
||||||
|
|
@ -37,13 +52,23 @@ export function CategorySettings() {
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}),
|
}),
|
||||||
]).then(([selected, depts]) => {
|
]).then(([catResp, cfg, depts]) => {
|
||||||
if (selected) setWfSelected(selected)
|
setCats(catResp.categories)
|
||||||
|
setTasks(cfg.general_tasks || [])
|
||||||
|
setTimeReqs(cfg.time_requirements || {})
|
||||||
|
setWfSelected(cfg.workforce_departments || [])
|
||||||
if (depts) setWfDepts(depts)
|
if (depts) setWfDepts(depts)
|
||||||
|
setLoading(false)
|
||||||
|
setWfLoading(false)
|
||||||
|
}).catch(e => {
|
||||||
|
setError(e.message)
|
||||||
|
setLoading(false)
|
||||||
setWfLoading(false)
|
setWfLoading(false)
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// ── Category handlers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function toggleExcluded(i: number) {
|
function toggleExcluded(i: number) {
|
||||||
setCats(cats.map((c, idx) => idx === i ? { ...c, excluded: !c.excluded } : c))
|
setCats(cats.map((c, idx) => idx === i ? { ...c, excluded: !c.excluded } : c))
|
||||||
}
|
}
|
||||||
|
|
@ -65,81 +90,106 @@ export function CategorySettings() {
|
||||||
|
|
||||||
function onDragEnd() { setDragIdx(null) }
|
function onDragEnd() { setDragIdx(null) }
|
||||||
|
|
||||||
async function save() {
|
async function saveCats() {
|
||||||
setSaving(true)
|
setSaving(true); setError(''); setMsg('')
|
||||||
setError('')
|
|
||||||
setMsg('')
|
|
||||||
try {
|
try {
|
||||||
const order = cats.map(c => c.id)
|
await putCategories(cats.map(c => c.id), cats.filter(c => c.excluded).map(c => c.id))
|
||||||
const excluded = cats.filter(c => c.excluded).map(c => c.id)
|
setMsg('Saved'); setTimeout(() => setMsg(''), 2500)
|
||||||
await putCategories(order, excluded)
|
|
||||||
setMsg('Saved')
|
|
||||||
setTimeout(() => setMsg(''), 2500)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Save failed')
|
setError(e instanceof Error ? e.message : 'Save failed')
|
||||||
} finally {
|
} finally { setSaving(false) }
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleTest() {
|
async function handleTest() {
|
||||||
setTesting(true)
|
setTesting(true); setError(''); setMsg('')
|
||||||
setError('')
|
|
||||||
setMsg('')
|
|
||||||
try {
|
try {
|
||||||
const res = await testNewbook()
|
const res = await testNewbook()
|
||||||
setMsg(res.ok ? `Connection OK: ${res.message || ''}` : `Failed: ${res.error || 'unknown'}`)
|
setMsg(res.ok ? `Connection OK: ${res.message || ''}` : `Failed: ${res.error || 'unknown'}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Test failed')
|
setError(e instanceof Error ? e.message : 'Test failed')
|
||||||
} finally {
|
} finally { setTesting(false) }
|
||||||
setTesting(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Workforce handlers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function toggleWfDept(id: string) {
|
function toggleWfDept(id: string) {
|
||||||
setWfSelected(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
|
setWfSelected(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveWfDepts() {
|
async function saveWfDepts() {
|
||||||
setWfSaving(true)
|
setWfSaving(true); setWfError(''); setWfMsg('')
|
||||||
setWfError('')
|
|
||||||
setWfMsg('')
|
|
||||||
try {
|
try {
|
||||||
await putWorkforceDepartments(wfSelected)
|
await putWorkforceDepartments(wfSelected)
|
||||||
setWfMsg('Departments saved')
|
setWfMsg('Departments saved'); setTimeout(() => setWfMsg(''), 2500)
|
||||||
setTimeout(() => setWfMsg(''), 2500)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setWfError(e instanceof Error ? e.message : 'Save failed')
|
setWfError(e instanceof Error ? e.message : 'Save failed')
|
||||||
} finally {
|
} finally { setWfSaving(false) }
|
||||||
setWfSaving(false)
|
}
|
||||||
|
|
||||||
|
// ── Recurring tasks handlers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function setTaskName(i: number, name: string) {
|
||||||
|
setTasks(tasks.map((t, idx) => idx === i ? { ...t, name } : t))
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTaskHours(i: number, day: string, val: string) {
|
||||||
|
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: number) {
|
||||||
|
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: string, action: string, value: number) {
|
||||||
|
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')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div style={{ padding: '2rem', color: 'var(--text-mid)' }}>Loading categories…</div>
|
return <div style={{ padding: '2rem', color: 'var(--text-mid)' }}>Loading…</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '1.5rem', maxWidth: '600px' }}>
|
<div style={{ padding: '1.5rem', maxWidth: '680px' }}>
|
||||||
<h1 style={{ fontSize: '1.1rem', fontWeight: 700, color: 'var(--text-dark)', marginBottom: '0.25rem' }}>
|
<h1 style={{ fontSize: '1.1rem', fontWeight: 700, color: 'var(--text-dark)', marginBottom: '1.5rem' }}>
|
||||||
Category Settings
|
Settings
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ fontSize: '0.82rem', color: 'var(--text-mid)', marginBottom: '1.5rem' }}>
|
|
||||||
|
{/* ── Room Categories ──────────────────────────────────────────────────── */}
|
||||||
|
<SectionHeading title="Room Categories" />
|
||||||
|
<p style={{ fontSize: '0.82rem', color: 'var(--text-mid)', marginBottom: '1rem' }}>
|
||||||
Drag to reorder. Toggle to exclude categories from the planner.
|
Drag to reorder. Toggle to exclude categories from the planner.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{error && (
|
{error && <Banner type="error">{error}</Banner>}
|
||||||
<div style={{ marginBottom: '1rem', padding: '0.75rem', borderRadius: '8px', background: '#fee2e2', color: 'var(--danger)', fontSize: '0.875rem' }}>
|
{msg && <Banner type="ok">{msg}</Banner>}
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{msg && (
|
|
||||||
<div style={{ marginBottom: '1rem', padding: '0.75rem', borderRadius: '8px', background: '#dcfce7', color: 'var(--success)', fontSize: '0.875rem' }}>
|
|
||||||
{msg}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '1.25rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '1rem' }}>
|
||||||
{cats.map((cat, i) => (
|
{cats.map((cat, i) => (
|
||||||
<div
|
<div
|
||||||
key={cat.id}
|
key={cat.id}
|
||||||
|
|
@ -167,11 +217,7 @@ export function CategorySettings() {
|
||||||
{cat.room_count} rooms
|
{cat.room_count} rooms
|
||||||
</span>
|
</span>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', fontSize: '0.8rem', color: 'var(--text-mid)' }}>
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', fontSize: '0.8rem', color: 'var(--text-mid)' }}>
|
||||||
<input
|
<input type="checkbox" checked={!cat.excluded} onChange={() => toggleExcluded(i)} />
|
||||||
type="checkbox"
|
|
||||||
checked={!cat.excluded}
|
|
||||||
onChange={() => toggleExcluded(i)}
|
|
||||||
/>
|
|
||||||
Active
|
Active
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -179,60 +225,31 @@ export function CategorySettings() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginBottom: '2.5rem' }}>
|
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginBottom: '2.5rem' }}>
|
||||||
<button
|
<Btn onClick={saveCats} disabled={saving} primary>{saving ? 'Saving…' : 'Save Order & Visibility'}</Btn>
|
||||||
onClick={save}
|
<Btn onClick={handleTest} disabled={testing}>{testing ? 'Testing…' : 'Test Newbook Connection'}</Btn>
|
||||||
disabled={saving}
|
|
||||||
style={{
|
|
||||||
background: 'var(--hk-green)', color: '#fff', border: 'none',
|
|
||||||
borderRadius: '6px', padding: '0.5rem 1.25rem', fontSize: '0.875rem',
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{saving ? 'Saving…' : 'Save Order & Visibility'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleTest}
|
|
||||||
disabled={testing}
|
|
||||||
style={{
|
|
||||||
background: 'var(--card-bg)', color: 'var(--text-dark)',
|
|
||||||
border: '1px solid var(--card-border)',
|
|
||||||
borderRadius: '6px', padding: '0.5rem 1.25rem', fontSize: '0.875rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{testing ? 'Testing…' : 'Test Newbook Connection'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Workforce Departments ─────────────────────────────────────────────── */}
|
{/* ── Workforce Departments ────────────────────────────────────────────── */}
|
||||||
<h2 style={{ fontSize: '1rem', fontWeight: 700, color: 'var(--text-dark)', marginBottom: '0.25rem' }}>
|
<Divider />
|
||||||
Workforce Departments
|
<SectionHeading title="Workforce Departments" />
|
||||||
</h2>
|
|
||||||
<p style={{ fontSize: '0.82rem', color: 'var(--text-mid)', marginBottom: '1rem' }}>
|
<p style={{ fontSize: '0.82rem', color: 'var(--text-mid)', marginBottom: '1rem' }}>
|
||||||
Select the department(s) whose shifts should appear in the HK staff rota.
|
Select the department(s) whose shifts should appear in the HK staff rota.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{wfNotConfigured && (
|
{wfNotConfigured && (
|
||||||
<div style={{ padding: '0.75rem', borderRadius: '8px', background: '#f8fafc', border: '1px solid var(--card-border)', color: 'var(--text-mid)', fontSize: '0.85rem' }}>
|
<div style={{ padding: '0.75rem', borderRadius: '8px', background: '#f8fafc', border: '1px solid var(--card-border)', color: 'var(--text-mid)', fontSize: '0.85rem', marginBottom: '2rem' }}>
|
||||||
Workforce integration not configured — add the bearer token in <strong>Settings → Integrations → Workforce</strong>.
|
Workforce integration not configured — add the bearer token in <strong>Settings → Integrations → Workforce</strong>.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!wfNotConfigured && wfLoading && (
|
{!wfNotConfigured && wfLoading && (
|
||||||
<div style={{ color: 'var(--text-mid)', fontSize: '0.85rem' }}>Loading departments…</div>
|
<div style={{ color: 'var(--text-mid)', fontSize: '0.85rem', marginBottom: '2rem' }}>Loading departments…</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!wfNotConfigured && !wfLoading && (
|
{!wfNotConfigured && !wfLoading && (
|
||||||
<>
|
<div style={{ marginBottom: '2.5rem' }}>
|
||||||
{wfError && (
|
{wfError && <Banner type="error">{wfError}</Banner>}
|
||||||
<div style={{ marginBottom: '0.75rem', padding: '0.75rem', borderRadius: '8px', background: '#fee2e2', color: 'var(--danger)', fontSize: '0.875rem' }}>
|
{wfMsg && <Banner type="ok">{wfMsg}</Banner>}
|
||||||
{wfError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{wfMsg && (
|
|
||||||
<div style={{ marginBottom: '0.75rem', padding: '0.75rem', borderRadius: '8px', background: '#dcfce7', color: 'var(--success)', fontSize: '0.875rem' }}>
|
|
||||||
{wfMsg}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{wfDepts.length === 0 ? (
|
{wfDepts.length === 0 ? (
|
||||||
<div style={{ color: 'var(--text-mid)', fontSize: '0.85rem', marginBottom: '1rem' }}>
|
<div style={{ color: 'var(--text-mid)', fontSize: '0.85rem', marginBottom: '1rem' }}>
|
||||||
|
|
@ -251,11 +268,7 @@ export function CategorySettings() {
|
||||||
cursor: 'pointer', fontSize: '0.9rem', color: 'var(--text-dark)',
|
cursor: 'pointer', fontSize: '0.9rem', color: 'var(--text-dark)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<input type="checkbox" checked={wfSelected.includes(dept.id)} onChange={() => toggleWfDept(dept.id)} />
|
||||||
type="checkbox"
|
|
||||||
checked={wfSelected.includes(dept.id)}
|
|
||||||
onChange={() => toggleWfDept(dept.id)}
|
|
||||||
/>
|
|
||||||
{dept.name}
|
{dept.name}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
|
|
@ -263,24 +276,154 @@ export function CategorySettings() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{wfSelected.length === 0 && wfDepts.length > 0 && (
|
{wfSelected.length === 0 && wfDepts.length > 0 && (
|
||||||
<div style={{ marginBottom: '0.75rem', fontSize: '0.8rem', color: 'var(--warning, #b45309)' }}>
|
<div style={{ marginBottom: '0.75rem', fontSize: '0.8rem', color: '#b45309' }}>
|
||||||
Select at least one department to enable Workforce sync.
|
Select at least one department to enable Workforce sync.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<Btn onClick={saveWfDepts} disabled={wfSaving} primary>{wfSaving ? 'Saving…' : 'Save Departments'}</Btn>
|
||||||
onClick={saveWfDepts}
|
</div>
|
||||||
disabled={wfSaving}
|
|
||||||
style={{
|
|
||||||
background: 'var(--hk-green)', color: '#fff', border: 'none',
|
|
||||||
borderRadius: '6px', padding: '0.5rem 1.25rem', fontSize: '0.875rem',
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{wfSaving ? 'Saving…' : 'Save Departments'}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Recurring General Tasks ──────────────────────────────────────────── */}
|
||||||
|
<Divider />
|
||||||
|
<SectionHeading title="Recurring General Tasks" />
|
||||||
|
<p style={{ fontSize: '0.82rem', color: 'var(--text-mid)', marginBottom: '1rem' }}>
|
||||||
|
Tasks that recur every week. Enter minutes per day. These add to the total required hours every week.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{taskError && <Banner type="error">{taskError}</Banner>}
|
||||||
|
{taskMsg && <Banner type="ok">{taskMsg}</Banner>}
|
||||||
|
|
||||||
|
<div style={{ overflowX: 'auto', marginBottom: '1rem' }}>
|
||||||
|
<table className="hk-table" style={{ minWidth: '560px' }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="col-label">Task</th>
|
||||||
|
{WEEKDAYS.map(d => <th key={d}>{d}</th>)}
|
||||||
|
<th style={{ width: '32px' }}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tasks.length === 0 && (
|
||||||
|
<tr><td colSpan={9} style={{ color: 'var(--text-mid)', textAlign: 'center', padding: '1rem' }}>No tasks yet</td></tr>
|
||||||
|
)}
|
||||||
|
{tasks.map((task, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td style={{ padding: '0.3rem 0.5rem' }}>
|
||||||
|
<input className="hk-text-input" value={task.name} placeholder="Task name"
|
||||||
|
onChange={e => setTaskName(i, e.target.value)} />
|
||||||
|
</td>
|
||||||
|
{WEEKDAYS.map(day => (
|
||||||
|
<td key={day} style={{ padding: '0.3rem 0.4rem' }}>
|
||||||
|
<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)} />
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td>
|
||||||
|
<button onClick={() => removeTask(i)} style={{
|
||||||
|
background: 'none', border: 'none', color: 'var(--text-mid)', fontSize: '1rem', padding: '0.2rem 0.4rem',
|
||||||
|
}}>×</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginBottom: '2.5rem' }}>
|
||||||
|
<Btn onClick={() => setTasks([...tasks, { name: '', hours: {} }])}>+ Add task</Btn>
|
||||||
|
<Btn onClick={saveTasks} disabled={taskSaving} primary>{taskSaving ? 'Saving…' : 'Save Tasks'}</Btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Time Requirements ────────────────────────────────────────────────── */}
|
||||||
|
<Divider />
|
||||||
|
<SectionHeading title="Time Requirements (minutes per room)" />
|
||||||
|
<p style={{ fontSize: '0.82rem', color: 'var(--text-mid)', marginBottom: '1rem' }}>
|
||||||
|
How many minutes each room type takes depending on guest status.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{timeError && <Banner type="error">{timeError}</Banner>}
|
||||||
|
{timeMsg && <Banner type="ok">{timeMsg}</Banner>}
|
||||||
|
|
||||||
|
<div style={{ overflowX: 'auto', marginBottom: '2rem' }}>
|
||||||
|
<table className="hk-table" style={{ maxWidth: '500px' }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="col-label">Category</th>
|
||||||
|
<th>Depart (mins)</th>
|
||||||
|
<th>Stay (mins)</th>
|
||||||
|
<th>Arrive (mins)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{cats.filter(c => !c.excluded).map(cat => {
|
||||||
|
const req = timeReqs[cat.id] || { depart: 0, stay: 0, arrive: 0 }
|
||||||
|
return (
|
||||||
|
<tr key={cat.id}>
|
||||||
|
<td className="col-label">{cat.name}</td>
|
||||||
|
{(['depart', 'stay', 'arrive'] as const).map(action => (
|
||||||
|
<td key={action} style={{ padding: '0.3rem 0.4rem' }}>
|
||||||
|
<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)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Small reusable helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SectionHeading({ title }: { title: string }) {
|
||||||
|
return (
|
||||||
|
<h2 style={{ fontSize: '0.95rem', fontWeight: 700, color: 'var(--text-dark)', marginBottom: '0.25rem' }}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Divider() {
|
||||||
|
return <div style={{ height: '1px', background: 'var(--card-border)', margin: '0.5rem 0 2rem' }} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function Banner({ type, children }: { type: 'ok' | 'error'; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<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}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Btn({ children, onClick, disabled, primary }: {
|
||||||
|
children: React.ReactNode
|
||||||
|
onClick?: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
primary?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<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}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { RefreshCw } from 'lucide-react'
|
import { RefreshCw } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
getBookings, getConfig, putTimeReq, putStaff, putPickup,
|
getBookings, getConfig, putStaff, putPickup,
|
||||||
putGeneralTasks, putLastReviewed, syncWorkforceRota, getWorkforceStaff,
|
putLastReviewed, putAdjustments, syncWorkforceRota, getWorkforceStaff,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
import type {
|
import type {
|
||||||
BookingsData, TimeReqs, StaffMember, GeneralTask,
|
BookingsData, TimeReqs, StaffMember, GeneralTask,
|
||||||
PickupData, RequiredDay, DayData, WorkforceRota,
|
PickupData, RequiredDay, DayData, WorkforceRota, Adjustment,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
|
|
||||||
// ── Date helpers ──────────────────────────────────────────────────────────────
|
// ── Date helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
@ -79,14 +79,16 @@ function getDisplayPickup(
|
||||||
// ── Required hours calculation ────────────────────────────────────────────────
|
// ── Required hours calculation ────────────────────────────────────────────────
|
||||||
|
|
||||||
function calcRequired(
|
function calcRequired(
|
||||||
bookings: BookingsData, timeReqs: TimeReqs, pickup: PickupData, generalTasks: GeneralTask[],
|
bookings: BookingsData, timeReqs: TimeReqs, pickup: PickupData,
|
||||||
|
generalTasks: GeneralTask[], adjustments: Adjustment[],
|
||||||
): Record<string, RequiredDay> {
|
): Record<string, RequiredDay> {
|
||||||
const result: Record<string, RequiredDay> = {}
|
const result: Record<string, RequiredDay> = {}
|
||||||
|
|
||||||
for (const date of bookings.dates) {
|
for (const date of bookings.dates) {
|
||||||
const dayName = getDayName(date)
|
const dayName = getDayName(date)
|
||||||
const genHrs = generalTasks.reduce((s, t) => s + ((t.hours[dayName] || 0) / 60), 0)
|
const genHrs = generalTasks.reduce((s, t) => s + ((t.hours[dayName] || 0) / 60), 0)
|
||||||
result[date] = { booked: 0, pickup: 0, general: genHrs, total: genHrs, by_cat: {}, pickup_by_cat: {} }
|
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) {
|
for (const cat of bookings.categories) {
|
||||||
|
|
@ -127,6 +129,7 @@ export function Planner() {
|
||||||
const [pickup, setPickup] = useState<PickupData>({})
|
const [pickup, setPickup] = useState<PickupData>({})
|
||||||
const [generalTasks, setGeneralTasks] = useState<GeneralTask[]>([])
|
const [generalTasks, setGeneralTasks] = useState<GeneralTask[]>([])
|
||||||
const [tolerance, setTolerance] = useState(30)
|
const [tolerance, setTolerance] = useState(30)
|
||||||
|
const [adjustments, setAdjustments] = useState<Adjustment[]>([])
|
||||||
const [workforceRota, setWorkforceRota] = useState<WorkforceRota | null>(null)
|
const [workforceRota, setWorkforceRota] = useState<WorkforceRota | null>(null)
|
||||||
const [wfStaff, setWfStaff] = useState<{ id: string; name: string }[]>([])
|
const [wfStaff, setWfStaff] = useState<{ id: string; name: string }[]>([])
|
||||||
const [syncing, setSyncing] = useState(false)
|
const [syncing, setSyncing] = useState(false)
|
||||||
|
|
@ -173,6 +176,7 @@ export function Planner() {
|
||||||
setPickup(cfg.pickup_data || {})
|
setPickup(cfg.pickup_data || {})
|
||||||
setGeneralTasks(cfg.general_tasks || [])
|
setGeneralTasks(cfg.general_tasks || [])
|
||||||
setTolerance(cfg.tolerance_minutes ?? 30)
|
setTolerance(cfg.tolerance_minutes ?? 30)
|
||||||
|
setAdjustments(cfg.adjustments || [])
|
||||||
setWorkforceRota(cfg.workforce_rota || null)
|
setWorkforceRota(cfg.workforce_rota || null)
|
||||||
if (cfg.last_reviewed) {
|
if (cfg.last_reviewed) {
|
||||||
setSavedLastReviewed(cfg.last_reviewed)
|
setSavedLastReviewed(cfg.last_reviewed)
|
||||||
|
|
@ -204,13 +208,13 @@ export function Planner() {
|
||||||
if (Object.keys(pickup).length) {
|
if (Object.keys(pickup).length) {
|
||||||
navigator.sendBeacon('/hk-planner/api/config/pickup', JSON.stringify({ pickup_data: pickup }))
|
navigator.sendBeacon('/hk-planner/api/config/pickup', JSON.stringify({ pickup_data: pickup }))
|
||||||
}
|
}
|
||||||
if (generalTasks.length) {
|
if (adjustments.length) {
|
||||||
navigator.sendBeacon('/hk-planner/api/config/general-tasks', JSON.stringify({ general_tasks: generalTasks }))
|
navigator.sendBeacon('/hk-planner/api/config/adjustments', JSON.stringify({ adjustments }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('beforeunload', onUnload)
|
window.addEventListener('beforeunload', onUnload)
|
||||||
return () => window.removeEventListener('beforeunload', onUnload)
|
return () => window.removeEventListener('beforeunload', onUnload)
|
||||||
}, [staff, pickup, generalTasks])
|
}, [staff, pickup, adjustments])
|
||||||
|
|
||||||
function handleWeekOffset(days: number) {
|
function handleWeekOffset(days: number) {
|
||||||
const today = todayStr()
|
const today = todayStr()
|
||||||
|
|
@ -249,20 +253,19 @@ export function Planner() {
|
||||||
debounce('pickup', () => putPickup(next).then(() => flash('Pickup saved')).catch(e => flash(e.message, true)))
|
debounce('pickup', () => putPickup(next).then(() => flash('Pickup saved')).catch(e => flash(e.message, true)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Time requirements ───────────────────────────────────────────────────────
|
// ── Adjustments ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function handleTimeReqChange(cat: string, action: string, value: number) {
|
function handleAdjustmentsChange(next: Adjustment[]) {
|
||||||
const next: TimeReqs = {
|
setAdjustments(next)
|
||||||
...timeReqs,
|
debounce('adjustments', () =>
|
||||||
[cat]: { ...(timeReqs[cat] || { depart: 0, stay: 0, arrive: 0 }), [action]: value },
|
putAdjustments(next).then(() => flash('Adjustments saved')).catch(e => flash(e.message, true))
|
||||||
}
|
|
||||||
setTimeReqs(next)
|
|
||||||
stampLastReviewed()
|
|
||||||
debounce(`req-${cat}-${action}`, () =>
|
|
||||||
putTimeReq(cat, action, value).then(() => flash('Time requirements saved')).catch(e => flash(e.message, true))
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addAdjustmentRow() {
|
||||||
|
handleAdjustmentsChange([...adjustments, { label: '', hours: {} }])
|
||||||
|
}
|
||||||
|
|
||||||
// ── Staff ────────────────────────────────────────────────────────────────────
|
// ── Staff ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function handleStaffChange(next: StaffMember[]) {
|
function handleStaffChange(next: StaffMember[]) {
|
||||||
|
|
@ -275,18 +278,6 @@ export function Planner() {
|
||||||
handleStaffChange([...staff, { name: '', hours: {} }])
|
handleStaffChange([...staff, { name: '', hours: {} }])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── General tasks ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function handleTasksChange(next: GeneralTask[]) {
|
|
||||||
setGeneralTasks(next)
|
|
||||||
stampLastReviewed()
|
|
||||||
debounce('tasks', () => putGeneralTasks(next).then(() => flash('Tasks saved')).catch(e => flash(e.message, true)))
|
|
||||||
}
|
|
||||||
|
|
||||||
function addTaskRow() {
|
|
||||||
handleTasksChange([...generalTasks, { name: '', hours: {} }])
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Workforce sync ───────────────────────────────────────────────────────────
|
// ── Workforce sync ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function syncRota() {
|
async function syncRota() {
|
||||||
|
|
@ -316,7 +307,7 @@ export function Planner() {
|
||||||
|
|
||||||
// ── Required hours (memoised on state changes) ────────────────────────────
|
// ── Required hours (memoised on state changes) ────────────────────────────
|
||||||
|
|
||||||
const required = bookings ? calcRequired(bookings, timeReqs, pickup, generalTasks) : null
|
const required = bookings ? calcRequired(bookings, timeReqs, pickup, generalTasks, adjustments) : null
|
||||||
|
|
||||||
// ── Render ──────────────────────────────────────────────────────────────────
|
// ── Render ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -417,7 +408,18 @@ export function Planner() {
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* 3 — Staff rota */}
|
{/* 3 — Adjustments */}
|
||||||
|
<Section title="Adjustments" action={<CtrlBtn onClick={addAdjustmentRow}>+ Add adjustment</CtrlBtn>}>
|
||||||
|
<div className="table-scroll">
|
||||||
|
<AdjustmentsTable
|
||||||
|
bookings={bookings}
|
||||||
|
adjustments={adjustments}
|
||||||
|
onChange={handleAdjustmentsChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* 4 — Staff rota */}
|
||||||
<Section
|
<Section
|
||||||
title="Staff Rota"
|
title="Staff Rota"
|
||||||
action={
|
action={
|
||||||
|
|
@ -453,19 +455,6 @@ export function Planner() {
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* 4 — General tasks */}
|
|
||||||
<Section title="General HK Tasks" action={<CtrlBtn onClick={addTaskRow}>+ Add task</CtrlBtn>}>
|
|
||||||
<div className="table-scroll">
|
|
||||||
<GeneralTasksTable tasks={generalTasks} onChange={handleTasksChange} />
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* 5 — Time requirements */}
|
|
||||||
<Section title="Time Requirements (minutes per room)">
|
|
||||||
<div className="table-scroll">
|
|
||||||
<TimeTable categories={bookings.categories} timeReqs={timeReqs} onChange={handleTimeReqChange} />
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -748,9 +737,22 @@ function RequiredTable({ bookings, required }: { bookings: BookingsData; require
|
||||||
{dates.map(d => <td key={d}>{required[d].pickup > 0.001 ? fmtH(required[d].pickup) : '—'}</td>)}
|
{dates.map(d => <td key={d}>{required[d].pickup > 0.001 ? fmtH(required[d].pickup) : '—'}</td>)}
|
||||||
</tr>
|
</tr>
|
||||||
<tr style={{ background: '#f1f5f9' }}>
|
<tr style={{ background: '#f1f5f9' }}>
|
||||||
<td className="col-label" style={{ color: 'var(--text-mid)', fontSize: '0.78rem' }}>General tasks</td>
|
<td className="col-label" style={{ color: 'var(--text-mid)', fontSize: '0.78rem' }}>Recurring tasks</td>
|
||||||
{dates.map(d => <td key={d}>{required[d].general > 0.001 ? fmtH(required[d].general) : '—'}</td>)}
|
{dates.map(d => <td key={d}>{required[d].general > 0.001 ? fmtH(required[d].general) : '—'}</td>)}
|
||||||
</tr>
|
</tr>
|
||||||
|
{dates.some(d => required[d].adjustments !== 0) && (
|
||||||
|
<tr style={{ background: '#f1f5f9' }}>
|
||||||
|
<td className="col-label" style={{ color: 'var(--text-mid)', fontSize: '0.78rem' }}>Adjustments</td>
|
||||||
|
{dates.map(d => {
|
||||||
|
const adj = required[d].adjustments
|
||||||
|
return (
|
||||||
|
<td key={d} style={{ color: adj < 0 ? 'var(--danger)' : adj > 0 ? '#1a7a4a' : 'var(--text-mid)' }}>
|
||||||
|
{adj === 0 ? '—' : (adj > 0 ? '+' : '') + fmtH(adj)}
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
<tr style={{ background: '#e2e8f0' }}>
|
<tr style={{ background: '#e2e8f0' }}>
|
||||||
<td className="col-label">Total Required</td>
|
<td className="col-label">Total Required</td>
|
||||||
{dates.map(d => (
|
{dates.map(d => (
|
||||||
|
|
@ -922,101 +924,96 @@ function DiffCell({ available, required, tolerance }: { available: number; requi
|
||||||
return <span className="diff-ok">✓ {diff >= 0 ? '+' : ''}{fmtH(diff)}</span>
|
return <span className="diff-ok">✓ {diff >= 0 ? '+' : ''}{fmtH(diff)}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── General Tasks Table ───────────────────────────────────────────────────────
|
// ── Adjustments Table ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
function AdjustmentsTable({ bookings, adjustments, onChange }: {
|
||||||
|
bookings: BookingsData
|
||||||
|
adjustments: Adjustment[]
|
||||||
|
onChange: (next: Adjustment[]) => void
|
||||||
|
}) {
|
||||||
|
const { dates } = bookings
|
||||||
|
|
||||||
function GeneralTasksTable({ tasks, onChange }: { tasks: GeneralTask[]; onChange: (next: GeneralTask[]) => void }) {
|
function setLabel(i: number, label: string) {
|
||||||
function setName(i: number, name: string) {
|
onChange(adjustments.map((a, idx) => idx === i ? { ...a, label } : a))
|
||||||
onChange(tasks.map((t, idx) => idx === i ? { ...t, name } : t))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setHours(i: number, day: string, val: string) {
|
function setHours(i: number, date: string, val: string) {
|
||||||
const mins = parseInt(val, 10)
|
const hrs = parseFloat(val)
|
||||||
onChange(tasks.map((t, idx) => {
|
onChange(adjustments.map((a, idx) => {
|
||||||
if (idx !== i) return t
|
if (idx !== i) return a
|
||||||
return { ...t, hours: { ...t.hours, [day]: isNaN(mins) ? 0 : Math.max(0, mins) } }
|
const hours = { ...a.hours }
|
||||||
|
if (!isNaN(hrs)) hours[date] = hrs
|
||||||
|
else delete hours[date]
|
||||||
|
return { ...a, hours }
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeRow(i: number) { onChange(tasks.filter((_, idx) => idx !== i)) }
|
function removeRow(i: number) {
|
||||||
|
onChange(adjustments.filter((_, idx) => idx !== i))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adjustments.length === 0) {
|
||||||
|
return (
|
||||||
|
<p style={{ color: 'var(--text-mid)', fontSize: '0.82rem', padding: '0.5rem 0' }}>
|
||||||
|
No adjustments — use "+ Add adjustment" above to add a one-off hour offset for a specific date.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<table className="hk-table">
|
<table className="hk-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="col-label">Task</th>
|
<th className="col-label">Label</th>
|
||||||
{WEEKDAYS.map(d => <th key={d}>{d}</th>)}
|
{dates.map(d => <th key={d}>{fmtDayHeader(d)}</th>)}
|
||||||
<th style={{ width: '32px' }}></th>
|
<th style={{ width: '32px' }}></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{tasks.length === 0 && (
|
{adjustments.map((adj, i) => (
|
||||||
<tr><td colSpan={9} style={{ color: 'var(--text-mid)', textAlign: 'center', padding: '1rem' }}>No tasks yet — add one above</td></tr>
|
|
||||||
)}
|
|
||||||
{tasks.map((task, i) => (
|
|
||||||
<tr key={i}>
|
<tr key={i}>
|
||||||
<td style={{ padding: '0.3rem 0.5rem' }}>
|
<td className="col-label" style={{ padding: '0.3rem 0.5rem' }}>
|
||||||
<input className="hk-text-input" value={task.name} placeholder="Task name"
|
<input
|
||||||
onChange={e => setName(i, e.target.value)} />
|
className="hk-text-input"
|
||||||
|
value={adj.label}
|
||||||
|
placeholder="e.g. Rooms from Sunday"
|
||||||
|
onChange={e => setLabel(i, e.target.value)}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
{WEEKDAYS.map(day => (
|
{dates.map(date => (
|
||||||
<td key={day} style={{ padding: '0.3rem 0.4rem' }}>
|
<td key={date} style={{ padding: '0.3rem 0.4rem' }}>
|
||||||
<input type="number" className="hk-num-input" min={0} max={999}
|
<input
|
||||||
value={task.hours[day] || ''}
|
type="number" className="hk-num-input"
|
||||||
|
step={0.25}
|
||||||
|
value={adj.hours[date] ?? ''}
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
onChange={e => setHours(i, day, e.target.value)} />
|
onChange={e => setHours(i, date, e.target.value)}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
<td>
|
<td>
|
||||||
<button onClick={() => removeRow(i)} style={{
|
<button onClick={() => removeRow(i)} style={{
|
||||||
background: 'none', border: 'none', color: 'var(--text-mid)', fontSize: '1rem', padding: '0.2rem 0.4rem',
|
background: 'none', border: 'none', color: 'var(--text-mid)',
|
||||||
|
fontSize: '1rem', padding: '0.2rem 0.4rem',
|
||||||
}}>×</button>
|
}}>×</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
<tfoot>
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Time Requirements Table ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function TimeTable({ categories, timeReqs, onChange }: {
|
|
||||||
categories: BookingsData['categories']
|
|
||||||
timeReqs: TimeReqs
|
|
||||||
onChange: (cat: string, action: string, value: number) => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<table className="hk-table" style={{ maxWidth: '560px' }}>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
<tr>
|
||||||
<th className="col-label">Category</th>
|
<td className="col-label" style={{ fontSize: '0.78rem', color: 'var(--text-mid)' }}>Total</td>
|
||||||
<th>Depart (mins)</th>
|
{dates.map(date => {
|
||||||
<th>Stay (mins)</th>
|
const total = adjustments.reduce((s, a) => s + (a.hours[date] || 0), 0)
|
||||||
<th>Arrive (mins)</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{categories.map(cat => {
|
|
||||||
const req = timeReqs[cat.id] || { depart: 0, stay: 0, arrive: 0 }
|
|
||||||
return (
|
return (
|
||||||
<tr key={cat.id}>
|
<td key={date} style={{ color: total < 0 ? 'var(--danger)' : total > 0 ? '#1a7a4a' : 'var(--text-mid)', fontWeight: total !== 0 ? 600 : undefined }}>
|
||||||
<td className="col-label">{cat.name}</td>
|
{total === 0 ? '—' : (total > 0 ? '+' : '') + fmtH(total)}
|
||||||
{(['depart', 'stay', 'arrive'] as const).map(action => (
|
|
||||||
<td key={action} style={{ padding: '0.3rem 0.4rem' }}>
|
|
||||||
<input type="number" className="hk-num-input" min={0} max={999}
|
|
||||||
value={req[action] || ''}
|
|
||||||
placeholder="0"
|
|
||||||
onChange={e => onChange(cat.id, action, parseInt(e.target.value, 10) || 0)}
|
|
||||||
onBlur={e => onChange(cat.id, action, parseInt(e.target.value, 10) || 0)}
|
|
||||||
/>
|
|
||||||
</td>
|
</td>
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</tbody>
|
<td />
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,11 @@ export interface GeneralTask {
|
||||||
|
|
||||||
export type PickupData = Record<string, Record<string, { count: number; total: number }>>
|
export type PickupData = Record<string, Record<string, { count: number; total: number }>>
|
||||||
|
|
||||||
|
export interface Adjustment {
|
||||||
|
label: string
|
||||||
|
hours: Record<string, number> // YYYY-MM-DD → positive or negative hours
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkforceShiftDay {
|
export interface WorkforceShiftDay {
|
||||||
hours: number
|
hours: number
|
||||||
times: string
|
times: string
|
||||||
|
|
@ -70,6 +75,7 @@ export interface RequiredDay {
|
||||||
booked: number
|
booked: number
|
||||||
pickup: number
|
pickup: number
|
||||||
general: number
|
general: number
|
||||||
|
adjustments: number
|
||||||
total: number
|
total: number
|
||||||
by_cat: Record<string, number>
|
by_cat: Record<string, number>
|
||||||
pickup_by_cat: Record<string, number>
|
pickup_by_cat: Record<string, number>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue