added pdf generation

This commit is contained in:
Patrick 2025-01-17 13:32:28 +01:00
parent 3885082d2f
commit d273e659e7
13 changed files with 467 additions and 47 deletions

6
.gitignore vendored
View File

@ -24,3 +24,9 @@ vite.config.ts.timestamp-*
# Databases
*.sqlite
# old files
*.old
# generated files
pdfgen/*

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -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

71
pdfgen/template.tex Normal file
View File

@ -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}

View File

@ -4,6 +4,7 @@ import { Database } from 'bun:sqlite';
export interface UserEntry {
id: number;
name: string;
initials: string;
created: string;
}

48
src/lib/server/PDFGen.ts Normal file
View File

@ -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) {
}

View File

@ -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,31 +60,98 @@ 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()
@ -92,6 +159,17 @@ export class User {
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;
}
get_entry(id: number): Entry {
const query = this._database.query(ENTRY_DATABASE_GET_ENTRY_BY_ID);
const res = query.get({ id: id });
@ -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);

View File

@ -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];
}

View File

@ -2,4 +2,41 @@
let { children } = $props();
</script>
<div class="nav">
<ul>
<li><a href="/dokumente">Ausdrucken</a></li>
</ul>
</div>
{@render children()}
<style>
.nav {
width: 100%;
display: flex;
justify-content: center;
}
.nav ul {
display: flex;
justify-content: right;
width: 80%;
list-style-type: none;
margin-top: 10px;
margin-bottom: 10px;
padding-right: 10px;
}
.nav ul li {
border: black;
margin-left: 10px;
margin-right: 10px;
}
</style>

View File

@ -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 @@
</script>
<div class="wrap">
<form id="form_new_entry" method="POST" action="?/new_entry" onsubmit={(e) => validateForm(e, new_state)} use:enhance></form>
<form id="form_edit_entry" method="POST" action="?/edit_entry" onsubmit={(e) => validateForm(e, edit_state)} use:enhance></form>
<table class="list">
<table>
<caption>Stundenliste</caption>
<thead>
@ -135,17 +132,12 @@
{#each data.records as entry}
{#if editing?.id != entry.id }
{@const weekday = parseDate(entry.date)?.getDay()}
<tr>
<td>{entry.date}</td>
{#if weekday != null }
<td>{WEEKDAYS[weekday]}</td>
{:else}
<td></td>
{/if}
<td>{weekday_of(parseDate(entry.date))}</td>
<td>{entry.start}</td>
<td>{entry.end}</td>
<td>{calculateDuration(entry.start, entry.end)}</td>
<td>{calculateDuration(entry.start, entry.end).toFixed(2)}</td>
<td>{entry.comment ?? ""}</td>
<td>
<form
@ -192,7 +184,7 @@
{/if}
</table>
</div>
<style>
@ -214,18 +206,22 @@ table caption {
font-weight: bold;
}
tbody tr:nth-child(odd) {
tbody > tr:nth-child(odd) {
background: gray;
}
tbody tr:nth-child(even) {
tbody > tr:nth-child(even) {
background: lightgray;
}
tr td:last-of-type {
tbody > tr > td:last-of-type {
display: inline-flex;
}
tfoot > tr {
border-top: 1px solid black;
}
.td-no-elements {
text-align: center;
}

View File

@ -0,0 +1,26 @@
import { toInt } from "$lib/util"
import { generateRecordPDF } from "$lib/server/PDFGen";
export async function load({ locals }) {
return {
availableMonths: locals.user.get_months()
}
}
export const actions = {
create_pdf: async ({ locals, request }) => {
const data = await request.formData();
let month = toInt(data.get("month") ?? "");
let year = toInt(data.get("year") ?? "");
if (isNaN(year) || isNaN(month) || month > 12) {
return fail(400, {});
}
generateRecordPDF(locals.user, new Date(year, month - 1), data.get("soll"));
return { success: true }
}
}

View File

@ -0,0 +1,93 @@
<script lang="ts">
interface _Props {
data: {
availableMonths: {
month: number,
year: number
}[]
}
};
let { data } : _Props = $props();
</script>
<table class="list">
<caption>Stundenliste</caption>
<thead>
<tr>
<th style:width="30ch">Monat</th>
<th style:width="12ch">Aktion</th>
</tr>
</thead>
<tbody>
{#each data.availableMonths as month}
<tr>
<td>Stundenliste {month.month + "-" + month.year}</td>
<td>
<form id="create_pdf" method="POST" action="?/create_pdf">
<input type="hidden" name="month" value={month.month}>
<input type="hidden" name="year" value={month.year}/>
<input type="text" name="soll"/>
<button type="submit">PDF</button>
</form>
</tr>
{/each}
<tr></tr>
</tbody>
{#if data.availableMonths === undefined || data.availableMonths.length === 0}
<tfoot>
<tr>
<td class="td-no-elements" colspan="999">No records</td>
</tr>
</tfoot>
{/if}
</table>
<style>
form {
width: fit-content;
border: none;
}
table {
width: 50%;
margin: auto;
border-collapse: collapse;
border: 1px solid;
}
table caption {
font-size: 25px;
font-weight: bold;
}
tbody > tr > td {
text-align: center;
}
tbody > tr:nth-child(odd) {
background: gray;
}
tbody > tr:nth-child(even) {
background: lightgray;
}
tbody > tr > td:last-of-type {
display: inline-flex;
}
tfoot {
border-top: 1px solid black;
}
.td-no-elements {
text-align: center;
}
</style>

View File

@ -9,7 +9,7 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { toInt, parseDate, calculateDuration } from "$lib/util";
import { toInt, parseDate, calculateDuration, weekday_of } from "$lib/util";
interface Props {
targetForm: string,
@ -19,8 +19,6 @@
}
let { targetForm, enabled, states = $bindable(), children }: Props = $props();
const WEEKDAYS: string[] = [ "Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag" ]
const TODAY: Date = new Date();
const CENTURY_PREF: number = Math.floor(TODAY.getFullYear() / 100);
const CENTURY_YEAR: number = CENTURY_PREF * 100;
@ -61,7 +59,7 @@
if (states?.date?.valid && states?.date?.value != null) {
date = parseDate(states.date.value);
}
return date ? WEEKDAYS[date.getDay()] : "";
return weekday_of(date);
})
let inDuration: string = $state("");
updateDuration();
@ -70,7 +68,8 @@
function updateDuration() {
if ((states?.start?.valid && states?.end?.valid)
&& (states?.start?.value != null && states?.end?.value != null)) {
inDuration = calculateDuration(states.start.value, states.end.value)
let duration = calculateDuration(states.start.value, states.end.value);
inDuration = isNaN(duration) ? "" : duration.toFixed(2);
}
}