From ac61674b69ef970b7a3dea558bde755c42a86371 Mon Sep 17 00:00:00 2001 From: Linus Miller Date: Wed, 26 Nov 2025 14:22:27 +0100 Subject: [PATCH] invoices --- .bruno/BRF/Invoice.bru | 15 + .bruno/BRF/Invoices.bru | 15 + .bruno/BRF/api-suppliers.bru | 15 + .gitignore | 2 + bin/add_fisken_invoices.ts | 64 +++++ bin/add_phm_invoices.ts | 73 +++++ bin/parse_file.ts | 2 + client/public/components/app.tsx | 41 +-- client/public/components/header.tsx | 12 +- client/public/components/invoice.tsx | 31 +++ client/public/components/invoices.tsx | 0 .../components/invoices_by_supplier_page.tsx | 65 +++++ client/public/components/invoices_page.tsx | 39 +++ client/public/routes.ts | 15 + docker-compose.yml | 1 + docker/postgres/01-schema.sql | 262 +++++++++++++++++- docker/postgres/02-data.sql | 44 +++ package.json | 6 +- pnpm-lock.yaml | 58 +++- server/routes/api.ts | 118 ++++++++ server/server.ts | 6 +- tsconfig.json | 2 +- 22 files changed, 847 insertions(+), 39 deletions(-) create mode 100644 .bruno/BRF/Invoice.bru create mode 100644 .bruno/BRF/Invoices.bru create mode 100644 .bruno/BRF/api-suppliers.bru create mode 100644 bin/add_fisken_invoices.ts create mode 100644 bin/add_phm_invoices.ts create mode 100644 client/public/components/invoice.tsx create mode 100644 client/public/components/invoices.tsx create mode 100644 client/public/components/invoices_by_supplier_page.tsx create mode 100644 client/public/components/invoices_page.tsx diff --git a/.bruno/BRF/Invoice.bru b/.bruno/BRF/Invoice.bru new file mode 100644 index 0000000..773da17 --- /dev/null +++ b/.bruno/BRF/Invoice.bru @@ -0,0 +1,15 @@ +meta { + name: Invoice + type: http + seq: 7 +} + +get { + url: {{base_url}}/ + body: none + auth: inherit +} + +settings { + encodeUrl: true +} diff --git a/.bruno/BRF/Invoices.bru b/.bruno/BRF/Invoices.bru new file mode 100644 index 0000000..9b485d4 --- /dev/null +++ b/.bruno/BRF/Invoices.bru @@ -0,0 +1,15 @@ +meta { + name: Invoices + type: http + seq: 6 +} + +get { + url: {{base_url}}/api/invoices + body: none + auth: inherit +} + +settings { + encodeUrl: true +} diff --git a/.bruno/BRF/api-suppliers.bru b/.bruno/BRF/api-suppliers.bru new file mode 100644 index 0000000..19ce449 --- /dev/null +++ b/.bruno/BRF/api-suppliers.bru @@ -0,0 +1,15 @@ +meta { + name: /api/suppliers + type: http + seq: 8 +} + +get { + url: {{base_url}}/ + body: none + auth: inherit +} + +settings { + encodeUrl: true +} diff --git a/.gitignore b/.gitignore index 1f85697..5fee0be 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ /dump /sie +/invoices +/uploads # Logs logs diff --git a/bin/add_fisken_invoices.ts b/bin/add_fisken_invoices.ts new file mode 100644 index 0000000..104aa95 --- /dev/null +++ b/bin/add_fisken_invoices.ts @@ -0,0 +1,64 @@ +import fs from 'node:fs/promises' +import { existsSync } from 'node:fs' +import path from 'node:path' +import knex from '../server/lib/knex.ts' + +const dirs = process.argv.slice(2) + +// 172-972, 2020-01-08, Great Security Sverige AB.pdf' +const rFileName = /^172-(\d+),\s(\d{4,4}-\d{2,2}-\d{2,2}), (.*)\.pdf$/ + +for await (const dir of dirs) { + await readdir(dir) +} + +knex.destroy() + +async function readdir(dir: string) { + const files = (await fs.readdir(dir)).toSorted((a: string, b: string) => { + const [, aNum] = a.match(rFileName) + const [, bNum] = b.match(rFileName) + + if (parseInt(aNum) > parseInt(bNum)) { + return 1 + } else { + return -1 + } + }) + + const trx = await knex.transaction() + + for await (const originalFilename of files) { + const result = originalFilename.match(rFileName) + + if (!result) { + throw new Error(originalFilename) + } + + const [, fiskenNumber, invoiceDate, supplierName] = result + + let supplier = await trx('supplier').first('*').where('name', supplierName) + + if (!supplier) { + supplier = (await trx('supplier').insert({ name: supplierName, supplierTypeId: 1 }).returning('*'))[0] + } + + const invoice = ( + await trx('invoice').insert({ fiskenNumber, invoiceDate, supplierId: supplier.id }).returning('*') + )[0] + + const ext = path.extname(originalFilename) + const filename = `${invoiceDate}_fisken_${fiskenNumber}_${supplierName.split(/[\s/]/).join('_').split(/[']/).join('')}${ext}` + const pathname = path.join('uploads', 'invoices', filename) + + if (!existsSync(pathname)) { + console.info(filename) + await fs.copyFile(path.join(dir, originalFilename), pathname) + } + + const file = (await trx('file').insert({ filename }).returning('id'))[0] + await trx('filesToInvoice').insert({ fileId: file.id, invoiceId: invoice.id }) + } + + await trx.commit() +} diff --git a/bin/add_phm_invoices.ts b/bin/add_phm_invoices.ts new file mode 100644 index 0000000..8bd4967 --- /dev/null +++ b/bin/add_phm_invoices.ts @@ -0,0 +1,73 @@ +import fs from 'node:fs/promises' +import { existsSync } from 'node:fs' +import path from 'node:path' +import knex from '../server/lib/knex.ts' +import split from '../server/lib/split.ts' +import { csvParseRows } from 'd3-dsv' + +const csvFilename = process.argv[2] +const csvString = await fs.readFile(csvFilename, { encoding: 'utf8' }) +const rows = csvParseRows(csvString) + +const trx = await knex.transaction() + +for (const row of rows.toReversed()) { + const [ + phmNumber, + type, + supplierId, + supplierName, + invoiceDate, + dueDate, + invoiceNumber, + ocr, + amount, + vat, + balance, + currency, + status, + filesString, + ] = row + + let supplier = await trx('supplier').first('*').where('name', supplierName) + + if (!supplier) { + supplier = (await trx('supplier').insert({ name: supplierName, supplierTypeId: 1 }).returning('*'))[0] + } + + const invoice = ( + await trx('invoice') + .insert({ + invoiceDate, + supplierId: supplier.id, + dueDate, + ocr, + invoiceNumber, + phmNumber, + amount, + }) + .returning('id') + )[0] + + const filenames = filesString.split(',').map((filename) => filename.trim()) + + // TODO handle names if multiple files with same extension (otherwise they will have the same name) + for (const originalFilename of filenames) { + const ext = path.extname(originalFilename) + const filename = `${invoiceDate}_phm_${phmNumber}_${supplierName.split(/[\s/]/).join('_').split(/[']/).join('')}${ext}` + const pathname = path.join('uploads', 'invoices', filename) + + if (!existsSync(pathname)) { + console.info(filename) + await fs.copyFile(path.join('invoices', 'phm', originalFilename), pathname) + } + + const file = (await trx('file').insert({ filename }).returning('id'))[0] + + trx('filesToInvoice').insert({ fileId: file.id, invoiceId: invoice.id }) + } +} + +trx.commit() + +knex.destroy() diff --git a/bin/parse_file.ts b/bin/parse_file.ts index 8a03885..8a03db6 100644 --- a/bin/parse_file.ts +++ b/bin/parse_file.ts @@ -8,6 +8,8 @@ for await (const file of process.argv.slice(2)) { console.log(`- parsing file: ${file}`) await parseStream(fh.readableWebStream()) + + await fh.close() } knex.destroy() diff --git a/client/public/components/app.tsx b/client/public/components/app.tsx index 8c121f6..76f6858 100644 --- a/client/public/components/app.tsx +++ b/client/public/components/app.tsx @@ -1,5 +1,5 @@ import { h } from 'preact' -import { Router } from 'preact-router' +import { LocationProvider, Route, Router } from 'preact-iso' import Head from './head.ts' import Footer from './footer.tsx' import Header from './header.tsx' @@ -10,27 +10,28 @@ import s from './app.module.scss' export default function App({ error, url, title }) { return ( -
- - {title || 'Untitled'} - + +
+ + {title || 'Untitled'} + -
+
-
- {error ? ( - - ) : ( - - {routes.map((route) => ( - // @ts-ignore - - ))} - - )} -
+
+ {error ? ( + + ) : ( + + {routes.map((route) => ( + + ))} + + )} +
-
-
+
+
+ ) } diff --git a/client/public/components/header.tsx b/client/public/components/header.tsx index 61280b9..cf71110 100644 --- a/client/public/components/header.tsx +++ b/client/public/components/header.tsx @@ -5,11 +5,13 @@ const Header = ({ routes }) => (

BRF Tegeltrasten

diff --git a/client/public/components/invoice.tsx b/client/public/components/invoice.tsx new file mode 100644 index 0000000..c512754 --- /dev/null +++ b/client/public/components/invoice.tsx @@ -0,0 +1,31 @@ +import { h, type FunctionalComponent } from 'preact' +import { useEffect, useState } from 'preact/hooks' +import rek from 'rek' + +interface Props { + year: number +} + +const Invoices: FunctionalComponent = ({ year }) => { + const [invoices, setInvoices] = useState([]) + + useEffect(() => { + rek(`/api/invoices/by-year/${year}`).then(setInvoices) + }, [year]) + + return ( + + + {invoices.map((invoice) => ( + + + + + + ))} + +
{invoice.accountNumber}{invoice.description}{invoice.amount}
+ ) +} + +export default Invoices diff --git a/client/public/components/invoices.tsx b/client/public/components/invoices.tsx new file mode 100644 index 0000000..e69de29 diff --git a/client/public/components/invoices_by_supplier_page.tsx b/client/public/components/invoices_by_supplier_page.tsx new file mode 100644 index 0000000..8ff9a8e --- /dev/null +++ b/client/public/components/invoices_by_supplier_page.tsx @@ -0,0 +1,65 @@ +import { h } from 'preact' +import Format from 'easy-tz/format' +import { useEffect, useState } from 'preact/hooks' +import { useRoute } from 'preact-iso' +import rek from 'rek' +import Head from './head.ts' + +const format = Format.bind(null, null, 'YYYY.MM.DD') + +const InvoicesPage = () => { + const [supplier, setSupplier] = useState(null) + const [invoices, setInvoices] = useState([]) + + const route = useRoute() + + useEffect(() => { + rek(`/api/suppliers/${route.params.supplier}`).then((supplier) => setSupplier(supplier)) + rek(`/api/invoices?supplier=${route.params.supplier}`).then((invoices) => setInvoices(invoices)) + }, [route.params.supplier]) + + return ( +
+ + : Invoices : {supplier?.name} + + +

Invoices for {supplier?.name}

+ + + + + + + + + + + + + + + + {invoices.map((invoice) => ( + + + + + + + + + + + ))} + +
IDFiskenPHMInvoice DateDue DateNumberAmountFiles
{invoice.id}{invoice.fiskenNumber}{invoice.phmNumber}{format(invoice.invoiceDate)}{invoice.dueDate && format(invoice.dueDate)}{invoice.invoiceNumber}{invoice.amount} + {invoice.files?.map((file) => ( + {file.filename} + ))} +
+
+ ) +} + +export default InvoicesPage diff --git a/client/public/components/invoices_page.tsx b/client/public/components/invoices_page.tsx new file mode 100644 index 0000000..2b4f64f --- /dev/null +++ b/client/public/components/invoices_page.tsx @@ -0,0 +1,39 @@ +import { h } from 'preact' +import { useEffect, useState } from 'preact/hooks' +import rek from 'rek' +import Head from './head.ts' +import Invoice from './invoice.tsx' + +const InvoicesPage = () => { + const [suppliers, setSuppliers] = useState([]) + const [financialYears, setFinancialYears] = useState([]) + const [currentYear, setCurrentYear] = useState(null) + + useEffect(() => { + rek('/api/suppliers').then((suppliers) => setSuppliers(suppliers)) + // rek('/api/financial-years').then((financialYears) => { + // setFinancialYears(financialYears) + // setCurrentYear(financialYears[financialYears.length - 1].year) + // }) + }, []) + console.log(suppliers) + + return ( +
+ + : Invoices + + +

Invoices

+ +
+ ) +} + +export default InvoicesPage diff --git a/client/public/routes.ts b/client/public/routes.ts index 05c78c3..6067436 100644 --- a/client/public/routes.ts +++ b/client/public/routes.ts @@ -1,4 +1,6 @@ import Start from './components/start_page.tsx' +import Invoices from './components/invoices_page.tsx' +import InvoicesBySupplier from './components/invoices_by_supplier_page.tsx' import Objects from './components/objects_page.tsx' import Results from './components/results_page.tsx' @@ -15,6 +17,19 @@ export default [ title: 'Objects', component: Objects, }, + { + path: '/invoices', + name: 'invoices', + title: 'Invoices', + component: Invoices, + }, + { + path: '/invoices/by-supplier/:supplier', + name: 'invoices', + title: 'Invoices', + nav: false, + component: InvoicesBySupplier, + }, { path: '/results', name: 'results', diff --git a/docker-compose.yml b/docker-compose.yml index 294b1e6..526cea1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,7 @@ services: volumes: - ./client:/home/node/brf_books/client - ./server:/home/node/brf_books/server + - ./uploads:/home/node/brf_books/uploads depends_on: - postgres - redis diff --git a/docker/postgres/01-schema.sql b/docker/postgres/01-schema.sql index b7d43f7..65833dc 100644 --- a/docker/postgres/01-schema.sql +++ b/docker/postgres/01-schema.sql @@ -2,7 +2,7 @@ -- PostgreSQL database dump -- -\restrict H2xBPQq6I6IQHZkAgiuKoY9lao4r4KAPtiyNvDE9oc0DN75cJ1gNbkoGFqutWDp +\restrict x1LNwo1MrXgJU7KVXELaKlpEPpIZLjRGuaweMIify4ofZcwqTGzXVX5DkRI11Hx -- Dumped from database version 18.1 -- Dumped by pg_dump version 18.1 @@ -156,6 +156,46 @@ CREATE SEQUENCE public.entry_id_seq ALTER SEQUENCE public.entry_id_seq OWNED BY public.entry.id; +-- +-- Name: file; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.file ( + id integer NOT NULL, + filename text CONSTRAINT file_file_not_null NOT NULL +); + + +-- +-- Name: file_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.file_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: file_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.file_id_seq OWNED BY public.file.id; + + +-- +-- Name: files_to_invoice; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.files_to_invoice ( + invoice_id integer NOT NULL, + file_id integer NOT NULL +); + + -- -- Name: financial_year; Type: TABLE; Schema: public; Owner: - -- @@ -188,6 +228,44 @@ CREATE SEQUENCE public.financial_year_id_seq ALTER SEQUENCE public.financial_year_id_seq OWNED BY public.financial_year.id; +-- +-- Name: invoice; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.invoice ( + id integer NOT NULL, + financial_year_id integer, + supplier_id integer NOT NULL, + fisken_number integer, + phm_number integer, + invoice_number text, + invoice_date date CONSTRAINT invoice_date_not_null NOT NULL, + due_date date, + ocr text, + amount numeric(12,2) +); + + +-- +-- Name: invoice_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.invoice_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: invoice_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.invoice_id_seq OWNED BY public.invoice.id; + + -- -- Name: journal; Type: TABLE; Schema: public; Owner: - -- @@ -251,6 +329,67 @@ CREATE SEQUENCE public.object_id_seq ALTER SEQUENCE public.object_id_seq OWNED BY public.object.id; +-- +-- Name: supplier; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.supplier ( + id integer NOT NULL, + name text, + supplier_type_id integer NOT NULL +); + + +-- +-- Name: supplier_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.supplier_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: supplier_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.supplier_id_seq OWNED BY public.supplier.id; + + +-- +-- Name: supplier_type; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.supplier_type ( + id integer NOT NULL, + name text NOT NULL +); + + +-- +-- Name: supplier_type_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.supplier_type_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: supplier_type_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.supplier_type_id_seq OWNED BY public.supplier_type.id; + + -- -- Name: transaction; Type: TABLE; Schema: public; Owner: - -- @@ -264,7 +403,8 @@ CREATE TABLE public.transaction ( description text, transaction_date date, quantity numeric(12,2), - signature text + signature text, + invoice_id integer ); @@ -319,6 +459,13 @@ ALTER TABLE ONLY public.dimension ALTER COLUMN id SET DEFAULT nextval('public.di ALTER TABLE ONLY public.entry ALTER COLUMN id SET DEFAULT nextval('public.entry_id_seq'::regclass); +-- +-- Name: file id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.file ALTER COLUMN id SET DEFAULT nextval('public.file_id_seq'::regclass); + + -- -- Name: financial_year id; Type: DEFAULT; Schema: public; Owner: - -- @@ -326,6 +473,13 @@ ALTER TABLE ONLY public.entry ALTER COLUMN id SET DEFAULT nextval('public.entry_ ALTER TABLE ONLY public.financial_year ALTER COLUMN id SET DEFAULT nextval('public.financial_year_id_seq'::regclass); +-- +-- Name: invoice id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoice ALTER COLUMN id SET DEFAULT nextval('public.invoice_id_seq'::regclass); + + -- -- Name: journal id; Type: DEFAULT; Schema: public; Owner: - -- @@ -340,6 +494,20 @@ ALTER TABLE ONLY public.journal ALTER COLUMN id SET DEFAULT nextval('public.jour ALTER TABLE ONLY public.object ALTER COLUMN id SET DEFAULT nextval('public.object_id_seq'::regclass); +-- +-- Name: supplier id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.supplier ALTER COLUMN id SET DEFAULT nextval('public.supplier_id_seq'::regclass); + + +-- +-- Name: supplier_type id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.supplier_type ALTER COLUMN id SET DEFAULT nextval('public.supplier_type_id_seq'::regclass); + + -- -- Name: transaction id; Type: DEFAULT; Schema: public; Owner: - -- @@ -387,6 +555,22 @@ ALTER TABLE ONLY public.entry ADD CONSTRAINT entry_pkey PRIMARY KEY (id); +-- +-- Name: file file_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.file + ADD CONSTRAINT file_pkey PRIMARY KEY (id); + + +-- +-- Name: files_to_invoice files_to_invoice_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.files_to_invoice + ADD CONSTRAINT files_to_invoice_pkey PRIMARY KEY (invoice_id, file_id); + + -- -- Name: financial_year financial_year_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -411,6 +595,14 @@ ALTER TABLE ONLY public.financial_year ADD CONSTRAINT financial_year_year_key UNIQUE (year); +-- +-- Name: invoice invoice_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoice + ADD CONSTRAINT invoice_pkey PRIMARY KEY (id); + + -- -- Name: journal journal_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -435,6 +627,22 @@ ALTER TABLE ONLY public.object ADD CONSTRAINT object_pkey PRIMARY KEY (id); +-- +-- Name: supplier supplier_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.supplier + ADD CONSTRAINT supplier_pkey PRIMARY KEY (id); + + +-- +-- Name: supplier_type supplier_type_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.supplier_type + ADD CONSTRAINT supplier_type_pkey PRIMARY KEY (id); + + -- -- Name: transaction transaction_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -451,6 +659,38 @@ ALTER TABLE ONLY public.transactions_to_objects ADD CONSTRAINT transactions_to_objects_transaction_id_object_id_key UNIQUE (transaction_id, object_id); +-- +-- Name: files_to_invoice files_to_invoice_file_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.files_to_invoice + ADD CONSTRAINT files_to_invoice_file_id_fkey FOREIGN KEY (file_id) REFERENCES public.file(id); + + +-- +-- Name: files_to_invoice files_to_invoice_invoice_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.files_to_invoice + ADD CONSTRAINT files_to_invoice_invoice_id_fkey FOREIGN KEY (invoice_id) REFERENCES public.invoice(id); + + +-- +-- Name: invoice invoice_financial_year_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoice + ADD CONSTRAINT invoice_financial_year_id_fkey FOREIGN KEY (financial_year_id) REFERENCES public.financial_year(id); + + +-- +-- Name: invoice invoice_supplier_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoice + ADD CONSTRAINT invoice_supplier_id_fkey FOREIGN KEY (supplier_id) REFERENCES public.supplier(id); + + -- -- Name: object object_dimension_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -459,6 +699,14 @@ ALTER TABLE ONLY public.object ADD CONSTRAINT object_dimension_id_fkey FOREIGN KEY (dimension_id) REFERENCES public.dimension(id); +-- +-- Name: supplier supplier_supplier_type_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.supplier + ADD CONSTRAINT supplier_supplier_type_id_fkey FOREIGN KEY (supplier_type_id) REFERENCES public.supplier_type(id); + + -- -- Name: transaction transaction_entry_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -467,6 +715,14 @@ ALTER TABLE ONLY public.transaction ADD CONSTRAINT transaction_entry_id_fkey FOREIGN KEY (entry_id) REFERENCES public.entry(id); +-- +-- Name: transaction transaction_invoice_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.transaction + ADD CONSTRAINT transaction_invoice_id_fkey FOREIGN KEY (invoice_id) REFERENCES public.invoice(id); + + -- -- Name: transaction transaction_object_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -495,5 +751,5 @@ ALTER TABLE ONLY public.transactions_to_objects -- PostgreSQL database dump complete -- -\unrestrict H2xBPQq6I6IQHZkAgiuKoY9lao4r4KAPtiyNvDE9oc0DN75cJ1gNbkoGFqutWDp +\unrestrict x1LNwo1MrXgJU7KVXELaKlpEPpIZLjRGuaweMIify4ofZcwqTGzXVX5DkRI11Hx diff --git a/docker/postgres/02-data.sql b/docker/postgres/02-data.sql index e69de29..9e9f1e5 100644 --- a/docker/postgres/02-data.sql +++ b/docker/postgres/02-data.sql @@ -0,0 +1,44 @@ +-- +-- PostgreSQL database dump +-- + +\restrict bwzPcLzyW22uigCF5UMc3FAcSU9hliUs5ZBJ4ZpwZeegYAG8Md8d7l0M55Czl7h + +-- Dumped from database version 18.1 +-- Dumped by pg_dump version 18.1 + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET transaction_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +-- +-- Data for Name: supplier_type; Type: TABLE DATA; Schema: public; Owner: - +-- + +COPY public.supplier_type (id, name) FROM stdin; +1 Company +2 Person +\. + + +-- +-- Name: supplier_type_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - +-- + +SELECT pg_catalog.setval('public.supplier_type_id_seq', 2, true); + + +-- +-- PostgreSQL database dump complete +-- + +\unrestrict bwzPcLzyW22uigCF5UMc3FAcSU9hliUs5ZBJ4ZpwZeegYAG8Md8d7l0M55Czl7h + diff --git a/package.json b/package.json index ec89ecb..b922284 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test": "pnpm run test:client && pnpm run test:server", "test:client": "node --no-warnings --import=./client/test/jsdom_polyfills.ts --import=./client/test/register_tsx_hook.ts --test ./client/**/*.test.ts{,x}", "test:server": "node --no-warnings --test ./server/**/*.test.ts", - "types": "tsgo" + "types": "tsgo --skipLibCheck" }, "dependencies": { "@bmp/console": "^0.1.0", @@ -29,6 +29,7 @@ "@fastify/static": "^8.3.0", "@fastify/type-provider-typebox": "^6.1.0", "chalk": "^5.6.2", + "easy-tz": "^0.2.0", "fastify": "^5.6.2", "fastify-plugin": "^5.1.0", "knex": "^3.1.0", @@ -38,7 +39,7 @@ "pg-protocol": "^1.10.3", "pino-abstract-transport": "^3.0.0", "preact": "^10.27.2", - "preact-router": "^4.1.2", + "preact-iso": "^2.11.0", "rek": "^0.8.1" }, "devDependencies": { @@ -48,6 +49,7 @@ "@types/lodash": "^4.17.16", "@types/node": "^24.10.1", "@typescript/native-preview": "7.0.0-dev.20251126.1", + "d3-dsv": "^3.0.1", "esbuild": "^0.27.0", "globals": "^16.0.0", "husky": "^9.1.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a19a0a..8331666 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: chalk: specifier: ^5.6.2 version: 5.6.2 + easy-tz: + specifier: ^0.2.0 + version: 0.2.0 fastify: specifier: ^5.6.2 version: 5.6.2 @@ -53,9 +56,9 @@ importers: preact: specifier: ^10.27.2 version: 10.27.2 - preact-router: - specifier: ^4.1.2 - version: 4.1.2(preact@10.27.2) + preact-iso: + specifier: ^2.11.0 + version: 2.11.0(preact-render-to-string@6.6.3(preact@10.27.2))(preact@10.27.2) rek: specifier: ^0.8.1 version: 0.8.1 @@ -78,6 +81,9 @@ importers: '@typescript/native-preview': specifier: 7.0.0-dev.20251126.1 version: 7.0.0-dev.20251126.1 + d3-dsv: + specifier: ^3.0.1 + version: 3.0.1 esbuild: specifier: ^0.27.0 version: 0.27.0 @@ -1114,6 +1120,10 @@ packages: resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} engines: {node: '>=20'} + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + content-disposition@0.5.4: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} @@ -1144,6 +1154,11 @@ packages: resolution: {integrity: sha512-OytmFH+13/QXONJcC75QNdMtKpceNk3u8ThBjyyYjkEcy/ekBwR1mMAuNvi3gdBPW3N5TlCzQ0WZw8H0lN/bDw==} engines: {node: '>=20'} + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + data-urls@6.0.0: resolution: {integrity: sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==} engines: {node: '>=20'} @@ -1217,6 +1232,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + easy-tz@0.2.0: + resolution: {integrity: sha512-Mf+tTNHaAUBqEMo9mvIdCIV5kXCazRCxGKH16itMxefrHy/FtHS5tnfOOS0DKMWU7rl/MnN9PzjdFfn8JCYqxg==} + electron-to-chromium@1.5.259: resolution: {integrity: sha512-I+oLXgpEJzD6Cwuwt1gYjxsDmu/S/Kd41mmLA3O+/uH2pFRO/DvOjUyGozL8j3KeLV6WyZ7ssPwELMsXCcsJAQ==} @@ -1848,10 +1866,16 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} - preact-router@4.1.2: - resolution: {integrity: sha512-uICUaUFYh+XQ+6vZtQn1q+X6rSqwq+zorWOCLWPF5FAsQh3EJ+RsDQ9Ee+fjk545YWQHfUxhrBAaemfxEnMOUg==} + preact-iso@2.11.0: + resolution: {integrity: sha512-oThWJQcgcnaWh6UKy1qrBkxIWp5CkqvnHiFdLiDUxfNkGdpQ5veGQw9wOVS0NDp7X8xo98wxE4wng5jLv1e9Ug==} peerDependencies: - preact: '>=10' + preact: '>=10 || >= 11.0.0-0' + preact-render-to-string: '>=6.4.0' + + preact-render-to-string@6.6.3: + resolution: {integrity: sha512-7oHG7jzjriqsFPkSPiPnzrQ0GcxFm6wOkYWNdStK5Ks9YlWSQQXKGBRAX4nKDdqX7HAQuRvI4pZNZMycK4WwDw==} + peerDependencies: + preact: '>=10 || >= 11.0.0-0' preact@10.27.2: resolution: {integrity: sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==} @@ -1933,6 +1957,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -3093,6 +3120,8 @@ snapshots: commander@14.0.2: {} + commander@7.2.0: {} + content-disposition@0.5.4: dependencies: safe-buffer: 5.2.1 @@ -3128,6 +3157,12 @@ snapshots: '@csstools/css-syntax-patches-for-csstree': 1.0.17 css-tree: 3.1.0 + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + data-urls@6.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -3211,6 +3246,8 @@ snapshots: eastasianwidth@0.2.0: {} + easy-tz@0.2.0: {} + electron-to-chromium@1.5.259: {} emoji-regex@10.6.0: {} @@ -3881,7 +3918,12 @@ snapshots: dependencies: xtend: 4.0.2 - preact-router@4.1.2(preact@10.27.2): + preact-iso@2.11.0(preact-render-to-string@6.6.3(preact@10.27.2))(preact@10.27.2): + dependencies: + preact: 10.27.2 + preact-render-to-string: 6.6.3(preact@10.27.2) + + preact-render-to-string@6.6.3(preact@10.27.2): dependencies: preact: 10.27.2 @@ -3973,6 +4015,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.53.3 fsevents: 2.3.3 + rw@1.3.3: {} + safe-buffer@5.2.1: {} safe-regex-test@1.1.0: diff --git a/server/routes/api.ts b/server/routes/api.ts index 3c68150..bf373d0 100644 --- a/server/routes/api.ts +++ b/server/routes/api.ts @@ -1,3 +1,4 @@ +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' @@ -19,6 +20,102 @@ const apiRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => { }, }) + fastify.route({ + url: '/invoices', + method: 'GET', + schema: { + querystring: Type.Object({ + year: Type.Optional(Type.Number()), + supplier: Type.Optional(Type.Number()), + }), + }, + async handler(req) { + let query: { financialYearId?: number; supplierId?: 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.supplier) { + query.supplierId = req.query.supplier + } + + return knex('invoice AS i') + .select('*', { + files: knex + .select(knex.raw('json_agg(files)')) + .from( + knex + .select('id', 'filename') + .from('file AS f') + .innerJoin('filesToInvoice AS fi', 'f.id', 'fi.fileId') + .where('fi.invoiceId', knex.ref('i.id')) + .as('files'), + ), + }) + .where(query) + }, + }) + + fastify.route({ + url: '/invoices/:id', + method: 'GET', + schema: { + params: Type.Object({ + id: Type.Number(), + }), + }, + handler(req) { + return knex('invoice').first('*').where('id', req.params.id) + }, + }) + + fastify.route({ + url: '/invoices/by-supplier/:supplier', + method: 'GET', + schema: { + params: Type.Object({ + supplier: Type.Number(), + }), + }, + handler(req) { + return knex('invoice AS i') + .select('*', { + files: knex + .select(knex.raw('json_agg(files)')) + .from( + knex + .select('id', 'filename') + .from('file AS f') + .innerJoin('filesToInvoice AS fi', 'f.id', 'fi.fileId') + .where('fi.invoiceId', knex.ref('i.id')) + .as('files'), + ), + }) + .where('supplierId', req.params.supplier) + }, + }) + + fastify.route({ + url: '/invoices/by-year/:year', + method: 'GET', + schema: { + params: Type.Object({ + year: Type.Number(), + }), + }, + async handler(req) { + const year = await knex('financialYear').first('*').where('year', req.params.year) + + if (!year) throw new StatusError(404, `Year ${req.params.year} not found.`) + + return knex('invoice').select('*').where('financialYearId', year.id) + }, + }) + fastify.route({ url: '/objects', method: 'GET', @@ -122,6 +219,27 @@ const apiRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => { }, }) + fastify.route({ + url: '/suppliers', + method: 'GET', + async handler(req) { + return knex('supplier').select('*').orderBy('name') + }, + }) + + fastify.route({ + url: '/suppliers/:id', + method: 'GET', + schema: { + params: Type.Object({ + id: Type.Number(), + }), + }, + async handler(req) { + return knex('supplier').first('*').where('id', req.params.id) + }, + }) + done() } diff --git a/server/server.ts b/server/server.ts index eb8a418..061e3bb 100644 --- a/server/server.ts +++ b/server/server.ts @@ -1,4 +1,5 @@ import fastify, { type FastifyServerOptions } from 'fastify' +import fstatic from '@fastify/static' import StatusError from './lib/status_error.ts' import env from './env.ts' import ErrorHandler from './handlers/error.ts' @@ -13,7 +14,10 @@ export default async (options: FastifyServerOptions) => { throw new StatusError(404) }) - console.dir(env) + server.register(fstatic, { + root: new URL('../uploads', import.meta.url), + prefix: '/uploads/', + }) server.register(vitePlugin, { mode: env.NODE_ENV, diff --git a/tsconfig.json b/tsconfig.json index 1b5efb7..31744a7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,5 +12,5 @@ "erasableSyntaxOnly": true, "allowArbitraryExtensions": true }, - "include": ["global.d.ts", "client", "server"] + "include": ["global.d.ts", "bin", "client", "server"] }