summaryrefslogtreecommitdiffstats
path: root/apps/projects-web/src/app/pages/data.svelte
diff options
context:
space:
mode:
authorivarlovlie <git@ivarlovlie.no>2022-06-01 22:10:32 +0200
committerivarlovlie <git@ivarlovlie.no>2022-06-01 22:10:32 +0200
commita640703f2da8815dc26ad1600a6f206be1624379 (patch)
treedbda195fb5783d16487e557e06471cf848b75427 /apps/projects-web/src/app/pages/data.svelte
downloadgreatoffice-a640703f2da8815dc26ad1600a6f206be1624379.tar.xz
greatoffice-a640703f2da8815dc26ad1600a6f206be1624379.zip
feat: Initial after clean slate
Diffstat (limited to 'apps/projects-web/src/app/pages/data.svelte')
-rw-r--r--apps/projects-web/src/app/pages/data.svelte392
1 files changed, 392 insertions, 0 deletions
diff --git a/apps/projects-web/src/app/pages/data.svelte b/apps/projects-web/src/app/pages/data.svelte
new file mode 100644
index 0000000..070b98b
--- /dev/null
+++ b/apps/projects-web/src/app/pages/data.svelte
@@ -0,0 +1,392 @@
+<script>
+ import {IconNames} from "$shared/lib/configuration";
+ import {onMount} from "svelte";
+ import {Temporal} from "@js-temporal/polyfill";
+ import Layout from "./_layout.svelte";
+ import Modal from "$shared/components/modal.svelte";
+ import Tile from "$shared/components/tile.svelte";
+ import Icon from "$shared/components/icon.svelte";
+ import EntryForm from "$app/pages/views/entry-form/index.svelte";
+ import {Table, THead, TBody, TCell, TRow, TablePaginator} from "$shared/components/table";
+ import {TimeEntryQueryDuration} from "$shared/lib/models/TimeEntryQuery";
+ import {delete_time_entry, get_time_entries, get_time_entry} from "$shared/lib/api/time-entry";
+ import {seconds_to_hour_minute_string, is_guid, move_focus, unwrap_date_time_from_entry} from "$shared/lib/helpers";
+ import Button from "$shared/components/button.svelte";
+
+ let pageCount = 1;
+ let page = 1;
+
+ const defaultQuery = {
+ duration: TimeEntryQueryDuration.THIS_YEAR,
+ categories: [],
+ labels: [],
+ page: page,
+ pageSize: 50,
+ };
+
+ let isLoading;
+ let categories = [];
+ let labels = [];
+ let entries = [];
+ let durationSummary = false;
+ let EditEntryModal;
+ let EditEntryForm;
+ let currentTimespanFilter = TimeEntryQueryDuration.THIS_YEAR;
+ let currentSpecificDateFilter = Temporal.Now.plainDateTimeISO().subtract({days: 1}).toString().substring(0, 10);
+ let currentDateRangeFilter = {};
+ let currentCategoryFilter = "all";
+ let currentLabelFilter = "all";
+ let showDateFilterOptions = false;
+ let secondsLogged = 0;
+
+ function set_duration_summary_string() {
+ if (entries.length > 0) {
+ durationSummary = `Showing ${entries.length} ${entries.length === 1 ? "entry" : "entries"}, totalling in ${seconds_to_hour_minute_string(secondsLogged)}`;
+ } else {
+ durationSummary = "";
+ }
+ }
+
+ async function load_entries(query = defaultQuery) {
+ isLoading = true;
+ const response = await get_time_entries(query);
+ if (response.status === 200) {
+ const responseEntries = [];
+ secondsLogged = 0;
+ for (const entry of response.data.results) {
+ const date_time = unwrap_date_time_from_entry(entry);
+ const seconds = (date_time.duration.hours * 60 * 60) + (date_time.duration.minutes * 60);
+ responseEntries.push({
+ id: entry.id,
+ date: date_time.start_date,
+ start: date_time.start_time,
+ stop: date_time.stop_time,
+ durationString: date_time.duration.hours + "h" + date_time.duration.minutes + "m",
+ seconds: seconds,
+ category: entry.category,
+ labels: entry.labels,
+ description: entry.description,
+ });
+ secondsLogged += seconds;
+ }
+ entries = responseEntries;
+ page = response.data.page;
+ pageCount = response.data.totalPageCount;
+ } else {
+ entries = [];
+ page = 0;
+ pageCount = 0;
+ }
+ isLoading = false;
+ set_duration_summary_string();
+ }
+
+ function load_entries_with_filter(page = 1) {
+ let query = defaultQuery;
+ query.duration = currentTimespanFilter;
+ query.labels = [];
+ query.categories = [];
+ query.page = page;
+
+ if (currentTimespanFilter === TimeEntryQueryDuration.SPECIFIC_DATE) {
+ query.specificDate = currentSpecificDateFilter;
+ } else {
+ delete query.specificDate;
+ }
+
+ if (currentTimespanFilter === TimeEntryQueryDuration.DATE_RANGE) {
+ query.dateRange = currentDateRangeFilter;
+ } else {
+ delete query.dateRange;
+ }
+
+ if ((currentCategoryFilter !== "all" && currentCategoryFilter?.length > 0) ?? false) {
+ for (const chosenCategoryId of currentCategoryFilter) {
+ if (chosenCategoryId === "all") {
+ continue;
+ }
+ query.categories.push({
+ id: chosenCategoryId,
+ });
+ }
+ }
+
+ if ((currentLabelFilter !== "all" && currentLabelFilter?.length > 0) ?? false) {
+ for (const chosenLabelId of currentLabelFilter) {
+ if (chosenLabelId === "all") {
+ continue;
+ }
+ query.labels.push({
+ id: chosenLabelId,
+ });
+ }
+ }
+
+ load_entries(query);
+ }
+
+ async function handle_delete_entry_button_click(e, entryId) {
+ if (confirm("Are you sure you want to delete this entry?")) {
+ const response = await delete_time_entry(entryId);
+ if (response.ok) {
+ const indexOfEntry = entries.findIndex((c) => c.id === entryId);
+ if (indexOfEntry !== -1) {
+ secondsLogged -= entries[indexOfEntry].seconds;
+ entries.splice(indexOfEntry, 1);
+ entries = entries;
+ set_duration_summary_string();
+ }
+ }
+ }
+ }
+
+ function handle_edit_entry_form_updated() {
+ load_entries_with_filter(page);
+ EditEntryModal.close();
+ }
+
+ async function handle_edit_entry_button_click(event, entryId) {
+ const response = await get_time_entry(entryId);
+ if (response.status === 200) {
+ if (is_guid(response.data.id)) {
+ EditEntryForm.set_values(response.data);
+ EditEntryModal.open();
+ move_focus(document.querySelector("input[id='date']"));
+ }
+ }
+ }
+
+ function close_date_filter_box(event) {
+ if (!event.target.closest(".date_filter_box_el")) {
+ showDateFilterOptions = false;
+ window.removeEventListener("click", close_date_filter_box);
+ }
+ }
+
+ function toggle_date_filter_box(event) {
+ const box = document.getElementById("date_filter_box");
+ const rect = event.target.getBoundingClientRect();
+ box.style.top = rect.y + "px";
+ box.style.left = rect.x - 50 + "px";
+ showDateFilterOptions = true;
+ window.addEventListener("click", close_date_filter_box);
+ }
+
+ onMount(() => {
+ isLoading = true;
+ Promise.all([load_entries()]).then(() => {
+ isLoading = false;
+ });
+ });
+</script>
+
+<Modal title="Edit entry"
+ bind:functions={EditEntryModal}
+ on:closed={() => EditEntryForm.reset()}>
+ <EntryForm bind:functions={EditEntryForm}
+ on:updated={handle_edit_entry_form_updated}/>
+</Modal>
+
+<div id="date_filter_box"
+ style="margin-top:25px"
+ class="padding-xs z-index-overlay bg shadow-sm position-absolute date_filter_box_el border {showDateFilterOptions ? '' : 'hide'}">
+ <div class="flex items-baseline margin-bottom-xxxxs">
+ <label class="text-sm color-contrast-medium margin-right-xs"
+ for="durationSelect">Timespan:</label>
+ <div class="select inline-block js-select">
+ <select name="durationSelect"
+ bind:value={currentTimespanFilter}
+ id="durationSelect">
+ <option value={TimeEntryQueryDuration.TODAY}
+ selected> Today
+ </option>
+ <option value={TimeEntryQueryDuration.THIS_WEEK}>This week</option>
+ <option value={TimeEntryQueryDuration.THIS_MONTH}>This month</option>
+ <option value={TimeEntryQueryDuration.THIS_YEAR}>This year</option>
+ <option value={TimeEntryQueryDuration.SPECIFIC_DATE}>Spesific date</option>
+ <option value={TimeEntryQueryDuration.DATE_RANGE}>Date range</option>
+ </select>
+
+ <svg class="icon icon--xxxs margin-left-xxs"
+ viewBox="0 0 8 8">
+ <path d="M7.934,1.251A.5.5,0,0,0,7.5,1H.5a.5.5,0,0,0-.432.752l3.5,6a.5.5,0,0,0,.864,0l3.5-6A.5.5,0,0,0,7.934,1.251Z"/>
+ </svg>
+ </div>
+ </div>
+
+ {#if currentTimespanFilter === TimeEntryQueryDuration.SPECIFIC_DATE}
+ <div class="flex items-baseline margin-bottom-xxxxs justify-between">
+ <span class="text-sm color-contrast-medium margin-right-xs">Date:</span>
+ <span class="text-sm">
+ <input type="date"
+ class="border-none padding-0 color-inherit bg-transparent"
+ bind:value={currentSpecificDateFilter}/>
+ </span>
+ </div>
+ {/if}
+
+ {#if currentTimespanFilter === TimeEntryQueryDuration.DATE_RANGE}
+ <div class="flex items-baseline margin-bottom-xxxxs justify-between">
+ <span class="text-sm color-contrast-medium margin-right-xs">From:</span>
+ <span class="text-sm">
+ <input type="date"
+ class="border-none padding-0 color-inherit bg-transparent"
+ on:change={(e) => (currentDateRangeFilter.from = e.target.value)}/>
+ </span>
+ </div>
+
+ <div class="flex items-baseline margin-bottom-xxxxs justify-between">
+ <span class="text-sm color-contrast-medium margin-right-xs">To:</span>
+ <span class="text-sm">
+ <input type="date"
+ class="border-none padding-0 color-inherit bg-transparent"
+ on:change={(e) => (currentDateRangeFilter.to = e.target.value)}/>
+ </span>
+ </div>
+ {/if}
+
+ <div class="flex items-baseline justify-end">
+ <Button variant="subtle"
+ on:click={() => load_entries_with_filter(page)}
+ class="text-sm"
+ text="Save"/>
+ </div>
+</div>
+
+<Layout>
+ <Tile class="{isLoading ? 'c-disabled loading' : ''}">
+ <nav class="s-tabs text-sm">
+ <ul class="s-tabs__list">
+ <li><span class="s-tabs__link s-tabs__link--current">All (21)</span></li>
+ <li><span class="s-tabs__link">Published (19)</span></li>
+ <li><span class="s-tabs__link">Draft (2)</span></li>
+ </ul>
+ </nav>
+ <div class="max-width-100% overflow-auto"
+ style="max-height: 82.5vh">
+ <Table class="text-sm width-100% int-table--sticky-header">
+ <THead>
+ <TCell type="th"
+ style="width: 30px;">
+ <div class="custom-checkbox int-table__checkbox">
+ <input class="custom-checkbox__input"
+ type="checkbox"
+ aria-label="Select all rows"/>
+ <div class="custom-checkbox__control"
+ aria-hidden="true"></div>
+ </div>
+ </TCell>
+
+ <TCell type="th"
+ style="width: 100px">
+ <div class="flex items-center justify-between">
+ <span>Date</span>
+ <div class="date_filter_box_el cursor-pointer"
+ on:click={toggle_date_filter_box}>
+ <Icon name="{IconNames.funnel}"/>
+ </div>
+ </div>
+ </TCell>
+
+ <TCell type="th"
+ style="width: 100px">
+ <div class="flex items-center">
+ <span>Duration</span>
+ </div>
+ </TCell>
+
+ <TCell type="th"
+ style="width: 100px;">
+ <div class="flex items-center">
+ <span>Category</span>
+ </div>
+ </TCell>
+
+ <TCell type="th"
+ style="width: 300px;">
+ <div class="flex items-center">
+ <span>Description</span>
+ </div>
+ </TCell>
+ <TCell type="th"
+ style="width: 50px"></TCell>
+ </THead>
+ <TBody>
+ {#if entries.length > 0}
+ {#each entries as entry}
+ <TRow class="text-nowrap"
+ data-id={entry.id}>
+ <TCell type="th"
+ thScope="row">
+ <div class="custom-checkbox int-table__checkbox">
+ <input class="custom-checkbox__input"
+ type="checkbox"
+ aria-label="Select this row"/>
+ <div class="custom-checkbox__control"
+ aria-hidden="true"></div>
+ </div>
+ </TCell>
+ <TCell>
+ <pre>{entry.date.toLocaleString()}</pre>
+ </TCell>
+ <TCell>
+ <pre class="flex justify-between">
+ <div class="flex justify-between">
+ <span>{entry.start.toLocaleString(undefined, {timeStyle: "short"})}</span>
+ <span> - </span>
+ <span>{entry.stop.toLocaleString(undefined, {timeStyle: "short"})}</span>
+ </div>
+ </pre>
+ </TCell>
+ <TCell>
+ <span data-id={entry.category.id}>{entry.category.name}</span>
+ </TCell>
+ <TCell class="text-truncate max-width-xxxxs"
+ title="{entry.description}">
+ {entry.description ?? ""}
+ </TCell>
+ <TCell class="flex flex-row justify-end items-center">
+ <Button icon="{IconNames.pencilSquare}"
+ variant="reset"
+ icon_width="1.2rem"
+ icon_height="1.2rem"
+ on:click={(e) => handle_edit_entry_button_click(e, entry.id)}
+ title="Edit entry"/>
+ <Button icon="{IconNames.trash}"
+ variant="reset"
+ icon_width="1.2rem"
+ icon_height="1.2rem"
+ on:click={(e) => handle_delete_entry_button_click(e, entry.id)}
+ title="Delete entry"/>
+ </TCell>
+ </TRow>
+ {/each}
+ {:else}
+ <TRow class="text-nowrap">
+ <TCell type="th"
+ thScope="row"
+ colspan="7">
+ {isLoading ? "Loading..." : "No entries"}
+ </TCell>
+ </TRow>
+ {/if}
+ </TBody>
+ </Table>
+ </div>
+ <div class="flex items-center justify-between">
+ <p class="text-sm">
+ {#if durationSummary}
+ <small class={isLoading ? "c-disabled loading" : ""}>{durationSummary}</small>
+ {:else}
+ <small class={isLoading ? "c-disabled loading" : ""}>No entries</small>
+ {/if}
+ </p>
+
+ <nav class="grid padding-y-sm {isLoading ? 'c-disabled loading' : ''}">
+ <TablePaginator {page}
+ on:value_change={(e) => load_entries_with_filter(e.detail.newValue)}
+ {pageCount}/>
+ </nav>
+ </div>
+ </Tile>
+</Layout>