mirror of
https://gitlab.com/PronounsPage/PronounsPage.git
synced 2025-09-25 22:19:28 -04:00
301 lines
12 KiB
Vue
301 lines
12 KiB
Vue
<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>
|