Weekly report: full occupancy stats + VAT column in sales
Occupancy table now calculates from raw Newbook data: - Rooms occupied (from per-category occupancy API) - Occ % (rooms occupied / total rooms from sites_data) - Guests (adults + children + infants from bookings_data) - Net accommodation revenue (from earned_revenue, ACC GL group) - Avg net per room, REVPAR - GGR (average guest rate from tariffs_quoted) - Avg lead time (arriving bookings only) Sales breakdown now shows Net, VAT, Gross as separate total columns. Debtors/Creditors adds Period Close row. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e4ab137025
commit
b53df5b6a5
1 changed files with 318 additions and 87 deletions
|
|
@ -12,19 +12,65 @@ interface DayData {
|
||||||
daily_stats: { gross_sales: number; transaction_count: number } | null
|
daily_stats: { gross_sales: number; transaction_count: number } | null
|
||||||
sales_breakdown: SalesCol[]
|
sales_breakdown: SalesCol[]
|
||||||
}
|
}
|
||||||
interface OccupancyItem { period?: string; date?: string; rooms_sold?: number; total_rooms?: number; total_people?: number }
|
|
||||||
|
// Newbook occupancy_data: per-category with per-date occupancy map
|
||||||
|
interface OccupancyCategoryRaw {
|
||||||
|
category_id?: string | number
|
||||||
|
category_name?: string
|
||||||
|
occupancy?: Record<string, { occupied?: number; maintenance?: number; available?: number }>
|
||||||
|
// fallback flat format
|
||||||
|
period?: string; date?: string; rooms_sold?: number; total_rooms?: number; total_people?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BookingRaw {
|
||||||
|
booking_arrival?: string
|
||||||
|
booking_departure?: string
|
||||||
|
booking_adults?: string | number
|
||||||
|
booking_children?: string | number
|
||||||
|
booking_infants?: string | number
|
||||||
|
category_id?: string | number
|
||||||
|
category_name?: string
|
||||||
|
booking_placed?: string
|
||||||
|
tariffs_quoted?: Array<{ stay_date?: string; calculated_amount?: string | number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SiteRaw {
|
||||||
|
category_id?: string | number
|
||||||
|
category_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EarnedRevenueItem {
|
||||||
|
period?: string
|
||||||
|
gl_group_id?: string
|
||||||
|
earned_revenue_ex?: number
|
||||||
|
earned_revenue_tax?: number
|
||||||
|
earned_revenue?: number
|
||||||
|
}
|
||||||
|
|
||||||
interface ReportResult {
|
interface ReportResult {
|
||||||
report_data: DayData[]
|
report_data: DayData[]
|
||||||
sales_columns: Array<{ gl_code: string; display_name: string }>
|
sales_columns: Array<{ gl_code: string; display_name: string }>
|
||||||
occupancy_data: OccupancyItem[]
|
occupancy_data: OccupancyCategoryRaw[]
|
||||||
bookings_data: Array<{ date?: string; period?: string; arrivals?: number; departures?: number; [k: string]: unknown }>
|
bookings_data: BookingRaw[]
|
||||||
|
sites_data: SiteRaw[]
|
||||||
|
earned_revenue: EarnedRevenueItem[]
|
||||||
|
gl_accounts: Array<{ gl_group_id?: string; gl_group_name?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DebtorBalance { creditors: number; debtors: number; overall: number }
|
interface DebtorBalance { creditors: number; debtors: number; overall: number }
|
||||||
interface DebtorsResult {
|
interface DebtorsResult {
|
||||||
period_open_balance: DebtorBalance
|
period_open_balance: DebtorBalance
|
||||||
balances_by_date: Record<string, DebtorBalance>
|
balances_by_date: Record<string, DebtorBalance>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DayOccStats {
|
||||||
|
roomsOccupied: number
|
||||||
|
people: number; adults: number; children: number; infants: number
|
||||||
|
netAccom: number
|
||||||
|
ggr: number; ggrRoomCount: number
|
||||||
|
avgLeadTime: number; arrivingCount: number
|
||||||
|
}
|
||||||
|
|
||||||
const RECON_LABELS: Record<string, string> = {
|
const RECON_LABELS: Record<string, string> = {
|
||||||
cash: 'Cash',
|
cash: 'Cash',
|
||||||
gateway_visa_mc: 'Gateway V/MC',
|
gateway_visa_mc: 'Gateway V/MC',
|
||||||
|
|
@ -33,7 +79,6 @@ const RECON_LABELS: Record<string, string> = {
|
||||||
pdq_amex: 'PDQ Amex',
|
pdq_amex: 'PDQ Amex',
|
||||||
bacs: 'BACS',
|
bacs: 'BACS',
|
||||||
}
|
}
|
||||||
|
|
||||||
const VARIANCE_CATEGORIES = new Set(['cash', 'pdq_visa_mc', 'pdq_amex'])
|
const VARIANCE_CATEGORIES = new Set(['cash', 'pdq_visa_mc', 'pdq_amex'])
|
||||||
|
|
||||||
function fmtDate(d: string) {
|
function fmtDate(d: string) {
|
||||||
|
|
@ -42,13 +87,116 @@ function fmtDate(d: string) {
|
||||||
return new Date(s + 'T12:00:00').toLocaleDateString('en-GB', { weekday: 'short', day: '2-digit', month: 'short' })
|
return new Date(s + 'T12:00:00').toLocaleDateString('en-GB', { weekday: 'short', day: '2-digit', month: 'short' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function variance(banked: number, reported: number) {
|
function buildOccupancyStats(
|
||||||
return banked - reported
|
dates: string[],
|
||||||
}
|
occupancyData: OccupancyCategoryRaw[],
|
||||||
|
bookingsData: BookingRaw[],
|
||||||
|
sitesData: SiteRaw[],
|
||||||
|
earnedRevenue: EarnedRevenueItem[],
|
||||||
|
glAccounts: Array<{ gl_group_id?: string; gl_group_name?: string }>,
|
||||||
|
): { byDate: Record<string, DayOccStats>; totalRooms: number } {
|
||||||
|
|
||||||
function varStyle(v: number): React.CSSProperties {
|
// Total rooms (exclude overflow)
|
||||||
if (Math.abs(v) < 0.005) return { textAlign: 'right', color: 'var(--text-mid)' }
|
const totalRooms = sitesData.filter(s =>
|
||||||
return { textAlign: 'right', color: v < 0 ? 'var(--danger)' : '#16a34a', fontWeight: 600 }
|
!(s.category_name ?? '').toLowerCase().includes('overflow')
|
||||||
|
).length
|
||||||
|
|
||||||
|
// Rooms occupied by date from per-category occupancy data
|
||||||
|
const roomsByDate: Record<string, number> = {}
|
||||||
|
for (const cat of occupancyData) {
|
||||||
|
if ((cat.category_name ?? '').toLowerCase().includes('overflow')) continue
|
||||||
|
if (cat.occupancy && typeof cat.occupancy === 'object') {
|
||||||
|
for (const [date, occ] of Object.entries(cat.occupancy)) {
|
||||||
|
const d = date.slice(0, 10)
|
||||||
|
roomsByDate[d] = (roomsByDate[d] ?? 0) + (Number(occ?.occupied) || 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Net accommodation revenue by date (identify ACC group from GL accounts)
|
||||||
|
const accomGroupIds = new Set<string>()
|
||||||
|
for (const a of glAccounts) {
|
||||||
|
const name = (a.gl_group_name ?? '').toLowerCase()
|
||||||
|
if (name.startsWith('acc') || name.includes('accommodation')) {
|
||||||
|
if (a.gl_group_id) accomGroupIds.add(a.gl_group_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const accomByDate: Record<string, number> = {}
|
||||||
|
for (const item of earnedRevenue) {
|
||||||
|
const d = (item.period ?? '').slice(0, 10)
|
||||||
|
if (!d || !item.gl_group_id) continue
|
||||||
|
if (accomGroupIds.has(item.gl_group_id)) {
|
||||||
|
accomByDate[d] = (accomByDate[d] ?? 0) + (item.earned_revenue_ex ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-day stats from bookings
|
||||||
|
const bookingStats: Record<string, {
|
||||||
|
people: number; adults: number; children: number; infants: number
|
||||||
|
totalRate: number; rateRooms: number; leadDaysSum: number; arrivingCount: number
|
||||||
|
}> = {}
|
||||||
|
|
||||||
|
for (const b of bookingsData) {
|
||||||
|
const catName = (b.category_name ?? '').toLowerCase()
|
||||||
|
if (catName.includes('overflow')) continue
|
||||||
|
|
||||||
|
const arrival = (b.booking_arrival ?? '').slice(0, 10)
|
||||||
|
const departure = (b.booking_departure ?? '').slice(0, 10)
|
||||||
|
if (!arrival || !departure) continue
|
||||||
|
|
||||||
|
const adults = Number(b.booking_adults) || 0
|
||||||
|
const children = Number(b.booking_children) || 0
|
||||||
|
const infants = Number(b.booking_infants) || 0
|
||||||
|
const people = adults + children + infants
|
||||||
|
|
||||||
|
const placedStr = (b.booking_placed ?? '').slice(0, 10)
|
||||||
|
let leadDays = 0
|
||||||
|
if (placedStr) {
|
||||||
|
const placedMs = new Date(placedStr + 'T12:00:00').getTime()
|
||||||
|
const arrivalMs = new Date(arrival + 'T12:00:00').getTime()
|
||||||
|
leadDays = Math.max(0, Math.floor((arrivalMs - placedMs) / 86400000))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build tariff lookup: { stayDate → amount }
|
||||||
|
const tariffMap: Record<string, number> = {}
|
||||||
|
for (const t of (b.tariffs_quoted ?? [])) {
|
||||||
|
const td = (t.stay_date ?? '').slice(0, 10)
|
||||||
|
if (td) tariffMap[td] = (tariffMap[td] ?? 0) + (Number(t.calculated_amount) || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate each stay day
|
||||||
|
let cur = new Date(arrival + 'T12:00:00')
|
||||||
|
const dep = new Date(departure + 'T12:00:00')
|
||||||
|
while (cur < dep) {
|
||||||
|
const d = cur.toISOString().slice(0, 10)
|
||||||
|
if (!bookingStats[d]) bookingStats[d] = { people: 0, adults: 0, children: 0, infants: 0, totalRate: 0, rateRooms: 0, leadDaysSum: 0, arrivingCount: 0 }
|
||||||
|
const s = bookingStats[d]
|
||||||
|
s.people += people; s.adults += adults; s.children += children; s.infants += infants
|
||||||
|
if (tariffMap[d] != null) { s.totalRate += tariffMap[d]; s.rateRooms++ }
|
||||||
|
if (d === arrival) { s.leadDaysSum += leadDays; s.arrivingCount++ }
|
||||||
|
cur.setDate(cur.getDate() + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const byDate: Record<string, DayOccStats> = {}
|
||||||
|
for (const date of dates) {
|
||||||
|
const bk = bookingStats[date]
|
||||||
|
const roomsOccupied = roomsByDate[date] ?? 0
|
||||||
|
const netAccom = accomByDate[date] ?? 0
|
||||||
|
byDate[date] = {
|
||||||
|
roomsOccupied,
|
||||||
|
people: bk?.people ?? 0,
|
||||||
|
adults: bk?.adults ?? 0,
|
||||||
|
children: bk?.children ?? 0,
|
||||||
|
infants: bk?.infants ?? 0,
|
||||||
|
netAccom,
|
||||||
|
ggr: bk && bk.rateRooms > 0 ? bk.totalRate / bk.rateRooms : 0,
|
||||||
|
ggrRoomCount: bk?.rateRooms ?? 0,
|
||||||
|
avgLeadTime: bk && bk.arrivingCount > 0 ? Math.round(bk.leadDaysSum / bk.arrivingCount) : 0,
|
||||||
|
arrivingCount: bk?.arrivingCount ?? 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { byDate, totalRooms }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MultiDayReport() {
|
export function MultiDayReport() {
|
||||||
|
|
@ -86,6 +234,15 @@ export function MultiDayReport() {
|
||||||
const dates = result?.report_data.map(d => d.date) ?? []
|
const dates = result?.report_data.map(d => d.date) ?? []
|
||||||
const salesCols = result?.sales_columns ?? []
|
const salesCols = result?.sales_columns ?? []
|
||||||
|
|
||||||
|
const occStats = result ? buildOccupancyStats(
|
||||||
|
dates,
|
||||||
|
result.occupancy_data,
|
||||||
|
result.bookings_data,
|
||||||
|
result.sites_data,
|
||||||
|
result.earned_revenue ?? [],
|
||||||
|
result.gl_accounts ?? [],
|
||||||
|
) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '1.5rem' }}>
|
<div style={{ padding: '1.5rem' }}>
|
||||||
<PageHeader title="Weekly / Multi-Day Report" />
|
<PageHeader title="Weekly / Multi-Day Report" />
|
||||||
|
|
@ -147,11 +304,7 @@ export function MultiDayReport() {
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '2px solid var(--card-border)' }}>
|
<tr style={{ borderBottom: '2px solid var(--card-border)' }}>
|
||||||
<th style={{ ...th, textAlign: 'left' }}>Category</th>
|
<th style={{ ...th, textAlign: 'left' }}>Category</th>
|
||||||
{dates.map(d => (
|
{dates.map(d => <th key={d} style={th}>{fmtDate(d)}</th>)}
|
||||||
<th key={d} style={th} colSpan={VARIANCE_CATEGORIES.has('') ? 1 : 1}>
|
|
||||||
{fmtDate(d)}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
<th style={th}>Total</th>
|
<th style={th}>Total</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -163,40 +316,28 @@ export function MultiDayReport() {
|
||||||
const hasVariance = VARIANCE_CATEGORIES.has(key)
|
const hasVariance = VARIANCE_CATEGORIES.has(key)
|
||||||
const totalBanked = bankedVals.reduce((s, v) => s + v, 0)
|
const totalBanked = bankedVals.reduce((s, v) => s + v, 0)
|
||||||
const totalReported = reportedVals.reduce((s, v) => s + v, 0)
|
const totalReported = reportedVals.reduce((s, v) => s + v, 0)
|
||||||
|
|
||||||
if (bankedVals.every(v => v === 0) && reportedVals.every(v => v === 0)) return null
|
if (bankedVals.every(v => v === 0) && reportedVals.every(v => v === 0)) return null
|
||||||
|
|
||||||
return [
|
return [
|
||||||
/* Reported row */
|
|
||||||
hasVariance && (
|
hasVariance && (
|
||||||
<tr key={`${key}-reported`} style={{ borderBottom: '1px solid transparent' }}>
|
<tr key={`${key}-rep`} style={{ borderBottom: '1px solid transparent' }}>
|
||||||
<td style={{ ...td, paddingLeft: '0.5rem', color: 'var(--text-mid)', fontSize: '0.75rem' }}>
|
<td style={{ ...td, paddingLeft: '0.5rem', color: 'var(--text-mid)', fontSize: '0.75rem' }}>{label} — Newbook</td>
|
||||||
{label} — Newbook
|
|
||||||
</td>
|
|
||||||
{reportedVals.map((v, i) => (
|
{reportedVals.map((v, i) => (
|
||||||
<td key={i} style={{ ...td, textAlign: 'right', color: 'var(--text-mid)', fontSize: '0.75rem' }}>
|
<td key={i} style={{ ...td, textAlign: 'right', color: 'var(--text-mid)', fontSize: '0.75rem' }}>{v ? fmtGBP(v) : '—'}</td>
|
||||||
{v ? fmtGBP(v) : '—'}
|
|
||||||
</td>
|
|
||||||
))}
|
))}
|
||||||
<td style={{ ...td, textAlign: 'right', color: 'var(--text-mid)', fontSize: '0.75rem' }}>
|
<td style={{ ...td, textAlign: 'right', color: 'var(--text-mid)', fontSize: '0.75rem' }}>{fmtGBP(totalReported)}</td>
|
||||||
{fmtGBP(totalReported)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
),
|
),
|
||||||
/* Banked / main row */
|
<tr key={`${key}-bnk`} style={{ borderBottom: '1px solid var(--card-border)' }}>
|
||||||
<tr key={`${key}-banked`} style={{ borderBottom: '1px solid var(--card-border)' }}>
|
<td style={{ ...td, fontWeight: 600 }}>{hasVariance ? `${label} — Banked` : label}</td>
|
||||||
<td style={{ ...td, fontWeight: 600 }}>
|
|
||||||
{hasVariance ? `${label} — Banked` : label}
|
|
||||||
</td>
|
|
||||||
{bankedVals.map((v, i) => {
|
{bankedVals.map((v, i) => {
|
||||||
const rep = reportedVals[i]
|
const rep = reportedVals[i]
|
||||||
const v2 = hasVariance ? variance(v, rep) : null
|
const vari = hasVariance ? (v - rep) : null
|
||||||
return (
|
return (
|
||||||
<td key={i} style={{ ...td, textAlign: 'right' }}>
|
<td key={i} style={{ ...td, textAlign: 'right' }}>
|
||||||
{v ? fmtGBP(v) : '—'}
|
{v ? fmtGBP(v) : '—'}
|
||||||
{v2 !== null && Math.abs(v2) > 0.005 && (
|
{vari !== null && Math.abs(vari) > 0.005 && (
|
||||||
<div style={{ fontSize: '0.7rem', color: v2 < 0 ? 'var(--danger)' : '#16a34a' }}>
|
<div style={{ fontSize: '0.7rem', color: vari < 0 ? 'var(--danger)' : '#16a34a' }}>
|
||||||
{v2 > 0 ? '+' : ''}{fmtGBP(v2)}
|
{vari > 0 ? '+' : ''}{fmtGBP(vari)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -205,15 +346,14 @@ export function MultiDayReport() {
|
||||||
<td style={{ ...td, textAlign: 'right', fontWeight: 700 }}>
|
<td style={{ ...td, textAlign: 'right', fontWeight: 700 }}>
|
||||||
{fmtGBP(totalBanked)}
|
{fmtGBP(totalBanked)}
|
||||||
{hasVariance && Math.abs(totalBanked - totalReported) > 0.005 && (
|
{hasVariance && Math.abs(totalBanked - totalReported) > 0.005 && (
|
||||||
<div style={varStyle(totalBanked - totalReported)}>
|
<div style={{ fontSize: '0.7rem', color: totalBanked - totalReported < 0 ? 'var(--danger)' : '#16a34a' }}>
|
||||||
{totalBanked - totalReported > 0 ? '+' : ''}{fmtGBP(totalBanked - totalReported)}
|
{totalBanked - totalReported > 0 ? '+' : ''}{fmtGBP(totalBanked - totalReported)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>,
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
})}
|
})}
|
||||||
{/* Total row */}
|
|
||||||
<tr style={{ borderTop: '2px solid var(--card-border)', background: 'var(--body-bg)' }}>
|
<tr style={{ borderTop: '2px solid var(--card-border)', background: 'var(--body-bg)' }}>
|
||||||
<td style={{ ...td, fontWeight: 700 }}>Total</td>
|
<td style={{ ...td, fontWeight: 700 }}>Total</td>
|
||||||
{result.report_data.map((day, i) => {
|
{result.report_data.map((day, i) => {
|
||||||
|
|
@ -237,7 +377,9 @@ export function MultiDayReport() {
|
||||||
<tr style={{ borderBottom: '2px solid var(--card-border)' }}>
|
<tr style={{ borderBottom: '2px solid var(--card-border)' }}>
|
||||||
<th style={{ ...th, textAlign: 'left' }}>Category</th>
|
<th style={{ ...th, textAlign: 'left' }}>Category</th>
|
||||||
{dates.map(d => <th key={d} style={th}>{fmtDate(d)}</th>)}
|
{dates.map(d => <th key={d} style={th}>{fmtDate(d)}</th>)}
|
||||||
<th style={th}>Total</th>
|
<th style={th}>Total Net</th>
|
||||||
|
<th style={th}>Total VAT</th>
|
||||||
|
<th style={th}>Total Gross</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -246,44 +388,46 @@ export function MultiDayReport() {
|
||||||
const sb = day.sales_breakdown.find(s => s.gl_code === col.gl_code)
|
const sb = day.sales_breakdown.find(s => s.gl_code === col.gl_code)
|
||||||
return { net: sb?.net_amount ?? 0, gross: sb?.gross_amount ?? 0, vat: sb?.vat_amount ?? 0 }
|
return { net: sb?.net_amount ?? 0, gross: sb?.gross_amount ?? 0, vat: sb?.vat_amount ?? 0 }
|
||||||
})
|
})
|
||||||
const totalGross = vals.reduce((s, v) => s + v.gross, 0)
|
const totNet = vals.reduce((s, v) => s + v.net, 0)
|
||||||
if (vals.every(v => v.gross === 0)) return null
|
const totVat = vals.reduce((s, v) => s + v.vat, 0)
|
||||||
|
const totGross = vals.reduce((s, v) => s + v.gross, 0)
|
||||||
|
if (vals.every(v => v.gross === 0 && v.net === 0)) return null
|
||||||
return (
|
return (
|
||||||
<tr key={col.gl_code} style={{ borderBottom: '1px solid var(--card-border)' }}>
|
<tr key={col.gl_code} style={{ borderBottom: '1px solid var(--card-border)' }}>
|
||||||
<td style={td}>{col.display_name}</td>
|
<td style={td}>{col.display_name}</td>
|
||||||
{vals.map((v, i) => (
|
{vals.map((v, i) => (
|
||||||
<td key={i} style={{ ...td, textAlign: 'right' }}>
|
<td key={i} style={{ ...td, textAlign: 'right' }}>
|
||||||
{v.gross ? fmtGBP(v.gross) : '—'}
|
{v.gross ? fmtGBP(v.gross) : '—'}
|
||||||
{v.vat > 0 && (
|
{v.vat > 0 && <div style={{ fontSize: '0.7rem', color: 'var(--text-mid)' }}>net {fmtGBP(v.net)}</div>}
|
||||||
<div style={{ fontSize: '0.7rem', color: 'var(--text-mid)' }}>
|
|
||||||
net {fmtGBP(v.net)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
<td style={{ ...td, textAlign: 'right', fontWeight: 700 }}>{fmtGBP(totalGross)}</td>
|
<td style={{ ...td, textAlign: 'right' }}>{fmtGBP(totNet)}</td>
|
||||||
|
<td style={{ ...td, textAlign: 'right', color: 'var(--text-mid)' }}>{fmtGBP(totVat)}</td>
|
||||||
|
<td style={{ ...td, textAlign: 'right', fontWeight: 700 }}>{fmtGBP(totGross)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<tr style={{ borderTop: '2px solid var(--card-border)', background: 'var(--body-bg)' }}>
|
<tr style={{ borderTop: '2px solid var(--card-border)', background: 'var(--body-bg)', fontWeight: 700 }}>
|
||||||
<td style={{ ...td, fontWeight: 700 }}>Total Gross</td>
|
<td style={td}>TOTAL</td>
|
||||||
{result.report_data.map((day, i) => {
|
{result.report_data.map((day, i) => {
|
||||||
const t = day.sales_breakdown.reduce((s, sb) => s + (sb.gross_amount ?? 0), 0)
|
const g = day.sales_breakdown.reduce((s, sb) => s + (sb.gross_amount ?? 0), 0)
|
||||||
return <td key={i} style={{ ...td, textAlign: 'right', fontWeight: 700 }}>{t ? fmtGBP(t) : '—'}</td>
|
const n = day.sales_breakdown.reduce((s, sb) => s + (sb.net_amount ?? 0), 0)
|
||||||
})}
|
return (
|
||||||
<td style={{ ...td, textAlign: 'right', fontWeight: 700 }}>
|
<td key={i} style={{ ...td, textAlign: 'right' }}>
|
||||||
{fmtGBP(result.report_data.reduce((s, d) => s + d.sales_breakdown.reduce((ss, sb) => ss + (sb.gross_amount ?? 0), 0), 0))}
|
{g ? fmtGBP(g) : '—'}
|
||||||
|
{n > 0 && <div style={{ fontSize: '0.7rem', color: 'var(--text-mid)' }}>net {fmtGBP(n)}</div>}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
)
|
||||||
<tr style={{ background: 'var(--body-bg)' }}>
|
|
||||||
<td style={{ ...td, fontWeight: 700, color: 'var(--text-mid)', fontSize: '0.75rem' }}>Total Net</td>
|
|
||||||
{result.report_data.map((day, i) => {
|
|
||||||
const t = day.sales_breakdown.reduce((s, sb) => s + (sb.net_amount ?? 0), 0)
|
|
||||||
return <td key={i} style={{ ...td, textAlign: 'right', color: 'var(--text-mid)', fontSize: '0.75rem' }}>{t ? fmtGBP(t) : '—'}</td>
|
|
||||||
})}
|
})}
|
||||||
<td style={{ ...td, textAlign: 'right', color: 'var(--text-mid)', fontSize: '0.75rem' }}>
|
<td style={{ ...td, textAlign: 'right' }}>
|
||||||
{fmtGBP(result.report_data.reduce((s, d) => s + d.sales_breakdown.reduce((ss, sb) => ss + (sb.net_amount ?? 0), 0), 0))}
|
{fmtGBP(result.report_data.reduce((s, d) => s + d.sales_breakdown.reduce((ss, sb) => ss + (sb.net_amount ?? 0), 0), 0))}
|
||||||
</td>
|
</td>
|
||||||
|
<td style={{ ...td, textAlign: 'right', color: 'var(--text-mid)' }}>
|
||||||
|
{fmtGBP(result.report_data.reduce((s, d) => s + d.sales_breakdown.reduce((ss, sb) => ss + (sb.vat_amount ?? 0), 0), 0))}
|
||||||
|
</td>
|
||||||
|
<td style={{ ...td, textAlign: 'right' }}>
|
||||||
|
{fmtGBP(result.report_data.reduce((s, d) => s + d.sales_breakdown.reduce((ss, sb) => ss + (sb.gross_amount ?? 0), 0), 0))}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
@ -291,35 +435,106 @@ export function MultiDayReport() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Table 3: Occupancy */}
|
{/* Table 3: Occupancy */}
|
||||||
{result.occupancy_data.length > 0 && (
|
{occStats && (
|
||||||
<Card style={{ marginBottom: '1.5rem', overflowX: 'auto' }}>
|
<Card style={{ marginBottom: '1.5rem', overflowX: 'auto' }}>
|
||||||
<h2 style={sectionTitle}>Occupancy</h2>
|
<h2 style={sectionTitle}>Occupancy</h2>
|
||||||
<table style={{ borderCollapse: 'collapse', fontSize: '0.8rem', minWidth: '400px', width: '100%' }}>
|
{occStats.totalRooms > 0 && (
|
||||||
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-mid)', marginBottom: '0.75rem' }}>
|
||||||
|
Total rooms: {occStats.totalRooms}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<table style={{ borderCollapse: 'collapse', fontSize: '0.8rem', minWidth: '600px', width: '100%' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '2px solid var(--card-border)' }}>
|
<tr style={{ borderBottom: '2px solid var(--card-border)' }}>
|
||||||
<th style={{ ...th, textAlign: 'left' }}>Date</th>
|
<th style={{ ...th, textAlign: 'left' }}>Metric</th>
|
||||||
<th style={th}>Rooms Sold</th>
|
{dates.map(d => <th key={d} style={th}>{fmtDate(d)}</th>)}
|
||||||
<th style={th}>Total Rooms</th>
|
<th style={th}>Total / Avg</th>
|
||||||
<th style={th}>Guests</th>
|
|
||||||
<th style={th}>Occ %</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{result.occupancy_data.map((row, i) => {
|
{[
|
||||||
const date = row.period ?? row.date ?? ''
|
{
|
||||||
const occ = row.total_rooms && row.rooms_sold
|
label: 'Rooms Occupied',
|
||||||
? ((row.rooms_sold / row.total_rooms) * 100).toFixed(1)
|
vals: dates.map(d => occStats.byDate[d]?.roomsOccupied ?? 0),
|
||||||
: '—'
|
fmt: (v: number) => String(v),
|
||||||
return (
|
total: (vs: number[]) => String(vs.reduce((s, v) => s + v, 0)),
|
||||||
<tr key={i} style={{ borderBottom: '1px solid var(--card-border)' }}>
|
},
|
||||||
<td style={td}>{fmtDate(date)}</td>
|
{
|
||||||
<td style={{ ...td, textAlign: 'right' }}>{row.rooms_sold ?? '—'}</td>
|
label: 'Occ %',
|
||||||
<td style={{ ...td, textAlign: 'right' }}>{row.total_rooms ?? '—'}</td>
|
vals: dates.map(d => occStats.totalRooms > 0 ? (occStats.byDate[d]?.roomsOccupied ?? 0) / occStats.totalRooms * 100 : 0),
|
||||||
<td style={{ ...td, textAlign: 'right' }}>{row.total_people ?? '—'}</td>
|
fmt: (v: number) => v ? v.toFixed(1) + '%' : '—',
|
||||||
<td style={{ ...td, textAlign: 'right' }}>{occ}{occ !== '—' ? '%' : ''}</td>
|
total: (vs: number[]) => { const avg = vs.filter(v => v > 0); return avg.length ? (avg.reduce((s, v) => s + v, 0) / avg.length).toFixed(1) + '% avg' : '—' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Guests',
|
||||||
|
vals: dates.map(d => occStats.byDate[d]?.people ?? 0),
|
||||||
|
fmt: (v: number) => v ? String(v) : '—',
|
||||||
|
total: (vs: number[]) => String(vs.reduce((s, v) => s + v, 0)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Net Accom',
|
||||||
|
vals: dates.map(d => occStats.byDate[d]?.netAccom ?? 0),
|
||||||
|
fmt: (v: number) => v ? fmtGBP(v) : '—',
|
||||||
|
total: (vs: number[]) => fmtGBP(vs.reduce((s, v) => s + v, 0)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Avg Net / Room',
|
||||||
|
vals: dates.map(d => {
|
||||||
|
const s = occStats.byDate[d]; if (!s) return 0
|
||||||
|
return s.roomsOccupied > 0 ? s.netAccom / s.roomsOccupied : 0
|
||||||
|
}),
|
||||||
|
fmt: (v: number) => v ? fmtGBP(v) : '—',
|
||||||
|
total: (vs: number[]) => {
|
||||||
|
const totalNetAccom = dates.reduce((s, d) => s + (occStats.byDate[d]?.netAccom ?? 0), 0)
|
||||||
|
const totalRooms = dates.reduce((s, d) => s + (occStats.byDate[d]?.roomsOccupied ?? 0), 0)
|
||||||
|
return totalRooms > 0 ? fmtGBP(totalNetAccom / totalRooms) + ' avg' : '—'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'REVPAR',
|
||||||
|
vals: dates.map(d => {
|
||||||
|
if (!occStats.totalRooms) return 0
|
||||||
|
return (occStats.byDate[d]?.netAccom ?? 0) / occStats.totalRooms
|
||||||
|
}),
|
||||||
|
fmt: (v: number) => v ? fmtGBP(v) : '—',
|
||||||
|
total: (vs: number[]) => {
|
||||||
|
const avg = vs.filter(v => v > 0)
|
||||||
|
return avg.length ? fmtGBP(avg.reduce((s, v) => s + v, 0) / avg.length) + ' avg' : '—'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'GGR (Avg Rate)',
|
||||||
|
vals: dates.map(d => occStats.byDate[d]?.ggr ?? 0),
|
||||||
|
fmt: (v: number) => v ? fmtGBP(v) : '—',
|
||||||
|
total: (vs: number[]) => {
|
||||||
|
const active = vs.filter(v => v > 0)
|
||||||
|
return active.length ? fmtGBP(active.reduce((s, v) => s + v, 0) / active.length) + ' avg' : '—'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Avg Lead Time',
|
||||||
|
vals: dates.map(d => occStats.byDate[d]?.avgLeadTime ?? 0),
|
||||||
|
fmt: (v: number, i: number) => {
|
||||||
|
const s = occStats.byDate[dates[i]]
|
||||||
|
if (!s || !s.arrivingCount) return '—'
|
||||||
|
return v + ' days'
|
||||||
|
},
|
||||||
|
total: (vs: number[]) => {
|
||||||
|
const active = vs.filter(v => v > 0)
|
||||||
|
return active.length ? Math.round(active.reduce((s, v) => s + v, 0) / active.length) + ' days avg' : '—'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
].map(row => (
|
||||||
|
<tr key={row.label} style={{ borderBottom: '1px solid var(--card-border)' }}>
|
||||||
|
<td style={{ ...td, fontWeight: 600 }}>{row.label}</td>
|
||||||
|
{row.vals.map((v, i) => (
|
||||||
|
<td key={i} style={{ ...td, textAlign: 'right' }}>{row.fmt(v, i)}</td>
|
||||||
|
))}
|
||||||
|
<td style={{ ...td, textAlign: 'right', fontWeight: 600, color: 'var(--text-mid)' }}>
|
||||||
|
{row.total(row.vals)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
))}
|
||||||
})}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -355,10 +570,26 @@ export function MultiDayReport() {
|
||||||
<td style={td}>{fmtDate(d)}</td>
|
<td style={td}>{fmtDate(d)}</td>
|
||||||
<td style={{ ...td, textAlign: 'right' }}>{fmtGBP(b.debtors)}</td>
|
<td style={{ ...td, textAlign: 'right' }}>{fmtGBP(b.debtors)}</td>
|
||||||
<td style={{ ...td, textAlign: 'right' }}>{fmtGBP(b.creditors)}</td>
|
<td style={{ ...td, textAlign: 'right' }}>{fmtGBP(b.creditors)}</td>
|
||||||
<td style={{ ...td, textAlign: 'right', fontWeight: 700 }}>{fmtGBP(b.overall)}</td>
|
<td style={{ ...td, textAlign: 'right', fontWeight: 700, color: b.overall >= 0 ? '#16a34a' : 'var(--danger)' }}>
|
||||||
|
{fmtGBP(b.overall)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
{(() => {
|
||||||
|
const last = debtors.balances_by_date[dates[dates.length - 1]]
|
||||||
|
if (!last) return null
|
||||||
|
return (
|
||||||
|
<tr style={{ borderTop: '2px solid var(--card-border)', background: 'var(--body-bg)' }}>
|
||||||
|
<td style={{ ...td, fontWeight: 700 }}>Period Close</td>
|
||||||
|
<td style={{ ...td, textAlign: 'right', fontWeight: 700 }}>{fmtGBP(last.debtors)}</td>
|
||||||
|
<td style={{ ...td, textAlign: 'right', fontWeight: 700 }}>{fmtGBP(last.creditors)}</td>
|
||||||
|
<td style={{ ...td, textAlign: 'right', fontWeight: 700, color: last.overall >= 0 ? '#16a34a' : 'var(--danger)' }}>
|
||||||
|
{fmtGBP(last.overall)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue