From d273e659e718e1f03832651be0edff308bfb3624 Mon Sep 17 00:00:00 2001 From: Patrick Date: Fri, 17 Jan 2025 13:32:28 +0100 Subject: [PATCH] added pdf generation --- .gitignore | 6 ++ docs/UserEntryDatabase.svg | 2 +- docs/UserEntryDatabase.uml | 31 ++++++-- pdfgen/template.tex | 71 +++++++++++++++++ src/lib/db_types.ts | 1 + src/lib/server/PDFGen.ts | 48 ++++++++++++ src/lib/server/database.ts | 113 +++++++++++++++++++++++---- src/lib/util.ts | 49 +++++++++++- src/routes/+layout.svelte | 37 +++++++++ src/routes/+page.svelte | 28 +++---- src/routes/dokumente/+page.server.ts | 26 ++++++ src/routes/dokumente/+page.svelte | 93 ++++++++++++++++++++++ src/routes/record_input_row.svelte | 9 +-- 13 files changed, 467 insertions(+), 47 deletions(-) create mode 100644 pdfgen/template.tex create mode 100644 src/lib/server/PDFGen.ts create mode 100644 src/routes/dokumente/+page.server.ts create mode 100644 src/routes/dokumente/+page.svelte diff --git a/.gitignore b/.gitignore index eaa6c19..1612285 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,9 @@ vite.config.ts.timestamp-* # Databases *.sqlite + +# old files +*.old + +# generated files +pdfgen/* diff --git a/docs/UserEntryDatabase.svg b/docs/UserEntryDatabase.svg index 40ebef8..161bb67 100644 --- a/docs/UserEntryDatabase.svg +++ b/docs/UserEntryDatabase.svg @@ -1 +1 @@ -recordsid: primarydate: varchar(10) [YYYY-MM-DD]start: varchar(5) [HH:MM]end: varchar(5) [HH:MM] created: timestampmodified: timestampmodified_to: records::idestimatesid: primarydate: varchar(7) [YYYY-MM]estimate: real created: timestampmodified: timestampmodified_to: estimates::id \ No newline at end of file +recordsid: primarydate: varchar(10) [YYYY-MM-DD]start: varchar(5) [HH:MM]end: varchar(5) [HH:MM] created: timestamprecords_historyid: primaryrecord: records::id date: varchar(10)start: varchar(5)end: varchar(5) modified_at: timestampestimatesid: primaryyear: integermonth: integerestimate: real created: timestampestimates_historyid: primaryestimate: estimates::id year: integermonth: integerestimate: real modified_at: timestamp \ No newline at end of file diff --git a/docs/UserEntryDatabase.uml b/docs/UserEntryDatabase.uml index 3644cab..0256fc7 100644 --- a/docs/UserEntryDatabase.uml +++ b/docs/UserEntryDatabase.uml @@ -10,19 +10,40 @@ entity "records" { end: varchar(5) [HH:MM] created: timestamp - modified: timestamp - modified_to: records::id +} + +entity "records_history" { + id: primary + -- + record: records::id + + date: varchar(10) + start: varchar(5) + end: varchar(5) + + modified_at: timestamp } entity "estimates" { id: primary -- - date: varchar(7) [YYYY-MM] + year: integer + month: integer estimate: real created: timestamp - modified: timestamp - modified_to: estimates::id +} + +entity "estimates_history" { + id: primary + -- + estimate: estimates::id + + year: integer + month: integer + estimate: real + + modified_at: timestamp } @enduml diff --git a/pdfgen/template.tex b/pdfgen/template.tex new file mode 100644 index 0000000..d2d7b1b --- /dev/null +++ b/pdfgen/template.tex @@ -0,0 +1,71 @@ +\documentclass[a4paper,oneside]{article} +\usepackage[left=2.5cm,top=3cm,right=2.5cm,bottom=3cm]{geometry} + +\usepackage{tabularx} +\usepackage{array} +\usepackage{csvsimple} +\usepackage{etoolbox} + +\newcommand{\datafile}{\jobname} + +\begin{document} +\pagestyle{empty} + +\begin{center} + \section*{Stundenliste} +\end{center} + +\vspace{1cm} + +%\Name ;\MonatJahr ;\IstStunden ;\SollStunden ;\DiffStunden +\csvreader[% + separator=semicolon, + no head, + filter test = \ifnumless{\thecsvinputline}{2}]{\datafile}{1=\Name,2=\MonatJahr}{% + + \begin{tabular}{l l} + Name: & \Name \\ + Monat/Jahr: & \MonatJahr + \end{tabular} +} + +\vspace{1cm} + +{ +%\adjustboxset{margin=3pt} +\noindent +\begin{tabularx}{\textwidth}{ + |>{\raggedleft\arraybackslash}p{2cm}% + |>{\raggedleft\arraybackslash}p{2cm}% + |>{\raggedleft\arraybackslash}p{1.25cm}% + |>{\raggedleft\arraybackslash}p{1.25cm}% + |>{\raggedleft\arraybackslash}p{1.25cm}% + |X|} + \hline + \centering\arraybackslash\bfseries Datum & % + \centering\arraybackslash\bfseries Wochentag & % + \centering\arraybackslash\bfseries Beginn & % + \centering\arraybackslash\bfseries Ende & % + \centering\arraybackslash\bfseries Dauer & % + \centering\arraybackslash\bfseries Anmerkung %\\ \noalign{\hrule height 1.5pt} + \csvreader[% + separator=semicolon, + no head, + filter test = \ifnumgreater{\thecsvinputline}{1}]{\datafile}{1=\csvdate,2=\csvday,3=\csvstart,4=\csvend,5=\csvduration,6=\csvcomment}{% + \\\noalign{\csviffirstrow{\hrule height 1.5pt}{\hrule height 0.5pt}} + \csvdate & \csvday & \csvstart & \csvend & \csvduration & \csvcomment + } + \csvreader[% + separator=semicolon, + no head, + filter test = \ifnumless{\thecsvinputline}{2}]{\datafile}{3=\Ist,4=\Soll,5=\Diff}{% + \\\hline + \multicolumn{5}{>{\tiny}p{7.5cm}}{} \\ + \multicolumn{2}{p{4cm}}{} & \multicolumn{2}{>{\raggedleft\arraybackslash}p{2.5cm}}{Ist-Stunden:} & \multicolumn{1}{>{\raggedleft\arraybackslash}p{1.25cm}}{\Ist} \\ + \multicolumn{2}{p{4cm}}{} & \multicolumn{2}{>{\raggedleft\arraybackslash}p{2.5cm}}{Soll-Stunden:} & \multicolumn{1}{>{\raggedleft\arraybackslash}p{1.25cm}}{\Soll} \\ \cline{3-5} + \multicolumn{2}{p{4cm}}{} & \multicolumn{2}{>{\raggedleft\arraybackslash}p{2.5cm}}{Differenz:} & \multicolumn{1}{>{\raggedleft\arraybackslash}p{1.25cm}}{\Diff} + } +\end{tabularx} +} + +\end{document} diff --git a/src/lib/db_types.ts b/src/lib/db_types.ts index 60b5032..5dd4ec7 100644 --- a/src/lib/db_types.ts +++ b/src/lib/db_types.ts @@ -4,6 +4,7 @@ import { Database } from 'bun:sqlite'; export interface UserEntry { id: number; name: string; + initials: string; created: string; } diff --git a/src/lib/server/PDFGen.ts b/src/lib/server/PDFGen.ts new file mode 100644 index 0000000..537b20f --- /dev/null +++ b/src/lib/server/PDFGen.ts @@ -0,0 +1,48 @@ +import b from "bun"; + +import type { RecordEntry } from "$lib/db_types"; +import { toInt, parseDate, calculateDuration, weekday_of } from "$lib/util" + +import { User } from "$lib/server/database" + +const MONTHS = [ "Jänner", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember" ]; + +export async function generateRecordPDF(user: User, date: Date, soll: str) { + + const year = date.getFullYear(); + const month = date.getMonth() + 1; + + const dir = "pdfgen/user-" + user.id + "/"; + const file_pref = "Stundenliste-" + year + "-" + month; + + const records = user.get_entries_by_month(year, month); + const hr_sum = (() => { let s = 0; records.forEach((r) => { s += calculateDuration(r.start, r.end) }); return s; })() + + const { exitCode } = await b.$`mkdir -p ${dir}`.nothrow().quiet(); + + if (exitCode != 0) { + return; + } + + // TODO: escape semicolon in comment + + console.log(`${user.name};${MONTHS[date.getMonth()]} ${year};${hr_sum.toFixed(2)};${soll};${(hr_sum - toInt(soll)).toFixed(2)}`); + records.forEach((record) => { + console.log(`${record.date};${weekday_of(parseDate(record.date))};${record.start};${record.end};${calculateDuration(record.start, record.end).toFixed(2)};${record.comment}`) + }) + + const csvfile = Bun.file(dir + file_pref + ".csv") + const csvfilewriter = csvfile.writer() + + csvfilewriter.write(`${user.name};${MONTHS[date.getMonth()]} ${year};${hr_sum.toFixed(2)};${soll};${(hr_sum - toInt(soll)).toFixed(2)}\n`) + + records.forEach((record) => { + csvfilewriter.write(`${record.date};${weekday_of(parseDate(record.date))};${record.start};${record.end};${calculateDuration(record.start, record.end).toFixed(2)};${record.comment}\n`) + }) + + csvfilewriter.end(); +} + +function generateTable(path: string) { + +} diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index c403183..9825820 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -2,7 +2,7 @@ import { mkdir } from "node:fs/promises"; import { Database, SQLiteError } from "bun:sqlite"; import { UserEntry, RecordEntry } from "$lib/db_types"; -import { parseDate, isTimeValidHHMM } from "$lib/util"; +import { calculateDuration, parseDate, isTimeValidHHMM } from "$lib/util"; const DATABASES_PATH: string = ""; const USER_DATABASE_PATH: string = DATABASES_PATH + "users.sqlite"; @@ -11,11 +11,11 @@ const CHECK_QUERY: string = "SELECT * FROM sqlite_master;"; const USER_DATABASE_SETUP: string[] = [ - "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name TEXT, created DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL);", + "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name TEXT, initials TEXT, created DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL);", ] const USER_DATABASE_ADD_USER: string = - "INSERT INTO users (name) VALUES ($name);"; + "INSERT INTO users (name, initials) VALUES ($name, $initials);"; const USER_DATABASE_GET_USER: string = "SELECT * FROM users;"; @@ -60,35 +60,113 @@ const ENTRY_DATABASE_SETUP: string[] = [ BEGIN UPDATE records SET (date, start, end, comment) = (null, null, null, null) WHERE OLD.id = id; SELECT raise(IGNORE); - END;` + END;`, + + `CREATE TABLE estimates ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + year INTEGER NOT NULL, + month INTEGER NOT NULL, + estimate REAL NOT NULL, + created DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + modified DATETIME DEFAULT NULL, + modified_to INTEGER UNIQUE DEFAULT NULL, + FOREIGN KEY(modified_to) REFERENCES estimates(id) + );`, + + `CREATE TRIGGER estimates_prevent_duplicates + BEFORE INSERT ON estimates + WHEN (SELECT value FROM meta WHERE key = 'triggerActive') = 1 + AND EXISTS(SELECT 1 FROM estimates WHERE year = NEW.year AND month = NEW.month) + BEGIN + SELECT raise (ABORT, 'Prevented INSERT of duplicate row'); + END;`, + + `CREATE TRIGGER estimates_prevent_update_if_superseded + BEFORE UPDATE ON estimates + WHEN (SELECT value FROM meta WHERE key = 'triggerActive') = 1 + AND (OLD.modified_to NOT NULL) + BEGIN + SELECT raise(ABORT, 'Modification on changed row is not allowed'); + END;`, + + `CREATE TRIGGER estimates_prevent_update + BEFORE UPDATE ON estimates + WHEN (SELECT value FROM meta WHERE key = 'triggerActive') = 1 + BEGIN + INSERT INTO estimates(year, month, estimate) VALUES (NEW.year, NEW.month, NEW.estimate); + UPDATE estimates SET (modified, modified_to) = (CURRENT_TIMESTAMP, last_insert_rowid()) WHERE NEW.id == id; + SELECT raise(IGNORE); + END;`, + + `CREATE TRIGGER estimates_prevent_delete + BEFORE DELETE ON estimates + BEGIN + SELECT raise(ABORT, 'DELETE is not allowed on this table'); + END;`, ] +const ENTRY_DATABASE_GET_MONTHS: string = + "SELECT DISTINCT SUBSTR(date, 7, 4) as year, SUBSTR(date, 4, 2) as month FROM records ORDER BY year DESC, month DESC;" + const ENTRY_DATABASE_GET_ENTRY_BY_ID: string = "SELECT * FROM records WHERE modified_to ISNULL AND id = $id;" +const ENTRY_DATABASE_GET_ENTRIES_IN_MONTH: string = + "SELECT * FROM records WHERE modified_to ISNULL AND SUBSTR(date, 7, 4) = $year AND SUBSTR(date, 4, 2) = $month ORDER BY SUBSTR(date, 1, 2);" + const ENTRY_DATABASE_GET_ENTRIES: string = - "SELECT * FROM records WHERE modified_to ISNULL;" + "SELECT * FROM records WHERE modified_to ISNULL ORDER BY SUBSTR(date, 7, 4) DESC, SUBSTR(date, 4, 2) DESC, SUBSTR(date, 1, 2) DESC;" const ENTRY_DATABASE_ADD_ENTRY: string = "INSERT INTO records(date, start, end, comment) VALUES ($date, $start, $end, $comment);" -const Entry_DATABASE_EDIT_ENTRY: string = +const ENTRY_DATABASE_EDIT_ENTRY: string = "UPDATE records SET date = $date, start = $start, end = $end, comment = $comment WHERE id = $id;"; export class User { - user: UserEntry; + id: number; + name: string; + created: string; private database: Database; constructor(user: UserEntry, db: Database) { - this.user = user; + this.id = user.id; + this.name = user.name; + this.created = user.created; this._database = db; } + get_months(): { year: number, month: number }[] { + const query = this._database.query(ENTRY_DATABASE_GET_MONTHS); + const res = query.all(); + + return res; + } + + get_hr_sum(year: number, month: number): number { + const months = this.get_entries_by_month(year, month); + + let sum = 0; + months.forEach((record) => { sum += calculateDuration(record.start, record.end) }) + return sum; + } + get_entries(): Entry[] { const query = this._database.query(ENTRY_DATABASE_GET_ENTRIES); const res = query.all() - + + return res; + } + + get_entries_by_month(year: number, month: number): Entry[] { + if (!(month > 0 && month < 13)) { + return []; + } + + const query = this._database.query(ENTRY_DATABASE_GET_ENTRIES_IN_MONTH); + const res = query.all({ year: year.toString(), month: month.toString().padStart(2, '0') }); + return res; } @@ -117,7 +195,7 @@ export class User { return null; } - const query = this._database.query(Entry_DATABASE_EDIT_ENTRY); + const query = this._database.query(ENTRY_DATABASE_EDIT_ENTRY); const res = query.run({ id: id, date: ndate, start: nstart, end: nend, comment: ncomment }); return res.changes > 1; @@ -169,11 +247,11 @@ export function close_db() { } } -export function create_user(name: string): boolean { +export function create_user(name: string, initials: string): boolean { try { const statement = user_database.query(USER_DATABASE_ADD_USER); - const result = statement.run({ name: name }); + const result = statement.run({ name: name, initials: initials }); return true; } catch (e) { @@ -186,22 +264,22 @@ export function create_user(name: string): boolean { } } -function _get_user(): UserEntry { +function _get_user(): UserEntry | null { try { const statement = user_database.prepare(USER_DATABASE_GET_USER); const result: UserEntry = statement.get(); if (result == null) { - create_user("PM"); - return get_user(); + create_user("Patrick Maschek", "PM"); + return _get_user(); } return result; } catch (e) { if (e instanceof SQLiteError) { - return false; + return null; } throw e; @@ -212,6 +290,9 @@ function _get_user(): UserEntry { export function get_user(): User | null { const user = _get_user(); + if (user == null) { + return null; + } const db_name = get_user_db_name(user); diff --git a/src/lib/util.ts b/src/lib/util.ts index 9f461fe..6deac4d 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -1,4 +1,6 @@ +const WEEKDAYS: string[] = [ "Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag" ] + export function toInt(str: string): number { let value = 0; for (let i = 0; i < str.length; ++i) { @@ -13,6 +15,36 @@ export function toInt(str: string): number { return value; } +export function toFloat(str: string): number { + let value = 0; + let value_after_dot = 0; + + let i = 0; + for (i = 0; i < str.length; ++i) { + let c = str.charAt(i); + if (c === ',' || c === '.') { + break; + } + let n = Number(c); + if (isNaN(n)) { + return NaN; + } + value = value * 10 + n; + } + + let dec = 1; + for (++i; i < str.length; ++i, ++dec) { + let c = str.charAt(i); + let n = Number(c); + if (isNaN(n)) { + return NaN; + } + value += n / Math.pow(10, dec); + } + + return value; +} + export function parseDate(str: string): Date | null { if (str.length != 2+1+2+1+4) { return null; @@ -35,10 +67,10 @@ export function parseDate(str: string): Date | null { return date; } -export function calculateDuration(start: string, end: string): string { +export function calculateDuration(start: string, end: string): number { if (start.length !== 5 || end.length !== 5) { - return ""; + return NaN; } let start_h = toInt(start.slice(0, 2)); @@ -49,7 +81,7 @@ export function calculateDuration(start: string, end: string): string { if (isNaN(start_h) || isNaN(start_m) || start.charAt(2) !== ':' || isNaN(end_h) || isNaN(end_m) || end.charAt(2) !== ':') { - return ""; + return NaN; } let start_n = start_h * 60 + start_m; @@ -57,7 +89,7 @@ export function calculateDuration(start: string, end: string): string { let duration = (end_n - start_n) / 60; - return duration.toFixed(2); + return duration; } export function localToIsoDate(str: string): string | undefined { @@ -87,3 +119,12 @@ export function isTimeValidHHMM(str: string): boolean { return (!(isNaN(h) || isNaN(m))) && h < 24 && m < 60 && str.charAt(2) == ':'; } + +export function weekday_of(date: Date | null): string { + if (date == null) { + return ""; + } + + const weekday = date.getDay(); + return WEEKDAYS[weekday]; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index a54cfdc..fc56442 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -2,4 +2,41 @@ let { children } = $props(); + + {@render children()} + + diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 562bff2..b85990c 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -4,7 +4,7 @@ import { page } from "$app/state" import type { RecordEntry } from "$lib/db_types" - import { toInt, parseDate, calculateDuration } from "$lib/util"; + import { toInt, parseDate, calculateDuration, weekday_of } from "$lib/util"; import type { RowState } from "./record_input_row.svelte" import RecordInputRow from "./record_input_row.svelte" @@ -12,8 +12,6 @@ let { data, form } : { data: { records: RecordEntry[] }, form: any } = $props(); - const WEEKDAYS: string[] = [ "Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag" ] - const HEADERS: string[] = [ "Datum", "Wochentag", "Beginn", "Ende", "Dauer", "Anmerkung" ]; const status_ok = "ok"; @@ -104,12 +102,11 @@ -
validateForm(e, new_state)} use:enhance>
validateForm(e, edit_state)} use:enhance>
- +
@@ -135,17 +132,12 @@ {#each data.records as entry} {#if editing?.id != entry.id } - {@const weekday = parseDate(entry.date)?.getDay()} - {#if weekday != null } - - {:else} - - {/if} + - +
Stundenliste
{entry.date}{WEEKDAYS[weekday]}{weekday_of(parseDate(entry.date))} {entry.start} {entry.end}{calculateDuration(entry.start, entry.end)}{calculateDuration(entry.start, entry.end).toFixed(2)} {entry.comment ?? ""}
- + diff --git a/src/routes/record_input_row.svelte b/src/routes/record_input_row.svelte index 8788c0a..cc74867 100644 --- a/src/routes/record_input_row.svelte +++ b/src/routes/record_input_row.svelte @@ -9,7 +9,7 @@