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}
+
+
+
+
+ | ID |
+ Fisken |
+ PHM |
+ Invoice Date |
+ Due Date |
+ Number |
+ Amount |
+ Files |
+
+
+
+ {invoices.map((invoice) => (
+
+ | {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"]
}