This commit is contained in:
Patrick 2025-03-25 15:25:04 +01:00
parent 4650cb84c3
commit 88041b8133
13 changed files with 884 additions and 204 deletions

View File

@ -123,6 +123,9 @@ const ENTRY_DATABASE_ADD_ENTRY: string =
const ENTRY_DATABASE_EDIT_ENTRY: string =
"UPDATE records SET date = $date, start = $start, end = $end, comment = $comment WHERE id = $id;";
const ENTRY_DATABASE_REMOVE_ENTRY: string =
"DELETE FROM records WHERE id = $id;";
const ESTIMATES_DATABASE_GET_ALL: string =
"SELECT * FROM estimates ORDER BY year DESC, quarter DESC;"
@ -228,6 +231,17 @@ export class User {
return res.changes > 1;
}
remove_entry(id: number): boolean {
if (isNaN(id)) {
return false;
}
const query = this._database.query(ENTRY_DATABASE_REMOVE_ENTRY);
const res = query.run({ id: id });
return res.changes > 1;
}
get_estimates(): Array<EstimatesEntry> {
const query = this._database.query(ESTIMATES_DATABASE_GET_ALL);
const res = query.all();
@ -256,6 +270,7 @@ export class User {
const query = this._database.query(ESTIMATES_DATABASE_INSERT);
const res = query.run({ year: year, quarter: quarter, estimate_0: estimate_0, estimate_1: estimate_1, estimate_2: estimate_2 });
console.log(res)
return res.changes > 1;
}

View File

@ -81,6 +81,40 @@ export async function getAllFiles(user: User) {
return files;
}
export async function getRecordFiles(user: User) {
const path = `${DOCUMENTS_PATH}/user-${user.id}/${RECORDS_FOLD}`;
let file_names = await fs.readdir(path).catch((err) => { console.log(err); return [] });
let files = await Promise.all(file_names.map(async (v) => {
return {
identifier: v.replace(/^Stundenliste-/, "").replace(/\.pdf$/, ""),
filename: v,
path: `${RECORDS_FOLD}/${v}`,
cdate: (await fs.stat(`${path}/${v}`)).ctime,
}
}))
return files;
}
export async function getEstimateFiles(user: User) {
const path = `${DOCUMENTS_PATH}/user-${user.id}/${ESTIMATES_FOLD}`;
let file_names = await fs.readdir(path).catch((err) => { console.log(err); return [] });
let files = await Promise.all(file_names.map(async (v) => {
return {
identifier: v.match(/\d.Quartal_\d\d\d\d/)[0].replace(/.Quartal_/, ".").split(".").reverse().join("-"),
filename: v,
path: `${ESTIMATES_FOLD}/${v}`,
cdate: (await fs.stat(`${path}/${v}`)).ctime,
}
}))
return files;
}
export async function getFile(user: User, filename: string) {
const path = `${DOCUMENTS_PATH}/user-${user.id}`;

View File

@ -2,6 +2,16 @@
export const MONTHS: string[] = [ "Jänner", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember" ];
export const WEEKDAYS: string[] = [ "Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag" ];
/*
* Converts a string containing a decimal number to number.
* If a non numeric (0-9) character is encountered returns NaN.
*
* @param str: string to be converted
* @returns the converted string or NaN if failed
*
*/
export function toInt(str: string): number {
if (str.length === 0) {
return NaN;
@ -10,8 +20,8 @@ export function toInt(str: string): number {
let value = 0;
for (let i = 0; i < str.length; ++i) {
let c = str.charAt(i);
let n = Number(c);
if (isNaN(n)) {
let n = c.charCodeAt(0) - "0".charCodeAt(0);
if (isNaN(n) || n < 0 || n > 9) {
return NaN;
}
value = value*10 + n;
@ -20,6 +30,15 @@ export function toInt(str: string): number {
return value;
}
/*
* Converts a string containing a decimal floating point number to number.
* If a invalid character is encountered returns NaN.
* Only one decimal separator may be present and may be one of "." or ",".
*
* @param str: string to convert to a number
* @returns the floating point number or NaN if failed
*
*/
export function toFloat(str: string): number {
if (str.length === 0) {
return NaN;
@ -31,11 +50,12 @@ export function toFloat(str: string): number {
let i = 0;
for (i = 0; i < str.length; ++i) {
let c = str.charAt(i);
console.log("c", c)
if (c === ',' || c === '.') {
break;
}
let n = Number(c);
if (isNaN(n)) {
let n = c.charCodeAt(0) - "0".charCodeAt(0);
if (isNaN(n) || n < 0 || n > 9) {
return NaN;
}
value = value * 10 + n;
@ -44,8 +64,8 @@ export function toFloat(str: string): number {
let dec = 1;
for (++i; i < str.length; ++i, ++dec) {
let c = str.charAt(i);
let n = Number(c);
if (isNaN(n)) {
let n = c.charCodeAt(0) - "0".charCodeAt(0);
if (isNaN(n) || n < 0 || n > 9) {
return NaN;
}
value += n / Math.pow(10, dec);
@ -54,15 +74,25 @@ export function toFloat(str: string): number {
return value;
}
export function padInt(num: number, upper: number, float: number = 0, pad_char: string = '0') {
export function padInt(num: number, upper: number, float: number = 0, pad_char: string = '0', add_plus_if_pos: boolean = false, show_dec_point_if_round: boolean = true, pad_char_back: string = "\u2007") {
if (num == null) {
return padInt(0, upper, float, pad_char, add_plus_if_pos, show_dec_point_if_round, pad_char_back)
}
if (num >= 0) {
if (add_plus_if_pos) {
return "+" + padInt(num, upper, float, pad_char, false, show_dec_point_if_round, pad_char_back);
}
if (float != 0) {
return num.toFixed(float).padStart(upper + 1 + float, pad_char)
if (show_dec_point_if_round || num.toFixed(float).search(".*\.0+") == -1) {
return num.toFixed(float).padStart(upper + 1 + float, pad_char)
} else {
return (num.toFixed(0) + pad_char_back.repeat(1 + float)).padStart(upper + 1 + float, pad_char);
}
} else {
return num.toFixed(float).padStart(upper, pad_char);
}
} else {
return "-" + padInt(-1 * num, upper, float);
return "-" + padInt(-1 * num, upper, float, pad_char, false, show_dec_point_if_round, pad_char_back);
}
}

View File

@ -2,43 +2,77 @@
let { children } = $props();
</script>
<div class="nav">
{#snippet nav(classlist: string)}
<div class={classlist}>
<h1>Navigation</h1>
<ul>
<li><a href="/">Stundenliste</a></li>
<li><a href="/schaetzung">Stundenschätzung</a></li>
<li><a href="/dokumente">Ausdrucken</a></li>
<li><a href="/dokumente">Dokumente</a></li>
</ul>
</div>
{/snippet}
<div style:display="flex">
{@render nav("navpos nav")}
{@render nav("navpseudo nav")}
<div class="content">
{@render children()}
</div>
</div>
<style>
.nav {
width: 100%;
.navpos {
position: fixed;
top: 0;
left: 0;
}
display: flex;
justify-content: center;
.navpseudo {
visibility: hidden;
}
.nav {
width: 10%;
height: 100%;
min-width: 130px;
padding: 8px;
border-right: 1px black solid;
}
.nav ul {
display: flex;
justify-content: right;
width: 80%;
list-style-type: none;
margin-top: 10px;
margin-bottom: 10px;
padding-right: 10px;
padding: 0px;
margin: 0px;
}
.nav ul li {
border: black;
padding-bottom: 5px;
}
margin-left: 10px;
margin-right: 10px;
.nav h1 {
width: 100%;
margin-top: 10px;
text-align: center;
font-size: 25px;
}
.content {
flex: 1;
}
.content :global(h1) {
width: 100%;
text-align: center;
font-weight: bold;
}
</style>

View File

@ -1,13 +1,60 @@
import type { SQLiteError } from "bun:sqlite"
import type { PageServerLoad, Actions } from "./$types";
import type { SQLiteError } from "bun:sqlite";
import type { RecordEntry, EstimatesEntry } from "$lib/db_types";
import { fail, redirect } from '@sveltejs/kit';
import { toInt, parseDate, isTimeValidHHMM } from "$lib/util"
import { MONTHS, toInt, parseDate, isTimeValidHHMM, month_of } from "$lib/util"
import { get_user, User } from "$lib/server/database";
import { getRecordFiles } from "$lib/server/docstore";
export const load: PageServerLoad = async ({ locals }) => {
let records: { entry: RecordEntry, parsedDate: Date } = locals.user.get_entries().map((v) => ({ entry: v, parsedDate: parseDate(v.date) }));
let estimates: EstimatesEntry = locals.user.get_estimates()
let documents = (await getRecordFiles(locals.user)).map((v) => { return { year: toInt(v.identifier.substring(0, 4)), month: toInt(v.identifier.substring(5, 7)), file: v }; });
let records_grouped = Map.groupBy(records, (v) => { return v.parsedDate.getFullYear() } );
records_grouped.forEach((value, key, map) => {
let m = Map.groupBy(value, (v) => v.parsedDate.getMonth());
// remove parsed date
m.forEach((value, key, map) => {
map.set(key, value.map((v) => v.entry));
});
map.set(key, m);
});
let estimates_grouped = Map.groupBy(estimates, (v) => { return v.year });
estimates_grouped.forEach((value, key, map) => {
let arr = value.map((v) => [
{ key: ((v.quarter - 1) * 3 + 2), value: v["estimate_2"] },
{ key: ((v.quarter - 1) * 3 + 1), value: v["estimate_1"] },
{ key: ((v.quarter - 1) * 3 + 0), value: v["estimate_0"] },
]).flat();
let m = Map.groupBy(arr, (e) => e.key);
m.forEach((value, key, map) => {
map.set(key, value[0].value);
})
map.set(key, m);
})
let documents_grouped = Map.groupBy(documents, (v) => v.year );
documents_grouped.forEach((value, key, map) => {
let m = Map.groupBy(value, (v) => v.month);
m.forEach((value, key, map) => {
map.set(key, value.map((v) => v.file)[0]);
})
map.set(key, m);
})
console.log(documents_grouped)
export async function load({ locals }) {
return {
records: locals.user.get_entries()
records: records_grouped,
estimates: estimates_grouped,
documents: documents_grouped
};
}
@ -140,6 +187,8 @@ export const actions = {
return fail(400, { edit_entry: return_obj });
}
id = toInt(id);
let current = locals.user.get_entry(id);
if (!current) {
@ -167,4 +216,34 @@ export const actions = {
redirect(303, '/');
return { success: true };
},
}
remove_entry: async ({ locals, request })=> {
const data = await request.formData();
let id = data.get("id");
if (id == null) {
return fail(400, { id: id });
}
id = toInt(id);
if (isNaN(id)) {
return fail(400, { id: id });
}
let res = false;
try {
res = locals.user.remove_entry(id);
} catch (e) {
if (!(e instanceof SQLiteError)) {
throw e;
}
}
if (!res) {
return fail(500, { });
}
return { success: true }
}
} satisfies Actions;

View File

@ -1,16 +1,20 @@
<script lang="ts">
import type { PageProps } from "./$types";
import { untrack } from "svelte";
import { enhance } from "$app/forms";
import { page } from "$app/state"
import type { RecordEntry } from "$lib/db_types"
import { toInt, parseDate, calculateDuration, weekday_of } from "$lib/util";
import { MONTHS, toInt, padInt, parseDate, calculateDuration, weekday_of } from "$lib/util";
import type { RowState } from "./record_input_row.svelte"
import RecordInputRow from "./record_input_row.svelte"
let { data, form } : { data: { records: RecordEntry[] }, form: any } = $props();
let { data, form } : PageProps = $props();
//$inspect(data);
const HEADERS: string[] = [ "Datum", "Wochentag", "Beginn", "Ende", "Dauer", "Anmerkung" ];
@ -64,7 +68,7 @@
editing = form.edit_entry
} else if (page.url.searchParams.get("edit")) {
let id = toInt(page.url.searchParams.get("edit")!)
let entry = data.records.find((entry) => { return entry.id == id; })
let entry = Array.from(data.records.values().map((months) => Array.from(months.values()))).flat(2).find((entry) => entry.id == id);
editing = entry ?? null;
} else {
editing = null;
@ -102,77 +106,174 @@
</script>
<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>
<form id="form_remove_entry" method="POST" action="?/remove_entry" use:enhance></form>
<h1>Stundenliste</h1>
<table>
<caption>Stundenliste</caption>
<colgroup>
<col style:width="5ex" class="cyear" />
<col style:width="5ex" class="cmonth" />
<col style:width="12ch" class="cdate" />
<col style:width="12ch" class="cweekday" />
<col style:width="7ch" class="cstart" />
<col style:width="7ch" class="cend" />
<col style:width="7ch" class="cduration" />
<col class="ccomment" />
<col style:width="12ch" class="caction" />
<col style:width="fit-content" class="month_stat" />
</colgroup>
<thead>
<tr>
<th style:width="20ch">Datum</th>
<th style:width="25ch">Wochentag</th>
<th style:width="12ch">Beginn</th>
<th style:width="12ch">Ende</th>
<th style:width="12ch">Dauer</th>
<th></th>
<th></th>
<th>Datum</th>
<th>Wochentag</th>
<th>Beginn</th>
<th>Ende</th>
<th>Dauer</th>
<th>Anmerkung</th>
<th style:width="12ch">Actions</th>
<th>Actions</th>
<th></th>
</tr>
</thead>
<tbody>
<RecordInputRow targetForm="form_new_entry" bind:states={new_state} enabled={editing == null}>
<button
type="submit"
form="form_new_entry"
disabled={editing != null}>
+
</button>
</RecordInputRow>
{#each data.records as entry}
{#if editing?.id != entry.id }
<tr>
<td>{entry.date}</td>
<td>{weekday_of(parseDate(entry.date))}</td>
<td>{entry.start}</td>
<td>{entry.end}</td>
<td>{calculateDuration(entry.start, entry.end).toFixed(2)}</td>
<td>{entry.comment ?? ""}</td>
<td>
<form
id="form_edit"
method="GET"
onsubmit={(event) => {
event.preventDefault();
setEditing(entry);
}}
>
<button type="submit" name="edit" value={entry.id}>Edit</button>
</form>
</td>
<tr style:border="none">
<td style:border="none"></td>
<td style:border="none"></td>
<RecordInputRow targetForm="form_new_entry" bind:states={new_state} enabled={editing == null}>
<button
type="submit"
form="form_new_entry"
disabled={editing != null}>
+
</button>
</RecordInputRow>
</tr>
{:else}
<RecordInputRow targetForm="form_edit_entry" bind:states={edit_state} enabled={true} >
<input type="hidden" form="form_edit_entry" name="id" value={entry.id} />
<button
type="submit"
form="form_edit_entry">
Save
</button>
<form
id="form_edit_cancel"
method="GET"
onsubmit={(event) => {
event.preventDefault();
setEditing(null);
}}
>
<button type="submit">Cancel</button>
</form>
</RecordInputRow>
{/if}
{/each}
{#each data.estimates as estimates_pair}
{@const year = estimates_pair[0] }
{@const mmonths = estimates_pair[1] }
{@const yentries = data.records.get(year)}
{#each mmonths as month_pair, mindex}
{@const month = month_pair[0] }
{@const estimate = month_pair[1] }
{@const entries = yentries.get(month) ?? [ null ] }
{#each entries as entry, eindex}
{@const record_sum = entries.reduce((acc, val) => acc + (val != null ? calculateDuration(val.start, val.end) : 0), 0)}
<!--<tr><td>{$inspect(year)}{$inspect(months)}{$inspect(month)}{$inspect(entries)}</td></tr>-->
<tr style:border-bottom={entries[0] == null ? "none" : ""} >
{#if mindex == 0 && eindex == 0}
<td class="year" rowspan={Array.from(yentries.values()).reduce((acc, val) => acc + ((val?.length ?? 0) < 4 ? 4 : val.length), 0) + 4 * (mmonths.size - yentries.size)}>{year}</td>
{/if}
{#if eindex == 0}
<td class={["month", month % 3 == 2 ? "quarter" : ""]} rowspan={entries.length < 4 ? 4 : entries.length }>{MONTHS[month]}</td>
{/if}
{#if entry == null}
<td style:text-align="center" colspan="7"></td>
{:else if editing?.id != entry.id }
<td>{entry.date}</td>
<td>{weekday_of(parseDate(entry.date))}</td>
<td class="start">{entry.start}</td>
<td class="end">{entry.end}</td>
<td class="duration">{padInt(calculateDuration(entry.start, entry.end), 2, 2, '\u2007;')}</td>
<td>{entry.comment ?? ""}</td>
<td class="action">
<form
id="form_edit"
method="GET"
onsubmit={(event) => {
event.preventDefault();
setEditing(entry);
}}
>
<button type="submit" name="edit" value={entry.id}>Edit</button>
</form>
</td>
{:else}
<RecordInputRow targetForm="form_edit_entry" bind:states={edit_state} enabled={true} >
<input type="hidden" form="form_edit_entry" name="id" value={entry.id} />
<button
type="submit"
form="form_edit_entry">
Save
</button>
<form
id="form_edit_cancel"
method="GET"
onsubmit={(event) => {
event.preventDefault();
setEditing(null);
}}
>
<button type="submit">Cancel</button>
</form>
<input type="hidden" form="form_remove_entry" name="id" value={entry.id} />
<button
type="submit"
form="form_remove_entry">
Delete
</button>
</RecordInputRow>
{/if}
{#if eindex == 0}
{@const document = data?.documents?.get(year)?.get(month)}
<td rowspan={entries.length < 4 ? 4 : entries.length} class="stats">
<table>
<colgroup>
<col style:width="10ch" class="col_labels" />
<col style:width="7ch" class="col_values" />
</colgroup>
<tbody>
<tr>
<td>Ist</td>
<td>{padInt(record_sum, 1, 2, "0", false, false)}</td>
</tr>
<tr>
<td>Soll</td>
<td>{padInt(estimate, 1, 2, "0", false, false)}</td>
</tr>
<tr class="diff">
<td>Differenz</td>
<td>{padInt(record_sum - estimate, 3, 2, "\u2007", true, false)}</td>
</tr>
<tr>
<td colspan="2" class="doc_action">
{#if document == null}
<form id="form_create_rec" method="POST" action="/dokumente?/create_record">
<input type="hidden" name="month" value={month + 1} />
<input type="hidden" name="year" value={year} />
<button type="submit">Stundenliste erstellen</button>
</form>
{:else}
<form id="form_download" method="GET" action={`/dokumente/${document.path}`}>
<input type="hidden" name="month" value={month + 1} />
<input type="hidden" name="year" value={year} />
<button type="submit">Download</button>
</form>
{/if}
</td>
</tr>
</tbody>
</table>
</td>
{/if}
</tr>
{/each}
{#if entries.length > 0 && entries.length < 4}
{#each { length: 4 - entries.length }, i }
<tr style="border: none;"><td><span style:visibility="hidden">#</span></td></tr>
{/each}
{/if}
{/each} <!-- months -->
{/each} <!-- data.estimates -->
<tr></tr>
</tbody>
{#if data.records === undefined || data.records.length === 0}
@ -198,28 +299,71 @@ table {
margin: auto;
border-collapse: collapse;
border: 1px solid;
}
table caption {
font-size: 25px;
font-weight: bold;
tbody > :global(tr) {
border-top: 1px solid black;
border-bottom: 1px solid black;
}
tbody > tr:nth-child(odd) {
background: gray;
tbody > :global(tr:has(> td.year):has(td.month)) {
border-top: 3px double black;
}
tbody > :global(tr:has(> td.month)) {
border-top: 1.5px solid black;
}
tbody > tr:nth-child(even) {
background: lightgray;
tbody .year {
vertical-align: middle;
writing-mode: sideways-lr;
text-orientation: mixed;
}
tbody > tr > td:last-of-type {
tbody .month {
vertical-align: middle;
writing-mode: sideways-lr;
text-orientation: mixed;
}
.start, .end, .duration {
text-align: right;
}
tbody .action {
display: inline-flex;
}
tbody .stats {
min-height: fit-content;
vertical-align: middle;
}
tbody .stats table tr {
border: none;
}
tbody .stats table .diff {
border-top: 1px solid black;
}
tbody .stats table tr td:first-of-type {
text-align: right;
}
tbody .stats table tr td:last-of-type {
text-align: right;
white-space: nowrap;
}
.doc_action * {
width: 100%;
}
tbody tr td {
vertical-align: text-bottom;
padding-left: 10px;
padding-right: 10px;
}
tfoot > tr {
border-top: 1px solid black;
border-top: 1px solid red;
}
.td-no-elements {

View File

@ -1,13 +1,15 @@
import type { PageServerLoad, Actions } from "./$types";
import { fail } from "@sveltejs/kit"
import { fail, redirect } from "@sveltejs/kit"
import { toInt } from "$lib/util"
import { getAllFiles, generateEstimatePDF, generateRecordPDF } from "$lib/server/docstore";
import { getAllFiles, getRecordFiles, getEstimateFiles, generateEstimatePDF, generateRecordPDF } from "$lib/server/docstore";
export const load: PageServerLoad = async ({ locals }) => {
return {
documents: await getAllFiles(locals.user)
documents: await getAllFiles(locals.user),
records: await getRecordFiles(locals.user),
estimates: await getEstimateFiles(locals.user)
}
}
@ -47,7 +49,9 @@ export const actions = {
console.log(e);
return fail(403, { success: false, message: e.toString(), year: year, month: month });
}
redirect(303, "dokumente")
return { success: true };
}

View File

@ -5,14 +5,91 @@
import { isoToLocalDate, padInt } from "$lib/util";
import Expander from "./expander.svelte";
let { data, form } : PageProps = $props();
$inspect(data);
</script>
<div>
<h1>Dokumente</h1>
<form method="POST" id="create_est" action="?/create_estimate" use:enhance></form>
<form id="form_download" method="GET" >
{#snippet table(first_cell_name: string, rows)}
<table>
<thead>
<tr>
<th>{first_cell_name}</th>
<th>Dateiname</th>
<th>Erstellt am</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{#each rows as file}
<tr>
<td>{file.identifier}</td>
<td>{file.filename}</td>
<td>{isoToLocalDate(file.cdate.toISOString())}<br/>um {padInt(file.cdate.getHours(), 2)}:{padInt(file.cdate.getMinutes(), 2)}:{padInt(file.cdate.getSeconds(), 2)}</td>
<td>
<form method="GET" action={`dokumente/${file.path}`} target="_blank">
<button type="submit">Download</button>
</form>
</td>
</tr>
{/each}
</tbody>
</table>
{/snippet}
<div class="content">
<Expander id="records" label="Stundenlisten">
{@render table("Monat", data.records)}
</Expander>
<div style:height="25px"></div>
<Expander id="estimates" label="Stundenschätzungen">
{@render table("Quartal", data.estimates)}
</Expander>
<div style:height="25px"></div>
<Expander id="history" label="Verlauf">
<table>
<thead>
<tr>
<th>Typ</th>
<th>Datum</th>
<th>Dateiname</th>
<th>Erstellt am</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{#each data.history as file}
<tr>
<td>{file.type}</td>
<td>{file.identifier}</td>
<td>{file.filename}</td>
<td>{isoToLocalDate(file.cdate.toISOString())}<br/>um {padInt(file.cdate.getHours(), 2)}:{padInt(file.cdate.getMinutes(), 2)}:{padInt(file.cdate.getSeconds(), 2)}</td>
<td></td>
</tr>
{/each}
<tr>
</tbody>
</table>
</Expander>
</div>
<div style:height="100px"></div>
<!--<form method="POST" id="create_est" action="?/create_estimate" use:enhance></form>
<form method="POST" id="create_rec" action="?/create_record" use:enhance></form>
<table>
@ -79,13 +156,12 @@
</tr>
</tfoot>
{/if}
</table>
</table> -->
</div>
<style>
h1 {
width: 100%;
text-align: center;
@ -93,6 +169,41 @@ h1 {
font-weight: bold;
}
.content {
width: 80%;
margin-right: auto;
margin-left: auto;
}
table {
width: 80%;
margin: auto;
border-collapse: collapse;
}
tbody tr {
border-top: 1px solid black;
border-bottom: 1px solid black;
}
tbody tr th {
text-align: center;
}
tbody tr td {
vertical-align: middle;
text-align: center;
padding-top: 2px;
padding-bottom: 2px;
padding-left: 10px;
padding-right: 10px;
}
/*
form {
width: fit-content;
border: none;
@ -128,5 +239,5 @@ tfoot {
.td-no-elements {
text-align: center;
}
}*/
</style>

View File

@ -0,0 +1,62 @@
<script lang="ts">
import type { Snippet } from "svelte";
let { id, label, children } : { label: string, children: Snippet } = $props();
let _id = id + "_check";
</script>
<input type="checkbox" id={_id} style:display="none">
<label for={_id}>
<span class="twister closed"><span class="twister-inner">&#x25b7;</span></span><span class="twister open"><span class="twister-inner">&#x25bd;</span></span>{label}
</label>
<div class="hidden">
{@render children?.()}
</div>
<style>
label {
display: block;
width: 100%;
border-bottom: 1px solid black;
}
.twister {
width: 1ch;
padding-right: 2ch;
}
.twister-inner {
height: 100%;
vertical-align: middle;
font-size: 11px;
}
.open {
display: none;
}
.closed {
display: inline-block;
}
.hidden {
display: none;
padding-top: 5px;
padding-left: 3ch;
}
:checked + label + .hidden {
display: block;
}
:checked + label .closed {
display: none;
}
:checked + label .open {
display: inline-block;
}
</style>

View File

@ -62,17 +62,15 @@
return weekday_of(date);
})
let inDuration: string = $state("");
updateDuration();
function updateDuration() {
$effect(() => {
states;
if ((states?.start?.valid && states?.end?.valid)
&& (states?.start?.value != null && states?.end?.value != null)) {
let duration = calculateDuration(states.start.value, states.end.value);
inDuration = isNaN(duration) ? "" : duration.toFixed(2);
}
}
})
function validateDate(element: HTMLInputElement) {
@ -498,7 +496,10 @@
</script>
<tr>
<!--<tr>
{#each { length: prepend_td }, n}
<td></td>
{/each}-->
<td>
<input
bind:this={dateInput}
@ -545,7 +546,6 @@
(_) => {
states.start.valid = validateTime(startInput);
states.start.value = startInput.value;
updateDuration();
}
}
disabled={!enabled}
@ -570,7 +570,6 @@
(_) => {
states.end.valid = validateTime(endInput);
states.end.value = endInput.value;
updateDuration();
}
}
disabled={!enabled}
@ -590,11 +589,11 @@
disabled={!enabled}>
</td>
<td>
<td class="action">
{@render children?.()}
</td>
</tr>
<!--</tr>-->
<style>
@ -608,10 +607,10 @@ td input {
vertical-align: middle;
border: none;
border: 1px solid gray;
}
tr td:last-of-type {
.action {
display: inline-flex;
}

View File

@ -1,10 +1,41 @@
import type { PageServerLoad, Actions } from "./$types";
import { fail } from '@sveltejs/kit';
import { toInt, toFloat } from "$lib/util";
import { MONTHS, toInt, toFloat } from "$lib/util";
import { getEstimateFiles } from "$lib/server/docstore";
export const load: PageServerLoad = async ({ locals }) => {
let estimates = locals.user.get_estimates();
let documents = await getEstimateFiles(locals.user);
let estimates_grouped = Map.groupBy(estimates, (v) => v.year);
estimates_grouped.forEach((value, key, map) => {
let quarters = Map.groupBy(value, (v) => v.quarter)
quarters.forEach((_value, _key, _map) => {
let months = _value.map((v) => [
{ month: MONTHS[(v.quarter - 1) * 3 + 2], estimate: v.estimate_2 },
{ month: MONTHS[(v.quarter - 1) * 3 + 1], estimate: v.estimate_1 },
{ month: MONTHS[(v.quarter - 1) * 3 + 0], estimate: v.estimate_0 },
] ).flat();
_map.set(_key, months);
})
map.set(key, quarters);
})
let documents_grouped = Map.groupBy(documents, (v) => toInt(v.identifier.slice(0, 4)))
documents_grouped.forEach((value, key, map) => {
let quarters = Map.groupBy(value, (v) => toInt(v.identifier.slice(5, 7)));
map.set(key, quarters);
})
export async function load({ locals }) {
return {
estimates: locals.user.get_estimates()
estimates: estimates_grouped,
documents: documents_grouped,
};
}
@ -24,7 +55,7 @@ export const actions = {
}
const y = toInt(year);
const q = toInt(quart)
const q = toInt(quart);
const est_0 = toFloat(estimate_0);
const est_1 = toFloat(estimate_1);
const est_2 = toFloat(estimate_2);
@ -33,6 +64,12 @@ export const actions = {
return fail(400, { year: year, quarter: quart, estimate_0: estimate_0, estimate_1: estimate_1, estimate_2: estimate_2 });
}
if (locals.user.get_estimate(y, q)) {
return fail(409, { reason: "Estimate already exists" } );
}
return { success: true };
const res = locals.user.insert_estimate(y, q, est_0, est_1, est_2)
if (!res) {
@ -41,4 +78,4 @@ export const actions = {
return { success: true };
}
}
} satisfies Actions;

View File

@ -1,23 +1,61 @@
<script lang="ts">
import { month_of } from "$lib/util";
import { enhance } from "$app/forms";
import type { PageProps } from "./$types";
interface Quarter {
year: number;
quarter: number;
estimate_0: number;
estimate_1: number;
estimate_2: number;
}
interface Props {
form: any;
data: {
estimates: Array<Quarter>;
import { enhance } from "$app/forms";
import { MONTHS, toInt, toFloat, padInt } from "$lib/util";
let { form, data }: PageProps = $props();
$inspect(data);
let next = $state((() => {
if (data.estimates.size == 0) {
return { year: (new Date()).getFullYear(), quarter: (new Date()).getMonth() / 3 + 1 };
}
const max_year = Math.max(...Array.from(data.estimates.keys()))
const max_quart = Math.max(...Array.from(data.estimates.get(max_year)?.keys() ?? [0]));
return { year: max_quart < 4 ? max_year : max_year + 1, quarter: max_quart < 4 ? max_quart + 1 : 1 };
})());
let new_quart = $state(next.quarter);
let estimate_store = $state({
estimate_0: { value: "" },
estimate_1: { value: "" },
estimate_2: { value: "" },
editing: { value: "" }
})
$inspect(estimate_store);
function validate_year(event: InputEvent) {
const input = event.target as HTMLInputElement;
if (event.inputType.startsWith("delete") || !isNaN(toInt(event.data ?? ""))) {
next.year = toInt(input.value);
} else {
input.value = next.year.toFixed(0);
}
}
let { form, data }: Props = $props();
const TODAY = new Date();
function validate_quarter(event: InputEvent) {
const input = event.target as HTMLInputElement;
let in_num = event.data != null ? toInt(event.data) : NaN;
if (event.inputType.startsWith("delete") || (!isNaN(in_num) && 1 <= in_num && in_num <= 4)) {
next.quarter = toInt(input.value);
} else {
input.value = next.quarter.toFixed(0);
}
}
function validate_estimate(event: InputEvent, estimate: { value: string }) {
const input = event.target as HTMLInputElement;
if (event.inputType.startsWith("delete") || !isNaN(toFloat(input.value))) {
estimate.value = input.value;
} else {
input.value = estimate.value;
}
}
/*const TODAY = new Date();
const NEXT_QUART = (data?.estimates?.length > 0)
? new Date(data.estimates[0].year + (data.estimates[0].quarter === 4 ? 1 : 0), 4 * (data.estimates[0].quarter - (data.estimates[0].quarter === 4 ? -4 : 0)))
: TODAY;
@ -30,25 +68,116 @@
function month_from_quart(quart: number, num: number) {
return new Date(TODAY.getFullYear(), (quart - 1) * 4 + num);
}
}*/
</script>
<form id="form_add_quart" method="POST" action="?/add_quarter" use:enhance></form>
<form id="form_create_pdf" method="POST" action="/dokumente?/create_estimate" use:enhance></form>
<table>
<caption>Stundenschätzung</caption>
<h1>Stundenschätzung</h1>
<table class="add">
<colgroup>
<col style:min-width="30ch" />
<col style:width="12ch" />
<col style:width="10ch" />
<col style:width="7ch" />
</colgroup>
<tbody>
<tr>
<td rowspan="3">Neue Schätzung: <input form="form_add_quart" style:width="7ch" type="number" name="year" oninput={validate_year} value={next.year} tabindex="8"/> - <input form="form_add_quart" style:width="4ch" type="number" name="quarter" oninput={validate_quarter} value={new_quart} tabindex="9"/>. Quartal</td>
<td>{MONTHS[(new_quart - 1) * 3 + 0]}</td>
<td><input form="form_add_quart" type="text" name="estimate_0" oninput={(e) => validate_estimate(e, estimate_store.estimate_0)} tabindex="10" /></td>
<td rowspan="3"><button form="form_add_quart" type="submit" tabindex="13">Erstellen</button></td>
</tr>
<tr>
<td>{MONTHS[(new_quart - 1) * 3 + 1]}</td>
<td><input form="form_add_quart" type="text" name="estimate_1" oninput={(e) => validate_estimate(e, estimate_store.estimate_1)} tabindex="11" /></td>
</tr>
<tr>
<td>{MONTHS[(new_quart - 1) * 3 + 2]}</td>
<td><input form="form_add_quart" type="text" name="estimate_2" oninput={(e) => validate_estimate(e, estimate_store.estimate_2)} tabindex="12" /></td>
</tr>
</tbody>
</table>
<div style:width="100%" style:height="40px"></div>
<table class="list">
<thead>
<tr>
<th style:width="20ch">Quartal</th>
<th style:width="30ch">Monat</th>
<th style:width="30ch">Schätzung</th>
<th style:width="12ch">Aktion</th>
<th style:width="5em"></th>
<th style:width="10ch">Quartal</th>
<th style:width="20ch">Monat</th>
<th style:width="10ch">Soll</th>
<th style:width="12ch" colspan="2">Aktion</th>
</tr>
</thead>
<tbody>
{#each data.estimates as year_pair}
{@const year = year_pair[0]}
{@const quarter_map = year_pair[1]}
{#each quarter_map as quarter_pair, quarter_i}
{@const quarter = quarter_pair[0]}
{@const months = quarter_pair[1]}
{@const fill_str = "\u2014\u2007\u2007\u2007"}
<tr>
{#if quarter_i === 0}
<td class="year" rowspan={quarter_map.size * 3}>{year}</td>
{/if}
<td class="quarter" rowspan="3">{quarter}.</td>
<td>{months[0].month}</td>
<td class="estimate">{months[0].estimate != null ? padInt(months[0].estimate, 3, 2, "\u2007", false, false) : fill_str}</td>
<td class="action" rowspan="3">
{#if data.documents?.get(year)?.get(quarter)?.[0] != null}
{@const document = data.documents.get(year).get(quarter)[0]}
<form method="GET" action={`/dokumente/${document.path}`}>
<button type="submit">Download PDF</button>
</form>
{:else}
<form method="POST" action="/dokumente?/create_estimate" use:enhance>
<input type="hidden" name="year" value={year} />
<input type="hidden" name="quarter" value={quarter} />
<button type="submit">Create PDF</button>
</form>
{/if}
</td>
</tr>
<tr>
<td>{months[1].month}</td>
<td class="estimate">{months[1].estimate != null ? padInt(months[0].estimate, 3, 2, "\u2007", false, false) : fill_str}</td>
</tr>
<tr>
<td>{months[2].month}</td>
<td class="estimate">{months[2].estimate != null ? padInt(months[0].estimate, 3, 2, "\u2007", false, false) : fill_str}</td>
</tr>
{/each}
{/each}
</tbody>
{#if data.estimates === undefined || data.estimates.size === 0}
<tfoot>
<tr>
<td class="td-no-elements" colspan="999">No records</td>
</tr>
</tfoot>
{/if}
</table>
{"" /*<tr>
<td rowspan="3">{add_quart}. Quartal {NEXT_QUART.getFullYear()}</td>
<td>{month_of(month_from_quart(add_quart, 0))}</td>
@ -70,19 +199,9 @@
<td>{month_of(month_from_quart(add_quart, 2))}</td>
<!-- svelte-ignore a11y_positive_tabindex -->
<td><input form="form_add_quart" type="text" tabindex="3" name="estimate_2" value={form?.data?.estimate_2}/></td>
</tr>
{#each data.estimates as quarter}
{@const rowspan = (quarter.estimate_0 != null ? 1 : 0) + (quarter.estimate_1 != null ? 1 : 0) + (quarter.estimate_2 != null ? 1 : 0)}
{@const months = (() => {
let arr : { i: number, estimate: number}[] = [];
for (let i = 0; i < 3; ++i) {
if (quarter[`estimate_${i}`] != null) {
arr.push({i: i, estimate: quarter[`estimate_${i}`]});
}
}
return arr;
})()}
</tr>*/}
{"" /*
{#if months.length > 0}
<tr>
<td rowspan={rowspan}>
@ -100,19 +219,7 @@
<td>{month.estimate.toFixed(2)}</td>
</tr>
{/each}
{/if}
{/each}
</tbody>
{#if data.estimates === undefined || data.estimates.length === 0}
<tfoot>
<tr>
<td class="td-no-elements" colspan="999">No records</td>
</tr>
</tfoot>
{/if}
</table>
{/if}*/}
<style>
@ -122,43 +229,67 @@ form {
}
table {
width: 50%;
margin: auto;
border-collapse: collapse;
border: 1px solid;
border: none;
}
table caption {
font-size: 25px;
font-weight: bold;
.add {
tr {
border-bottom: solid 1px black;
}
tr:last-of-type {
border-bottom: none;
}
td {
text-align: center;
padding-left: 10px;
padding-right: 5px;
}
input {
width: 80%;
}
}
tbody > tr > td {
text-align: center;
}
.list {
/*width: 50%;*/
margin: auto;
/*tbody > tr:nth-child(odd) > td[rowspan="3"] {
background: lightgray;
}
tbody > tr:nth-child(even) > td[rowspan="3"] {
background: gray;
}*/
tbody > tr:nth-child(odd) {
background: gray;
}
tbody > tr:has(> td.year):has(td.quarter) {
border-top: 3px black double;
}
tbody > tr:nth-child(even) {
background: lightgray;
}
tbody > tr:has(> td.quarter) {
border-top: 1px black solid;
}
tfoot {
border-top: 1px solid black;
}
tbody .year {
vertical-align: middle;
writing-mode: sideways-lr;
text-orientation: mixed;
}
.td-no-elements {
text-align: center;
tbody .estimate {
padding-right: 1ch;
text-align: right;
}
tbody > tr > td {
text-align: center;
}
tfoot {
border-top: 1px solid black;
}
.td-no-elements {
text-align: center;
}
}
</style>

View File

@ -1,6 +1,6 @@
body {
background: #BBBBBB;
background: #FFFFFF;
}
/*body * {