Stundenaufzeichnung/src/routes/+page.svelte

374 lines
9.8 KiB
Svelte

<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 { 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 } : PageProps = $props();
//$inspect(data);
const HEADERS: string[] = [ "Datum", "Wochentag", "Beginn", "Ende", "Dauer", "Anmerkung" ];
const status_ok = "ok";
const status_missing = "missing";
const status_invalid = "invalid";
let new_state = $state() as RowState;
let editing: RecordEntry | null = $state(null);
let edit_state = $state() as RowState;
setNewState();
setEditing();
$effect(() => {
if (form) {
untrack(() => setNewState());
}
})
$effect(() => {
if (form) {
untrack(() => setEditing(null));
}
})
function setNewState() {
new_state = {
date: {
valid: form?.new_entry?.date?.value !== "" || form?.new_entry?.date?.valid,
value: form?.new_entry?.date?.value ?? "",
},
start: {
valid: form?.new_entry?.start?.value !== "" || form?.new_entry?.start?.valid,
value: form?.new_entry?.start?.value ?? "",
},
end: {
valid: form?.new_entry?.end?.value !== "" || form?.new_entry?.end?.valid,
value: form?.new_entry?.end?.value ?? "",
},
comment: {
value: form?.new_entry?.date?.comment ?? "",
},
}
}
function setEditing(entry: RecordEntry | null = null) {
if (entry) {
editing = entry;
} else if (form?.edit_entry) {
editing = form.edit_entry
} else if (page.url.searchParams.get("edit")) {
let id = toInt(page.url.searchParams.get("edit")!)
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;
}
edit_state = {
date: {
valid: editing?.id != form?.edit_entry?.id || !(form?.edit_entry?.date?.status && form.edit_entry.date.status != status_ok),
value: editing?.date ?? ""
},
start: {
valid: editing?.id != form?.edit_entry?.id || !(form?.edit_entry?.start?.status && form.edit_entry.start.status != status_ok),
value: editing?.start ?? ""
},
end: {
valid: editing?.id != form?.edit_entry?.id || !(form?.edit_entry?.end?.status && form.edit_entry.end.status != status_ok),
value: editing?.end ?? ""
},
comment: {
value: editing?.comment ?? ""
},
}
}
function validateForm(event: Event, state: RowState): boolean {
let valid = state.date.valid && state.date.value.length !== 0
&& state.start.valid && state.start.value.length !== 0
&& state.end.valid && state.end.value.length !== 0;
if (!valid) {
event.preventDefault();
}
return valid;
}
</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>
<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></th>
<th></th>
<th>Datum</th>
<th>Wochentag</th>
<th>Beginn</th>
<th>Ende</th>
<th>Dauer</th>
<th>Anmerkung</th>
<th>Actions</th>
<th></th>
</tr>
</thead>
<tbody>
<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} autofocus={true}>
<button
type="submit"
form="form_new_entry"
disabled={editing != null}>
+
</button>
</RecordInputRow>
</tr>
{#each data.estimates as estimates_pair}
{@const year = estimates_pair[0] }
{@const mmonths = estimates_pair[1] }
{@const yentries = data.records.get(year) ?? new Map()}
{#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 + 1)}
<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}`} target="_blank">
<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}
<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: 80%;
margin: auto;
border-collapse: collapse;
}
tbody > :global(tr) {
border-top: 1px solid black;
border-bottom: 1px solid black;
}
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 .year {
vertical-align: middle;
writing-mode: sideways-lr;
text-orientation: mixed;
}
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 red;
}
.td-no-elements {
text-align: center;
}
</style>