From fec7300092d3da6ed36056f9c1aedcb31e1c9f56 Mon Sep 17 00:00:00 2001 From: Nic D Date: Wed, 28 Jan 2026 08:56:01 -0500 Subject: [PATCH 1/3] fix(timesheet): fix issue where email was incorrectly being sent as param when using timesheet page --- .../components/timesheet-wrapper.vue | 11 +---------- src/pages/timesheet-page.vue | 18 +++++++++++------- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/src/modules/timesheets/components/timesheet-wrapper.vue b/src/modules/timesheets/components/timesheet-wrapper.vue index 9600ef4..6a5534d 100644 --- a/src/modules/timesheets/components/timesheet-wrapper.vue +++ b/src/modules/timesheets/components/timesheet-wrapper.vue @@ -6,7 +6,6 @@ import ShiftListScrollable from 'src/modules/timesheets/components/shift-list-scrollable.vue'; import LoadingOverlay from 'src/modules/shared/components/loading-overlay.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 TimesheetErrorWidget from 'src/modules/timesheets/components/timesheet-error-widget.vue'; import ShiftListWeeklyOverview from 'src/modules/timesheets/components/shift-list-weekly-overview.vue'; @@ -104,15 +103,7 @@ /> - - - +
- + import PageHeaderTemplate from 'src/modules/shared/components/page-header-template.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 auth_store = useAuthStore(); + const timesheet_store = useTimesheetStore(); \ No newline at end of file From 119a14554915b28d24626cb338f49f8894d97d2a Mon Sep 17 00:00:00 2001 From: Nic D Date: Fri, 30 Jan 2026 13:44:43 -0500 Subject: [PATCH 2/3] 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 --- package-lock.json | 105 ++++++++++++------ package.json | 1 + .../components/add-modify-dialog-schedule.vue | 11 +- .../components/expense-dialog-form.vue | 30 +++-- .../components/expense-dialog-list-item.vue | 16 +-- .../mobile/expense-dialog-form-mobile.vue | 34 +++--- .../timesheets/composables/use-expense-api.ts | 8 +- .../timesheets/models/expense.models.ts | 8 +- .../timesheets/services/expense-service.ts | 18 ++- src/stores/expense-store.ts | 18 +++ src/utils/crc-encoder.ts | 17 +++ 11 files changed, 186 insertions(+), 80 deletions(-) create mode 100644 src/utils/crc-encoder.ts 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))); +} From 8998918002a0b7ae11a41e671cde45ad25212d90 Mon Sep 17 00:00:00 2001 From: Nic D Date: Fri, 30 Jan 2026 14:38:32 -0500 Subject: [PATCH 3/3] fix(timesheet): resolve issue with mobile expenses and timesheet presets. fix issue with mobile version of expense dialog where select menu for expense type was refusing to show on screen due to z-index behavior. switch q-select options dialog to menu behavior instead of dialog fix issue with timesheet presets not applying correctly due to user email being sent as query param on timesheet inadvertedly, which is a route reserved for timesheet approval module --- .../components/expense-dialog-form.vue | 2 +- .../mobile/expense-dialog-form-mobile.vue | 37 ++++++++++++------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/modules/timesheets/components/expense-dialog-form.vue b/src/modules/timesheets/components/expense-dialog-form.vue index f630b99..8324b65 100644 --- a/src/modules/timesheets/components/expense-dialog-form.vue +++ b/src/modules/timesheets/components/expense-dialog-form.vue @@ -11,7 +11,7 @@ import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api'; import { getExpenseIcon, useExpenseRules } from 'src/modules/timesheets/utils/expense.util'; import { Expense, type ExpenseOption, TYPES_WITH_AMOUNT_ONLY } from 'src/modules/timesheets/models/expense.models'; -import { useAuthStore } from 'src/stores/auth-store'; + import { useAuthStore } from 'src/stores/auth-store'; // ================= state ====================== 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 675b34f..8b7c0bc 100644 --- a/src/modules/timesheets/components/mobile/expense-dialog-form-mobile.vue +++ b/src/modules/timesheets/components/mobile/expense-dialog-form-mobile.vue @@ -2,19 +2,20 @@ setup lang="ts" > - import { computed, ref } from 'vue'; + import { computed, ref, onMounted } from 'vue'; import { useI18n } from 'vue-i18n'; import { useUiStore } from 'src/stores/ui-store'; import { useExpensesStore } from 'src/stores/expense-store'; 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 ExpenseOption, TYPES_WITH_AMOUNT_ONLY } from 'src/modules/timesheets/models/expense.models'; -import { useAuthStore } from 'src/stores/auth-store'; + import { Expense, type ExpenseOption, TYPES_WITH_AMOUNT_ONLY } from 'src/modules/timesheets/models/expense.models'; + import { useAuthStore } from 'src/stores/auth-store'; const COMMENT_MAX_LENGTH = 280; - const file = defineModel(); + const expense = defineModel({ default: new Expense(new Date().toISOString().slice(0, 10)) }) + const file = defineModel('file'); const { employeeEmail } = defineProps<{ employeeEmail?: string; @@ -53,6 +54,13 @@ import { useAuthStore } from 'src/stores/auth-store'; 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]; + }) +
- + +