Add Weekly Actuals directors report with charts

Full weekly actuals report matching the spreadsheet layout:
- Week summary table (Rooms/Dry/Wet vs Budget vs Last Year vs Last Week)
- Grouped bar chart + dept split pie chart
- 5-week rolling summary table
- Occupancy table by day of week (rooms, occ%, ARR, RevPAR)
- Month progress to date (totals + dept split) with progress bar chart
- 12-month turnover trend line chart by dept
- Utilities placeholder (pending meter readings app)

Budget values come from the forecasting API which distributes monthly
budget proportionally to daily sales — so high-demand weeks (e.g.
Christmas) carry a higher budget share. Gross shown as net × 1.20.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-20 13:07:19 +00:00
parent 29a119f51d
commit b97358e9fb
9 changed files with 1433 additions and 6 deletions

View file

@ -10,7 +10,8 @@
"dependencies": {
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"recharts": "^3.9.2"
},
"devDependencies": {
"@types/react": "^18.3.1",
@ -2125,6 +2126,32 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@ -2602,6 +2629,18 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@trickfilm400/rollup-plugin-off-main-thread": {
"version": "3.0.0-pre1",
"resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz",
@ -2663,6 +2702,69 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@ -2674,14 +2776,14 @@
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.31",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@ -2712,6 +2814,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@ -3035,6 +3143,15 @@
],
"license": "CC-BY-4.0"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
@ -3102,9 +3219,130 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/data-view-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@ -3177,6 +3415,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@ -3419,6 +3663,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es-toolkit": {
"version": "1.49.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz",
"integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@ -3501,6 +3755,12 @@
"url": "https://github.com/bgub/eta?sponsor=1"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -3935,6 +4195,16 @@
"dev": true,
"license": "ISC"
},
"node_modules/immer": {
"version": "11.1.15",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.15.tgz",
"integrity": "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@ -3950,6 +4220,15 @@
"node": ">= 0.4"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@ -4832,6 +5111,36 @@
"react": "^18.3.1"
}
},
"node_modules/react-is": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@ -4842,6 +5151,51 @@
"node": ">=0.10.0"
}
},
"node_modules/recharts": {
"version": "3.9.2",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.2.tgz",
"integrity": "sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^11.1.8",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.2.0",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@ -4954,6 +5308,12 @@
"node": ">=0.10.0"
}
},
"node_modules/reselect": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@ -5509,6 +5869,12 @@
"node": ">=10"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@ -5769,6 +6135,37 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",

View file

