Wire Newbook credentials to settings service
This commit is contained in:
commit
63a5a72fa3
32 changed files with 3386 additions and 0 deletions
37
frontend/src/App.tsx
Normal file
37
frontend/src/App.tsx
Normal 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
37
frontend/src/api.ts
Normal 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()
|
||||
}
|
||||
99
frontend/src/components/AuthGate.tsx
Normal file
99
frontend/src/components/AuthGate.tsx
Normal 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!)}</>
|
||||
}
|
||||
154
frontend/src/components/Layout.tsx
Normal file
154
frontend/src/components/Layout.tsx
Normal 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
36
frontend/src/index.css
Normal 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
10
frontend/src/main.tsx
Normal 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>
|
||||
)
|
||||
99
frontend/src/pages/CashSummary.tsx
Normal file
99
frontend/src/pages/CashSummary.tsx
Normal 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)' }
|
||||
455
frontend/src/pages/DailyCashUp.tsx
Normal file
455
frontend/src/pages/DailyCashUp.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
321
frontend/src/pages/FloatManagement.tsx
Normal file
321
frontend/src/pages/FloatManagement.tsx
Normal 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' }
|
||||
182
frontend/src/pages/History.tsx
Normal file
182
frontend/src/pages/History.tsx
Normal 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' }
|
||||
207
frontend/src/pages/MultiDayReport.tsx
Normal file
207
frontend/src/pages/MultiDayReport.tsx
Normal 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' }
|
||||
200
frontend/src/pages/Settings.tsx
Normal file
200
frontend/src/pages/Settings.tsx
Normal 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
119
frontend/src/types.ts
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue