add transactions and random fixes

This commit is contained in:
Linus Miller 2025-12-04 20:54:23 +01:00
parent fa4563f771
commit e5bbd266e9
18 changed files with 311 additions and 38 deletions

View File

@ -0,0 +1,15 @@
meta {
name: /api/transactions
type: http
seq: 14
}
get {
url: {{base_url}}/api/transactions
body: none
auth: inherit
}
settings {
encodeUrl: true
}

View File

@ -20,7 +20,7 @@ const AccountsPage = () => {
</Head> </Head>
<h1>Accounts</h1> <h1>Accounts</h1>
<table> <table className='grid'>
<thead> <thead>
<th>Number</th> <th>Number</th>
<th>Description</th> <th>Description</th>

View File

@ -1,14 +1,43 @@
import { h } from 'preact' import { h } from 'preact'
import { useCallback, useEffect } from 'preact/hooks'
import { LocationProvider, Route, Router } from 'preact-iso' import { LocationProvider, Route, Router } from 'preact-iso'
import { get } from 'lowline'
import Head from './head.ts' import Head from './head.ts'
import Footer from './footer.tsx' import Footer from './footer.tsx'
import Header from './header.tsx' import Header from './header.tsx'
import ErrorPage from './error_page.tsx' import ErrorPage from './error_page.tsx'
import routes from '../routes.ts' import routes from '../routes.ts'
import throttle from '../../shared/utils/throttle.ts'
import s from './app.module.scss' import s from './app.module.scss'
const remember = throttle(function remember() {
window.history.replaceState(
{
...window.history.state,
scrollTop: window.scrollY,
},
'',
null,
)
}, 100)
export default function App({ error, url, title }) { export default function App({ error, url, title }) {
useEffect(() => {
addEventListener('scroll', remember)
return () => removeEventListener('scroll', remember)
})
const onRouteChange = useCallback(() => {
const offset = get(window.history, 'state.scrollTop')
console.log('offset', offset)
setTimeout(() => {
window.scrollTo(0, offset || 0)
}, 100)
}, [])
return ( return (
<LocationProvider> <LocationProvider>
<div id='app' className={s.base}> <div id='app' className={s.base}>
@ -22,7 +51,7 @@ export default function App({ error, url, title }) {
{error ? ( {error ? (
<ErrorPage error={error} /> <ErrorPage error={error} />
) : ( ) : (
<Router> <Router onRouteChange={onRouteChange}>
{routes.map((route) => ( {routes.map((route) => (
<Route key={route.path} route={route} path={route.path} component={route.component} /> <Route key={route.path} route={route} path={route.path} component={route.component} />
))} ))}

View File

@ -34,7 +34,7 @@ const EntriesPage = () => {
setJournals(journals) setJournals(journals)
}) })
rek('/api/financial-years').then((financialYears) => { rek('/api/financial-years').then((financialYears) => {
setFinancialYears(financialYears.toReversed()) setFinancialYears(financialYears)
}) })
}, []) }, [])
@ -68,7 +68,7 @@ const EntriesPage = () => {
<h2> <h2>
{selectedJournal} : {selectedYear} {selectedJournal} : {selectedYear}
</h2> </h2>
<table> <table className='grid'>
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
@ -85,10 +85,13 @@ const EntriesPage = () => {
<td> <td>
<a href={`/entries/${entry.id}`}>{entry.id}</a> <a href={`/entries/${entry.id}`}>{entry.id}</a>
</td> </td>
<td>{entry.number}</td> <td>
{entry.journal}
{entry.number}
</td>
<td>{entry.entryDate?.slice(0, 10)}</td> <td>{entry.entryDate?.slice(0, 10)}</td>
<td>{entry.transactionDate?.slice(0, 10)}</td> <td>{entry.transactionDate?.slice(0, 10)}</td>
<td>{entry.amount}</td> <td className='tar'>{entry.amount}</td>
<td>{entry.description}</td> <td>{entry.description}</td>
</tr> </tr>
))} ))}

View File

