301 lines
12 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import gm from 'avris-generator';
import { DateTime } from 'luxon';
import { useNuxtApp } from 'nuxt/app';
import { PermissionAreas } from '#shared/helpers.ts';
import {
min,
max,
closed,
MONTHS as months,
AREAS as areas,
TRANSFER_METHODS as transferMethods,
} from '#shared/timesheets.ts';
import type { TimesheetData, Timesheet } from '#shared/timesheets.ts';
import useDialogue from '~/composables/useDialogue.ts';
import useSimpleHead from '~/composables/useSimpleHead.ts';
const { $translator: translator } = useNuxtApp();
const dialogue = useDialogue();
useSimpleHead({
title: `${translator.translate('admin.header')} • Volunteering timesheets`,
}, translator);
const persistentAsyncData = useFetch<TimesheetData>('/api/admin/timesheet');
const moderationAsyncData = useFetch('/api/admin/moderation', { pick: ['timesheets'] });
await Promise.all([persistentAsyncData, moderationAsyncData]);
const persistent = persistentAsyncData.data;
const moderation = moderationAsyncData.data;
const years: number[] = [];
for (let y = min.year; y <= max.year; y++) {
years.push(y);
}
const year = ref(max.year);
const cells = ref<Map<string, HTMLElement>>(new Map());
watch(year, () => {
cells.value.get('1-0')?.focus();
});
const buildTimesheet = (persistent: TimesheetData | undefined): Timesheet => {
const timesheet: Timesheet = {};
for (let y = min.year; y <= max.year; y++) {
timesheet[y] = {};
for (const m of Object.keys(months)) {
timesheet[y][m] = {};
for (const area of areas) {
timesheet[y][m][area] = persistent?.timesheet?.[y]?.[m]?.[area] || 0;
}
}
}
return timesheet;
};
const timesheet = ref(buildTimesheet(persistent.value));
const transferMethod = ref<TimesheetData['transfer']>(persistent.value?.transfer || '');
const transferDetails = ref<TimesheetData['details']>({
bank_name: persistent.value?.details?.bank_name || '',
bank_iban: persistent.value?.details?.bank_iban || '',
bank_bic: persistent.value?.details?.bank_bic || '',
paypal_email: persistent.value?.details?.paypal_email || '',
charity_name: persistent.value?.details?.charity_name || '',
charity_url: persistent.value?.details?.charity_url || '',
notes: persistent.value?.details?.notes || '',
});
const focusMonth = ref<keyof typeof months | null>(null);
const focusArea = ref<number | null>(null);
const dt = (year: number, month: number) => {
return DateTime.local(year, month, 1);
};
const validateIBAN = (iban: string): string => {
if (!iban.trim()) {
return '';
}
try {
gm.validate('_', 'iban', iban);
return 'is-valid';
} catch (e) {
return 'is-invalid';
}
};
const validateBIC = (bic: string): string => {
if (!bic.trim()) {
return '';
}
return bic.replace(/ /g, '').match(/^[A-Z0-9]{8,11}$/)
? 'is-valid'
: 'is-invalid';
};
const save = async () => {
await dialogue.postWithAlertOnError('/api/admin/timesheet', {
timesheets: {
timesheet: timesheet.value,
transfer: transferMethod.value,
details: transferDetails.value,
},
});
await dialogue.alert('Saved successfully', 'success');
};
const cellKeydown = (e: KeyboardEvent) => {
let newFocusMonth = focusMonth.value ?? 1;
let newFocusArea = focusArea.value ?? 0;
switch (e.key) {
case 'ArrowLeft':
newFocusMonth--;
break;
case 'ArrowRight':
newFocusMonth++;
break;
case 'ArrowUp':
newFocusArea--;
break;
case 'ArrowDown':
newFocusArea++;
break;
default:
return;
}
e.preventDefault();
e.stopPropagation();
const cell = cells.value.get(`${newFocusMonth}-${newFocusArea}`);
if (!cell || cell.getAttribute('disabled')) {
return;
}
cell.focus();
};
const sumCells = (area: typeof areas[number] | undefined, month: keyof typeof months | undefined) => {
let sum = 0;
for (const m of Object.keys(timesheet.value[year.value])) {
if (month !== undefined && parseInt(m) !== parseInt(month as any as string)) {
continue;
}
for (const a in timesheet.value[year.value][m]) {
if (area !== undefined && a !== area) {
continue;
}
sum += timesheet.value[year.value][m][a] || 0;
}
}
return sum;
};
</script>
<template>
<Page wide>
<NotFound v-if="!$multiIsGranted([PermissionAreas.Panel, PermissionAreas.External])" />
<form v-else @submit.prevent="save">
<p class="d-flex justify-content-between">
<nuxt-link to="/admin">
<Icon v="user-cog" />
<T>admin.header</T>
</nuxt-link>
<span v-if="$isGranted(PermissionAreas.Panel)">
<nuxt-link to="/admin/timesheets/overview">
<Icon v="file-spreadsheet" />
Overview
</nuxt-link>
<nuxt-link to="/admin/timesheets/expenses">
<Icon v="file-spreadsheet" />
Declare expenses
</nuxt-link>
</span>
</p>
<div v-html="moderation?.timesheets"></div>
<hr>
<h3>
Year:
<button
v-for="y in years"
:key="y"
type="button"
:class="['btn', y === year ? 'btn-primary' : 'btn-outline-primary', 'mx-2']"
@click="year = y"
>
{{ y }}
</button>
</h3>
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Area</th>
<th v-for="month in months">
{{ month }}
</th>
<th>Sum</th>
</tr>
</thead>
<tbody>
<tr v-for="area in areas">
<th>{{ area }}</th>
<td v-for="(month, m) in months">
<input
:ref="(el) => cells.set(`${m}-${areas.indexOf(area)}`, el as HTMLInputElement)"
v-model.number="timesheet[year][m][area]"
type="number"
min="0"
max="160"
step="0.5"
class="form-control form-control-sm"
style="min-width: 3rem"
:disabled="dt(year, +m) < closed || dt(year, +m) > max"
required
@focus="focusMonth = m;focusArea = areas.indexOf(area)"
@keydown="cellKeydown"
>
</td>
<td>
<strong>
{{ sumCells(area, undefined) }}
</strong>
</td>
</tr>
<tr>
<th>Sum</th>
<td v-for="(month, m) in months">
<strong>
{{ sumCells(undefined, m) }}
</strong>
</td>
<td>
<strong>
{{ sumCells(undefined, undefined) }}
</strong>
</td>
</tr>
</tbody>
</table>
</div>
<section>
<p>
<strong>Transfer method:</strong>
<span v-for="(label, value) in transferMethods" class="form-check form-check-inline">
<input
:id="`method_${value}`"
v-model="transferMethod"
class="form-check-input"
type="radio"
name="inlineRadioOptions"
:value="value"
>
<label class="form-check-label" :for="`method_${value}`">{{ label }}</label>
</span>
</p>
<div v-if="transferMethod === 'bank'" class="mb-3">
<label for="bank_name" class="form-label">Owner name</label>
<input id="bank_name" v-model="transferDetails.bank_name" type="text" class="form-control" placeholder="Jay Doe">
</div>
<div v-if="transferMethod === 'bank'" class="mb-3">
<label for="bank_iban" class="form-label">IBAN</label>
<input id="bank_iban" v-model="transferDetails.bank_iban" type="text" :class="['form-control', validateIBAN(transferDetails.bank_iban)]" placeholder="NL12 ABCD 1234 5678 90">
</div>
<div v-if="transferMethod === 'bank'" class="mb-3">
<label for="bank_bic" class="form-label">BIC</label>
<input id="bank_bic" v-model="transferDetails.bank_bic" type="text" :class="['form-control', validateBIC(transferDetails.bank_bic)]" placeholder="BUNQNL2A">
</div>
<div v-if="transferMethod === 'paypal'" class="mb-3">
<label for="paypal_email" class="form-label">Email</label>
<input id="paypal_email" v-model="transferDetails.paypal_email" type="email" class="form-control" placeholder="paypal-user@email.com">
</div>
<div v-if="transferMethod !== 'skip' && transferMethod !== 'charity'" class="mb-3">
<p><em>There's a legal limit of how much we can send as volunteer allowance, so please also pick a charity in case it's exceeded.</em></p>
</div>
<div v-if="transferMethod !== 'skip'" class="mb-3">
<p><em>You can also leave the charity details empty in that case we'll set aside that share and pick a charity together at the end of year, or whenever need arises.</em></p>
</div>
<div v-if="transferMethod !== 'skip'" class="mb-3">
<label for="charity_name" class="form-label">Charity name</label>
<input id="charity_name" v-model="transferDetails.charity_name" type="text" class="form-control" placeholder="Trevor Project">
</div>
<div v-if="transferMethod !== 'skip'" class="mb-3">
<label for="charity_url" class="form-label">Link</label>
<input id="charity_url" v-model="transferDetails.charity_url" type="text" class="form-control" placeholder="https://www.thetrevorproject.org/">
</div>
<div v-if="transferMethod" class="mb-3">
<label for="notes" class="form-label">Notes</label>
<input id="notes" v-model="transferDetails.notes" type="text" class="form-control" placeholder="">
</div>
</section>
<button type="submit" class="btn btn-primary" :disabled="!transferMethod">
<Icon v="save" />
Save
</button>
</form>
</Page>
</template>