Wire Newbook credentials to settings service

This commit is contained in:
jtricerolph 2026-07-01 19:33:16 +00:00
commit 63a5a72fa3
32 changed files with 3386 additions and 0 deletions

8
backend/Dockerfile Normal file
View file

@ -0,0 +1,8 @@
FROM node:22-alpine
WORKDIR /app
RUN mkdir -p /app/uploads
COPY package.json .
RUN npm install --omit=dev
COPY src ./src
EXPOSE 3001
CMD ["node", "src/index.js"]

18
backend/package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "hnf-cashup-backend",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"@fastify/cookie": "^9.4.0",
"@fastify/cors": "^9.0.1",
"@fastify/multipart": "^9.0.1",
"@fastify/static": "^8.0.1",
"fastify": "^4.28.1",
"jose": "^5.9.6",
"pg": "^8.13.1"
}
}

35
backend/src/auth.js Normal file
View file

@ -0,0 +1,35 @@
import { jwtVerify } from 'jose'
import { isOnsite } from './ip-check.js'
const APP_SLUG = process.env.APP_SLUG || 'cashup'
const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '')
export async function requireAuth(request, reply) {
const token = request.cookies?.hnf_session
if (!token) return reply.status(401).send({ error: 'Not authenticated' })
let payload
try {
const { payload: p } = await jwtVerify(token, secret)
payload = p
} catch {
return reply.status(401).send({ error: 'Invalid session' })
}
if (!payload.apps?.includes(APP_SLUG)) {
return reply.status(403).send({ error: 'No permission for this app' })
}
if (!payload.offsite_allowed) {
const clientIP = request.headers['x-real-ip'] || request.ip
if (!(await isOnsite(clientIP))) {
return reply.status(403).send({ error: 'Access restricted to site network' })
}
}
request.user = {
email: payload.sub,
name: payload.name,
is_admin: payload.is_admin ?? false,
}
}

141
backend/src/db.js Normal file
View file

@ -0,0 +1,141 @@
import pg from 'pg'
const { Pool } = pg
export const pool = new Pool({ connectionString: process.env.DATABASE_URL })
export async function initDb() {
await pool.query(`
CREATE TABLE IF NOT EXISTS cash_ups (
id SERIAL PRIMARY KEY,
session_date DATE NOT NULL UNIQUE,
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
status TEXT NOT NULL DEFAULT 'draft',
total_float_counted NUMERIC(10,2) NOT NULL DEFAULT 0,
total_cash_counted NUMERIC(10,2) NOT NULL DEFAULT 0,
notes TEXT,
submitted_at TIMESTAMPTZ,
submitted_by TEXT
);
CREATE TABLE IF NOT EXISTS denominations (
id SERIAL PRIMARY KEY,
cash_up_id INTEGER NOT NULL REFERENCES cash_ups(id) ON DELETE CASCADE,
count_type TEXT NOT NULL DEFAULT 'takings',
denomination_type TEXT NOT NULL,
denomination_value NUMERIC(10,2) NOT NULL,
quantity INTEGER,
value_entered NUMERIC(10,2),
total_amount NUMERIC(10,2) NOT NULL
);
CREATE TABLE IF NOT EXISTS card_machines (
id SERIAL PRIMARY KEY,
cash_up_id INTEGER NOT NULL REFERENCES cash_ups(id) ON DELETE CASCADE,
machine_name TEXT NOT NULL,
total_amount NUMERIC(10,2) NOT NULL DEFAULT 0,
amex_amount NUMERIC(10,2) NOT NULL DEFAULT 0,
visa_mc_amount NUMERIC(10,2) NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS payment_records (
id SERIAL PRIMARY KEY,
cash_up_id INTEGER REFERENCES cash_ups(id) ON DELETE SET NULL,
newbook_payment_id TEXT,
booking_id TEXT,
guest_name TEXT,
payment_date TIMESTAMPTZ NOT NULL,
payment_type TEXT,
payment_method TEXT,
transaction_method TEXT,
card_type TEXT,
amount NUMERIC(10,2) NOT NULL,
tendered NUMERIC(10,2),
processed_by TEXT,
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS reconciliation (
id SERIAL PRIMARY KEY,
cash_up_id INTEGER NOT NULL REFERENCES cash_ups(id) ON DELETE CASCADE,
category TEXT NOT NULL,
banked_amount NUMERIC(10,2) NOT NULL,
reported_amount NUMERIC(10,2) NOT NULL,
variance NUMERIC(10,2) NOT NULL
);
CREATE TABLE IF NOT EXISTS daily_stats (
id SERIAL PRIMARY KEY,
business_date DATE NOT NULL UNIQUE,
gross_sales NUMERIC(10,2) NOT NULL DEFAULT 0,
debtors_creditors_balance NUMERIC(10,2) NOT NULL DEFAULT 0,
rooms_sold INTEGER NOT NULL DEFAULT 0,
total_people INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
source TEXT NOT NULL DEFAULT 'manual'
);
CREATE TABLE IF NOT EXISTS sales_breakdown (
id SERIAL PRIMARY KEY,
business_date DATE NOT NULL,
category TEXT NOT NULL,
net_amount NUMERIC(10,2) NOT NULL
);
CREATE TABLE IF NOT EXISTS float_counts (
id SERIAL PRIMARY KEY,
count_type TEXT NOT NULL,
count_date TIMESTAMPTZ NOT NULL,
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
total_counted NUMERIC(10,2) NOT NULL DEFAULT 0,
total_receipts NUMERIC(10,2) NOT NULL DEFAULT 0,
target_amount NUMERIC(10,2) NOT NULL DEFAULT 0,
variance NUMERIC(10,2) NOT NULL DEFAULT 0,
notes TEXT
);
CREATE TABLE IF NOT EXISTS float_denominations (
id SERIAL PRIMARY KEY,
float_count_id INTEGER NOT NULL REFERENCES float_counts(id) ON DELETE CASCADE,
denomination_value NUMERIC(10,2) NOT NULL,
quantity INTEGER NOT NULL DEFAULT 0,
total_amount NUMERIC(10,2) NOT NULL
);
CREATE TABLE IF NOT EXISTS float_receipts (
id SERIAL PRIMARY KEY,
float_count_id INTEGER NOT NULL REFERENCES float_counts(id) ON DELETE CASCADE,
receipt_value NUMERIC(10,2) NOT NULL,
receipt_description TEXT
);
CREATE TABLE IF NOT EXISTS cash_count_attachments (
id SERIAL PRIMARY KEY,
cash_up_id INTEGER NOT NULL REFERENCES cash_ups(id) ON DELETE CASCADE,
file_name TEXT NOT NULL,
file_path TEXT NOT NULL,
file_size BIGINT NOT NULL,
mime_type TEXT NOT NULL,
uploaded_by TEXT NOT NULL,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`)
// Seed default settings if not present (Newbook credentials live in the settings service)
await pool.query(`
INSERT INTO settings (key, value) VALUES
('default_report_days', '7'),
('petty_cash_float', '200.00'),
('sales_breakdown_columns', '[]'),
('change_tin_breakdown', '{"50.00":0,"20.00":0,"10.00":0,"5.00":0,"2.00":20,"1.00":20,"0.50":10,"0.20":10,"0.10":5,"0.05":5}')
ON CONFLICT (key) DO NOTHING
`)
}

99
backend/src/index.js Normal file
View file

@ -0,0 +1,99 @@
import Fastify from 'fastify'
import cookie from '@fastify/cookie'
import cors from '@fastify/cors'
import multipart from '@fastify/multipart'
import staticFiles from '@fastify/static'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
import { initDb } from './db.js'
import { cashupRoutes } from './routes/cashup.js'
import { newbookRoutes } from './routes/newbook.js'
import { reportRoutes } from './routes/reports.js'
import { floatRoutes } from './routes/floats.js'
import { settingsRoutes } from './routes/settings.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const UPLOADS_DIR = join(__dirname, '..', '..', 'uploads')
const app = Fastify({ logger: true, trustProxy: true })
await app.register(cookie)
await app.register(cors, {
origin: process.env.CORS_ORIGIN || false,
credentials: true,
})
await app.register(multipart, { limits: { fileSize: 10 * 1024 * 1024 } })
await app.register(staticFiles, {
root: UPLOADS_DIR,
prefix: '/api/uploads/',
decorateReply: false,
})
app.get('/health', async () => ({ status: 'healthy' }))
await app.register(cashupRoutes)
await app.register(newbookRoutes)
await app.register(reportRoutes)
await app.register(floatRoutes)
await app.register(settingsRoutes)
// File upload for cash up receipt attachments
import { requireAuth } from './auth.js'
import { pool } from './db.js'
import { createWriteStream } from 'fs'
import { mkdir } from 'fs/promises'
import { randomUUID } from 'crypto'
import { extname } from 'path'
app.post('/api/attachments/upload/:cash_up_id', { preHandler: requireAuth }, async (req, reply) => {
const cashUpId = parseInt(req.params.cash_up_id)
const { rows } = await pool.query('SELECT id FROM cash_ups WHERE id = $1', [cashUpId])
if (!rows.length) return reply.status(404).send({ error: 'Cash up not found' })
const data = await req.file()
if (!data) return reply.status(400).send({ error: 'No file uploaded' })
const allowed = ['image/jpeg', 'image/jpg', 'image/png', 'application/pdf']
if (!allowed.includes(data.mimetype)) {
return reply.status(400).send({ error: 'Only JPEG, PNG and PDF files are allowed' })
}
const ext = extname(data.filename) || '.bin'
const filename = randomUUID() + ext
const dir = join(UPLOADS_DIR, 'cashup', String(cashUpId))
await mkdir(dir, { recursive: true })
let size = 0
const dest = createWriteStream(join(dir, filename))
for await (const chunk of data.file) { dest.write(chunk); size += chunk.length }
await new Promise(r => dest.end(r))
const filePath = `/cashup/${cashUpId}/${filename}`
const { rows: ins } = await pool.query(
`INSERT INTO cash_count_attachments (cash_up_id, file_name, file_path, file_size, mime_type, uploaded_by)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
[cashUpId, data.filename, filePath, size, data.mimetype, req.user.email]
)
return ins.rows[0]
})
app.delete('/api/attachments/:id', { preHandler: requireAuth }, async (req, reply) => {
const { rows } = await pool.query('SELECT * FROM cash_count_attachments WHERE id = $1', [req.params.id])
if (!rows.length) return reply.status(404).send({ error: 'Not found' })
// Delete file from disk (best effort)
const { unlink } = await import('fs/promises')
await unlink(join(UPLOADS_DIR, rows[0].file_path)).catch(() => {})
await pool.query('DELETE FROM cash_count_attachments WHERE id = $1', [req.params.id])
return { message: 'Deleted' }
})
try {
await initDb()
await app.listen({ port: 3001, host: '0.0.0.0' })
} catch (err) {
app.log.error(err)
process.exit(1)
}

80
backend/src/ip-check.js Normal file
View file

@ -0,0 +1,80 @@
import dns from 'dns/promises'
const raw = (process.env.OFFICE_IP_CHECK || 'disabled').trim()
const matchers = raw.split(',').map(s => s.trim()).filter(Boolean)
const TTL = 5 * 60 * 1000
const cache = new Map()
const PUBLIC_IP_URLS = [
'https://api.ipify.org',
'https://ifconfig.co/ip',
'https://icanhazip.com',
]
function normalizeIP(ip) {
return ip?.startsWith('::ffff:') ? ip.slice(7) : ip
}
function isIPv4(s) {
return /^\d{1,3}(\.\d{1,3}){3}$/.test(s)
}
function ipInCidr(ip, cidr) {
const [range, bits] = cidr.split('/')
if (!isIPv4(ip) || !isIPv4(range)) return false
const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0
const toInt = s => s.split('.').reduce((a, o) => (a << 8) + parseInt(o), 0) >>> 0
return (toInt(ip) & mask) === (toInt(range) & mask)
}
async function fetchPublicIP() {
for (const url of PUBLIC_IP_URLS) {
try {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), 4000)
const res = await fetch(url, { signal: ctrl.signal })
clearTimeout(timer)
if (!res.ok) continue
const ip = (await res.text()).trim()
if (isIPv4(ip)) return ip
} catch {
// try next
}
}
return null
}
async function resolveDynamic(key, resolver) {
const hit = cache.get(key)
if (hit && Date.now() < hit.expiry) return hit.ip
const ip = await resolver()
if (ip) {
cache.set(key, { ip, expiry: Date.now() + TTL })
return ip
}
return hit ? hit.ip : null
}
export async function isOnsite(requestIP) {
if (matchers.length === 0 || matchers.includes('disabled')) return true
const ip = normalizeIP(requestIP)
if (!ip) return false
for (const m of matchers) {
if (m === 'auto') {
const pub = await resolveDynamic('auto', fetchPublicIP)
if (pub && ip === pub) return true
} else if (m.includes('/')) {
if (ipInCidr(ip, m)) return true
} else if (/[a-zA-Z]/.test(m)) {
const resolved = await resolveDynamic(m, async () => {
try { return (await dns.resolve4(m))[0] } catch { return null }
})
if (resolved && ip === resolved) return true
} else {
if (ip === m) return true
}
}
return false
}

309
backend/src/lib/newbook.js Normal file
View file

@ -0,0 +1,309 @@
const API_BASE = 'https://api.newbook.cloud/rest/'
async function getCredentials() {
const url = `${process.env.SETTINGS_URL}/settings/api/internal/integration/newbook`
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.SETTINGS_SECRET}` },
signal: AbortSignal.timeout(5000),
})
if (!res.ok) throw new Error(`Settings service returned ${res.status} fetching Newbook credentials`)
const s = await res.json()
return {
username: s.username || '',
password: s.password || '',
apiKey: s.api_key || '',
region: s.region || 'eu',
}
}
async function callApi(endpoint, data = {}) {
const creds = await getCredentials()
if (!creds.username || !creds.password || !creds.apiKey) {
throw new Error('Newbook API credentials not configured')
}
const body = { ...data, region: creds.region, api_key: creds.apiKey }
const auth = Buffer.from(`${creds.username}:${creds.password}`).toString('base64')
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), 30000)
try {
const res = await fetch(API_BASE + endpoint, {
method: 'POST',
signal: ctrl.signal,
headers: {
'Content-Type': 'application/json',
'Authorization': `Basic ${auth}`,
},
body: JSON.stringify(body),
})
clearTimeout(timer)
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`Newbook API ${res.status}: ${text.slice(0, 200)}`)
}
return await res.json()
} catch (err) {
clearTimeout(timer)
throw err
}
}
function identifyCardType(transaction) {
const type = (transaction.payment_type || transaction.type || '').toLowerCase()
const method = (transaction.method || '').toLowerCase()
const txMethod = (transaction.payment_transaction_method || '').toLowerCase()
const combined = type + ' ' + method
if (combined.includes('cash')) return 'cash'
if (combined.includes('eft') || combined.includes('bacs') ||
combined.includes('bank transfer') || combined.includes('direct debit')) return 'bacs'
if (combined.includes('amex') || combined.includes('american express')) return 'amex'
if (combined.includes('visa') || combined.includes('mastercard') ||
combined.includes('master card') || combined.includes(' mc ')) return 'visa_mc'
// Gateway/automated transactions default to visa_mc
if (txMethod === 'automated' || txMethod === 'gateway' || txMethod === 'cc_gateway') {
if (combined.includes('card') || combined.includes('credit') || combined.includes('debit')) return 'visa_mc'
return 'visa_mc'
}
return 'other'
}
function normaliseTransaction(transaction) {
// Newbook: payments negative, refunds positive (accounting view)
// We negate to get revenue view: payments positive, refunds negative
const amount = -parseFloat(transaction.item_amount || 0)
return {
payment_id: transaction.item_id || '',
booking_id: transaction.booking_id || '',
guest_name: transaction.account_for_name || '',
payment_date: transaction.item_date || '',
payment_type: transaction.payment_type || '',
transaction_method: transaction.payment_transaction_method || 'manual',
card_type: identifyCardType(transaction),
amount,
item_type: transaction.item_type || '',
}
}
const PAYMENT_TYPES = new Set(['payments_raised', 'refunds_raised', 'payments_voided', 'refunds_voided'])
export async function fetchPaymentsByDate(date, returnRaw = false) {
const response = await callApi('reports_transaction_flow', {
period_from: `${date} 00:00:00`,
period_to: `${date} 23:59:59`,
data_offset: 0,
data_limit: 1000,
})
if (!response?.data) throw new Error('No payment data returned')
const payments = response.data
.filter(t => PAYMENT_TYPES.has(t.item_type) && t.payment_type !== 'balance_transfer')
.map(normaliseTransaction)
return returnRaw ? { payments, raw_data: response } : payments
}
export async function fetchPaymentsByDateRange(startDate, endDate) {
const response = await callApi('reports_transaction_flow', {
period_from: `${startDate} 00:00:00`,
period_to: `${endDate} 23:59:59`,
data_offset: 0,
data_limit: 5000,
})
if (!response?.data) throw new Error('No payment data returned')
const byDate = {}
for (const t of response.data) {
if (!PAYMENT_TYPES.has(t.item_type) || t.payment_type === 'balance_transfer') continue
const date = (t.item_date || '').slice(0, 10)
if (!date) continue
if (!byDate[date]) byDate[date] = []
byDate[date].push(normaliseTransaction(t))
}
return byDate
}
export async function fetchDailyAuditSummary(date) {
const response = await callApi('reports_daily_audit_summary', {
period_from: `${date} 00:00:00`,
period_to: `${date} 23:59:59`,
})
return response?.data ?? []
}
export async function fetchEarnedRevenue(startDate, endDate, periodIncrement = 'day') {
const response = await callApi('reports_earned_revenue', {
period_from: `${startDate} 00:00:00`,
period_to: `${endDate} 23:59:59`,
period_increment: periodIncrement,
})
return response?.data ?? []
}
export async function fetchOccupancy(startDate, endDate) {
const response = await callApi('reports_occupancy', {
period_from: `${startDate} 00:00:00`,
period_to: `${endDate} 23:59:59`,
})
return response?.data ?? []
}
export async function fetchBookingsList(startDate, endDate) {
const response = await callApi('bookings_list', {
period_from: `${startDate} 00:00:00`,
period_to: `${endDate} 23:59:59`,
list_type: 'staying',
})
return response?.data ?? []
}
export async function fetchSitesList() {
const response = await callApi('sites_list', {})
return response?.data ?? []
}
export async function fetchGlAccountList() {
const response = await callApi('gl_account_list', {})
return response?.data ?? []
}
export async function fetchGlAccountsGrouped() {
const data = await fetchGlAccountList()
const groups = {}
for (const item of data) {
if (item.gl_group_id && item.gl_group_name && !groups[item.gl_group_id]) {
let displayName = item.gl_group_name
if (displayName.includes(' - ')) displayName = displayName.split(' - ').slice(1).join(' - ').trim()
groups[item.gl_group_id] = displayName
}
}
return groups
}
export async function fetchDebtorsCreditors(date) {
const response = await callApi('reports_balances_dated', { period_from: date })
if (!response?.data) return { creditors: 0, debtors: 0, overall: 0, accounts: [] }
let creditors = 0, debtors = 0
const accounts = []
for (const account of response.data) {
const balance = parseFloat(account.account_balance || 0)
if (balance < 0) creditors += Math.abs(balance)
else if (balance > 0) debtors += balance
accounts.push({ name: account.account_for_name || 'Unknown', balance })
}
return { creditors, debtors, overall: debtors - creditors, accounts }
}
export async function testConnection() {
try {
const data = await fetchSitesList()
return { success: true, message: `Connected. Found ${data.length} site(s).` }
} catch (err) {
return { success: false, message: err.message }
}
}
export function calculatePaymentTotals(payments) {
const totals = { cash: 0, manual_visa_mc: 0, manual_amex: 0, gateway_visa_mc: 0, gateway_amex: 0, bacs: 0 }
for (const p of payments) {
const amt = parseFloat(p.amount)
if (p.card_type === 'cash') {
totals.cash += amt
} else if (p.card_type === 'bacs') {
totals.bacs += amt
} else if (p.transaction_method === 'manual') {
if (p.card_type === 'amex') totals.manual_amex += amt
else if (p.card_type === 'visa_mc') totals.manual_visa_mc += amt
} else {
if (p.card_type === 'amex') totals.gateway_amex += amt
else if (p.card_type === 'visa_mc') totals.gateway_visa_mc += amt
}
}
return totals
}
export function parseTillTransactions(rawData) {
if (!rawData?.data) return []
const grouped = {}
for (const t of rawData.data) {
if (!PAYMENT_TYPES.has(t.item_type)) continue
if (t.payment_transaction_method !== 'manual') continue
if (t.payment_type === 'balance_transfer') continue
const desc = t.item_description || ''
const match = desc.match(/^Ticket:\s*(\d+)\s*-\s*(.+)$/i)
if (!match) continue
const paymentType = match[2].trim()
const amount = -parseFloat(t.item_amount || 0)
if (amount === 0) continue
if (!grouped[paymentType]) grouped[paymentType] = { payment_type: paymentType, quantity: 0, total_value: 0 }
grouped[paymentType].quantity += amount > 0 ? 1 : -1
grouped[paymentType].total_value += amount
}
return Object.values(grouped)
}
export function parseTransactionBreakdown(rawData) {
const reception_manual = {}, reception_gateway = {}, restaurant_bar = {}
if (!rawData?.data) return { reception_manual, reception_gateway, restaurant_bar }
function categorise(type) {
const t = type.toLowerCase()
if (t.includes('cash')) return 'Cash'
if (t.includes('bacs') || t.includes('eft') || t.includes('bank transfer') || t.includes('direct debit')) return 'BACS'
return 'Card'
}
for (const t of rawData.data) {
if (!PAYMENT_TYPES.has(t.item_type)) continue
if (t.payment_type === 'balance_transfer') continue
const method = t.payment_transaction_method || ''
const desc = t.item_description || ''
const payType = t.payment_type || ''
const isVoided = t.item_type === 'payments_voided' || t.item_type === 'refunds_voided'
const displayType = isVoided ? payType + ' -void' : payType
const category = categorise(payType)
const item = {
time: t.item_date || '',
payment_type: displayType,
details: '',
amount: parseFloat(t.item_amount || 0),
is_voided: isVoided,
}
const ticketMatch = desc.match(/^Ticket:\s*(\d+)\s*-\s*(.+)$/i)
if (ticketMatch) {
item.details = `Ticket: ${ticketMatch[1]}`
if (!restaurant_bar[category]) restaurant_bar[category] = []
restaurant_bar[category].push(item)
} else {
const bookingId = t.booking_id || ''
const name = t.account_for_name || ''
item.details = bookingId && name ? `#${bookingId} - ${name}` : bookingId ? `#${bookingId}` : name || desc
if (method === 'manual') {
if (!reception_manual[category]) reception_manual[category] = []
reception_manual[category].push(item)
} else if (['cc_gateway', 'gateway', 'automated'].includes(method)) {
if (!reception_gateway[category]) reception_gateway[category] = []
reception_gateway[category].push(item)
}
}
}
return { reception_manual, reception_gateway, restaurant_bar }
}