@ -11,7 +11,8 @@
"dependencies": {
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"recharts": "^3.9.2"
},
"devDependencies": {
"@types/react": "^18.3.1",

View file

@ -1,4 +1,4 @@
import type { ReportMeta, ReportResult, WorksheetData, ForecastReportData } from './types'
import type { ReportMeta, ReportResult, WorksheetData, ForecastReportData, WeeklyActualData } from './types'
const BASE = '/reports/api'
@ -30,6 +30,12 @@ export function runReport(id: string, dateFrom: string, dateTo: string): Promise
})
}
// ── Weekly Actuals API ──────────────────────────────────────────────────────
export function getWeeklyActual(weekEnding: string): Promise<WeeklyActualData> {
return request<WeeklyActualData>(`/weekly-actual/${weekEnding}`)
}
// ── Directors Forecast API ──────────────────────────────────────────────────
export function dfGetWorksheet(year: number, month: number): Promise<WorksheetData> {

View file

@ -534,3 +534,188 @@ html, body, #root {
.df-weekly-grid { grid-template-columns: 1fr; }
.df-table { font-size: 11px; }
}
/* ── Weekly Actuals ─────────────────────────────────────────────────────── */
.wa-page {
height: 100%;
overflow-y: auto;
background: var(--body-bg);
}
.wa-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
border-bottom: 1px solid var(--border);
background: var(--card-bg);
gap: 12px;
flex-wrap: wrap;
position: sticky;
top: 0;
z-index: 10;
}
.wa-week-nav {
display: flex;
align-items: center;
gap: 8px;
}
.wa-week-label {
font-size: 13px;
color: var(--text-primary);
min-width: 160px;
text-align: center;
}
.wa-toolbar-actions {
display: flex;
align-items: center;
gap: 6px;
}
.wa-content {
padding: 20px;
display: flex;
flex-direction: column;
gap: 28px;
max-width: 1400px;
}
.wa-section {
background: var(--card-bg);
border-radius: var(--radius);
border: 1px solid var(--border);
padding: 20px;
box-shadow: var(--shadow-sm);
}
.wa-section-title {
font-size: 13px;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--text-primary);
margin: 0 0 14px;
padding-bottom: 10px;
border-bottom: 2px solid var(--gold);
}
.wa-note {
font-size: 11px;
color: var(--text-muted);
margin: 0 0 10px;
font-style: italic;
}
.wa-table-wrap {
overflow-x: auto;
}
.wa-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.wa-table th {
background: #f8f9fa;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--text-muted);
padding: 8px 10px;
border-bottom: 2px solid var(--border);
text-align: right;
white-space: nowrap;
}
.wa-table th:first-child { text-align: left; }
.wa-table td {
padding: 7px 10px;
border-bottom: 1px solid #f0f0f0;
vertical-align: middle;
}
.wa-table tbody tr:last-child td { border-bottom: none; }
.wa-table tbody tr:hover { background: #fafafa; }
.wa-row-label {
font-size: 12px;
color: var(--text-primary);
font-weight: 500;
white-space: nowrap;
}
.wa-num {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.wa-muted { color: var(--text-muted); }
.wa-pos { color: #16a34a; font-weight: 600; text-align: right; font-variant-numeric: tabular-nums; }
.wa-neg { color: #dc2626; font-weight: 600; text-align: right; font-variant-numeric: tabular-nums; }
.wa-total-row td {
font-weight: 700;
background: #f4f5f7;
border-top: 2px solid var(--border);
border-bottom: none;
}
.wa-charts-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 16px;
}
.wa-chart-card {
background: #fafafa;
border: 1px solid var(--border);
border-radius: 6px;
padding: 14px 10px 10px;
}
.wa-chart-card h4 {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--text-muted);
margin: 0 0 10px;
text-align: center;
}
.wa-month-section {
display: flex;
flex-direction: column;
gap: 0;
}
.wa-utilities-placeholder {
background: #f8f9fa;
border: 1px dashed var(--border);
border-radius: 6px;
padding: 24px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
}
@media print {
.wa-toolbar { display: none !important; }
.wa-page { overflow: visible; }
.wa-charts-row { break-inside: avoid; }
}
@media (max-width: 900px) {
.wa-charts-row { grid-template-columns: 1fr; }
.wa-table { font-size: 11px; }
}

View file

@ -8,6 +8,7 @@ import { useAuth } from '../components/AuthGate'
import { fetchReports, runReport } from '../api'
import type { ReportMeta, ReportResult } from '../types'
import DirectorsForecast from './DirectorsForecast'
import WeeklyActual from './WeeklyActual'
const DIRECTORS_REPORTS: ReportMeta[] = [
{
@ -18,6 +19,14 @@ const DIRECTORS_REPORTS: ReportMeta[] = [
subcategory: null,
description: 'Monthly OTB, pickup and revenue forecast with budget and last-year comparison.',
},
{
id: 'weekly-actual',
name: 'Weekly Actuals',
category: 'directors',
categoryLabel: 'Directors Reports',
subcategory: null,
description: 'Weekly actuals report with dept split, occupancy, month progress and 12-month trend.',
},
]
// ── helpers ───────────────────────────────────────────────────────────────────
@ -304,6 +313,8 @@ export default function ReportsPage() {
<h2>Custom Reports</h2>
<p>Select a report from the sidebar to get started.</p>
</div>
) : selected.id === 'weekly-actual' ? (
<WeeklyActual />
) : selected.category === 'directors' ? (
<DirectorsForecast />
) : (

View file

@ -0,0 +1,480 @@
import { useCallback, useEffect, useState } from 'react'
import { ChevronLeft, ChevronRight, RefreshCw, Printer } from 'lucide-react'
import {
ResponsiveContainer,
BarChart, Bar,
XAxis, YAxis, Tooltip, Legend,
PieChart, Pie, Cell,
LineChart, Line,
CartesianGrid,
} from 'recharts'
import { getWeeklyActual } from '../api'
import type { WeeklyActualData, WeekSummaryRow } from '../types'
// ── Formatters ─────────────────────────────────────────────────────────────
const fmtCcy = (n: number | null | undefined, dp = 2) =>
n == null ? '—' : `£${n.toLocaleString('en-GB', { minimumFractionDigits: dp, maximumFractionDigits: dp })}`
const fmtPct = (n: number | null | undefined) =>
n == null ? '—' : `${n >= 0 ? '+' : ''}${n.toFixed(2)}%`
const fmtPctClass = (n: number | null | undefined) =>
n == null ? '' : n >= 0 ? 'wa-pos' : 'wa-neg'
const fmtShortDate = (s: string) => {
const d = new Date(s + 'T00:00:00')
return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`
}
const MONTH_NAMES = ['January','February','March','April','May','June','July','August','September','October','November','December']
// ── Week navigation ────────────────────────────────────────────────────────
function defaultWeekEnding(): string {
const d = new Date()
const day = d.getDay()
// Go back to the most recent Saturday (day=6→today, else go back)
const daysBack = day === 6 ? 0 : day + 1
d.setDate(d.getDate() - daysBack)
return d.toISOString().split('T')[0]
}
function shiftWeek(dateStr: string, weeks: number): string {
const d = new Date(dateStr + 'T00:00:00')
d.setDate(d.getDate() + weeks * 7)
return d.toISOString().split('T')[0]
}
// ── Chart colours ──────────────────────────────────────────────────────────
const CHART_COLORS = {
rooms: '#c9a84c',
dry: '#2563eb',
wet: '#059669',
thisWeek: '#c9a84c',
budget: '#9ca3af',
lastYear: '#d1d5db',
}
const PIE_COLORS = [CHART_COLORS.rooms, CHART_COLORS.dry, CHART_COLORS.wet]
// ── Tooltip formatter ──────────────────────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fmtChartCcy = (v: any): string => {
if (v == null || Array.isArray(v)) return '—'
const n = Number(v)
return isNaN(n) ? '—' : `£${n.toLocaleString('en-GB', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`
}
// ── Section: Week Summary Table ────────────────────────────────────────────
function WeekTable({ tw, lw }: { tw: WeekSummaryRow; lw: WeekSummaryRow['dept'] }) {
const rows = [
{ label: 'Rooms', tw_net: tw.dept.rooms.net, budget: tw.dept.rooms.budget, ly: tw.dept.rooms.ly, lw_net: lw.rooms.net },
{ label: 'Dry', tw_net: tw.dept.dry.net, budget: tw.dept.dry.budget, ly: tw.dept.dry.ly, lw_net: lw.dry.net },
{ label: 'Wet', tw_net: tw.dept.wet.net, budget: tw.dept.wet.budget, ly: tw.dept.wet.ly, lw_net: lw.wet.net },
{ label: 'Total Net', tw_net: tw.net, budget: tw.budget_net, ly: tw.ly_net, lw_net: lw.rooms.net + lw.dry.net + lw.wet.net },
]
return (
<div className="wa-table-wrap">
<table className="wa-table">
<thead>
<tr>
<th></th>
<th>This Week</th>
<th>% to Budget</th>
<th>Budget</th>
<th>% to Last Year</th>
<th>Last Year</th>
<th>Last Week</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => {
const pctBud = r.budget > 0 ? ((r.tw_net - r.budget) / r.budget) * 100 : null
const pctLy = r.ly > 0 ? ((r.tw_net - r.ly) / r.ly) * 100 : null
return (
<tr key={r.label} className={i === rows.length - 1 ? 'wa-total-row' : ''}>
<td className="wa-row-label">{r.label}</td>
<td className="wa-num">{fmtCcy(r.tw_net)}</td>
<td className={`wa-num ${fmtPctClass(pctBud)}`}>{fmtPct(pctBud)}</td>
<td className="wa-num wa-muted">{fmtCcy(r.budget)}</td>
<td className={`wa-num ${fmtPctClass(pctLy)}`}>{fmtPct(pctLy)}</td>
<td className="wa-num wa-muted">{fmtCcy(r.ly)}</td>
<td className="wa-num wa-muted">{fmtCcy(r.lw_net)}</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
// ── Section: Week Charts ───────────────────────────────────────────────────
function WeekCharts({ tw }: { tw: WeekSummaryRow }) {
const barData = [
{ name: 'Rooms', 'This Week': tw.dept.rooms.net, 'Budget': tw.dept.rooms.budget, 'Last Year': tw.dept.rooms.ly },
{ name: 'Dry', 'This Week': tw.dept.dry.net, 'Budget': tw.dept.dry.budget, 'Last Year': tw.dept.dry.ly },
{ name: 'Wet', 'This Week': tw.dept.wet.net, 'Budget': tw.dept.wet.budget, 'Last Year': tw.dept.wet.ly },
]
const pieData = [
{ name: 'Rooms', value: tw.dept.rooms.net },
{ name: 'Dry', value: tw.dept.dry.net },
{ name: 'Wet', value: tw.dept.wet.net },
]
return (
<div className="wa-charts-row">
<div className="wa-chart-card">
<h4>This Week vs Budget vs Last Year</h4>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={barData} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={v => `£${(v/1000).toFixed(0)}k`} tick={{ fontSize: 10 }} width={52} />
<Tooltip formatter={fmtChartCcy} />
<Legend iconSize={10} wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="This Week" fill={CHART_COLORS.thisWeek} radius={[2,2,0,0]} />
<Bar dataKey="Budget" fill={CHART_COLORS.budget} radius={[2,2,0,0]} />
<Bar dataKey="Last Year" fill={CHART_COLORS.lastYear} radius={[2,2,0,0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="wa-chart-card">
<h4>This Week Dept Split</h4>
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie
data={pieData}
cx="50%" cy="50%"
outerRadius={80}
dataKey="value"
label={({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`}
labelLine={false}
>
{pieData.map((_, idx) => (
<Cell key={idx} fill={PIE_COLORS[idx]} />
))}
</Pie>
<Tooltip formatter={fmtChartCcy} />
</PieChart>
</ResponsiveContainer>
</div>
</div>
)
}
// ── Section: 5 Week Summary ────────────────────────────────────────────────
function FiveWeekTable({ weeks }: { weeks: WeekSummaryRow[] }) {
return (
<div className="wa-table-wrap">
<table className="wa-table">
<thead>
<tr>
<th>Week Ending</th>
<th>Gross Sales</th>
<th>Net Sales</th>
<th>% to Budget</th>
<th>Budget Net</th>
<th>% to Last Year</th>
<th>Last Year Net</th>
</tr>
</thead>
<tbody>
{weeks.map((w, i) => (
<tr key={w.week_ending} className={i === 0 ? 'wa-total-row' : ''}>
<td className="wa-row-label">{fmtShortDate(w.week_ending)}</td>
<td className="wa-num">{fmtCcy(w.gross, 2)}</td>
<td className="wa-num">{fmtCcy(w.net, 2)}</td>
<td className={`wa-num ${fmtPctClass(w.pct_to_budget)}`}>{fmtPct(w.pct_to_budget)}</td>
<td className="wa-num wa-muted">{fmtCcy(w.budget_net, 2)}</td>
<td className={`wa-num ${fmtPctClass(w.pct_to_ly)}`}>{fmtPct(w.pct_to_ly)}</td>
<td className="wa-num wa-muted">{fmtCcy(w.ly_net, 2)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
// ── Section: Occupancy ─────────────────────────────────────────────────────
function OccupancyTable({ occupancy }: { occupancy: WeeklyActualData['occupancy'] }) {
const { daily, totals } = occupancy
return (
<div className="wa-table-wrap">
<table className="wa-table">
<thead>
<tr>
<th></th>
<th>Rooms Avail.</th>
<th>Rooms Sold</th>
<th>Revenue</th>
<th>Av Rate</th>
<th>Occupancy %</th>
<th>RevPAR</th>
</tr>
</thead>
<tbody>
{daily.map(d => (
<tr key={d.date}>
<td className="wa-row-label">{d.dow}</td>
<td className="wa-num">{d.available}</td>
<td className="wa-num">{d.rooms_sold}</td>
<td className="wa-num">{fmtCcy(d.accomm)}</td>
<td className="wa-num">{fmtCcy(d.arr)}</td>
<td className="wa-num">{d.occ_pct == null ? '—' : `${d.occ_pct.toFixed(2)}%`}</td>
<td className="wa-num">{fmtCcy(d.revpar)}</td>
</tr>
))}
<tr className="wa-total-row">
<td className="wa-row-label">Totals</td>
<td className="wa-num">{totals.available}</td>
<td className="wa-num">{totals.rooms_sold}</td>
<td className="wa-num">{fmtCcy(totals.accomm)}</td>
<td className="wa-num">{fmtCcy(totals.arr)}</td>
<td className="wa-num">{totals.occ_pct == null ? '—' : `${totals.occ_pct.toFixed(2)}%`}</td>
<td className="wa-num">{fmtCcy(totals.revpar)}</td>
</tr>
</tbody>
</table>
</div>
)
}
// ── Section: Month Progress ────────────────────────────────────────────────
function MonthProgressSection({
mp, trend,
}: {
mp: WeeklyActualData['month_progress']
trend: WeeklyActualData['monthly_trend']
}) {
const deptBarData = [
{ name: 'Rooms', 'Net Sales': mp.split.rooms.net, 'Budget Net': mp.split.rooms.budget_net, 'Last Year': mp.split.rooms.ly_net },
{ name: 'Dry', 'Net Sales': mp.split.dry.net, 'Budget Net': mp.split.dry.budget_net, 'Last Year': mp.split.dry.ly_net },
{ name: 'Wet', 'Net Sales': mp.split.wet.net, 'Budget Net': mp.split.wet.budget_net, 'Last Year': mp.split.wet.ly_net },
]
const deptSplit = [
{ label: 'Rooms', ...mp.split.rooms },
{ label: 'Dry', ...mp.split.dry },
{ label: 'Wet', ...mp.split.wet },
]
return (
<div className="wa-month-section">
{/* Progress to date summary */}
<div className="wa-table-wrap">
<table className="wa-table">
<thead>
<tr>
<th>Date to</th>
<th>Gross Sales</th>
<th>Net Sales</th>
<th>% to Budget Net</th>
<th>Budget Net</th>
<th>% to Last Year</th>
<th>Last Year Net</th>
</tr>
</thead>
<tbody>
<tr className="wa-total-row">
<td className="wa-row-label">{fmtShortDate(mp.date_to)}</td>
<td className="wa-num">{fmtCcy(mp.gross)}</td>
<td className="wa-num">{fmtCcy(mp.net)}</td>
<td className={`wa-num ${fmtPctClass(mp.pct_to_budget)}`}>{fmtPct(mp.pct_to_budget)}</td>
<td className="wa-num wa-muted">{fmtCcy(mp.budget_net)}</td>
<td className={`wa-num ${fmtPctClass(mp.pct_to_ly)}`}>{fmtPct(mp.pct_to_ly)}</td>
<td className="wa-num wa-muted">{fmtCcy(mp.ly_net)}</td>
</tr>
</tbody>
</table>
</div>
{/* Dept split */}
<div className="wa-table-wrap" style={{ marginTop: 8 }}>
<table className="wa-table">
<thead>
<tr>
<th></th>
<th>Net Sales</th>
<th>% to Budget</th>
<th>Budget Net</th>
<th>% to Last Year</th>
<th>Last Year Net</th>
</tr>
</thead>
<tbody>
{deptSplit.map(d => (
<tr key={d.label}>
<td className="wa-row-label">{d.label}</td>
<td className="wa-num">{fmtCcy(d.net)}</td>
<td className={`wa-num ${fmtPctClass(d.pct_to_budget)}`}>{fmtPct(d.pct_to_budget)}</td>
<td className="wa-num wa-muted">{fmtCcy(d.budget_net)}</td>
<td className={`wa-num ${fmtPctClass(d.pct_to_ly)}`}>{fmtPct(d.pct_to_ly)}</td>
<td className="wa-num wa-muted">{fmtCcy(d.ly_net)}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Charts row */}
<div className="wa-charts-row" style={{ marginTop: 16 }}>
<div className="wa-chart-card">
<h4>Month Progress Dept vs Budget vs Last Year</h4>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={deptBarData} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={v => `£${(v/1000).toFixed(0)}k`} tick={{ fontSize: 10 }} width={52} />
<Tooltip formatter={fmtChartCcy} />
<Legend iconSize={10} wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="Net Sales" fill={CHART_COLORS.thisWeek} radius={[2,2,0,0]} />
<Bar dataKey="Budget Net" fill={CHART_COLORS.budget} radius={[2,2,0,0]} />
<Bar dataKey="Last Year" fill={CHART_COLORS.lastYear} radius={[2,2,0,0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="wa-chart-card">
<h4>Monthly Average Turnover Last 12 Months</h4>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={trend} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="label" tick={{ fontSize: 9 }} interval={1} />
<YAxis tickFormatter={v => `£${(v/1000).toFixed(0)}k`} tick={{ fontSize: 10 }} width={52} />
<Tooltip formatter={fmtChartCcy} />
<Legend iconSize={10} wrapperStyle={{ fontSize: 11 }} />
<Line type="monotone" dataKey="rooms" stroke={CHART_COLORS.rooms} strokeWidth={2} dot={false} name="Rooms" />
<Line type="monotone" dataKey="dry" stroke={CHART_COLORS.dry} strokeWidth={2} dot={false} name="Dry" />
<Line type="monotone" dataKey="wet" stroke={CHART_COLORS.wet} strokeWidth={2} dot={false} name="Wet" />
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
)
}
// ── Section: Utilities Placeholder ─────────────────────────────────────────
function UtilitiesPlaceholder() {
return (
<div className="wa-utilities-placeholder">
<p>Utilities report pending meter readings app integration.</p>
</div>
)
}
// ── Main component ─────────────────────────────────────────────────────────
export default function WeeklyActual() {
const [weekEnding, setWeekEnding] = useState(defaultWeekEnding)
const [data, setData] = useState<WeeklyActualData | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const load = useCallback(async (date: string) => {
setLoading(true)
setError(null)
setData(null)
try {
setData(await getWeeklyActual(date))
} catch (e) {
setError((e as Error).message)
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load(weekEnding) }, [load, weekEnding])
const prevWeek = () => setWeekEnding(w => shiftWeek(w, -1))
const nextWeek = () => setWeekEnding(w => shiftWeek(w, +1))
return (
<div className="wa-page" id="wa-printable">
{/* Toolbar */}
<div className="wa-toolbar no-print">
<div className="wa-week-nav">
<button className="btn-icon" onClick={prevWeek} title="Previous week">
<ChevronLeft size={16} strokeWidth={1.75} />
</button>
<div className="wa-week-label">
Week ending&nbsp;<strong>{fmtShortDate(weekEnding)}</strong>
</div>
<button className="btn-icon" onClick={nextWeek} title="Next week">
<ChevronRight size={16} strokeWidth={1.75} />
</button>
<input
type="date"
className="date-input"
value={weekEnding}
onChange={e => setWeekEnding(e.target.value)}
/>
</div>
<div className="wa-toolbar-actions">
<button className="btn-icon" onClick={() => load(weekEnding)} title="Refresh">
<RefreshCw size={15} strokeWidth={1.75} />
</button>
<button className="btn-icon" onClick={() => window.print()} title="Print">
<Printer size={15} strokeWidth={1.75} />
</button>
</div>
</div>
{error && <div className="report-error" style={{ margin: '16px 24px' }}>{error}</div>}
{loading && <div className="loading"><div className="spinner" /></div>}
{data && (
<div className="wa-content">
{/* ── Week Report ─────────────────────────────────────────── */}
<section className="wa-section">
<h2 className="wa-section-title">Week Report {fmtShortDate(weekEnding)}</h2>
<WeekTable tw={data.this_week} lw={data.last_week} />
<WeekCharts tw={data.this_week} />
</section>
{/* ── 5 Week Summary ──────────────────────────────────────── */}
<section className="wa-section">
<h2 className="wa-section-title">5 Week Summary</h2>
<FiveWeekTable weeks={data.five_weeks} />
</section>
{/* ── Occupancy ────────────────────────────────────────────── */}
<section className="wa-section">
<h2 className="wa-section-title">Occupancy %</h2>
<p className="wa-note">Overflow room types excluded from available room counts.</p>
<OccupancyTable occupancy={data.occupancy} />
</section>
{/* ── Month Progress ──────────────────────────────────────── */}
<section className="wa-section">
<h2 className="wa-section-title">
{MONTH_NAMES[data.month_progress.month - 1]} {data.month_progress.year} Month Progress to Date
</h2>
<MonthProgressSection mp={data.month_progress} trend={data.monthly_trend} />
</section>
{/* ── Utilities ────────────────────────────────────────────── */}
<section className="wa-section">
<h2 className="wa-section-title">Utilities</h2>
<UtilitiesPlaceholder />
</section>
</div>
)}
</div>
)
}

View file

@ -30,6 +30,98 @@ export interface ReportResult {
summary?: { label: string; value: string }[]
}
// ── Weekly Actuals types ────────────────────────────────────────────────────
export interface WeekDeptTotals {
net: number
budget: number
ly: number
}
export interface WeekSummaryDept {
rooms: WeekDeptTotals
dry: WeekDeptTotals
wet: WeekDeptTotals
}
export interface WeekSummaryRow {
week_ending: string
gross: number
net: number
pct_to_budget: number | null
budget_net: number
pct_to_ly: number | null
ly_net: number
dept: WeekSummaryDept
}
export interface OccDay {
date: string
dow: string
available: number
rooms_sold: number
accomm: number
arr: number | null
occ_pct: number | null
revpar: number | null
}
export interface OccTotals {
available: number
rooms_sold: number
accomm: number
arr: number | null
occ_pct: number | null
revpar: number | null
}
export interface MonthSplit {
net: number
budget_net: number
ly_net: number
pct_to_budget: number | null
pct_to_ly: number | null
}
export interface MonthProgress {
year: number
month: number
date_to: string
gross: number
net: number
budget_net: number
ly_net: number
pct_to_budget: number | null
pct_to_ly: number | null
split: {
rooms: MonthSplit
dry: MonthSplit
wet: MonthSplit
}
}
export interface MonthTrend {
label: string
rooms: number
dry: number
wet: number
total: number
}
export interface WeeklyActualData {
week_ending: string
week_start: string
this_week: WeekSummaryRow
last_week: WeekSummaryDept
five_weeks: WeekSummaryRow[]
occupancy: {
daily: OccDay[]
totals: OccTotals
}
month_progress: MonthProgress
monthly_trend: MonthTrend[]
}
// ── Directors Forecast types ────────────────────────────────────────────────
export const DOW_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']