feat(timesheet): add functionality to upload expense attachment to garage test instance

requires further development. Key used to store file needs to be saved to expense to be later used for retrieval
This commit is contained in:
Nic D 2026-01-30 13:44:43 -05:00
parent fec7300092
commit 119a145549
11 changed files with 186 additions and 80 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

@ -9,25 +9,26 @@
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 { 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 file = defineModel<File | undefined>();
icon: string;
} 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,7 +50,8 @@
}; };
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');
}; };
</script> </script>
@ -218,7 +220,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 +253,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 +278,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

@ -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

@ -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)));
}