View file

@ -0,0 +1,154 @@
import { pool } from '../db.js'
import { requireAuth } from '../auth.js'
export async function cashupRoutes(app) {
app.addHook('preHandler', requireAuth)
// GET /api/cashup?date=YYYY-MM-DD
app.get('/api/cashup', async (req, reply) => {
const { date } = req.query
if (!date) return reply.status(400).send({ error: 'date required' })
const { rows } = await pool.query(
'SELECT * FROM cash_ups WHERE session_date = $1', [date]
)
if (!rows.length) return reply.status(404).send({ error: 'Not found' })
const cashUp = rows[0]
const [denoms, machines, recon, attachments] = await Promise.all([
pool.query('SELECT * FROM denominations WHERE cash_up_id = $1 ORDER BY denomination_value DESC', [cashUp.id]),
pool.query('SELECT * FROM card_machines WHERE cash_up_id = $1', [cashUp.id]),
pool.query('SELECT * FROM reconciliation WHERE cash_up_id = $1', [cashUp.id]),
pool.query('SELECT * FROM cash_count_attachments WHERE cash_up_id = $1 ORDER BY uploaded_at DESC', [cashUp.id]),
])
return {
cash_up: cashUp,
denominations: denoms.rows,
card_machines: machines.rows,
reconciliation: recon.rows,
attachments: attachments.rows,
}
})
// POST /api/cashup/save
app.post('/api/cashup/save', async (req, reply) => {
const { session_date, status, notes, denominations = [], card_machines = [] } = req.body
if (!session_date) return reply.status(400).send({ error: 'session_date required' })
if (!['draft', 'final'].includes(status)) return reply.status(400).send({ error: 'invalid status' })
let totalFloat = 0, totalCash = 0
for (const d of denominations) {
if (d.count_type === 'float') totalFloat += parseFloat(d.total_amount || 0)
else totalCash += parseFloat(d.total_amount || 0)
}
const existing = await pool.query('SELECT id, status FROM cash_ups WHERE session_date = $1', [session_date])
if (existing.rows.length && existing.rows[0].status === 'final') {
return reply.status(409).send({ error: 'Cannot edit a finalised cash up' })
}
let cashUpId
if (existing.rows.length) {
cashUpId = existing.rows[0].id
const updateData = [
status, totalFloat, totalCash, notes || null,
status === 'final' ? new Date() : null,
status === 'final' ? req.user.email : null,
new Date(), cashUpId,
]
await pool.query(
`UPDATE cash_ups SET status=$1, total_float_counted=$2, total_cash_counted=$3, notes=$4,
submitted_at=$5, submitted_by=$6, updated_at=$7 WHERE id=$8`,
updateData
)
} else {
const ins = await pool.query(
`INSERT INTO cash_ups (session_date, created_by, status, total_float_counted, total_cash_counted, notes, submitted_at, submitted_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`,
[
session_date, req.user.email, status, totalFloat, totalCash, notes || null,
status === 'final' ? new Date() : null,
status === 'final' ? req.user.email : null,
]
)
cashUpId = ins.rows[0].id
}
// Replace denominations
await pool.query('DELETE FROM denominations WHERE cash_up_id = $1', [cashUpId])
for (const d of denominations) {
await pool.query(
`INSERT INTO denominations (cash_up_id, count_type, denomination_type, denomination_value, quantity, value_entered, total_amount)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[cashUpId, d.count_type || 'takings', d.type, parseFloat(d.value), d.quantity ?? null, d.value_entered ?? null, parseFloat(d.total_amount)]
)
}
// Replace card machines
await pool.query('DELETE FROM card_machines WHERE cash_up_id = $1', [cashUpId])
for (const m of card_machines) {
await pool.query(
`INSERT INTO card_machines (cash_up_id, machine_name, total_amount, amex_amount, visa_mc_amount)
VALUES ($1,$2,$3,$4,$5)`,
[cashUpId, m.name, parseFloat(m.total || 0), parseFloat(m.amex || 0), parseFloat(m.visa_mc || 0)]
)
}
return { message: status === 'final' ? 'Cash up submitted.' : 'Saved as draft.', cash_up_id: cashUpId }
})
// DELETE /api/cashup/:id
app.delete('/api/cashup/:id', async (req, reply) => {
const { rows } = await pool.query('SELECT status FROM cash_ups WHERE id = $1', [req.params.id])
if (!rows.length) return reply.status(404).send({ error: 'Not found' })
if (rows[0].status === 'final') return reply.status(409).send({ error: 'Cannot delete a finalised cash up' })
await pool.query('DELETE FROM cash_ups WHERE id = $1', [req.params.id])
return { message: 'Deleted' }
})
// POST /api/cashup/bulk-finalize
app.post('/api/cashup/bulk-finalize', async (req, reply) => {
const { ids } = req.body
if (!Array.isArray(ids) || !ids.length) return reply.status(400).send({ error: 'ids required' })
let success = 0, failed = []
for (const id of ids) {
const { rows } = await pool.query('SELECT status FROM cash_ups WHERE id = $1', [id])
if (!rows.length || rows[0].status !== 'draft') { failed.push(id); continue }
await pool.query(
'UPDATE cash_ups SET status=$1, submitted_at=$2 WHERE id=$3',
['final', new Date(), id]
)
success++
}
return { success, failed_count: failed.length }
})
// GET /api/cashup/history
app.get('/api/cashup/history', async (req, reply) => {
const { status, from, to, offset = 0, limit = 20 } = req.query
const conditions = []
const params = []
if (status && status !== 'all') { params.push(status); conditions.push(`status = $${params.length}`) }
if (from) { params.push(from); conditions.push(`session_date >= $${params.length}`) }
if (to) { params.push(to); conditions.push(`session_date <= $${params.length}`) }
const where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : ''
params.push(parseInt(limit), parseInt(offset))
const { rows } = await pool.query(
`SELECT * FROM cash_ups ${where} ORDER BY session_date DESC LIMIT $${params.length - 1} OFFSET $${params.length}`,
params
)
const countParams = params.slice(0, params.length - 2)
const { rows: countRows } = await pool.query(`SELECT COUNT(*) FROM cash_ups ${where}`, countParams)
return { rows, total: parseInt(countRows[0].count) }
})
}

View file

@ -0,0 +1,95 @@
import { pool } from '../db.js'
import { requireAuth } from '../auth.js'
export async function floatRoutes(app) {
app.addHook('preHandler', requireAuth)
// POST /api/floats/save
// body: { count_type, count_date, denominations, receipts?, total_counted, total_receipts?, target_amount?, variance?, notes }
app.post('/api/floats/save', async (req, reply) => {
const {
count_type, count_date, denominations = [], receipts = [],
total_counted, total_receipts = 0, target_amount = 0, variance = 0, notes = '',
} = req.body
const validTypes = ['petty_cash', 'change_tin', 'safe_cash']
if (!validTypes.includes(count_type)) return reply.status(400).send({ error: 'invalid count_type' })
if (!count_date) return reply.status(400).send({ error: 'count_date required' })
const ins = await pool.query(
`INSERT INTO float_counts (count_type, count_date, created_by, total_counted, total_receipts, target_amount, variance, notes)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`,
[count_type, count_date, req.user.email, parseFloat(total_counted), parseFloat(total_receipts),
parseFloat(target_amount), parseFloat(variance), notes]
)
const countId = ins.rows[0].id
for (const d of denominations) {
await pool.query(
`INSERT INTO float_denominations (float_count_id, denomination_value, quantity, total_amount)
VALUES ($1,$2,$3,$4)`,
[countId, parseFloat(d.denomination), parseInt(d.quantity), parseFloat(d.total)]
)
}
if (count_type === 'petty_cash') {
for (const r of receipts) {
await pool.query(
`INSERT INTO float_receipts (float_count_id, receipt_value, receipt_description)
VALUES ($1,$2,$3)`,
[countId, parseFloat(r.amount), r.description || '']
)
}
}
return { message: 'Count saved.', count_id: countId }
})
// GET /api/floats?type=petty_cash&offset=0&limit=10
app.get('/api/floats', async (req, reply) => {
const { type, offset = 0, limit = 10 } = req.query
const validTypes = ['petty_cash', 'change_tin', 'safe_cash']
if (!validTypes.includes(type)) return reply.status(400).send({ error: 'invalid type' })
const { rows } = await pool.query(
`SELECT * FROM float_counts WHERE count_type = $1 ORDER BY count_date DESC LIMIT $2 OFFSET $3`,
[type, parseInt(limit), parseInt(offset)]
)
const { rows: countRows } = await pool.query(
`SELECT COUNT(*) FROM float_counts WHERE count_type = $1`, [type]
)
const total = parseInt(countRows[0].count)
return { rows, total, has_more: parseInt(offset) + rows.length < total }
})
// GET /api/floats/:id
app.get('/api/floats/:id', async (req, reply) => {
const { rows } = await pool.query('SELECT * FROM float_counts WHERE id = $1', [req.params.id])
if (!rows.length) return reply.status(404).send({ error: 'Not found' })
const count = rows[0]
const [denoms, receipts] = await Promise.all([
pool.query('SELECT * FROM float_denominations WHERE float_count_id = $1 ORDER BY denomination_value DESC', [count.id]),
count.count_type === 'petty_cash'
? pool.query('SELECT * FROM float_receipts WHERE float_count_id = $1', [count.id])
: Promise.resolve({ rows: [] }),
])
// For change tin, load target amounts from settings
let changeTinBreakdown = {}
if (count.count_type === 'change_tin') {
const { rows: s } = await pool.query(`SELECT value FROM settings WHERE key = 'change_tin_breakdown'`)
try { changeTinBreakdown = JSON.parse(s[0]?.value || '{}') } catch {}
}
const denomsWithTarget = denoms.rows.map(d => ({
...d,
target: count.count_type === 'change_tin'
? parseFloat(changeTinBreakdown[parseFloat(d.denomination_value).toFixed(2)] || 0)
: 0,
}))
return { ...count, denominations: denomsWithTarget, receipts: receipts.rows }
})
}

View file

@ -0,0 +1,49 @@
import { pool } from '../db.js'
import { requireAuth } from '../auth.js'
import {
fetchPaymentsByDate,
calculatePaymentTotals,
parseTillTransactions,
parseTransactionBreakdown,
} from '../lib/newbook.js'
export async function newbookRoutes(app) {
app.addHook('preHandler', requireAuth)
// POST /api/newbook/payments { date: 'YYYY-MM-DD' }
app.post('/api/newbook/payments', async (req, reply) => {
const { date } = req.body
if (!date) return reply.status(400).send({ error: 'date required' })
let result
try {
result = await fetchPaymentsByDate(date, true)
} catch (err) {
return reply.status(502).send({ error: err.message })
}
const { payments, raw_data } = result
// Cache payments in DB (delete existing for date, insert fresh)
await pool.query(
`DELETE FROM payment_records WHERE DATE(payment_date) = $1`, [date]
)
for (const p of payments) {
await pool.query(
`INSERT INTO payment_records
(newbook_payment_id, booking_id, guest_name, payment_date, payment_type,
transaction_method, card_type, amount, synced_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW())`,
[p.payment_id, p.booking_id, p.guest_name, p.payment_date,
p.payment_type, p.transaction_method, p.card_type, p.amount]
)
}
return {
count: payments.length,
totals: calculatePaymentTotals(payments),
till_payments: parseTillTransactions(raw_data),
transaction_breakdown: parseTransactionBreakdown(raw_data),
}
})
}

View file

@ -0,0 +1,210 @@
import { pool } from '../db.js'
import { requireAuth } from '../auth.js'
import {
fetchPaymentsByDateRange,
fetchDailyAuditSummary,
fetchGlAccountList,
fetchEarnedRevenue,
fetchOccupancy,
fetchBookingsList,
fetchSitesList,
fetchDebtorsCreditors,
} from '../lib/newbook.js'
function addDays(dateStr, n) {
const d = new Date(dateStr + 'T00:00:00Z')
d.setUTCDate(d.getUTCDate() + n)
return d.toISOString().slice(0, 10)
}
function buildDateRange(startDate, numDays) {
const dates = []
for (let i = 0; i < numDays; i++) dates.push(addDays(startDate, i))
return dates
}
export async function reportRoutes(app) {
app.addHook('preHandler', requireAuth)
// POST /api/reports/multiday { start_date, num_days }
app.post('/api/reports/multiday', async (req, reply) => {
const { start_date, num_days } = req.body
const days = parseInt(num_days)
if (!start_date || isNaN(days) || days < 1 || days > 365) {
return reply.status(400).send({ error: 'start_date and num_days (1365) required' })
}
const dates = buildDateRange(start_date, days)
const endDate = dates[dates.length - 1]
// Fetch all Newbook data in parallel
const [paymentsByDate, glAccountList, earnedRevenueRaw, occupancyData, bookingsData, sitesData] =
await Promise.allSettled([
fetchPaymentsByDateRange(start_date, endDate),
fetchGlAccountList(),
fetchEarnedRevenue(start_date, endDate, 'day'),
fetchOccupancy(start_date, endDate),
fetchBookingsList(start_date, endDate),
fetchSitesList(),
]).then(results => results.map(r => r.status === 'fulfilled' ? r.value : []))
// Build GL account → group map
const accountToGroup = {}
for (const a of (glAccountList || [])) {
if (a.gl_account_code && a.gl_group_id) accountToGroup[a.gl_account_code] = a.gl_group_id
}
// Aggregate earned revenue by GL group + period
const aggregatedRevenue = {}
for (const item of (earnedRevenueRaw || [])) {
const period = item.period || ''
const groupId = accountToGroup[item.gl_account_code] ?? item.gl_group_id ?? ''
if (!period || !groupId) continue
const key = `${period}_${groupId}`
if (!aggregatedRevenue[key]) {
aggregatedRevenue[key] = { period, gl_group_id: groupId, earned_revenue_ex: 0, earned_revenue_tax: 0, earned_revenue: 0 }
}
aggregatedRevenue[key].earned_revenue_ex += parseFloat(item.earned_revenue_ex || 0)
aggregatedRevenue[key].earned_revenue_tax += parseFloat(item.earned_revenue_tax || 0)
aggregatedRevenue[key].earned_revenue += parseFloat(item.earned_revenue || 0)
}
const earnedRevenue = Object.values(aggregatedRevenue)
// Load sales breakdown column settings
const { rows: settingsRows } = await pool.query(
`SELECT value FROM settings WHERE key = 'sales_breakdown_columns'`
)
let columnSettings = []
try { columnSettings = JSON.parse(settingsRows[0]?.value || '[]') } catch {}
if (!columnSettings.length) {
columnSettings = [
{ gl_code: 'ACCOMMODATION', display_name: 'Accommodation', enabled: true, sort_order: 1 },
{ gl_code: 'FOOD', display_name: 'Food', enabled: true, sort_order: 2 },
{ gl_code: 'BEVERAGE', display_name: 'Beverage', enabled: true, sort_order: 3 },
{ gl_code: 'OTHER', display_name: 'Other', enabled: true, sort_order: 4 },
]
}
columnSettings.sort((a, b) => a.sort_order - b.sort_order)
const enabledColumns = columnSettings.filter(c => c.enabled)
const disabledColumns = columnSettings.filter(c => !c.enabled)
// Process each date
const reportData = await Promise.all(dates.map(async (date) => {
const freshPayments = (paymentsByDate || {})[date] || []
// Payment totals
const pt = { cash: 0, gateway_visa_mc: 0, gateway_amex: 0, manual_visa_mc: 0, manual_amex: 0, bacs: 0 }
for (const p of freshPayments) {
const amt = parseFloat(p.amount)
if (p.card_type === 'other') continue
if (p.card_type === 'cash') { pt.cash += amt; continue }
if (p.card_type === 'bacs') { pt.bacs += amt; continue }
const gw = ['automated','gateway','cc_gateway'].includes(p.transaction_method)
if (p.card_type === 'amex') { gw ? (pt.gateway_amex += amt) : (pt.manual_amex += amt); continue }
if (p.card_type === 'visa_mc') { gw ? (pt.gateway_visa_mc += amt) : (pt.manual_visa_mc += amt) }
}
// Cash up record
const { rows: cashUpRows } = await pool.query(
'SELECT * FROM cash_ups WHERE session_date = $1', [date]
)
const cashUp = cashUpRows[0] || null
let bankedCash = 0, bankedPdqVisMc = 0, bankedPdqAmex = 0
if (cashUp) {
bankedCash = parseFloat(cashUp.total_cash_counted)
const { rows: machines } = await pool.query(
'SELECT * FROM card_machines WHERE cash_up_id = $1', [cashUp.id]
)
for (const m of machines) {
bankedPdqVisMc += parseFloat(m.visa_mc_amount)
bankedPdqAmex += parseFloat(m.amex_amount)
}
}
const reconciliation = [
{ category: 'cash', banked_amount: bankedCash, reported_amount: pt.cash },
{ category: 'gateway_visa_mc', banked_amount: pt.gateway_visa_mc, reported_amount: pt.gateway_visa_mc },
{ category: 'gateway_amex', banked_amount: pt.gateway_amex, reported_amount: pt.gateway_amex },
{ category: 'pdq_visa_mc', banked_amount: bankedPdqVisMc, reported_amount: pt.manual_visa_mc },
{ category: 'pdq_amex', banked_amount: bankedPdqAmex, reported_amount: pt.manual_amex },
{ category: 'bacs', banked_amount: pt.bacs, reported_amount: pt.bacs },
]
// Sales breakdown from earned revenue
let displayedGross = 0
const salesBreakdown = enabledColumns.map(col => {
const item = earnedRevenue.find(r =>
r.period === date && r.gl_group_id.toUpperCase() === col.gl_code.toUpperCase()
)
const net = parseFloat(item?.earned_revenue_ex || 0)
const vat = parseFloat(item?.earned_revenue_tax || 0)
const gross = parseFloat(item?.earned_revenue || 0)
displayedGross += gross
return { gl_code: col.gl_code, category: col.display_name, net_amount: net, vat_amount: vat, gross_amount: gross }
})
// Daily stats from payments
const grossSales = freshPayments.reduce((s, p) => s + parseFloat(p.amount), 0)
const dailyStats = grossSales > 0
? { business_date: date, gross_sales: grossSales, transaction_count: freshPayments.length }
: null
return { date, cash_up: cashUp, reconciliation, daily_stats: dailyStats, sales_breakdown: salesBreakdown }
}))
return {
report_data: reportData,
gl_accounts: glAccountList || [],
earned_revenue: earnedRevenue,
occupancy_data: occupancyData || [],
bookings_data: bookingsData || [],
sites_data: sitesData || [],
sales_columns: enabledColumns,
disabled_columns: disabledColumns,
// Debtors/creditors loaded lazily by the client via /api/reports/debtors-creditors
balances_loading: true,
}
})
// POST /api/reports/debtors-creditors { start_date, num_days }
app.post('/api/reports/debtors-creditors', async (req, reply) => {
const { start_date, num_days } = req.body
const days = parseInt(num_days)
if (!start_date || isNaN(days) || days < 1 || days > 365) {
return reply.status(400).send({ error: 'start_date and num_days required' })
}
const dates = buildDateRange(start_date, days)
const dayBefore = addDays(start_date, -1)
const [periodOpen, ...dateBalances] = await Promise.all([
fetchDebtorsCreditors(dayBefore).catch(() => ({ creditors: 0, debtors: 0, overall: 0, accounts: [] })),
...dates.map(d => fetchDebtorsCreditors(d).catch(() => ({ creditors: 0, debtors: 0, overall: 0, accounts: [] }))),
])
const balancesByDate = Object.fromEntries(dates.map((d, i) => [d, dateBalances[i]]))
return { period_open_balance: periodOpen, balances_by_date: balancesByDate }
})
// GET /api/reports/cash-summary?from=YYYY-MM-DD&to=YYYY-MM-DD
app.get('/api/reports/cash-summary', async (req, reply) => {
const { from, to } = req.query
if (!from || !to) return reply.status(400).send({ error: 'from and to required' })
const { rows } = await pool.query(
`SELECT d.denomination_value, SUM(d.quantity) AS total_quantity, SUM(d.total_amount) AS total_value
FROM denominations d
JOIN cash_ups c ON d.cash_up_id = c.id
WHERE c.session_date >= $1 AND c.session_date <= $2 AND d.count_type = 'takings'
GROUP BY d.denomination_value
ORDER BY d.denomination_value DESC`,
[from, to]
)
return {
denominations: rows,
period: { from, to },
}
})
}

View file

@ -0,0 +1,86 @@
import { pool } from '../db.js'
import { requireAuth } from '../auth.js'
import { testConnection, fetchGlAccountsGrouped } from '../lib/newbook.js'
const ALL_KEYS = [
'default_report_days', 'petty_cash_float',
'sales_breakdown_columns', 'change_tin_breakdown',
]
export async function settingsRoutes(app) {
app.addHook('preHandler', requireAuth)
// GET /api/settings
app.get('/api/settings', async () => {
const { rows } = await pool.query(`SELECT key, value FROM settings WHERE key = ANY($1)`, [ALL_KEYS])
return Object.fromEntries(rows.map(r => [r.key, r.value]))
})
// PUT /api/settings (admin only)
app.put('/api/settings', async (req, reply) => {
if (!req.user.is_admin) return reply.status(403).send({ error: 'Admin only' })
for (const [key, value] of Object.entries(req.body)) {
if (!ALL_KEYS.includes(key)) continue
await pool.query(
`INSERT INTO settings (key, value) VALUES ($1,$2) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
[key, typeof value === 'object' ? JSON.stringify(value) : String(value)]
)
}
return { message: 'Settings saved.' }
})
// POST /api/settings/test-connection (admin only) — proxies to settings service
app.post('/api/settings/test-connection', async (req, reply) => {
if (!req.user.is_admin) return reply.status(403).send({ error: 'Admin only' })
const result = await testConnection()
return result
})
// POST /api/settings/refresh-gl-accounts (admin only)
app.post('/api/settings/refresh-gl-accounts', async (req, reply) => {
if (!req.user.is_admin) return reply.status(403).send({ error: 'Admin only' })
let groups
try {
groups = await fetchGlAccountsGrouped()
} catch (err) {
return reply.status(502).send({ error: err.message })
}
const { rows: s } = await pool.query(`SELECT value FROM settings WHERE key = 'sales_breakdown_columns'`)
let existing = []
try { existing = JSON.parse(s[0]?.value || '[]') } catch {}
const existingMap = Object.fromEntries(existing.map(c => [c.gl_code, c]))
const updated = []
let newCount = 0, removedCount = 0, updatedCount = 0
for (const col of existing) {
if (groups[col.gl_code] !== undefined) {
updated.push({ ...col, display_name: groups[col.gl_code] })
updatedCount++
delete groups[col.gl_code]
} else {
removedCount++
}
}
let maxOrder = updated.length ? Math.max(...updated.map(c => c.sort_order)) : 0
for (const [glCode, displayName] of Object.entries(groups)) {
updated.push({ gl_code: glCode, display_name: displayName, enabled: false, sort_order: ++maxOrder })
newCount++
}
await pool.query(
`INSERT INTO settings (key, value) VALUES ('sales_breakdown_columns', $1)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
[JSON.stringify(updated)]
)
const parts = []
if (newCount) parts.push(`Added ${newCount}`)
if (removedCount) parts.push(`Removed ${removedCount}`)
if (updatedCount) parts.push(`Updated ${updatedCount}`)
return { message: parts.length ? parts.join(', ') + ' GL account(s).' : 'No changes.', columns: updated }
})
}

36
docker-compose.yml Normal file
View file

@ -0,0 +1,36 @@
services:
backend:
build: ./backend
security_opt:
- apparmor=unconfined
environment:
- DATABASE_URL=${DATABASE_URL}
- CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
- APP_SLUG=cashup
- OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-disabled}
volumes:
- uploads_data:/app/uploads
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3001/health || exit 1"]
interval: 10s
retries: 5
start_period: 20s
restart: unless-stopped
frontend:
build: ./frontend
security_opt:
- apparmor=unconfined
ports:
- "${FRONTEND_PORT:-3083}:80"
depends_on:
backend:
condition: service_healthy
restart: unless-stopped
volumes:
uploads_data:
networks:
default:
driver: bridge

13
frontend/Dockerfile Normal file
View file

@ -0,0 +1,13 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
ARG VITE_HOTEL_NAME="Number Four at Stow"
ENV VITE_HOTEL_NAME=$VITE_HOTEL_NAME
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html/cashup
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#6b2d8b" />
<title>Cash Up</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

38
frontend/nginx.conf Normal file
View file

@ -0,0 +1,38 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
location = /cashup/manifest.json {
add_header Cache-Control "no-cache";
try_files $uri =404;
}
location /cashup/api/ {
proxy_pass http://backend:3001/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
add_header Cache-Control "no-store";
client_max_body_size 15M;
}
location /cashup/health {
proxy_pass http://backend:3001/health;
}
location ~* /cashup/.*\.(js|css|png|ico|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
location /cashup/ {
add_header Cache-Control "no-cache" always;
try_files $uri $uri/ /cashup/index.html;
}
location = / {
return 301 /cashup/;
}
}

24
frontend/package.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "hnf-cashup-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^1.23.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.5"
}
}

37
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,37 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthGate } from './components/AuthGate'
import { Layout } from './components/Layout'
import { DailyCashUp } from './pages/DailyCashUp'
import { History } from './pages/History'
import { MultiDayReport } from './pages/MultiDayReport'
import { FloatManagement } from './pages/FloatManagement'
import { CashSummary } from './pages/CashSummary'
import { SettingsPage } from './pages/Settings'
import type { User } from './types'
function AppRoutes({ user }: { user: User }) {
return (
<Layout user={user}>
<Routes>
<Route path="/" element={<Navigate to="/daily" replace />} />
<Route path="/daily" element={<DailyCashUp user={user} />} />
<Route path="/history" element={<History />} />
<Route path="/report" element={<MultiDayReport />} />
<Route path="/floats/*" element={<FloatManagement />} />
<Route path="/summary" element={<CashSummary />} />
<Route path="/settings" element={<SettingsPage user={user} />} />
<Route path="*" element={<Navigate to="/daily" replace />} />
</Routes>
</Layout>
)
}
export default function App() {
return (
<BrowserRouter basename="/cashup">
<AuthGate>
{user => <AppRoutes user={user} />}
</AuthGate>
</BrowserRouter>
)
}

37
frontend/src/api.ts Normal file
View file

@ -0,0 +1,37 @@
const BASE = '/cashup/api'
async function req<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(BASE + path, {
method,
credentials: 'include',
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error((err as { error?: string }).error || res.statusText)
}
return res.json()
}
export const api = {
get: <T>(path: string) => req<T>('GET', path),
post: <T>(path: string, body: unknown) => req<T>('POST', path, body),
put: <T>(path: string, body: unknown) => req<T>('PUT', path, body),
delete: <T>(path: string) => req<T>('DELETE', path),
}
export async function uploadAttachment(cashUpId: number, file: File) {
const fd = new FormData()
fd.append('file', file)
const res = await fetch(`${BASE}/attachments/upload/${cashUpId}`, {
method: 'POST',
credentials: 'include',
body: fd,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error((err as { error?: string }).error || res.statusText)
}
return res.json()
}

View file

@ -0,0 +1,99 @@
import { useEffect, useState } from 'react'
import type { User } from '../types'
interface Props {
children: (user: User) => React.ReactNode
}
const inputStyle: React.CSSProperties = {
background: 'var(--navy-dark)', border: '1px solid var(--surface-2)',
borderRadius: '6px', color: 'var(--text)', padding: '0.625rem 0.75rem',
fontSize: '1rem', width: '100%', outline: 'none',
}
const btnStyle: React.CSSProperties = {
background: 'var(--gold)', color: 'var(--navy-dark)', border: 'none',
borderRadius: '6px', padding: '0.625rem', fontSize: '1rem',
fontWeight: 600, marginTop: '0.25rem',
}
export function AuthGate({ children }: Props) {
const [state, setState] = useState<'checking' | 'authed' | 'login'>('checking')
const [user, setUser] = useState<User | null>(null)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
useEffect(() => {
fetch('/cashup/api/auth/verify?app=cashup', { credentials: 'include' })
.then(async r => {
if (r.ok) { setUser(await r.json()); setState('authed') }
else setState('login')
})
.catch(() => setState('login'))
}, [])
async function login(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError('')
try {
const res = await fetch('/cashup/api/auth/login', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (!res.ok) { setError('Invalid email or password'); return }
const verify = await fetch('/cashup/api/auth/verify?app=cashup', { credentials: 'include' })
if (verify.ok) { setUser(await verify.json()); setState('authed') }
else setError("You don't have access to this app.")
} catch {
setError('Connection error — please try again')
} finally {
setLoading(false)
}
}
if (state === 'checking') {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100dvh' }}>
<div style={{ color: 'var(--text-muted)' }}>Loading</div>
</div>
)
}
if (state === 'login') {
return (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
justifyContent: 'center', height: '100dvh', padding: '1.5rem',
background: 'var(--navy-dark)',
}}>
<div style={{
background: 'var(--navy)', borderRadius: 'var(--radius)',
padding: '2rem', width: '100%', maxWidth: '360px',
border: '1px solid var(--surface-2)',
}}>
<h1 style={{ fontSize: '1.4rem', marginBottom: '0.25rem', color: 'var(--gold)' }}>
Cash Up
</h1>
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginBottom: '1.5rem' }}>
{import.meta.env.VITE_HOTEL_NAME}
</p>
<form onSubmit={login} style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<input type="email" value={email} onChange={e => setEmail(e.target.value)}
placeholder="Email" required autoComplete="email" style={inputStyle} />
<input type="password" value={password} onChange={e => setPassword(e.target.value)}
placeholder="Password" required autoComplete="current-password" style={inputStyle} />
{error && <p style={{ color: 'var(--danger)', fontSize: '0.875rem' }}>{error}</p>}
<button type="submit" disabled={loading} style={btnStyle}>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
</div>
)
}
return <>{children(user!)}</>
}

View file

@ -0,0 +1,154 @@
import { NavLink, useNavigate } from 'react-router-dom'
import {
Banknote, ClipboardList, BarChart2, Wallet, FileText, Settings, LogOut, ChevronRight,
} from 'lucide-react'
import type { User } from '../types'
interface Props {
user: User
children: React.ReactNode
}
const navItems = [
{ to: '/daily', label: 'Daily Cash Up', icon: Banknote },
{ to: '/history', label: 'History', icon: ClipboardList },
{ to: '/report', label: 'Weekly Report', icon: BarChart2 },
{ to: '/floats', label: 'Float Management', icon: Wallet },
{ to: '/summary', label: 'Cash Summary', icon: FileText },
{ to: '/settings',label: 'Settings', icon: Settings },
]
export function Layout({ user, children }: Props) {
const navigate = useNavigate()
async function logout() {
await fetch('/cashup/api/auth/logout', { method: 'POST', credentials: 'include' })
navigate('/login', { replace: true })
window.location.reload()
}
return (
<div style={{ display: 'flex', height: '100dvh', overflow: 'hidden' }}>
{/* Sidebar */}
<nav style={{
width: '220px', flexShrink: 0, background: 'var(--navy)',
display: 'flex', flexDirection: 'column', padding: '1rem 0',
borderRight: '1px solid var(--surface-2)',
}}>
<div style={{ padding: '0 1rem 1rem', borderBottom: '1px solid var(--surface-2)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Banknote size={20} strokeWidth={1.75} color="var(--gold)" />
<span style={{ color: 'var(--gold)', fontWeight: 700, fontSize: '1rem' }}>Cash Up</span>
</div>
<p style={{ color: 'var(--text-muted)', fontSize: '0.75rem', marginTop: '0.25rem' }}>{user.name}</p>
</div>
<div style={{ flex: 1, padding: '0.5rem 0', overflowY: 'auto' }}>
{navItems.map(({ to, label, icon: Icon }) => (
<NavLink key={to} to={to} style={({ isActive }) => ({
display: 'flex', alignItems: 'center', gap: '0.625rem',
padding: '0.625rem 1rem', textDecoration: 'none',
color: isActive ? 'var(--gold)' : 'var(--text)',
background: isActive ? 'var(--surface)' : 'transparent',
borderLeft: isActive ? '2px solid var(--gold)' : '2px solid transparent',
fontSize: '0.875rem', transition: 'background 0.15s',
})}>
<Icon size={15} strokeWidth={1.75} />
{label}
</NavLink>
))}
</div>
<div style={{ padding: '0.75rem 1rem', borderTop: '1px solid var(--surface-2)' }}>
<button onClick={logout} style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
background: 'none', border: 'none', color: 'var(--text-muted)',
fontSize: '0.875rem', padding: '0.375rem 0', width: '100%',
}}>
<LogOut size={14} strokeWidth={1.75} />
Sign out
</button>
</div>
</nav>
{/* Content */}
<main style={{ flex: 1, overflow: 'auto', background: 'var(--body-bg)' }}>
{children}
</main>
</div>
)
}
export function PageHeader({ title, subtitle }: { title: string; subtitle?: string }) {
return (
<div style={{ marginBottom: '1.5rem' }}>
<h1 style={{ fontSize: '1.375rem', fontWeight: 700, color: 'var(--text-dark)' }}>{title}</h1>
{subtitle && <p style={{ color: 'var(--text-mid)', fontSize: '0.875rem', marginTop: '0.25rem' }}>{subtitle}</p>}
</div>
)
}
export function Card({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) {
return (
<div style={{
background: 'var(--card-bg)', border: '1px solid var(--card-border)',
borderRadius: 'var(--radius)', padding: '1.25rem',
boxShadow: 'var(--shadow-sm)', ...style,
}}>
{children}
</div>
)
}
export function Btn({
children, onClick, variant = 'primary', disabled, small, type = 'button', style,
}: {
children: React.ReactNode
onClick?: () => void
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
disabled?: boolean
small?: boolean
type?: 'button' | 'submit'
style?: React.CSSProperties
}) {
const styles: Record<string, React.CSSProperties> = {
primary: { background: 'var(--gold)', color: 'var(--navy-dark)', border: 'none' },
secondary: { background: 'var(--card-bg)', color: 'var(--text-dark)', border: '1px solid var(--card-border)' },
danger: { background: 'var(--danger)', color: '#fff', border: 'none' },
ghost: { background: 'transparent', color: 'var(--text-mid)', border: '1px solid var(--card-border)' },
}
return (
<button
type={type}
onClick={onClick}
disabled={disabled}
style={{
...styles[variant],
borderRadius: '6px',
padding: small ? '0.35rem 0.75rem' : '0.55rem 1rem',
fontSize: small ? '0.8rem' : '0.875rem',
fontWeight: 600,
opacity: disabled ? 0.5 : 1,
cursor: disabled ? 'not-allowed' : 'pointer',
...style,
}}
>
{children}
</button>
)
}
export function StatusBadge({ status }: { status: 'draft' | 'final' }) {
const styles = {
draft: { background: '#fef9c3', color: '#ca8a04' },
final: { background: '#dcfce7', color: '#16a34a' },
}
return (
<span style={{
...styles[status], fontSize: '0.75rem', fontWeight: 600,
padding: '0.2rem 0.5rem', borderRadius: '4px', textTransform: 'uppercase',
}}>
{status}
</span>
)
}

36
frontend/src/index.css Normal file
View file

@ -0,0 +1,36 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--navy: #1a1a2e;
--navy-dark: #0f0f20;
--gold: #c9a84c;
--gold-light: #e8c96d;
--surface: rgba(255,255,255,0.07);
--surface-2: rgba(255,255,255,0.08);
--text: rgba(255,255,255,0.88);
--text-muted: rgba(255,255,255,0.48);
--body-bg: #f4f5f7;
--card-bg: #ffffff;
--card-border: #e4e8ee;
--text-dark: #1e293b;
--text-mid: #64748b;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
--danger: #dc2626;
--success: #16a34a;
--warning: #d97706;
--radius: 10px;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
body {
background: var(--body-bg);
color: var(--text-dark);
font-family: var(--font);
min-height: 100dvh;
}
button { cursor: pointer; font-family: inherit; }
input, textarea, select { font-family: inherit; }

10
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
)

View file

@ -0,0 +1,99 @@
import { useState } from 'react'
import { api } from '../api'
import { PageHeader, Card, Btn } from '../components/Layout'
import { GBP_DENOMINATIONS, fmtGBP, today } from '../types'
interface DenomRow { denomination_value: string; total_quantity: string; total_value: string }
interface SummaryResult { denominations: DenomRow[]; period: { from: string; to: string } }
export function CashSummary() {
const [from, setFrom] = useState(() => { const d = new Date(); d.setDate(d.getDate() - 6); return d.toISOString().slice(0, 10) })
const [to, setTo] = useState(today)
const [result, setResult] = useState<SummaryResult | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function generate() {
setLoading(true); setError(''); setResult(null)
try {
const data = await api.get<SummaryResult>(`/reports/cash-summary?from=${from}&to=${to}`)
setResult(data)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to generate summary')
} finally {
setLoading(false)
}
}
const grandTotal = result?.denominations.reduce((s, r) => s + parseFloat(r.total_value), 0) ?? 0
return (
<div style={{ padding: '1.5rem', maxWidth: '640px' }}>
<PageHeader title="Cash Denomination Summary" subtitle="Aggregate cash count across a date range" />
<Card style={{ marginBottom: '1rem', display: 'flex', gap: '1rem', alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div>
<label style={{ fontSize: '0.75rem', color: 'var(--text-mid)', display: 'block', marginBottom: '0.25rem' }}>From</label>
<input type="date" value={from} onChange={e => setFrom(e.target.value)}
style={inpSt} />
</div>
<div>
<label style={{ fontSize: '0.75rem', color: 'var(--text-mid)', display: 'block', marginBottom: '0.25rem' }}>To</label>
<input type="date" value={to} onChange={e => setTo(e.target.value)}
style={inpSt} />
</div>
<Btn onClick={generate} disabled={loading}>{loading ? 'Loading…' : 'Generate'}</Btn>
</Card>
{error && (
<div style={{ background: '#fee2e2', color: 'var(--danger)', border: '1px solid #fecaca', borderRadius: '6px', padding: '0.75rem 1rem', marginBottom: '1rem' }}>
{error}
</div>
)}
{result && (
<Card>
<h2 style={{ fontSize: '0.875rem', fontWeight: 700, marginBottom: '0.25rem' }}>
{result.period.from} {result.period.to}
</h2>
<p style={{ fontSize: '0.8rem', color: 'var(--text-mid)', marginBottom: '1rem' }}>
Takings only (excludes float counts)
</p>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--card-border)' }}>
<th style={{ ...thSt, textAlign: 'left' }}>Denomination</th>
<th style={thSt}>Total Qty</th>
<th style={thSt}>Total Value</th>
</tr>
</thead>
<tbody>
{GBP_DENOMINATIONS.map(d => {
const row = result.denominations.find(r => Math.abs(parseFloat(r.denomination_value) - d.value) < 0.001)
if (!row) return null
return (
<tr key={d.value} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={{ padding: '0.45rem 0.5rem', fontWeight: 600 }}>{d.label}</td>
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'right' }}>{row.total_quantity}</td>
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'right' }}>{fmtGBP(row.total_value)}</td>
</tr>
)
})}
</tbody>
<tfoot>
<tr style={{ borderTop: '2px solid var(--card-border)', background: 'var(--body-bg)' }}>
<td style={{ padding: '0.6rem 0.5rem', fontWeight: 700 }}>Grand Total</td>
<td></td>
<td style={{ padding: '0.6rem 0.5rem', textAlign: 'right', fontWeight: 700, fontSize: '1rem' }}>{fmtGBP(grandTotal)}</td>
</tr>
</tfoot>
</table>
</Card>
)}
</div>
)
}
const inpSt: React.CSSProperties = { border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.4rem 0.6rem', fontSize: '0.875rem' }
const thSt: React.CSSProperties = { padding: '0.5rem 0.5rem', textAlign: 'right', fontWeight: 600, color: 'var(--text-mid)' }

View file

@ -0,0 +1,455 @@
import { useState, useCallback } from 'react'
import { RefreshCw, Save, CheckCircle, Loader } from 'lucide-react'
import { api } from '../api'
import { PageHeader, Card, Btn, StatusBadge } from '../components/Layout'
import {
GBP_DENOMINATIONS, fmtGBP, today,
type User, type CashUp, type Denomination, type CardMachine,
type PaymentTotals, type ReconciliationRow, type TillPayment, type Attachment,
} from '../types'
const MACHINES = ['Front Desk', 'Restaurant / Bar']
function initDenominations(countType: 'takings' | 'float'): Denomination[] {
return GBP_DENOMINATIONS.map(d => ({
count_type: countType,
denomination_type: d.type,
denomination_value: d.value,
quantity: null,
value_entered: null,
total_amount: 0,
}))
}
function initMachines(): CardMachine[] {
return MACHINES.map(name => ({ machine_name: name, total_amount: 0, amex_amount: 0, visa_mc_amount: 0 }))
}
function denomTotal(denoms: Denomination[]) {
return denoms.reduce((s, d) => s + d.total_amount, 0)
}
interface Props { user: User }
export function DailyCashUp({ user: _user }: Props) {
const [date, setDate] = useState(today())
const [cashUp, setCashUp] = useState<CashUp | null>(null)
const [takings, setTakings] = useState<Denomination[]>(initDenominations('takings'))
const [float, setFloat] = useState<Denomination[]>(initDenominations('float'))
const [machines, setMachines] = useState<CardMachine[]>(initMachines())
const [notes, setNotes] = useState('')
const [attachments, setAttachments] = useState<Attachment[]>([])
const [newbookTotals, setNewbookTotals] = useState<PaymentTotals | null>(null)
const [tillPayments, setTillPayments] = useState<TillPayment[]>([])
const [fetching, setFetching] = useState(false)
const [saving, setSaving] = useState(false)
const [loading, setLoading] = useState(false)
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null)
const [showFloat, setShowFloat] = useState(false)
const isFinal = cashUp?.status === 'final'
function flash(text: string, ok = true) {
setMsg({ text, ok })
setTimeout(() => setMsg(null), 4000)
}
async function loadExisting() {
setLoading(true)
try {
const data = await api.get<{
cash_up: CashUp; denominations: Denomination[]; card_machines: CardMachine[];
reconciliation: ReconciliationRow[]; attachments: Attachment[]
}>(`/cashup?date=${date}`)
setCashUp(data.cash_up)
setNotes(data.cash_up.notes || '')
setAttachments(data.attachments || [])
// Rebuild denomination grids from saved data
const rebuild = (ct: 'takings' | 'float') =>
GBP_DENOMINATIONS.map(d => {
const saved = data.denominations.find(
s => s.count_type === ct && parseFloat(String(s.denomination_value)) === d.value
)
return saved
? { ...saved, denomination_value: d.value }
: { count_type: ct, denomination_type: d.type, denomination_value: d.value, quantity: null, value_entered: null, total_amount: 0 }
})
setTakings(rebuild('takings'))
setFloat(rebuild('float'))
if (data.card_machines.length) {
setMachines(MACHINES.map(name => {
const m = data.card_machines.find(c => c.machine_name === name)
return m ?? { machine_name: name, total_amount: 0, amex_amount: 0, visa_mc_amount: 0 }
}))
}
flash('Loaded existing cash up.')
} catch (e: unknown) {
if (e instanceof Error && e.message === 'Not found') flash('No cash up for this date.', false)
else flash('Failed to load.', false)
} finally {
setLoading(false)
}
}
function reset() {
setCashUp(null)
setTakings(initDenominations('takings'))
setFloat(initDenominations('float'))
setMachines(initMachines())
setNotes('')
setAttachments([])
setNewbookTotals(null)
setTillPayments([])
}
function updateDenom(list: Denomination[], setList: (d: Denomination[]) => void, idx: number, field: 'quantity' | 'value_entered', raw: string) {
const val = raw === '' ? null : parseFloat(raw)
const updated = list.map((d, i) => {
if (i !== idx) return d
if (field === 'quantity') {
const qty = val === null ? null : Math.max(0, Math.round(val))
return { ...d, quantity: qty, value_entered: null, total_amount: qty === null ? 0 : qty * d.denomination_value }
} else {
const ve = val === null ? null : Math.max(0, val)
return { ...d, value_entered: ve, quantity: null, total_amount: ve ?? 0 }
}
})
setList(updated)
}
function updateMachine(idx: number, field: 'total_amount' | 'amex_amount', raw: string) {
const val = raw === '' ? 0 : parseFloat(raw) || 0
setMachines(machines.map((m, i) => {
if (i !== idx) return m
const total = field === 'total_amount' ? val : m.total_amount
const amex = field === 'amex_amount' ? val : m.amex_amount
return { ...m, total_amount: total, amex_amount: amex, visa_mc_amount: Math.max(0, total - amex) }
}))
}
async function fetchNewbook() {
setFetching(true)
try {
const data = await api.post<{
count: number; totals: PaymentTotals; till_payments: TillPayment[]
}>('/newbook/payments', { date })
setNewbookTotals(data.totals)
setTillPayments(data.till_payments || [])
flash(`Fetched ${data.count} payment(s) from Newbook.`)
} catch (e: unknown) {
flash(e instanceof Error ? e.message : 'Failed to fetch Newbook data.', false)
} finally {
setFetching(false)
}
}
async function save(status: 'draft' | 'final') {
setSaving(true)
try {
const allDenoms = [...takings, ...float].filter(d => d.total_amount > 0)
const result = await api.post<{ cash_up_id: number; message: string }>('/cashup/save', {
session_date: date,
status,
notes,
denominations: allDenoms.map(d => ({
count_type: d.count_type,
type: d.denomination_type,
value: d.denomination_value,
quantity: d.quantity,
value_entered: d.value_entered,
total_amount: d.total_amount,
})),
card_machines: machines.map(m => ({
name: m.machine_name,
total: m.total_amount,
amex: m.amex_amount,
visa_mc: m.visa_mc_amount,
})),
})
flash(result.message)
if (status === 'final') {
setCashUp(prev => prev ? { ...prev, status: 'final' } : null)
}
} catch (e: unknown) {
flash(e instanceof Error ? e.message : 'Save failed.', false)
} finally {
setSaving(false)
}
}
// Build reconciliation rows from local + Newbook data
const recon: ReconciliationRow[] = newbookTotals ? [
{ category: 'Cash', banked_amount: denomTotal(takings), reported_amount: newbookTotals.cash },
{ category: 'PDQ Visa/MC', banked_amount: machines.reduce((s, m) => s + m.visa_mc_amount, 0), reported_amount: newbookTotals.manual_visa_mc },
{ category: 'PDQ Amex', banked_amount: machines.reduce((s, m) => s + m.amex_amount, 0), reported_amount: newbookTotals.manual_amex },
{ category: 'Gateway Visa/MC', banked_amount: newbookTotals.gateway_visa_mc, reported_amount: newbookTotals.gateway_visa_mc },
{ category: 'Gateway Amex', banked_amount: newbookTotals.gateway_amex, reported_amount: newbookTotals.gateway_amex },
{ category: 'BACS', banked_amount: newbookTotals.bacs, reported_amount: newbookTotals.bacs },
] : []
const totalPdq = machines.reduce((s, m) => s + m.total_amount, 0)
return (
<div style={{ padding: '1.5rem', maxWidth: '900px' }}>
<PageHeader title="Daily Cash Up" subtitle={cashUp ? `Status: ` : undefined} />
{cashUp && <div style={{ marginTop: '-1rem', marginBottom: '1rem' }}><StatusBadge status={cashUp.status} /></div>}
{msg && (
<div style={{
background: msg.ok ? '#dcfce7' : '#fee2e2', color: msg.ok ? '#16a34a' : 'var(--danger)',
border: `1px solid ${msg.ok ? '#bbf7d0' : '#fecaca'}`, borderRadius: '6px',
padding: '0.625rem 1rem', marginBottom: '1rem', fontSize: '0.875rem',
}}>
{msg.text}
</div>
)}
{/* Date selector */}
<Card style={{ marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<div>
<label style={{ fontSize: '0.8rem', color: 'var(--text-mid)', display: 'block', marginBottom: '0.25rem' }}>Business Date</label>
<input type="date" value={date} disabled={isFinal}
onChange={e => { setDate(e.target.value); reset() }}
style={{ border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.45rem 0.75rem', fontSize: '0.9rem' }}
/>
</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-end', paddingBottom: '1px' }}>
<Btn onClick={loadExisting} disabled={loading || isFinal} variant="secondary" small>
{loading ? 'Loading…' : 'Load Existing'}
</Btn>
{cashUp && <Btn onClick={reset} variant="ghost" small>New</Btn>}
</div>
</Card>
{/* Cash denomination — Takings */}
<Card style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 700 }}>Cash Takings</h2>
<span style={{ fontWeight: 700, fontSize: '1.1rem' }}>{fmtGBP(denomTotal(takings))}</span>
</div>
<DenomGrid denoms={takings} onChange={(i, f, v) => updateDenom(takings, setTakings, i, f, v)} disabled={isFinal} />
</Card>
{/* Float (collapsible) */}
<Card style={{ marginBottom: '1rem' }}>
<button onClick={() => setShowFloat(f => !f)} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
width: '100%', background: 'none', border: 'none', padding: 0, cursor: 'pointer',
}}>
<h2 style={{ fontSize: '1rem', fontWeight: 700 }}>Float Count</h2>
<span style={{ fontSize: '0.9rem', color: 'var(--text-mid)' }}>
{fmtGBP(denomTotal(float))} {showFloat ? '▲' : '▼'}
</span>
</button>
{showFloat && (
<div style={{ marginTop: '1rem' }}>
<DenomGrid denoms={float} onChange={(i, f, v) => updateDenom(float, setFloat, i, f, v)} disabled={isFinal} />
</div>
)}
</Card>
{/* Card Machines */}
<Card style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 700 }}>Card Machines (PDQ)</h2>
<span style={{ fontWeight: 700, fontSize: '1.1rem' }}>Total: {fmtGBP(totalPdq)}</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
{machines.map((m, i) => (
<div key={m.machine_name} style={{ border: '1px solid var(--card-border)', borderRadius: '8px', padding: '1rem' }}>
<h3 style={{ fontSize: '0.875rem', fontWeight: 600, marginBottom: '0.75rem', color: 'var(--text-mid)' }}>
{m.machine_name}
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<MoneyInput label="Total" value={m.total_amount}
onChange={v => updateMachine(i, 'total_amount', v)} disabled={isFinal} />
<MoneyInput label="Amex" value={m.amex_amount}
onChange={v => updateMachine(i, 'amex_amount', v)} disabled={isFinal} />
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.875rem', paddingTop: '0.25rem' }}>
<span style={{ color: 'var(--text-mid)' }}>Visa / MC</span>
<span style={{ fontWeight: 600 }}>{fmtGBP(m.visa_mc_amount)}</span>
</div>
</div>
</div>
))}
</div>
</Card>
{/* Newbook + Reconciliation */}
<Card style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 700 }}>Newbook Reconciliation</h2>
<Btn onClick={fetchNewbook} disabled={fetching || isFinal} small>
{fetching ? <><Loader size={13} style={{ animation: 'spin 1s linear infinite' }} /> Fetching</> : <><RefreshCw size={13} /> Fetch Payments</>}
</Btn>
</div>
{newbookTotals && (
<>
<table style={{ width: '100%', fontSize: '0.875rem', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--card-border)' }}>
{['Category', 'Banked', 'Reported', 'Variance'].map(h => (
<th key={h} style={{ padding: '0.4rem 0.5rem', textAlign: h === 'Category' ? 'left' : 'right', color: 'var(--text-mid)', fontWeight: 600 }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{recon.map(row => {
const variance = row.banked_amount - row.reported_amount
const varColor = Math.abs(variance) < 0.01 ? 'var(--text-mid)' : variance > 0 ? 'var(--success)' : 'var(--danger)'
return (
<tr key={row.category} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={{ padding: '0.5rem' }}>{row.category}</td>
<td style={{ padding: '0.5rem', textAlign: 'right' }}>{fmtGBP(row.banked_amount)}</td>
<td style={{ padding: '0.5rem', textAlign: 'right' }}>{fmtGBP(row.reported_amount)}</td>
<td style={{ padding: '0.5rem', textAlign: 'right', color: varColor, fontWeight: 600 }}>
{Math.abs(variance) < 0.01 ? '—' : (variance > 0 ? '+' : '') + fmtGBP(Math.abs(variance))}
</td>
</tr>
)
})}
</tbody>
</table>
{tillPayments.length > 0 && (
<div style={{ marginTop: '1rem' }}>
<h3 style={{ fontSize: '0.875rem', fontWeight: 600, marginBottom: '0.5rem', color: 'var(--text-mid)' }}>
Till / Restaurant Transactions
</h3>
<table style={{ width: '100%', fontSize: '0.8rem', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--card-border)' }}>
<th style={{ padding: '0.35rem 0.5rem', textAlign: 'left', color: 'var(--text-mid)' }}>Type</th>
<th style={{ padding: '0.35rem 0.5rem', textAlign: 'right', color: 'var(--text-mid)' }}>Qty</th>
<th style={{ padding: '0.35rem 0.5rem', textAlign: 'right', color: 'var(--text-mid)' }}>Total</th>
</tr>
</thead>
<tbody>
{tillPayments.map(t => (
<tr key={t.payment_type} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={{ padding: '0.35rem 0.5rem' }}>{t.payment_type}</td>
<td style={{ padding: '0.35rem 0.5rem', textAlign: 'right' }}>{t.quantity}</td>
<td style={{ padding: '0.35rem 0.5rem', textAlign: 'right' }}>{fmtGBP(t.total_value)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</Card>
{/* Notes */}
<Card style={{ marginBottom: '1rem' }}>
<label style={{ fontSize: '0.875rem', fontWeight: 600, display: 'block', marginBottom: '0.5rem' }}>Notes</label>
<textarea
value={notes} onChange={e => setNotes(e.target.value)}
disabled={isFinal}
rows={3}
placeholder="Explain any variances or issues…"
style={{
width: '100%', border: '1px solid var(--card-border)', borderRadius: '6px',
padding: '0.6rem 0.75rem', fontSize: '0.875rem', resize: 'vertical',
background: isFinal ? 'var(--body-bg)' : 'white',
}}
/>
</Card>
{/* Action buttons */}
{!isFinal && (
<div style={{ display: 'flex', gap: '0.75rem' }}>
<Btn onClick={() => save('draft')} disabled={saving} variant="secondary">
<Save size={14} style={{ marginRight: '0.4rem' }} />
{saving ? 'Saving…' : 'Save Draft'}
</Btn>
<Btn onClick={() => save('final')} disabled={saving}>
<CheckCircle size={14} style={{ marginRight: '0.4rem' }} />
{saving ? 'Submitting…' : 'Submit Final'}
</Btn>
</div>
)}
{isFinal && (
<div style={{ color: 'var(--text-mid)', fontSize: '0.875rem', fontStyle: 'italic' }}>
This cash up has been finalised and cannot be edited.
</div>
)}
<style>{`@keyframes spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }`}</style>
</div>
)
}
function DenomGrid({ denoms, onChange, disabled }: {
denoms: Denomination[]
onChange: (idx: number, field: 'quantity' | 'value_entered', val: string) => void
disabled: boolean
}) {
return (
<div>
<div style={{ display: 'grid', gridTemplateColumns: '80px 1fr 1fr 80px', gap: '0.25rem 0.5rem', marginBottom: '0.35rem' }}>
{['Denom', 'Qty', 'Value Override', 'Total'].map(h => (
<span key={h} style={{ fontSize: '0.75rem', color: 'var(--text-mid)', fontWeight: 600 }}>{h}</span>
))}
</div>
{GBP_DENOMINATIONS.map((d, i) => {
const row = denoms[i]
return (
<div key={d.value} style={{ display: 'grid', gridTemplateColumns: '80px 1fr 1fr 80px', gap: '0.25rem 0.5rem', marginBottom: '0.2rem', alignItems: 'center' }}>
<span style={{ fontSize: '0.875rem', fontWeight: 600 }}>{d.label}</span>
<input
type="number" min="0" step="1"
value={row.quantity ?? ''}
onChange={e => onChange(i, 'quantity', e.target.value)}
disabled={disabled}
placeholder="0"
style={inputSt}
/>
<input
type="number" min="0" step="0.01"
value={row.value_entered ?? ''}
onChange={e => onChange(i, 'value_entered', e.target.value)}
disabled={disabled || row.quantity !== null}
placeholder="—"
style={{ ...inputSt, opacity: row.quantity !== null ? 0.4 : 1 }}
/>
<span style={{ fontSize: '0.875rem', textAlign: 'right' }}>
{row.total_amount > 0 ? fmtGBP(row.total_amount) : '—'}
</span>
</div>
)
})}
</div>
)
}
const inputSt: React.CSSProperties = {
border: '1px solid var(--card-border)', borderRadius: '4px',
padding: '0.3rem 0.5rem', fontSize: '0.875rem', width: '100%',
background: 'white',
}
function MoneyInput({ label, value, onChange, disabled }: {
label: string; value: number; onChange: (v: string) => void; disabled: boolean
}) {
return (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '0.5rem' }}>
<label style={{ fontSize: '0.8rem', color: 'var(--text-mid)', minWidth: '60px' }}>{label}</label>
<input
type="number" min="0" step="0.01"
value={value || ''}
onChange={e => onChange(e.target.value)}
disabled={disabled}
placeholder="0.00"
style={{ ...inputSt, width: '110px', textAlign: 'right' }}
/>
</div>
)
}

View file

@ -0,0 +1,321 @@
import { useState, useEffect, useCallback } from 'react'
import { Routes, Route, NavLink, Navigate, useNavigate } from 'react-router-dom'
import { api } from '../api'
import { PageHeader, Card, Btn } from '../components/Layout'
import { GBP_DENOMINATIONS, fmtGBP } from '../types'
import type { FloatCount, FloatDenomination, FloatReceipt } from '../types'
type CountType = 'petty_cash' | 'change_tin' | 'safe_cash'
const TYPE_LABELS: Record<CountType, string> = {
petty_cash: 'Petty Cash',
change_tin: 'Change Tin',
safe_cash: 'Safe Cash',
}
// Denominations relevant for each type (change_tin uses bags, no £0.02/£0.01)
const CHANGE_TIN_DENOMS = GBP_DENOMINATIONS.filter(d => d.value >= 0.05)
function FloatCountForm({ type }: { type: CountType }) {
const navigate = useNavigate()
const [denomQtys, setDenomQtys] = useState<Record<number, number>>({})
const [receipts, setReceipts] = useState<Array<{ amount: string; description: string }>>([])
const [notes, setNotes] = useState('')
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null)
const denoms = type === 'change_tin' ? CHANGE_TIN_DENOMS : GBP_DENOMINATIONS
// Load settings for change tin targets
const [changeTinTargets, setChangeTinTargets] = useState<Record<string, number>>({})
const [pettyTarget, setPettyTarget] = useState(200)
useEffect(() => {
api.get<Record<string, string>>('/settings').then(s => {
if (type === 'change_tin') {
try { setChangeTinTargets(JSON.parse(s.change_tin_breakdown || '{}')) } catch {}
}
if (type === 'petty_cash') {
setPettyTarget(parseFloat(s.petty_cash_float || '200'))
}
}).catch(() => {})
}, [type])
const totalCounted = denoms.reduce((s, d) => s + d.value * (denomQtys[d.value] ?? 0), 0)
const totalReceipts = receipts.reduce((s, r) => s + (parseFloat(r.amount) || 0), 0)
const targetAmount = type === 'petty_cash' ? pettyTarget : type === 'change_tin'
? Object.entries(changeTinTargets).reduce((s, [k, v]) => s + parseFloat(v as string || '0'), 0)
: 0
const variance = type === 'petty_cash'
? totalCounted + totalReceipts - pettyTarget
: type === 'change_tin' ? totalCounted - targetAmount : 0
async function save() {
setSaving(true)
try {
await api.post('/floats/save', {
count_type: type,
count_date: new Date().toISOString(),
denominations: denoms.filter(d => (denomQtys[d.value] ?? 0) > 0).map(d => ({
denomination: d.value, quantity: denomQtys[d.value] ?? 0, total: d.value * (denomQtys[d.value] ?? 0),
})),
receipts: type === 'petty_cash' ? receipts.filter(r => r.amount) : [],
total_counted: totalCounted,
total_receipts: totalReceipts,
target_amount: targetAmount,
variance,
notes,
})
setMsg({ text: 'Count saved.', ok: true })
setDenomQtys({})
setReceipts([])
setNotes('')
} catch (e: unknown) {
setMsg({ text: e instanceof Error ? e.message : 'Save failed.', ok: false })
} finally {
setSaving(false)
}
}
return (
<div style={{ maxWidth: '640px' }}>
<PageHeader title={TYPE_LABELS[type]} subtitle="Enter denomination counts" />
{msg && (
<div style={{
background: msg.ok ? '#dcfce7' : '#fee2e2', color: msg.ok ? '#16a34a' : 'var(--danger)',
border: `1px solid ${msg.ok ? '#bbf7d0' : '#fecaca'}`, borderRadius: '6px',
padding: '0.625rem 1rem', marginBottom: '1rem', fontSize: '0.875rem',
}}>
{msg.text}
</div>
)}
<Card style={{ marginBottom: '1rem' }}>
<h2 style={{ fontSize: '0.875rem', fontWeight: 700, marginBottom: '0.75rem', color: 'var(--text-mid)' }}>DENOMINATIONS</h2>
{denoms.map(d => {
const qty = denomQtys[d.value] ?? 0
const target = type === 'change_tin' ? parseFloat(String(changeTinTargets[d.value.toFixed(2)] ?? '0')) : undefined
const rowTotal = d.value * qty
return (
<div key={d.value} style={{ display: 'grid', gridTemplateColumns: '60px 1fr 80px 80px', gap: '0.4rem 0.75rem', alignItems: 'center', marginBottom: '0.35rem' }}>
<span style={{ fontWeight: 600, fontSize: '0.875rem' }}>{d.label}</span>
<input type="number" min="0" step="1" value={qty || ''}
onChange={e => setDenomQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))}
placeholder="0" style={inpSt} />
{target !== undefined && (
<span style={{ fontSize: '0.75rem', color: 'var(--text-mid)', textAlign: 'right' }}>
tgt {fmtGBP(target)}
</span>
)}
<span style={{ textAlign: 'right', fontSize: '0.875rem' }}>{rowTotal > 0 ? fmtGBP(rowTotal) : '—'}</span>
</div>
)
})}
<div style={{ borderTop: '2px solid var(--card-border)', paddingTop: '0.75rem', marginTop: '0.5rem', display: 'flex', justifyContent: 'space-between', fontWeight: 700 }}>
<span>Total Counted</span>
<span>{fmtGBP(totalCounted)}</span>
</div>
</Card>
{type === 'petty_cash' && (
<Card style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
<h2 style={{ fontSize: '0.875rem', fontWeight: 700, color: 'var(--text-mid)' }}>RECEIPTS</h2>
<Btn small variant="ghost" onClick={() => setReceipts(r => [...r, { amount: '', description: '' }])}>+ Add</Btn>
</div>
{receipts.map((r, i) => (
<div key={i} style={{ display: 'grid', gridTemplateColumns: '100px 1fr 32px', gap: '0.4rem', marginBottom: '0.35rem', alignItems: 'center' }}>
<input type="number" min="0" step="0.01" value={r.amount} placeholder="0.00"
onChange={e => setReceipts(prev => prev.map((x, j) => j === i ? { ...x, amount: e.target.value } : x))}
style={inpSt} />
<input type="text" value={r.description} placeholder="Description"
onChange={e => setReceipts(prev => prev.map((x, j) => j === i ? { ...x, description: e.target.value } : x))}
style={inpSt} />
<button onClick={() => setReceipts(prev => prev.filter((_, j) => j !== i))}
style={{ background: 'none', border: 'none', color: 'var(--danger)', fontSize: '1.1rem', cursor: 'pointer' }}>×</button>
</div>
))}
{receipts.length > 0 && (
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 600, fontSize: '0.875rem', paddingTop: '0.5rem' }}>
<span>Total Receipts</span><span>{fmtGBP(totalReceipts)}</span>
</div>
)}
</Card>
)}
{type !== 'safe_cash' && (
<Card style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.875rem', marginBottom: '0.4rem' }}>
<span style={{ color: 'var(--text-mid)' }}>Target Amount</span>
<span>{fmtGBP(targetAmount)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.875rem', fontWeight: 700 }}>
<span>Variance</span>
<span style={{ color: Math.abs(variance) < 0.01 ? 'var(--text-mid)' : variance > 0 ? 'var(--success)' : 'var(--danger)' }}>
{Math.abs(variance) < 0.01 ? '£0.00' : (variance > 0 ? '+' : '') + fmtGBP(Math.abs(variance))}
</span>
</div>
</Card>
)}
<Card style={{ marginBottom: '1rem' }}>
<label style={{ fontSize: '0.875rem', fontWeight: 600, display: 'block', marginBottom: '0.5rem' }}>Notes</label>
<textarea value={notes} onChange={e => setNotes(e.target.value)} rows={2}
style={{ width: '100%', border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.5rem', fontSize: '0.875rem', resize: 'vertical' }} />
</Card>
<div style={{ display: 'flex', gap: '0.75rem' }}>
<Btn onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save Count'}</Btn>
<Btn variant="ghost" onClick={() => navigate(`/floats/${type}/history`)}>View History</Btn>
</div>
</div>
)
}
function FloatHistory({ type }: { type: CountType }) {
const [rows, setRows] = useState<FloatCount[]>([])
const [total, setTotal] = useState(0)
const [offset, setOffset] = useState(0)
const [detail, setDetail] = useState<(FloatCount & { denominations: FloatDenomination[]; receipts: FloatReceipt[] }) | null>(null)
const limit = 10
useEffect(() => {
api.get<{ rows: FloatCount[]; total: number }>(`/floats?type=${type}&offset=${offset}&limit=${limit}`)
.then(d => { setRows(d.rows); setTotal(d.total) })
.catch(() => {})
}, [type, offset])
async function loadDetail(id: number) {
const d = await api.get<FloatCount & { denominations: FloatDenomination[]; receipts: FloatReceipt[] }>(`/floats/${id}`)
setDetail(d)
}
return (
<div>
<PageHeader title={`${TYPE_LABELS[type]} History`} />
{detail ? (
<Card>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '0.875rem', fontWeight: 700 }}>{new Date(detail.count_date).toLocaleString('en-GB')}</h2>
<Btn small variant="ghost" onClick={() => setDetail(null)}>Back</Btn>
</div>
<div style={{ display: 'flex', gap: '2rem', marginBottom: '1rem', fontSize: '0.875rem' }}>
<span>Total: <strong>{fmtGBP(detail.total_counted)}</strong></span>
{detail.count_type !== 'safe_cash' && <span>Variance: <strong>{fmtGBP(detail.variance)}</strong></span>}
{detail.count_type === 'petty_cash' && <span>Receipts: <strong>{fmtGBP(detail.total_receipts)}</strong></span>}
</div>
<table style={{ width: '100%', fontSize: '0.8rem', borderCollapse: 'collapse' }}>
<tbody>
{detail.denominations.map(d => (
<tr key={String(d.denomination_value)} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={{ padding: '0.3rem 0.5rem' }}>{fmtGBP(d.denomination_value)}</td>
<td style={{ padding: '0.3rem 0.5rem', textAlign: 'right' }}>×{d.quantity}</td>
<td style={{ padding: '0.3rem 0.5rem', textAlign: 'right' }}>{fmtGBP(d.total_amount)}</td>
{d.target !== undefined && (
<td style={{ padding: '0.3rem 0.5rem', textAlign: 'right', color: 'var(--text-mid)' }}>tgt {fmtGBP(d.target)}</td>
)}
</tr>
))}
</tbody>
</table>
{detail.receipts.length > 0 && (
<>
<h3 style={{ fontSize: '0.8rem', fontWeight: 700, margin: '0.75rem 0 0.4rem', color: 'var(--text-mid)' }}>RECEIPTS</h3>
{detail.receipts.map(r => (
<div key={r.id} style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.8rem', borderBottom: '1px solid var(--card-border)', padding: '0.3rem 0.5rem' }}>
<span>{r.receipt_description || '—'}</span>
<span>{fmtGBP(r.receipt_value)}</span>
</div>
))}
</>
)}
{detail.notes && <p style={{ marginTop: '0.75rem', fontSize: '0.8rem', color: 'var(--text-mid)' }}>{detail.notes}</p>}
</Card>
) : (
<>
{rows.length === 0 ? (
<Card><p style={{ color: 'var(--text-mid)', textAlign: 'center', padding: '1rem' }}>No records found.</p></Card>
) : (
<Card style={{ padding: 0, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--card-border)', background: 'var(--body-bg)' }}>
<th style={{ ...thS, textAlign: 'left' }}>Date / Time</th>
<th style={thS}>Total</th>
{type !== 'safe_cash' && <th style={thS}>Variance</th>}
<th style={thS}></th>
</tr>
</thead>
<tbody>
{rows.map(row => (
<tr key={row.id} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={tdS}>{new Date(row.count_date).toLocaleString('en-GB')}</td>
<td style={{ ...tdS, textAlign: 'right', fontWeight: 600 }}>{fmtGBP(row.total_counted)}</td>
{type !== 'safe_cash' && (
<td style={{ ...tdS, textAlign: 'right', color: Math.abs(parseFloat(row.variance)) < 0.01 ? 'var(--text-mid)' : parseFloat(row.variance) > 0 ? 'var(--success)' : 'var(--danger)' }}>
{Math.abs(parseFloat(row.variance)) < 0.01 ? '£0.00' : (parseFloat(row.variance) > 0 ? '+' : '') + fmtGBP(Math.abs(parseFloat(row.variance)))}
</td>
)}
<td style={tdS}><Btn small variant="ghost" onClick={() => loadDetail(row.id)}>View</Btn></td>
</tr>
))}
</tbody>
</table>
</Card>
)}
{total > limit && (
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem', justifyContent: 'center' }}>
<Btn onClick={() => setOffset(Math.max(0, offset - limit))} disabled={offset === 0} small variant="secondary">Prev</Btn>
<span style={{ alignSelf: 'center', fontSize: '0.875rem', color: 'var(--text-mid)' }}>{offset + 1}{Math.min(offset + limit, total)} of {total}</span>
<Btn onClick={() => setOffset(offset + limit)} disabled={offset + limit >= total} small variant="secondary">Next</Btn>
</div>
)}
</>
)}
</div>
)
}
export function FloatManagement() {
const tabs: Array<{ path: string; label: string; type: CountType }> = [
{ path: 'petty-cash', label: 'Petty Cash', type: 'petty_cash' },
{ path: 'change-tin', label: 'Change Tin', type: 'change_tin' },
{ path: 'safe-cash', label: 'Safe Cash', type: 'safe_cash' },
]
return (
<div style={{ padding: '1.5rem' }}>
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem', borderBottom: '2px solid var(--card-border)', paddingBottom: '0' }}>
{tabs.map(t => (
<NavLink key={t.path} to={t.path}
style={({ isActive }) => ({
padding: '0.5rem 1rem', textDecoration: 'none', fontSize: '0.875rem', fontWeight: 600,
color: isActive ? 'var(--gold)' : 'var(--text-mid)',
borderBottom: isActive ? '2px solid var(--gold)' : '2px solid transparent',
marginBottom: '-2px',
})}>
{t.label}
</NavLink>
))}
</div>
<Routes>
<Route index element={<Navigate to="petty-cash" replace />} />
{tabs.map(t => (
<Route key={t.path} path={t.path} element={<FloatCountForm type={t.type} />} />
))}
{tabs.map(t => (
<Route key={t.path + '/history'} path={`${t.path}/history`} element={<FloatHistory type={t.type} />} />
))}
</Routes>
</div>
)
}
const inpSt: React.CSSProperties = {
border: '1px solid var(--card-border)', borderRadius: '4px',
padding: '0.3rem 0.5rem', fontSize: '0.875rem', width: '100%',
}
const thS: React.CSSProperties = { padding: '0.6rem 0.75rem', fontWeight: 600, color: 'var(--text-mid)', fontSize: '0.8rem', textAlign: 'right' }
const tdS: React.CSSProperties = { padding: '0.6rem 0.75rem' }

View file

@ -0,0 +1,182 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api'
import { PageHeader, Card, Btn, StatusBadge } from '../components/Layout'
import { fmtGBP, today } from '../types'
import type { CashUp } from '../types'
export function History() {
const navigate = useNavigate()
const [rows, setRows] = useState<CashUp[]>([])
const [total, setTotal] = useState(0)
const [offset, setOffset] = useState(0)
const [status, setStatus] = useState('all')
const [from, setFrom] = useState('')
const [to, setTo] = useState(today())
const [loading, setLoading] = useState(false)
const [selected, setSelected] = useState<Set<number>>(new Set())
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null)
const limit = 20
const flash = (text: string, ok = true) => {
setMsg({ text, ok })
setTimeout(() => setMsg(null), 4000)
}
const load = useCallback(async () => {
setLoading(true)
try {
const params = new URLSearchParams({ offset: String(offset), limit: String(limit) })
if (status !== 'all') params.set('status', status)
if (from) params.set('from', from)
if (to) params.set('to', to)
const data = await api.get<{ rows: CashUp[]; total: number }>(`/cashup/history?${params}`)
setRows(data.rows)
setTotal(data.total)
} finally {
setLoading(false)
}
}, [offset, status, from, to])
useEffect(() => { load() }, [load])
function toggleSelect(id: number) {
setSelected(prev => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
async function deleteDraft(id: number) {
if (!confirm('Delete this draft cash up?')) return
await api.delete(`/cashup/${id}`)
flash('Deleted.')
load()
}
async function bulkFinalize() {
if (!selected.size) return
if (!confirm(`Finalise ${selected.size} draft(s)?`)) return
const { success, failed_count } = await api.post<{ success: number; failed_count: number }>(
'/cashup/bulk-finalize', { ids: Array.from(selected) }
)
flash(`${success} finalised${failed_count ? `, ${failed_count} failed` : ''}.`, !failed_count)
setSelected(new Set())
load()
}
return (
<div style={{ padding: '1.5rem', maxWidth: '960px' }}>
<PageHeader title="Cash Up History" />
{msg && (
<div style={{
background: msg.ok ? '#dcfce7' : '#fee2e2', color: msg.ok ? '#16a34a' : 'var(--danger)',
border: `1px solid ${msg.ok ? '#bbf7d0' : '#fecaca'}`,
borderRadius: '6px', padding: '0.625rem 1rem', marginBottom: '1rem', fontSize: '0.875rem',
}}>
{msg.text}
</div>
)}
{/* Filters */}
<Card style={{ marginBottom: '1rem', display: 'flex', gap: '1rem', alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div>
<label style={{ fontSize: '0.75rem', color: 'var(--text-mid)', display: 'block', marginBottom: '0.25rem' }}>Status</label>
<select value={status} onChange={e => { setStatus(e.target.value); setOffset(0) }} style={selSt}>
<option value="all">All</option>
<option value="draft">Draft</option>
<option value="final">Final</option>
</select>
</div>
<div>
<label style={{ fontSize: '0.75rem', color: 'var(--text-mid)', display: 'block', marginBottom: '0.25rem' }}>From</label>
<input type="date" value={from} onChange={e => { setFrom(e.target.value); setOffset(0) }} style={inpSt} />
</div>
<div>
<label style={{ fontSize: '0.75rem', color: 'var(--text-mid)', display: 'block', marginBottom: '0.25rem' }}>To</label>
<input type="date" value={to} onChange={e => { setTo(e.target.value); setOffset(0) }} style={inpSt} />
</div>
<Btn onClick={() => { setOffset(0); load() }} small>Filter</Btn>
{selected.size > 0 && (
<Btn onClick={bulkFinalize} small>Finalise {selected.size} selected</Btn>
)}
</Card>
{loading ? (
<div style={{ color: 'var(--text-mid)', padding: '2rem', textAlign: 'center' }}>Loading</div>
) : rows.length === 0 ? (
<Card>
<p style={{ color: 'var(--text-mid)', textAlign: 'center', padding: '1rem' }}>No cash ups found.</p>
</Card>
) : (
<Card style={{ padding: 0, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--card-border)', background: 'var(--body-bg)' }}>
<th style={thSt}></th>
<th style={{ ...thSt, textAlign: 'left' }}>Date</th>
<th style={{ ...thSt, textAlign: 'left' }}>Status</th>
<th style={{ ...thSt, textAlign: 'right' }}>Cash</th>
<th style={{ ...thSt, textAlign: 'right' }}>Float</th>
<th style={{ ...thSt, textAlign: 'left' }}>Created By</th>
<th style={{ ...thSt, textAlign: 'left' }}>Submitted</th>
<th style={thSt}></th>
</tr>
</thead>
<tbody>
{rows.map(row => (
<tr key={row.id} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={tdSt}>
{row.status === 'draft' && (
<input type="checkbox" checked={selected.has(row.id)}
onChange={() => toggleSelect(row.id)} />
)}
</td>
<td style={tdSt}>
<span style={{ fontWeight: 600 }}>
{new Date(row.session_date + 'T12:00:00').toLocaleDateString('en-GB', { weekday: 'short', day: '2-digit', month: 'short', year: 'numeric' })}
</span>
</td>
<td style={tdSt}><StatusBadge status={row.status} /></td>
<td style={{ ...tdSt, textAlign: 'right' }}>{fmtGBP(row.total_cash_counted)}</td>
<td style={{ ...tdSt, textAlign: 'right' }}>{fmtGBP(row.total_float_counted)}</td>
<td style={{ ...tdSt, color: 'var(--text-mid)' }}>{row.created_by}</td>
<td style={{ ...tdSt, color: 'var(--text-mid)', fontSize: '0.8rem' }}>
{row.submitted_at ? new Date(row.submitted_at).toLocaleDateString('en-GB') : '—'}
</td>
<td style={{ ...tdSt, display: 'flex', gap: '0.4rem', justifyContent: 'flex-end' }}>
<Btn small variant="ghost"
onClick={() => navigate(`/daily?date=${row.session_date}`)}>
{row.status === 'draft' ? 'Edit' : 'View'}
</Btn>
{row.status === 'draft' && (
<Btn small variant="danger" onClick={() => deleteDraft(row.id)}>Delete</Btn>
)}
</td>
</tr>
))}
</tbody>
</table>
</Card>
)}
{/* Pagination */}
{total > limit && (
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem', justifyContent: 'center' }}>
<Btn onClick={() => setOffset(Math.max(0, offset - limit))} disabled={offset === 0} small variant="secondary">Prev</Btn>
<span style={{ alignSelf: 'center', fontSize: '0.875rem', color: 'var(--text-mid)' }}>
{offset + 1}{Math.min(offset + limit, total)} of {total}
</span>
<Btn onClick={() => setOffset(offset + limit)} disabled={offset + limit >= total} small variant="secondary">Next</Btn>
</div>
)}
</div>
)
}
const thSt: React.CSSProperties = { padding: '0.6rem 0.75rem', fontWeight: 600, color: 'var(--text-mid)', fontSize: '0.8rem' }
const tdSt: React.CSSProperties = { padding: '0.6rem 0.75rem' }
const selSt: React.CSSProperties = { border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.4rem 0.6rem', fontSize: '0.875rem' }
const inpSt: React.CSSProperties = { border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.4rem 0.6rem', fontSize: '0.875rem' }

View file

@ -0,0 +1,207 @@
import { useState } from 'react'
import { api } from '../api'
import { PageHeader, Card, Btn } from '../components/Layout'
import { fmtGBP, today } from '../types'
interface SalesCol { gl_code: string; category: string; net_amount: number; vat_amount: number; gross_amount: number }
interface ReconRow { category: string; banked_amount: number; reported_amount: number }
interface DayData {
date: string
cash_up: { total_cash_counted: string; status: string } | null
reconciliation: ReconRow[]
daily_stats: { gross_sales: number; transaction_count: number } | null
sales_breakdown: SalesCol[]
}
interface OccupancyItem { period?: string; date?: string; rooms_sold?: number; total_rooms?: number; total_people?: number }
interface ReportResult {
report_data: DayData[]
sales_columns: Array<{ gl_code: string; display_name: string }>
occupancy_data: OccupancyItem[]
}
const RECON_LABELS: Record<string, string> = {
cash: 'Cash', gateway_visa_mc: 'Gateway V/MC', gateway_amex: 'Gateway Amex',
pdq_visa_mc: 'PDQ V/MC', pdq_amex: 'PDQ Amex', bacs: 'BACS',
}
function fmtDate(d: string) {
return new Date(d + 'T12:00:00').toLocaleDateString('en-GB', { weekday: 'short', day: '2-digit', month: 'short' })
}
export function MultiDayReport() {
const [startDate, setStartDate] = useState(() => {
const d = new Date(); d.setDate(d.getDate() - 6); return d.toISOString().slice(0, 10)
})
const [numDays, setNumDays] = useState(7)
const [result, setResult] = useState<ReportResult | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function generate() {
setLoading(true); setError(''); setResult(null)
try {
const data = await api.post<ReportResult>('/reports/multiday', { start_date: startDate, num_days: numDays })
setResult(data)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to generate report')
} finally {
setLoading(false)
}
}
const dates = result?.report_data.map(d => d.date) ?? []
const salesCols = result?.sales_columns ?? []
return (
<div style={{ padding: '1.5rem' }}>
<PageHeader title="Weekly / Multi-Day Report" />
<Card style={{ marginBottom: '1.5rem', display: 'flex', gap: '1rem', alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div>
<label style={{ fontSize: '0.75rem', color: 'var(--text-mid)', display: 'block', marginBottom: '0.25rem' }}>Start Date</label>
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)}
style={{ border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.4rem 0.6rem', fontSize: '0.875rem' }} />
</div>
<div>
<label style={{ fontSize: '0.75rem', color: 'var(--text-mid)', display: 'block', marginBottom: '0.25rem' }}>Number of Days</label>
<input type="number" value={numDays} min={1} max={365}
onChange={e => setNumDays(Math.max(1, Math.min(365, parseInt(e.target.value) || 7)))}
style={{ border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.4rem 0.6rem', fontSize: '0.875rem', width: '80px' }} />
</div>
<Btn onClick={generate} disabled={loading}>
{loading ? 'Generating…' : 'Generate Report'}
</Btn>
</Card>
{error && (
<div style={{ background: '#fee2e2', color: 'var(--danger)', border: '1px solid #fecaca', borderRadius: '6px', padding: '0.75rem 1rem', marginBottom: '1rem' }}>
{error}
</div>
)}
{result && (
<>
{/* Table 1: Reconciliation Summary */}
<Card style={{ marginBottom: '1.5rem', overflowX: 'auto' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 700, marginBottom: '1rem' }}>Reconciliation Summary</h2>
<table style={{ borderCollapse: 'collapse', fontSize: '0.8rem', minWidth: '600px' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--card-border)' }}>
<th style={{ ...th, textAlign: 'left' }}>Category</th>
{dates.map(d => <th key={d} style={th}>{fmtDate(d)}</th>)}
<th style={th}>Total</th>
</tr>
</thead>
<tbody>
{Object.entries(RECON_LABELS).map(([key, label]) => {
const values = result.report_data.map(day => {
const row = day.reconciliation.find(r => r.category === key)
return row ? row.banked_amount : 0
})
const rowTotal = values.reduce((s, v) => s + v, 0)
if (values.every(v => v === 0) && rowTotal === 0) return null
return (
<tr key={key} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={{ ...td, fontWeight: 600 }}>{label}</td>
{values.map((v, i) => <td key={i} style={{ ...td, textAlign: 'right' }}>{v ? fmtGBP(v) : '—'}</td>)}
<td style={{ ...td, textAlign: 'right', fontWeight: 700 }}>{fmtGBP(rowTotal)}</td>
</tr>
)
})}
{/* Cash total row */}
<tr style={{ borderTop: '2px solid var(--card-border)', background: 'var(--body-bg)' }}>
<td style={{ ...td, fontWeight: 700 }}>Total Banked</td>
{result.report_data.map((day, i) => {
const dayTotal = day.reconciliation.reduce((s, r) => s + r.banked_amount, 0)
return <td key={i} style={{ ...td, textAlign: 'right', fontWeight: 700 }}>{fmtGBP(dayTotal)}</td>
})}
<td style={{ ...td, textAlign: 'right', fontWeight: 700 }}>
{fmtGBP(result.report_data.reduce((s, d) => s + d.reconciliation.reduce((ss, r) => ss + r.banked_amount, 0), 0))}
</td>
</tr>
</tbody>
</table>
</Card>
{/* Table 2: Sales Breakdown */}
{salesCols.length > 0 && (
<Card style={{ marginBottom: '1.5rem', overflowX: 'auto' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 700, marginBottom: '1rem' }}>Sales Breakdown (Net)</h2>
<table style={{ borderCollapse: 'collapse', fontSize: '0.8rem', minWidth: '600px' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--card-border)' }}>
<th style={{ ...th, textAlign: 'left' }}>Category</th>
{dates.map(d => <th key={d} style={th}>{fmtDate(d)}</th>)}
<th style={th}>Total</th>
</tr>
</thead>
<tbody>
{salesCols.map(col => {
const values = result.report_data.map(day => {
const sb = day.sales_breakdown.find(s => s.gl_code === col.gl_code)
return sb?.net_amount ?? 0
})
const rowTotal = values.reduce((s, v) => s + v, 0)
return (
<tr key={col.gl_code} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={td}>{col.display_name}</td>
{values.map((v, i) => <td key={i} style={{ ...td, textAlign: 'right' }}>{v ? fmtGBP(v) : '—'}</td>)}
<td style={{ ...td, textAlign: 'right', fontWeight: 700 }}>{fmtGBP(rowTotal)}</td>
</tr>
)
})}
<tr style={{ borderTop: '2px solid var(--card-border)', background: 'var(--body-bg)' }}>
<td style={{ ...td, fontWeight: 700 }}>Total</td>
{result.report_data.map((day, i) => {
const dayTotal = day.sales_breakdown.reduce((s, sb) => s + (sb.net_amount ?? 0), 0)
return <td key={i} style={{ ...td, textAlign: 'right', fontWeight: 700 }}>{fmtGBP(dayTotal)}</td>
})}
<td style={{ ...td, textAlign: 'right', fontWeight: 700 }}>
{fmtGBP(result.report_data.reduce((s, d) => s + d.sales_breakdown.reduce((ss, sb) => ss + (sb.net_amount ?? 0), 0), 0))}
</td>
</tr>
</tbody>
</table>
</Card>
)}
{/* Table 3: Occupancy */}
{result.occupancy_data.length > 0 && (
<Card style={{ marginBottom: '1.5rem', overflowX: 'auto' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 700, marginBottom: '1rem' }}>Occupancy</h2>
<table style={{ borderCollapse: 'collapse', fontSize: '0.8rem', minWidth: '400px' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--card-border)' }}>
<th style={{ ...th, textAlign: 'left' }}>Date</th>
<th style={th}>Rooms Sold</th>
<th style={th}>Total Rooms</th>
<th style={th}>Guests</th>
<th style={th}>Occ %</th>
</tr>
</thead>
<tbody>
{result.occupancy_data.map((row, i) => {
const date = row.period ?? row.date ?? ''
const occ = row.total_rooms && row.rooms_sold ? ((row.rooms_sold / row.total_rooms) * 100).toFixed(1) : '—'
return (
<tr key={i} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={td}>{date ? fmtDate(date) : '—'}</td>
<td style={{ ...td, textAlign: 'right' }}>{row.rooms_sold ?? '—'}</td>
<td style={{ ...td, textAlign: 'right' }}>{row.total_rooms ?? '—'}</td>
<td style={{ ...td, textAlign: 'right' }}>{row.total_people ?? '—'}</td>
<td style={{ ...td, textAlign: 'right' }}>{occ}{occ !== '—' ? '%' : ''}</td>
</tr>
)
})}
</tbody>
</table>
</Card>
)}
</>
)}
</div>
)
}
const th: React.CSSProperties = { padding: '0.5rem 0.625rem', textAlign: 'right', color: 'var(--text-mid)', fontWeight: 600, whiteSpace: 'nowrap' }
const td: React.CSSProperties = { padding: '0.45rem 0.625rem' }

View file

@ -0,0 +1,200 @@
import { useState, useEffect } from 'react'
import { api } from '../api'
import { PageHeader, Card, Btn } from '../components/Layout'
import type { User } from '../types'
interface SettingsData {
default_report_days: string
petty_cash_float: string
sales_breakdown_columns: string
change_tin_breakdown: string
}
interface GlColumn { gl_code: string; display_name: string; enabled: boolean; sort_order: number }
export function SettingsPage({ user }: { user: User }) {
const [settings, setSettings] = useState<Partial<SettingsData>>({})
const [columns, setColumns] = useState<GlColumn[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null)
function flash(text: string, ok = true) {
setMsg({ text, ok })
setTimeout(() => setMsg(null), 5000)
}
useEffect(() => {
api.get<SettingsData>('/settings').then(s => {
setSettings(s)
try { setColumns(JSON.parse(s.sales_breakdown_columns || '[]')) } catch { setColumns([]) }
}).finally(() => setLoading(false))
}, [])
function set(key: keyof SettingsData, value: string) {
setSettings(prev => ({ ...prev, [key]: value }))
}
async function save() {
setSaving(true)
try {
await api.put('/settings', { ...settings, sales_breakdown_columns: JSON.stringify(columns) })
flash('Settings saved.')
} catch (e: unknown) {
flash(e instanceof Error ? e.message : 'Save failed.', false)
} finally {
setSaving(false)
}
}
async function testConnection() {
setTesting(true)
try {
const r = await api.post<{ success: boolean; message: string }>('/settings/test-connection', {})
flash(r.message, r.success)
} catch (e: unknown) {
flash(e instanceof Error ? e.message : 'Test failed.', false)
} finally {
setTesting(false)
}
}
async function refreshGl() {
setRefreshing(true)
try {
const r = await api.post<{ message: string; columns: GlColumn[] }>('/settings/refresh-gl-accounts', {})
setColumns(r.columns)
flash(r.message)
} catch (e: unknown) {
flash(e instanceof Error ? e.message : 'Refresh failed.', false)
} finally {
setRefreshing(false)
}
}
function moveColumn(idx: number, dir: -1 | 1) {
const next = [...columns]
const target = idx + dir
if (target < 0 || target >= next.length) return
;[next[idx], next[target]] = [next[target], next[idx]]
next.forEach((c, i) => (c.sort_order = i + 1))
setColumns(next)
}
if (loading) return <div style={{ padding: '1.5rem', color: 'var(--text-mid)' }}>Loading</div>
return (
<div style={{ padding: '1.5rem', maxWidth: '680px' }}>
<PageHeader title="Settings" />
{msg && (
<div style={{
background: msg.ok ? '#dcfce7' : '#fee2e2', color: msg.ok ? '#16a34a' : 'var(--danger)',
border: `1px solid ${msg.ok ? '#bbf7d0' : '#fecaca'}`,
borderRadius: '6px', padding: '0.625rem 1rem', marginBottom: '1rem', fontSize: '0.875rem',
}}>
{msg.text}
</div>
)}
{/* Newbook — managed centrally */}
<Card style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h2 style={{ fontSize: '1rem', fontWeight: 700 }}>Newbook PMS</h2>
<p style={{ fontSize: '0.8rem', color: 'var(--text-mid)', marginTop: '0.25rem' }}>
Credentials are managed in the{' '}
<a href="/settings" target="_blank" rel="noreferrer"
style={{ color: 'var(--gold)' }}>Settings service</a>.
</p>
</div>
{user.is_admin && (
<Btn onClick={testConnection} disabled={testing} variant="secondary" small>
{testing ? 'Testing…' : 'Test Connection'}
</Btn>
)}
</div>
</Card>
{/* Report defaults */}
<Card style={{ marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 700, marginBottom: '1rem' }}>Report Defaults</h2>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<FieldRow label="Default Report Days" value={settings.default_report_days ?? '7'}
type="number" onChange={v => set('default_report_days', v)} />
</div>
</Card>
{/* Float settings */}
<Card style={{ marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 700, marginBottom: '1rem' }}>Float Settings</h2>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<FieldRow label="Petty Cash Float (£)" value={settings.petty_cash_float ?? '200'}
type="number" onChange={v => set('petty_cash_float', v)} />
</div>
</Card>
{/* Sales breakdown GL columns — admin only */}
{user.is_admin && (
<Card style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 700 }}>Sales Breakdown Columns</h2>
<Btn onClick={refreshGl} disabled={refreshing} small variant="secondary">
{refreshing ? 'Refreshing…' : 'Sync from Newbook'}
</Btn>
</div>
{columns.length === 0 ? (
<p style={{ fontSize: '0.875rem', color: 'var(--text-mid)' }}>
No GL columns configured. Click "Sync from Newbook" to import GL account groups.
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
{columns.map((col, i) => (
<div key={col.gl_code} style={{
display: 'grid', gridTemplateColumns: '24px 1fr 140px 60px', gap: '0.5rem',
alignItems: 'center', padding: '0.5rem', border: '1px solid var(--card-border)',
borderRadius: '6px', background: col.enabled ? 'white' : 'var(--body-bg)',
}}>
<input type="checkbox" checked={col.enabled}
onChange={e => setColumns(cols => cols.map((c, j) => j === i ? { ...c, enabled: e.target.checked } : c))} />
<div>
<span style={{ fontSize: '0.875rem', fontWeight: 600 }}>{col.display_name}</span>
<span style={{ fontSize: '0.75rem', color: 'var(--text-mid)', marginLeft: '0.5rem' }}>{col.gl_code}</span>
</div>
<input type="text" value={col.display_name}
onChange={e => setColumns(cols => cols.map((c, j) => j === i ? { ...c, display_name: e.target.value } : c))}
style={{ ...inpSt, fontSize: '0.8rem', padding: '0.25rem 0.5rem' }} />
<div style={{ display: 'flex', gap: '0.25rem' }}>
<button onClick={() => moveColumn(i, -1)} disabled={i === 0} style={arrowBtn}></button>
<button onClick={() => moveColumn(i, 1)} disabled={i === columns.length - 1} style={arrowBtn}></button>
</div>
</div>
))}
</div>
)}
</Card>
)}
<Btn onClick={save} disabled={saving}>
{saving ? 'Saving…' : 'Save Settings'}
</Btn>
</div>
)
}
function FieldRow({ label, value, onChange, type = 'text' }: {
label: string; value: string; onChange: (v: string) => void; type?: string
}) {
return (
<div>
<label style={labelSt}>{label}</label>
<input type={type} value={value} onChange={e => onChange(e.target.value)} style={inpSt} />
</div>
)
}
const labelSt: React.CSSProperties = { fontSize: '0.75rem', color: 'var(--text-mid)', display: 'block', marginBottom: '0.25rem', fontWeight: 600 }
const inpSt: React.CSSProperties = { border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.45rem 0.6rem', fontSize: '0.875rem', width: '100%' }
const arrowBtn: React.CSSProperties = { background: 'none', border: '1px solid var(--card-border)', borderRadius: '4px', padding: '0.15rem 0.4rem', cursor: 'pointer', fontSize: '0.75rem' }

119
frontend/src/types.ts Normal file
View file

@ -0,0 +1,119 @@
export interface User {
email: string
name: string
is_admin: boolean
}
export interface CashUp {
id: number
session_date: string
created_by: string
created_at: string
updated_at: string
status: 'draft' | 'final'
total_float_counted: string
total_cash_counted: string
notes: string | null
submitted_at: string | null
submitted_by: string | null
}
export interface Denomination {
id?: number
cash_up_id?: number
count_type: 'takings' | 'float'
denomination_type: 'note' | 'coin'
denomination_value: number
quantity: number | null
value_entered: number | null
total_amount: number
}
export interface CardMachine {
id?: number
cash_up_id?: number
machine_name: string
total_amount: number
amex_amount: number
visa_mc_amount: number
}
export interface ReconciliationRow {
category: string
banked_amount: number
reported_amount: number
variance?: number
}
export interface PaymentTotals {
cash: number
manual_visa_mc: number
manual_amex: number
gateway_visa_mc: number
gateway_amex: number
bacs: number
}
export interface TillPayment {
payment_type: string
quantity: number
total_value: number
}
export interface Attachment {
id: number
cash_up_id: number
file_name: string
file_path: string
file_size: number
mime_type: string
uploaded_at: string
}
export interface FloatCount {
id: number
count_type: 'petty_cash' | 'change_tin' | 'safe_cash'
count_date: string
created_by: string
total_counted: string
total_receipts: string
target_amount: string
variance: string
notes: string | null
}
export interface FloatDenomination {
denomination_value: string
quantity: number
total_amount: string
target?: number
}
export interface FloatReceipt {
id: number
receipt_value: string
receipt_description: string
}
export const GBP_DENOMINATIONS: Array<{ value: number; label: string; type: 'note' | 'coin' }> = [
{ value: 50, label: '£50', type: 'note' },
{ value: 20, label: '£20', type: 'note' },
{ value: 10, label: '£10', type: 'note' },
{ value: 5, label: '£5', type: 'note' },
{ value: 2, label: '£2', type: 'coin' },
{ value: 1, label: '£1', type: 'coin' },
{ value: 0.50, label: '50p', type: 'coin' },
{ value: 0.20, label: '20p', type: 'coin' },
{ value: 0.10, label: '10p', type: 'coin' },
{ value: 0.05, label: '5p', type: 'coin' },
{ value: 0.02, label: '2p', type: 'coin' },
{ value: 0.01, label: '1p', type: 'coin' },
]
export function fmtGBP(n: number | string) {
return '£' + parseFloat(String(n || 0)).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
export function today() {
return new Date().toISOString().slice(0, 10)
}

15
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true
},
"include": ["src"]
}

7
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
base: '/cashup/',
plugins: [react()],
})