diff --git a/package-lock.json b/package-lock.json index 6e3ae83..5ba07ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@quasar/extras": "^1.17.0", "axios": "^1.11.0", "chart.js": "^4.5.0", + "crc": "^4.3.2", "markdown-it": "^14.1.0", "pinia": "^3.0.3", "pinia-plugin-persistedstate": "^4.4.1", @@ -3091,7 +3092,7 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -3152,6 +3153,30 @@ "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": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -3318,10 +3343,10 @@ } }, "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "devOptional": true, "funding": [ { "type": "github", @@ -3336,10 +3361,9 @@ "url": "https://feross.org/support" } ], - "license": "MIT", "dependencies": { "base64-js": "^1.3.1", - "ieee754": "^1.1.13" + "ieee754": "^1.2.1" } }, "node_modules/buffer-builder": { @@ -4018,6 +4042,22 @@ "dev": true, "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": { "version": "1.2.2", "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_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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz", @@ -5870,7 +5934,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -8173,31 +8237,6 @@ "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": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", diff --git a/package.json b/package.json index 6ffffb1..0cabb69 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@quasar/extras": "^1.17.0", "axios": "^1.11.0", "chart.js": "^4.5.0", + "crc": "^4.3.2", "markdown-it": "^14.1.0", "pinia": "^3.0.3", "pinia-plugin-persistedstate": "^4.4.1", diff --git a/src/modules/employee-list/components/add-modify-dialog-schedule.vue b/src/modules/employee-list/components/add-modify-dialog-schedule.vue index c8f77b2..4f73c10 100644 --- a/src/modules/employee-list/components/add-modify-dialog-schedule.vue +++ b/src/modules/employee-list/components/add-modify-dialog-schedule.vue @@ -6,19 +6,22 @@ import SchedulePresetsDialog from 'src/modules/employee-list/components/schedule-presets-dialog.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 { useEmployeeStore } from 'src/stores/employee-store'; import { useEmployeeListApi } from '../composables/use-employee-api'; import type { PresetManagerMode } from 'src/modules/employee-list/models/schedule-presets.models'; + // ================= state ====================== + const schedule_preset_store = useSchedulePresetsStore(); const employee_store = useEmployeeStore(); const employee_list_api = useEmployeeListApi(); const preset_options = ref<{ label: string, value: number }[]>([]); 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 options = schedule_preset_store.schedule_presets.map(preset => { return { label: preset.name, value: preset.id } }); @@ -41,8 +44,6 @@ onMounted(() => { loadSelectedPresetOption(); }); - - watch(manager_watcher, loadSelectedPresetOption) \ No newline at end of file diff --git a/src/modules/timesheets/components/expense-dialog-list-item.vue b/src/modules/timesheets/components/expense-dialog-list-item.vue index 3bdd0a6..0973c58 100644 --- a/src/modules/timesheets/components/expense-dialog-list-item.vue +++ b/src/modules/timesheets/components/expense-dialog-list-item.vue @@ -14,19 +14,21 @@ import { getExpenseIcon } from 'src/modules/timesheets/utils/expense.util'; import type { Expense } from 'src/modules/timesheets/models/expense.models'; - const { t } = useI18n(); + // ================== state ===================== const expense = defineModel({ 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<{ 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 () => { await expenses_api.deleteExpenseById(expense.value.id); diff --git a/src/modules/timesheets/components/mobile/expense-dialog-form-mobile.vue b/src/modules/timesheets/components/mobile/expense-dialog-form-mobile.vue index 2c1fdc7..675b34f 100644 --- a/src/modules/timesheets/components/mobile/expense-dialog-form-mobile.vue +++ b/src/modules/timesheets/components/mobile/expense-dialog-form-mobile.vue @@ -9,25 +9,26 @@ import { useTimesheetStore } from 'src/stores/timesheet-store'; import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api'; 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 { - label: string; - value: ExpenseType; - icon: string; - } + const COMMENT_MAX_LENGTH = 280; + + const file = defineModel(); + + const { employeeEmail } = defineProps<{ + employeeEmail?: string; + }>(); const { t } = useI18n(); - const ui_store = useUiStore(); const timesheet_store = useTimesheetStore(); const expenses_store = useExpensesStore(); + const auth_store = useAuthStore(); const expenses_api = useExpensesApi(); - const files = defineModel('files'); const is_navigator_open = ref(false); const is_showing_comment_dialog_mobile = ref(false); - const COMMENT_MAX_LENGTH = 280; const rules = useExpenseRules(t); const period_start_date = computed(() => timesheet_store.pay_period?.period_start.replaceAll('-', '/') ?? ''); @@ -49,7 +50,8 @@ }; 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'); }; @@ -218,7 +220,10 @@ class="col-auto" /> - + -
+
{ const expenses_store = useExpensesStore(); const timesheet_store = useTimesheetStore(); - const upsertExpense = async (expense: Expense, employee_email?: string): Promise => { + const upsertExpense = async (expense: Expense, file: File, employee_email: string): Promise => { + const presignedURL = expenses_store.uploadAttachment(file); + if (!presignedURL) return 'PRESIGN_FAILED'; + const success = await expenses_store.upsertExpense(expense, employee_email); if (success) { expenses_store.current_expense = new Expense(date.formatDate( new Date(), 'YYYY-MM-DD')); timesheet_store.getTimesheetsByOptionalEmployeeEmail(employee_email); + return 'SUCCESS'; } + + return 'INVALID_EXPENSE'; }; const deleteExpenseById = async (expense_id: number, employee_email?: string): Promise => { diff --git a/src/modules/timesheets/models/expense.models.ts b/src/modules/timesheets/models/expense.models.ts index fcf3138..6e8b98e 100644 --- a/src/modules/timesheets/models/expense.models.ts +++ b/src/modules/timesheets/models/expense.models.ts @@ -24,4 +24,10 @@ export class Expense { this.comment = ''; this.is_approved = false; }; -}; \ No newline at end of file +}; + +export interface ExpenseOption { + label: string; + value: ExpenseType; + icon: string; +} \ No newline at end of file diff --git a/src/modules/timesheets/services/expense-service.ts b/src/modules/timesheets/services/expense-service.ts index bd199b5..0b0c0f0 100644 --- a/src/modules/timesheets/services/expense-service.ts +++ b/src/modules/timesheets/services/expense-service.ts @@ -1,19 +1,31 @@ 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"; 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); 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); 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}`); return response.data; + }, + + getPresignedUploadURL: async (file: File, checksum_crc32: string): Promise> => { + 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); } }; \ No newline at end of file diff --git a/src/stores/expense-store.ts b/src/stores/expense-store.ts index 1fdcf5c..8e7ee27 100644 --- a/src/stores/expense-store.ts +++ b/src/stores/expense-store.ts @@ -4,6 +4,7 @@ import { defineStore } from "pinia"; import { useTimesheetStore } from "src/stores/timesheet-store"; import { Expense } from "src/modules/timesheets/models/expense.models"; import { ExpenseService } from "src/modules/timesheets/services/expense-service"; +import { computeCRC32Base64 } from "src/utils/crc-encoder"; export const useExpensesStore = defineStore('expenses', () => { const timesheet_store = useTimesheetStore(); @@ -50,6 +51,22 @@ export const useExpensesStore = defineStore('expenses', () => { 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 { is_open, is_loading, @@ -62,5 +79,6 @@ export const useExpensesStore = defineStore('expenses', () => { upsertExpense, deleteExpenseById, close, + uploadAttachment, }; }); \ No newline at end of file diff --git a/src/utils/crc-encoder.ts b/src/utils/crc-encoder.ts new file mode 100644 index 0000000..97254e8 --- /dev/null +++ b/src/utils/crc-encoder.ts @@ -0,0 +1,17 @@ +import { crc32 } from 'crc'; + +export async function computeCRC32Base64(file: File): Promise { + 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))); +}