From 6c28c0c2d2bee4e49fcdb6b12f34f775aeb70bdc Mon Sep 17 00:00:00 2001 From: Edwin Eames Date: Fri, 6 Feb 2026 20:31:16 -0500 Subject: [PATCH] feat(ui): Massive frontend modernization including customer table redesign, new map features, and consistent styling --- .env.local | 8 + Dockerfile.dev | 2 +- Dockerfile.local | 1 + Dockerfile.prod | 1 + package-lock.json | 66 ++ package.json | 4 + src/assets/modern.css | 184 +++++ src/composables/useAddressAutocomplete.ts | 190 +++++ src/composables/useFormValidation.ts | 150 ++++ src/layouts/sidebar/sidebar.vue | 30 +- src/main.ts | 5 +- src/pages/admin/authorize.vue | 134 +-- src/pages/admin/oilprice.vue | 29 +- src/pages/admin/promo/promo.vue | 168 ++-- src/pages/admin/routes.ts | 9 +- src/pages/admin/stats/DailyGraph.vue | 291 +++++++ src/pages/admin/stats/StatsHome.vue | 59 ++ src/pages/admin/stats/TotalsComparison.vue | 123 +++ src/pages/auth/changepassword.vue | 4 +- src/pages/auth/login.vue | 8 +- src/pages/auth/lostpassword.vue | 4 +- src/pages/auth/register.vue | 8 +- src/pages/automatic/home.vue | 184 +++-- src/pages/card/addcard.vue | 14 +- src/pages/card/editcard.vue | 12 +- src/pages/customer/create.vue | 317 +++++++- src/pages/customer/edit.vue | 314 +++++++- src/pages/customer/home.vue | 242 ++++-- src/pages/delivery/create.vue | 15 +- src/pages/delivery/edit.vue | 7 +- src/pages/delivery/home.vue | 323 ++++++-- src/pages/delivery/map.vue | 271 +++++++ src/pages/delivery/routes.ts | 6 + src/pages/delivery/viewstatus/cancelled.vue | 274 ++++++- src/pages/delivery/viewstatus/delivered.vue | 274 ++++++- src/pages/delivery/viewstatus/finalized.vue | 276 ++++++- src/pages/delivery/viewstatus/issue.vue | 278 ++++++- src/pages/delivery/viewstatus/pending.vue | 299 ++++++- .../delivery/viewstatus/todaysdeliveries.vue | 761 ++++++++++++++---- src/pages/delivery/viewstatus/tommorrow.vue | 208 ++++- src/pages/delivery/viewstatus/waiting.vue | 381 +++++++-- src/pages/employee/create.vue | 25 +- src/pages/employee/edit.vue | 39 +- src/pages/employee/home.vue | 94 ++- src/pages/money/profit_year.vue | 204 ++--- src/pages/service/ServiceHome.vue | 157 ++-- src/pages/service/ServicePast.vue | 159 ++-- src/pages/service/ServicePlans.vue | 80 +- src/pages/service/ServiceToday.vue | 136 ++-- src/pages/stats/DailyDeliveriesGraph.vue | 282 +++++++ src/pages/stats/StatsLayout.vue | 51 ++ src/pages/stats/TotalsComparison.vue | 207 +++++ src/pages/stats/components/ComparisonCard.vue | 81 ++ src/pages/stats/components/DeliveryChart.vue | 179 ++++ src/pages/stats/components/ExportButtons.vue | 108 +++ .../stats/components/TimeRangeSelector.vue | 32 + src/pages/stats/components/YearSelector.vue | 60 ++ src/pages/stats/routes.ts | 28 + src/pages/transactions/authorize/index.vue | 135 ++-- src/pages/transactions/history.vue | 372 +++++++++ src/pages/transactions/routes.ts | 6 + src/router/index.ts | 2 + src/services/addressService.ts | 69 ++ src/services/adminService.ts | 7 + src/services/deliveryService.ts | 8 + src/services/statsService.ts | 64 ++ src/types/models.ts | 46 ++ src/types/stats.ts | 160 ++++ 68 files changed, 7472 insertions(+), 1253 deletions(-) create mode 100644 .env.local create mode 100644 src/assets/modern.css create mode 100644 src/composables/useAddressAutocomplete.ts create mode 100644 src/composables/useFormValidation.ts create mode 100644 src/pages/admin/stats/DailyGraph.vue create mode 100644 src/pages/admin/stats/StatsHome.vue create mode 100644 src/pages/admin/stats/TotalsComparison.vue create mode 100644 src/pages/delivery/map.vue create mode 100644 src/pages/stats/DailyDeliveriesGraph.vue create mode 100644 src/pages/stats/StatsLayout.vue create mode 100644 src/pages/stats/TotalsComparison.vue create mode 100644 src/pages/stats/components/ComparisonCard.vue create mode 100644 src/pages/stats/components/DeliveryChart.vue create mode 100644 src/pages/stats/components/ExportButtons.vue create mode 100644 src/pages/stats/components/TimeRangeSelector.vue create mode 100644 src/pages/stats/components/YearSelector.vue create mode 100644 src/pages/stats/routes.ts create mode 100644 src/pages/transactions/history.vue create mode 100644 src/services/addressService.ts create mode 100644 src/services/statsService.ts create mode 100644 src/types/stats.ts diff --git a/.env.local b/.env.local new file mode 100644 index 0000000..1717acb --- /dev/null +++ b/.env.local @@ -0,0 +1,8 @@ +VITE_BASE_URL="http://localhost:9610" +VITE_AUTO_URL="http://localhost:9614" +VITE_MONEY_URL="http://localhost:9613" +VITE_AUTHORIZE_URL="http://localhost:9616" +VITE_VOIPMS_URL="http://localhost:9617" +VITE_SERVICE_URL="http://localhost:9615" +VITE_ADDRESS_CHECKER_URL="http://localhost:9618" +VITE_COMPANY_ID='1' diff --git a/Dockerfile.dev b/Dockerfile.dev index 2e8b7a2..29e6562 100755 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -6,7 +6,7 @@ ENV VITE_MONEY_URL="http://localhost:9513" ENV VITE_AUTHORIZE_URL="http://localhost:9516" ENV VITE_VOIPMS_URL="http://localhost:9517" ENV VITE_SERVICE_URL="http://localhost:9515" - +ENV VITE_ADDRESS_CHECKER_URL="http://localhost:9618" ENV VITE_VOIPMS_TOKEN="my_secret_token" ENV VITE_COMPANY_ID='1' diff --git a/Dockerfile.local b/Dockerfile.local index 93494fc..0d3a31f 100644 --- a/Dockerfile.local +++ b/Dockerfile.local @@ -6,6 +6,7 @@ ENV VITE_MONEY_URL="http://192.168.1.204:9613" ENV VITE_AUTHORIZE_URL="http://192.168.1.204:9616" ENV VITE_VOIPMS_URL="http://192.168.1.204:9617" ENV VITE_SERVICE_URL="http://192.168.1.204:9615" +ENV VITE_ADDRESS_CHECKER_URL="http://192.168.1.204:9618" ENV VITE_COMPANY_ID='1' diff --git a/Dockerfile.prod b/Dockerfile.prod index cf5ffaa..8f99a1e 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -10,6 +10,7 @@ ENV VITE_MONEY_URL="https://apimoney.edwineames.com" ENV VITE_AUTHORIZE_URL="https://apicard.edwineames.com" ENV VITE_VOIPMS_URL="https://apiphone.edwineames.com" ENV VITE_SERVICE_URL="https://apiservice.edwineames.com" +ENV VITE_ADDRESS_CHECKER_URL="https://apiaddress.edwineames.com" ENV VITE_VOIPMS_TOKEN="my_secret_token" diff --git a/package-lock.json b/package-lock.json index 5b03183..d75ab76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,12 +16,16 @@ "@vuelidate/validators": "^2.0.4", "@vueuse/core": "^10.7.0", "axios": "^1.11.0", + "chart.js": "^4.5.1", + "chartjs-adapter-dayjs-4": "^1.0.4", + "chartjs-plugin-zoom": "^2.2.0", "dayjs": "^1.11.13", "html-to-image": "^1.11.11", "html2canvas": "^1.4.1", "pinia": "^2.3.1", "v-pagination-3": "^0.1.7", "vue": "^3.3.11", + "vue-chartjs": "^5.3.3", "vue-debounce": "^5.0.0", "vue-router": "^4.2.5", "vue3-pdfmake": "^2.2.0" @@ -586,6 +590,11 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==" + }, "node_modules/@kyvg/vue3-notification": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/@kyvg/vue3-notification/-/vue3-notification-3.4.1.tgz", @@ -1215,6 +1224,11 @@ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", "dev": true }, + "node_modules/@types/hammerjs": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", + "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==" + }, "node_modules/@types/leaflet": { "version": "1.9.20", "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.20.tgz", @@ -1800,6 +1814,41 @@ } ] }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/chartjs-adapter-dayjs-4": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chartjs-adapter-dayjs-4/-/chartjs-adapter-dayjs-4-1.0.4.tgz", + "integrity": "sha512-yy9BAYW4aNzPVrCWZetbILegTRb7HokhgospPoC3b5iZ5qdlqNmXts2KdSp6AqnjkPAp/YWyHDxLvIvwt5x81w==", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "chart.js": ">=4.0.1", + "dayjs": "^1.9.7" + } + }, + "node_modules/chartjs-plugin-zoom": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.2.0.tgz", + "integrity": "sha512-in6kcdiTlP6npIVLMd4zXZ08PDUXC52gZ4FAy5oyjk1zX3gKarXMAof7B9eFiisf9WOC3bh2saHg+J5WtLXZeA==", + "dependencies": { + "@types/hammerjs": "^2.0.45", + "hammerjs": "^2.0.8" + }, + "peerDependencies": { + "chart.js": ">=3.2.0" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -2366,6 +2415,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hammerjs": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz", + "integrity": "sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -3676,6 +3733,15 @@ } } }, + "node_modules/vue-chartjs": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.3.tgz", + "integrity": "sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==", + "peerDependencies": { + "chart.js": "^4.1.1", + "vue": "^3.0.0-0 || ^2.7.0" + } + }, "node_modules/vue-debounce": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/vue-debounce/-/vue-debounce-5.0.1.tgz", diff --git a/package.json b/package.json index 80c1e11..4c32396 100755 --- a/package.json +++ b/package.json @@ -17,12 +17,16 @@ "@vuelidate/validators": "^2.0.4", "@vueuse/core": "^10.7.0", "axios": "^1.11.0", + "chart.js": "^4.5.1", + "chartjs-adapter-dayjs-4": "^1.0.4", + "chartjs-plugin-zoom": "^2.2.0", "dayjs": "^1.11.13", "html-to-image": "^1.11.11", "html2canvas": "^1.4.1", "pinia": "^2.3.1", "v-pagination-3": "^0.1.7", "vue": "^3.3.11", + "vue-chartjs": "^5.3.3", "vue-debounce": "^5.0.0", "vue-router": "^4.2.5", "vue3-pdfmake": "^2.2.0" diff --git a/src/assets/modern.css b/src/assets/modern.css new file mode 100644 index 0000000..2e7a0f9 --- /dev/null +++ b/src/assets/modern.css @@ -0,0 +1,184 @@ +/* Shared Modern UI Styles */ + +/* Stat Pills */ +.stat-pill { + @apply flex items-center gap-2 px-4 py-2 rounded-xl bg-base-200/80 border border-base-content/5; +} +.stat-pill-value { + @apply text-xl font-bold; +} +.stat-pill-label { + @apply text-xs text-base-content/60 uppercase tracking-wider; +} +.stat-pill-success { + @apply bg-success/10 border-success/20; +} +.stat-pill-success .stat-pill-value { + @apply text-success; +} +.stat-pill-info { + @apply bg-info/10 border-info/20; +} +.stat-pill-info .stat-pill-value { + @apply text-info; +} + +/* Town Chips */ +.town-chip { + @apply flex items-center gap-2 px-3 py-1.5 rounded-full bg-base-200 hover:bg-base-300 transition-all text-sm whitespace-nowrap cursor-pointer; +} +.town-chip-count { + @apply px-2 py-0.5 rounded-full bg-base-content/10 text-xs font-mono; +} +.town-chip-active { + @apply bg-primary text-primary-content; +} +.town-chip-active .town-chip-count { + @apply bg-primary-content/20; +} +.town-chip-clear { + @apply bg-error/10 text-error hover:bg-error/20; +} + +/* Modern Table Card */ +.modern-table-card { + @apply bg-gradient-to-br from-neutral to-neutral/80 rounded-2xl shadow-xl border border-base-content/5 overflow-hidden; +} + +/* Modern Table */ +.modern-table { + @apply w-full; +} +.modern-table thead { + @apply bg-base-content/5; +} +.modern-table th { + @apply px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-base-content/60; +} +.modern-table td { + @apply px-4 py-4; +} +.modern-table tbody tr { + @apply border-t border-base-content/5; +} + +/* Sort Header */ +.sort-header { + @apply flex items-center gap-1 hover:text-primary transition-colors cursor-pointer; +} + +/* Table Row Hover */ +.table-row-hover { + @apply transition-all duration-200; +} +.table-row-hover:hover { + @apply bg-primary/5; +} + +/* Row urgency highlighting */ +.row-urgent { + @apply bg-error/5 border-l-4 border-l-error; +} +.row-urgent:hover { + @apply bg-error/10; +} +.row-prime { + @apply bg-warning/5 border-l-4 border-l-warning; +} +.row-prime:hover { + @apply bg-warning/10; +} +.row-sameday { + @apply bg-info/5 border-l-4 border-l-info; +} +.row-sameday:hover { + @apply bg-info/10; +} + +/* Status Badge */ +.status-badge { + @apply inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium; +} +.status-dot { + @apply w-1.5 h-1.5 rounded-full; +} +.status-waiting { + @apply bg-warning/10 text-warning; +} +.status-waiting .status-dot { + @apply bg-warning animate-pulse; +} +.status-outfordelivery { + @apply bg-info/10 text-info; +} +.status-outfordelivery .status-dot { + @apply bg-info animate-pulse; +} +.status-finalized { + @apply bg-success/10 text-success; +} +.status-finalized .status-dot { + @apply bg-success; +} +.status-cancelled, .status-issue { + @apply bg-error/10 text-error; +} +.status-cancelled .status-dot, .status-issue .status-dot { + @apply bg-error; +} +.status-default { + @apply bg-base-content/10 text-base-content/60; +} +.status-default .status-dot { + @apply bg-base-content/40; +} + +/* Special Tags */ +.special-tag { + @apply inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-bold uppercase tracking-wide; +} +.tag-emergency { + @apply bg-error text-error-content animate-pulse; +} +.tag-prime { + @apply bg-warning text-warning-content; +} +.tag-sameday { + @apply bg-info text-info-content; +} + +/* Gallons Badge */ +.gallons-badge { + @apply inline-flex items-center gap-1 px-3 py-1.5 rounded-lg bg-success/10 border border-success/20 text-success font-mono text-lg font-bold shadow-sm; +} +.gallons-fill { + @apply bg-info/10 text-info border-info/20; +} + +/* Action Buttons */ +.action-btn { + @apply p-2 rounded-lg hover:bg-base-content/10 transition-colors text-base-content/60 hover:text-base-content; +} +.action-btn-secondary { + @apply hover:bg-secondary/20 hover:text-secondary; +} +.action-btn-accent { + @apply hover:bg-accent/20 hover:text-accent; +} +.action-btn-success { + @apply hover:bg-success/20 hover:text-success; +} + +/* Mobile Cards */ +.mobile-card { + @apply bg-base-100/50 backdrop-blur-sm rounded-xl mb-4 shadow-sm border border-base-content/5; +} +.mobile-card-urgent { + @apply border-l-4 border-l-error bg-error/5; +} +.mobile-card-prime { + @apply border-l-4 border-l-warning bg-warning/5; +} +.mobile-card-sameday { + @apply border-l-4 border-l-info bg-info/5; +} diff --git a/src/composables/useAddressAutocomplete.ts b/src/composables/useAddressAutocomplete.ts new file mode 100644 index 0000000..a4795cf --- /dev/null +++ b/src/composables/useAddressAutocomplete.ts @@ -0,0 +1,190 @@ +import { ref, watch } from 'vue'; +import { addressService, TownSuggestion, StreetSuggestion } from '../services/addressService'; + +/** + * Debounce utility function + */ +function debounce void>(fn: T, delay: number): T { + let timeout: ReturnType; + return ((...args: any[]) => { + clearTimeout(timeout); + timeout = setTimeout(() => fn(...args), delay); + }) as T; +} + +/** + * Composable for town autocomplete functionality + */ +export function useTownAutocomplete() { + const townSuggestions = ref([]); + const isLoadingTowns = ref(false); + const showTownDropdown = ref(false); + const highlightedTownIndex = ref(-1); + const townError = ref(''); + + const searchTowns = debounce(async (query: string) => { + if (!query || query.length < 2) { + townSuggestions.value = []; + showTownDropdown.value = false; + return; + } + + isLoadingTowns.value = true; + townError.value = ''; + + try { + const response = await addressService.searchTowns(query); + if (response.data.ok) { + townSuggestions.value = response.data.suggestions; + showTownDropdown.value = townSuggestions.value.length > 0; + highlightedTownIndex.value = -1; + } + } catch (error) { + console.error('Town search error:', error); + townError.value = 'Unable to search towns'; + townSuggestions.value = []; + } finally { + isLoadingTowns.value = false; + } + }, 300); + + const closeTownDropdown = () => { + // Delay to allow click events to fire + setTimeout(() => { + showTownDropdown.value = false; + highlightedTownIndex.value = -1; + }, 150); + }; + + const handleTownKeydown = ( + event: KeyboardEvent, + onSelect: (suggestion: TownSuggestion) => void + ) => { + if (!showTownDropdown.value || townSuggestions.value.length === 0) return; + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + highlightedTownIndex.value = Math.min( + highlightedTownIndex.value + 1, + townSuggestions.value.length - 1 + ); + break; + case 'ArrowUp': + event.preventDefault(); + highlightedTownIndex.value = Math.max(highlightedTownIndex.value - 1, 0); + break; + case 'Enter': + event.preventDefault(); + if (highlightedTownIndex.value >= 0) { + onSelect(townSuggestions.value[highlightedTownIndex.value]); + } else if (townSuggestions.value.length === 1) { + onSelect(townSuggestions.value[0]); + } + break; + case 'Escape': + showTownDropdown.value = false; + highlightedTownIndex.value = -1; + break; + } + }; + + return { + townSuggestions, + isLoadingTowns, + showTownDropdown, + highlightedTownIndex, + townError, + searchTowns, + closeTownDropdown, + handleTownKeydown, + }; +} + +/** + * Composable for street/address autocomplete functionality + */ +export function useStreetAutocomplete() { + const streetSuggestions = ref([]); + const isLoadingStreets = ref(false); + const showStreetDropdown = ref(false); + const highlightedStreetIndex = ref(-1); + const streetError = ref(''); + + const searchStreets = debounce(async (town: string, state: string, query: string) => { + if (!town || !state || !query || query.length < 1) { + streetSuggestions.value = []; + showStreetDropdown.value = false; + return; + } + + isLoadingStreets.value = true; + streetError.value = ''; + + try { + const response = await addressService.searchStreets(town, state, query); + if (response.data.ok) { + streetSuggestions.value = response.data.suggestions; + showStreetDropdown.value = streetSuggestions.value.length > 0; + highlightedStreetIndex.value = -1; + } + } catch (error) { + console.error('Street search error:', error); + streetError.value = 'Unable to search addresses'; + streetSuggestions.value = []; + } finally { + isLoadingStreets.value = false; + } + }, 300); + + const closeStreetDropdown = () => { + setTimeout(() => { + showStreetDropdown.value = false; + highlightedStreetIndex.value = -1; + }, 150); + }; + + const handleStreetKeydown = ( + event: KeyboardEvent, + onSelect: (suggestion: StreetSuggestion) => void + ) => { + if (!showStreetDropdown.value || streetSuggestions.value.length === 0) return; + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + highlightedStreetIndex.value = Math.min( + highlightedStreetIndex.value + 1, + streetSuggestions.value.length - 1 + ); + break; + case 'ArrowUp': + event.preventDefault(); + highlightedStreetIndex.value = Math.max(highlightedStreetIndex.value - 1, 0); + break; + case 'Enter': + event.preventDefault(); + if (highlightedStreetIndex.value >= 0) { + onSelect(streetSuggestions.value[highlightedStreetIndex.value]); + } else if (streetSuggestions.value.length === 1) { + onSelect(streetSuggestions.value[0]); + } + break; + case 'Escape': + showStreetDropdown.value = false; + highlightedStreetIndex.value = -1; + break; + } + }; + + return { + streetSuggestions, + isLoadingStreets, + showStreetDropdown, + highlightedStreetIndex, + streetError, + searchStreets, + closeStreetDropdown, + handleStreetKeydown, + }; +} diff --git a/src/composables/useFormValidation.ts b/src/composables/useFormValidation.ts new file mode 100644 index 0000000..67bbbca --- /dev/null +++ b/src/composables/useFormValidation.ts @@ -0,0 +1,150 @@ +import { notify } from "@kyvg/vue3-notification"; + +/** + * Vuelidate field validator interface for type checking. + * This is a simplified version of the actual vuelidate type. + */ +interface FieldValidator { + $error: boolean; + $errors?: Array<{ $message: string }>; + $validate?: () => Promise; +} + +/** + * Composable for consistent form validation styling and behavior across the app. + * + * Provides: + * - Input class helper that adds 'input-error' when field has validation errors + * - Select class helper that adds 'select-error' when field has validation errors + * - Textarea class helper that adds 'textarea-error' when field has validation errors + * - Standardized form validation with popup notification + */ +export function useFormValidation() { + /** + * Returns input classes with error styling when validation fails. + * Use with :class binding on input elements. + * + * @param fieldValidator - The vuelidate field validator (e.g., v$.form.fieldName) + * @param baseClasses - Optional base classes to include (default: 'input input-bordered input-sm w-full') + * @returns Object with class bindings + * + * @example + * + */ + const inputClasses = ( + fieldValidator: FieldValidator | undefined, + baseClasses: string = 'input input-bordered input-sm w-full' + ): Record | string => { + if (!fieldValidator) { + return baseClasses; + } + return { + [baseClasses]: true, + 'input-error': fieldValidator.$error === true, + }; + }; + + /** + * Returns select classes with error styling when validation fails. + * + * @param fieldValidator - The vuelidate field validator + * @param baseClasses - Optional base classes (default: 'select select-bordered select-sm w-full') + */ + const selectClasses = ( + fieldValidator: FieldValidator | undefined, + baseClasses: string = 'select select-bordered select-sm w-full' + ): Record | string => { + if (!fieldValidator) { + return baseClasses; + } + return { + [baseClasses]: true, + 'select-error': fieldValidator.$error === true, + }; + }; + + /** + * Returns textarea classes with error styling when validation fails. + * + * @param fieldValidator - The vuelidate field validator + * @param baseClasses - Optional base classes (default: 'textarea textarea-bordered') + */ + const textareaClasses = ( + fieldValidator: FieldValidator | undefined, + baseClasses: string = 'textarea textarea-bordered' + ): Record | string => { + if (!fieldValidator) { + return baseClasses; + } + return { + [baseClasses]: true, + 'textarea-error': fieldValidator.$error === true, + }; + }; + + /** + * Validates the form and shows a popup notification if validation fails. + * Returns true if form is valid, false otherwise. + * + * @param v$ - The vuelidate instance + * @param errorTitle - Title for the error notification (default: 'Validation Error') + * @param errorMessage - Message for the error notification (default: 'Please fill out all required fields correctly.') + * @returns Promise - true if valid, false if invalid + * + * @example + * const onSubmit = async () => { + * if (await validateForm(v$)) { + * // Submit the form + * } + * } + */ + const validateForm = async ( + v$: { value: { $validate: () => Promise } }, + errorTitle: string = 'Validation Error', + errorMessage: string = 'Please fill out all required fields correctly.' + ): Promise => { + const isValid = await v$.value.$validate(); + + if (!isValid) { + notify({ + title: errorTitle, + text: errorMessage, + type: 'error', + }); + } + + return isValid; + }; + + /** + * Gets the first error message for a field, or empty string if no error. + * + * @param fieldValidator - The vuelidate field validator + * @returns The error message string or empty string + */ + const getErrorMessage = (fieldValidator: FieldValidator | undefined): string => { + if (!fieldValidator || !fieldValidator.$error || !fieldValidator.$errors?.length) { + return ''; + } + return fieldValidator.$errors[0].$message as string; + }; + + /** + * Checks if a field has an error. + * + * @param fieldValidator - The vuelidate field validator + * @returns boolean - true if field has error + */ + const hasError = (fieldValidator: FieldValidator | undefined): boolean => { + return fieldValidator?.$error === true; + }; + + return { + inputClasses, + selectClasses, + textareaClasses, + validateForm, + getErrorMessage, + hasError, + }; +} diff --git a/src/layouts/sidebar/sidebar.vue b/src/layouts/sidebar/sidebar.vue index 1c6f782..664c10d 100755 --- a/src/layouts/sidebar/sidebar.vue +++ b/src/layouts/sidebar/sidebar.vue @@ -44,30 +44,31 @@
  • Home
  • - Todays Deliveries + Todays Tickets {{ countsStore.today }}
  • - Tomorrows Deliveries + Tomorrows Tickets {{ countsStore.tomorrow }}
  • - Waiting Deliveries + Waiting Tickets {{ countsStore.waiting }}
  • Issue Tickets
  • - Pending Payment + Pending Tickets {{ countsStore.pending }}
  • Finalized Tickets
  • + @@ -137,6 +138,11 @@ {{ countsStore.transaction }} +
  • + + History + +
  • @@ -159,6 +165,22 @@ + + +
  • +
    + + + + + Stats + +
      +
    • Daily Deliveries
    • +
    • Totals Comparison
    • +
    +
    +
  • diff --git a/src/main.ts b/src/main.ts index 4076d7b..872ec23 100755 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,6 @@ import { createApp } from 'vue'; import './assets/tailwind.css' +import './assets/modern.css' // Import api early to register global axios interceptors import './services/api'; import App from './App.vue'; @@ -11,8 +12,8 @@ import { useThemeStore } from './stores/theme'; const pinia = createPinia() const app = createApp(App) - app.use(pinia) - app.use(router) +app.use(pinia) +app.use(router) .component('pagination', Pagination); // Initialize theme before mounting to prevent flash of default theme diff --git a/src/pages/admin/authorize.vue b/src/pages/admin/authorize.vue index 41fa256..fedc82b 100644 --- a/src/pages/admin/authorize.vue +++ b/src/pages/admin/authorize.vue @@ -9,15 +9,29 @@
  • Set Oil Pricing
  • - -

    - Set Today's Oil Pricing -

    + + +
    +
    +

    +
    + + + +
    + Set Today's Oil Pricing +

    +

    Set daily oil prices and service fees

    +
    +
    -
    +
    - +

    Base Pricing

    @@ -29,23 +43,26 @@
    @@ -60,30 +77,33 @@
    -
    - +
    @@ -92,7 +112,7 @@
    - + + + diff --git a/src/pages/admin/stats/StatsHome.vue b/src/pages/admin/stats/StatsHome.vue new file mode 100644 index 0000000..d1996ba --- /dev/null +++ b/src/pages/admin/stats/StatsHome.vue @@ -0,0 +1,59 @@ + + + diff --git a/src/pages/admin/stats/TotalsComparison.vue b/src/pages/admin/stats/TotalsComparison.vue new file mode 100644 index 0000000..0df0514 --- /dev/null +++ b/src/pages/admin/stats/TotalsComparison.vue @@ -0,0 +1,123 @@ + + + diff --git a/src/pages/auth/changepassword.vue b/src/pages/auth/changepassword.vue index b5fc045..ee1269e 100755 --- a/src/pages/auth/changepassword.vue +++ b/src/pages/auth/changepassword.vue @@ -12,7 +12,7 @@
    + id="password" type="password" placeholder="Password" :class="{ 'input-error': v$.ChangePasswordForm.new_password.$error }" /> {{ v$.ChangePasswordForm.new_password.$errors[0].$message }} @@ -20,7 +20,7 @@
    + id="passwordtwo" type="password" autocomplete="off" placeholder="Confirm Password" :class="{ 'input-error': v$.ChangePasswordForm.password_confirm.$error }" /> {{ v$.ChangePasswordForm.password_confirm.$errors[0].$message }} diff --git a/src/pages/auth/login.vue b/src/pages/auth/login.vue index 0c45407..c2508d4 100755 --- a/src/pages/auth/login.vue +++ b/src/pages/auth/login.vue @@ -14,7 +14,7 @@
    + autocomplete="off" placeholder="Username" :class="{ 'input-error': v$.ForgotForm.username.$error }" /> {{ v$.ForgotForm.username.$errors[0].$message }}
    + autocomplete="off" placeholder="Email" :class="{ 'input-error': v$.ForgotForm.email.$error }" /> {{ v$.ForgotForm.email.$errors[0].$message }} diff --git a/src/pages/auth/register.vue b/src/pages/auth/register.vue index aba9e02..9cfc6f2 100755 --- a/src/pages/auth/register.vue +++ b/src/pages/auth/register.vue @@ -9,7 +9,7 @@
    + type="text" placeholder="Login Username" :class="{ 'input-error': v$.registerForm.username.$error }" /> {{ v$.registerForm.username.$errors[0].$message }} @@ -18,7 +18,7 @@
    + type="text" placeholder="Email" :class="{ 'input-error': v$.registerForm.email.$error }" /> {{ v$.registerForm.email.$errors[0].$message }} @@ -26,7 +26,7 @@
    + type="password" autocomplete="off" placeholder="Password" :class="{ 'input-error': v$.registerForm.password.$error }" /> {{ v$.registerForm.password.$errors[0].$message }} @@ -34,7 +34,7 @@
    + id="password" type="password" autocomplete="off" placeholder="Confirm Password" :class="{ 'input-error': v$.registerForm.password_confirm.$error }" /> {{ v$.registerForm.password_confirm.$errors[0].$message }} diff --git a/src/pages/automatic/home.vue b/src/pages/automatic/home.vue index 2ca9046..4ccf570 100755 --- a/src/pages/automatic/home.vue +++ b/src/pages/automatic/home.vue @@ -9,49 +9,82 @@
  • Automatic Deliveries
  • -

    Automatic Deliveries

    + +
    +
    +

    +
    + + + +
    + Automatic Deliveries +

    +

    Manage automatic delivery customers and schedules

    +
    + + +
    +
    + {{ deliveries.length }} + Customers +
    +
    +
    -
    - -
    -

    Customers on Automatic Delivery

    -
    {{ deliveries.length }} customers found
    -
    -
    +