Merge pull request 'release/nicolas/v1.1' (#73) from release/nicolas/v1.1 into main

Reviewed-on: Targo/targo_frontend#73
This commit is contained in:
Nicolas 2026-01-30 14:41:03 -05:00
commit 5889482df5
13 changed files with 218 additions and 108 deletions

105
package-lock.json generated
View File

@ -12,6 +12,7 @@
"@quasar/extras": "^1.17.0", "@quasar/extras": "^1.17.0",
"axios": "^1.11.0", "axios": "^1.11.0",
"chart.js": "^4.5.0", "chart.js": "^4.5.0",
"crc": "^4.3.2",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.4.1", "pinia-plugin-persistedstate": "^4.4.1",
@ -3091,7 +3092,7 @@
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true, "devOptional": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -3152,6 +3153,30 @@
"readable-stream": "^3.4.0" "readable-stream": "^3.4.0"
} }
}, },
"node_modules/bl/node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/bl/node_modules/readable-stream": { "node_modules/bl/node_modules/readable-stream": {
"version": "3.6.2", "version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@ -3318,10 +3343,10 @@
} }
}, },
"node_modules/buffer": { "node_modules/buffer": {
"version": "5.7.1", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"dev": true, "devOptional": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -3336,10 +3361,9 @@
"url": "https://feross.org/support" "url": "https://feross.org/support"
} }
], ],
"license": "MIT",
"dependencies": { "dependencies": {
"base64-js": "^1.3.1", "base64-js": "^1.3.1",
"ieee754": "^1.1.13" "ieee754": "^1.2.1"
} }
}, },
"node_modules/buffer-builder": { "node_modules/buffer-builder": {
@ -4018,6 +4042,22 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/crc": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/crc/-/crc-4.3.2.tgz",
"integrity": "sha512-uGDHf4KLLh2zsHa8D8hIQ1H/HtFQhyHrc0uhHBcoKGol/Xnb+MPYfUMw7cvON6ze/GUESTudKayDcJC5HnJv1A==",
"engines": {
"node": ">=12"
},
"peerDependencies": {
"buffer": ">=6.0.3"
},
"peerDependenciesMeta": {
"buffer": {
"optional": true
}
}
},
"node_modules/crc-32": { "node_modules/crc-32": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
@ -4139,6 +4179,30 @@
"node": "^18.0.0 || ^20.0.0 || >=22.0.0" "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
} }
}, },
"node_modules/cypress/node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/cypress/node_modules/proxy-from-env": { "node_modules/cypress/node_modules/proxy-from-env": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz",
@ -5870,7 +5934,7 @@
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"dev": true, "devOptional": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -8173,31 +8237,6 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
} }
}, },
"node_modules/readable-stream/node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/readdir-glob": { "node_modules/readdir-glob": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz",

View File

@ -17,6 +17,7 @@
"@quasar/extras": "^1.17.0", "@quasar/extras": "^1.17.0",
"axios": "^1.11.0", "axios": "^1.11.0",
"chart.js": "^4.5.0", "chart.js": "^4.5.0",
"crc": "^4.3.2",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.4.1", "pinia-plugin-persistedstate": "^4.4.1",

View File

@ -6,19 +6,22 @@
import SchedulePresetsDialog from 'src/modules/employee-list/components/schedule-presets-dialog.vue'; import SchedulePresetsDialog from 'src/modules/employee-list/components/schedule-presets-dialog.vue';
import AddModifyDialogSchedulePreview from './add-modify-dialog-schedule-preview.vue'; import AddModifyDialogSchedulePreview from './add-modify-dialog-schedule-preview.vue';
import { onMounted, ref, watch } from 'vue'; import { onMounted, ref } from 'vue';
import { useSchedulePresetsStore } from 'src/stores/schedule-presets.store'; import { useSchedulePresetsStore } from 'src/stores/schedule-presets.store';
import { useEmployeeStore } from 'src/stores/employee-store'; import { useEmployeeStore } from 'src/stores/employee-store';
import { useEmployeeListApi } from '../composables/use-employee-api'; import { useEmployeeListApi } from '../composables/use-employee-api';
import type { PresetManagerMode } from 'src/modules/employee-list/models/schedule-presets.models'; import type { PresetManagerMode } from 'src/modules/employee-list/models/schedule-presets.models';
// ================= state ======================
const schedule_preset_store = useSchedulePresetsStore(); const schedule_preset_store = useSchedulePresetsStore();
const employee_store = useEmployeeStore(); const employee_store = useEmployeeStore();
const employee_list_api = useEmployeeListApi(); const employee_list_api = useEmployeeListApi();
const preset_options = ref<{ label: string, value: number }[]>([]); const preset_options = ref<{ label: string, value: number }[]>([]);
const current_preset = ref<{ label: string | undefined, value: number }>({ label: undefined, value: -1 }); const current_preset = ref<{ label: string | undefined, value: number }>({ label: undefined, value: -1 });
const manager_watcher = ref(schedule_preset_store.is_manager_open);
// ====================== methods ========================
const getPresetOptions = (): { label: string, value: number }[] => { const getPresetOptions = (): { label: string, value: number }[] => {
const options = schedule_preset_store.schedule_presets.map(preset => { return { label: preset.name, value: preset.id } }); const options = schedule_preset_store.schedule_presets.map(preset => { return { label: preset.name, value: preset.id } });
@ -41,8 +44,6 @@
onMounted(() => { onMounted(() => {
loadSelectedPresetOption(); loadSelectedPresetOption();
}); });
watch(manager_watcher, loadSelectedPresetOption)
</script> </script>
<template> <template>
@ -50,7 +51,7 @@
:key="schedule_preset_store.is_manager_open === false ? '0' : '1'" :key="schedule_preset_store.is_manager_open === false ? '0' : '1'"
class="column full-width flex-center items-start" class="column full-width flex-center items-start"
> >
<SchedulePresetsDialog /> <SchedulePresetsDialog @before-hide="loadSelectedPresetOption"/>
<div class="col row justify-center full-width no-wrap"> <div class="col row justify-center full-width no-wrap">
<q-select <q-select

View File

@ -10,25 +10,22 @@
import { useTimesheetStore } from 'src/stores/timesheet-store'; import { useTimesheetStore } from 'src/stores/timesheet-store';
import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api'; import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api';
import { getExpenseIcon, useExpenseRules } from 'src/modules/timesheets/utils/expense.util'; import { getExpenseIcon, useExpenseRules } from 'src/modules/timesheets/utils/expense.util';
import { Expense, type ExpenseType, TYPES_WITH_AMOUNT_ONLY } from 'src/modules/timesheets/models/expense.models'; import { Expense, type ExpenseOption, TYPES_WITH_AMOUNT_ONLY } from 'src/modules/timesheets/models/expense.models';
import { useAuthStore } from 'src/stores/auth-store';
// ================= state ====================== // ================= state ======================
interface ExpenseOption {
label: string;
value: ExpenseType;
icon: string;
}
const expense = defineModel<Expense>({ default: new Expense(new Date().toISOString().slice(0, 10)) })
const COMMENT_MAX_LENGTH = 280; const COMMENT_MAX_LENGTH = 280;
const expense = defineModel<Expense>({ default: new Expense(new Date().toISOString().slice(0, 10)) })
const file = defineModel<File>('file');
const { t } = useI18n(); const { t } = useI18n();
const ui_store = useUiStore(); const ui_store = useUiStore();
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const expenses_store = useExpensesStore(); const expenses_store = useExpensesStore();
const auth_store = useAuthStore();
const expenses_api = useExpensesApi(); const expenses_api = useExpensesApi();
const files = defineModel<File[] | null>('files');
const is_navigator_open = ref(false); const is_navigator_open = ref(false);
const rules = useExpenseRules(t); const rules = useExpenseRules(t);
@ -62,8 +59,9 @@
} }
const requestExpenseCreationOrUpdate = async () => { const requestExpenseCreationOrUpdate = async () => {
await expenses_api.upsertExpense(expenses_store.current_expense, employeeEmail); if (file.value)
await expenses_api.upsertExpense(expenses_store.current_expense, file.value, employeeEmail ?? auth_store.user?.email ?? 'MISSING_EMAIL');
expenses_store.is_showing_create_form = true; expenses_store.is_showing_create_form = true;
expenses_store.mode = 'create'; expenses_store.mode = 'create';
expenses_store.current_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD')); expenses_store.current_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
@ -255,11 +253,9 @@
<!-- import attach file section --> <!-- import attach file section -->
<div class="col q-px-xs"> <div class="col q-px-xs">
<q-file <q-file
v-model="files" v-model="file"
standout standout
dense dense
use-chips
multiple
stack-label stack-label
label-slot label-slot
> >
@ -298,7 +294,7 @@
</template> </template>
<style scoped> <style scoped>
:deep(.q-field--standout.q-field--readonly .q-field__control::before) { :deep(.q-field--standout.q-field--readonly .q-field__control::before) {
border: transparent; border: transparent;
} }
</style> </style>

View File

@ -14,19 +14,21 @@
import { getExpenseIcon } from 'src/modules/timesheets/utils/expense.util'; import { getExpenseIcon } from 'src/modules/timesheets/utils/expense.util';
import type { Expense } from 'src/modules/timesheets/models/expense.models'; import type { Expense } from 'src/modules/timesheets/models/expense.models';
const { t } = useI18n(); // ================== state =====================
const expense = defineModel<Expense>({ required: true }); const expense = defineModel<Expense>({ required: true });
const expenses_api = useExpensesApi();
const expenses_store = useExpensesStore();
const timesheet_store = useTimesheetStore();
const is_showing_update_form = ref(false);
const { mode = 'normal' } = defineProps<{ const { mode = 'normal' } = defineProps<{
mode?: 'approval' | 'normal'; mode?: 'approval' | 'normal';
}>(); }>();
const { t } = useI18n();
const expenses_api = useExpensesApi();
const expenses_store = useExpensesStore();
const timesheet_store = useTimesheetStore();
const is_showing_update_form = ref(false);
// ===================== methods =========================
const requestExpenseDeletion = async () => { const requestExpenseDeletion = async () => {
await expenses_api.deleteExpenseById(expense.value.id); await expenses_api.deleteExpenseById(expense.value.id);

View File

@ -2,32 +2,34 @@
setup setup
lang="ts" lang="ts"
> >
import { computed, ref } from 'vue'; import { computed, ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useUiStore } from 'src/stores/ui-store'; import { useUiStore } from 'src/stores/ui-store';
import { useExpensesStore } from 'src/stores/expense-store'; import { useExpensesStore } from 'src/stores/expense-store';
import { useTimesheetStore } from 'src/stores/timesheet-store'; import { useTimesheetStore } from 'src/stores/timesheet-store';
import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api'; import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api';
import { getExpenseIcon, useExpenseRules } from 'src/modules/timesheets/utils/expense.util'; import { getExpenseIcon, useExpenseRules } from 'src/modules/timesheets/utils/expense.util';
import { type ExpenseType, TYPES_WITH_AMOUNT_ONLY } from 'src/modules/timesheets/models/expense.models'; import { Expense, type ExpenseOption, TYPES_WITH_AMOUNT_ONLY } from 'src/modules/timesheets/models/expense.models';
import { useAuthStore } from 'src/stores/auth-store';
interface ExpenseOption { const COMMENT_MAX_LENGTH = 280;
label: string;
value: ExpenseType; const expense = defineModel<Expense>({ default: new Expense(new Date().toISOString().slice(0, 10)) })
icon: string; const file = defineModel<File | undefined>('file');
}
const { employeeEmail } = defineProps<{
employeeEmail?: string;
}>();
const { t } = useI18n(); const { t } = useI18n();
const ui_store = useUiStore(); const ui_store = useUiStore();
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const expenses_store = useExpensesStore(); const expenses_store = useExpensesStore();
const auth_store = useAuthStore();
const expenses_api = useExpensesApi(); const expenses_api = useExpensesApi();
const files = defineModel<File[] | null>('files');
const is_navigator_open = ref(false); const is_navigator_open = ref(false);
const is_showing_comment_dialog_mobile = ref(false); const is_showing_comment_dialog_mobile = ref(false);
const COMMENT_MAX_LENGTH = 280;
const rules = useExpenseRules(t); const rules = useExpenseRules(t);
const period_start_date = computed(() => timesheet_store.pay_period?.period_start.replaceAll('-', '/') ?? ''); const period_start_date = computed(() => timesheet_store.pay_period?.period_start.replaceAll('-', '/') ?? '');
@ -49,8 +51,16 @@
}; };
const requestExpenseCreationOrUpdate = async () => { const requestExpenseCreationOrUpdate = async () => {
await expenses_api.upsertExpense(expenses_store.current_expense); if (file.value)
await expenses_api.upsertExpense(expenses_store.current_expense, file.value, employeeEmail ?? auth_store.user?.email ?? 'MISSING_EMAIL');
}; };
onMounted(() => {
if (expense.value)
expense_selected.value = expense_options.find(expense_option => expense_option.value === expense.value.type);
else
expense_selected.value = expense_options[1];
})
</script> </script>
<template> <template>
@ -64,9 +74,8 @@
class="col column items-start rounded-5 q-pb-sm" class="col column items-start rounded-5 q-pb-sm"
:class="expenses_store.is_showing_create_form ? 'q-px-md' : 'q-px-sm'" :class="expenses_store.is_showing_create_form ? 'q-px-md' : 'q-px-sm'"
> >
<!-- date and type row --> <!-- date selection input -->
<div class="col row q-my-xs full-width"> <div class="col-auto row q-my-xs full-width">
<!-- date selection input -->
<q-input <q-input
v-model="expenses_store.current_expense.date" v-model="expenses_store.current_expense.date"
dense dense
@ -75,7 +84,7 @@
stack-label stack-label
hide-bottom-space hide-bottom-space
color="primary" color="primary"
class="col-auto q-mr-sm" class="col-auto full-width"
input-class="text-weight-medium" input-class="text-weight-medium"
input-style="font-size: 1em;" input-style="font-size: 1em;"
:label="$t('timesheet.expense.date')" :label="$t('timesheet.expense.date')"
@ -112,8 +121,10 @@
</span> </span>
</template> </template>
</q-input> </q-input>
</div>
<!-- expenses type selection --> <!-- expenses type selection -->
<div class="col-auto row q-my-xs full-width">
<q-select <q-select
v-model="expense_selected" v-model="expense_selected"
standout="bg-blue-grey-9 text-white" standout="bg-blue-grey-9 text-white"
@ -125,12 +136,12 @@
hide-bottom-space hide-bottom-space
class="col" class="col"
color="primary" color="primary"
behavior="menu"
:menu-offset="[0, 5]"
:label="$t('timesheet.expense.type')" :label="$t('timesheet.expense.type')"
:menu-offset="[0, 10]" popup-content-class="text-uppercase text-weight-bold text-center rounded-5 z-top"
menu-anchor="bottom middle" popup-content-style="border: 3px solid var(--q-accent)"
menu-self="top middle" options-selected-class="text-weight-bolder text-white bg-accent"
popup-content-class="text-uppercase text-weight-bold text-center rounded-5"
popup-content-style="border: 2px solid var(--q-accent)"
:rules="[rules.typeRequired]" :rules="[rules.typeRequired]"
@update:model-value="option => expenses_store.current_expense.type = option.value" @update:model-value="option => expenses_store.current_expense.type = option.value"
> >
@ -218,7 +229,10 @@
class="col-auto" class="col-auto"
/> />
<q-dialog v-model="is_showing_comment_dialog_mobile" class="z-top"> <q-dialog
v-model="is_showing_comment_dialog_mobile"
class="z-top"
>
<q-card class="full-width bg-primary rounded-10"> <q-card class="full-width bg-primary rounded-10">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<span <span
@ -248,7 +262,7 @@
<!-- import attach file section --> <!-- import attach file section -->
<q-file <q-file
v-model="files" v-model="file"
standout="bg-blue-grey-9" standout="bg-blue-grey-9"
dense dense
use-chips use-chips
@ -273,7 +287,10 @@
</q-file> </q-file>
</div> </div>
<div class="col row full-width items-center" :class="expenses_store.mode === 'create' ? 'q-px-md q-py-xs' : ''"> <div
class="col row full-width items-center"
:class="expenses_store.mode === 'create' ? 'q-px-md q-py-xs' : ''"
>
<q-btn <q-btn
push push
color="accent" color="accent"

View File

@ -6,7 +6,6 @@
import ShiftListScrollable from 'src/modules/timesheets/components/shift-list-scrollable.vue'; import ShiftListScrollable from 'src/modules/timesheets/components/shift-list-scrollable.vue';
import LoadingOverlay from 'src/modules/shared/components/loading-overlay.vue'; import LoadingOverlay from 'src/modules/shared/components/loading-overlay.vue';
import ExpenseDialog from 'src/modules/timesheets/components/expense-dialog.vue'; import ExpenseDialog from 'src/modules/timesheets/components/expense-dialog.vue';
import PageHeaderTemplate from 'src/modules/shared/components/page-header-template.vue';
import PayPeriodNavigator from 'src/modules/shared/components/pay-period-navigator.vue'; import PayPeriodNavigator from 'src/modules/shared/components/pay-period-navigator.vue';
import TimesheetErrorWidget from 'src/modules/timesheets/components/timesheet-error-widget.vue'; import TimesheetErrorWidget from 'src/modules/timesheets/components/timesheet-error-widget.vue';
import ShiftListWeeklyOverview from 'src/modules/timesheets/components/shift-list-weekly-overview.vue'; import ShiftListWeeklyOverview from 'src/modules/timesheets/components/shift-list-weekly-overview.vue';
@ -104,15 +103,7 @@
/> />
</div> </div>
<PageHeaderTemplate <q-space />
v-if="mode === 'normal'"
:title="'timesheet.page_header'"
:start-date="timesheet_store.pay_period?.period_start ?? ''"
:end-date="timesheet_store.pay_period?.period_end ?? ''"
class="col"
/>
<q-space v-if="!$q.platform.is.mobile && mode === 'approval'" />
<!-- employee weekly overview --> <!-- employee weekly overview -->
<div <div

View File

@ -8,13 +8,19 @@ export const useExpensesApi = () => {
const expenses_store = useExpensesStore(); const expenses_store = useExpensesStore();
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const upsertExpense = async (expense: Expense, employee_email?: string): Promise<void> => { const upsertExpense = async (expense: Expense, file: File, employee_email: string): Promise<string> => {
const presignedURL = expenses_store.uploadAttachment(file);
if (!presignedURL) return 'PRESIGN_FAILED';
const success = await expenses_store.upsertExpense(expense, employee_email); const success = await expenses_store.upsertExpense(expense, employee_email);
if (success) { if (success) {
expenses_store.current_expense = new Expense(date.formatDate( new Date(), 'YYYY-MM-DD')); expenses_store.current_expense = new Expense(date.formatDate( new Date(), 'YYYY-MM-DD'));
timesheet_store.getTimesheetsByOptionalEmployeeEmail(employee_email); timesheet_store.getTimesheetsByOptionalEmployeeEmail(employee_email);
return 'SUCCESS';
} }
return 'INVALID_EXPENSE';
}; };
const deleteExpenseById = async (expense_id: number, employee_email?: string): Promise<void> => { const deleteExpenseById = async (expense_id: number, employee_email?: string): Promise<void> => {

View File

@ -24,4 +24,10 @@ export class Expense {
this.comment = ''; this.comment = '';
this.is_approved = false; this.is_approved = false;
}; };
}; };
export interface ExpenseOption {
label: string;
value: ExpenseType;
icon: string;
}

View File

@ -1,19 +1,31 @@
import { api } from "src/boot/axios"; import { api } from "src/boot/axios";
import type { BackendResponse } from "src/modules/shared/models/backend-response.models";
import type { Expense } from "src/modules/timesheets/models/expense.models"; import type { Expense } from "src/modules/timesheets/models/expense.models";
export const ExpenseService = { export const ExpenseService = {
createExpense: async (expense: Expense): Promise<{success: boolean, data: Expense, error?: unknown}> => { createExpense: async (expense: Expense): Promise<{ success: boolean, data: Expense, error?: unknown }> => {
const response = await api.post('expense/create', expense); const response = await api.post('expense/create', expense);
return response.data; return response.data;
}, },
updateExpense: async (expense: Expense, email?: string): Promise<{success: boolean, data: Expense, error?: unknown}> => { updateExpense: async (expense: Expense, email?: string): Promise<{ success: boolean, data: Expense, error?: unknown }> => {
const response = await api.patch(`expense/update${email ? '?employee_email=' + email : ''}`, expense); const response = await api.patch(`expense/update${email ? '?employee_email=' + email : ''}`, expense);
return response.data; return response.data;
}, },
deleteExpenseById: async (expense_id: number): Promise<{success: boolean, data: number, error?: unknown}> => { deleteExpenseById: async (expense_id: number): Promise<{ success: boolean, data: number, error?: unknown }> => {
const response = await api.delete(`expense/delete/${expense_id}`); const response = await api.delete(`expense/delete/${expense_id}`);
return response.data; return response.data;
},
getPresignedUploadURL: async (file: File, checksum_crc32: string): Promise<BackendResponse<string>> => {
const [file_name, file_type] = file.name.split('.');
const response = await api.post(`attachments/s3/upload?file-name=${file_name}&file-type=${file_type}&checksumCRC32=${checksum_crc32}`);
return response.data;
},
uploadAttachmentWithPresignedUrl: async (file: File, url: string) => {
const response = await api.put(url, file, { headers: { 'Content-Type': file.type, }, withCredentials: false });
console.log('response to upload: ', response);
} }
}; };

View File

@ -2,12 +2,12 @@
setup setup
lang="ts" lang="ts"
> >
import PageHeaderTemplate from 'src/modules/shared/components/page-header-template.vue';
import TimesheetWrapper from 'src/modules/timesheets/components/timesheet-wrapper.vue'; import TimesheetWrapper from 'src/modules/timesheets/components/timesheet-wrapper.vue';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { useAuthStore } from 'src/stores/auth-store'; const timesheet_store = useTimesheetStore();
const auth_store = useAuthStore();
</script> </script>
<template> <template>
@ -16,10 +16,14 @@
class="col column fit" class="col column fit"
:style="$q.platform.is.mobile && ($q.screen.width < $q.screen.height) ? '' : 'width: 90vw'" :style="$q.platform.is.mobile && ($q.screen.width < $q.screen.height) ? '' : 'width: 90vw'"
> >
<TimesheetWrapper <PageHeaderTemplate
:employee-email="auth_store.user?.email ?? ''" :title="'timesheet.page_header'"
class="col" :start-date="timesheet_store.pay_period?.period_start ?? ''"
:end-date="timesheet_store.pay_period?.period_end ?? ''"
class="col-auto"
/> />
<TimesheetWrapper class="col" />
</div> </div>
</q-page> </q-page>
</template> </template>

View File

@ -4,6 +4,7 @@ import { defineStore } from "pinia";
import { useTimesheetStore } from "src/stores/timesheet-store"; import { useTimesheetStore } from "src/stores/timesheet-store";
import { Expense } from "src/modules/timesheets/models/expense.models"; import { Expense } from "src/modules/timesheets/models/expense.models";
import { ExpenseService } from "src/modules/timesheets/services/expense-service"; import { ExpenseService } from "src/modules/timesheets/services/expense-service";
import { computeCRC32Base64 } from "src/utils/crc-encoder";
export const useExpensesStore = defineStore('expenses', () => { export const useExpensesStore = defineStore('expenses', () => {
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
@ -50,6 +51,22 @@ export const useExpensesStore = defineStore('expenses', () => {
return data.success; return data.success;
} }
const uploadAttachment = async (file: File) => {
try {
const checksum = await computeCRC32Base64(file);
const presignedUrlResponse = await ExpenseService.getPresignedUploadURL(file, checksum);
if (presignedUrlResponse.success && presignedUrlResponse.data) {
const { url, key } = JSON.parse(presignedUrlResponse.data);
console.log('key: ', key);
await ExpenseService.uploadAttachmentWithPresignedUrl(file, url);
}
} catch (error) {
console.error(error);
}
}
return { return {
is_open, is_open,
is_loading, is_loading,
@ -62,5 +79,6 @@ export const useExpensesStore = defineStore('expenses', () => {
upsertExpense, upsertExpense,
deleteExpenseById, deleteExpenseById,
close, close,
uploadAttachment,
}; };
}); });

17
src/utils/crc-encoder.ts Normal file
View File

@ -0,0 +1,17 @@
import { crc32 } from 'crc';
export async function computeCRC32Base64(file: File): Promise<string> {
const arrayBuffer = await file.arrayBuffer();
// Pass arrayBuffer directly to crc32
let crc = crc32(arrayBuffer);
crc >>>= 0;
// Convert to 4-byte big-endian
const buffer = new ArrayBuffer(4);
const view = new DataView(buffer);
view.setUint32(0, crc, false);
// Base64 encode
return btoa(String.fromCharCode(...new Uint8Array(buffer)));
}