feat(expense): finalize implementation of S3 in expenses

Now able to upload and then view images attached to expenses. Will need to check if further changes need to be made to updating expenses. Minor structural changes here and there.
This commit is contained in:
Nic D 2026-02-11 07:52:07 -05:00
parent 9f2fc1b706
commit 505fdf0e62
9 changed files with 137 additions and 24 deletions

View File

@ -0,0 +1,45 @@
<script
setup
lang="ts"
>
import { ref } from 'vue';
import { useExpensesStore } from 'src/stores/expense-store';
const expenseStore = useExpensesStore();
const isMaximized = ref(false);
const imageDimension = ref('400px');
const onClickMaximizeButton = () => {
isMaximized.value = !isMaximized.value;
imageDimension.value = isMaximized.value ? '100%' : '400px';
}
</script>
<template>
<q-dialog
v-model="expenseStore.isShowingAttachmentDialog"
backdrop-filter="blur(4px)"
:full-height="isMaximized"
:full-width="isMaximized"
>
<q-card class="q-pa-md flex-center relative-position">
<q-img
spinner-color="accent"
fit="contain"
:height="imageDimension"
:width="imageDimension"
:src="expenseStore.attachmentURL"
/>
<q-btn
dense
size="lg"
color="accent"
:icon="isMaximized ? 'zoom_in_map' : 'zoom_out_map'"
class="absolute-bottom-right q-ma-sm rounded-5"
style="opacity: 0.5;"
@click="onClickMaximizeButton"
/>
</q-card>
</q-dialog>
</template>

View File

@ -67,6 +67,12 @@
});
}
}
const onClickAttachment = async () => {
expenses_store.isShowingAttachmentDialog = true;
await expenses_store.getAttachmentURL(expense.value.attachment_key);
console.log('image url: ', expenses_store.attachmentURL);
}
</script>
<template>
@ -131,6 +137,7 @@
:text-color="expense.is_approved ? 'accent' : 'white'"
class="col-auto q-px-sm q-mr-sm"
icon="attach_file"
@click.stop="onClickAttachment"
/>
<q-item-label class="col">

View File

@ -6,6 +6,7 @@
import ExpenseDialogForm from 'src/modules/timesheets/components/expense-dialog-form.vue';
import ExpenseDialogHeader from 'src/modules/timesheets/components/expense-dialog-header.vue';
import ExpenseDialogFormMobile from 'src/modules/timesheets/components/mobile/expense-dialog-form-mobile.vue';
import ExpenseDialogAttachmentViewer from 'src/modules/timesheets/components/expense-dialog-attachment-viewer.vue';
import { date } from 'quasar';
import { useExpensesStore } from 'src/stores/expense-store';
@ -32,6 +33,8 @@
transition-show="jump-down"
transition-hide="jump-down"
>
<ExpenseDialogAttachmentViewer class="z-top" />
<q-card
class="q-pa-none rounded-10 shadow-24 bg-secondary"
style=" min-width: 70vw;"

View File

@ -61,6 +61,11 @@
employeeEmail ?? auth_store.user?.email ?? 'MISSING_EMAIL',
file.value
);
else
await expenses_api.upsertExpense(
expenses_store.current_expense,
employeeEmail ?? auth_store.user?.email ?? 'MISSING_EMAIL'
);
emit('onUpdateClicked');
};

View File