@ -11,10 +11,6 @@ const EntriesPage = () => {
const location = useLocation() const location = useLocation()
const route = useRoute() const route = useRoute()
// console.dir(route)
// console.dir(location)
console.log(entry)
useEffect(() => { useEffect(() => {
rek(`/api/entries/${route.params.id}`).then((entry) => { rek(`/api/entries/${route.params.id}`).then((entry) => {
setEntry(entry) setEntry(entry)
@ -23,8 +19,6 @@ const EntriesPage = () => {
if (!entry) return if (!entry) return
console.log(entry)
return ( return (
<section> <section>
<Head> <Head>
@ -37,7 +31,7 @@ const EntriesPage = () => {
Entry {entry.journal} {entry.number} Entry {entry.journal} {entry.number}
</h1> </h1>
<table> <table className='grid'>
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
@ -58,14 +52,14 @@ const EntriesPage = () => {
<td>{entry.number}</td> <td>{entry.number}</td>
<td>{entry.entryDate?.slice(0, 10)}</td> <td>{entry.entryDate?.slice(0, 10)}</td>
<td>{entry.transactionDate?.slice(0, 10)}</td> <td>{entry.transactionDate?.slice(0, 10)}</td>
<td>{entry.amount}</td> <td className='tar'>{entry.amount}</td>
<td>{entry.description}</td> <td>{entry.description}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<h2>Transactions</h2> <h2>Transactions</h2>
<table> <table className='grid'>
<thead> <thead>
<tr> <tr>
<th>Account</th> <th>Account</th>
@ -78,8 +72,8 @@ const EntriesPage = () => {
{entry?.transactions?.map((transaction) => ( {entry?.transactions?.map((transaction) => (
<tr> <tr>
<td>{transaction.account_number}</td> <td>{transaction.account_number}</td>
<td>{transaction.amount >= 0 ? formatNumber(transaction.amount) : null}</td> <td className='tar'>{transaction.amount >= 0 ? formatNumber(transaction.amount) : null}</td>
<td>{transaction.amount < 0 ? formatNumber(Math.abs(transaction.amount)) : null}</td> <td className='tar'>{transaction.amount < 0 ? formatNumber(Math.abs(transaction.amount)) : null}</td>
<td>{transaction.description}</td> <td>{transaction.description}</td>
</tr> </tr>
))} ))}

View File

@ -0,0 +1,16 @@
@use '../../shared/styles/utils';
.nav {
> ul {
@include utils.wipe-list();
display: flex;
> li {
> a {
display: block;
padding: 5px;
}
}
}
}

View File

@ -1,9 +1,10 @@
import { h } from 'preact' import { h } from 'preact'
import s from './header.module.scss'
const Header = ({ routes }) => ( const Header = ({ routes }) => (
<header> <header>
<h1>BRF Tegeltrasten</h1> <h1>BRF Tegeltrasten</h1>
<nav> <nav className={s.nav}>
<ul> <ul>
{routes.map((route) => {routes.map((route) =>
route.nav !== false ? ( route.nav !== false ? (

View File

@ -14,13 +14,24 @@ const Result: FunctionalComponent<Props> = ({ objectId }) => {
}, [objectId]) }, [objectId])
return ( return (
<table> <table className='grid'>
<thead>
<tr>
<th>Entry</th>
<th>Date</th>
<th>Account</th>
<th>Amount</th>
</tr>
</thead>
<tbody> <tbody>
{transactions.map((transaction) => ( {transactions.map((transaction) => (
<tr> <tr>
<td>
<a href={`/entries/${transaction.entryId}`}>{transaction.entryId}</a>
</td>
<td>{transaction.transactionDate.slice(0, 10)}</td> <td>{transaction.transactionDate.slice(0, 10)}</td>
<td>{transaction.accountNumber}</td> <td>{transaction.accountNumber}</td>
<td>{transaction.amount}</td> <td className='tar'>{transaction.amount}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@ -1,16 +1,9 @@
.table { .table {
td:nth-child(3), a {
td:nth-child(3) ~ td { text-decoration: none;
text-align: right; color: black;
} &:hover {
font-weight: bold;
tr:hover { }
background: #eee;
}
td,
th {
border: 1px solid #ccc;
padding: 3px 5px;
} }
} }

View File

@ -1,5 +1,6 @@
import { h } from 'preact' import { h } from 'preact'
import { useEffect, useState } from 'preact/hooks' import { useEffect, useState } from 'preact/hooks'
import cn from 'classnames'
import rek from 'rek' import rek from 'rek'
import Head from './head.ts' import Head from './head.ts'
import Result from './result.tsx' import Result from './result.tsx'
@ -13,7 +14,7 @@ const ResultsPage = () => {
useEffect(() => { useEffect(() => {
rek(`/api/results`).then(setResults) rek(`/api/results`).then(setResults)
rek(`/api/financial-years`).then((years) => setYears(years.map((fy) => fy.year).toReversed())) rek(`/api/financial-years`).then((years) => setYears(years.map((fy) => fy.year)))
}, []) }, [])
return ( return (
@ -24,7 +25,7 @@ const ResultsPage = () => {
<h1>Results</h1> <h1>Results</h1>
{years.length && results.length && ( {years.length && results.length && (
<table className={s.table}> <table className={cn('grid', s.table)}>
<thead> <thead>
<tr> <tr>
<th>Account</th> <th>Account</th>
@ -40,7 +41,11 @@ const ResultsPage = () => {
<td>{result.accountNumber}</td> <td>{result.accountNumber}</td>
<td>{result.description}</td> <td>{result.description}</td>
{years.map((year) => ( {years.map((year) => (
<td>{formatNumber(result[year])}</td> <td className='tar'>
<a href={`/transactions?year=${year}&accountNumber=${result.accountNumber}`}>
{formatNumber(result[year])}
</a>
</td>
))} ))}
</tr> </tr>
))} ))}

View File

@ -0,0 +1,96 @@
import { h } from 'preact'
import { useCallback, useEffect, useState } from 'preact/hooks'
import { useLocation } from 'preact-iso'
import { isEmpty } from 'lowline'
import qs from 'mini-qs'
import rek from 'rek'
import Head from './head.ts'
import serializeForm from '../../shared/utils/serialize_form.ts'
import { formatNumber } from '../utils/format_number.ts'
const TransactionsPage = () => {
const [financialYears, setFinancialYears] = useState([])
const [transactions, setTransactions] = useState([])
const location = useLocation()
console.log(location)
const onSubmit = useCallback((e: SubmitEvent) => {
e.preventDefault()
const values = serializeForm(e.target as HTMLFormElement)
const search = !isEmpty(values) ? '?' + qs.stringify(values) : ''
location.route(`/transactions${search}`)
}, [])
useEffect(() => {
rek('/api/financial-years').then((financialYears) => {
setFinancialYears(financialYears)
})
}, [])
useEffect(() => {
const search = location.url.split('?')[1] || ''
rek(`/api/transactions${search ? '?' + search : ''}`).then((transactions) => {
setTransactions(transactions)
})
}, [location.url])
return (
<section>
<Head>
<title> : Transactions</title>
</Head>
<form onSubmit={onSubmit}>
<select name='year' defaultValue={location.query.year || '2025'}>
{financialYears.map((financialYear) => (
<option value={financialYear.year}>{financialYear.year}</option>
))}
</select>
<input type='text' name='accountNumber' defaultValue={location.query.accountNumber || ''} />
<button>Search</button>
</form>
<h1>Transactions</h1>
<div>
<table className='grid'>
<thead>
<th>Date</th>
<th>Account</th>
<th>Debit</th>
<th>Credit</th>
<th>Description</th>
<th>Entry</th>
<th>Entry Description</th>
<th>Invoice</th>
</thead>
<tbody>
{transactions.map((transaction) => (
<tr>
<td>{transaction.transactionDate.slice(0, 10)}</td>
<td>{transaction.accountNumber}</td>
<td className='tar'>{transaction.amount >= 0 ? formatNumber(transaction.amount) : null}</td>
<td className='tar'>{transaction.amount < 0 ? formatNumber(Math.abs(transaction.amount)) : null}</td>
<td>{transaction.description}</td>
<td>
<a href={`/entries/${transaction.entryId}`}>{transaction.entryId}</a>
</td>
<td>{transaction.entryDescription}</td>
<td>
<a href={`/invoices/${transaction.invoiceId}`}>{transaction.invoiceId}</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)
}
export default TransactionsPage

View File

@ -7,6 +7,7 @@ import InvoicesBySupplier from './components/invoices_by_supplier_page.tsx'
import Objects from './components/objects_page.tsx' import Objects from './components/objects_page.tsx'
import Results from './components/results_page.tsx' import Results from './components/results_page.tsx'
import Start from './components/start_page.tsx' import Start from './components/start_page.tsx'
import Transactions from './components/transactions_page.tsx'
export default [ export default [
{ {
@ -66,4 +67,10 @@ export default [
title: 'Results', title: 'Results',
component: Results, component: Results,
}, },
{
path: '/transactions',
name: 'transactions',
title: 'Transactions',
component: Transactions,
},
] ]

View File

@ -7,4 +7,20 @@
table { table {
border-collapse: collapse; border-collapse: collapse;
border-spacing: 0; border-spacing: 0;
&.grid {
tr:hover {
background: #eee;
}
td,
th {
border: 1px solid #ccc;
padding: 3px 5px;
}
}
}
.tar {
text-align: right;
} }

View File

@ -0,0 +1,24 @@
// copied and modified from https://github.com/sindresorhus/throttleit/blob/ed1d22c70a964ef0299d0400dbfd1fbedef56a59/index.js
export default function throttle(fnc, wait) {
let timeout
let lastCallTime = 0
return function throttled(...args) {
clearTimeout(timeout)
const now = Date.now()
const timeSinceLastCall = now - lastCallTime
const delayForNextCall = wait - timeSinceLastCall
if (delayForNextCall <= 0) {
lastCallTime = now
fnc(...args)
} else {
timeout = setTimeout(() => {
lastCallTime = Date.now()
fnc(...args)
}, delayForNextCall)
}
}
}

View File

@ -10,6 +10,7 @@ import journals from './api/journals.ts'
import objects from './api/objects.ts' import objects from './api/objects.ts'
import results from './api/results.ts' import results from './api/results.ts'
import suppliers from './api/suppliers.ts' import suppliers from './api/suppliers.ts'
import transactions from './api/transactions.ts'
export const FinancialYear = Type.Object({ export const FinancialYear = Type.Object({
year: Type.Number(), year: Type.Number(),
@ -28,6 +29,7 @@ const apiRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => {
fastify.register(objects, { prefix: '/objects' }) fastify.register(objects, { prefix: '/objects' })
fastify.register(results, { prefix: '/results' }) fastify.register(results, { prefix: '/results' })
fastify.register(suppliers, { prefix: '/suppliers' }) fastify.register(suppliers, { prefix: '/suppliers' })
fastify.register(transactions, { prefix: '/transactions' })
done() done()
} }

View File

@ -7,7 +7,7 @@ const financialYearRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) =>
url: '/', url: '/',
method: 'GET', method: 'GET',
handler() { handler() {
return knex('financialYear').select('*') return knex('financialYear').select('*').orderBy('startDate', 'desc')
}, },
}) })

View File

@ -25,7 +25,7 @@ const objectRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => {
}, },
async handler(req) { async handler(req) {
return knex('transaction AS t') return knex('transaction AS t')
.select('e.transactionDate', 't.accountNumber', 't.amount') .select('t.entryId', 'e.transactionDate', 't.accountNumber', 't.amount')
.innerJoin('transactions_to_objects AS to', function () { .innerJoin('transactions_to_objects AS to', function () {
this.on('t.id', 'to.transactionId') this.on('t.id', 'to.transactionId')
}) })

View File

@ -0,0 +1,61 @@
import _ from 'lodash'
import { Type, type Static, type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox'
import knex from '../../lib/knex.ts'
import StatusError from '../../lib/status_error.ts'
const transactionRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => {
fastify.route({
url: '/',
method: 'GET',
schema: {
querystring: Type.Object({
year: Type.Optional(Type.Number()),
accountNumber: Type.Optional(Type.Number()),
}),
},
async handler(req) {
const query: { financialYearId?: number; accountNumber?: number } = {}
if (req.query.year) {
const year = await knex('financialYear').first('*').where('year', req.query.year)
if (!year) throw new StatusError(404, `Year ${req.query.year} not found.`)
query.financialYearId = year.id
}
if (req.query.accountNumber) {
query.accountNumber = req.query.accountNumber
}
return knex('transaction AS t')
.select(
't.accountNumber',
'e.transactionDate',
't.entryId',
't.amount',
't.description',
't.invoiceId',
'e.description AS entryDescription',
)
.innerJoin('entry AS e', 't.entry_id', 'e.id')
.where(query)
},
})
fastify.route({
url: '/:id',
method: 'GET',
schema: {
params: Type.Object({
id: Type.Number(),
}),
},
async handler(req) {
return knex('transaction').first('*').where('id', req.params.id)
},
})
done()
}
export default transactionRoutes