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:
parent
9f2fc1b706
commit
505fdf0e62
|
|
@ -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>
|
||||||
|
|
@ -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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -131,6 +137,7 @@
|
||||||
:text-color="expense.is_approved ? 'accent' : 'white'"
|
:text-color="expense.is_approved ? 'accent' : 'white'"
|
||||||
class="col-auto q-px-sm q-mr-sm"
|
class="col-auto q-px-sm q-mr-sm"
|
||||||
icon="attach_file"
|
icon="attach_file"
|
||||||
|
@click.stop="onClickAttachment"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<q-item-label class="col">
|
<q-item-label class="col">
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
import ExpenseDialogForm from 'src/modules/timesheets/components/expense-dialog-form.vue';
|
import ExpenseDialogForm from 'src/modules/timesheets/components/expense-dialog-form.vue';
|
||||||
import ExpenseDialogHeader from 'src/modules/timesheets/components/expense-dialog-header.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 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 { date } from 'quasar';
|
||||||
import { useExpensesStore } from 'src/stores/expense-store';
|
import { useExpensesStore } from 'src/stores/expense-store';
|
||||||
|
|
@ -32,6 +33,8 @@
|
||||||
transition-show="jump-down"
|
transition-show="jump-down"
|
||||||
transition-hide="jump-down"
|
transition-hide="jump-down"
|
||||||
>
|
>
|
||||||
|
<ExpenseDialogAttachmentViewer class="z-top" />
|
||||||
|
|
||||||
<q-card
|
<q-card
|
||||||
class="q-pa-none rounded-10 shadow-24 bg-secondary"
|
class="q-pa-none rounded-10 shadow-24 bg-secondary"
|
||||||
style=" min-width: 70vw;"
|
style=" min-width: 70vw;"
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,11 @@
|
||||||
employeeEmail ?? auth_store.user?.email ?? 'MISSING_EMAIL',
|
employeeEmail ?? auth_store.user?.email ?? 'MISSING_EMAIL',
|
||||||
file.value
|
file.value
|
||||||
);
|
);
|
||||||
|
else
|
||||||
|
await expenses_api.upsertExpense(
|
||||||
|
expenses_store.current_expense,
|
||||||
|
employeeEmail ?? auth_store.user?.email ?? 'MISSING_EMAIL'
|
||||||
|
);
|
||||||
|
|
||||||
emit('onUpdateClicked');
|
emit('onUpdateClicked');
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,11 @@
|
||||||
import { SHIFT_OPTIONS } from 'src/modules/timesheets/utils/shift.util';
|
import { SHIFT_OPTIONS } from 'src/modules/timesheets/utils/shift.util';
|
||||||
import type { Shift } from 'src/modules/timesheets/models/shift.models';
|
import type { Shift } from 'src/modules/timesheets/models/shift.models';
|
||||||
|
|
||||||
const ui_store = useUiStore();
|
// ========== state ========================================
|
||||||
|
|
||||||
const COMMENT_LENGTH_MAX = 280;
|
const COMMENT_LENGTH_MAX = 280;
|
||||||
|
|
||||||
const shift = defineModel<Shift>('shift', { required: true });
|
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<{
|
const { errorMessage = undefined, dense = false, hasShiftAfter = false, isTimesheetApproved = false } = defineProps<{
|
||||||
dense?: boolean;
|
dense?: boolean;
|
||||||
|
|
@ -31,6 +26,18 @@
|
||||||
'onTimeFieldBlur': [void];
|
'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 = () => {
|
const onBlurShiftTypeSelect = () => {
|
||||||
if (shift_type_selected.value === undefined) {
|
if (shift_type_selected.value === undefined) {
|
||||||
shift.value.type = 'REGULAR';
|
shift.value.type = 'REGULAR';
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,14 @@ export const useExpensesApi = () => {
|
||||||
|
|
||||||
const upsertExpense = async (expense: Expense, employee_email: string, file?: File): Promise<string> => {
|
const upsertExpense = async (expense: Expense, employee_email: string, file?: File): Promise<string> => {
|
||||||
if (file) {
|
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);
|
const success = await expenses_store.upsertExpense(expense, employee_email);
|
||||||
|
|
|
||||||
|
|
@ -33,3 +33,8 @@ export interface ExpenseOption {
|
||||||
value: ExpenseType;
|
value: ExpenseType;
|
||||||
icon: string;
|
icon: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AttachmentPresignedURLResponse {
|
||||||
|
url: string;
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
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 { 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 = {
|
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 }> => {
|
||||||
|
|
@ -18,14 +18,19 @@ export const ExpenseService = {
|
||||||
return response.data;
|
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 [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}`);
|
const response = await api.post(`attachments/s3/upload?file-name=${file_name}&file-type=${file_type}&checksumCRC32=${checksum_crc32}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
uploadAttachmentWithPresignedUrl: async (file: File, url: string) => {
|
uploadAttachmentWithPresignedUrl: async (file: File, url: string): Promise<number> => {
|
||||||
const response = await api.put(url, file, { headers: { 'Content-Type': file.type, }, withCredentials: false });
|
const response = await api.put(url, file, { headers: { 'Content-Type': `image/${file.type}`, }, withCredentials: false });
|
||||||
console.log('response to upload: ', response);
|
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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -11,9 +11,11 @@ export const useExpensesStore = defineStore('expenses', () => {
|
||||||
const is_open = ref(false);
|
const is_open = ref(false);
|
||||||
const is_loading = ref(false);
|
const is_loading = ref(false);
|
||||||
const is_showing_create_form = ref(false);
|
const is_showing_create_form = ref(false);
|
||||||
|
const attachmentURL = ref<string>('');
|
||||||
const mode = ref<'create' | 'update' | 'delete'>('create');
|
const mode = ref<'create' | 'update' | 'delete'>('create');
|
||||||
const current_expense = ref<Expense>(new Expense(date.formatDate(new Date(), 'YYYY-MM-DD')));
|
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 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 is_save_disabled = computed(() => JSON.stringify(current_expense.value) === JSON.stringify(initial_expense.value))
|
||||||
|
|
||||||
const open = (): void => {
|
const open = (): void => {
|
||||||
|
|
@ -51,22 +53,48 @@ export const useExpensesStore = defineStore('expenses', () => {
|
||||||
return data.success;
|
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 {
|
try {
|
||||||
const checksum = await computeCRC32Base64(file);
|
const checksum = await computeCRC32Base64(file);
|
||||||
const presignedUrlResponse = await ExpenseService.getPresignedUploadURL(file, checksum);
|
const presignedUrlResponse = await ExpenseService.getPresignedUploadURL(file, checksum);
|
||||||
|
|
||||||
if (presignedUrlResponse.success && presignedUrlResponse.data) {
|
if (!presignedUrlResponse.success || !presignedUrlResponse.data) {
|
||||||
const { url, key } = JSON.parse(presignedUrlResponse.data);
|
console.error('failed to get presigned URL from server');
|
||||||
console.log('key: ', key);
|
return;
|
||||||
|
|
||||||
await ExpenseService.uploadAttachmentWithPresignedUrl(file, url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
} catch (error) {
|
||||||
console.error(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 {
|
return {
|
||||||
is_open,
|
is_open,
|
||||||
is_loading,
|
is_loading,
|
||||||
|
|
@ -74,11 +102,14 @@ export const useExpensesStore = defineStore('expenses', () => {
|
||||||
mode,
|
mode,
|
||||||
current_expense,
|
current_expense,
|
||||||
initial_expense,
|
initial_expense,
|
||||||
|
isShowingAttachmentDialog,
|
||||||
is_save_disabled,
|
is_save_disabled,
|
||||||
|
attachmentURL,
|
||||||
open,
|
open,
|
||||||
upsertExpense,
|
upsertExpense,
|
||||||
deleteExpenseById,
|
deleteExpenseById,
|
||||||
close,
|
close,
|
||||||
uploadAttachment,
|
uploadAttachment,
|
||||||
|
getAttachmentURL,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
Loading…
Reference in New Issue
Block a user