@ -8,16 +8,11 @@
import { SHIFT_OPTIONS } from 'src/modules/timesheets/utils/shift.util';
import type { Shift } from 'src/modules/timesheets/models/shift.models';
const ui_store = useUiStore();
// ========== state ========================================
const COMMENT_LENGTH_MAX = 280;
const shift = defineModel<Shift>('shift', { required: true });
const shift_type_selected = ref(SHIFT_OPTIONS.find(option => option.value == shift.value.type));
const select_ref = ref<QSelect | null>(null);
const is_showing_comment_popup = ref(false);
const comment_length = computed(() => shift.value.comment?.length ?? 0);
const error_message = ref('');
const { errorMessage = undefined, dense = false, hasShiftAfter = false, isTimesheetApproved = false } = defineProps<{
dense?: boolean;
@ -31,6 +26,18 @@
'onTimeFieldBlur': [void];
}>();
const ui_store = useUiStore();
const shift_type_selected = ref(SHIFT_OPTIONS.find(option => option.value == shift.value.type));
const select_ref = ref<QSelect | null>(null);
const is_showing_comment_popup = ref(false);
const error_message = ref('');
// ========== computed ========================================
const comment_length = computed(() => shift.value.comment?.length ?? 0);
// ========== methods =========================================
const onBlurShiftTypeSelect = () => {
if (shift_type_selected.value === undefined) {
shift.value.type = 'REGULAR';

View File

@ -10,9 +10,14 @@ export const useExpensesApi = () => {
const upsertExpense = async (expense: Expense, employee_email: string, file?: File): Promise<string> => {
if (file) {
const presignedURL = expenses_store.uploadAttachment(file);
const attachmentKey = await expenses_store.uploadAttachment(file);
if (!presignedURL) return 'PRESIGN_FAILED';
if (!attachmentKey)
console.error('failed to upload attachment');
else {
expense.attachment_key = attachmentKey;
expense.attachment_name = file.name;
}
}
const success = await expenses_store.upsertExpense(expense, employee_email);

View File

@ -33,3 +33,8 @@ export interface ExpenseOption {
value: ExpenseType;
icon: string;
}
export interface AttachmentPresignedURLResponse {
url: string;
key: string;
}

View File

@ -1,6 +1,6 @@
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 { AttachmentPresignedURLResponse, Expense } from "src/modules/timesheets/models/expense.models";
export const ExpenseService = {
createExpense: async (expense: Expense): Promise<{ success: boolean, data: Expense, error?: unknown }> => {
@ -18,14 +18,19 @@ export const ExpenseService = {
return response.data;
},
getPresignedUploadURL: async (file: File, checksum_crc32: string): Promise<BackendResponse<string>> => {
getPresignedUploadURL: async (file: File, checksum_crc32: string): Promise<BackendResponse<AttachmentPresignedURLResponse>> => {
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);
uploadAttachmentWithPresignedUrl: async (file: File, url: string): Promise<number> => {
const response = await api.put(url, file, { headers: { 'Content-Type': `image/${file.type}`, }, withCredentials: false });
return response.status;
},
getPresignedDownloadURL: async (key: string): Promise<BackendResponse<string>> => {
const response = await api.get<BackendResponse<string>>(`attachments/s3/download?attachmentKey=${key}`);
return response.data;
}
};

View File

@ -11,9 +11,11 @@ export const useExpensesStore = defineStore('expenses', () => {
const is_open = ref(false);
const is_loading = ref(false);
const is_showing_create_form = ref(false);
const attachmentURL = ref<string>('');
const mode = ref<'create' | 'update' | 'delete'>('create');
const current_expense = ref<Expense>(new Expense(date.formatDate(new Date(), 'YYYY-MM-DD')));
const initial_expense = ref<Expense>(new Expense(date.formatDate(new Date(), 'YYYY-MM-DD')));
const isShowingAttachmentDialog = ref(false);
const is_save_disabled = computed(() => JSON.stringify(current_expense.value) === JSON.stringify(initial_expense.value))
const open = (): void => {
@ -51,22 +53,48 @@ export const useExpensesStore = defineStore('expenses', () => {
return data.success;
}
const uploadAttachment = async (file: File) => {
/**
* Attemps to upload the provided image file to the S3 storage bucket for attachments.
*
* @param file image file to be uploaded to the S3 storage
* @returns Key `string` associated with the uploaded image file if successful,
* `undefined` if it fails.
*/
const uploadAttachment = async (file: File): Promise<string | undefined> => {
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);
if (!presignedUrlResponse.success || !presignedUrlResponse.data) {
console.error('failed to get presigned URL from server');
return;
}
const { url, key } = presignedUrlResponse.data;
const responseStatus = await ExpenseService.uploadAttachmentWithPresignedUrl(file, url);
if (responseStatus >= 400) {
console.error('an error occured during upload: error ', responseStatus);
return;
}
return key;
} catch (error) {
console.error(error);
}
}
const getAttachmentURL = async (key?: string) => {
if (!key)
return;
const presignedAttachmentURL = await ExpenseService.getPresignedDownloadURL(key);
if (presignedAttachmentURL.success && presignedAttachmentURL.data)
attachmentURL.value = presignedAttachmentURL.data;
}
return {
is_open,
is_loading,
@ -74,11 +102,14 @@ export const useExpensesStore = defineStore('expenses', () => {
mode,
current_expense,
initial_expense,
isShowingAttachmentDialog,
is_save_disabled,
attachmentURL,
open,
upsertExpense,
deleteExpenseById,
close,
uploadAttachment,
getAttachmentURL,
};
});