Wire Newbook credentials to settings service
This commit is contained in:
commit
63a5a72fa3
32 changed files with 3386 additions and 0 deletions
154
backend/src/routes/cashup.js
Normal file
154
backend/src/routes/cashup.js
Normal 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) }
|
||||
})
|
||||
}
|
||||
95
backend/src/routes/floats.js
Normal file
95
backend/src/routes/floats.js
Normal 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 }
|
||||
})
|
||||
}
|
||||
49
backend/src/routes/newbook.js
Normal file
49
backend/src/routes/newbook.js
Normal 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),
|
||||
}
|
||||
})
|
||||
}
|
||||
210
backend/src/routes/reports.js
Normal file
210
backend/src/routes/reports.js
Normal 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 (1–365) 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 },
|
||||
}
|
||||
})
|
||||
}
|
||||
86
backend/src/routes/settings.js
Normal file
86
backend/src/routes/settings.js
Normal 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 }
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue