Compare commits
3 Commits
61f93ec4e8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c28c0c2d2 | |||
| 421ba896a0 | |||
| 9a4d5dd07b |
8
.env.local
Normal file
8
.env.local
Normal file
@@ -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'
|
||||||
@@ -6,7 +6,7 @@ ENV VITE_MONEY_URL="http://localhost:9513"
|
|||||||
ENV VITE_AUTHORIZE_URL="http://localhost:9516"
|
ENV VITE_AUTHORIZE_URL="http://localhost:9516"
|
||||||
ENV VITE_VOIPMS_URL="http://localhost:9517"
|
ENV VITE_VOIPMS_URL="http://localhost:9517"
|
||||||
ENV VITE_SERVICE_URL="http://localhost:9515"
|
ENV VITE_SERVICE_URL="http://localhost:9515"
|
||||||
|
ENV VITE_ADDRESS_CHECKER_URL="http://localhost:9618"
|
||||||
|
|
||||||
ENV VITE_VOIPMS_TOKEN="my_secret_token"
|
ENV VITE_VOIPMS_TOKEN="my_secret_token"
|
||||||
ENV VITE_COMPANY_ID='1'
|
ENV VITE_COMPANY_ID='1'
|
||||||
|
|||||||
@@ -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_AUTHORIZE_URL="http://192.168.1.204:9616"
|
||||||
ENV VITE_VOIPMS_URL="http://192.168.1.204:9617"
|
ENV VITE_VOIPMS_URL="http://192.168.1.204:9617"
|
||||||
ENV VITE_SERVICE_URL="http://192.168.1.204:9615"
|
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'
|
ENV VITE_COMPANY_ID='1'
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ ENV VITE_MONEY_URL="https://apimoney.edwineames.com"
|
|||||||
ENV VITE_AUTHORIZE_URL="https://apicard.edwineames.com"
|
ENV VITE_AUTHORIZE_URL="https://apicard.edwineames.com"
|
||||||
ENV VITE_VOIPMS_URL="https://apiphone.edwineames.com"
|
ENV VITE_VOIPMS_URL="https://apiphone.edwineames.com"
|
||||||
ENV VITE_SERVICE_URL="https://apiservice.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"
|
ENV VITE_VOIPMS_TOKEN="my_secret_token"
|
||||||
|
|
||||||
|
|||||||
66
package-lock.json
generated
66
package-lock.json
generated
@@ -16,12 +16,16 @@
|
|||||||
"@vuelidate/validators": "^2.0.4",
|
"@vuelidate/validators": "^2.0.4",
|
||||||
"@vueuse/core": "^10.7.0",
|
"@vueuse/core": "^10.7.0",
|
||||||
"axios": "^1.11.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",
|
"dayjs": "^1.11.13",
|
||||||
"html-to-image": "^1.11.11",
|
"html-to-image": "^1.11.11",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"pinia": "^2.3.1",
|
"pinia": "^2.3.1",
|
||||||
"v-pagination-3": "^0.1.7",
|
"v-pagination-3": "^0.1.7",
|
||||||
"vue": "^3.3.11",
|
"vue": "^3.3.11",
|
||||||
|
"vue-chartjs": "^5.3.3",
|
||||||
"vue-debounce": "^5.0.0",
|
"vue-debounce": "^5.0.0",
|
||||||
"vue-router": "^4.2.5",
|
"vue-router": "^4.2.5",
|
||||||
"vue3-pdfmake": "^2.2.0"
|
"vue3-pdfmake": "^2.2.0"
|
||||||
@@ -586,6 +590,11 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@kyvg/vue3-notification": {
|
||||||
"version": "3.4.1",
|
"version": "3.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@kyvg/vue3-notification/-/vue3-notification-3.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@kyvg/vue3-notification/-/vue3-notification-3.4.1.tgz",
|
||||||
@@ -1215,6 +1224,11 @@
|
|||||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/@types/leaflet": {
|
||||||
"version": "1.9.20",
|
"version": "1.9.20",
|
||||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.20.tgz",
|
"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": {
|
"node_modules/chokidar": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||||
@@ -2366,6 +2415,14 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/has-symbols": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
"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": {
|
"node_modules/vue-debounce": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/vue-debounce/-/vue-debounce-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/vue-debounce/-/vue-debounce-5.0.1.tgz",
|
||||||
|
|||||||
@@ -17,12 +17,16 @@
|
|||||||
"@vuelidate/validators": "^2.0.4",
|
"@vuelidate/validators": "^2.0.4",
|
||||||
"@vueuse/core": "^10.7.0",
|
"@vueuse/core": "^10.7.0",
|
||||||
"axios": "^1.11.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",
|
"dayjs": "^1.11.13",
|
||||||
"html-to-image": "^1.11.11",
|
"html-to-image": "^1.11.11",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"pinia": "^2.3.1",
|
"pinia": "^2.3.1",
|
||||||
"v-pagination-3": "^0.1.7",
|
"v-pagination-3": "^0.1.7",
|
||||||
"vue": "^3.3.11",
|
"vue": "^3.3.11",
|
||||||
|
"vue-chartjs": "^5.3.3",
|
||||||
"vue-debounce": "^5.0.0",
|
"vue-debounce": "^5.0.0",
|
||||||
"vue-router": "^4.2.5",
|
"vue-router": "^4.2.5",
|
||||||
"vue3-pdfmake": "^2.2.0"
|
"vue3-pdfmake": "^2.2.0"
|
||||||
|
|||||||
184
src/assets/modern.css
Normal file
184
src/assets/modern.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
190
src/composables/useAddressAutocomplete.ts
Normal file
190
src/composables/useAddressAutocomplete.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import { addressService, TownSuggestion, StreetSuggestion } from '../services/addressService';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounce utility function
|
||||||
|
*/
|
||||||
|
function debounce<T extends (...args: any[]) => void>(fn: T, delay: number): T {
|
||||||
|
let timeout: ReturnType<typeof setTimeout>;
|
||||||
|
return ((...args: any[]) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => fn(...args), delay);
|
||||||
|
}) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for town autocomplete functionality
|
||||||
|
*/
|
||||||
|
export function useTownAutocomplete() {
|
||||||
|
const townSuggestions = ref<TownSuggestion[]>([]);
|
||||||
|
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<StreetSuggestion[]>([]);
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
150
src/composables/useFormValidation.ts
Normal file
150
src/composables/useFormValidation.ts
Normal file
@@ -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<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* <input :class="inputClasses(v$.form.email)" v-model="form.email" />
|
||||||
|
*/
|
||||||
|
const inputClasses = (
|
||||||
|
fieldValidator: FieldValidator | undefined,
|
||||||
|
baseClasses: string = 'input input-bordered input-sm w-full'
|
||||||
|
): Record<string, boolean> | 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, boolean> | 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, boolean> | 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<boolean> - true if valid, false if invalid
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const onSubmit = async () => {
|
||||||
|
* if (await validateForm(v$)) {
|
||||||
|
* // Submit the form
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
const validateForm = async (
|
||||||
|
v$: { value: { $validate: () => Promise<boolean> } },
|
||||||
|
errorTitle: string = 'Validation Error',
|
||||||
|
errorMessage: string = 'Please fill out all required fields correctly.'
|
||||||
|
): Promise<boolean> => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -15,11 +15,35 @@
|
|||||||
<div class="">Spring Rebuilders - (508) 799-9342</div>
|
<div class="">Spring Rebuilders - (508) 799-9342</div>
|
||||||
</nav>
|
</nav>
|
||||||
<nav>
|
<nav>
|
||||||
<h6 class="footer-title">Google Review link / qrcode</h6>
|
<h6 class="footer-title">Search Shortcuts</h6>
|
||||||
<a class="link link-hover">https://g.page/r/CZHnPQ85LsMUEBM/review</a>
|
<div class="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||||
<button @click="copyReviewLink" class="btn btn-outline btn-sm ml-2">Copy Link</button>
|
<div class="flex items-center gap-2">
|
||||||
<h6 class="link link-hover"> <img src="../../assets/images/googlereview.png" alt="Company Logo" class="h-10 w-auto" /></h6>
|
<kbd class="kbd kbd-sm bg-neutral text-neutral-content border-neutral-content/30">@</kbd>
|
||||||
<a class="link link-hover"></a>
|
<span class="text-sm opacity-80">Last name</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<kbd class="kbd kbd-sm bg-neutral text-neutral-content border-neutral-content/30">!</kbd>
|
||||||
|
<span class="text-sm opacity-80">Address</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<kbd class="kbd kbd-sm bg-neutral text-neutral-content border-neutral-content/30">#</kbd>
|
||||||
|
<span class="text-sm opacity-80">Phone</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<kbd class="kbd kbd-sm bg-neutral text-neutral-content border-neutral-content/30">$</kbd>
|
||||||
|
<span class="text-sm opacity-80">Account #</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<nav>
|
||||||
|
<h6 class="footer-title">Google Review</h6>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<img src="../../assets/images/googlereview.png" alt="Google Review QR" class="h-16 w-auto rounded" />
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<a class="link link-hover text-xs break-all max-w-32">g.page/r/CZHnPQ85LsMUEBM/review</a>
|
||||||
|
<button @click="copyReviewLink" class="btn btn-outline btn-xs">Copy Link</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</footer>
|
</footer>
|
||||||
</template>
|
</template>
|
||||||
@@ -29,13 +53,45 @@
|
|||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
|
||||||
const copyReviewLink = async () => {
|
const copyReviewLink = async () => {
|
||||||
try {
|
const textToCopy = 'https://g.page/r/CZHnPQ85LsMUEBM/review';
|
||||||
await navigator.clipboard.writeText('https://g.page/r/CZHnPQ85LsMUEBM/review')
|
|
||||||
alert('Link copied to clipboard!')
|
// Try the modern Clipboard API first (works in secure contexts like HTTPS or localhost)
|
||||||
} catch (err) {
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
console.error('Failed to copy text: ', err)
|
try {
|
||||||
alert('Failed to copy link. Please try again.')
|
await navigator.clipboard.writeText(textToCopy);
|
||||||
}
|
alert('Link copied to clipboard!');
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Clipboard API failed, falling back to legacy method.', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for non-secure contexts (like HTTP LAN)
|
||||||
|
const textArea = document.createElement("textarea");
|
||||||
|
textArea.value = textToCopy;
|
||||||
|
|
||||||
|
// Ensure it's not visible but part of the DOM
|
||||||
|
textArea.style.position = "fixed";
|
||||||
|
textArea.style.left = "-9999px";
|
||||||
|
textArea.style.top = "0";
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const successful = document.execCommand('copy');
|
||||||
|
if (successful) {
|
||||||
|
alert('Link copied to clipboard!');
|
||||||
|
} else {
|
||||||
|
alert('Unable to copy link. Please manually copy: ' + textToCopy);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fallback copy failed', err);
|
||||||
|
alert('Unable to copy link. Please manually copy: ' + textToCopy);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.removeChild(textArea);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<style></style>
|
<style></style>
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- Main header container, contains both rows -->
|
<!-- Main header container, contains both rows -->
|
||||||
<header class="sticky top-0 z-50 bg-base-200 shadow-sm">
|
<header class="sticky top-0 z-50 bg-base-200 shadow-sm">
|
||||||
|
|
||||||
<!-- Row 1: The primary navbar -->
|
<!-- Row 1: The primary navbar -->
|
||||||
<div class="navbar px-4">
|
<div class="navbar px-4">
|
||||||
|
|
||||||
<!-- Navbar Start: Logo & Mobile Menu Toggle -->
|
<!-- Navbar Start: Logo & Mobile Menu Toggle -->
|
||||||
<div class="navbar-start">
|
<div class="navbar-start">
|
||||||
<label for="my-drawer-2" class="btn btn-ghost btn-circle drawer-button lg:hidden">
|
<label for="my-drawer-2" class="btn btn-ghost btn-circle drawer-button lg:hidden">
|
||||||
@@ -13,11 +13,98 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||||
</svg>
|
</svg>
|
||||||
</label>
|
</label>
|
||||||
<div class="flex flex-col items-center">
|
|
||||||
<span class="text-xs text-base-content/60">{{ dayOfWeek }}</span>
|
<!-- Oil Price Dropdown (replaces date display) -->
|
||||||
<span class="normal-case text-xl font-bold">
|
<div class="dropdown dropdown-hover">
|
||||||
{{ currentDate }}
|
<label tabindex="0" class="btn btn-ghost gap-2 hover:bg-warning/10 transition-colors">
|
||||||
</span>
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-warning to-warning/60 flex items-center justify-center shadow-lg">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-warning-content">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-start">
|
||||||
|
<span class="text-xs text-base-content/60 font-medium">Oil Price</span>
|
||||||
|
<span class="text-xl font-bold text-warning" v-if="oilPrice.price_for_customer !== null">
|
||||||
|
${{ formatPrice(oilPrice.price_for_customer) }}<span class="text-xs font-normal text-base-content/60">/gal</span>
|
||||||
|
</span>
|
||||||
|
<span v-else class="loading loading-dots loading-xs"></span>
|
||||||
|
</div>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 text-base-content/40">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<div tabindex="0" class="dropdown-content z-[60] mt-2 p-0 shadow-2xl bg-base-100 rounded-xl w-80 border border-base-300">
|
||||||
|
<!-- Header with date -->
|
||||||
|
<div class="bg-gradient-to-r from-warning/20 to-warning/5 p-4 rounded-t-xl border-b border-base-300">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-base-content/60 uppercase tracking-wider font-medium">Today's Pricing</p>
|
||||||
|
<p class="text-sm font-semibold">{{ dayOfWeek }}, {{ currentDate }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="badge badge-warning badge-sm">Live</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Oil Pricing Section -->
|
||||||
|
<div class="p-4">
|
||||||
|
<h4 class="text-xs uppercase tracking-wider text-base-content/50 font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0112 21 8.25 8.25 0 016.038 7.048 8.287 8.287 0 009 9.6a8.983 8.983 0 013.361-6.867 8.21 8.21 0 003 2.48z" />
|
||||||
|
</svg>
|
||||||
|
Oil Delivery
|
||||||
|
</h4>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex justify-between items-center p-2 rounded-lg bg-base-200/50 hover:bg-base-200 transition-colors">
|
||||||
|
<span class="text-sm font-medium">Regular Delivery</span>
|
||||||
|
<span class="font-mono font-bold text-lg text-success">${{ formatPrice(oilPrice.price_for_customer) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center p-2 rounded-lg hover:bg-base-200/50 transition-colors">
|
||||||
|
<span class="text-sm text-base-content/70">Same Day</span>
|
||||||
|
<span class="font-mono font-semibold">${{ formatPrice(oilPrice.price_same_day) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center p-2 rounded-lg hover:bg-base-200/50 transition-colors">
|
||||||
|
<span class="text-sm text-base-content/70">Prime</span>
|
||||||
|
<span class="font-mono font-semibold">${{ formatPrice(oilPrice.price_prime) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center p-2 rounded-lg hover:bg-base-200/50 transition-colors">
|
||||||
|
<span class="text-sm text-base-content/70">Emergency</span>
|
||||||
|
<span class="font-mono font-semibold text-error">${{ formatPrice(oilPrice.price_emergency) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider my-0 px-4"></div>
|
||||||
|
|
||||||
|
<!-- Service Pricing Section -->
|
||||||
|
<div class="p-4">
|
||||||
|
<h4 class="text-xs uppercase tracking-wider text-base-content/50 font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 11-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 004.486-6.336l-3.276 3.277a3.004 3.004 0 01-2.25-2.25l3.276-3.276a4.5 4.5 0 00-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437l1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008z" />
|
||||||
|
</svg>
|
||||||
|
Service Calls
|
||||||
|
</h4>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex justify-between items-center p-2 rounded-lg bg-base-200/50 hover:bg-base-200 transition-colors">
|
||||||
|
<span class="text-sm font-medium">Per Hour</span>
|
||||||
|
<span class="font-mono font-bold text-lg text-info">$125</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center p-2 rounded-lg hover:bg-base-200/50 transition-colors">
|
||||||
|
<span class="text-sm text-base-content/70">Emergency</span>
|
||||||
|
<span class="font-mono font-semibold text-error">$200</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer with supplier cost (admin only) -->
|
||||||
|
<div v-if="user.user_admin === 0 && oilPrice.price_from_supplier" class="bg-base-200/30 p-3 rounded-b-xl border-t border-base-300">
|
||||||
|
<div class="flex justify-between items-center text-xs text-base-content/50">
|
||||||
|
<span>Supplier Cost</span>
|
||||||
|
<span class="font-mono">${{ formatPrice(oilPrice.price_from_supplier) }}/gal</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -83,7 +170,7 @@
|
|||||||
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52">
|
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52">
|
||||||
<li class="p-2 font-semibold">{{ user.user_name }}</li>
|
<li class="p-2 font-semibold">{{ user.user_name }}</li>
|
||||||
<div class="divider my-0"></div>
|
<div class="divider my-0"></div>
|
||||||
<li><router-link :to="{ name: 'employeeProfile', params: { id: user.user_id } }">Profile</router-link></li>
|
<li v-if="user && user.user_id"><router-link :to="{ name: 'employeeProfile', params: { id: user.user_id } }">Profile</router-link></li>
|
||||||
<li><router-link :to="{ name: 'changePassword' }">Change Password</router-link></li>
|
<li><router-link :to="{ name: 'changePassword' }">Change Password</router-link></li>
|
||||||
<div class="divider my-0"></div>
|
<div class="divider my-0"></div>
|
||||||
<li class="menu-title text-xs opacity-60">Theme</li>
|
<li class="menu-title text-xs opacity-60">Theme</li>
|
||||||
@@ -245,9 +332,27 @@ interface RoutingOption {
|
|||||||
// Router
|
// Router
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
// Oil price interface
|
||||||
|
interface OilPrice {
|
||||||
|
price_from_supplier: number | null;
|
||||||
|
price_for_customer: number | null;
|
||||||
|
price_for_employee: number | null;
|
||||||
|
price_same_day: number | null;
|
||||||
|
price_prime: number | null;
|
||||||
|
price_emergency: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
// Reactive data
|
// Reactive data
|
||||||
const user = ref({} as User)
|
const user = ref({} as User)
|
||||||
const currentPhone = ref('')
|
const currentPhone = ref('')
|
||||||
|
const oilPrice = ref<OilPrice>({
|
||||||
|
price_from_supplier: null,
|
||||||
|
price_for_customer: null,
|
||||||
|
price_for_employee: null,
|
||||||
|
price_same_day: null,
|
||||||
|
price_prime: null,
|
||||||
|
price_emergency: null
|
||||||
|
})
|
||||||
const routingOptions = ref([
|
const routingOptions = ref([
|
||||||
{ value: 'main', label: '407323' },
|
{ value: 'main', label: '407323' },
|
||||||
{ value: 'sip', label: '407323_auburnoil' },
|
{ value: 'sip', label: '407323_auburnoil' },
|
||||||
@@ -290,12 +395,45 @@ const dayOfWeek = computed((): string => {
|
|||||||
return now.toLocaleDateString('en-US', { weekday: 'long' });
|
return now.toLocaleDateString('en-US', { weekday: 'long' });
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Format price safely (handles null, string, and number)
|
||||||
|
const formatPrice = (price: number | string | null): string => {
|
||||||
|
if (price === null || price === undefined) return '—';
|
||||||
|
const numPrice = typeof price === 'string' ? parseFloat(price) : price;
|
||||||
|
if (isNaN(numPrice)) return '—';
|
||||||
|
return numPrice.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
userStatus()
|
userStatus()
|
||||||
updatestatus()
|
updatestatus()
|
||||||
|
fetchOilPrice()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Fetch oil pricing
|
||||||
|
const fetchOilPrice = () => {
|
||||||
|
const path = import.meta.env.VITE_BASE_URL + '/info/price/oil';
|
||||||
|
axios({
|
||||||
|
method: 'get',
|
||||||
|
url: path,
|
||||||
|
withCredentials: true,
|
||||||
|
headers: authHeader(),
|
||||||
|
})
|
||||||
|
.then((response: any) => {
|
||||||
|
oilPrice.value = {
|
||||||
|
price_from_supplier: response.data.price_from_supplier,
|
||||||
|
price_for_customer: response.data.price_for_customer,
|
||||||
|
price_for_employee: response.data.price_for_employee,
|
||||||
|
price_same_day: response.data.price_same_day,
|
||||||
|
price_prime: response.data.price_prime,
|
||||||
|
price_emergency: response.data.price_emergency
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.catch((error: any) => {
|
||||||
|
console.error('Failed to fetch oil pricing:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Functions
|
// Functions
|
||||||
const userStatus = async () => {
|
const userStatus = async () => {
|
||||||
let path = import.meta.env.VITE_BASE_URL + '/auth/whoami';
|
let path = import.meta.env.VITE_BASE_URL + '/auth/whoami';
|
||||||
|
|||||||
@@ -44,30 +44,31 @@
|
|||||||
<li><router-link :to="{ name: 'delivery' }" exact-active-class="active">Home</router-link></li>
|
<li><router-link :to="{ name: 'delivery' }" exact-active-class="active">Home</router-link></li>
|
||||||
<li>
|
<li>
|
||||||
<router-link :to="{ name: 'deliveryOutForDelivery' }" exact-active-class="active">
|
<router-link :to="{ name: 'deliveryOutForDelivery' }" exact-active-class="active">
|
||||||
Todays Deliveries
|
Todays Tickets
|
||||||
<span v-if="countsStore.today > 0" class="badge badge-secondary">{{ countsStore.today }}</span>
|
<span v-if="countsStore.today > 0" class="badge badge-secondary">{{ countsStore.today }}</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<router-link :to="{ name: 'deliveryTommorrow' }" exact-active-class="active">
|
<router-link :to="{ name: 'deliveryTommorrow' }" exact-active-class="active">
|
||||||
Tomorrows Deliveries
|
Tomorrows Tickets
|
||||||
<span v-if="countsStore.tomorrow > 0" class="badge badge-secondary">{{ countsStore.tomorrow }}</span>
|
<span v-if="countsStore.tomorrow > 0" class="badge badge-secondary">{{ countsStore.tomorrow }}</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<router-link :to="{ name: 'deliveryWaiting' }" exact-active-class="active">
|
<router-link :to="{ name: 'deliveryWaiting' }" exact-active-class="active">
|
||||||
Waiting Deliveries
|
Waiting Tickets
|
||||||
<span v-if="countsStore.waiting > 0" class="badge badge-info">{{ countsStore.waiting }}</span>
|
<span v-if="countsStore.waiting > 0" class="badge badge-info">{{ countsStore.waiting }}</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
<li><router-link :to="{ name: 'deliveryIssue' }" exact-active-class="active">Issue Tickets</router-link></li>
|
<li><router-link :to="{ name: 'deliveryIssue' }" exact-active-class="active">Issue Tickets</router-link></li>
|
||||||
<li>
|
<li>
|
||||||
<router-link :to="{ name: 'deliveryPending' }" exact-active-class="active">
|
<router-link :to="{ name: 'deliveryPending' }" exact-active-class="active">
|
||||||
Pending Payment
|
Pending Tickets
|
||||||
<span v-if="countsStore.pending > 0" class="badge badge-warning">{{ countsStore.pending }}</span>
|
<span v-if="countsStore.pending > 0" class="badge badge-warning">{{ countsStore.pending }}</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
<li><router-link :to="{ name: 'deliveryFinalized' }" exact-active-class="active">Finalized Tickets</router-link></li>
|
<li><router-link :to="{ name: 'deliveryFinalized' }" exact-active-class="active">Finalized Tickets</router-link></li>
|
||||||
|
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
</li>
|
</li>
|
||||||
@@ -137,6 +138,11 @@
|
|||||||
<span v-if="countsStore.transaction > 0" class="badge badge-secondary">{{ countsStore.transaction }}</span>
|
<span v-if="countsStore.transaction > 0" class="badge badge-secondary">{{ countsStore.transaction }}</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<router-link :to="{ name: 'transactionsHistory' }" exact-active-class="active">
|
||||||
|
History
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
</li>
|
</li>
|
||||||
@@ -159,6 +165,22 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<!-- Stats Section -->
|
||||||
|
<li>
|
||||||
|
<details>
|
||||||
|
<summary class="font-bold text-lg gap-3">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
|
||||||
|
</svg>
|
||||||
|
Stats
|
||||||
|
</summary>
|
||||||
|
<ul>
|
||||||
|
<li><router-link :to="{ name: 'statsDailyDeliveries' }" exact-active-class="active">Daily Deliveries</router-link></li>
|
||||||
|
<li><router-link :to="{ name: 'statsTotals' }" exact-active-class="active">Totals Comparison</router-link></li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createApp } from 'vue';
|
import { createApp } from 'vue';
|
||||||
import './assets/tailwind.css'
|
import './assets/tailwind.css'
|
||||||
|
import './assets/modern.css'
|
||||||
// Import api early to register global axios interceptors
|
// Import api early to register global axios interceptors
|
||||||
import './services/api';
|
import './services/api';
|
||||||
import App from './App.vue';
|
import App from './App.vue';
|
||||||
@@ -11,8 +12,8 @@ import { useThemeStore } from './stores/theme';
|
|||||||
|
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
app.use(pinia)
|
app.use(pinia)
|
||||||
app.use(router)
|
app.use(router)
|
||||||
.component('pagination', Pagination);
|
.component('pagination', Pagination);
|
||||||
|
|
||||||
// Initialize theme before mounting to prevent flash of default theme
|
// Initialize theme before mounting to prevent flash of default theme
|
||||||
|
|||||||
@@ -1,186 +1,296 @@
|
|||||||
<!-- src/pages/Index.vue -->
|
<!-- src/pages/Index.vue -->
|
||||||
<template>
|
<template>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="w-full px-4 md:px-10 ">
|
<div class="w-full px-4 md:px-10 py-4">
|
||||||
<!-- Breadcrumbs & Welcome Header -->
|
<!-- Welcome Header with Greeting -->
|
||||||
<div class="text-sm breadcrumbs">
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6">
|
||||||
<ul>
|
<div>
|
||||||
<li><router-link :to="{ name: 'home' }">Home</router-link></li>
|
<h1 class="text-3xl md:text-4xl font-bold bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent">
|
||||||
</ul>
|
Welcome back, {{ employee.employee_first_name }}!
|
||||||
|
</h1>
|
||||||
|
<p class="text-base-content/60 mt-1">Here's what's happening today</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="badge badge-lg badge-primary gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
|
||||||
|
</svg>
|
||||||
|
{{ formattedDate }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-3xl font-bold mt-4">
|
|
||||||
Welcome, {{ employee.employee_first_name }}!
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<!-- Main Dashboard Grid -->
|
<!-- Main Dashboard Grid -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 my-6 animate-fade-in">
|
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6 animate-fade-in">
|
||||||
|
|
||||||
<!-- Card 1: Today's Deliveries -->
|
|
||||||
<div class="bg-gradient-to-br from-neutral to-neutral/80 rounded-xl p-6 shadow-medium hover-lift xl:col-span-2">
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<h3 class="text-xl font-semibold">Today's Deliveries</h3>
|
|
||||||
<div class="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 text-primary">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 00-10.026 0 1.106 1.106 0 00-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex items-baseline gap-2">
|
|
||||||
<span class="text-4xl font-bold">{{ delivery_count }}</span>
|
|
||||||
<span class="text-base-content/60">total deliveries</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="flex justify-between text-sm mb-2">
|
|
||||||
<span class="font-medium">Completed</span>
|
|
||||||
<span class="font-mono">{{ delivery_count_delivered }} / {{ delivery_count }}</span>
|
|
||||||
</div>
|
|
||||||
<progress class="progress progress-primary w-full h-3" :value="delivery_count_delivered" :max="delivery_count"></progress>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Card 2: Today's Oil Price -->
|
<!-- Left Column: Stats & Chart -->
|
||||||
<div class="bg-gradient-to-br from-neutral to-neutral/80 rounded-xl p-6 shadow-medium hover-lift">
|
<div class="lg:col-span-8 space-y-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<h3 class="text-xl font-semibold">Oil Pricing</h3>
|
<!-- Stats Row: Quick Glance Cards -->
|
||||||
<div class="w-12 h-12 rounded-full bg-warning/10 flex items-center justify-center">
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 text-warning">
|
<!-- Today's Deliveries -->
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<div class="stat-card group">
|
||||||
</svg>
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-xs uppercase tracking-wider text-base-content/50 font-semibold">Today</span>
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 text-primary">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 00-10.026 0 1.106 1.106 0 00-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-3xl font-bold">{{ delivery_count }}</div>
|
||||||
|
<div class="flex items-center gap-1 mt-1">
|
||||||
|
<span class="text-success text-sm font-medium">{{ delivery_count_delivered }}</span>
|
||||||
|
<span class="text-base-content/50 text-xs">delivered</span>
|
||||||
|
</div>
|
||||||
|
<progress class="progress progress-primary w-full h-2 mt-2" :value="delivery_count_delivered" :max="delivery_count || 1"></progress>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Week Gallons -->
|
||||||
|
<div class="stat-card group">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-xs uppercase tracking-wider text-base-content/50 font-semibold">This Week</span>
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-success/10 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 text-success">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-3xl font-bold">{{ formatNumber(total_gallons_past_week) }}</div>
|
||||||
|
<div class="text-base-content/50 text-xs mt-1">gallons delivered</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Week Deliveries -->
|
||||||
|
<div class="stat-card group">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-xs uppercase tracking-wider text-base-content/50 font-semibold">Deliveries</span>
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-info/10 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 text-info">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-3xl font-bold">{{ total_deliveries }}</div>
|
||||||
|
<div class="text-base-content/50 text-xs mt-1">this week</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tomorrow's Deliveries -->
|
||||||
|
<div class="stat-card group">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-xs uppercase tracking-wider text-base-content/50 font-semibold">Tomorrow</span>
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-warning/10 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 text-warning">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5m-9-6h.008v.008H12v-.008zM12 15h.008v.008H12V15zm0 2.25h.008v.008H12v-.008zM9.75 15h.008v.008H9.75V15zm0 2.25h.008v.008H9.75v-.008zM7.5 15h.008v.008H7.5V15zm0 2.25h.008v.008H7.5v-.008zm6.75-4.5h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008V15zm0 2.25h.008v.008h-.008v-.008zm2.25-4.5h.008v.008H16.5v-.008zm0 2.25h.008v.008H16.5V15z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-3xl font-bold">{{ countsStore.tomorrow }}</div>
|
||||||
|
<div class="text-base-content/50 text-xs mt-1">deliveries scheduled</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex justify-between items-center">
|
<!-- Weekly Trend Chart -->
|
||||||
<span class="text-sm text-base-content/70">Per Gallon</span>
|
<div class="card-glass p-6">
|
||||||
<span class="text-2xl font-bold font-mono">${{ today_oil_price }}</span>
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold">Weekly Delivery Trend</h3>
|
||||||
|
<p class="text-sm text-base-content/50">Gallons delivered over the past 4 weeks</p>
|
||||||
|
</div>
|
||||||
|
<router-link :to="{ name: 'stats' }" class="btn btn-ghost btn-sm gap-1">
|
||||||
|
View Stats
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||||
|
</svg>
|
||||||
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
<div class="divider my-2"></div>
|
<div class="h-64">
|
||||||
<div class="space-y-2 text-sm">
|
<Line
|
||||||
<div class="flex justify-between">
|
v-if="chartData"
|
||||||
<span class="text-base-content/70">Same Day</span>
|
:data="chartData as any"
|
||||||
<span class="font-mono font-semibold">${{ price_same_day }}</span>
|
:options="chartOptions as any"
|
||||||
</div>
|
/>
|
||||||
<div class="flex justify-between">
|
<div v-else class="flex items-center justify-center h-full">
|
||||||
<span class="text-base-content/70">Prime</span>
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
<span class="font-mono font-semibold">${{ price_prime }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-base-content/70">Emergency</span>
|
|
||||||
<span class="font-mono font-semibold">${{ price_emergency }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Card 3: Service Pricing -->
|
<!-- Right Column: Map & Quick Actions -->
|
||||||
<div class="bg-gradient-to-br from-neutral to-neutral/80 rounded-xl p-6 shadow-medium hover-lift">
|
<div class="lg:col-span-4 space-y-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<h3 class="text-xl font-semibold">Service Pricing</h3>
|
<!-- Mini Delivery Map -->
|
||||||
<div class="w-12 h-12 rounded-full bg-info/10 flex items-center justify-center">
|
<div class="card-glass p-4 overflow-hidden">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 text-info">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 11-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 004.486-6.336l-3.276 3.277a3.004 3.004 0 01-2.25-2.25l3.276-3.276a4.5 4.5 0 00-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437l1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008z" />
|
<h3 class="font-semibold flex items-center gap-2">
|
||||||
</svg>
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-primary">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||||
|
</svg>
|
||||||
|
Today's Routes
|
||||||
|
</h3>
|
||||||
|
<router-link :to="{ name: 'deliveryMap' }" class="btn btn-xs btn-ghost">
|
||||||
|
Full Map
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Map Stats -->
|
||||||
|
<div class="flex gap-2 mb-3">
|
||||||
|
<span class="badge badge-sm badge-primary">{{ mapDeliveries.length }} stops</span>
|
||||||
|
<span class="badge badge-sm badge-secondary">{{ uniqueTowns.length }} towns</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Map Container -->
|
||||||
|
<div class="rounded-lg overflow-hidden h-64 bg-base-300">
|
||||||
|
<l-map
|
||||||
|
v-if="mapDeliveries.length > 0"
|
||||||
|
ref="map"
|
||||||
|
:zoom="mapZoom"
|
||||||
|
:center="mapCenter"
|
||||||
|
:use-global-leaflet="false"
|
||||||
|
class="h-full w-full"
|
||||||
|
>
|
||||||
|
<l-tile-layer
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
layer-type="base"
|
||||||
|
name="OpenStreetMap"
|
||||||
|
></l-tile-layer>
|
||||||
|
<l-marker
|
||||||
|
v-for="delivery in mappedDeliveries"
|
||||||
|
:key="delivery.id"
|
||||||
|
:lat-lng="[parseFloat(delivery.latitude!), parseFloat(delivery.longitude!)]"
|
||||||
|
>
|
||||||
|
<l-popup>
|
||||||
|
<div class="text-sm">
|
||||||
|
<p class="font-bold">{{ delivery.customerName }}</p>
|
||||||
|
<p>{{ delivery.town }}</p>
|
||||||
|
</div>
|
||||||
|
</l-popup>
|
||||||
|
</l-marker>
|
||||||
|
</l-map>
|
||||||
|
<div v-else class="flex flex-col items-center justify-center h-full text-base-content/50">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-12 h-12 mb-2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 6.75V15m6-6v8.25m.503 3.498l4.875-2.437c.381-.19.622-.58.622-1.006V4.82c0-.836-.88-1.38-1.628-1.006l-3.869 1.934c-.317.159-.69.159-1.006 0L9.503 3.252a1.125 1.125 0 00-1.006 0L3.622 5.689C3.24 5.88 3 6.27 3 6.695V19.18c0 .836.88 1.38 1.628 1.006l3.869-1.934c.317-.159.69-.159 1.006 0l4.994 2.497c.317.158.69.158 1.006 0z" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm">No deliveries mapped</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex justify-between items-center">
|
<!-- Quick Actions -->
|
||||||
<span class="text-sm text-base-content/70">Per Hour</span>
|
<div class="card-glass p-4">
|
||||||
<span class="text-2xl font-bold font-mono">$125</span>
|
<h3 class="font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-accent">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" />
|
||||||
|
</svg>
|
||||||
|
Quick Actions
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<router-link :to="{ name: 'customerCreate' }" class="quick-action-btn">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zM4 19.235v-.11a6.375 6.375 0 0112.75 0v.109A12.318 12.318 0 0110.374 21c-2.331 0-4.512-.645-6.374-1.766z" />
|
||||||
|
</svg>
|
||||||
|
<span>New Customer</span>
|
||||||
|
</router-link>
|
||||||
|
<router-link :to="{ name: 'delivery' }" class="quick-action-btn">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
|
</svg>
|
||||||
|
<span>Deliveries</span>
|
||||||
|
</router-link>
|
||||||
|
<router-link :to="{ name: 'deliveryOutForDelivery' }" class="quick-action-btn">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 00-10.026 0 1.106 1.106 0 00-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12" />
|
||||||
|
</svg>
|
||||||
|
<span>Today's Run</span>
|
||||||
|
</router-link>
|
||||||
|
<router-link :to="{ name: 'ServiceCalendar' }" class="quick-action-btn">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
|
||||||
|
</svg>
|
||||||
|
<span>Service Calendar</span>
|
||||||
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-center">
|
</div>
|
||||||
<span class="text-sm text-base-content/70">Emergency</span>
|
|
||||||
<span class="text-2xl font-bold font-mono">$200</span>
|
<!-- Recent Activity / Town Distribution -->
|
||||||
|
<div class="card-glass p-4">
|
||||||
|
<h3 class="font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-info">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3.75h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008z" />
|
||||||
|
</svg>
|
||||||
|
Today by Town
|
||||||
|
</h3>
|
||||||
|
<div v-if="townCounts.length > 0" class="space-y-2">
|
||||||
|
<div v-for="town in townCounts.slice(0, 5)" :key="town.name" class="flex items-center justify-between">
|
||||||
|
<span class="text-sm truncate flex-1">{{ town.name }}</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-24 bg-base-300 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
class="bg-primary h-2 rounded-full transition-all duration-500"
|
||||||
|
:style="{ width: `${(town.count / townCounts[0].count) * 100}%` }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-mono font-semibold w-6 text-right">{{ town.count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-sm text-base-content/50 text-center py-4">
|
||||||
|
No deliveries scheduled today
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Card 4: Search Shortcuts -->
|
|
||||||
<div class="bg-gradient-to-br from-neutral to-neutral/80 rounded-xl p-6 shadow-medium hover-lift">
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<h3 class="text-xl font-semibold">Search Shortcuts</h3>
|
|
||||||
<div class="w-12 h-12 rounded-full bg-accent/10 flex items-center justify-center">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 text-accent">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-3 text-sm">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<kbd class="kbd kbd-sm">@</kbd>
|
|
||||||
<span class="text-base-content/70">Last name</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<kbd class="kbd kbd-sm">!</kbd>
|
|
||||||
<span class="text-base-content/70">Address</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<kbd class="kbd kbd-sm">#</kbd>
|
|
||||||
<span class="text-base-content/70">Phone number</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<kbd class="kbd kbd-sm">$</kbd>
|
|
||||||
<span class="text-base-content/70">Account number</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Card 5: This Week's Stats -->
|
|
||||||
<!-- <div class="bg-neutral rounded-lg p-5 xl:col-span-4">
|
|
||||||
<h3 class="text-xl font-bold mb-4">This Week's Stats</h3>
|
|
||||||
<div class="stats stats-vertical lg:stats-horizontal shadow bg-base-100 w-full">
|
|
||||||
<div class="stat">
|
|
||||||
<div class="stat-title">Total Deliveries</div>
|
|
||||||
<div class="stat-value">{{ total_deliveries }}</div>
|
|
||||||
<div class="stat-desc">In the last 7 days</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat">
|
|
||||||
<div class="stat-title">Total Gallons</div>
|
|
||||||
<div class="stat-value">{{ total_gallons_past_week }}</div>
|
|
||||||
<div class="stat-desc">Delivered this week</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat">
|
|
||||||
<div class="stat-title">Total Profit</div>
|
|
||||||
<div class="stat-value text-success">${{ total_profit_past_week }}</div>
|
|
||||||
<div class="stat-desc">Estimated earnings</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import authHeader from '../services/auth.header'
|
import authHeader from '../services/auth.header'
|
||||||
import Header from '../layouts/headers/headerauth.vue'
|
import { deliveryService } from '../services/deliveryService'
|
||||||
import SideBar from '../layouts/sidebar/sidebar.vue'
|
import { useCountsStore } from '../stores/counts'
|
||||||
|
import { DeliveryMapItem } from '../types/models'
|
||||||
|
import "leaflet/dist/leaflet.css"
|
||||||
|
import { LMap, LTileLayer, LMarker, LPopup } from "@vue-leaflet/vue-leaflet"
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler
|
||||||
|
} from 'chart.js'
|
||||||
|
import { Line } from 'vue-chartjs'
|
||||||
|
|
||||||
// Props
|
// Register Chart.js components
|
||||||
const props = defineProps<{
|
ChartJS.register(
|
||||||
clickCount?: number
|
CategoryScale,
|
||||||
}>()
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler
|
||||||
|
)
|
||||||
|
|
||||||
// Router
|
// Router
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
// Stores
|
||||||
|
const countsStore = useCountsStore()
|
||||||
|
|
||||||
// Reactive data
|
// Reactive data
|
||||||
const token = ref(null)
|
|
||||||
const call_count = ref(0)
|
|
||||||
const delivery_count = ref(0)
|
const delivery_count = ref(0)
|
||||||
const delivery_count_delivered = ref(0)
|
const delivery_count_delivered = ref(0)
|
||||||
const price_from_supplier = ref(0)
|
const total_gallons_past_week = ref(0)
|
||||||
const today_oil_price = ref(0)
|
const total_deliveries = ref(0)
|
||||||
const price_for_employee = ref(0)
|
|
||||||
const price_same_day = ref(0)
|
|
||||||
const price_prime = ref(0)
|
|
||||||
const price_emergency = ref(0)
|
|
||||||
const user = ref({
|
const user = ref({
|
||||||
user_id: 0,
|
user_id: 0,
|
||||||
user_name: '',
|
user_name: '',
|
||||||
@@ -201,24 +311,130 @@ const employee = ref({
|
|||||||
employee_type: '',
|
employee_type: '',
|
||||||
employee_state: '',
|
employee_state: '',
|
||||||
})
|
})
|
||||||
const total_gallons_past_week = ref(0)
|
|
||||||
const total_profit_past_week = ref(0)
|
// Map data
|
||||||
const total_deliveries = ref(0)
|
const mapDeliveries = ref<DeliveryMapItem[]>([])
|
||||||
const loaded = ref(false)
|
const mapZoom = ref(10)
|
||||||
|
|
||||||
|
// Chart data
|
||||||
|
const weeklyData = ref<{ date: string; gallons: number }[]>([])
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const formattedDate = computed(() => {
|
||||||
|
const now = new Date()
|
||||||
|
return now.toLocaleDateString('en-US', {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const mappedDeliveries = computed(() =>
|
||||||
|
mapDeliveries.value.filter(d => d.latitude && d.longitude)
|
||||||
|
)
|
||||||
|
|
||||||
|
const uniqueTowns = computed(() =>
|
||||||
|
[...new Set(mapDeliveries.value.map(d => d.town))]
|
||||||
|
)
|
||||||
|
|
||||||
|
const townCounts = computed(() => {
|
||||||
|
const counts: Record<string, number> = {}
|
||||||
|
mapDeliveries.value.forEach(d => {
|
||||||
|
const town = d.town || 'Unknown'
|
||||||
|
counts[town] = (counts[town] || 0) + 1
|
||||||
|
})
|
||||||
|
return Object.entries(counts)
|
||||||
|
.map(([name, count]) => ({ name, count }))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapCenter = computed<[number, number]>(() => {
|
||||||
|
if (mappedDeliveries.value.length === 0) {
|
||||||
|
return [42.0654, -71.8984] // Worcester, MA area
|
||||||
|
}
|
||||||
|
const lats = mappedDeliveries.value.map(d => parseFloat(d.latitude!))
|
||||||
|
const lngs = mappedDeliveries.value.map(d => parseFloat(d.longitude!))
|
||||||
|
const avgLat = lats.reduce((a, b) => a + b, 0) / lats.length
|
||||||
|
const avgLng = lngs.reduce((a, b) => a + b, 0) / lngs.length
|
||||||
|
return [avgLat, avgLng]
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartData = computed(() => {
|
||||||
|
if (weeklyData.value.length === 0) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: weeklyData.value.map(d => {
|
||||||
|
const date = new Date(d.date)
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||||
|
}),
|
||||||
|
datasets: [{
|
||||||
|
label: 'Gallons',
|
||||||
|
data: weeklyData.value.map(d => d.gallons),
|
||||||
|
borderColor: 'rgb(59, 130, 246)',
|
||||||
|
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||||
|
tension: 0.4,
|
||||||
|
fill: true,
|
||||||
|
pointRadius: 4,
|
||||||
|
pointHoverRadius: 6
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: (context: { parsed: { y: number | null } }) => {
|
||||||
|
const value = context.parsed?.y ?? 0
|
||||||
|
return `${value.toLocaleString()} gallons`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { display: false }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
callback: (value: string | number) => {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value >= 1000 ? `${(value / 1000).toFixed(1)}k` : value
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
const formatNumber = (num: number) => {
|
||||||
|
if (num >= 1000) {
|
||||||
|
return num.toLocaleString()
|
||||||
|
}
|
||||||
|
return num.toString()
|
||||||
|
}
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
userStatus()
|
userStatus()
|
||||||
today_delivery_count()
|
today_delivery_count()
|
||||||
today_delivery_delivered()
|
today_delivery_delivered()
|
||||||
today_price_oil()
|
|
||||||
totalgallonsweek()
|
totalgallonsweek()
|
||||||
totalprofitweek()
|
totaldeliveriesweek()
|
||||||
|
countsStore.fetchSidebarCounts()
|
||||||
|
fetchMapDeliveries()
|
||||||
|
fetchWeeklyChartData()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Functions
|
// API Functions
|
||||||
const userStatus = () => {
|
const userStatus = () => {
|
||||||
let path = import.meta.env.VITE_BASE_URL + '/auth/whoami';
|
const path = import.meta.env.VITE_BASE_URL + '/auth/whoami'
|
||||||
axios({
|
axios({
|
||||||
method: "get",
|
method: "get",
|
||||||
url: path,
|
url: path,
|
||||||
@@ -227,44 +443,17 @@ const userStatus = () => {
|
|||||||
})
|
})
|
||||||
.then((response: any) => {
|
.then((response: any) => {
|
||||||
if (response.data.ok) {
|
if (response.data.ok) {
|
||||||
user.value = response.data.user;
|
user.value = response.data.user
|
||||||
employeeStatus()
|
employeeStatus()
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem('user');
|
localStorage.removeItem('user')
|
||||||
router.push('/login');
|
router.push('/login')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalgallonsweek = () => {
|
|
||||||
let path = import.meta.env.VITE_BASE_URL + '/stats/gallons/week';
|
|
||||||
axios({
|
|
||||||
method: "get",
|
|
||||||
url: path,
|
|
||||||
withCredentials: true,
|
|
||||||
headers: authHeader(),
|
|
||||||
})
|
|
||||||
.then((response: any) => {
|
|
||||||
total_gallons_past_week.value = response.data.total;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalprofitweek = () => {
|
|
||||||
let path = import.meta.env.VITE_BASE_URL + '/money/profit/week';
|
|
||||||
axios({
|
|
||||||
method: "get",
|
|
||||||
url: path,
|
|
||||||
withCredentials: true,
|
|
||||||
headers: authHeader(),
|
|
||||||
})
|
|
||||||
.then((response: any) => {
|
|
||||||
total_profit_past_week.value = response.data.total_profit;
|
|
||||||
total_deliveries.value = response.data.total_deliveries;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const employeeStatus = () => {
|
const employeeStatus = () => {
|
||||||
let path = import.meta.env.VITE_BASE_URL + '/employee/userid/' + user.value.user_id;
|
const path = import.meta.env.VITE_BASE_URL + '/employee/userid/' + user.value.user_id
|
||||||
axios({
|
axios({
|
||||||
method: "get",
|
method: "get",
|
||||||
url: path,
|
url: path,
|
||||||
@@ -272,26 +461,12 @@ const employeeStatus = () => {
|
|||||||
headers: authHeader(),
|
headers: authHeader(),
|
||||||
})
|
})
|
||||||
.then((response: any) => {
|
.then((response: any) => {
|
||||||
employee.value = response.data?.employee || response.data;
|
employee.value = response.data?.employee || response.data
|
||||||
loaded.value = true;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const total_calls = () => {
|
|
||||||
let path = import.meta.env.VITE_BASE_URL + '/stats/call/count/today'
|
|
||||||
axios({
|
|
||||||
method: "get",
|
|
||||||
url: path,
|
|
||||||
withCredentials: true,
|
|
||||||
headers: authHeader(),
|
|
||||||
})
|
|
||||||
.then((response: any) => {
|
|
||||||
call_count.value = response.data.data;
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const today_delivery_count = () => {
|
const today_delivery_count = () => {
|
||||||
let path = import.meta.env.VITE_BASE_URL + '/stats/delivery/count/today'
|
const path = import.meta.env.VITE_BASE_URL + '/stats/delivery/count/today'
|
||||||
axios({
|
axios({
|
||||||
method: "get",
|
method: "get",
|
||||||
url: path,
|
url: path,
|
||||||
@@ -299,12 +474,12 @@ const today_delivery_count = () => {
|
|||||||
headers: authHeader(),
|
headers: authHeader(),
|
||||||
})
|
})
|
||||||
.then((response: any) => {
|
.then((response: any) => {
|
||||||
delivery_count.value = response.data.data;
|
delivery_count.value = response.data.data
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const today_delivery_delivered = () => {
|
const today_delivery_delivered = () => {
|
||||||
let path = import.meta.env.VITE_BASE_URL + '/stats/delivery/count/delivered/today'
|
const path = import.meta.env.VITE_BASE_URL + '/stats/delivery/count/delivered/today'
|
||||||
axios({
|
axios({
|
||||||
method: "get",
|
method: "get",
|
||||||
url: path,
|
url: path,
|
||||||
@@ -312,13 +487,12 @@ const today_delivery_delivered = () => {
|
|||||||
headers: authHeader(),
|
headers: authHeader(),
|
||||||
})
|
})
|
||||||
.then((response: any) => {
|
.then((response: any) => {
|
||||||
console.log(response.data)
|
delivery_count_delivered.value = response.data.data
|
||||||
delivery_count_delivered.value = response.data.data;
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const today_price_oil = () => {
|
const totalgallonsweek = () => {
|
||||||
let path = import.meta.env.VITE_BASE_URL + '/info/price/oil'
|
const path = import.meta.env.VITE_BASE_URL + '/stats/gallons/week'
|
||||||
axios({
|
axios({
|
||||||
method: "get",
|
method: "get",
|
||||||
url: path,
|
url: path,
|
||||||
@@ -326,12 +500,122 @@ const today_price_oil = () => {
|
|||||||
headers: authHeader(),
|
headers: authHeader(),
|
||||||
})
|
})
|
||||||
.then((response: any) => {
|
.then((response: any) => {
|
||||||
price_from_supplier.value = response.data.price_from_supplier;
|
total_gallons_past_week.value = response.data.total
|
||||||
today_oil_price.value = response.data.price_for_customer;
|
|
||||||
price_for_employee.value = response.data.price_for_employee;
|
|
||||||
price_same_day.value = response.data.price_same_day;
|
|
||||||
price_prime.value = response.data.price_prime;
|
|
||||||
price_emergency.value = response.data.price_emergency;
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const totaldeliveriesweek = () => {
|
||||||
|
const path = import.meta.env.VITE_BASE_URL + '/stats/delivery/count/week'
|
||||||
|
axios({
|
||||||
|
method: "get",
|
||||||
|
url: path,
|
||||||
|
withCredentials: true,
|
||||||
|
headers: authHeader(),
|
||||||
|
})
|
||||||
|
.then((response: any) => {
|
||||||
|
total_deliveries.value = response.data.data || response.data.total || 0
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Fallback: use the money endpoint but only extract deliveries count
|
||||||
|
const fallbackPath = import.meta.env.VITE_BASE_URL + '/money/profit/week'
|
||||||
|
axios({
|
||||||
|
method: "get",
|
||||||
|
url: fallbackPath,
|
||||||
|
withCredentials: true,
|
||||||
|
headers: authHeader(),
|
||||||
|
})
|
||||||
|
.then((response: any) => {
|
||||||
|
total_deliveries.value = response.data.total_deliveries || 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchMapDeliveries = async () => {
|
||||||
|
try {
|
||||||
|
const today = new Date().toISOString().split('T')[0]
|
||||||
|
const response = await deliveryService.getForMap(today)
|
||||||
|
if (response.data.ok) {
|
||||||
|
mapDeliveries.value = response.data.deliveries || []
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching map deliveries:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchWeeklyChartData = async () => {
|
||||||
|
try {
|
||||||
|
// Get last 28 days of data
|
||||||
|
const endDate = new Date()
|
||||||
|
const startDate = new Date()
|
||||||
|
startDate.setDate(startDate.getDate() - 28)
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear()
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
start_date: startDate.toISOString().split('T')[0],
|
||||||
|
end_date: endDate.toISOString().split('T')[0],
|
||||||
|
years: currentYear.toString()
|
||||||
|
})
|
||||||
|
|
||||||
|
const path = import.meta.env.VITE_BASE_URL + `/stats/gallons/daily?${queryParams.toString()}`
|
||||||
|
const response = await axios({
|
||||||
|
method: "get",
|
||||||
|
url: path,
|
||||||
|
withCredentials: true,
|
||||||
|
headers: authHeader(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.data.ok && response.data.years?.length > 0) {
|
||||||
|
weeklyData.value = response.data.years[0].data.map((d: { date: string; gallons: number }) => ({
|
||||||
|
date: d.date,
|
||||||
|
gallons: d.gallons
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching chart data:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fadeIn 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
@apply bg-gradient-to-br from-neutral to-neutral/80 rounded-xl p-4 shadow-md hover:shadow-lg transition-all duration-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-glass {
|
||||||
|
@apply bg-gradient-to-br from-neutral/90 to-neutral/70 backdrop-blur-sm rounded-xl shadow-lg border border-base-content/5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-action-btn {
|
||||||
|
@apply flex flex-col items-center justify-center gap-1 p-3 rounded-lg bg-base-200/50 hover:bg-primary/10 hover:text-primary transition-all duration-200 text-xs font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Leaflet fixes */
|
||||||
|
:deep(.leaflet-container) {
|
||||||
|
z-index: 0;
|
||||||
|
background: hsl(var(--b3));
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.leaflet-popup-content-wrapper) {
|
||||||
|
background: hsl(var(--b1));
|
||||||
|
color: hsl(var(--bc));
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.leaflet-popup-tip) {
|
||||||
|
background: hsl(var(--b1));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -9,15 +9,29 @@
|
|||||||
<li>Set Oil Pricing</li>
|
<li>Set Oil Pricing</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="text-3xl font-bold mt-4">
|
<!-- Page Header -->
|
||||||
Set Today's Oil Pricing
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mt-4 mb-6">
|
||||||
</h1>
|
<div>
|
||||||
|
<h1 class="text-2xl md:text-3xl font-bold flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center shadow-lg">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
||||||
|
stroke="currentColor" class="w-5 h-5 text-primary-content">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
Set Today's Oil Pricing
|
||||||
|
</h1>
|
||||||
|
<p class="text-base-content/60 mt-1 ml-13">Set daily oil prices and service fees</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Main Form Card -->
|
<!-- Main Form Card -->
|
||||||
<div class="bg-neutral rounded-lg p-6 mt-6">
|
<div class="modern-table-card p-6 mt-6">
|
||||||
<form @submit.prevent="onSubmit" class="space-y-6">
|
<form @submit.prevent="onSubmit" class="space-y-6">
|
||||||
|
|
||||||
<!-- SECTION 1: Base Pricing -->
|
<!-- SECTION 1: Base Pricing -->
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-bold">Base Pricing</h2>
|
<h2 class="text-lg font-bold">Base Pricing</h2>
|
||||||
@@ -29,23 +43,26 @@
|
|||||||
<label class="label"><span class="label-text">Price from Supplier</span></label>
|
<label class="label"><span class="label-text">Price from Supplier</span></label>
|
||||||
<label class="input-group input-group-sm">
|
<label class="input-group input-group-sm">
|
||||||
<span>$</span>
|
<span>$</span>
|
||||||
<input v-model.number="OilForm.price_from_supplier" type="number" step="0.01" placeholder="3.50" class="input input-bordered input-sm w-full" />
|
<input v-model.number="OilForm.price_from_supplier" type="number" step="0.01" placeholder="3.50"
|
||||||
|
class="input input-bordered input-sm w-full" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<!-- Price for Customer -->
|
<!-- Price for Customer -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Price for Customer</span></label>
|
<label class="label"><span class="label-text">Price for Customer</span></label>
|
||||||
<label class="input-group input-group-sm">
|
<label class="input-group input-group-sm">
|
||||||
<span>$</span>
|
<span>$</span>
|
||||||
<input v-model.number="OilForm.price_for_customer" type="number" step="0.01" placeholder="4.50" class="input input-bordered input-sm w-full" />
|
<input v-model.number="OilForm.price_for_customer" type="number" step="0.01" placeholder="4.50"
|
||||||
|
class="input input-bordered input-sm w-full" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<!-- Price for Employee -->
|
<!-- Price for Employee -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Price for Employee</span></label>
|
<label class="label"><span class="label-text">Price for Employee</span></label>
|
||||||
<label class="input-group input-group-sm">
|
<label class="input-group input-group-sm">
|
||||||
<span>$</span>
|
<span>$</span>
|
||||||
<input v-model.number="OilForm.price_for_employee" type="number" step="0.01" placeholder="4.00" class="input input-bordered input-sm w-full" />
|
<input v-model.number="OilForm.price_for_employee" type="number" step="0.01" placeholder="4.00"
|
||||||
|
class="input input-bordered input-sm w-full" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -60,30 +77,33 @@
|
|||||||
<!-- Price Same Day -->
|
<!-- Price Same Day -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Same Day Fee</span></label>
|
<label class="label"><span class="label-text">Same Day Fee</span></label>
|
||||||
<label class="input-group input-group-sm">
|
<label class="input-group input-group-sm">
|
||||||
<span>$</span>
|
<span>$</span>
|
||||||
<input v-model.number="OilForm.price_same_day" type="number" step="1.00" placeholder="50.00" class="input input-bordered input-sm w-full" />
|
<input v-model.number="OilForm.price_same_day" type="number" step="1.00" placeholder="50.00"
|
||||||
|
class="input input-bordered input-sm w-full" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<!-- Price Prime -->
|
<!-- Price Prime -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Prime Fee</span></label>
|
<label class="label"><span class="label-text">Prime Fee</span></label>
|
||||||
<label class="input-group input-group-sm">
|
<label class="input-group input-group-sm">
|
||||||
<span>$</span>
|
<span>$</span>
|
||||||
<input v-model.number="OilForm.price_prime" type="number" step="1.00" placeholder="75.00" class="input input-bordered input-sm w-full" />
|
<input v-model.number="OilForm.price_prime" type="number" step="1.00" placeholder="75.00"
|
||||||
|
class="input input-bordered input-sm w-full" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<!-- Price Emergency -->
|
<!-- Price Emergency -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Emergency Fee</span></label>
|
<label class="label"><span class="label-text">Emergency Fee</span></label>
|
||||||
<label class="input-group input-group-sm">
|
<label class="input-group input-group-sm">
|
||||||
<span>$</span>
|
<span>$</span>
|
||||||
<input v-model.number="OilForm.price_emergency" type="number" step="1.00" placeholder="150.00" class="input input-bordered input-sm w-full" />
|
<input v-model.number="OilForm.price_emergency" type="number" step="1.00" placeholder="150.00"
|
||||||
|
class="input input-bordered input-sm w-full" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SUBMIT BUTTON -->
|
<!-- SUBMIT BUTTON -->
|
||||||
<div class="pt-4">
|
<div class="pt-4">
|
||||||
<button type="submit" class="btn btn-primary btn-sm">Update Pricing</button>
|
<button type="submit" class="btn btn-primary btn-sm">Update Pricing</button>
|
||||||
@@ -92,7 +112,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -118,52 +138,52 @@ const router = useRouter();
|
|||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
const userStatus = async () => {
|
const userStatus = async () => {
|
||||||
try {
|
try {
|
||||||
const response: AxiosResponse<any> = await authService.whoami();
|
const response: AxiosResponse<any> = await authService.whoami();
|
||||||
if (response.data.ok) {
|
if (response.data.ok) {
|
||||||
user.value = response.data.user;
|
user.value = response.data.user;
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
user.value = null;
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
user.value = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCurrentPrices = async () => {
|
const getCurrentPrices = async () => {
|
||||||
try {
|
try {
|
||||||
const response: AxiosResponse<any> = await adminService.getOilPricing();
|
const response: AxiosResponse<any> = await adminService.getOilPricing();
|
||||||
if (response.data) {
|
if (response.data) {
|
||||||
OilForm.value = response.data;
|
OilForm.value = response.data;
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to fetch oil prices", err);
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch oil prices", err);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const CreatePricing = async (payload: any) => {
|
const CreatePricing = async (payload: any) => {
|
||||||
try {
|
try {
|
||||||
const response: AxiosResponse<any> = await adminService.updateOilPricing(payload);
|
const response: AxiosResponse<any> = await adminService.updateOilPricing(payload);
|
||||||
if (response.data.ok) {
|
if (response.data.ok) {
|
||||||
notify({
|
notify({
|
||||||
title: "Success",
|
title: "Success",
|
||||||
text: "Prices have been updated!",
|
text: "Prices have been updated!",
|
||||||
type: "success",
|
type: "success",
|
||||||
});
|
});
|
||||||
router.push({ name: "home" });
|
router.push({ name: "home" });
|
||||||
} else {
|
} else {
|
||||||
notify({
|
notify({
|
||||||
title: "Error",
|
title: "Error",
|
||||||
text: response.data.error || "An unknown error occurred.",
|
text: response.data.error || "An unknown error occurred.",
|
||||||
type: "error",
|
type: "error",
|
||||||
});
|
});
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const error = err as AxiosError<{ error?: string }>;
|
|
||||||
notify({
|
|
||||||
title: "Error",
|
|
||||||
text: error.response?.data?.error || "An error occurred while updating prices.",
|
|
||||||
type: "error",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as AxiosError<{ error?: string }>;
|
||||||
|
notify({
|
||||||
|
title: "Error",
|
||||||
|
text: error.response?.data?.error || "An error occurred while updating prices.",
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = () => {
|
const onSubmit = () => {
|
||||||
|
|||||||
@@ -10,12 +10,23 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="text-3xl font-bold mt-4">
|
<!-- Page Header -->
|
||||||
Set Today's Oil Pricing
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mt-4 mb-6">
|
||||||
</h1>
|
<div>
|
||||||
|
<h1 class="text-2xl md:text-3xl font-bold flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center shadow-lg">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-primary-content">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
Oil Pricing
|
||||||
|
</h1>
|
||||||
|
<p class="text-base-content/60 mt-1 ml-13">Set daily oil prices and service fees</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Main Form Card -->
|
<!-- Main Form Card -->
|
||||||
<div class="bg-neutral rounded-lg p-6 mt-6">
|
<div class="modern-table-card p-6 mt-6">
|
||||||
<form @submit.prevent="onSubmit" class="space-y-6">
|
<form @submit.prevent="onSubmit" class="space-y-6">
|
||||||
|
|
||||||
<!-- SECTION 1: Base Pricing -->
|
<!-- SECTION 1: Base Pricing -->
|
||||||
@@ -134,8 +145,16 @@ const getCurrentPrices = async () => {
|
|||||||
if (response.data) {
|
if (response.data) {
|
||||||
OilForm.value = response.data;
|
OilForm.value = response.data;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
console.error("Failed to fetch oil prices", err);
|
console.error("Failed to fetch oil prices", err);
|
||||||
|
const error = err as AxiosError;
|
||||||
|
if (error.response?.status === 403) {
|
||||||
|
notify({
|
||||||
|
title: "Access Denied",
|
||||||
|
text: "You do not have permission to view oil pricing.",
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<!-- src/pages/admin/promo/promo.vue -->
|
<!-- src/pages/admin/promo/promo.vue -->
|
||||||
<template>
|
<template>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
|
|
||||||
<div class=" w-full px-10 ">
|
<div class=" w-full px-10 ">
|
||||||
<div class="text-sm breadcrumbs pb-10">
|
<div class="text-sm breadcrumbs pb-10">
|
||||||
<ul>
|
<ul>
|
||||||
@@ -13,66 +13,120 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex start pb-10 text-2xl">Promos </div>
|
<!-- Page Header with Stats -->
|
||||||
<div class="flex justify-end pb-5">
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mt-4 mb-6">
|
||||||
<router-link :to="{ name: 'promocreate' }">
|
<div>
|
||||||
<button class="btn btn-secondary btn-sm">Create Promo</button>
|
<h1 class="text-2xl md:text-3xl font-bold flex items-center gap-3">
|
||||||
</router-link>
|
<div
|
||||||
|
class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center shadow-lg">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
||||||
|
stroke="currentColor" class="w-5 h-5 text-primary-content">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
Promotions
|
||||||
|
</h1>
|
||||||
|
<p class="text-base-content/60 mt-1 ml-13">Manage active discount codes and promotions</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Stats -->
|
||||||
|
<div class="flex flex-wrap gap-3 items-center">
|
||||||
|
<div class="stat-pill">
|
||||||
|
<span class="stat-pill-value">{{ promos.length }}</span>
|
||||||
|
<span class="stat-pill-label">Active Promos</span>
|
||||||
|
</div>
|
||||||
|
<router-link :to="{ name: 'promocreate' }" class="btn btn-primary btn-sm ml-2">
|
||||||
|
Create Promo
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="overflow-x-auto">
|
<!-- Main Content Card -->
|
||||||
<table class="table">
|
<div class="modern-table-card">
|
||||||
<!-- head -->
|
<!-- DESKTOP VIEW: Table -->
|
||||||
<thead class=" bg-neutral">
|
<div class="overflow-x-auto hidden xl:block">
|
||||||
<tr>
|
<table class="modern-table">
|
||||||
<th>Id</th>
|
<thead>
|
||||||
<th>Name</th>
|
<tr>
|
||||||
<th>Pennys off gallon</th>
|
<th>ID</th>
|
||||||
<th>Description</th>
|
<th>Name</th>
|
||||||
<th>text_on_ticket</th>
|
<th>Discount</th>
|
||||||
<th></th>
|
<th>Description</th>
|
||||||
</tr>
|
<th>Ticket Text</th>
|
||||||
</thead>
|
<th class="text-right">Actions</th>
|
||||||
<tbody class="bg-neutral">
|
</tr>
|
||||||
<!-- row 1 -->
|
</thead>
|
||||||
|
<tbody>
|
||||||
<tr v-for="promo in promos" :key="promo['id']">
|
<tr v-for="promo in promos" :key="promo['id']" class="table-row-hover">
|
||||||
|
<td>{{ promo['id'] }}</td>
|
||||||
<td>{{ promo['id'] }} </td>
|
<td class="font-bold">
|
||||||
<router-link :to="{ name: 'promoedit', params: { id: promo['id'] } }">
|
<router-link :to="{ name: 'promoedit', params: { id: promo['id'] } }"
|
||||||
<td>
|
class="link link-hover">
|
||||||
<div class="hover:text-accent">{{ promo['name_of_promotion'] }} </div>
|
{{ promo['name_of_promotion'] }}
|
||||||
</td>
|
</router-link>
|
||||||
</router-link>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ promo['money_off_delivery'] }}
|
<span class="badge badge-success badge-sm">${{ promo['money_off_delivery'] }}
|
||||||
</td>
|
off/gal</span>
|
||||||
<td>
|
</td>
|
||||||
{{ promo['description'] }}
|
<td>{{ promo['description'] }}</td>
|
||||||
</td>
|
<td>{{ promo['text_on_ticket'] }}</td>
|
||||||
<td>
|
<td class="text-right">
|
||||||
{{ promo['text_on_ticket'] }}
|
<div class="flex items-center justify-end gap-2">
|
||||||
</td>
|
<router-link :to="{ name: 'promoedit', params: { id: promo['id'] } }"
|
||||||
|
class="btn btn-sm btn-secondary">
|
||||||
<td class="flex gap-2">
|
Edit
|
||||||
<router-link :to="{ name: 'promoedit', params: { id: promo['id'] } }">
|
</router-link>
|
||||||
<button class="btn btn-secondary btn-sm">Edit Promo</button>
|
<button @click.prevent="deletepromo(promo['id'])"
|
||||||
</router-link>
|
class="btn btn-sm btn-error btn-outline">
|
||||||
|
Delete
|
||||||
<button @click.prevent="deletepromo(promo['id'])" class="btn btn-error btn-sm">
|
</button>
|
||||||
Delete
|
</div>
|
||||||
</button>
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
</tr>
|
</tbody>
|
||||||
</tbody>
|
</table>
|
||||||
</table>
|
</div>
|
||||||
|
|
||||||
|
<!-- MOBILE VIEW: Cards -->
|
||||||
|
<div class="xl:hidden space-y-4 px-4 pb-4 pt-4">
|
||||||
|
<div v-for="promo in promos" :key="promo['id']" class="mobile-card">
|
||||||
|
<div class="p-3">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-base font-bold">{{ promo['name_of_promotion'] }}</h2>
|
||||||
|
<p class="text-xs text-base-content/60">ID: #{{ promo['id'] }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="badge badge-success badge-sm">
|
||||||
|
${{ promo['money_off_delivery'] }} off/gal
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm mt-3 space-y-2">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-base-content/50">Description</p>
|
||||||
|
<p>{{ promo['description'] }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-base-content/50">Ticket Text</p>
|
||||||
|
<p class="font-mono text-xs bg-base-300 p-1 rounded inline-block">{{
|
||||||
|
promo['text_on_ticket'] }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 pt-3 mt-3 border-t border-base-content/10 flex-wrap">
|
||||||
|
<router-link :to="{ name: 'promoedit', params: { id: promo['id'] } }"
|
||||||
|
class="btn btn-sm btn-secondary flex-1">Edit</router-link>
|
||||||
|
<button @click.prevent="deletepromo(promo['id'])"
|
||||||
|
class="btn btn-sm btn-error btn-outline flex-1">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
@@ -132,7 +186,11 @@ export default defineComponent({
|
|||||||
url: path,
|
url: path,
|
||||||
headers: authHeader(),
|
headers: authHeader(),
|
||||||
}).then((response: any) => {
|
}).then((response: any) => {
|
||||||
this.promos = response.data
|
if (response.data && response.data.promos) {
|
||||||
|
this.promos = response.data.promos;
|
||||||
|
} else {
|
||||||
|
this.promos = [];
|
||||||
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ const Promo = () => import('../admin/promo/promo.vue');
|
|||||||
const PromoCreate = () => import('../admin/promo/create.vue');
|
const PromoCreate = () => import('../admin/promo/create.vue');
|
||||||
const PromoEdit = () => import('../admin/promo/edit.vue');
|
const PromoEdit = () => import('../admin/promo/edit.vue');
|
||||||
|
|
||||||
|
const StatsHome = () => import('../admin/stats/StatsHome.vue');
|
||||||
|
|
||||||
|
|
||||||
const adminRoutes = [
|
const adminRoutes = [
|
||||||
{
|
{
|
||||||
@@ -16,7 +18,7 @@ const adminRoutes = [
|
|||||||
|
|
||||||
|
|
||||||
{
|
{
|
||||||
path: '/promo/edit:id',
|
path: '/promo/edit/:id',
|
||||||
name: 'promoedit',
|
name: 'promoedit',
|
||||||
component: PromoEdit,
|
component: PromoEdit,
|
||||||
},
|
},
|
||||||
@@ -25,6 +27,11 @@ const adminRoutes = [
|
|||||||
name: 'promocreate',
|
name: 'promocreate',
|
||||||
component: PromoCreate,
|
component: PromoCreate,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/stats',
|
||||||
|
name: 'stats',
|
||||||
|
component: StatsHome,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/promo',
|
path: '/promo',
|
||||||
name: 'promo',
|
name: 'promo',
|
||||||
|
|||||||
291
src/pages/admin/stats/DailyGraph.vue
Normal file
291
src/pages/admin/stats/DailyGraph.vue
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
<template>
|
||||||
|
<div class="modern-table-card p-6">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="flex flex-col md:flex-row justify-between items-center mb-6">
|
||||||
|
<h2 class="card-title text-2xl mb-4 md:mb-0">Daily Deliveries</h2>
|
||||||
|
|
||||||
|
<!-- Controls -->
|
||||||
|
<div class="flex flex-wrap gap-2 items-center">
|
||||||
|
<!-- Time Range -->
|
||||||
|
<div class="join">
|
||||||
|
<button class="join-item btn btn-sm" :class="{ 'btn-primary': rangeMode === 'week' }"
|
||||||
|
@click="setRange('week')">
|
||||||
|
Week
|
||||||
|
</button>
|
||||||
|
<button class="join-item btn btn-sm" :class="{ 'btn-primary': rangeMode === 'month' }"
|
||||||
|
@click="setRange('month')">
|
||||||
|
Month
|
||||||
|
</button>
|
||||||
|
<button class="join-item btn btn-sm" :class="{ 'btn-primary': rangeMode === 'quarter' }"
|
||||||
|
@click="setRange('quarter')">
|
||||||
|
Quarter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider divider-horizontal mx-0"></div>
|
||||||
|
|
||||||
|
<!-- Year Comparison -->
|
||||||
|
<div class="dropdown dropdown-end">
|
||||||
|
<label tabindex="0" class="btn btn-sm btn-outline gap-2">
|
||||||
|
Compare Years
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</label>
|
||||||
|
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
|
||||||
|
<li v-for="year in availableYears" :key="year">
|
||||||
|
<label class="label cursor-pointer">
|
||||||
|
<span class="label-text">{{ year }}</span>
|
||||||
|
<input type="checkbox" :value="year" v-model="selectedYears"
|
||||||
|
class="checkbox checkbox-primary checkbox-sm" />
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chart Container -->
|
||||||
|
<div class="h-96 w-full relative">
|
||||||
|
<div v-if="loading" class="absolute inset-0 flex items-center justify-center bg-base-100/50 z-10">
|
||||||
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||||
|
</div>
|
||||||
|
<Line v-if="chartData.labels.length > 0" :data="chartData" :options="chartOptions" />
|
||||||
|
<div v-else-if="!loading" class="flex h-full items-center justify-center text-gray-500">
|
||||||
|
No data available for the selected period.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary Text -->
|
||||||
|
<div class="stats stats-horizontal shadow mt-4 w-full bg-base-200">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">Total Gallons (Period)</div>
|
||||||
|
<div class="stat-value text-primary">{{ formatNumber(periodTotalGallons) }}</div>
|
||||||
|
<div class="stat-desc">Gallons delivered in selected range</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">Total Deliveries</div>
|
||||||
|
<div class="stat-value">{{ periodTotalCount }}</div>
|
||||||
|
<div class="stat-desc">Stops made in selected range</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">Avg Drop</div>
|
||||||
|
<div class="stat-value text-secondary">{{ formatNumber(averageDrop) }}</div>
|
||||||
|
<div class="stat-desc">Gallons</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue';
|
||||||
|
import { adminService } from '../../../services/adminService';
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend
|
||||||
|
} from 'chart.js';
|
||||||
|
import { Line } from 'vue-chartjs';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
// Register ChartJS components
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend
|
||||||
|
);
|
||||||
|
|
||||||
|
// Types
|
||||||
|
type RangeMode = 'week' | 'month' | 'quarter';
|
||||||
|
|
||||||
|
// State
|
||||||
|
const loading = ref(false);
|
||||||
|
const rangeMode = ref<RangeMode>('week');
|
||||||
|
const selectedYears = ref<number[]>([]);
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const availableYears = ref([currentYear - 1, currentYear - 2, currentYear - 3, currentYear - 4]); // Dynamic in real app?
|
||||||
|
|
||||||
|
// Data storage
|
||||||
|
const primaryData = ref<any[]>([]);
|
||||||
|
// Store comparison data: { year: [data points] }
|
||||||
|
const comparisonData = ref<Record<number, any[]>>({});
|
||||||
|
|
||||||
|
// Computed Metrics
|
||||||
|
const periodTotalGallons = computed(() => primaryData.value.reduce((acc, curr) => acc + curr.gallons, 0));
|
||||||
|
const periodTotalCount = computed(() => primaryData.value.reduce((acc, curr) => acc + curr.count, 0));
|
||||||
|
const averageDrop = computed(() => periodTotalCount.value > 0 ? periodTotalGallons.value / periodTotalCount.value : 0);
|
||||||
|
|
||||||
|
|
||||||
|
// Chart Configuration
|
||||||
|
const chartOptions: any = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'top' as const,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function (context: any) {
|
||||||
|
let label = context.dataset.label || '';
|
||||||
|
if (label) {
|
||||||
|
label += ': ';
|
||||||
|
}
|
||||||
|
if (context.parsed.y !== null) {
|
||||||
|
label += new Intl.NumberFormat('en-US').format(context.parsed.y) + ' gal';
|
||||||
|
}
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Gallons'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartData = computed(() => {
|
||||||
|
// Labels depend on the primary date range
|
||||||
|
const labels = primaryData.value.map(d => dayjs(d.date).format('MMM D'));
|
||||||
|
|
||||||
|
const datasets = [
|
||||||
|
{
|
||||||
|
label: `Current (${currentYear})`,
|
||||||
|
backgroundColor: '#00afb9', // Teal
|
||||||
|
borderColor: '#00afb9',
|
||||||
|
data: primaryData.value.map(d => d.gallons),
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 2,
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add comparison datasets
|
||||||
|
selectedYears.value.forEach((year, index) => {
|
||||||
|
const yearData = comparisonData.value[year] || [];
|
||||||
|
// We need to map comparison data to align with the primary labels/indices if possible
|
||||||
|
// Simple approach: assume same array length and order for "same logic" comparison (e.g. "last 7 days")
|
||||||
|
|
||||||
|
// Colors for comparisons
|
||||||
|
const colors = ['#f07167', '#fed9b7', '#0081a7', '#6d597a'];
|
||||||
|
const color = colors[index % colors.length];
|
||||||
|
|
||||||
|
datasets.push({
|
||||||
|
label: `${year}`,
|
||||||
|
backgroundColor: color,
|
||||||
|
borderColor: color,
|
||||||
|
data: yearData.map(d => d.gallons),
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 2,
|
||||||
|
borderDash: [5, 5]
|
||||||
|
} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels,
|
||||||
|
datasets
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const formatNumber = (num: number) => {
|
||||||
|
return new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(num);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDateRange = () => {
|
||||||
|
const end = dayjs();
|
||||||
|
let start = dayjs();
|
||||||
|
|
||||||
|
if (rangeMode.value === 'week') start = end.subtract(7, 'day');
|
||||||
|
else if (rangeMode.value === 'month') start = end.subtract(30, 'day');
|
||||||
|
else if (rangeMode.value === 'quarter') start = end.subtract(90, 'day');
|
||||||
|
|
||||||
|
return { start, end };
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
const { start, end } = getDateRange();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch Primary Data
|
||||||
|
const response = await adminService.stats.getDailyStats(
|
||||||
|
start.format('YYYY-MM-DD'),
|
||||||
|
end.format('YYYY-MM-DD')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data && response.data.daily_stats) {
|
||||||
|
primaryData.value = response.data.daily_stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch Comparison Data if selected
|
||||||
|
// Note: For comparison, we need to shift the dates back by N years
|
||||||
|
comparisonData.value = {}; // Reset
|
||||||
|
|
||||||
|
const promises = selectedYears.value.map(async (year) => {
|
||||||
|
const yearDiff = currentYear - year;
|
||||||
|
const histStart = start.subtract(yearDiff, 'year');
|
||||||
|
const histEnd = end.subtract(yearDiff, 'year');
|
||||||
|
|
||||||
|
const res = await adminService.stats.getDailyStats(
|
||||||
|
histStart.format('YYYY-MM-DD'),
|
||||||
|
histEnd.format('YYYY-MM-DD')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.data && res.data.daily_stats) {
|
||||||
|
comparisonData.value[year] = res.data.daily_stats;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching daily stats", err);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setRange = (mode: RangeMode) => {
|
||||||
|
rangeMode.value = mode;
|
||||||
|
// Debounce or immediate fetch?
|
||||||
|
fetchData();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watchers
|
||||||
|
watch(selectedYears, () => {
|
||||||
|
fetchData();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
fetchData();
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Add extra chart styling if needed */
|
||||||
|
</style>
|
||||||
59
src/pages/admin/stats/StatsHome.vue
Normal file
59
src/pages/admin/stats/StatsHome.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="w-full px-4 md:px-10 py-4">
|
||||||
|
<!-- Breadcrumbs -->
|
||||||
|
<div class="text-sm breadcrumbs">
|
||||||
|
<ul>
|
||||||
|
<li><router-link :to="{ name: 'home' }">Home</router-link></li>
|
||||||
|
<li>Admin</li>
|
||||||
|
<li>Stats</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mt-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl md:text-3xl font-bold flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center shadow-lg">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
||||||
|
stroke="currentColor" class="w-5 h-5 text-primary-content">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
Business Statistics
|
||||||
|
</h1>
|
||||||
|
<p class="text-base-content/60 mt-1 ml-13">Analyze performance and delivery metrics</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div role="tablist" class="tabs tabs-boxed mb-6 bg-neutral w-fit">
|
||||||
|
<a role="tab" class="tab" :class="{ 'tab-active': activeTab === 'graph' }" @click="activeTab = 'graph'">
|
||||||
|
Daily Graph
|
||||||
|
</a>
|
||||||
|
<a role="tab" class="tab" :class="{ 'tab-active': activeTab === 'totals' }"
|
||||||
|
@click="activeTab = 'totals'">
|
||||||
|
Totals Comparison
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div v-show="activeTab === 'graph'">
|
||||||
|
<DailyGraph />
|
||||||
|
</div>
|
||||||
|
<div v-show="activeTab === 'totals'">
|
||||||
|
<TotalsComparison />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import DailyGraph from './DailyGraph.vue';
|
||||||
|
import TotalsComparison from './TotalsComparison.vue';
|
||||||
|
|
||||||
|
const activeTab = ref<'graph' | 'totals'>('graph');
|
||||||
|
</script>
|
||||||
123
src/pages/admin/stats/TotalsComparison.vue
Normal file
123
src/pages/admin/stats/TotalsComparison.vue
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<template>
|
||||||
|
<div class="modern-table-card p-6">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<h2 class="card-title text-2xl mb-6 flex items-center gap-2">
|
||||||
|
Totals Comparison
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- Period Selector -->
|
||||||
|
<div class="flex justify-center mb-8">
|
||||||
|
<div class="join">
|
||||||
|
<button class="join-item btn" :class="{ 'btn-primary': period === 'day' }"
|
||||||
|
@click="setPeriod('day')">
|
||||||
|
Daily
|
||||||
|
</button>
|
||||||
|
<button class="join-item btn" :class="{ 'btn-primary': period === 'month' }"
|
||||||
|
@click="setPeriod('month')">
|
||||||
|
Monthly
|
||||||
|
</button>
|
||||||
|
<button class="join-item btn" :class="{ 'btn-primary': period === 'year' }"
|
||||||
|
@click="setPeriod('year')">
|
||||||
|
Yearly
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date Picker for Reference (Only for Day/Month) -->
|
||||||
|
<div v-if="period !== 'year'" class="flex justify-center mb-8">
|
||||||
|
<div class="form-control w-full max-w-xs">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Select Date</span>
|
||||||
|
</label>
|
||||||
|
<input type="date" v-model="referenceDate" @change="fetchData"
|
||||||
|
class="input input-bordered w-full max-w-xs" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="flex justify-center py-10">
|
||||||
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stat Cards Grid -->
|
||||||
|
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
<div v-for="(yearData, index) in totalsData" :key="yearData.year"
|
||||||
|
class="stat bg-base-200 rounded-box shadow" :class="{ 'border-2 border-primary': index === 0 }">
|
||||||
|
<div class="stat-figure text-primary" v-if="index === 0">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||||
|
class="inline-block w-8 h-8 stroke-current">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-title font-bold text-lg">{{ yearData.period_label }}</div>
|
||||||
|
<div class="stat-value text-2xl md:text-3xl">{{ formatNumber(yearData.gallons) }}</div>
|
||||||
|
<div class="stat-desc font-semibold">{{ formatNumber(yearData.count) }} deliveries</div>
|
||||||
|
|
||||||
|
<!-- Comparison pill (vs previous year) -->
|
||||||
|
<!-- Only show for non-oldest year -->
|
||||||
|
<div v-if="index < totalsData.length - 1" class="mt-2">
|
||||||
|
<div class="badge" :class="getDiffClass(yearData.gallons, totalsData[index + 1].gallons)">
|
||||||
|
{{ calculateDiff(yearData.gallons, totalsData[index + 1].gallons) }}%
|
||||||
|
<span class="ml-1 text-xs opacity-70">vs {{ totalsData[index + 1].year }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { adminService } from '../../../services/adminService';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
type Period = 'day' | 'month' | 'year';
|
||||||
|
|
||||||
|
const period = ref<Period>('day');
|
||||||
|
const referenceDate = ref(dayjs().format('YYYY-MM-DD'));
|
||||||
|
const loading = ref(false);
|
||||||
|
const totalsData = ref<any[]>([]);
|
||||||
|
|
||||||
|
const setPeriod = (p: Period) => {
|
||||||
|
period.value = p;
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatNumber = (num: number) => {
|
||||||
|
return new Intl.NumberFormat('en-US').format(num);
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateDiff = (current: number, previous: number) => {
|
||||||
|
if (previous === 0) return current > 0 ? '+100' : '0';
|
||||||
|
const diff = ((current - previous) / previous) * 100;
|
||||||
|
return (diff > 0 ? '+' : '') + diff.toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDiffClass = (current: number, previous: number) => {
|
||||||
|
const diff = calculateDiff(current, previous);
|
||||||
|
if (diff === '0') return 'badge-ghost';
|
||||||
|
return diff.startsWith('+') ? 'badge-success gap-2' : 'badge-error gap-2';
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await adminService.stats.getTotals(period.value, referenceDate.value);
|
||||||
|
if (response.data && response.data.totals) {
|
||||||
|
totalsData.value = response.data.totals;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch totals", err);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchData();
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-white text-sm font-bold mb-2">Enter New Password</label>
|
<label class="block text-white text-sm font-bold mb-2">Enter New Password</label>
|
||||||
<input v-model="ChangePasswordForm.new_password" class="rounded w-full py-2 px-3 input-primary text-black"
|
<input v-model="ChangePasswordForm.new_password" class="rounded w-full py-2 px-3 input-primary text-black"
|
||||||
id="password" type="password" placeholder="Password" />
|
id="password" type="password" placeholder="Password" :class="{ 'input-error': v$.ChangePasswordForm.new_password.$error }" />
|
||||||
<span v-if="v$.ChangePasswordForm.new_password.$error" class="text-red-600 text-center">
|
<span v-if="v$.ChangePasswordForm.new_password.$error" class="text-red-600 text-center">
|
||||||
{{ v$.ChangePasswordForm.new_password.$errors[0].$message }}
|
{{ v$.ChangePasswordForm.new_password.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<label class="block text-white text-sm font-bold mb-2">Confirm New Password</label>
|
<label class="block text-white text-sm font-bold mb-2">Confirm New Password</label>
|
||||||
<input v-model="ChangePasswordForm.password_confirm" class="rounded w-full py-2 px-3 input-primary text-black"
|
<input v-model="ChangePasswordForm.password_confirm" class="rounded w-full py-2 px-3 input-primary text-black"
|
||||||
id="passwordtwo" type="password" autocomplete="off" placeholder="Confirm Password" />
|
id="passwordtwo" type="password" autocomplete="off" placeholder="Confirm Password" :class="{ 'input-error': v$.ChangePasswordForm.password_confirm.$error }" />
|
||||||
<span v-if="v$.ChangePasswordForm.password_confirm.$error" class="text-red-600 text-center">
|
<span v-if="v$.ChangePasswordForm.password_confirm.$error" class="text-red-600 text-center">
|
||||||
{{ v$.ChangePasswordForm.password_confirm.$errors[0].$message }}
|
{{ v$.ChangePasswordForm.password_confirm.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<label class="block text-white text-sm font-bold mb-2">Username</label>
|
<label class="block text-white text-sm font-bold mb-2">Username</label>
|
||||||
<input
|
<input
|
||||||
v-model="loginForm.username"
|
v-model="loginForm.username"
|
||||||
class="rounded w-full py-2 px-3 input-primary text-black"
|
:class="inputClasses(v$.loginForm.username, 'rounded w-full py-2 px-3 input-primary text-black')"
|
||||||
id="username"
|
id="username"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
@@ -33,8 +33,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
v-model="loginForm.password"
|
v-model="loginForm.password"
|
||||||
class="rounded w-full py-2 px-3
|
:class="inputClasses(v$.loginForm.password, 'rounded w-full py-2 px-3 input-primary text-black')"
|
||||||
input-primary text-black"
|
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
@@ -87,6 +86,9 @@ import { required, minLength } from "@vuelidate/validators";
|
|||||||
import { useAuthStore } from "../../stores/auth";
|
import { useAuthStore } from "../../stores/auth";
|
||||||
import { authService } from "../../services/authService";
|
import { authService } from "../../services/authService";
|
||||||
import { AxiosResponse, AxiosError } from "../../types/models";
|
import { AxiosResponse, AxiosError } from "../../types/models";
|
||||||
|
import { useFormValidation } from "../../composables/useFormValidation";
|
||||||
|
|
||||||
|
const { inputClasses, validateForm } = useFormValidation();
|
||||||
|
|
||||||
// Stores & Utilities
|
// Stores & Utilities
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
|||||||
@@ -25,14 +25,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="my-5">
|
<div class="my-5">
|
||||||
<input v-model="ForgotForm.username" class="rounded w-full py-2 px-3 input-primary text-black" type="text"
|
<input v-model="ForgotForm.username" class="rounded w-full py-2 px-3 input-primary text-black" type="text"
|
||||||
autocomplete="off" placeholder="Username" />
|
autocomplete="off" placeholder="Username" :class="{ 'input-error': v$.ForgotForm.username.$error }" />
|
||||||
<span v-if="v$.ForgotForm.username.$error" class="text-red-600 text-center">
|
<span v-if="v$.ForgotForm.username.$error" class="text-red-600 text-center">
|
||||||
{{ v$.ForgotForm.username.$errors[0].$message }}
|
{{ v$.ForgotForm.username.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="my-5">
|
<div class="my-5">
|
||||||
<input v-model="ForgotForm.email" class="rounded w-full py-2 px-3 input-primary text-black" type="text"
|
<input v-model="ForgotForm.email" class="rounded w-full py-2 px-3 input-primary text-black" type="text"
|
||||||
autocomplete="off" placeholder="Email" />
|
autocomplete="off" placeholder="Email" :class="{ 'input-error': v$.ForgotForm.email.$error }" />
|
||||||
<span v-if="v$.ForgotForm.email.$error" class="text-red-600 text-center">
|
<span v-if="v$.ForgotForm.email.$error" class="text-red-600 text-center">
|
||||||
{{ v$.ForgotForm.email.$errors[0].$message }}
|
{{ v$.ForgotForm.email.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-white text-sm font-bold mb-2" for="username">Username</label>
|
<label class="block text-white text-sm font-bold mb-2" for="username">Username</label>
|
||||||
<input v-model="registerForm.username" class="rounded w-full py-2 px-3 input-primary text-black" id="username"
|
<input v-model="registerForm.username" class="rounded w-full py-2 px-3 input-primary text-black" id="username"
|
||||||
type="text" placeholder="Login Username" />
|
type="text" placeholder="Login Username" :class="{ 'input-error': v$.registerForm.username.$error }" />
|
||||||
<span v-if="v$.registerForm.username.$error" class="text-red-600 text-center">
|
<span v-if="v$.registerForm.username.$error" class="text-red-600 text-center">
|
||||||
{{ v$.registerForm.username.$errors[0].$message }}
|
{{ v$.registerForm.username.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-white text-sm font-bold mb-2" for="username">Email</label>
|
<label class="block text-white text-sm font-bold mb-2" for="username">Email</label>
|
||||||
<input v-model="registerForm.email" class="rounded w-full py-2 px-3 input-primary text-black" id="email"
|
<input v-model="registerForm.email" class="rounded w-full py-2 px-3 input-primary text-black" id="email"
|
||||||
type="text" placeholder="Email" />
|
type="text" placeholder="Email" :class="{ 'input-error': v$.registerForm.email.$error }" />
|
||||||
<span v-if="v$.registerForm.email.$error" class="text-red-600 text-center">
|
<span v-if="v$.registerForm.email.$error" class="text-red-600 text-center">
|
||||||
{{ v$.registerForm.email.$errors[0].$message }}
|
{{ v$.registerForm.email.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-white text-sm font-bold mb-2" for="password">Password</label>
|
<label class="block text-white text-sm font-bold mb-2" for="password">Password</label>
|
||||||
<input v-model="registerForm.password" class="rounded w-full py-2 px-3 input-primary text-black" id="password"
|
<input v-model="registerForm.password" class="rounded w-full py-2 px-3 input-primary text-black" id="password"
|
||||||
type="password" autocomplete="off" placeholder="Password" />
|
type="password" autocomplete="off" placeholder="Password" :class="{ 'input-error': v$.registerForm.password.$error }" />
|
||||||
<span v-if="v$.registerForm.password.$error" class="text-red-600 text-center">
|
<span v-if="v$.registerForm.password.$error" class="text-red-600 text-center">
|
||||||
{{ v$.registerForm.password.$errors[0].$message }}
|
{{ v$.registerForm.password.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-white text-sm font-bold mb-2" for="password_confirm">Confirm Password</label>
|
<label class="block text-white text-sm font-bold mb-2" for="password_confirm">Confirm Password</label>
|
||||||
<input v-model="registerForm.password_confirm" class="rounded w-full py-2 px-3 input-primary text-black"
|
<input v-model="registerForm.password_confirm" class="rounded w-full py-2 px-3 input-primary text-black"
|
||||||
id="password" type="password" autocomplete="off" placeholder="Confirm Password" />
|
id="password" type="password" autocomplete="off" placeholder="Confirm Password" :class="{ 'input-error': v$.registerForm.password_confirm.$error }" />
|
||||||
<span v-if="v$.registerForm.password_confirm.$error" class="text-red-600 text-center">
|
<span v-if="v$.registerForm.password_confirm.$error" class="text-red-600 text-center">
|
||||||
{{ v$.registerForm.password_confirm.$errors[0].$message }}
|
{{ v$.registerForm.password_confirm.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -9,49 +9,82 @@
|
|||||||
<li>Automatic Deliveries</li>
|
<li>Automatic Deliveries</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-3xl font-bold mt-4">Automatic Deliveries</h1>
|
<!-- Page Header with Stats -->
|
||||||
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mt-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl md:text-3xl font-bold flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center shadow-lg">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
||||||
|
stroke="currentColor" class="w-5 h-5 text-primary-content">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
Automatic Deliveries
|
||||||
|
</h1>
|
||||||
|
<p class="text-base-content/60 mt-1 ml-13">Manage automatic delivery customers and schedules</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Stats -->
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<div class="stat-pill">
|
||||||
|
<span class="stat-pill-value">{{ deliveries.length }}</span>
|
||||||
|
<span class="stat-pill-label">Customers</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Main Content Card -->
|
<!-- Main Content Card -->
|
||||||
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
|
<div class="modern-table-card">
|
||||||
<!-- Header -->
|
|
||||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-4">
|
|
||||||
<h2 class="text-lg font-bold">Customers on Automatic Delivery</h2>
|
|
||||||
<div class="badge badge-ghost">{{ deliveries.length }} customers found</div>
|
|
||||||
</div>
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<!-- Data Display -->
|
<!-- Data Display -->
|
||||||
<div>
|
<div>
|
||||||
<!-- DESKTOP VIEW: Sortable Table -->
|
<!-- DESKTOP VIEW: Sortable Table -->
|
||||||
<div class="overflow-x-auto hidden xl:block">
|
<div class="overflow-x-auto hidden xl:block">
|
||||||
<table class="table w-full">
|
<table class="modern-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<!-- SORTABLE HEADERS -->
|
<!-- SORTABLE HEADERS -->
|
||||||
<th @click="sortBy('tank_level_percent')"
|
<th @click="sortBy('tank_level_percent')" class="sort-header cursor-pointer select-none"
|
||||||
:class="sortKey === 'tank_level_percent' ? 'cursor-pointer hover:text-white bg-orange-500' : 'cursor-pointer hover:text-white'">
|
:class="{ 'text-primary': sortKey === 'tank_level_percent' }">
|
||||||
Tank Level
|
<div class="flex items-center gap-2">
|
||||||
<span v-if="sortKey === 'tank_level_percent'">{{ sortAsc ? '▲' : '▼' }}</span>
|
Tank Level
|
||||||
|
<span v-if="sortKey === 'tank_level_percent'">{{ sortAsc ? '▲' : '▼' }}</span>
|
||||||
|
<span v-else class="opacity-30 text-xs">⇵</span>
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th @click="sortBy('days_since_last_fill')"
|
<th @click="sortBy('days_since_last_fill')" class="sort-header cursor-pointer select-none"
|
||||||
:class="sortKey === 'days_since_last_fill' ? 'cursor-pointer hover:text-white bg-orange-500' : 'cursor-pointer hover:text-white'">
|
:class="{ 'text-primary': sortKey === 'days_since_last_fill' }">
|
||||||
Days Since Fill
|
<div class="flex items-center gap-2">
|
||||||
<span v-if="sortKey === 'days_since_last_fill'">{{ sortAsc ? '▲' : '▼' }}</span>
|
Days Since Fill
|
||||||
|
<span v-if="sortKey === 'days_since_last_fill'">{{ sortAsc ? '▲' : '▼' }}</span>
|
||||||
|
<span v-else class="opacity-30 text-xs">⇵</span>
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th @click="sortBy('customer_full_name')"
|
<th @click="sortBy('customer_full_name')" class="sort-header cursor-pointer select-none"
|
||||||
:class="sortKey === 'customer_full_name' ? 'cursor-pointer hover:text-white bg-orange-500' : 'cursor-pointer hover:text-white'">
|
:class="{ 'text-primary': sortKey === 'customer_full_name' }">
|
||||||
Name
|
<div class="flex items-center gap-2">
|
||||||
<span v-if="sortKey === 'customer_full_name'">{{ sortAsc ? '▲' : '▼' }}</span>
|
Name
|
||||||
|
<span v-if="sortKey === 'customer_full_name'">{{ sortAsc ? '▲' : '▼' }}</span>
|
||||||
|
<span v-else class="opacity-30 text-xs">⇵</span>
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th @click="sortBy('house_factor')"
|
<th @click="sortBy('house_factor')" class="sort-header cursor-pointer select-none"
|
||||||
:class="sortKey === 'house_factor' ? 'cursor-pointer hover:text-white bg-orange-500' : 'cursor-pointer hover:text-white'">
|
:class="{ 'text-primary': sortKey === 'house_factor' }">
|
||||||
Usage Factor
|
<div class="flex items-center gap-2">
|
||||||
<span v-if="sortKey === 'house_factor'">{{ sortAsc ? '▲' : '▼' }}</span>
|
Usage Factor
|
||||||
|
<span v-if="sortKey === 'house_factor'">{{ sortAsc ? '▲' : '▼' }}</span>
|
||||||
|
<span v-else class="opacity-30 text-xs">⇵</span>
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th @click="sortBy('hot_water_summer')"
|
<th @click="sortBy('hot_water_summer')" class="sort-header cursor-pointer select-none"
|
||||||
:class="sortKey === 'hot_water_summer' ? 'cursor-pointer hover:text-white bg-orange-500' : 'cursor-pointer hover:text-white'">
|
:class="{ 'text-primary': sortKey === 'hot_water_summer' }">
|
||||||
Hot Water Tank
|
<div class="flex items-center gap-2">
|
||||||
<span v-if="sortKey === 'hot_water_summer'">{{ sortAsc ? '▲' : '▼' }}</span>
|
Hot Water Tank
|
||||||
|
<span v-if="sortKey === 'hot_water_summer'">{{ sortAsc ? '▲' : '▼' }}</span>
|
||||||
|
<span v-else class="opacity-30 text-xs">⇵</span>
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th>Address</th>
|
<th>Address</th>
|
||||||
<th class="text-right">Actions</th>
|
<th class="text-right">Actions</th>
|
||||||
@@ -59,8 +92,8 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<!-- Loop over the new 'sortedDeliveries' computed property -->
|
<!-- Loop over the new 'sortedDeliveries' computed property -->
|
||||||
<tr v-for="oil in sortedDeliveries" :key="oil.id" class="hover:bg-blue-600 hover:text-white"
|
<tr v-for="oil in sortedDeliveries" :key="oil.id" class="table-row-hover"
|
||||||
:class="{ 'bg-yellow-400 text-black': oil.auto_status == 3 }">
|
:class="{ 'row-urgent': oil.auto_status == 3 }">
|
||||||
<td>
|
<td>
|
||||||
<div v-if="oil.last_fill === null" class="text-gray-500">New Auto</div>
|
<div v-if="oil.last_fill === null" class="text-gray-500">New Auto</div>
|
||||||
<div v-else class="flex items-center gap-3">
|
<div v-else class="flex items-center gap-3">
|
||||||
@@ -83,24 +116,24 @@
|
|||||||
<td>{{ oil.hot_water_summer ? 'Yes' : 'No' }}</td>
|
<td>{{ oil.hot_water_summer ? 'Yes' : 'No' }}</td>
|
||||||
<td>{{ oil.customer_address }}, {{ oil.customer_town }}</td>
|
<td>{{ oil.customer_address }}, {{ oil.customer_town }}</td>
|
||||||
<td class="text-right ">
|
<td class="text-right ">
|
||||||
|
|
||||||
<!-- <router-link :to="{ name: 'customerEdit', params: { id: oil.customer_id } }" class="btn btn-sm btn-secondary">Edit Customer</router-link> -->
|
<!-- <router-link :to="{ name: 'customerEdit', params: { id: oil.customer_id } }" class="btn btn-sm btn-secondary">Edit Customer</router-link> -->
|
||||||
<router-link v-if="oil.auto_status != 3"
|
<router-link v-if="oil.auto_status != 3"
|
||||||
:to="{ name: 'payAutoAuthorize', params: { id: oil['id'] } }">
|
:to="{ name: 'payAutoAuthorize', params: { id: oil['id'] } }">
|
||||||
<button class="btn btn-primary btn-sm">Preauthorize</button>
|
<button class="btn btn-primary btn-sm">Preauthorize</button>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link :to="{ name: 'finalizeTicketAutoNocc', params: { id: oil['id'] } }">
|
<router-link :to="{ name: 'finalizeTicketAutoNocc', params: { id: oil['id'] } }">
|
||||||
<button class="btn btn-secondary btn-sm">Finalize</button>
|
<button class="btn btn-secondary btn-sm">Finalize</button>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link v-if="oil.auto_status == 3"
|
<router-link v-if="oil.auto_status == 3"
|
||||||
:to="{ name: 'finalizeTicketAuto', params: { id: oil.open_ticket_id || oil['id'] } }">
|
:to="{ name: 'finalizeTicketAuto', params: { id: oil.open_ticket_id || oil['id'] } }">
|
||||||
<button class="btn btn-secondary btn-sm">Finalize</button>
|
<button class="btn btn-secondary btn-sm">Finalize</button>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link :to="{ name: 'TicketAuto', params: { id: oil['id'] } }">
|
<router-link :to="{ name: 'TicketAuto', params: { id: oil['id'] } }">
|
||||||
<button class="btn btn-success btn-sm">
|
<button class="btn btn-success btn-sm">
|
||||||
Print Ticket
|
Print Ticket
|
||||||
</button>
|
</button>
|
||||||
</router-link>
|
</router-link>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -108,33 +141,34 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- MOBILE VIEW: Cards -->
|
<!-- MOBILE VIEW: Cards -->
|
||||||
<div class="xl:hidden space-y-4">
|
<div class="xl:hidden space-y-4 px-4 pb-4">
|
||||||
<div v-for="oil in sortedDeliveries" :key="oil.id" class="card bg-base-100 shadow-md">
|
<div v-for="oil in sortedDeliveries" :key="oil.id" class="mobile-card"
|
||||||
<div class="card-body p-4">
|
:class="{ 'mobile-card-urgent': oil.auto_status == 3 }">
|
||||||
|
<div class="p-3">
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="card-title text-base">{{ oil.customer_full_name }}</h2>
|
<h2 class="text-base font-bold">{{ oil.customer_full_name }}</h2>
|
||||||
<p class="text-xs text-gray-400">{{ oil.customer_address }}, {{ oil.customer_town }}</p>
|
<p class="text-xs text-base-content/60">{{ oil.customer_address }}, {{ oil.customer_town }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<div class="font-bold">{{ oil.days_since_last_fill }}</div>
|
<div class="font-bold">{{ oil.days_since_last_fill }}</div>
|
||||||
<div class="text-xs text-gray-400">days ago</div>
|
<div class="text-xs text-base-content/60">days ago</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="label p-0 mb-1"><span class="label-text">Usage Factor</span></label>
|
<p class="text-xs text-base-content/50">Usage Factor</p>
|
||||||
<div class="text-sm">{{ oil.house_factor }}</div>
|
<div class="text-sm font-medium">{{ oil.house_factor }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="label p-0 mb-1"><span class="label-text">Hot Water Tank</span></label>
|
<p class="text-xs text-base-content/50">Hot Water Tank</p>
|
||||||
<div class="text-sm">{{ oil.hot_water_summer ? 'Yes' : 'No' }}</div>
|
<div class="text-sm font-medium">{{ oil.hot_water_summer ? 'Yes' : 'No' }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<label class="label p-0 mb-1"><span class="label-text">Tank Level</span></label>
|
<p class="text-xs text-base-content/50 mb-1">Tank Level</p>
|
||||||
<div v-if="oil.last_fill === null" class="text-gray-500 text-sm">New Auto Customer</div>
|
<div v-if="oil.last_fill === null" class="text-gray-500 text-sm">New Auto Customer</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<progress class="progress w-full" :value="oil.estimated_gallons_left" :max="oil.tank_size" :class="{
|
<progress class="progress w-full" :value="oil.estimated_gallons_left" :max="oil.tank_size" :class="{
|
||||||
@@ -142,30 +176,30 @@
|
|||||||
'progress-warning': getTankLevelPercentage(oil) >= 25 && getTankLevelPercentage(oil) <= 60,
|
'progress-warning': getTankLevelPercentage(oil) >= 25 && getTankLevelPercentage(oil) <= 60,
|
||||||
'progress-error': getTankLevelPercentage(oil) < 25
|
'progress-error': getTankLevelPercentage(oil) < 25
|
||||||
}"></progress>
|
}"></progress>
|
||||||
<div class="text-xs text-gray-400 text-right">{{ oil.estimated_gallons_left }} / {{ oil.tank_size
|
<div class="text-xs text-base-content/60 text-right mt-1">{{ oil.estimated_gallons_left }} / {{
|
||||||
|
oil.tank_size
|
||||||
}} gal estimated</div>
|
}} gal estimated</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-actions justify-end flex-wrap gap-2 mt-4">
|
<div class="flex gap-2 pt-3 mt-3 border-t border-base-content/10 flex-wrap">
|
||||||
<router-link :to="{ name: 'customerEdit', params: { id: oil.customer_id } }"
|
<router-link :to="{ name: 'customerEdit', params: { id: oil.customer_id } }"
|
||||||
class="btn btn-sm btn-secondary">Edit Customer</router-link>
|
class="btn btn-sm btn-ghost flex-1">Edit</router-link>
|
||||||
<router-link v-if="oil.auto_status != 3"
|
<router-link v-if="oil.auto_status != 3" :to="{ name: 'payAutoAuthorize', params: { id: oil['id'] } }"
|
||||||
:to="{ name: 'payAutoAuthorize', params: { id: oil['id'] } }">
|
class="flex-1">
|
||||||
<button class="btn btn-primary btn-sm">Preauthorize</button>
|
<button class="btn btn-primary btn-sm btn-outline w-full">Auth</button>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link :to="{ name: 'finalizeTicketAutoNocc', params: { id: oil['id'] } }">
|
<router-link :to="{ name: 'finalizeTicketAutoNocc', params: { id: oil['id'] } }" class="flex-1">
|
||||||
<button class="btn btn-secondary btn-sm">Finalize</button>
|
<button class="btn btn-secondary btn-sm btn-outline w-full">Finalize</button>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link v-if="oil.auto_status == 3"
|
<router-link v-if="oil.auto_status == 3"
|
||||||
:to="{ name: 'finalizeTicketAuto', params: { id: oil.open_ticket_id || oil['id'] } }">
|
:to="{ name: 'finalizeTicketAuto', params: { id: oil.open_ticket_id || oil['id'] } }"
|
||||||
<button class="btn btn-secondary btn-sm">Finalize</button>
|
class="flex-1">
|
||||||
|
<button class="btn btn-secondary btn-sm btn-outline w-full">Finalize</button>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link :to="{ name: 'TicketAuto', params: { id: oil['id'] } }">
|
<router-link :to="{ name: 'TicketAuto', params: { id: oil['id'] } }" class="flex-1">
|
||||||
<button class="btn btn-success btn-sm">
|
<button class="btn btn-success btn-sm btn-outline w-full">Ticket</button>
|
||||||
Print Ticket
|
|
||||||
</button>
|
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,7 +209,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
|||||||
@@ -55,21 +55,21 @@
|
|||||||
<!-- Name -->
|
<!-- Name -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text font-bold">Name on Card</span></label>
|
<label class="label"><span class="label-text font-bold">Name on Card</span></label>
|
||||||
<input v-model="CardForm.name_on_card" type="text" placeholder="Name" class="input input-bordered input-sm w-full" />
|
<input v-model="CardForm.name_on_card" type="text" placeholder="Name" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CardForm.name_on_card.$error }" />
|
||||||
<span v-if="v$.CardForm.name_on_card.$error" class="text-red-500 text-xs mt-1">A valid name_on_card is required.</span>
|
<span v-if="v$.CardForm.name_on_card.$error" class="text-red-500 text-xs mt-1">A valid name_on_card is required.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Card Number -->
|
<!-- Card Number -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text font-bold">Card Number</span></label>
|
<label class="label"><span class="label-text font-bold">Card Number</span></label>
|
||||||
<input v-model="CardForm.card_number" type="text" placeholder="•••• •••• •••• ••••" class="input input-bordered input-sm w-full" />
|
<input v-model="CardForm.card_number" type="text" placeholder="•••• •••• •••• ••••" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CardForm.card_number.$error }" />
|
||||||
<span v-if="v$.CardForm.card_number.$error" class="text-red-500 text-xs mt-1">A valid card number is required.</span>
|
<span v-if="v$.CardForm.card_number.$error" class="text-red-500 text-xs mt-1">A valid card number is required.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- CVV (Security Code) -->
|
<!-- CVV (Security Code) -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text font-bold">CVV</span></label>
|
<label class="label"><span class="label-text font-bold">CVV</span></label>
|
||||||
<input v-model="CardForm.cvv" type="text" placeholder="123" class="input input-bordered input-sm w-full" />
|
<input v-model="CardForm.cvv" type="text" placeholder="123" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CardForm.cvv.$error }" />
|
||||||
<span v-if="v$.CardForm.cvv.$error" class="text-red-500 text-xs mt-1">CVV is required.</span>
|
<span v-if="v$.CardForm.cvv.$error" class="text-red-500 text-xs mt-1">CVV is required.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -77,11 +77,11 @@
|
|||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text font-bold">Expiration Date</span></label>
|
<label class="label"><span class="label-text font-bold">Expiration Date</span></label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<select v-model="CardForm.expiration_month" class="select select-bordered select-sm w-full">
|
<select v-model="CardForm.expiration_month" class="select select-bordered select-sm w-full" :class="{ 'select-error': v$.CardForm.expiration_month.$error }">
|
||||||
<option disabled value="">Month</option>
|
<option disabled value="">Month</option>
|
||||||
<option v-for="m in 12" :key="m" :value="String(m).padStart(2, '0')">{{ String(m).padStart(2, '0') }}</option>
|
<option v-for="m in 12" :key="m" :value="String(m).padStart(2, '0')">{{ String(m).padStart(2, '0') }}</option>
|
||||||
</select>
|
</select>
|
||||||
<select v-model="CardForm.expiration_year" class="select select-bordered select-sm w-full">
|
<select v-model="CardForm.expiration_year" class="select select-bordered select-sm w-full" :class="{ 'select-error': v$.CardForm.expiration_year.$error }">
|
||||||
<option disabled value="">Year</option>
|
<option disabled value="">Year</option>
|
||||||
<option v-for="y in 10" :key="y" :value="new Date().getFullYear() + y - 1">{{ new Date().getFullYear() + y - 1 }}</option>
|
<option v-for="y in 10" :key="y" :value="new Date().getFullYear() + y - 1">{{ new Date().getFullYear() + y - 1 }}</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
<!-- Card Type dropdown -->
|
<!-- Card Type dropdown -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text font-bold">Card Type</span></label>
|
<label class="label"><span class="label-text font-bold">Card Type</span></label>
|
||||||
<select v-model="CardForm.type_of_card" class="select select-bordered select-sm w-full">
|
<select v-model="CardForm.type_of_card" class="select select-bordered select-sm w-full" :class="{ 'select-error': v$.CardForm.type_of_card.$error }">
|
||||||
<option disabled value="">Select Type</option>
|
<option disabled value="">Select Type</option>
|
||||||
<option>Visa</option>
|
<option>Visa</option>
|
||||||
<option>MasterCard</option>
|
<option>MasterCard</option>
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
<!-- Billing Zip Code input -->
|
<!-- Billing Zip Code input -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text font-bold">Billing Zip Code</span></label>
|
<label class="label"><span class="label-text font-bold">Billing Zip Code</span></label>
|
||||||
<input v-model="CardForm.zip_code" type="text" placeholder="12345" class="input input-bordered input-sm w-full" />
|
<input v-model="CardForm.zip_code" type="text" placeholder="12345" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CardForm.zip_code.$error }" />
|
||||||
<span v-if="v$.CardForm.zip_code.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
<span v-if="v$.CardForm.zip_code.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -60,14 +60,14 @@
|
|||||||
<!-- Name on Card -->
|
<!-- Name on Card -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text font-bold">Name on Card</span></label>
|
<label class="label"><span class="label-text font-bold">Name on Card</span></label>
|
||||||
<input v-model="CardForm.name_on_card" type="text" placeholder="" class="input input-bordered input-sm w-full" />
|
<input v-model="CardForm.name_on_card" type="text" placeholder="" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CardForm.name_on_card.$error }" />
|
||||||
<span v-if="v$.CardForm.name_on_card.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
<span v-if="v$.CardForm.name_on_card.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Card Number -->
|
<!-- Card Number -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text font-bold">Card Number</span></label>
|
<label class="label"><span class="label-text font-bold">Card Number</span></label>
|
||||||
<input v-model="CardForm.card_number" type="text" placeholder="" class="input input-bordered input-sm w-full" />
|
<input v-model="CardForm.card_number" type="text" placeholder="" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CardForm.card_number.$error }" />
|
||||||
<span v-if="v$.CardForm.card_number.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
<span v-if="v$.CardForm.card_number.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -75,11 +75,11 @@
|
|||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text font-bold">Expiration</span></label>
|
<label class="label"><span class="label-text font-bold">Expiration</span></label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<select v-model="CardForm.expiration_month" class="select select-bordered select-sm w-full">
|
<select v-model="CardForm.expiration_month" class="select select-bordered select-sm w-full" :class="{ 'select-error': v$.CardForm.expiration_month.$error }">
|
||||||
<option disabled value="">MM</option>
|
<option disabled value="">MM</option>
|
||||||
<option v-for="m in 12" :key="m" :value="String(m).padStart(2, '0')">{{ String(m).padStart(2, '0') }}</option>
|
<option v-for="m in 12" :key="m" :value="String(m).padStart(2, '0')">{{ String(m).padStart(2, '0') }}</option>
|
||||||
</select>
|
</select>
|
||||||
<select v-model="CardForm.expiration_year" class="select select-bordered select-sm w-full">
|
<select v-model="CardForm.expiration_year" class="select select-bordered select-sm w-full" :class="{ 'select-error': v$.CardForm.expiration_year.$error }">
|
||||||
<option disabled value="">YYYY</option>
|
<option disabled value="">YYYY</option>
|
||||||
<option v-for="y in 10" :key="y" :value="new Date().getFullYear() + y - 1">{{ new Date().getFullYear() + y - 1 }}</option>
|
<option v-for="y in 10" :key="y" :value="new Date().getFullYear() + y - 1">{{ new Date().getFullYear() + y - 1 }}</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -90,14 +90,14 @@
|
|||||||
<!-- Security Number (CVV) -->
|
<!-- Security Number (CVV) -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text font-bold">CVV</span></label>
|
<label class="label"><span class="label-text font-bold">CVV</span></label>
|
||||||
<input v-model="CardForm.security_number" type="text" placeholder="" class="input input-bordered input-sm w-full" />
|
<input v-model="CardForm.security_number" type="text" placeholder="" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CardForm.security_number.$error }" />
|
||||||
<span v-if="v$.CardForm.security_number.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
<span v-if="v$.CardForm.security_number.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Card Type -->
|
<!-- Card Type -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text font-bold">Card Type</span></label>
|
<label class="label"><span class="label-text font-bold">Card Type</span></label>
|
||||||
<select v-model="CardForm.type_of_card" class="select select-bordered select-sm w-full">
|
<select v-model="CardForm.type_of_card" class="select select-bordered select-sm w-full" :class="{ 'select-error': v$.CardForm.type_of_card.$error }">
|
||||||
<option disabled value="">Select Type</option>
|
<option disabled value="">Select Type</option>
|
||||||
<option>Visa</option>
|
<option>Visa</option>
|
||||||
<option>MasterCard</option>
|
<option>MasterCard</option>
|
||||||
|
|||||||
@@ -7,11 +7,11 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li><router-link :to="{ name: 'home' }">Home</router-link></li>
|
<li><router-link :to="{ name: 'home' }">Home</router-link></li>
|
||||||
<li><router-link :to="{ name: 'customer' }">Customers</router-link></li>
|
<li><router-link :to="{ name: 'customer' }">Customers</router-link></li>
|
||||||
|
|
||||||
<li>Create New Customer</li>
|
<li>Create New Customer</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="text-3xl font-bold mt-4">
|
<h1 class="text-3xl font-bold mt-4">
|
||||||
Create New Customer
|
Create New Customer
|
||||||
</h1>
|
</h1>
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
<!-- Main Form Card -->
|
<!-- Main Form Card -->
|
||||||
<div class="bg-neutral rounded-lg p-6 mt-6">
|
<div class="bg-neutral rounded-lg p-6 mt-6">
|
||||||
<form @submit.prevent="onSubmit" class="space-y-6">
|
<form @submit.prevent="onSubmit" class="space-y-6">
|
||||||
|
|
||||||
<!-- SECTION 1: General Info -->
|
<!-- SECTION 1: General Info -->
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-bold">General Info</h2>
|
<h2 class="text-lg font-bold">General Info</h2>
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
<!-- First Name -->
|
<!-- First Name -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">First Name</span></label>
|
<label class="label"><span class="label-text">First Name</span></label>
|
||||||
<input v-model="CreateCustomerForm.customer_first_name" type="text" placeholder="First Name" class="input input-bordered input-sm w-full" />
|
<input v-model="CreateCustomerForm.customer_first_name" type="text" placeholder="First Name" :class="inputClasses(v$.CreateCustomerForm.customer_first_name)" />
|
||||||
<span v-if="v$.CreateCustomerForm.customer_first_name.$error" class="text-red-500 text-xs mt-1">
|
<span v-if="v$.CreateCustomerForm.customer_first_name.$error" class="text-red-500 text-xs mt-1">
|
||||||
{{ v$.CreateCustomerForm.customer_first_name.$errors[0].$message }}
|
{{ v$.CreateCustomerForm.customer_first_name.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
<!-- Last Name -->
|
<!-- Last Name -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Last Name</span></label>
|
<label class="label"><span class="label-text">Last Name</span></label>
|
||||||
<input v-model="CreateCustomerForm.customer_last_name" type="text" placeholder="Last Name" class="input input-bordered input-sm w-full" />
|
<input v-model="CreateCustomerForm.customer_last_name" type="text" placeholder="Last Name" :class="inputClasses(v$.CreateCustomerForm.customer_last_name)" />
|
||||||
<span v-if="v$.CreateCustomerForm.customer_last_name.$error" class="text-red-500 text-xs mt-1">
|
<span v-if="v$.CreateCustomerForm.customer_last_name.$error" class="text-red-500 text-xs mt-1">
|
||||||
{{ v$.CreateCustomerForm.customer_last_name.$errors[0].$message }}
|
{{ v$.CreateCustomerForm.customer_last_name.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
<!-- Phone Number -->
|
<!-- Phone Number -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Phone Number</span></label>
|
<label class="label"><span class="label-text">Phone Number</span></label>
|
||||||
<input v-model="CreateCustomerForm.customer_phone_number" type="tel" placeholder="Phone Number" class="input input-bordered input-sm w-full" @input="acceptNumber()" />
|
<input v-model="CreateCustomerForm.customer_phone_number" type="tel" placeholder="Phone Number" :class="inputClasses(v$.CreateCustomerForm.customer_phone_number)" @input="acceptNumber()" />
|
||||||
<span v-if="v$.CreateCustomerForm.customer_phone_number.$error" class="text-red-500 text-xs mt-1">
|
<span v-if="v$.CreateCustomerForm.customer_phone_number.$error" class="text-red-500 text-xs mt-1">
|
||||||
{{ v$.CreateCustomerForm.customer_phone_number.$errors[0].$message }}
|
{{ v$.CreateCustomerForm.customer_phone_number.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
<!-- Email -->
|
<!-- Email -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Email (Optional)</span></label>
|
<label class="label"><span class="label-text">Email (Optional)</span></label>
|
||||||
<input v-model="CreateCustomerForm.customer_email" type="text" placeholder="Email" class="input input-bordered input-sm w-full" />
|
<input v-model="CreateCustomerForm.customer_email" type="text" placeholder="Email" :class="inputClasses(v$.CreateCustomerForm.customer_email)" />
|
||||||
<span v-if="v$.CreateCustomerForm.customer_email.$error" class="text-red-500 text-xs mt-1">
|
<span v-if="v$.CreateCustomerForm.customer_email.$error" class="text-red-500 text-xs mt-1">
|
||||||
{{ v$.CreateCustomerForm.customer_email.$errors[0].$message }}
|
{{ v$.CreateCustomerForm.customer_email.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
@@ -60,8 +60,8 @@
|
|||||||
<!-- Customer Type -->
|
<!-- Customer Type -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Customer Type</span></label>
|
<label class="label"><span class="label-text">Customer Type</span></label>
|
||||||
<select v-model="CreateCustomerForm.customer_home_type" class="select select-bordered select-sm w-full">
|
<select v-model="CreateCustomerForm.customer_home_type" :class="selectClasses(v$.CreateCustomerForm.customer_home_type)">
|
||||||
<option disabled :value="0">Select a type</option>
|
<option disabled :value="-1">Select a type</option>
|
||||||
<option v-for="customer in custList" :key="customer.value" :value="customer.value">
|
<option v-for="customer in custList" :key="customer.value" :value="customer.value">
|
||||||
{{ customer.text }}
|
{{ customer.text }}
|
||||||
</option>
|
</option>
|
||||||
@@ -76,31 +76,61 @@
|
|||||||
<h2 class="text-lg font-bold">Address</h2>
|
<h2 class="text-lg font-bold">Address</h2>
|
||||||
<div class="divider mt-2 mb-4"></div>
|
<div class="divider mt-2 mb-4"></div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<!-- Street Address -->
|
<!-- Town with Autocomplete -->
|
||||||
<div class="form-control">
|
<div class="form-control relative">
|
||||||
<label class="label"><span class="label-text">Street Address</span></label>
|
|
||||||
<input v-model="CreateCustomerForm.customer_address" type="text" placeholder="Street Address" class="input input-bordered input-sm w-full" />
|
|
||||||
<span v-if="v$.CreateCustomerForm.customer_address.$error" class="text-red-500 text-xs mt-1">
|
|
||||||
{{ v$.CreateCustomerForm.customer_address.$errors[0].$message }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<!-- Apt, Suite, etc. -->
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label"><span class="label-text">Apt, Suite, etc. (Optional)</span></label>
|
|
||||||
<input v-model="CreateCustomerForm.customer_apt" type="text" placeholder="Apt, suite, unit..." class="input input-bordered input-sm w-full" />
|
|
||||||
</div>
|
|
||||||
<!-- Town -->
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label"><span class="label-text">Town</span></label>
|
<label class="label"><span class="label-text">Town</span></label>
|
||||||
<input v-model="CreateCustomerForm.customer_town" type="text" placeholder="Town" class="input input-bordered input-sm w-full" />
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
v-model="CreateCustomerForm.customer_town"
|
||||||
|
type="text"
|
||||||
|
placeholder="Start typing a town name..."
|
||||||
|
:class="inputClasses(v$.CreateCustomerForm.customer_town)"
|
||||||
|
autocomplete="off"
|
||||||
|
@input="onTownInput"
|
||||||
|
@keydown="(e) => handleTownKeydown(e, selectTown)"
|
||||||
|
@blur="closeTownDropdown"
|
||||||
|
@focus="onTownFocus"
|
||||||
|
/>
|
||||||
|
<!-- Loading indicator -->
|
||||||
|
<span v-if="isLoadingTowns" class="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Town Suggestions Dropdown -->
|
||||||
|
<div
|
||||||
|
v-if="showTownDropdown && townSuggestions.length > 0"
|
||||||
|
class="absolute z-50 top-full left-0 right-0 mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(suggestion, index) in townSuggestions"
|
||||||
|
:key="`${suggestion.town}-${suggestion.state}`"
|
||||||
|
class="px-3 py-2 cursor-pointer hover:bg-base-200 transition-colors"
|
||||||
|
:class="{ 'bg-base-200': index === highlightedTownIndex }"
|
||||||
|
@mousedown.prevent="selectTown(suggestion)"
|
||||||
|
>
|
||||||
|
<div class="font-medium">{{ suggestion.town }}</div>
|
||||||
|
<div class="text-xs text-base-content/60">
|
||||||
|
{{ suggestion.state }}
|
||||||
|
<span class="badge badge-xs badge-ghost ml-1">{{ suggestion.customer_count }} customers</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- No results message -->
|
||||||
|
<div
|
||||||
|
v-if="showTownDropdown && townSuggestions.length === 0 && !isLoadingTowns && CreateCustomerForm.customer_town.length >= 2"
|
||||||
|
class="absolute z-50 top-full left-0 right-0 mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg p-3 text-sm text-base-content/60"
|
||||||
|
>
|
||||||
|
No matching towns found. You can enter a new town name.
|
||||||
|
</div>
|
||||||
<span v-if="v$.CreateCustomerForm.customer_town.$error" class="text-red-500 text-xs mt-1">
|
<span v-if="v$.CreateCustomerForm.customer_town.$error" class="text-red-500 text-xs mt-1">
|
||||||
{{ v$.CreateCustomerForm.customer_town.$errors[0].$message }}
|
{{ v$.CreateCustomerForm.customer_town.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- State -->
|
<!-- State -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">State</span></label>
|
<label class="label"><span class="label-text">State</span></label>
|
||||||
<select v-model="CreateCustomerForm.customer_state" class="select select-bordered select-sm w-full">
|
<select v-model="CreateCustomerForm.customer_state" :class="selectClasses(v$.CreateCustomerForm.customer_state)">
|
||||||
<option disabled :value="0">Select a state</option>
|
<option disabled :value="0">Select a state</option>
|
||||||
<option v-for="state in stateList" :key="state.value" :value="state.value">
|
<option v-for="state in stateList" :key="state.value" :value="state.value">
|
||||||
{{ state.text }}
|
{{ state.text }}
|
||||||
@@ -108,10 +138,73 @@
|
|||||||
</select>
|
</select>
|
||||||
<span v-if="v$.CreateCustomerForm.customer_state.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
<span v-if="v$.CreateCustomerForm.customer_state.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Zip Code -->
|
|
||||||
|
<!-- Street Address with Autocomplete -->
|
||||||
|
<div class="form-control relative">
|
||||||
|
<label class="label"><span class="label-text">Street Address</span></label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
v-model="CreateCustomerForm.customer_address"
|
||||||
|
type="text"
|
||||||
|
placeholder="Start typing your street address..."
|
||||||
|
:class="inputClasses(v$.CreateCustomerForm.customer_address)"
|
||||||
|
autocomplete="off"
|
||||||
|
@input="onAddressInput"
|
||||||
|
@keydown="(e) => handleStreetKeydown(e, selectStreet)"
|
||||||
|
@blur="closeStreetDropdown"
|
||||||
|
@focus="onAddressFocus"
|
||||||
|
/>
|
||||||
|
<!-- Loading indicator -->
|
||||||
|
<span v-if="isLoadingStreets" class="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Street Suggestions Dropdown -->
|
||||||
|
<div
|
||||||
|
v-if="showStreetDropdown && streetSuggestions.length > 0"
|
||||||
|
class="absolute z-50 top-full left-0 right-0 mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(suggestion, index) in streetSuggestions"
|
||||||
|
:key="suggestion.street_name + index"
|
||||||
|
class="px-3 py-2 cursor-pointer hover:bg-base-200 transition-colors"
|
||||||
|
:class="{ 'bg-base-200': index === highlightedStreetIndex }"
|
||||||
|
@mousedown.prevent="selectStreet(suggestion)"
|
||||||
|
>
|
||||||
|
<div class="font-medium">{{ suggestion.street_name }}</div>
|
||||||
|
<div class="text-xs text-base-content/60">
|
||||||
|
{{ suggestion.full_address }}
|
||||||
|
<span v-if="suggestion.zip" class="badge badge-xs badge-primary ml-1">{{ suggestion.zip }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Helper text -->
|
||||||
|
<p v-if="!CreateCustomerForm.customer_town && CreateCustomerForm.customer_address.length > 0" class="text-xs text-base-content/60 mt-1">
|
||||||
|
Enter a town first for address suggestions
|
||||||
|
</p>
|
||||||
|
<span v-if="v$.CreateCustomerForm.customer_address.$error" class="text-red-500 text-xs mt-1">
|
||||||
|
{{ v$.CreateCustomerForm.customer_address.$errors[0].$message }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Apt, Suite, etc. -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Zip Code</span></label>
|
<label class="label"><span class="label-text">Apt, Suite, etc. (Optional)</span></label>
|
||||||
<input v-model="CreateCustomerForm.customer_zip" type="text" placeholder="Zip Code" class="input input-bordered input-sm w-full" />
|
<input v-model="CreateCustomerForm.customer_apt" type="text" placeholder="Apt, suite, unit..." class="input input-bordered input-sm w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zip Code (Auto-populated) -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Zip Code</span>
|
||||||
|
<span v-if="zipAutoFilled" class="label-text-alt text-success">Auto-filled</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="CreateCustomerForm.customer_zip"
|
||||||
|
type="text"
|
||||||
|
placeholder="Zip Code"
|
||||||
|
:class="[inputClasses(v$.CreateCustomerForm.customer_zip), { 'input-success': zipAutoFilled && !v$.CreateCustomerForm.customer_zip.$error }]"
|
||||||
|
/>
|
||||||
<span v-if="v$.CreateCustomerForm.customer_zip.$error" class="text-red-500 text-xs mt-1">
|
<span v-if="v$.CreateCustomerForm.customer_zip.$error" class="text-red-500 text-xs mt-1">
|
||||||
{{ v$.CreateCustomerForm.customer_zip.$errors[0].$message }}
|
{{ v$.CreateCustomerForm.customer_zip.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
@@ -128,7 +221,7 @@
|
|||||||
<textarea v-model="CreateCustomerForm.customer_description" rows="4" placeholder="Description of customer's house, tank, etc." class="textarea textarea-bordered"></textarea>
|
<textarea v-model="CreateCustomerForm.customer_description" rows="4" placeholder="Description of customer's house, tank, etc." class="textarea textarea-bordered"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SUBMIT BUTTON -->
|
<!-- SUBMIT BUTTON -->
|
||||||
<div class="pt-4">
|
<div class="pt-4">
|
||||||
<button type="submit" class="btn btn-primary btn-sm">Create Customer</button>
|
<button type="submit" class="btn btn-primary btn-sm">Create Customer</button>
|
||||||
@@ -137,26 +230,48 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { authService } from '../../services/authService'
|
import { authService } from '../../services/authService'
|
||||||
import { customerService } from '../../services/customerService'
|
import { customerService } from '../../services/customerService'
|
||||||
import { queryService } from '../../services/queryService'
|
import { queryService } from '../../services/queryService'
|
||||||
import { StateOption, HomeTypeOption } from '../../types/models'
|
import { StateOption, HomeTypeOption } from '../../types/models'
|
||||||
|
import { useTownAutocomplete, useStreetAutocomplete } from '../../composables/useAddressAutocomplete'
|
||||||
|
import { useFormValidation } from '../../composables/useFormValidation'
|
||||||
|
import { TownSuggestion, StreetSuggestion } from '../../services/addressService'
|
||||||
import useValidate from "@vuelidate/core";
|
import useValidate from "@vuelidate/core";
|
||||||
import { email, minLength, required } from "@vuelidate/validators";
|
import { email, minLength, required } from "@vuelidate/validators";
|
||||||
import { notify } from "@kyvg/vue3-notification";
|
import { notify } from "@kyvg/vue3-notification";
|
||||||
|
|
||||||
|
const { inputClasses, selectClasses, textareaClasses, validateForm } = useFormValidation();
|
||||||
|
|
||||||
// Reactive data
|
// Reactive data
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = ref(null)
|
const user = ref(null)
|
||||||
const stateList = ref<StateOption[]>([])
|
const stateList = ref<StateOption[]>([])
|
||||||
const custList = ref<HomeTypeOption[]>([])
|
const custList = ref<HomeTypeOption[]>([])
|
||||||
|
|
||||||
|
// Track selected town for street autocomplete
|
||||||
|
const selectedTown = ref<TownSuggestion | null>(null)
|
||||||
|
const zipAutoFilled = ref(false)
|
||||||
|
|
||||||
|
// State ID to abbreviation mapping (matches backend STATE_MAPPING)
|
||||||
|
const STATE_ABBR_MAP: Record<number, string> = {
|
||||||
|
0: 'MA', // Default for unmapped
|
||||||
|
1: 'AL', 2: 'AK', 3: 'AS', 4: 'AZ', 5: 'AR', 6: 'CA', 7: 'CO', 8: 'CT',
|
||||||
|
9: 'DE', 10: 'DC', 11: 'FL', 12: 'GA', 13: 'GU', 14: 'HI', 15: 'ID',
|
||||||
|
16: 'IL', 17: 'IN', 18: 'IA', 19: 'KS', 20: 'KY', 21: 'LA', 22: 'ME',
|
||||||
|
23: 'MD', 24: 'MA', 25: 'MI', 26: 'MN', 27: 'MS', 28: 'MO', 29: 'MT',
|
||||||
|
30: 'NE', 31: 'NV', 32: 'NH', 33: 'NJ', 34: 'NM', 35: 'NY', 36: 'NC',
|
||||||
|
37: 'ND', 38: 'OH', 39: 'OK', 40: 'OR', 41: 'PA', 42: 'PR', 43: 'RI',
|
||||||
|
44: 'SC', 45: 'SD', 46: 'TN', 47: 'TX', 48: 'UT', 49: 'VT', 50: 'VA',
|
||||||
|
51: 'VI', 52: 'WA', 53: 'WV', 54: 'WI', 55: 'WY',
|
||||||
|
}
|
||||||
|
|
||||||
// Form object
|
// Form object
|
||||||
const CreateCustomerForm = ref({
|
const CreateCustomerForm = ref({
|
||||||
customer_last_name: "",
|
customer_last_name: "",
|
||||||
@@ -172,6 +287,27 @@ const CreateCustomerForm = ref({
|
|||||||
customer_state: 0,
|
customer_state: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Autocomplete composables
|
||||||
|
const {
|
||||||
|
townSuggestions,
|
||||||
|
isLoadingTowns,
|
||||||
|
showTownDropdown,
|
||||||
|
highlightedTownIndex,
|
||||||
|
searchTowns,
|
||||||
|
closeTownDropdown,
|
||||||
|
handleTownKeydown,
|
||||||
|
} = useTownAutocomplete()
|
||||||
|
|
||||||
|
const {
|
||||||
|
streetSuggestions,
|
||||||
|
isLoadingStreets,
|
||||||
|
showStreetDropdown,
|
||||||
|
highlightedStreetIndex,
|
||||||
|
searchStreets,
|
||||||
|
closeStreetDropdown,
|
||||||
|
handleStreetKeydown,
|
||||||
|
} = useStreetAutocomplete()
|
||||||
|
|
||||||
// Validation rules
|
// Validation rules
|
||||||
const rules = {
|
const rules = {
|
||||||
CreateCustomerForm: {
|
CreateCustomerForm: {
|
||||||
@@ -190,6 +326,103 @@ const rules = {
|
|||||||
// Vuelidate instance
|
// Vuelidate instance
|
||||||
const v$ = useValidate(rules, { CreateCustomerForm })
|
const v$ = useValidate(rules, { CreateCustomerForm })
|
||||||
|
|
||||||
|
// Town autocomplete handlers
|
||||||
|
const onTownInput = () => {
|
||||||
|
searchTowns(CreateCustomerForm.value.customer_town)
|
||||||
|
// Clear selected town when user types (they're changing it)
|
||||||
|
selectedTown.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTownFocus = () => {
|
||||||
|
if (CreateCustomerForm.value.customer_town.length >= 2) {
|
||||||
|
searchTowns(CreateCustomerForm.value.customer_town)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectTown = (suggestion: TownSuggestion) => {
|
||||||
|
CreateCustomerForm.value.customer_town = suggestion.town
|
||||||
|
CreateCustomerForm.value.customer_state = suggestion.state_id
|
||||||
|
selectedTown.value = suggestion
|
||||||
|
showTownDropdown.value = false
|
||||||
|
|
||||||
|
// Clear address fields when town changes
|
||||||
|
CreateCustomerForm.value.customer_address = ''
|
||||||
|
CreateCustomerForm.value.customer_zip = ''
|
||||||
|
zipAutoFilled.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get current town and state for street search
|
||||||
|
const getCurrentTownState = () => {
|
||||||
|
// If a town was selected from dropdown, use that
|
||||||
|
if (selectedTown.value) {
|
||||||
|
return { town: selectedTown.value.town, state: selectedTown.value.state }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, use whatever is typed in the town field
|
||||||
|
const townValue = CreateCustomerForm.value.customer_town.trim()
|
||||||
|
if (!townValue) return null
|
||||||
|
|
||||||
|
// Get state abbreviation from state ID using our mapping
|
||||||
|
const stateId = CreateCustomerForm.value.customer_state
|
||||||
|
const stateAbbr = STATE_ABBR_MAP[stateId] || 'MA'
|
||||||
|
|
||||||
|
return { town: townValue, state: stateAbbr }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address autocomplete handlers
|
||||||
|
const onAddressInput = () => {
|
||||||
|
const townState = getCurrentTownState()
|
||||||
|
if (townState && CreateCustomerForm.value.customer_address.length >= 1) {
|
||||||
|
searchStreets(
|
||||||
|
townState.town,
|
||||||
|
townState.state,
|
||||||
|
CreateCustomerForm.value.customer_address
|
||||||
|
)
|
||||||
|
}
|
||||||
|
zipAutoFilled.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const onAddressFocus = () => {
|
||||||
|
const townState = getCurrentTownState()
|
||||||
|
if (townState && CreateCustomerForm.value.customer_address.length >= 1) {
|
||||||
|
searchStreets(
|
||||||
|
townState.town,
|
||||||
|
townState.state,
|
||||||
|
CreateCustomerForm.value.customer_address
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectStreet = (suggestion: StreetSuggestion) => {
|
||||||
|
CreateCustomerForm.value.customer_address = suggestion.street_name
|
||||||
|
if (suggestion.zip) {
|
||||||
|
CreateCustomerForm.value.customer_zip = suggestion.zip
|
||||||
|
zipAutoFilled.value = true
|
||||||
|
// Flash effect for auto-filled zip
|
||||||
|
setTimeout(() => {
|
||||||
|
zipAutoFilled.value = false
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
showStreetDropdown.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for manual town changes to update selectedTown state
|
||||||
|
watch(() => CreateCustomerForm.value.customer_state, (newState) => {
|
||||||
|
// If user manually selects a different state, update selectedTown
|
||||||
|
if (selectedTown.value && newState !== selectedTown.value.state_id) {
|
||||||
|
// Find the state abbreviation
|
||||||
|
const stateOption = stateList.value.find(s => s.value === newState)
|
||||||
|
if (stateOption && CreateCustomerForm.value.customer_town) {
|
||||||
|
selectedTown.value = {
|
||||||
|
town: CreateCustomerForm.value.customer_town,
|
||||||
|
state: stateOption.text.split(' - ')[0] || stateOption.text,
|
||||||
|
state_id: newState,
|
||||||
|
customer_count: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Functions
|
// Functions
|
||||||
const acceptNumber = () => {
|
const acceptNumber = () => {
|
||||||
const x = CreateCustomerForm.value.customer_phone_number.replace(/\D/g, '').match(/(\d{0,3})(\d{0,3})(\d{0,4})/);
|
const x = CreateCustomerForm.value.customer_phone_number.replace(/\D/g, '').match(/(\d{0,3})(\d{0,3})(\d{0,4})/);
|
||||||
@@ -208,12 +441,28 @@ const userStatus = () => {
|
|||||||
|
|
||||||
const getCustomerTypeList = () => {
|
const getCustomerTypeList = () => {
|
||||||
queryService.getCustomerTypes()
|
queryService.getCustomerTypes()
|
||||||
.then((response: any) => { custList.value = response.data; });
|
.then((response: any) => {
|
||||||
|
if (response.data && response.data.customer_types) {
|
||||||
|
custList.value = response.data.customer_types;
|
||||||
|
// Set default to Residential (ID 0) based on user feedback
|
||||||
|
if (custList.value.some(t => t.value === 0)) {
|
||||||
|
CreateCustomerForm.value.customer_home_type = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
custList.value = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatesList = () => {
|
const getStatesList = () => {
|
||||||
queryService.getStates()
|
queryService.getStates()
|
||||||
.then((response: any) => { stateList.value = response.data; });
|
.then((response: any) => {
|
||||||
|
if (response.data && response.data.states) {
|
||||||
|
stateList.value = response.data.states;
|
||||||
|
} else {
|
||||||
|
stateList.value = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const CreateCustomer = (payload: any) => {
|
const CreateCustomer = (payload: any) => {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mt-4">
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mt-4">
|
||||||
<h1 class="text-3xl font-bold">
|
<h1 class="text-3xl font-bold">
|
||||||
Edit Customer: {{ customer.account_number }}
|
Edit Customer: {{ customer.account_number }}
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
<!-- Main Form Card -->
|
<!-- Main Form Card -->
|
||||||
<div class="bg-neutral rounded-lg p-6 mt-6">
|
<div class="bg-neutral rounded-lg p-6 mt-6">
|
||||||
<form @submit.prevent="onSubmit" class="space-y-6">
|
<form @submit.prevent="onSubmit" class="space-y-6">
|
||||||
|
|
||||||
<!-- SECTION 1: General Info -->
|
<!-- SECTION 1: General Info -->
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-bold">General Info</h2>
|
<h2 class="text-lg font-bold">General Info</h2>
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
<!-- First Name -->
|
<!-- First Name -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">First Name</span></label>
|
<label class="label"><span class="label-text">First Name</span></label>
|
||||||
<input v-model="CreateCustomerForm.basicInfo.customer_first_name" type="text" placeholder="First Name" class="input input-bordered input-sm w-full" />
|
<input v-model="CreateCustomerForm.basicInfo.customer_first_name" type="text" placeholder="First Name" :class="inputClasses(v$.CreateCustomerForm.basicInfo.customer_first_name)" />
|
||||||
<span v-if="v$.CreateCustomerForm.basicInfo.customer_first_name.$error" class="text-red-500 text-xs mt-1">
|
<span v-if="v$.CreateCustomerForm.basicInfo.customer_first_name.$error" class="text-red-500 text-xs mt-1">
|
||||||
{{ v$.CreateCustomerForm.basicInfo.customer_first_name.$errors[0].$message }}
|
{{ v$.CreateCustomerForm.basicInfo.customer_first_name.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
<!-- Last Name -->
|
<!-- Last Name -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Last Name</span></label>
|
<label class="label"><span class="label-text">Last Name</span></label>
|
||||||
<input v-model="CreateCustomerForm.basicInfo.customer_last_name" type="text" placeholder="Last Name" class="input input-bordered input-sm w-full" />
|
<input v-model="CreateCustomerForm.basicInfo.customer_last_name" type="text" placeholder="Last Name" :class="inputClasses(v$.CreateCustomerForm.basicInfo.customer_last_name)" />
|
||||||
<span v-if="v$.CreateCustomerForm.basicInfo.customer_last_name.$error" class="text-red-500 text-xs mt-1">
|
<span v-if="v$.CreateCustomerForm.basicInfo.customer_last_name.$error" class="text-red-500 text-xs mt-1">
|
||||||
{{ v$.CreateCustomerForm.basicInfo.customer_last_name.$errors[0].$message }}
|
{{ v$.CreateCustomerForm.basicInfo.customer_last_name.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
<!-- Phone Number -->
|
<!-- Phone Number -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Phone Number</span></label>
|
<label class="label"><span class="label-text">Phone Number</span></label>
|
||||||
<input v-model="CreateCustomerForm.basicInfo.customer_phone_number" type="text" placeholder="Phone Number" class="input input-bordered input-sm w-full" @input="acceptNumber()" />
|
<input v-model="CreateCustomerForm.basicInfo.customer_phone_number" type="text" placeholder="Phone Number" :class="inputClasses(v$.CreateCustomerForm.basicInfo.customer_phone_number)" @input="acceptNumber()" />
|
||||||
<span v-if="v$.CreateCustomerForm.basicInfo.customer_phone_number.$error" class="text-red-500 text-xs mt-1">
|
<span v-if="v$.CreateCustomerForm.basicInfo.customer_phone_number.$error" class="text-red-500 text-xs mt-1">
|
||||||
{{ v$.CreateCustomerForm.basicInfo.customer_phone_number.$errors[0].$message }}
|
{{ v$.CreateCustomerForm.basicInfo.customer_phone_number.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
<!-- Email -->
|
<!-- Email -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Email (Optional)</span></label>
|
<label class="label"><span class="label-text">Email (Optional)</span></label>
|
||||||
<input v-model="CreateCustomerForm.basicInfo.customer_email" type="text" placeholder="Email" class="input input-bordered input-sm w-full" />
|
<input v-model="CreateCustomerForm.basicInfo.customer_email" type="text" placeholder="Email" :class="inputClasses(v$.CreateCustomerForm.basicInfo.customer_email)" />
|
||||||
<span v-if="v$.CreateCustomerForm.basicInfo.customer_email.$error" class="text-red-500 text-xs mt-1">
|
<span v-if="v$.CreateCustomerForm.basicInfo.customer_email.$error" class="text-red-500 text-xs mt-1">
|
||||||
{{ v$.CreateCustomerForm.basicInfo.customer_email.$errors[0].$message }}
|
{{ v$.CreateCustomerForm.basicInfo.customer_email.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
<!-- Customer Type -->
|
<!-- Customer Type -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Customer Type</span></label>
|
<label class="label"><span class="label-text">Customer Type</span></label>
|
||||||
<select v-model="CreateCustomerForm.basicInfo.customer_home_type" class="select select-bordered select-sm w-full">
|
<select v-model="CreateCustomerForm.basicInfo.customer_home_type" :class="selectClasses(v$.CreateCustomerForm.basicInfo.customer_home_type)">
|
||||||
<option v-for="customer in custList" :key="customer.value" :value="customer.value">
|
<option v-for="customer in custList" :key="customer.value" :value="customer.value">
|
||||||
{{ customer.text }}
|
{{ customer.text }}
|
||||||
</option>
|
</option>
|
||||||
@@ -82,31 +82,61 @@
|
|||||||
<h2 class="text-lg font-bold">Address</h2>
|
<h2 class="text-lg font-bold">Address</h2>
|
||||||
<div class="divider mt-2 mb-4"></div>
|
<div class="divider mt-2 mb-4"></div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<!-- Street Address -->
|
<!-- Town with Autocomplete -->
|
||||||
<div class="form-control">
|
<div class="form-control relative">
|
||||||
<label class="label"><span class="label-text">Street Address</span></label>
|
|
||||||
<input v-model="CreateCustomerForm.basicInfo.customer_address" type="text" placeholder="Street Address" class="input input-bordered input-sm w-full" />
|
|
||||||
<span v-if="v$.CreateCustomerForm.basicInfo.customer_address.$error" class="text-red-500 text-xs mt-1">
|
|
||||||
{{ v$.CreateCustomerForm.basicInfo.customer_address.$errors[0].$message }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<!-- Apt, Suite, etc. -->
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label"><span class="label-text">Apt, Suite, etc. (Optional)</span></label>
|
|
||||||
<input v-model="CreateCustomerForm.basicInfo.customer_apt" type="text" placeholder="Apt, suite, unit..." class="input input-bordered input-sm w-full" />
|
|
||||||
</div>
|
|
||||||
<!-- Town -->
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label"><span class="label-text">Town</span></label>
|
<label class="label"><span class="label-text">Town</span></label>
|
||||||
<input v-model="CreateCustomerForm.basicInfo.customer_town" type="text" placeholder="Town" class="input input-bordered input-sm w-full" />
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
v-model="CreateCustomerForm.basicInfo.customer_town"
|
||||||
|
type="text"
|
||||||
|
placeholder="Start typing a town name..."
|
||||||
|
:class="inputClasses(v$.CreateCustomerForm.basicInfo.customer_town)"
|
||||||
|
autocomplete="off"
|
||||||
|
@input="onTownInput"
|
||||||
|
@keydown="(e) => handleTownKeydown(e, selectTown)"
|
||||||
|
@blur="closeTownDropdown"
|
||||||
|
@focus="onTownFocus"
|
||||||
|
/>
|
||||||
|
<!-- Loading indicator -->
|
||||||
|
<span v-if="isLoadingTowns" class="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Town Suggestions Dropdown -->
|
||||||
|
<div
|
||||||
|
v-if="showTownDropdown && townSuggestions.length > 0"
|
||||||
|
class="absolute z-50 top-full left-0 right-0 mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(suggestion, index) in townSuggestions"
|
||||||
|
:key="`${suggestion.town}-${suggestion.state}`"
|
||||||
|
class="px-3 py-2 cursor-pointer hover:bg-base-200 transition-colors"
|
||||||
|
:class="{ 'bg-base-200': index === highlightedTownIndex }"
|
||||||
|
@mousedown.prevent="selectTown(suggestion)"
|
||||||
|
>
|
||||||
|
<div class="font-medium">{{ suggestion.town }}</div>
|
||||||
|
<div class="text-xs text-base-content/60">
|
||||||
|
{{ suggestion.state }}
|
||||||
|
<span class="badge badge-xs badge-ghost ml-1">{{ suggestion.customer_count }} customers</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- No results message -->
|
||||||
|
<div
|
||||||
|
v-if="showTownDropdown && townSuggestions.length === 0 && !isLoadingTowns && CreateCustomerForm.basicInfo.customer_town.length >= 2"
|
||||||
|
class="absolute z-50 top-full left-0 right-0 mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg p-3 text-sm text-base-content/60"
|
||||||
|
>
|
||||||
|
No matching towns found. You can enter a new town name.
|
||||||
|
</div>
|
||||||
<span v-if="v$.CreateCustomerForm.basicInfo.customer_town.$error" class="text-red-500 text-xs mt-1">
|
<span v-if="v$.CreateCustomerForm.basicInfo.customer_town.$error" class="text-red-500 text-xs mt-1">
|
||||||
{{ v$.CreateCustomerForm.basicInfo.customer_town.$errors[0].$message }}
|
{{ v$.CreateCustomerForm.basicInfo.customer_town.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- State -->
|
<!-- State -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">State</span></label>
|
<label class="label"><span class="label-text">State</span></label>
|
||||||
<select v-model="CreateCustomerForm.basicInfo.customer_state" class="select select-bordered select-sm w-full">
|
<select v-model="CreateCustomerForm.basicInfo.customer_state" :class="selectClasses(v$.CreateCustomerForm.basicInfo.customer_state)">
|
||||||
<option v-for="state in stateList" :key="state.value" :value="state.value">
|
<option v-for="state in stateList" :key="state.value" :value="state.value">
|
||||||
{{ state.text }}
|
{{ state.text }}
|
||||||
</option>
|
</option>
|
||||||
@@ -115,10 +145,73 @@
|
|||||||
{{ v$.CreateCustomerForm.basicInfo.customer_state.$errors[0].$message }}
|
{{ v$.CreateCustomerForm.basicInfo.customer_state.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Zip Code -->
|
|
||||||
|
<!-- Street Address with Autocomplete -->
|
||||||
|
<div class="form-control relative">
|
||||||
|
<label class="label"><span class="label-text">Street Address</span></label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
v-model="CreateCustomerForm.basicInfo.customer_address"
|
||||||
|
type="text"
|
||||||
|
placeholder="Start typing your street address..."
|
||||||
|
:class="inputClasses(v$.CreateCustomerForm.basicInfo.customer_address)"
|
||||||
|
autocomplete="off"
|
||||||
|
@input="onAddressInput"
|
||||||
|
@keydown="(e) => handleStreetKeydown(e, selectStreet)"
|
||||||
|
@blur="closeStreetDropdown"
|
||||||
|
@focus="onAddressFocus"
|
||||||
|
/>
|
||||||
|
<!-- Loading indicator -->
|
||||||
|
<span v-if="isLoadingStreets" class="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Street Suggestions Dropdown -->
|
||||||
|
<div
|
||||||
|
v-if="showStreetDropdown && streetSuggestions.length > 0"
|
||||||
|
class="absolute z-50 top-full left-0 right-0 mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(suggestion, index) in streetSuggestions"
|
||||||
|
:key="suggestion.street_name + index"
|
||||||
|
class="px-3 py-2 cursor-pointer hover:bg-base-200 transition-colors"
|
||||||
|
:class="{ 'bg-base-200': index === highlightedStreetIndex }"
|
||||||
|
@mousedown.prevent="selectStreet(suggestion)"
|
||||||
|
>
|
||||||
|
<div class="font-medium">{{ suggestion.street_name }}</div>
|
||||||
|
<div class="text-xs text-base-content/60">
|
||||||
|
{{ suggestion.full_address }}
|
||||||
|
<span v-if="suggestion.zip" class="badge badge-xs badge-primary ml-1">{{ suggestion.zip }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Helper text -->
|
||||||
|
<p v-if="!CreateCustomerForm.basicInfo.customer_town && CreateCustomerForm.basicInfo.customer_address.length > 0" class="text-xs text-base-content/60 mt-1">
|
||||||
|
Enter a town first for address suggestions
|
||||||
|
</p>
|
||||||
|
<span v-if="v$.CreateCustomerForm.basicInfo.customer_address.$error" class="text-red-500 text-xs mt-1">
|
||||||
|
{{ v$.CreateCustomerForm.basicInfo.customer_address.$errors[0].$message }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Apt, Suite, etc. -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Zip Code</span></label>
|
<label class="label"><span class="label-text">Apt, Suite, etc. (Optional)</span></label>
|
||||||
<input v-model="CreateCustomerForm.basicInfo.customer_zip" type="text" placeholder="Zip Code" class="input input-bordered input-sm w-full" />
|
<input v-model="CreateCustomerForm.basicInfo.customer_apt" type="text" placeholder="Apt, suite, unit..." class="input input-bordered input-sm w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zip Code (Auto-populated) -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Zip Code</span>
|
||||||
|
<span v-if="zipAutoFilled" class="label-text-alt text-success">Auto-filled</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="CreateCustomerForm.basicInfo.customer_zip"
|
||||||
|
type="text"
|
||||||
|
placeholder="Zip Code"
|
||||||
|
:class="[inputClasses(v$.CreateCustomerForm.basicInfo.customer_zip), { 'input-success': zipAutoFilled && !v$.CreateCustomerForm.basicInfo.customer_zip.$error }]"
|
||||||
|
/>
|
||||||
<span v-if="v$.CreateCustomerForm.basicInfo.customer_zip.$error" class="text-red-500 text-xs mt-1">
|
<span v-if="v$.CreateCustomerForm.basicInfo.customer_zip.$error" class="text-red-500 text-xs mt-1">
|
||||||
{{ v$.CreateCustomerForm.basicInfo.customer_zip.$errors[0].$message }}
|
{{ v$.CreateCustomerForm.basicInfo.customer_zip.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
@@ -145,7 +238,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- SUBMIT BUTTON -->
|
<!-- SUBMIT BUTTON -->
|
||||||
<div class="pt-4">
|
<div class="pt-4">
|
||||||
<button type="submit" class="btn btn-primary btn-sm">Save Changes</button>
|
<button type="submit" class="btn btn-primary btn-sm">Save Changes</button>
|
||||||
@@ -157,20 +250,26 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { authService } from '../../services/authService'
|
import { authService } from '../../services/authService'
|
||||||
import { customerService } from '../../services/customerService'
|
import { customerService } from '../../services/customerService'
|
||||||
import { queryService } from '../../services/queryService'
|
import { queryService } from '../../services/queryService'
|
||||||
import { StateOption, HomeTypeOption } from '../../types/models'
|
import { StateOption, HomeTypeOption } from '../../types/models'
|
||||||
|
import { useTownAutocomplete, useStreetAutocomplete } from '../../composables/useAddressAutocomplete'
|
||||||
|
import { useFormValidation } from '../../composables/useFormValidation'
|
||||||
|
import { TownSuggestion, StreetSuggestion } from '../../services/addressService'
|
||||||
import useValidate from "@vuelidate/core";
|
import useValidate from "@vuelidate/core";
|
||||||
import { email, minLength, required } from "@vuelidate/validators";
|
import { email, minLength, required } from "@vuelidate/validators";
|
||||||
|
import { notify } from "@kyvg/vue3-notification";
|
||||||
|
|
||||||
|
const { inputClasses, selectClasses, validateForm } = useFormValidation();
|
||||||
|
|
||||||
// Reactive data
|
// Reactive data
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -178,6 +277,24 @@ const router = useRouter()
|
|||||||
const user = ref(null)
|
const user = ref(null)
|
||||||
const stateList = ref<StateOption[]>([])
|
const stateList = ref<StateOption[]>([])
|
||||||
const custList = ref<HomeTypeOption[]>([])
|
const custList = ref<HomeTypeOption[]>([])
|
||||||
|
|
||||||
|
// Track selected town for street autocomplete
|
||||||
|
const selectedTown = ref<TownSuggestion | null>(null)
|
||||||
|
const zipAutoFilled = ref(false)
|
||||||
|
|
||||||
|
// State ID to abbreviation mapping (matches backend STATE_MAPPING)
|
||||||
|
const STATE_ABBR_MAP: Record<number, string> = {
|
||||||
|
0: 'MA', // Default for unmapped
|
||||||
|
1: 'AL', 2: 'AK', 3: 'AS', 4: 'AZ', 5: 'AR', 6: 'CA', 7: 'CO', 8: 'CT',
|
||||||
|
9: 'DE', 10: 'DC', 11: 'FL', 12: 'GA', 13: 'GU', 14: 'HI', 15: 'ID',
|
||||||
|
16: 'IL', 17: 'IN', 18: 'IA', 19: 'KS', 20: 'KY', 21: 'LA', 22: 'ME',
|
||||||
|
23: 'MD', 24: 'MA', 25: 'MI', 26: 'MN', 27: 'MS', 28: 'MO', 29: 'MT',
|
||||||
|
30: 'NE', 31: 'NV', 32: 'NH', 33: 'NJ', 34: 'NM', 35: 'NY', 36: 'NC',
|
||||||
|
37: 'ND', 38: 'OH', 39: 'OK', 40: 'OR', 41: 'PA', 42: 'PR', 43: 'RI',
|
||||||
|
44: 'SC', 45: 'SD', 46: 'TN', 47: 'TX', 48: 'UT', 49: 'VT', 50: 'VA',
|
||||||
|
51: 'VI', 52: 'WA', 53: 'WV', 54: 'WI', 55: 'WY',
|
||||||
|
}
|
||||||
|
|
||||||
const customer = ref({
|
const customer = ref({
|
||||||
id: 0,
|
id: 0,
|
||||||
user_id: 0,
|
user_id: 0,
|
||||||
@@ -224,6 +341,27 @@ const CreateCustomerForm = ref({
|
|||||||
})
|
})
|
||||||
const renewalDate = ref("")
|
const renewalDate = ref("")
|
||||||
|
|
||||||
|
// Autocomplete composables
|
||||||
|
const {
|
||||||
|
townSuggestions,
|
||||||
|
isLoadingTowns,
|
||||||
|
showTownDropdown,
|
||||||
|
highlightedTownIndex,
|
||||||
|
searchTowns,
|
||||||
|
closeTownDropdown,
|
||||||
|
handleTownKeydown,
|
||||||
|
} = useTownAutocomplete()
|
||||||
|
|
||||||
|
const {
|
||||||
|
streetSuggestions,
|
||||||
|
isLoadingStreets,
|
||||||
|
showStreetDropdown,
|
||||||
|
highlightedStreetIndex,
|
||||||
|
searchStreets,
|
||||||
|
closeStreetDropdown,
|
||||||
|
handleStreetKeydown,
|
||||||
|
} = useStreetAutocomplete()
|
||||||
|
|
||||||
// Validation rules
|
// Validation rules
|
||||||
const rules = {
|
const rules = {
|
||||||
CreateCustomerForm: {
|
CreateCustomerForm: {
|
||||||
@@ -244,6 +382,97 @@ const rules = {
|
|||||||
// Vuelidate instance
|
// Vuelidate instance
|
||||||
const v$ = useValidate(rules, { CreateCustomerForm })
|
const v$ = useValidate(rules, { CreateCustomerForm })
|
||||||
|
|
||||||
|
// Town autocomplete handlers
|
||||||
|
const onTownInput = () => {
|
||||||
|
searchTowns(CreateCustomerForm.value.basicInfo.customer_town)
|
||||||
|
// Clear selected town when user types (they're changing it)
|
||||||
|
selectedTown.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTownFocus = () => {
|
||||||
|
if (CreateCustomerForm.value.basicInfo.customer_town.length >= 2) {
|
||||||
|
searchTowns(CreateCustomerForm.value.basicInfo.customer_town)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectTown = (suggestion: TownSuggestion) => {
|
||||||
|
CreateCustomerForm.value.basicInfo.customer_town = suggestion.town
|
||||||
|
CreateCustomerForm.value.basicInfo.customer_state = suggestion.state_id
|
||||||
|
selectedTown.value = suggestion
|
||||||
|
showTownDropdown.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get current town and state for street search
|
||||||
|
const getCurrentTownState = () => {
|
||||||
|
// If a town was selected from dropdown, use that
|
||||||
|
if (selectedTown.value) {
|
||||||
|
return { town: selectedTown.value.town, state: selectedTown.value.state }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, use whatever is typed in the town field
|
||||||
|
const townValue = CreateCustomerForm.value.basicInfo.customer_town.trim()
|
||||||
|
if (!townValue) return null
|
||||||
|
|
||||||
|
// Get state abbreviation from state ID using our mapping
|
||||||
|
const stateId = CreateCustomerForm.value.basicInfo.customer_state
|
||||||
|
const stateAbbr = STATE_ABBR_MAP[stateId] || 'MA'
|
||||||
|
|
||||||
|
return { town: townValue, state: stateAbbr }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address autocomplete handlers
|
||||||
|
const onAddressInput = () => {
|
||||||
|
const townState = getCurrentTownState()
|
||||||
|
if (townState && CreateCustomerForm.value.basicInfo.customer_address.length >= 1) {
|
||||||
|
searchStreets(
|
||||||
|
townState.town,
|
||||||
|
townState.state,
|
||||||
|
CreateCustomerForm.value.basicInfo.customer_address
|
||||||
|
)
|
||||||
|
}
|
||||||
|
zipAutoFilled.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const onAddressFocus = () => {
|
||||||
|
const townState = getCurrentTownState()
|
||||||
|
if (townState && CreateCustomerForm.value.basicInfo.customer_address.length >= 1) {
|
||||||
|
searchStreets(
|
||||||
|
townState.town,
|
||||||
|
townState.state,
|
||||||
|
CreateCustomerForm.value.basicInfo.customer_address
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectStreet = (suggestion: StreetSuggestion) => {
|
||||||
|
CreateCustomerForm.value.basicInfo.customer_address = suggestion.street_name
|
||||||
|
if (suggestion.zip) {
|
||||||
|
CreateCustomerForm.value.basicInfo.customer_zip = suggestion.zip
|
||||||
|
zipAutoFilled.value = true
|
||||||
|
// Flash effect for auto-filled zip
|
||||||
|
setTimeout(() => {
|
||||||
|
zipAutoFilled.value = false
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
showStreetDropdown.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for manual state changes to update selectedTown
|
||||||
|
watch(() => CreateCustomerForm.value.basicInfo.customer_state, (newState) => {
|
||||||
|
if (newState && CreateCustomerForm.value.basicInfo.customer_town) {
|
||||||
|
// Find the state abbreviation
|
||||||
|
const stateOption = stateList.value.find(s => s.value === newState)
|
||||||
|
if (stateOption) {
|
||||||
|
selectedTown.value = {
|
||||||
|
town: CreateCustomerForm.value.basicInfo.customer_town,
|
||||||
|
state: stateOption.text.split(' - ')[0] || stateOption.text,
|
||||||
|
state_id: newState,
|
||||||
|
customer_count: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Functions
|
// Functions
|
||||||
const acceptNumber = () => {
|
const acceptNumber = () => {
|
||||||
let x = CreateCustomerForm.value.basicInfo.customer_phone_number.replace(/\D/g, '').match(/(\d{0,3})(\d{0,3})(\d{0,4})/);
|
let x = CreateCustomerForm.value.basicInfo.customer_phone_number.replace(/\D/g, '').match(/(\d{0,3})(\d{0,3})(\d{0,4})/);
|
||||||
@@ -298,6 +527,19 @@ const getCustomer = (userid: number) => {
|
|||||||
if (data.customer_automatic === 0) {
|
if (data.customer_automatic === 0) {
|
||||||
CreateCustomerForm.value.basicInfo.customer_automatic = false
|
CreateCustomerForm.value.basicInfo.customer_automatic = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize selectedTown for existing customer data
|
||||||
|
if (data.customer_town && data.customer_state) {
|
||||||
|
const stateOption = stateList.value.find(s => s.value === data.customer_state)
|
||||||
|
if (stateOption) {
|
||||||
|
selectedTown.value = {
|
||||||
|
town: data.customer_town,
|
||||||
|
state: stateOption.text.split(' - ')[0] || stateOption.text,
|
||||||
|
state_id: data.customer_state,
|
||||||
|
customer_count: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -313,7 +555,10 @@ const editItem = (payload: any) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSubmit = () => {
|
const onSubmit = async () => {
|
||||||
|
const isValid = await validateForm(v$);
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
...CreateCustomerForm.value.basicInfo,
|
...CreateCustomerForm.value.basicInfo,
|
||||||
service_plan: CreateCustomerForm.value.servicePlan
|
service_plan: CreateCustomerForm.value.servicePlan
|
||||||
@@ -354,10 +599,11 @@ const renewContract = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
userStatus()
|
userStatus()
|
||||||
|
// Load states first so we can initialize selectedTown properly
|
||||||
|
await getStatesList();
|
||||||
|
await getCustomerTypeList();
|
||||||
getCustomer(route.params.id)
|
getCustomer(route.params.id)
|
||||||
getCustomerTypeList();
|
|
||||||
getStatesList();
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<!-- src/pages/customer/home.vue -->
|
<!-- src/pages/customer/home.vue -->
|
||||||
<template>
|
<template>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="w-full px-4 md:px-10 ">
|
<div class="w-full px-4 md:px-10 py-4">
|
||||||
<!-- Breadcrumbs & Title -->
|
<!-- Breadcrumbs & Title -->
|
||||||
<div class="text-sm breadcrumbs">
|
<div class="text-sm breadcrumbs">
|
||||||
<ul>
|
<ul>
|
||||||
@@ -9,87 +9,204 @@
|
|||||||
<li>Customers</li>
|
<li>Customers</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-3xl font-bold mt-4">Customers</h1>
|
|
||||||
|
|
||||||
<!-- Main Content Card -->
|
<!-- Page Header -->
|
||||||
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mt-4 mb-6">
|
||||||
<!-- Header: Search, Count, and Add Button -->
|
<div>
|
||||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-4">
|
<h1 class="text-2xl md:text-3xl font-bold flex items-center gap-3">
|
||||||
<!-- SEARCH AND COUNT (IMPROVED ALIGNMENT) -->
|
<div
|
||||||
<div class="form-control">
|
class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center shadow-lg">
|
||||||
<label class="label pt-1 pb-0">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
||||||
<span class="label-text-alt">{{ customer_count }} customers found</span>
|
stroke="currentColor" class="w-5 h-5 text-primary-content">
|
||||||
</label>
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
</div>
|
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
Customers
|
||||||
|
</h1>
|
||||||
|
<p class="text-base-content/60 mt-1 ml-13">Manage customer profiles and accounts</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<div class="stat-pill">
|
||||||
|
<span class="stat-pill-value">{{ customer_count }}</span>
|
||||||
|
<span class="stat-pill-label">Total Customers</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- DESKTOP VIEW: Table (Now breaks at XL) -->
|
<!-- Main Content Card -->
|
||||||
|
<div class="modern-table-card">
|
||||||
|
|
||||||
|
<!-- Search/Filter Placeholder -->
|
||||||
|
<div class="p-4 border-b border-base-content/5 flex justify-between items-center">
|
||||||
|
<div class="text-sm text-base-content/60">
|
||||||
|
Showing {{ customers.length }} records
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DESKTOP VIEW: Table -->
|
||||||
<div class="overflow-x-auto hidden xl:block">
|
<div class="overflow-x-auto hidden xl:block">
|
||||||
<table class="table w-full">
|
<table class="modern-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Account #</th>
|
<th>Customer Name</th>
|
||||||
<th>Name</th>
|
<th>Address</th>
|
||||||
<th>Town</th>
|
<th>Map</th>
|
||||||
<th>Automatic</th>
|
<th>Status</th>
|
||||||
<th>Phone Number</th>
|
<th>Phone</th>
|
||||||
|
<th class="text-right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="person in customers" :key="person.id" class="hover:bg-blue-600 hover:text-white">
|
<tr v-for="person in customers" :key="person.id" class="table-row-hover">
|
||||||
<td>
|
<td>
|
||||||
<router-link v-if="person.id" :to="{ name: 'customerProfile', params: { id: person.id } }" class="link link-hover">
|
<router-link v-if="person.id" :to="{ name: 'customerProfile', params: { id: person.id } }"
|
||||||
{{ person.account_number }}
|
class="group">
|
||||||
|
<div class="font-bold text-base group-hover:text-primary transition-colors">
|
||||||
|
{{ person.customer_first_name }} {{ person.customer_last_name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs font-mono opacity-60 flex items-center gap-1">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||||
|
stroke="currentColor" class="w-3 h-3">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||||
|
</svg>
|
||||||
|
#{{ person.account_number }}
|
||||||
|
</div>
|
||||||
</router-link>
|
</router-link>
|
||||||
<span v-else>{{ person.account_number }}</span>
|
<div v-else>
|
||||||
|
<div class="font-bold text-base">{{ person.customer_first_name }} {{ person.customer_last_name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs opacity-60">#{{ person.account_number }}</div>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<router-link v-if="person.id" :to="{ name: 'customerProfile', params: { id: person.id } }" class="link link-hover hover:text-green-500">{{ person.customer_first_name }} {{ person.customer_last_name }}</router-link>
|
<div class="flex flex-col">
|
||||||
<span v-else>{{ person.customer_first_name }} {{ person.customer_last_name }}</span>
|
<span class="font-medium text-sm">{{ person.customer_address }}</span>
|
||||||
|
<span class="text-xs opacity-70">{{ person.customer_town }}, {{ getStateAbbr(person.customer_state)
|
||||||
|
}} {{ person.customer_zip }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a :href="`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${person.customer_address}, ${person.customer_town}, ${getStateAbbr(person.customer_state)}, ${person.customer_zip}`)}`"
|
||||||
|
target="_blank"
|
||||||
|
class="btn btn-xs btn-circle btn-ghost text-base-content/60 hover:text-primary hover:bg-primary/10 border border-base-content/10"
|
||||||
|
title="View on Google Maps">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||||
|
stroke="currentColor" class="w-4 h-4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-sm font-medium"
|
||||||
|
:class="person.customer_automatic ? 'badge-success text-white' : 'badge-ghost opacity-70'">
|
||||||
|
{{ person.customer_automatic ? 'Automatic' : 'Will Call' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="font-mono text-sm">{{ person.customer_phone_number }}</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<div class="flex items-center justify-end gap-1">
|
||||||
|
<router-link :to="{ name: 'deliveryCreate', params: { id: person.id } }"
|
||||||
|
class="btn btn-xs btn-warning btn-outline" title="New Delivery">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||||
|
stroke="currentColor" class="w-3 h-3">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M15.59 14.37a6 6 0 01-5.84 7.38v-4.8m5.84-2.58a14.98 14.98 0 006.16-12.12A14.98 14.98 0 009.631 8.41m5.96 5.96a14.926 14.926 0 01-5.841 2.58m-.119-8.54a6 6 0 00-7.381 5.84h4.8m2.581-5.84a14.927 14.927 0 00-2.58 5.84m2.699 2.7c-.103.021-.207.041-.311.06a15.09 15.09 0 01-2.448-2.448 14.9 14.9 0 01.06-.312m-2.24 2.39a4.493 4.493 0 00-1.757 4.306 4.493 4.493 0 004.306-1.758M16.5 9a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0z" />
|
||||||
|
</svg>
|
||||||
|
<span class="hidden 2xl:inline ml-1">Deliv</span>
|
||||||
|
</router-link>
|
||||||
|
|
||||||
|
<router-link :to="{ name: 'CalenderCustomer', params: { id: person.id } }"
|
||||||
|
class="btn btn-xs btn-accent btn-outline" title="New Service">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||||
|
stroke="currentColor" class="w-3 h-3">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 11-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 004.486-6.336l-3.276 3.277a3.004 3.004 0 01-2.25-2.25l3.276-3.276a4.5 4.5 0 00-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437l1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008z" />
|
||||||
|
</svg>
|
||||||
|
<span class="hidden 2xl:inline ml-1">Svc</span>
|
||||||
|
</router-link>
|
||||||
|
|
||||||
|
<router-link :to="{ name: 'customerProfile', params: { id: person.id } }"
|
||||||
|
class="btn btn-xs btn-success btn-outline">View</router-link>
|
||||||
|
<router-link :to="{ name: 'customerEdit', params: { id: person.id } }"
|
||||||
|
class="btn btn-xs btn-info btn-outline">Edit</router-link>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ person.customer_town }}</td>
|
|
||||||
<td><span :class="person.customer_automatic ? 'text-success' : 'text-gray-500'">{{ person.customer_automatic ? 'Yes' : 'No' }}</span></td>
|
|
||||||
<td>{{ person.customer_phone_number }}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- MOBILE VIEW: Cards (Now breaks at XL) -->
|
<!-- MOBILE VIEW: Cards -->
|
||||||
<div class="xl:hidden space-y-4">
|
<div class="xl:hidden space-y-4 px-4 pb-4 pt-4">
|
||||||
<div v-for="person in customers" :key="person.id" class="card bg-base-100 shadow-md">
|
<div v-for="person in customers" :key="person.id" class="mobile-card">
|
||||||
<div class="card-body p-4">
|
<div class="p-3">
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<router-link v-if="person.id" :to="{ name: 'customerProfile', params: { id: person.id } }" class="hover:text-green-500">
|
<router-link v-if="person.id" :to="{ name: 'customerProfile', params: { id: person.id } }"
|
||||||
<h2 class="card-title text-base">{{ person.customer_first_name }} {{ person.customer_last_name }}</h2>
|
class="font-bold text-base link link-hover text-primary">
|
||||||
|
{{ person.customer_first_name }} {{ person.customer_last_name }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<h2 v-else class="card-title text-base">{{ person.customer_first_name }} {{ person.customer_last_name }}</h2>
|
<div v-else class="font-bold text-base">{{ person.customer_first_name }} {{ person.customer_last_name
|
||||||
<p class="text-xs text-gray-400">#{{ person.account_number }}</p>
|
}}</div>
|
||||||
|
|
||||||
|
<div class="text-xs text-base-content/60 flex items-center gap-1 mt-0.5">
|
||||||
|
Account #{{ person.account_number }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="badge" :class="person.customer_automatic ? 'badge-success' : 'badge-ghost'">
|
<span class="badge badge-sm"
|
||||||
{{ person.customer_automatic ? 'Automatic' : 'Will Call' }}
|
:class="person.customer_automatic ? 'badge-success text-white' : 'badge-ghost opacity-70'">
|
||||||
|
{{ person.customer_automatic ? 'Auto' : 'Will Call' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-sm mt-3 grid grid-cols-1 gap-y-2">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||||
|
stroke="currentColor" class="w-4 h-4 mt-0.5 opacity-50">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p>{{ person.customer_address }}</p>
|
||||||
|
<p class="text-xs opacity-70">{{ person.customer_town }}, {{ getStateAbbr(person.customer_state) }}
|
||||||
|
{{ person.customer_zip }}</p>
|
||||||
|
<a :href="`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${person.customer_address}, ${person.customer_town}, ${getStateAbbr(person.customer_state)}, ${person.customer_zip}`)}`"
|
||||||
|
target="_blank" class="link link-primary text-xs mt-1 block">
|
||||||
|
Open in Maps
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||||
|
stroke="currentColor" class="w-4 h-4 opacity-50">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z" />
|
||||||
|
</svg>
|
||||||
|
<span>{{ person.customer_phone_number }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm mt-2">
|
|
||||||
<p>{{ person.customer_town }}</p>
|
<div class="flex gap-2 pt-3 mt-3 border-t border-base-content/10 flex-wrap">
|
||||||
<p>{{ person.customer_phone_number }}</p>
|
<router-link :to="{ name: 'deliveryCreate', params: { id: person.id } }"
|
||||||
</div>
|
class="btn btn-sm btn-warning btn-outline flex-1">
|
||||||
<div v-if="person.id" class="card-actions justify-end flex-wrap gap-2 mt-2">
|
Delivery
|
||||||
<router-link :to="{ name: 'deliveryCreate', params: { id: person.id } }" class="btn btn-sm btn-primary">
|
|
||||||
New Delivery
|
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link :to="{ name: 'CalenderCustomer', params: { id: person.id } }" class="btn btn-sm btn-accent">
|
<router-link :to="{ name: 'CalenderCustomer', params: { id: person.id } }"
|
||||||
New Service
|
class="btn btn-sm btn-accent flex-1">
|
||||||
|
Service
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link :to="{ name: 'customerEdit', params: { id: person.id } }" class="btn btn-sm btn-secondary">
|
<router-link :to="{ name: 'customerEdit', params: { id: person.id } }"
|
||||||
|
class="btn btn-sm btn-info btn-outline flex-1">
|
||||||
Edit
|
Edit
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link :to="{ name: 'customerProfile', params: { id: person.id } }" class="btn btn-sm btn-ghost">
|
<router-link :to="{ name: 'customerProfile', params: { id: person.id } }"
|
||||||
|
class="btn btn-sm btn-success btn-outline flex-1">
|
||||||
View
|
View
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,7 +214,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
<div class="mt-6 flex justify-center">
|
<div class="mt-6 flex justify-center">
|
||||||
<pagination @paginate="getPage" :records="customer_count" v-model="page" :per-page="10" :options="options">
|
<pagination @paginate="getPage" :records="customer_count" v-model="page" :per-page="10" :options="options">
|
||||||
@@ -106,7 +223,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, markRaw } from 'vue'
|
import { ref, onMounted, markRaw } from 'vue'
|
||||||
@@ -131,6 +248,23 @@ const options = ref({
|
|||||||
template: markRaw(PaginationComp)
|
template: markRaw(PaginationComp)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// State Mapping
|
||||||
|
const STATE_ABBR_MAP: Record<number, string> = {
|
||||||
|
0: 'MA', // Default for unmapped
|
||||||
|
1: 'AL', 2: 'AK', 3: 'AS', 4: 'AZ', 5: 'AR', 6: 'CA', 7: 'CO', 8: 'CT',
|
||||||
|
9: 'DE', 10: 'DC', 11: 'FL', 12: 'GA', 13: 'GU', 14: 'HI', 15: 'ID',
|
||||||
|
16: 'IL', 17: 'IN', 18: 'IA', 19: 'KS', 20: 'KY', 21: 'LA', 22: 'ME',
|
||||||
|
23: 'MD', 24: 'MA', 25: 'MI', 26: 'MN', 27: 'MS', 28: 'MO', 29: 'MT',
|
||||||
|
30: 'NE', 31: 'NV', 32: 'NH', 33: 'NJ', 34: 'NM', 35: 'NY', 36: 'NC',
|
||||||
|
37: 'ND', 38: 'OH', 39: 'OK', 40: 'OR', 41: 'PA', 42: 'PR', 43: 'RI',
|
||||||
|
44: 'SC', 45: 'SD', 46: 'TN', 47: 'TX', 48: 'UT', 49: 'VT', 50: 'VA',
|
||||||
|
51: 'VI', 52: 'WA', 53: 'WV', 54: 'WI', 55: 'WY',
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStateAbbr = (stateId: number): string => {
|
||||||
|
return STATE_ABBR_MAP[stateId] || 'MA';
|
||||||
|
}
|
||||||
|
|
||||||
// Functions
|
// Functions
|
||||||
const getPage = (pageVal: any) => {
|
const getPage = (pageVal: any) => {
|
||||||
customers.value = [];
|
customers.value = [];
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<label class="label"><span class="label-text font-bold">Gallons Ordered</span></label>
|
<label class="label"><span class="label-text font-bold">Gallons Ordered</span></label>
|
||||||
<input v-model="formDelivery.gallons_ordered" :disabled="formDelivery.customer_asked_for_fill"
|
<input v-model="formDelivery.gallons_ordered" :disabled="formDelivery.customer_asked_for_fill"
|
||||||
class="input input-bordered input-sm w-full max-w-xs" type="number" placeholder="# gallons" />
|
:class="inputClasses(v$.formDelivery.gallons_ordered, 'input input-bordered input-sm w-full max-w-xs')" type="number" placeholder="# gallons" />
|
||||||
<div class="flex flex-wrap gap-2 mt-2">
|
<div class="flex flex-wrap gap-2 mt-2">
|
||||||
<button v-for="amount in quickGallonAmounts"
|
<button v-for="amount in quickGallonAmounts"
|
||||||
:key="amount"
|
:key="amount"
|
||||||
@@ -132,7 +132,7 @@
|
|||||||
<!-- Date, Driver, Promo -->
|
<!-- Date, Driver, Promo -->
|
||||||
<div>
|
<div>
|
||||||
<label class="label"><span class="label-text font-bold">Expected Delivery Date</span></label>
|
<label class="label"><span class="label-text font-bold">Expected Delivery Date</span></label>
|
||||||
<input v-model="formDelivery.expected_delivery_date" class="input input-bordered input-sm w-full max-w-xs" type="date" />
|
<input v-model="formDelivery.expected_delivery_date" :class="inputClasses(v$.formDelivery.expected_delivery_date, 'input input-bordered input-sm w-full max-w-xs')" type="date" />
|
||||||
<div class="flex flex-wrap gap-2 mt-2">
|
<div class="flex flex-wrap gap-2 mt-2">
|
||||||
<button @click.prevent="setDeliveryDate(1)" :class="['btn', 'btn-xs', isDeliveryDateSelected(1) ? 'bg-blue-600 text-white border-blue-600' : 'btn-outline']">Tomorrow</button>
|
<button @click.prevent="setDeliveryDate(1)" :class="['btn', 'btn-xs', isDeliveryDateSelected(1) ? 'bg-blue-600 text-white border-blue-600' : 'btn-outline']">Tomorrow</button>
|
||||||
|
|
||||||
@@ -248,17 +248,17 @@
|
|||||||
<!-- --- MODIFICATION --- This form now correctly creates a secure tokenized card -->
|
<!-- --- MODIFICATION --- This form now correctly creates a secure tokenized card -->
|
||||||
<div>
|
<div>
|
||||||
<label class="label py-1"><span class="label-text">Name on Card</span></label>
|
<label class="label py-1"><span class="label-text">Name on Card</span></label>
|
||||||
<input v-model="formCard.card_name" type="text" class="input input-bordered input-sm w-full" />
|
<input v-model="formCard.card_name" type="text" :class="inputClasses(v$.formCard.card_name)" />
|
||||||
<span v-if="v$.formCard.card_name.$error" class="text-red-500 text-xs mt-1">Required</span>
|
<span v-if="v$.formCard.card_name.$error" class="text-red-500 text-xs mt-1">Required</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="label py-1"><span class="label-text">Card Number</span></label>
|
<label class="label py-1"><span class="label-text">Card Number</span></label>
|
||||||
<input v-model="formCard.card_number" type="text" class="input input-bordered input-sm w-full" />
|
<input v-model="formCard.card_number" type="text" :class="inputClasses(v$.formCard.card_number)" />
|
||||||
<span v-if="v$.formCard.card_number.$error" class="text-red-500 text-xs mt-1">Required</span>
|
<span v-if="v$.formCard.card_number.$error" class="text-red-500 text-xs mt-1">Required</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="label py-1"><span class="label-text">Card Type</span></label>
|
<label class="label py-1"><span class="label-text">Card Type</span></label>
|
||||||
<select v-model="formCard.type_of_card" class="select select-bordered select-sm w-full">
|
<select v-model="formCard.type_of_card" :class="selectClasses(v$.formCard.type_of_card)">
|
||||||
<option disabled value="">Select Type</option>
|
<option disabled value="">Select Type</option>
|
||||||
<option>Visa</option>
|
<option>Visa</option>
|
||||||
<option>MasterCard</option>
|
<option>MasterCard</option>
|
||||||
@@ -277,7 +277,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="md:col-span-1">
|
<div class="md:col-span-1">
|
||||||
<label class="label py-1"><span class="label-text">Security Number</span></label>
|
<label class="label py-1"><span class="label-text">Security Number</span></label>
|
||||||
<input v-model="formCard.security_number" type="text" class="input input-bordered input-sm w-full" />
|
<input v-model="formCard.security_number" type="text" :class="inputClasses(v$.formCard.security_number)" />
|
||||||
<span v-if="v$.formCard.security_number.$error" class="text-red-500 text-xs mt-1">Required</span>
|
<span v-if="v$.formCard.security_number.$error" class="text-red-500 text-xs mt-1">Required</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -316,7 +316,10 @@ import SideBar from '../../layouts/sidebar/sidebar.vue'
|
|||||||
import { useVuelidate } from "@vuelidate/core";
|
import { useVuelidate } from "@vuelidate/core";
|
||||||
import { notify } from "@kyvg/vue3-notification"
|
import { notify } from "@kyvg/vue3-notification"
|
||||||
import { minLength, required, requiredIf } from "@vuelidate/validators";
|
import { minLength, required, requiredIf } from "@vuelidate/validators";
|
||||||
|
import { useFormValidation } from '../../composables/useFormValidation';
|
||||||
import deliveryService from '../../services/deliveryService';
|
import deliveryService from '../../services/deliveryService';
|
||||||
|
|
||||||
|
const { inputClasses, selectClasses, validateForm } = useFormValidation();
|
||||||
import customerService from '../../services/customerService';
|
import customerService from '../../services/customerService';
|
||||||
import paymentService from '../../services/paymentService';
|
import paymentService from '../../services/paymentService';
|
||||||
import adminService from '../../services/adminService';
|
import adminService from '../../services/adminService';
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<label class="label"><span class="label-text font-bold">Gallons Ordered</span></label>
|
<label class="label"><span class="label-text font-bold">Gallons Ordered</span></label>
|
||||||
<input v-model="CreateOilOrderForm.basicInfo.gallons_ordered" :disabled="CreateOilOrderForm.basicInfo.customer_asked_for_fill"
|
<input v-model="CreateOilOrderForm.basicInfo.gallons_ordered" :disabled="CreateOilOrderForm.basicInfo.customer_asked_for_fill"
|
||||||
class="input input-bordered input-sm w-full max-w-xs" type="number" placeholder="# gallons" />
|
:class="inputClasses(v$.CreateOilOrderForm.basicInfo.gallons_ordered, 'input input-bordered input-sm w-full max-w-xs')" type="number" placeholder="# gallons" />
|
||||||
<div class="flex flex-wrap gap-2 mt-2">
|
<div class="flex flex-wrap gap-2 mt-2">
|
||||||
<button v-for="amount in quickGallonAmounts" :key="amount" @click.prevent="setGallons(amount)" :class="['btn', 'btn-xs', selectedGallonsAmount == amount ? 'bg-blue-600 text-white border-blue-600' : 'btn-outline']">{{ amount }} gal</button>
|
<button v-for="amount in quickGallonAmounts" :key="amount" @click.prevent="setGallons(amount)" :class="['btn', 'btn-xs', selectedGallonsAmount == amount ? 'bg-blue-600 text-white border-blue-600' : 'btn-outline']">{{ amount }} gal</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,7 +126,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="label"><span class="label-text font-bold">Expected Delivery Date</span></label>
|
<label class="label"><span class="label-text font-bold">Expected Delivery Date</span></label>
|
||||||
<input v-model="CreateOilOrderForm.basicInfo.expected_delivery_date" type="date" class="input input-bordered input-sm w-full" />
|
<input v-model="CreateOilOrderForm.basicInfo.expected_delivery_date" type="date" :class="inputClasses(v$.CreateOilOrderForm.basicInfo.expected_delivery_date)" />
|
||||||
<div class="flex flex-wrap gap-2 mt-2">
|
<div class="flex flex-wrap gap-2 mt-2">
|
||||||
<button @click.prevent="setDeliveryDate(0)" :class="['btn', 'btn-xs', isDeliveryDateSelected(0) ? 'bg-blue-600 text-white border-blue-600' : 'btn-outline']">Today</button>
|
<button @click.prevent="setDeliveryDate(0)" :class="['btn', 'btn-xs', isDeliveryDateSelected(0) ? 'bg-blue-600 text-white border-blue-600' : 'btn-outline']">Today</button>
|
||||||
<button @click.prevent="setDeliveryDate(1)" :class="['btn', 'btn-xs', isDeliveryDateSelected(1) ? 'bg-blue-600 text-white border-blue-600' : 'btn-outline']">Tomorrow</button>
|
<button @click.prevent="setDeliveryDate(1)" :class="['btn', 'btn-xs', isDeliveryDateSelected(1) ? 'bg-blue-600 text-white border-blue-600' : 'btn-outline']">Tomorrow</button>
|
||||||
@@ -249,7 +249,10 @@ import SideBar from '../../layouts/sidebar/sidebar.vue'
|
|||||||
import { useVuelidate } from "@vuelidate/core";
|
import { useVuelidate } from "@vuelidate/core";
|
||||||
import { required, requiredIf } from "@vuelidate/validators";
|
import { required, requiredIf } from "@vuelidate/validators";
|
||||||
import { notify } from "@kyvg/vue3-notification";
|
import { notify } from "@kyvg/vue3-notification";
|
||||||
|
import { useFormValidation } from '../../composables/useFormValidation';
|
||||||
import deliveryService from '../../services/deliveryService';
|
import deliveryService from '../../services/deliveryService';
|
||||||
|
|
||||||
|
const { inputClasses, selectClasses } = useFormValidation();
|
||||||
import customerService from '../../services/customerService';
|
import customerService from '../../services/customerService';
|
||||||
import paymentService from '../../services/paymentService';
|
import paymentService from '../../services/paymentService';
|
||||||
import adminService from '../../services/adminService';
|
import adminService from '../../services/adminService';
|
||||||
|
|||||||
@@ -10,28 +10,41 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content Card -->
|
<!-- Page Header with Stats -->
|
||||||
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mt-4 mb-6">
|
||||||
<!-- Header: Search and Stats -->
|
<div>
|
||||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-6 mb-4">
|
<h1 class="text-2xl md:text-3xl font-bold flex items-center gap-3">
|
||||||
<div class="form-control">
|
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center shadow-lg">
|
||||||
<h2 class="text-lg font-bold">Deliveries </h2>
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-primary-content">
|
||||||
</div>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 00-10.026 0 1.106 1.106 0 00-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12" />
|
||||||
<!-- Today's Stats Card -->
|
</svg>
|
||||||
<div class="stats stats-vertical sm:stats-horizontal shadow bg-base-100 text-center text-sm">
|
|
||||||
<div class="stat p-3">
|
|
||||||
<div class="stat-title text-xs"> Deliveries</div>
|
|
||||||
<div class="stat-value text-lg">{{ delivery_count }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
Delivery Overview
|
||||||
</div>
|
</h1>
|
||||||
|
<p class="text-base-content/60 mt-1 ml-13">Recent delivery activity</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider">Recent Deliveries</div>
|
<!-- Quick Stats -->
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<div class="stat-pill">
|
||||||
|
<span class="stat-pill-value">{{ delivery_count }}</span>
|
||||||
|
<span class="stat-pill-label">Total Today</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-pill stat-pill-success">
|
||||||
|
<span class="stat-pill-value">{{ delivery_count_delivered }}</span>
|
||||||
|
<span class="stat-pill-label">Delivered</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Table Card -->
|
||||||
|
<div class="modern-table-card">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- DESKTOP VIEW: Table -->
|
<!-- DESKTOP VIEW: Table -->
|
||||||
<div class="overflow-x-auto hidden xl:block">
|
<div class="hidden xl:block overflow-x-auto">
|
||||||
<table class="table w-full">
|
<table class="modern-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Delivery #</th>
|
<th>Delivery #</th>
|
||||||
@@ -76,8 +89,8 @@
|
|||||||
<div class="text-xs opacity-70">{{ oil.customer_address }}</div>
|
<div class="text-xs opacity-70">{{ oil.customer_address }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span v-if="oil.customer_asked_for_fill == 1" class="badge badge-info">FILL</span>
|
<span v-if="oil.customer_asked_for_fill == 1" class="badge badge-info text-lg h-auto py-1">FILL</span>
|
||||||
<span v-else>{{ oil.gallons_ordered }}</span>
|
<span v-else class="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">{{ oil.gallons_ordered }} gal</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ oil.expected_delivery_date }}</td>
|
<td>{{ oil.expected_delivery_date }}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -95,11 +108,11 @@
|
|||||||
<span v-else-if="oil.payment_type == 4">Other</span>
|
<span v-else-if="oil.payment_type == 4">Other</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<div class="flex items-center justify-end gap-2">
|
<div class="flex items-center justify-end gap-1">
|
||||||
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-sm btn-ghost">View</router-link>
|
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-xs btn-ghost">View</router-link>
|
||||||
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-sm btn-secondary">Edit</router-link>
|
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-xs btn-info btn-outline">Edit</router-link>
|
||||||
<router-link :to="{ name: 'finalizeTicket', params: { id: oil.id } }" v-if="!isFinalizedStatus(oil.delivery_status)" class="btn btn-sm btn-accent">Finalize</router-link>
|
<router-link :to="{ name: 'finalizeTicket', params: { id: oil.id } }" v-if="!isFinalizedStatus(oil.delivery_status)" class="btn btn-xs btn-accent btn-outline">Finalize</router-link>
|
||||||
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-sm btn-success">Print</router-link>
|
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-xs btn-success btn-outline">Print</router-link>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -111,26 +124,34 @@
|
|||||||
<!-- MOBILE VIEW: Cards -->
|
<!-- MOBILE VIEW: Cards -->
|
||||||
<div class="xl:hidden space-y-4">
|
<div class="xl:hidden space-y-4">
|
||||||
<template v-for="oil in deliveries" :key="oil.id">
|
<template v-for="oil in deliveries" :key="oil.id">
|
||||||
<div v-if="oil.id" class="card bg-base-100 shadow-md">
|
<div
|
||||||
<div class="card-body p-4">
|
v-if="oil.id"
|
||||||
|
class="mobile-card"
|
||||||
|
:class="{
|
||||||
|
'mobile-card-urgent': oil.emergency,
|
||||||
|
'mobile-card-prime': oil.prime && !oil.emergency,
|
||||||
|
'mobile-card-sameday': oil.same_day && !oil.prime && !oil.emergency
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="p-3">
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="card-title text-base">{{ oil.customer_name }}</h2>
|
<h2 class="text-base font-bold">{{ oil.customer_name }}</h2>
|
||||||
<p class="text-xs text-gray-400">Delivery #{{ oil.id }}</p>
|
<p class="text-xs text-base-content/60">Delivery #{{ oil.id }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="badge" :class="{
|
<div class="badge badge-sm" :class="{
|
||||||
'badge-warning': oil.delivery_status === computedDELIVERY_STATUS.WAITING,
|
'badge-warning': oil.delivery_status === computedDELIVERY_STATUS.WAITING,
|
||||||
'badge-success': [computedDELIVERY_STATUS.DELIVERED, computedDELIVERY_STATUS.FINALIZED].includes(oil.delivery_status as any),
|
'badge-success': [computedDELIVERY_STATUS.DELIVERED, computedDELIVERY_STATUS.FINALIZED].includes(oil.delivery_status as any),
|
||||||
'badge-info': oil.delivery_status === computedDELIVERY_STATUS.OUT_FOR_DELIVERY,
|
'badge-info': oil.delivery_status === computedDELIVERY_STATUS.OUT_FOR_DELIVERY,
|
||||||
'badge-error': [computedDELIVERY_STATUS.TOMORROW, computedDELIVERY_STATUS.ISSUE].includes(oil.delivery_status as any),
|
'badge-error': [computedDELIVERY_STATUS.TOMORROW, computedDELIVERY_STATUS.ISSUE].includes(oil.delivery_status as any),
|
||||||
}">
|
}">
|
||||||
<span v-if="oil.delivery_status === computedDELIVERY_STATUS.WAITING">Waiting</span>
|
<span v-if="oil.delivery_status === computedDELIVERY_STATUS.WAITING">Waiting</span>
|
||||||
<span v-else-if="oil.delivery_status === computedDELIVERY_STATUS.DELIVERED">Delivered</span>
|
<span v-else-if="oil.delivery_status === computedDELIVERY_STATUS.DELIVERED">Delivered</span>
|
||||||
<span v-else-if="oil.delivery_status === computedDELIVERY_STATUS.OUT_FOR_DELIVERY">Today_Delivery</span>
|
<span v-else-if="oil.delivery_status === computedDELIVERY_STATUS.OUT_FOR_DELIVERY">Today</span>
|
||||||
<span v-else-if="oil.delivery_status === computedDELIVERY_STATUS.TOMORROW">Tommorrow_Delivery</span>
|
<span v-else-if="oil.delivery_status === computedDELIVERY_STATUS.TOMORROW">Tomorrow</span>
|
||||||
<span v-else-if="oil.delivery_status === computedDELIVERY_STATUS.PARTIAL_DELIVERY">Partial Delivery</span>
|
<span v-else-if="oil.delivery_status === computedDELIVERY_STATUS.PARTIAL_DELIVERY">Partial</span>
|
||||||
<span v-else-if="oil.delivery_status === computedDELIVERY_STATUS.ISSUE">Issue</span>
|
<span v-else-if="oil.delivery_status === computedDELIVERY_STATUS.ISSUE">Issue</span>
|
||||||
<span v-else-if="oil.delivery_status === computedDELIVERY_STATUS.FINALIZED">Finalized</span>
|
<span v-else-if="oil.delivery_status === computedDELIVERY_STATUS.FINALIZED">Finalized</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -140,21 +161,30 @@
|
|||||||
<div v-if="oil.emergency" class="badge badge-error badge-sm">EMERGENCY</div>
|
<div v-if="oil.emergency" class="badge badge-error badge-sm">EMERGENCY</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
|
<div class="text-sm mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
|
||||||
<p><strong class="font-semibold">Address:</strong> {{ oil.customer_address }}</p>
|
<div>
|
||||||
<p><strong class="font-semibold">Town:</strong> {{ oil.customer_town }}</p>
|
<p class="text-xs text-base-content/50">Address</p>
|
||||||
<p><strong class="font-semibold">Date:</strong> {{ oil.expected_delivery_date }}</p>
|
<p class="font-medium">{{ oil.customer_address }}</p>
|
||||||
<p><strong class="font-semibold">Gallons:</strong>
|
<p class="text-xs">{{ oil.customer_town }}</p>
|
||||||
<span v-if="oil.customer_asked_for_fill" class="badge badge-info badge-xs">FILL</span>
|
</div>
|
||||||
<span v-else>{{ oil.gallons_ordered }}</span>
|
<div>
|
||||||
</p>
|
<p class="text-xs text-base-content/50">Gallons</p>
|
||||||
|
<p class="font-bold text-lg text-success">
|
||||||
|
<span v-if="oil.customer_asked_for_fill" class="badge badge-info badge-xs">FILL</span>
|
||||||
|
<span v-else>{{ oil.gallons_ordered }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-base-content/50">Date</p>
|
||||||
|
<p class="font-medium">{{ oil.expected_delivery_date }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-actions justify-end flex-wrap gap-2 mt-2">
|
<div class="flex gap-2 pt-3 mt-3 border-t border-base-content/10 flex-wrap">
|
||||||
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-sm btn-ghost">View</router-link>
|
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-sm btn-ghost flex-1">View</router-link>
|
||||||
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-sm btn-secondary">Edit</router-link>
|
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-sm btn-info btn-outline flex-1">Edit</router-link>
|
||||||
<router-link :to="{ name: 'finalizeTicket', params: { id: oil.id } }" v-if="!isFinalizedStatus(oil.delivery_status)" class="btn btn-sm btn-accent">Finalize</router-link>
|
<router-link :to="{ name: 'finalizeTicket', params: { id: oil.id } }" v-if="!isFinalizedStatus(oil.delivery_status)" class="btn btn-sm btn-accent btn-outline flex-1">Finalize</router-link>
|
||||||
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-sm btn-success">Print</router-link>
|
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-sm btn-success btn-outline flex-1">Print</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -294,4 +324,187 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped>
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
271
src/pages/delivery/map.vue
Normal file
271
src/pages/delivery/map.vue
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
<!-- src/pages/delivery/map.vue -->
|
||||||
|
<template>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="w-full px-4 md:px-10 py-4">
|
||||||
|
<!-- Breadcrumbs & Title -->
|
||||||
|
<div class="text-sm breadcrumbs">
|
||||||
|
<ul>
|
||||||
|
<li><router-link :to="{ name: 'home' }">Home</router-link></li>
|
||||||
|
<li><router-link :to="{ name: 'delivery' }">Delivery</router-link></li>
|
||||||
|
<li>Delivery Map</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content Card -->
|
||||||
|
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
|
||||||
|
<!-- Header: Date Picker -->
|
||||||
|
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-4">
|
||||||
|
<h2 class="text-lg font-bold">Delivery Map</h2>
|
||||||
|
<div class="form-control">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
v-model="selectedDate"
|
||||||
|
@change="fetchDeliveries"
|
||||||
|
class="input input-bordered w-full max-w-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<!-- Stats Bar -->
|
||||||
|
<div v-if="!loading && deliveries.length > 0" class="mb-4 flex flex-wrap gap-2">
|
||||||
|
<span class="badge badge-primary">{{ deliveries.length }} Deliveries</span>
|
||||||
|
<span class="badge badge-secondary">{{ uniqueTowns.length }} Towns</span>
|
||||||
|
<span class="badge badge-accent">{{ mappedCount }} Mapped</span>
|
||||||
|
<span v-if="unmappedCount > 0" class="badge badge-warning">{{ unmappedCount }} Without Coordinates</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="loading" class="flex justify-center items-center py-20">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
<div v-else-if="error" class="alert alert-error">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-else-if="deliveries.length === 0" class="text-center py-20">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-2 text-sm font-medium">No deliveries found</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">No deliveries scheduled for {{ formatDate(selectedDate) }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Map and List -->
|
||||||
|
<div v-else>
|
||||||
|
<!-- Map Container -->
|
||||||
|
<div class="rounded-lg overflow-hidden mb-6" style="height: 500px;">
|
||||||
|
<l-map
|
||||||
|
ref="map"
|
||||||
|
v-model:zoom="zoom"
|
||||||
|
:center="mapCenter"
|
||||||
|
:use-global-leaflet="false"
|
||||||
|
>
|
||||||
|
<l-tile-layer
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
layer-type="base"
|
||||||
|
name="OpenStreetMap"
|
||||||
|
></l-tile-layer>
|
||||||
|
<l-marker
|
||||||
|
v-for="delivery in mappedDeliveries"
|
||||||
|
:key="delivery.id"
|
||||||
|
:lat-lng="[parseFloat(delivery.latitude!), parseFloat(delivery.longitude!)]"
|
||||||
|
>
|
||||||
|
<l-popup>
|
||||||
|
<div class="text-sm">
|
||||||
|
<p class="font-bold">{{ delivery.customerName }}</p>
|
||||||
|
<p>{{ delivery.street }}</p>
|
||||||
|
<p>{{ delivery.town }}, {{ delivery.state }} {{ delivery.zipcode }}</p>
|
||||||
|
<p class="mt-1">
|
||||||
|
<span v-if="delivery.isFill" class="font-semibold text-blue-600">FILL</span>
|
||||||
|
<span v-else>{{ delivery.gallonsOrdered }} gallons</span>
|
||||||
|
</p>
|
||||||
|
<p v-if="delivery.notes" class="mt-1 text-gray-600 italic">{{ delivery.notes }}</p>
|
||||||
|
<div class="mt-2">
|
||||||
|
<router-link
|
||||||
|
:to="{ name: 'deliveryOrder', params: { id: delivery.id } }"
|
||||||
|
class="text-blue-500 hover:underline"
|
||||||
|
>
|
||||||
|
View Delivery
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</l-popup>
|
||||||
|
</l-marker>
|
||||||
|
</l-map>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grouped List -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div v-for="(townDeliveries, town) in groupedByTown" :key="town" class="collapse collapse-arrow bg-base-100">
|
||||||
|
<input type="checkbox" checked />
|
||||||
|
<div class="collapse-title text-lg font-medium">
|
||||||
|
{{ town }}
|
||||||
|
<span class="badge badge-primary ml-2">{{ townDeliveries.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="collapse-content">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-sm w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Customer</th>
|
||||||
|
<th>Address</th>
|
||||||
|
<th>Gallons</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="delivery in townDeliveries" :key="delivery.id" class="hover">
|
||||||
|
<td>{{ delivery.id }}</td>
|
||||||
|
<td>
|
||||||
|
<router-link
|
||||||
|
:to="{ name: 'customerProfile', params: { id: delivery.customerId } }"
|
||||||
|
class="link link-hover hover:text-green-500"
|
||||||
|
>
|
||||||
|
{{ delivery.customerName }}
|
||||||
|
</router-link>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span :class="{ 'text-warning': !delivery.latitude }">
|
||||||
|
{{ delivery.street }}
|
||||||
|
</span>
|
||||||
|
<span v-if="!delivery.latitude" class="ml-1 text-xs">(no coords)</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="delivery.isFill" class="badge badge-info badge-sm">FILL</span>
|
||||||
|
<span v-else>{{ delivery.gallonsOrdered }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="max-w-xs truncate">{{ delivery.notes || '-' }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<router-link
|
||||||
|
:to="{ name: 'deliveryOrder', params: { id: delivery.id } }"
|
||||||
|
class="btn btn-xs btn-ghost"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
:to="{ name: 'deliveryEdit', params: { id: delivery.id } }"
|
||||||
|
class="btn btn-xs btn-secondary"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
import { LMap, LTileLayer, LMarker, LPopup } from "@vue-leaflet/vue-leaflet";
|
||||||
|
import { deliveryService } from '../../services/deliveryService';
|
||||||
|
import { DeliveryMapItem } from '../../types/models';
|
||||||
|
|
||||||
|
// State
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
const deliveries = ref<DeliveryMapItem[]>([]);
|
||||||
|
const selectedDate = ref(new Date().toISOString().split('T')[0]);
|
||||||
|
const zoom = ref(11);
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const mappedDeliveries = computed(() =>
|
||||||
|
deliveries.value.filter(d => d.latitude && d.longitude)
|
||||||
|
);
|
||||||
|
|
||||||
|
const mappedCount = computed(() => mappedDeliveries.value.length);
|
||||||
|
const unmappedCount = computed(() => deliveries.value.length - mappedCount.value);
|
||||||
|
|
||||||
|
const uniqueTowns = computed(() =>
|
||||||
|
[...new Set(deliveries.value.map(d => d.town))]
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupedByTown = computed(() => {
|
||||||
|
const groups: Record<string, DeliveryMapItem[]> = {};
|
||||||
|
for (const delivery of deliveries.value) {
|
||||||
|
const town = delivery.town || 'Unknown';
|
||||||
|
if (!groups[town]) {
|
||||||
|
groups[town] = [];
|
||||||
|
}
|
||||||
|
groups[town].push(delivery);
|
||||||
|
}
|
||||||
|
// Sort towns alphabetically
|
||||||
|
const sortedGroups: Record<string, DeliveryMapItem[]> = {};
|
||||||
|
Object.keys(groups).sort().forEach(key => {
|
||||||
|
sortedGroups[key] = groups[key];
|
||||||
|
});
|
||||||
|
return sortedGroups;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapCenter = computed<[number, number]>(() => {
|
||||||
|
if (mappedDeliveries.value.length === 0) {
|
||||||
|
// Default to CT center
|
||||||
|
return [41.6032, -73.0877];
|
||||||
|
}
|
||||||
|
const lats = mappedDeliveries.value.map(d => parseFloat(d.latitude!));
|
||||||
|
const lngs = mappedDeliveries.value.map(d => parseFloat(d.longitude!));
|
||||||
|
const avgLat = lats.reduce((a, b) => a + b, 0) / lats.length;
|
||||||
|
const avgLng = lngs.reduce((a, b) => a + b, 0) / lngs.length;
|
||||||
|
return [avgLat, avgLng];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const fetchDeliveries = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await deliveryService.getForMap(selectedDate.value);
|
||||||
|
if (response.data.ok) {
|
||||||
|
deliveries.value = response.data.deliveries || [];
|
||||||
|
} else {
|
||||||
|
error.value = 'Failed to fetch deliveries';
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error fetching deliveries for map:', err);
|
||||||
|
error.value = err.response?.data?.error || 'Failed to fetch deliveries';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
const date = new Date(dateStr + 'T00:00:00');
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
fetchDeliveries();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.leaflet-container {
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,6 +2,7 @@ const DeliveryHome = () => import('./home.vue');
|
|||||||
const DeliveryCreate = () => import("./create.vue");
|
const DeliveryCreate = () => import("./create.vue");
|
||||||
const DeliveryEdit = () => import('./edit.vue');
|
const DeliveryEdit = () => import('./edit.vue');
|
||||||
const DeliveryOrder = () => import('./view.vue');
|
const DeliveryOrder = () => import('./view.vue');
|
||||||
|
const DeliveryMap = () => import('./map.vue');
|
||||||
const deliveryTicketsMissing = () => import('./update_tickets/missing_data_home.vue');
|
const deliveryTicketsMissing = () => import('./update_tickets/missing_data_home.vue');
|
||||||
|
|
||||||
const deliveryPending = () => import('./viewstatus/pending.vue');
|
const deliveryPending = () => import('./viewstatus/pending.vue');
|
||||||
@@ -37,6 +38,11 @@ const deliveryRoutes = [
|
|||||||
name: 'deliveryEdit',
|
name: 'deliveryEdit',
|
||||||
component: DeliveryEdit,
|
component: DeliveryEdit,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/delivery/map',
|
||||||
|
name: 'deliveryMap',
|
||||||
|
component: DeliveryMap,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/delivery/:id',
|
path: '/delivery/:id',
|
||||||
name: 'deliveryOrder',
|
name: 'deliveryOrder',
|
||||||
|
|||||||
@@ -11,19 +11,35 @@
|
|||||||
</div>
|
</div>
|
||||||
<h1 class="text-3xl font-bold mt-4">Cancelled Deliveries</h1>
|
<h1 class="text-3xl font-bold mt-4">Cancelled Deliveries</h1>
|
||||||
|
|
||||||
<!-- Main Content Card -->
|
<!-- Page Header with Stats -->
|
||||||
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mt-4 mb-6">
|
||||||
<!-- Header: Title and Count -->
|
<div>
|
||||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-4">
|
<h1 class="text-2xl md:text-3xl font-bold flex items-center gap-3">
|
||||||
<h2 class="text-lg font-bold">Archived Cancelled Deliveries</h2>
|
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center shadow-lg">
|
||||||
<!-- <div class="badge badge-ghost">{{ recordsLength }} items Found</div> -->
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-primary-content">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
Cancelled Deliveries
|
||||||
|
</h1>
|
||||||
|
<p class="text-base-content/60 mt-1 ml-13">Archived cancelled deliveries</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<!-- Quick Stats -->
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<div class="stat-pill">
|
||||||
|
<span class="stat-pill-value">{{ recordsLength }}</span>
|
||||||
|
<span class="stat-pill-label">Deliveries</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Table Card -->
|
||||||
|
<div class="modern-table-card">
|
||||||
|
|
||||||
<!-- DESKTOP VIEW: Table -->
|
<!-- DESKTOP VIEW: Table -->
|
||||||
<div class="overflow-x-auto hidden xl:block">
|
<div class="hidden xl:block overflow-x-auto">
|
||||||
<table class="table w-full">
|
<table class="modern-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Delivery #</th>
|
<th>Delivery #</th>
|
||||||
@@ -54,8 +70,8 @@
|
|||||||
<div class="text-xs opacity-70">{{ oil.customer_address }}</div>
|
<div class="text-xs opacity-70">{{ oil.customer_address }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span v-if="oil.customer_asked_for_fill == 1" class="badge badge-info">FILL</span>
|
<span v-if="oil.customer_asked_for_fill == 1" class="badge badge-info text-lg h-auto py-1">FILL</span>
|
||||||
<span v-else>{{ oil.gallons_ordered }}</span>
|
<span v-else class="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">{{ oil.gallons_ordered }} gal</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ oil.expected_delivery_date }}</td>
|
<td>{{ oil.expected_delivery_date }}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -65,9 +81,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<div class="flex items-center justify-end gap-2">
|
<div class="flex items-center justify-end gap-1">
|
||||||
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-sm btn-ghost">View</router-link>
|
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-xs btn-ghost">View</router-link>
|
||||||
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-sm btn-secondary">Edit</router-link>
|
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-xs btn-info btn-outline">Edit</router-link>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -79,12 +95,20 @@
|
|||||||
<!-- MOBILE VIEW: Cards -->
|
<!-- MOBILE VIEW: Cards -->
|
||||||
<div class="xl:hidden space-y-4">
|
<div class="xl:hidden space-y-4">
|
||||||
<template v-for="oil in deliveries" :key="oil.id">
|
<template v-for="oil in deliveries" :key="oil.id">
|
||||||
<div v-if="oil.id" class="card bg-base-100 shadow-md">
|
<div
|
||||||
<div class="card-body p-4">
|
v-if="oil.id"
|
||||||
|
class="mobile-card"
|
||||||
|
:class="{
|
||||||
|
'mobile-card-urgent': oil.emergency,
|
||||||
|
'mobile-card-prime': oil.prime && !oil.emergency,
|
||||||
|
'mobile-card-sameday': oil.same_day && !oil.prime && !oil.emergency
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="p-3">
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="card-title text-base">{{ oil.customer_name }}</h2>
|
<h2 class="text-base font-bold">{{ oil.customer_name }}</h2>
|
||||||
<p class="text-xs text-gray-400">Delivery #{{ oil.id }}</p>
|
<p class="text-xs text-base-content/60">Delivery #{{ oil.id }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="badge badge-error">
|
<div class="badge badge-error">
|
||||||
Cancelled
|
Cancelled
|
||||||
@@ -96,19 +120,28 @@
|
|||||||
<div v-if="oil.same_day" class="badge badge-error badge-sm">SAME DAY</div>
|
<div v-if="oil.same_day" class="badge badge-error badge-sm">SAME DAY</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
|
<div class="text-sm mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
|
||||||
<p><strong class="font-semibold">Address:</strong> {{ oil.customer_address }}</p>
|
<div>
|
||||||
<p><strong class="font-semibold">Town:</strong> {{ oil.customer_town }}</p>
|
<p class="text-xs text-base-content/50">Address</p>
|
||||||
<p><strong class="font-semibold">Gallons:</strong>
|
<p class="font-medium">{{ oil.customer_address }}</p>
|
||||||
<span v-if="oil.customer_asked_for_fill" class="badge badge-info badge-xs">FILL</span>
|
<p class="text-xs">{{ oil.customer_town }}</p>
|
||||||
<span v-else>{{ oil.gallons_ordered }}</span>
|
</div>
|
||||||
</p>
|
<div>
|
||||||
<p><strong class="font-semibold">Date:</strong> {{ oil.expected_delivery_date }}</p>
|
<p class="text-xs text-base-content/50">Gallons</p>
|
||||||
|
<p class="font-bold text-lg text-success">
|
||||||
|
<span v-if="oil.customer_asked_for_fill" class="badge badge-info badge-xs">FILL</span>
|
||||||
|
<span v-else>{{ oil.gallons_ordered }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-base-content/50">Date</p>
|
||||||
|
<p class="font-medium">{{ oil.expected_delivery_date }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-actions justify-end flex-wrap gap-2 mt-2">
|
<div class="flex gap-2 pt-3 mt-3 border-t border-base-content/10">
|
||||||
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-sm btn-ghost">View</router-link>
|
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-sm btn-ghost flex-1">View</router-link>
|
||||||
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-sm btn-secondary">Edit</router-link>
|
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-sm btn-info btn-outline flex-1">Edit</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,6 +243,187 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -11,19 +11,35 @@
|
|||||||
</div>
|
</div>
|
||||||
<h1 class="text-3xl font-bold mt-4">Delivered Deliveries</h1>
|
<h1 class="text-3xl font-bold mt-4">Delivered Deliveries</h1>
|
||||||
|
|
||||||
<!-- Main Content Card -->
|
<!-- Page Header with Stats -->
|
||||||
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mt-4 mb-6">
|
||||||
<!-- Header: Title and Count -->
|
<div>
|
||||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-4">
|
<h1 class="text-2xl md:text-3xl font-bold flex items-center gap-3">
|
||||||
<h2 class="text-lg font-bold">Deliveries Awaiting Finalization</h2>
|
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center shadow-lg">
|
||||||
<!-- <div class="badge badge-ghost">{{ recordsLength }} items Found</div> -->
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-primary-content">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
Delivered Deliveries
|
||||||
|
</h1>
|
||||||
|
<p class="text-base-content/60 mt-1 ml-13">Awaiting finalization</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<!-- Quick Stats -->
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<div class="stat-pill">
|
||||||
|
<span class="stat-pill-value">{{ recordsLength }}</span>
|
||||||
|
<span class="stat-pill-label">Deliveries</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Table Card -->
|
||||||
|
<div class="modern-table-card">
|
||||||
|
|
||||||
<!-- DESKTOP VIEW: Table -->
|
<!-- DESKTOP VIEW: Table -->
|
||||||
<div class="overflow-x-auto hidden xl:block">
|
<div class="hidden xl:block overflow-x-auto">
|
||||||
<table class="table w-full">
|
<table class="modern-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Delivery #</th>
|
<th>Delivery #</th>
|
||||||
@@ -54,8 +70,8 @@
|
|||||||
<div class="text-xs opacity-70">{{ oil.customer_address }}</div>
|
<div class="text-xs opacity-70">{{ oil.customer_address }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span v-if="oil.customer_asked_for_fill == 1" class="badge badge-info">FILL</span>
|
<span v-if="oil.customer_asked_for_fill == 1" class="badge badge-info text-lg h-auto py-1">FILL</span>
|
||||||
<span v-else>{{ oil.gallons_ordered }}</span>
|
<span v-else class="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">{{ oil.gallons_ordered }} gal</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ oil.expected_delivery_date }}</td>
|
<td>{{ oil.expected_delivery_date }}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -65,9 +81,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<router-link :to="{ name: 'finalizeTicket', params: { id: oil.id } }" class="btn btn-sm btn-accent">
|
<div class="flex items-center justify-end gap-1">
|
||||||
Finalize
|
<router-link :to="{ name: 'finalizeTicket', params: { id: oil.id } }" class="btn btn-xs btn-accent btn-outline">
|
||||||
</router-link>
|
Finalize
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
@@ -78,12 +96,20 @@
|
|||||||
<!-- MOBILE VIEW: Cards -->
|
<!-- MOBILE VIEW: Cards -->
|
||||||
<div class="xl:hidden space-y-4">
|
<div class="xl:hidden space-y-4">
|
||||||
<template v-for="oil in deliveries" :key="oil.id">
|
<template v-for="oil in deliveries" :key="oil.id">
|
||||||
<div v-if="oil.id" class="card bg-base-100 shadow-md">
|
<div
|
||||||
<div class="card-body p-4">
|
v-if="oil.id"
|
||||||
|
class="mobile-card"
|
||||||
|
:class="{
|
||||||
|
'mobile-card-urgent': oil.emergency,
|
||||||
|
'mobile-card-prime': oil.prime && !oil.emergency,
|
||||||
|
'mobile-card-sameday': oil.same_day && !oil.prime && !oil.emergency
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="p-3">
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="card-title text-base">{{ oil.customer_name }}</h2>
|
<h2 class="text-base font-bold">{{ oil.customer_name }}</h2>
|
||||||
<p class="text-xs text-gray-400">Delivery #{{ oil.id }}</p>
|
<p class="text-xs text-base-content/60">Delivery #{{ oil.id }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="badge badge-success">
|
<div class="badge badge-success">
|
||||||
Delivered
|
Delivered
|
||||||
@@ -95,18 +121,27 @@
|
|||||||
<div v-if="oil.same_day" class="badge badge-error badge-sm">SAME DAY</div>
|
<div v-if="oil.same_day" class="badge badge-error badge-sm">SAME DAY</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
|
<div class="text-sm mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
|
||||||
<p><strong class="font-semibold">Address:</strong> {{ oil.customer_address }}</p>
|
<div>
|
||||||
<p><strong class="font-semibold">Town:</strong> {{ oil.customer_town }}</p>
|
<p class="text-xs text-base-content/50">Address</p>
|
||||||
<p><strong class="font-semibold">Gallons:</strong>
|
<p class="font-medium">{{ oil.customer_address }}</p>
|
||||||
<span v-if="oil.customer_asked_for_fill" class="badge badge-info badge-xs">FILL</span>
|
<p class="text-xs">{{ oil.customer_town }}</p>
|
||||||
<span v-else>{{ oil.gallons_ordered }}</span>
|
</div>
|
||||||
</p>
|
<div>
|
||||||
<p><strong class="font-semibold">Date:</strong> {{ oil.expected_delivery_date }}</p>
|
<p class="text-xs text-base-content/50">Gallons</p>
|
||||||
|
<p class="font-bold text-lg text-success">
|
||||||
|
<span v-if="oil.customer_asked_for_fill" class="badge badge-info badge-xs">FILL</span>
|
||||||
|
<span v-else>{{ oil.gallons_ordered }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-base-content/50">Date</p>
|
||||||
|
<p class="font-medium">{{ oil.expected_delivery_date }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-actions justify-end flex-wrap gap-2 mt-2">
|
<div class="flex gap-2 pt-3 mt-3 border-t border-base-content/10">
|
||||||
<router-link :to="{ name: 'finalizeTicket', params: { id: oil.id } }" class="btn btn-sm btn-accent">
|
<router-link :to="{ name: 'finalizeTicket', params: { id: oil.id } }" class="btn btn-sm btn-accent btn-outline flex-1">
|
||||||
Finalize
|
Finalize
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -209,6 +244,187 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -10,18 +10,35 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content Card -->
|
<!-- Page Header with Stats -->
|
||||||
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mt-4 mb-6">
|
||||||
<!-- Header: Title and Count -->
|
<div>
|
||||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-4">
|
<h1 class="text-2xl md:text-3xl font-bold flex items-center gap-3">
|
||||||
<h2 class="text-lg font-bold">Completed and Finalized Deliveries</h2>
|
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center shadow-lg">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-primary-content">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
Finalized Deliveries
|
||||||
|
</h1>
|
||||||
|
<p class="text-base-content/60 mt-1 ml-13">Completed and finalized deliveries</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<!-- Quick Stats -->
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<div class="stat-pill">
|
||||||
|
<span class="stat-pill-value">{{ recordsLength }}</span>
|
||||||
|
<span class="stat-pill-label">Deliveries</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Table Card -->
|
||||||
|
<div class="modern-table-card">
|
||||||
|
|
||||||
<!-- DESKTOP VIEW: Table -->
|
<!-- DESKTOP VIEW: Table -->
|
||||||
<div class="overflow-x-auto hidden xl:block">
|
<div class="hidden xl:block overflow-x-auto">
|
||||||
<table class="table w-full">
|
<table class="modern-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Ticket #</th>
|
<th>Ticket #</th>
|
||||||
@@ -53,7 +70,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|
||||||
{{ oil.gallons_delivered }}
|
<span class="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">{{ oil.gallons_delivered }} gal</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ oil.expected_delivery_date }}</td>
|
<td>{{ oil.expected_delivery_date }}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -63,10 +80,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<div class="flex items-center justify-end gap-2">
|
<div class="flex items-center justify-end gap-1">
|
||||||
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-sm btn-ghost">View</router-link>
|
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-xs btn-ghost">View</router-link>
|
||||||
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-sm btn-secondary">Edit</router-link>
|
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-xs btn-info btn-outline">Edit</router-link>
|
||||||
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-sm btn-success">Print</router-link>
|
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-xs btn-success btn-outline">Print</router-link>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -78,12 +95,20 @@
|
|||||||
<!-- MOBILE VIEW: Cards -->
|
<!-- MOBILE VIEW: Cards -->
|
||||||
<div class="xl:hidden space-y-4">
|
<div class="xl:hidden space-y-4">
|
||||||
<template v-for="oil in deliveries" :key="oil.id">
|
<template v-for="oil in deliveries" :key="oil.id">
|
||||||
<div v-if="oil.id" class="card bg-base-100 shadow-md">
|
<div
|
||||||
<div class="card-body p-4">
|
v-if="oil.id"
|
||||||
|
class="mobile-card"
|
||||||
|
:class="{
|
||||||
|
'mobile-card-urgent': oil.emergency,
|
||||||
|
'mobile-card-prime': oil.prime && !oil.emergency,
|
||||||
|
'mobile-card-sameday': oil.same_day && !oil.prime && !oil.emergency
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="p-3">
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="card-title text-base">{{ oil.customer_name }}</h2>
|
<h2 class="text-base font-bold">{{ oil.customer_name }}</h2>
|
||||||
<p class="text-xs text-gray-400">Ticket #{{ oil.id }}</p>
|
<p class="text-xs text-base-content/60">Ticket #{{ oil.id }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="badge badge-success">
|
<div class="badge badge-success">
|
||||||
Finalized
|
Finalized
|
||||||
@@ -95,20 +120,28 @@
|
|||||||
<div v-if="oil.same_day" class="badge badge-error badge-sm">SAME DAY</div>
|
<div v-if="oil.same_day" class="badge badge-error badge-sm">SAME DAY</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
|
<div class="text-sm mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
|
||||||
<p><strong class="font-semibold">Address:</strong> {{ oil.customer_address }}</p>
|
<div>
|
||||||
<p><strong class="font-semibold">Town:</strong> {{ oil.customer_town }}</p>
|
<p class="text-xs text-base-content/50">Address</p>
|
||||||
<p><strong class="font-semibold">Gallons:</strong>
|
<p class="font-medium">{{ oil.customer_address }}</p>
|
||||||
|
<p class="text-xs">{{ oil.customer_town }}</p>
|
||||||
{{ oil.gallons_delivered }}
|
</div>
|
||||||
</p>
|
<div>
|
||||||
<p><strong class="font-semibold">Date:</strong> {{ oil.expected_delivery_date }}</p>
|
<p class="text-xs text-base-content/50">Gallons</p>
|
||||||
|
<p class="font-bold text-lg text-success">
|
||||||
|
{{ oil.gallons_delivered }} gal
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-base-content/50">Date</p>
|
||||||
|
<p class="font-medium">{{ oil.expected_delivery_date }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-actions justify-end flex-wrap gap-2 mt-2">
|
<div class="flex gap-2 pt-3 mt-3 border-t border-base-content/10">
|
||||||
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-sm btn-ghost">View</router-link>
|
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-sm btn-ghost flex-1">View</router-link>
|
||||||
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-sm btn-secondary">Edit</router-link>
|
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-sm btn-info btn-outline flex-1">Edit</router-link>
|
||||||
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-sm btn-success">Print</router-link>
|
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-sm btn-success btn-outline flex-1">Print</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,5 +243,186 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* Stat Pills */
|
||||||
</style>
|
.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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -10,19 +10,35 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- Main Content Card -->
|
<!-- Page Header with Stats -->
|
||||||
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mt-4 mb-6">
|
||||||
<!-- Header: Title and Count -->
|
<div>
|
||||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-4">
|
<h1 class="text-2xl md:text-3xl font-bold flex items-center gap-3">
|
||||||
<h2 class="text-lg font-bold">Deliveries Requiring Attention</h2>
|
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center shadow-lg">
|
||||||
<!-- <div class="badge badge-ghost">{{ recordsLength }} items Found</div> -->
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-primary-content">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
Issue Deliveries
|
||||||
|
</h1>
|
||||||
|
<p class="text-base-content/60 mt-1 ml-13">Deliveries requiring attention</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<!-- Quick Stats -->
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<div class="stat-pill">
|
||||||
|
<span class="stat-pill-value">{{ recordsLength }}</span>
|
||||||
|
<span class="stat-pill-label">Deliveries</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Table Card -->
|
||||||
|
<div class="modern-table-card">
|
||||||
|
|
||||||
<!-- DESKTOP VIEW: Table -->
|
<!-- DESKTOP VIEW: Table -->
|
||||||
<div class="overflow-x-auto hidden xl:block">
|
<div class="hidden xl:block overflow-x-auto">
|
||||||
<table class="table w-full">
|
<table class="modern-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Ticket #</th>
|
<th>Ticket #</th>
|
||||||
@@ -53,8 +69,8 @@
|
|||||||
<div class="text-xs opacity-70">{{ oil.customer_address }}</div>
|
<div class="text-xs opacity-70">{{ oil.customer_address }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span v-if="oil.customer_asked_for_fill == 1" class="badge badge-info">FILL</span>
|
<span v-if="oil.customer_asked_for_fill == 1" class="badge badge-info text-lg h-auto py-1">FILL</span>
|
||||||
<span v-else>{{ oil.gallons_ordered }}</span>
|
<span v-else class="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">{{ oil.gallons_ordered }} gal</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ oil.expected_delivery_date }}</td>
|
<td>{{ oil.expected_delivery_date }}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -64,10 +80,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<div class="flex items-center justify-end gap-2">
|
<div class="flex items-center justify-end gap-1">
|
||||||
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-sm btn-ghost">View</router-link>
|
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-xs btn-ghost">View</router-link>
|
||||||
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-sm btn-secondary">Edit</router-link>
|
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-xs btn-info btn-outline">Edit</router-link>
|
||||||
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-sm btn-success">Print</router-link>
|
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-xs btn-success btn-outline">Print</router-link>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -79,12 +95,20 @@
|
|||||||
<!-- MOBILE VIEW: Cards -->
|
<!-- MOBILE VIEW: Cards -->
|
||||||
<div class="xl:hidden space-y-4">
|
<div class="xl:hidden space-y-4">
|
||||||
<template v-for="oil in deliveries" :key="oil.id">
|
<template v-for="oil in deliveries" :key="oil.id">
|
||||||
<div v-if="oil.id" class="card bg-base-100 shadow-md">
|
<div
|
||||||
<div class="card-body p-4">
|
v-if="oil.id"
|
||||||
|
class="mobile-card"
|
||||||
|
:class="{
|
||||||
|
'mobile-card-urgent': oil.emergency,
|
||||||
|
'mobile-card-prime': oil.prime && !oil.emergency,
|
||||||
|
'mobile-card-sameday': oil.same_day && !oil.prime && !oil.emergency
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="p-3">
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="card-title text-base">{{ oil.customer_name }}</h2>
|
<h2 class="text-base font-bold">{{ oil.customer_name }}</h2>
|
||||||
<p class="text-xs text-gray-400">Ticket #{{ oil.id }}</p>
|
<p class="text-xs text-base-content/60">Ticket #{{ oil.id }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="badge badge-error">
|
<div class="badge badge-error">
|
||||||
Issue
|
Issue
|
||||||
@@ -96,20 +120,29 @@
|
|||||||
<div v-if="oil.same_day" class="badge badge-error badge-sm">SAME DAY</div>
|
<div v-if="oil.same_day" class="badge badge-error badge-sm">SAME DAY</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
|
<div class="text-sm mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
|
||||||
<p><strong class="font-semibold">Address:</strong> {{ oil.customer_address }}</p>
|
<div>
|
||||||
<p><strong class="font-semibold">Town:</strong> {{ oil.customer_town }}</p>
|
<p class="text-xs text-base-content/50">Address</p>
|
||||||
<p><strong class="font-semibold">Gallons:</strong>
|
<p class="font-medium">{{ oil.customer_address }}</p>
|
||||||
<span v-if="oil.customer_asked_for_fill" class="badge badge-info badge-xs">FILL</span>
|
<p class="text-xs">{{ oil.customer_town }}</p>
|
||||||
<span v-else>{{ oil.gallons_ordered }}</span>
|
</div>
|
||||||
</p>
|
<div>
|
||||||
<p><strong class="font-semibold">Date:</strong> {{ oil.expected_delivery_date }}</p>
|
<p class="text-xs text-base-content/50">Gallons</p>
|
||||||
|
<p class="font-bold text-lg text-success">
|
||||||
|
<span v-if="oil.customer_asked_for_fill" class="badge badge-info badge-xs">FILL</span>
|
||||||
|
<span v-else>{{ oil.gallons_ordered }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-base-content/50">Date</p>
|
||||||
|
<p class="font-medium">{{ oil.expected_delivery_date }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-actions justify-end flex-wrap gap-2 mt-2">
|
<div class="flex gap-2 pt-3 mt-3 border-t border-base-content/10">
|
||||||
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-sm btn-ghost">View</router-link>
|
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-sm btn-ghost flex-1">View</router-link>
|
||||||
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-sm btn-secondary">Edit</router-link>
|
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-sm btn-info btn-outline flex-1">Edit</router-link>
|
||||||
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-sm btn-success">Print</router-link>
|
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-sm btn-success btn-outline flex-1">Print</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,6 +243,187 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -9,19 +9,35 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content Card -->
|
<!-- Page Header with Stats -->
|
||||||
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mt-4 mb-6">
|
||||||
<!-- Header: Title and Count -->
|
<div>
|
||||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-4">
|
<h1 class="text-2xl md:text-3xl font-bold flex items-center gap-3">
|
||||||
<h2 class="text-lg font-bold">Deliveries Awaiting Payment</h2>
|
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center shadow-lg">
|
||||||
<!-- <div class="badge badge-ghost">{{ recordsLength }} items Found</div> -->
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-primary-content">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
Pending Deliveries
|
||||||
|
</h1>
|
||||||
|
<p class="text-base-content/60 mt-1 ml-13">Awaiting payment or credit approval</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<!-- Quick Stats -->
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<div class="stat-pill">
|
||||||
|
<span class="stat-pill-value">{{ recordsLength }}</span>
|
||||||
|
<span class="stat-pill-label">Deliveries</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Table Card -->
|
||||||
|
<div class="modern-table-card">
|
||||||
|
|
||||||
<!-- DESKTOP VIEW: Table -->
|
<!-- DESKTOP VIEW: Table -->
|
||||||
<div class="overflow-x-auto hidden xl:block">
|
<div class="hidden xl:block overflow-x-auto">
|
||||||
<table class="table w-full">
|
<table class="modern-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Delivery #</th>
|
<th>Delivery #</th>
|
||||||
@@ -63,8 +79,8 @@
|
|||||||
<div class="text-xs opacity-70">{{ oil.customer_address }}</div>
|
<div class="text-xs opacity-70">{{ oil.customer_address }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span v-if="oil.customer_asked_for_fill == 1" class="badge badge-info">FILL</span>
|
<span v-if="oil.customer_asked_for_fill == 1" class="badge badge-info text-lg h-auto py-1">FILL</span>
|
||||||
<span v-else>{{ oil.gallons_ordered }}</span>
|
<span v-else class="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">{{ oil.gallons_ordered }} gal</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span v-if="oil.payment_type == 0">Cash</span>
|
<span v-if="oil.payment_type == 0">Cash</span>
|
||||||
@@ -81,11 +97,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<div class="flex items-center justify-end gap-2">
|
<div class="flex items-center justify-end gap-1">
|
||||||
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-sm btn-ghost">View</router-link>
|
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-xs btn-ghost">View</router-link>
|
||||||
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-sm btn-secondary">Edit</router-link>
|
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-xs btn-info btn-outline">Edit</router-link>
|
||||||
<router-link :to="{ name: 'finalizeTicket', params: { id: oil.id } }" class="btn btn-sm btn-accent">Finalize</router-link>
|
<router-link :to="{ name: 'finalizeTicket', params: { id: oil.id } }" class="btn btn-xs btn-accent btn-outline">Finalize</router-link>
|
||||||
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-sm btn-success">Print</router-link>
|
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-xs btn-success btn-outline">Print</router-link>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -97,12 +113,21 @@
|
|||||||
<!-- MOBILE VIEW: Cards -->
|
<!-- MOBILE VIEW: Cards -->
|
||||||
<div class="xl:hidden space-y-4">
|
<div class="xl:hidden space-y-4">
|
||||||
<template v-for="oil in deliveries" :key="oil.id">
|
<template v-for="oil in deliveries" :key="oil.id">
|
||||||
<div v-if="oil.id" class="card bg-base-100 shadow-md">
|
<div
|
||||||
<div class="card-body p-4">
|
v-if="oil.id"
|
||||||
|
class="mobile-card"
|
||||||
|
:class="{
|
||||||
|
'mobile-card-urgent': oil.emergency,
|
||||||
|
'mobile-card-prime': oil.prime && !oil.emergency,
|
||||||
|
'mobile-card-sameday': oil.same_day && !oil.prime && !oil.emergency
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<!-- Card content adapted to new style but preserving data fields -->
|
||||||
|
<div class="p-3">
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="card-title text-base">{{ oil.customer_name }}</h2>
|
<h2 class="text-base font-bold">{{ oil.customer_name }}</h2>
|
||||||
<p class="text-xs text-gray-400">Delivery #{{ oil.id }}</p>
|
<p class="text-xs text-base-content/60">Delivery #{{ oil.id }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="badge" :class="{
|
<div class="badge" :class="{
|
||||||
'badge-warning': oil.delivery_status == 0,
|
'badge-warning': oil.delivery_status == 0,
|
||||||
@@ -124,27 +149,38 @@
|
|||||||
<div v-if="oil.emergency" class="badge badge-error badge-sm">EMERGENCY</div>
|
<div v-if="oil.emergency" class="badge badge-error badge-sm">EMERGENCY</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
|
<div class="text-sm mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
|
||||||
<p><strong class="font-semibold">Address:</strong> {{ oil.customer_address }}</p>
|
<div>
|
||||||
<p><strong class="font-semibold">Town:</strong> {{ oil.customer_town }}</p>
|
<p class="text-xs text-base-content/50">Address</p>
|
||||||
<p><strong class="font-semibold">Gallons:</strong>
|
<p class="font-medium">{{ oil.customer_address }}</p>
|
||||||
<span v-if="oil.customer_asked_for_fill" class="badge badge-info badge-xs">FILL</span>
|
<p class="text-xs">{{ oil.customer_town }}</p>
|
||||||
<span v-else>{{ oil.gallons_ordered }}</span>
|
</div>
|
||||||
</p>
|
<div>
|
||||||
<p><strong class="font-semibold">Payment:</strong>
|
<p class="text-xs text-base-content/50">Gallons</p>
|
||||||
<span v-if="oil.payment_type == 0">Cash</span>
|
<p class="font-bold text-lg text-success">
|
||||||
<span v-else-if="oil.payment_type == 1">CC</span>
|
<span v-if="oil.customer_asked_for_fill" class="badge badge-info badge-xs">FILL</span>
|
||||||
<span v-else-if="oil.payment_type == 2">Cash/CC</span>
|
<span v-else>{{ oil.gallons_ordered }}</span>
|
||||||
<span v-else-if="oil.payment_type == 3">Check</span>
|
</p>
|
||||||
<span v-else-if="oil.payment_type == 4">Other</span>
|
</div>
|
||||||
</p>
|
<div>
|
||||||
|
<p class="text-xs text-base-content/50">Payment</p>
|
||||||
|
<span v-if="oil.payment_type == 0">Cash</span>
|
||||||
|
<span v-else-if="oil.payment_type == 1">CC</span>
|
||||||
|
<span v-else-if="oil.payment_type == 2">Cash/CC</span>
|
||||||
|
<span v-else-if="oil.payment_type == 3">Check</span>
|
||||||
|
<span v-else-if="oil.payment_type == 4">Other</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-actions justify-end flex-wrap gap-2 mt-2">
|
<div class="flex gap-2 pt-3 mt-3 border-t border-base-content/10">
|
||||||
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-sm btn-ghost">View</router-link>
|
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-sm btn-ghost flex-1">View</router-link>
|
||||||
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-sm btn-secondary">Edit</router-link>
|
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-sm btn-info btn-outline flex-1">Edit</router-link>
|
||||||
<router-link :to="{ name: 'finalizeTicket', params: { id: oil.id } }" class="btn btn-sm btn-accent">Finalize</router-link>
|
<router-link :to="{ name: 'finalizeTicket', params: { id: oil.id } }" class="btn btn-sm btn-accent btn-outline flex-1">Finalize</router-link>
|
||||||
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-sm btn-success">Print</router-link>
|
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-sm btn-success btn-outline">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6.72 13.829c-.24.03-.48.062-.72.096m.72-.096a42.415 42.415 0 0110.56 0m-10.56 0L6.34 18m10.94-4.171c.24.03.48.062.72.096m-.72-.096L17.66 18m0 0l.229 2.523a1.125 1.125 0 01-1.12 1.227H7.231c-.662 0-1.18-.568-1.12-1.227L6.34 18m11.318 0h1.091A2.25 2.25 0 0021 15.75V9.456c0-1.081-.768-2.015-1.837-2.175a48.055 48.055 0 00-1.913-.247M6.34 18H5.25A2.25 2.25 0 013 15.75V9.456c0-1.081.768-2.015 1.837-2.175a48.041 48.041 0 011.913-.247m10.5 0a48.536 48.536 0 00-10.5 0m10.5 0V3.375c0-.621-.504-1.125-1.125-1.125h-8.25c-.621 0-1.125.504-1.125 1.125v3.659M18 10.5h.008v.008H18V10.5zm-3 0h.008v.008H15V10.5z" />
|
||||||
|
</svg>
|
||||||
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -245,4 +281,187 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped>
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,182 +2,347 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="w-full px-4 md:px-10 py-4">
|
<div class="w-full px-4 md:px-10 py-4">
|
||||||
<!-- Breadcrumbs & Title -->
|
<!-- Breadcrumbs -->
|
||||||
<div class="text-sm breadcrumbs">
|
<div class="text-sm breadcrumbs">
|
||||||
<ul>
|
<ul>
|
||||||
<li><router-link :to="{ name: 'home' }">Home</router-link></li>
|
<li><router-link :to="{ name: 'home' }">Home</router-link></li>
|
||||||
|
<li><router-link :to="{ name: 'delivery' }">Delivery</router-link></li>
|
||||||
<li>Today's Deliveries</li>
|
<li>Today's Deliveries</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- Main Content Card -->
|
<!-- Page Header with Stats -->
|
||||||
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mt-4 mb-6">
|
||||||
<!-- Header: Search and Count -->
|
<div>
|
||||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-4">
|
<h1 class="text-2xl md:text-3xl font-bold flex items-center gap-3">
|
||||||
<h2 class="text-lg font-bold">Todays Deliveries</h2>
|
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center shadow-lg">
|
||||||
<div class="form-control">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-primary-content">
|
||||||
<label class="label pt-1 pb-0">
|
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 00-10.026 0 1.106 1.106 0 00-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12" />
|
||||||
<!-- <span class="label-text-alt">{{ recordsLength }} deliveries found</span> -->
|
</svg>
|
||||||
</label>
|
</div>
|
||||||
</div>
|
Today's Deliveries
|
||||||
|
</h1>
|
||||||
|
<p class="text-base-content/60 mt-1 ml-13">Out for delivery right now</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<!-- Quick Stats -->
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
<!-- Total Gallons -->
|
<div class="stat-pill">
|
||||||
<div v-if="grand_total > 0 || totals.length > 0" class="mb-4">
|
<span class="stat-pill-value">{{ deliveries.length }}</span>
|
||||||
<div v-if="grand_total > 0" class="mb-2">
|
<span class="stat-pill-label">Deliveries</span>
|
||||||
<span class="badge badge-accent">Total: {{ grand_total }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="stat-pill stat-pill-success">
|
||||||
<span v-for="total in totals" :key="total.town" class="badge badge-primary">
|
<span class="stat-pill-value">{{ grand_total.toLocaleString() }}</span>
|
||||||
{{ total.town }}: {{ total.gallons }}
|
<span class="stat-pill-label">Total Gallons</span>
|
||||||
</span>
|
</div>
|
||||||
|
<div class="stat-pill stat-pill-info">
|
||||||
|
<span class="stat-pill-value">{{ totals.length }}</span>
|
||||||
|
<span class="stat-pill-label">Towns</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- DESKTOP VIEW: Table -->
|
<!-- Town Breakdown Bar -->
|
||||||
<div class="overflow-x-auto hidden xl:block">
|
<div v-if="totals.length > 0" class="mb-6 overflow-x-auto">
|
||||||
<table class="table w-full">
|
<div class="flex gap-2 pb-2">
|
||||||
|
<button
|
||||||
|
v-for="total in totals"
|
||||||
|
:key="total.town"
|
||||||
|
@click="toggleTownFilter(total.town)"
|
||||||
|
class="town-chip"
|
||||||
|
:class="{ 'town-chip-active': filterTown === total.town }"
|
||||||
|
>
|
||||||
|
<span class="font-semibold">{{ total.town }}</span>
|
||||||
|
<span class="town-chip-count">{{ total.gallons }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="filterTown"
|
||||||
|
@click="filterTown = ''"
|
||||||
|
class="town-chip town-chip-clear"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
Clear Filter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search & Actions Bar -->
|
||||||
|
<div class="flex flex-col sm:flex-row gap-3 mb-4">
|
||||||
|
<div class="relative flex-1 max-w-md">
|
||||||
|
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 text-base-content/40">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="Search by name, address, or delivery #..."
|
||||||
|
class="input input-bordered w-full pl-10 bg-base-200/50 focus:bg-base-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<router-link :to="{ name: 'deliveryMap' }" class="btn btn-outline btn-primary gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 6.75V15m6-6v8.25m.503 3.498l4.875-2.437c.381-.19.622-.58.622-1.006V4.82c0-.836-.88-1.38-1.628-1.006l-3.869 1.934c-.317.159-.69.159-1.006 0L9.503 3.252a1.125 1.125 0 00-1.006 0L3.622 5.689C3.24 5.88 3 6.27 3 6.695V19.18c0 .836.88 1.38 1.628 1.006l3.869-1.934c.317-.159.69-.159 1.006 0l4.994 2.497c.317.158.69.158 1.006 0z" />
|
||||||
|
</svg>
|
||||||
|
View Map
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Table Card -->
|
||||||
|
<div class="modern-table-card">
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="loading" class="flex items-center justify-center py-20">
|
||||||
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-else-if="filteredDeliveries.length === 0" class="text-center py-16">
|
||||||
|
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-base-200 flex items-center justify-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-8 h-8 text-base-content/40">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 00-10.026 0 1.106 1.106 0 00-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold mb-1">No deliveries found</h3>
|
||||||
|
<p class="text-base-content/60">{{ searchQuery || filterTown ? 'Try adjusting your search or filter' : 'No deliveries are out for delivery today' }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DESKTOP TABLE -->
|
||||||
|
<div v-else class="hidden xl:block overflow-x-auto">
|
||||||
|
<table class="modern-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Delivery #</th>
|
<th class="w-24">
|
||||||
<th>Name</th>
|
<button @click="toggleSort('id')" class="sort-header">
|
||||||
<th>Status</th>
|
<span>#</span>
|
||||||
<th>Town / Address</th>
|
<svg v-if="sortBy === 'id'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4" :class="{ 'rotate-180': sortDir === 'asc' }">
|
||||||
<th>Gallons</th>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||||
<th class="text-right">Actions</th>
|
</svg>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th>Customer</th>
|
||||||
|
<th class="w-32">Status</th>
|
||||||
|
<th>Location</th>
|
||||||
|
<th class="w-28">
|
||||||
|
<button @click="toggleSort('gallons')" class="sort-header">
|
||||||
|
<span>Gallons</span>
|
||||||
|
<svg v-if="sortBy === 'gallons'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4" :class="{ 'rotate-180': sortDir === 'asc' }">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th class="w-72 text-right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<template v-for="oil in deliveries" :key="oil.id">
|
<tr
|
||||||
<tr v-if="oil.id" class="hover:bg-blue-600 hover:text-white">
|
v-for="oil in filteredDeliveries"
|
||||||
<td>{{ oil.id }}</td>
|
:key="oil.id"
|
||||||
|
class="table-row-hover"
|
||||||
|
:class="{ 'row-urgent': oil.emergency, 'row-prime': oil.prime && !oil.emergency, 'row-sameday': oil.same_day && !oil.prime && !oil.emergency }"
|
||||||
|
>
|
||||||
<td>
|
<td>
|
||||||
<router-link v-if="oil.customer_id" :to="{ name: 'customerProfile', params: { id: oil.customer_id } }" class="link link-hover hover:text-green-500">
|
<span class="font-mono text-sm font-semibold text-base-content/70">#{{ oil.id }}</span>
|
||||||
{{ oil.customer_name }}
|
|
||||||
</router-link>
|
|
||||||
<span v-else>{{ oil.customer_name }}</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge badge-sm" :class="{
|
<div class="flex items-center gap-3">
|
||||||
'badge-warning': oil.delivery_status == 0,
|
<div>
|
||||||
'badge-success': oil.delivery_status == 10,
|
<router-link
|
||||||
'badge-info': oil.delivery_status == 2,
|
v-if="oil.customer_id"
|
||||||
'badge-error': [1, 5].includes(oil.delivery_status),
|
:to="{ name: 'customerProfile', params: { id: oil.customer_id } }"
|
||||||
}">
|
class="font-semibold hover:text-primary transition-colors"
|
||||||
<span v-if="oil.delivery_status == 0">Waiting</span>
|
>
|
||||||
<span v-else-if="oil.delivery_status == 1">Cancelled</span>
|
{{ oil.customer_name }}
|
||||||
<span v-else-if="oil.delivery_status == 2">Out_for_Delivery</span>
|
</router-link>
|
||||||
<span v-else-if="oil.delivery_status == 3">Tomorrow</span>
|
<span v-else class="font-semibold">{{ oil.customer_name }}</span>
|
||||||
<span v-else-if="oil.delivery_status == 4">Partial</span>
|
<!-- Special Tags -->
|
||||||
<span v-else-if="oil.delivery_status == 5">Issue</span>
|
<div class="flex gap-1 mt-0.5">
|
||||||
<span v-else-if="oil.delivery_status == 10">Finalized</span>
|
<span v-if="oil.emergency" class="special-tag tag-emergency">
|
||||||
</span>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-3 h-3">
|
||||||
</td>
|
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
|
||||||
<td>
|
</svg>
|
||||||
<div>{{ oil.customer_town }}</div>
|
EMERGENCY
|
||||||
<div class="text-xs opacity-70">{{ oil.customer_address }}</div>
|
</span>
|
||||||
</td>
|
<span v-if="oil.prime" class="special-tag tag-prime">
|
||||||
<td>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-3 h-3">
|
||||||
<span v-if="oil.customer_asked_for_fill == 1" class="badge badge-info">FILL</span>
|
<path fill-rule="evenodd" d="M10.868 2.884c-.321-.772-1.415-.772-1.736 0l-1.83 4.401-4.753.381c-.833.067-1.171 1.107-.536 1.651l3.62 3.102-1.106 4.637c-.194.813.691 1.456 1.405 1.02L10 15.591l4.069 2.485c.713.436 1.598-.207 1.404-1.02l-1.106-4.637 3.62-3.102c.635-.544.297-1.584-.536-1.65l-4.752-.382-1.831-4.401z" clip-rule="evenodd" />
|
||||||
<span v-else>{{ oil.gallons_ordered }}</span>
|
</svg>
|
||||||
</td>
|
PRIME
|
||||||
<td>
|
</span>
|
||||||
<div class="flex flex-col gap-1">
|
<span v-if="oil.same_day" class="special-tag tag-sameday">
|
||||||
<span v-if="oil.prime" class="badge badge-error badge-xs">PRIME</span>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-3 h-3">
|
||||||
<span v-if="oil.same_day" class="badge badge-error badge-xs">SAME DAY</span>
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm.75-13a.75.75 0 00-1.5 0v5c0 .414.336.75.75.75h4a.75.75 0 000-1.5h-3.25V5z" clip-rule="evenodd" />
|
||||||
<span v-if="oil.emergency" class="badge badge-error badge-xs">EMERGENCY</span>
|
</svg>
|
||||||
|
SAME DAY
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
<td class="text-right">
|
<div class="status-badge" :class="getStatusClass(oil.delivery_status)">
|
||||||
<div class="flex items-center justify-end gap-2">
|
<span class="status-dot"></span>
|
||||||
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-sm btn-ghost">View</router-link>
|
{{ getStatusText(oil.delivery_status) }}
|
||||||
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-sm btn-secondary">Edit</router-link>
|
</div>
|
||||||
<router-link :to="{ name: 'finalizeTicket', params: { id: oil.id } }" v-if="oil.delivery_status != 10" class="btn btn-sm btn-accent">Finalize</router-link>
|
</td>
|
||||||
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-sm btn-success">Print</router-link>
|
<td>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 text-base-content/40 mt-0.5 flex-shrink-0">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{{ oil.customer_town }}</div>
|
||||||
|
<div class="text-sm text-base-content/60">{{ oil.customer_address }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div v-if="oil.customer_asked_for_fill == 1" class="gallons-badge gallons-fill">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
||||||
|
<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z" />
|
||||||
|
</svg>
|
||||||
|
FILL
|
||||||
|
</div>
|
||||||
|
<div v-else class="gallons-badge">
|
||||||
|
{{ oil.gallons_ordered }}
|
||||||
|
<span class="text-xs text-base-content/50">gal</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex items-center justify-end gap-1">
|
||||||
|
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-xs btn-ghost" title="View">
|
||||||
|
View
|
||||||
|
</router-link>
|
||||||
|
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-xs btn-info btn-outline" title="Edit">
|
||||||
|
Edit
|
||||||
|
</router-link>
|
||||||
|
<router-link v-if="oil.delivery_status != 10" :to="{ name: 'finalizeTicket', params: { id: oil.id } }" class="btn btn-xs btn-accent btn-outline" title="Finalize">
|
||||||
|
Finalize
|
||||||
|
</router-link>
|
||||||
|
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-xs btn-success btn-outline" title="Print">
|
||||||
|
Print
|
||||||
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- MOBILE VIEW: Cards -->
|
<!-- MOBILE CARDS -->
|
||||||
<div class="xl:hidden space-y-4">
|
<div class="xl:hidden space-y-3 p-4">
|
||||||
<template v-for="oil in deliveries" :key="oil.id">
|
<!-- Mobile Card -->
|
||||||
<div v-if="oil.id" class="card bg-base-100 shadow-md">
|
<div
|
||||||
<div class="card-body p-4">
|
v-for="oil in filteredDeliveries"
|
||||||
<div class="flex justify-between items-start">
|
:key="oil.id"
|
||||||
|
class="mobile-card"
|
||||||
|
:class="{
|
||||||
|
'mobile-card-urgent': oil.emergency,
|
||||||
|
'mobile-card-prime': oil.prime && !oil.emergency,
|
||||||
|
'mobile-card-sameday': oil.same_day && !oil.prime && !oil.emergency
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<!-- Card Header -->
|
||||||
|
<div class="flex items-start justify-between mb-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="avatar placeholder">
|
||||||
|
<div class="bg-gradient-to-br from-primary/20 to-primary/5 text-primary rounded-lg w-12 h-12">
|
||||||
|
<span class="text-base font-bold">{{ getInitials(oil.customer_name) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="card-title text-base">{{ oil.customer_name }}</h2>
|
<router-link
|
||||||
<p class="text-xs text-gray-400">Delivery #{{ oil.id }}</p>
|
v-if="oil.customer_id"
|
||||||
</div>
|
:to="{ name: 'customerProfile', params: { id: oil.customer_id } }"
|
||||||
<div class="badge" :class="{
|
class="font-semibold text-lg hover:text-primary"
|
||||||
'badge-warning': oil.delivery_status == 0,
|
>
|
||||||
'badge-success': oil.delivery_status == 10,
|
{{ oil.customer_name }}
|
||||||
'badge-info': oil.delivery_status == 2,
|
</router-link>
|
||||||
'badge-error': [1, 5].includes(oil.delivery_status),
|
<span v-else class="font-semibold text-lg">{{ oil.customer_name }}</span>
|
||||||
}">
|
<p class="text-sm text-base-content/50">#{{ oil.id }}</p>
|
||||||
<span v-if="oil.delivery_status == 0">Waiting</span>
|
|
||||||
<span v-else-if="oil.delivery_status == 1">Cancelled</span>
|
|
||||||
<span v-else-if="oil.delivery_status == 2">Out_for_Delivery</span>
|
|
||||||
<span v-else-if="oil.delivery_status == 3">Tomorrow</span>
|
|
||||||
<span v-else-if="oil.delivery_status == 4">Partial</span>
|
|
||||||
<span v-else-if="oil.delivery_status == 5">Issue</span>
|
|
||||||
<span v-else-if="oil.delivery_status == 10">Finalized</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="status-badge" :class="getStatusClass(oil.delivery_status)">
|
||||||
<div class="flex gap-2 mt-2">
|
<span class="status-dot"></span>
|
||||||
<div v-if="oil.prime" class="badge badge-error badge-sm">PRIME</div>
|
{{ getStatusText(oil.delivery_status) }}
|
||||||
<div v-if="oil.same_day" class="badge badge-error badge-sm">SAME DAY</div>
|
|
||||||
<div v-if="oil.emergency" class="badge badge-error badge-sm">EMERGENCY</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-sm mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
|
|
||||||
<p><strong class="font-semibold">Address:</strong> {{ oil.customer_address }}</p>
|
|
||||||
<p><strong class="font-semibold">Town:</strong> {{ oil.customer_town }}</p>
|
|
||||||
<p><strong class="font-semibold">Gallons:</strong>
|
|
||||||
<span v-if="oil.customer_asked_for_fill" class="badge badge-info badge-xs">FILL</span>
|
|
||||||
<span v-else>{{ oil.gallons_ordered }}</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-actions justify-end flex-wrap gap-2 mt-2">
|
|
||||||
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-sm btn-ghost">View</router-link>
|
|
||||||
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-sm btn-secondary">Edit</router-link>
|
|
||||||
<router-link :to="{ name: 'finalizeTicket', params: { id: oil.id } }" v-if="oil.delivery_status != 10" class="btn btn-sm btn-accent">Finalize</router-link>
|
|
||||||
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-sm btn-success">Print</router-link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Special Tags -->
|
||||||
|
<div v-if="oil.emergency || oil.prime || oil.same_day" class="flex gap-2 mb-3">
|
||||||
|
<span v-if="oil.emergency" class="special-tag tag-emergency">EMERGENCY</span>
|
||||||
|
<span v-if="oil.prime" class="special-tag tag-prime">PRIME</span>
|
||||||
|
<span v-if="oil.same_day" class="special-tag tag-sameday">SAME DAY</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Details Grid -->
|
||||||
|
<div class="grid grid-cols-2 gap-3 mb-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span class="text-base-content/50">Location</span>
|
||||||
|
<p class="font-medium">{{ oil.customer_town }}</p>
|
||||||
|
<p class="text-base-content/60 text-xs">{{ oil.customer_address }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-base-content/50">Gallons</span>
|
||||||
|
<p class="font-medium">
|
||||||
|
<span v-if="oil.customer_asked_for_fill == 1" class="badge badge-info">FILL</span>
|
||||||
|
<span v-else>{{ oil.gallons_ordered }} gal</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex gap-2 pt-3 border-t border-base-content/10">
|
||||||
|
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-sm btn-ghost flex-1">
|
||||||
|
View
|
||||||
|
</router-link>
|
||||||
|
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-sm btn-info btn-outline flex-1">
|
||||||
|
Edit
|
||||||
|
</router-link>
|
||||||
|
<router-link v-if="oil.delivery_status != 10" :to="{ name: 'finalizeTicket', params: { id: oil.id } }" class="btn btn-sm btn-accent btn-outline flex-1">
|
||||||
|
Finalize
|
||||||
|
</router-link>
|
||||||
|
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-sm btn-success btn-outline">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6.72 13.829c-.24.03-.48.062-.72.096m.72-.096a42.415 42.415 0 0110.56 0m-10.56 0L6.34 18m10.94-4.171c.24.03.48.062.72.096m-.72-.096L17.66 18m0 0l.229 2.523a1.125 1.125 0 01-1.12 1.227H7.231c-.662 0-1.18-.568-1.12-1.227L6.34 18m11.318 0h1.091A2.25 2.25 0 0021 15.75V9.456c0-1.081-.768-2.015-1.837-2.175a48.055 48.055 0 00-1.913-.247M6.34 18H5.25A2.25 2.25 0 013 15.75V9.456c0-1.081.768-2.015 1.837-2.175a48.041 48.041 0 011.913-.247m10.5 0a48.536 48.536 0 00-10.5 0m10.5 0V3.375c0-.621-.504-1.125-1.125-1.125h-8.25c-.621 0-1.125.504-1.125 1.125v3.659M18 10.5h.008v.008H18V10.5zm-3 0h.008v.008H15V10.5z" />
|
||||||
|
</svg>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
<div class="mt-6 flex justify-center">
|
<div v-if="filteredDeliveries.length > 0" class="p-4 border-t border-base-content/10">
|
||||||
<pagination @paginate="getPage" :records="recordsLength" v-model="page" :per-page="50" :options="options">
|
<div class="flex items-center justify-between">
|
||||||
</pagination>
|
<span class="text-sm text-base-content/60">
|
||||||
|
Showing {{ filteredDeliveries.length }} of {{ deliveries.length }} deliveries
|
||||||
|
</span>
|
||||||
|
<pagination
|
||||||
|
@paginate="getPage"
|
||||||
|
:records="recordsLength"
|
||||||
|
v-model="page"
|
||||||
|
:per-page="50"
|
||||||
|
:options="options"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, markRaw } from 'vue'
|
import { ref, computed, onMounted, markRaw } from 'vue'
|
||||||
import { deliveryService } from '../../../services/deliveryService'
|
import { deliveryService } from '../../../services/deliveryService'
|
||||||
import authService from '../../../services/authService'
|
import authService from '../../../services/authService'
|
||||||
import { Delivery } from '../../../types/models'
|
import { Delivery } from '../../../types/models'
|
||||||
import Header from '../../../layouts/headers/headerauth.vue'
|
|
||||||
import PaginationComp from '../../../components/pagination.vue'
|
import PaginationComp from '../../../components/pagination.vue'
|
||||||
import SideBar from '../../../layouts/sidebar/sidebar.vue'
|
import { notify } from "@kyvg/vue3-notification"
|
||||||
import { notify } from "@kyvg/vue3-notification";
|
|
||||||
|
|
||||||
interface TownTotal {
|
interface TownTotal {
|
||||||
town: string;
|
town: string;
|
||||||
@@ -192,82 +357,139 @@ const grand_total = ref(0)
|
|||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const perPage = ref(50)
|
const perPage = ref(50)
|
||||||
const recordsLength = ref(0)
|
const recordsLength = ref(0)
|
||||||
|
const loading = ref(true)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const filterTown = ref('')
|
||||||
|
const sortBy = ref<'id' | 'gallons'>('id')
|
||||||
|
const sortDir = ref<'asc' | 'desc'>('desc')
|
||||||
const options = ref({
|
const options = ref({
|
||||||
edgeNavigation: false,
|
edgeNavigation: false,
|
||||||
format: false,
|
format: false,
|
||||||
template: markRaw(PaginationComp)
|
template: markRaw(PaginationComp)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const filteredDeliveries = computed(() => {
|
||||||
|
let result = [...deliveries.value]
|
||||||
|
|
||||||
|
// Filter by town
|
||||||
|
if (filterTown.value) {
|
||||||
|
result = result.filter(d => d.customer_town === filterTown.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by search
|
||||||
|
if (searchQuery.value) {
|
||||||
|
const query = searchQuery.value.toLowerCase()
|
||||||
|
result = result.filter(d =>
|
||||||
|
d.customer_name?.toLowerCase().includes(query) ||
|
||||||
|
d.customer_address?.toLowerCase().includes(query) ||
|
||||||
|
d.customer_town?.toLowerCase().includes(query) ||
|
||||||
|
d.id?.toString().includes(query)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
result.sort((a, b) => {
|
||||||
|
let aVal: number, bVal: number
|
||||||
|
if (sortBy.value === 'id') {
|
||||||
|
aVal = a.id
|
||||||
|
bVal = b.id
|
||||||
|
} else {
|
||||||
|
aVal = a.customer_asked_for_fill ? 9999 : (a.gallons_ordered || 0)
|
||||||
|
bVal = b.customer_asked_for_fill ? 9999 : (b.gallons_ordered || 0)
|
||||||
|
}
|
||||||
|
return sortDir.value === 'desc' ? bVal - aVal : aVal - bVal
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
// Functions
|
// Functions
|
||||||
const getPage = (pageVal: any) => {
|
const getInitials = (name: string) => {
|
||||||
deliveries.value = [];
|
if (!name) return '?'
|
||||||
|
const parts = name.split(' ')
|
||||||
|
return parts.length > 1
|
||||||
|
? `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase()
|
||||||
|
: name.substring(0, 2).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusClass = (status: number) => {
|
||||||
|
switch (status) {
|
||||||
|
case 0: return 'status-waiting'
|
||||||
|
case 1: return 'status-cancelled'
|
||||||
|
case 2: return 'status-outfordelivery'
|
||||||
|
case 5: return 'status-issue'
|
||||||
|
case 10: return 'status-finalized'
|
||||||
|
default: return 'status-default'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusText = (status: number) => {
|
||||||
|
switch (status) {
|
||||||
|
case 0: return 'Waiting'
|
||||||
|
case 1: return 'Cancelled'
|
||||||
|
case 2: return 'Out for Delivery'
|
||||||
|
case 3: return 'Tomorrow'
|
||||||
|
case 4: return 'Partial'
|
||||||
|
case 5: return 'Issue'
|
||||||
|
case 10: return 'Finalized'
|
||||||
|
default: return 'Unknown'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSort = (field: 'id' | 'gallons') => {
|
||||||
|
if (sortBy.value === field) {
|
||||||
|
sortDir.value = sortDir.value === 'desc' ? 'asc' : 'desc'
|
||||||
|
} else {
|
||||||
|
sortBy.value = field
|
||||||
|
sortDir.value = 'desc'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleTownFilter = (town: string) => {
|
||||||
|
filterTown.value = filterTown.value === town ? '' : town
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPage = (pageVal: number) => {
|
||||||
|
deliveries.value = []
|
||||||
|
loading.value = true
|
||||||
get_oil_orders(pageVal)
|
get_oil_orders(pageVal)
|
||||||
}
|
}
|
||||||
|
|
||||||
const userStatus = async () => {
|
const userStatus = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await authService.whoami();
|
const response = await authService.whoami()
|
||||||
if (response.data.ok) {
|
if (response.data.ok) {
|
||||||
user.value = response.data.user;
|
user.value = response.data.user
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
user.value = null;
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
user.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mod = (date: any) => new Date(date).getTime()
|
|
||||||
|
|
||||||
const get_oil_orders = async (pageVal: number) => {
|
const get_oil_orders = async (pageVal: number) => {
|
||||||
try {
|
try {
|
||||||
const response = await deliveryService.getOutForDelivery(pageVal)
|
const response = await deliveryService.getOutForDelivery(pageVal)
|
||||||
const data = response.data?.deliveries || []
|
const data = response.data?.deliveries || []
|
||||||
deliveries.value = Array.isArray(data) ? data : []
|
deliveries.value = Array.isArray(data) ? data : []
|
||||||
// Sort deliveries by Delivery # (id) in descending order
|
|
||||||
if (deliveries.value.length > 0) {
|
|
||||||
deliveries.value.sort((a, b) => b.id - a.id);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching out for delivery:', error)
|
console.error('Error fetching out for delivery:', error)
|
||||||
deliveries.value = []
|
deliveries.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const get_totals = async () => {
|
const get_totals = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await deliveryService.getTodayTotals();
|
const response = await deliveryService.getTodayTotals()
|
||||||
totals.value = response.data.totals || [];
|
totals.value = response.data.totals || []
|
||||||
grand_total.value = response.data.grand_total || 0;
|
grand_total.value = response.data.grand_total || 0
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching totals:', error);
|
console.error('Error fetching totals:', error)
|
||||||
totals.value = []
|
totals.value = []
|
||||||
grand_total.value = 0
|
grand_total.value = 0
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const deleteCall = async (delivery_id: number) => {
|
|
||||||
try {
|
|
||||||
const response = await deliveryService.delete(delivery_id);
|
|
||||||
if (response.data.ok) {
|
|
||||||
notify({
|
|
||||||
title: "Success",
|
|
||||||
text: "deleted delivery",
|
|
||||||
type: "success",
|
|
||||||
});
|
|
||||||
getPage(page.value)
|
|
||||||
} else {
|
|
||||||
notify({
|
|
||||||
title: "Failure",
|
|
||||||
text: "error deleting delivery",
|
|
||||||
type: "success",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
notify({
|
|
||||||
title: "Failure",
|
|
||||||
text: "error deleting delivery",
|
|
||||||
type: "success",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
@@ -278,4 +500,187 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped>
|
||||||
|
/* 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 p-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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -10,30 +10,88 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content Card -->
|
<!-- Main Content Card -->
|
||||||
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
|
<!-- Page Header with Stats -->
|
||||||
<!-- Header: Title and Count (No Search Input) -->
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mt-4 mb-6">
|
||||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-4">
|
<div>
|
||||||
<h2 class="text-lg font-bold">Deliveries Scheduled</h2>
|
<h1 class="text-2xl md:text-3xl font-bold flex items-center gap-3">
|
||||||
<!-- <div class="badge badge-ghost">{{ recordsLength }} deliveries found</div> -->
|
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center shadow-lg">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-primary-content">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5m-9-6h.008v.008H12v-.008zM12 15h.008v.008H12V15zm0 2.25h.008v.008H12v-.008zM9.75 15h.008v.008H9.75V15zm0 2.25h.008v.008H9.75v-.008zM7.5 15h.008v.008H7.5V15zm0 2.25h.008v.008H7.5v-.008zM14.25 15h.008v.008H14.25V15zm0 2.25h.008v.008H14.25v-.008zM16.5 15h.008v.008H16.5V15zm0 2.25h.008v.008H16.5v-.008z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
Scheduled Deliveries
|
||||||
|
</h1>
|
||||||
|
<p class="text-base-content/60 mt-1 ml-13">Deliveries scheduled for tomorrow</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<!-- Quick Stats -->
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
<!-- Total Gallons -->
|
<div class="stat-pill">
|
||||||
<div v-if="grand_total > 0 || totals.length > 0" class="mb-4">
|
<span class="stat-pill-value">{{ deliveries.length }}</span>
|
||||||
<div v-if="grand_total > 0" class="mb-2">
|
<span class="stat-pill-label">Deliveries</span>
|
||||||
<span class="badge badge-accent">Total: {{ grand_total }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="stat-pill stat-pill-success">
|
||||||
<span v-for="total in totals" :key="total.town" class="badge badge-primary">
|
<span class="stat-pill-value">{{ grand_total.toLocaleString() }}</span>
|
||||||
{{ total.town }}: {{ total.gallons }}
|
<span class="stat-pill-label">Total Gallons</span>
|
||||||
</span>
|
</div>
|
||||||
|
<div class="stat-pill stat-pill-info">
|
||||||
|
<span class="stat-pill-value">{{ totals.length }}</span>
|
||||||
|
<span class="stat-pill-label">Towns</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Town Breakdown Bar -->
|
||||||
|
<div v-if="totals.length > 0" class="mb-6 overflow-x-auto">
|
||||||
|
<div class="flex gap-2 pb-2">
|
||||||
|
<button
|
||||||
|
v-for="total in totals"
|
||||||
|
:key="total.town"
|
||||||
|
@click="toggleTownFilter(total.town)"
|
||||||
|
class="town-chip"
|
||||||
|
:class="{ 'town-chip-active': filterTown === total.town }"
|
||||||
|
>
|
||||||
|
<span class="font-semibold">{{ total.town }}</span>
|
||||||
|
<span class="town-chip-count">{{ total.gallons }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="filterTown"
|
||||||
|
@click="filterTown = ''"
|
||||||
|
class="town-chip town-chip-clear"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
Clear Filter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search & Actions Bar -->
|
||||||
|
<div class="flex flex-col sm:flex-row gap-3 mb-4">
|
||||||
|
<div class="relative flex-1 max-w-md">
|
||||||
|
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 text-base-content/40">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="Search by name, address, or delivery #..."
|
||||||
|
class="input input-bordered w-full pl-10 bg-base-200/50 focus:bg-base-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Table Card -->
|
||||||
|
<div class="modern-table-card">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- DESKTOP VIEW: Table -->
|
<!-- DESKTOP VIEW: Table -->
|
||||||
<div class="overflow-x-auto hidden xl:block">
|
<div class="hidden xl:block overflow-x-auto">
|
||||||
<table class="table w-full">
|
<table class="modern-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Delivery #</th>
|
<th>Delivery #</th>
|
||||||
@@ -46,7 +104,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<template v-for="oil in deliveries" :key="oil.id">
|
<template v-for="oil in filteredDeliveries" :key="oil.id">
|
||||||
<tr v-if="oil.id" class="hover:bg-blue-600 hover:text-white">
|
<tr v-if="oil.id" class="hover:bg-blue-600 hover:text-white">
|
||||||
<td>{{ oil.id }}</td>
|
<td>{{ oil.id }}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -76,8 +134,8 @@
|
|||||||
<div class="text-xs opacity-70">{{ oil.customer_address }}</div>
|
<div class="text-xs opacity-70">{{ oil.customer_address }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span v-if="oil.customer_asked_for_fill == 1" class="badge badge-info">FILL</span>
|
<span v-if="oil.customer_asked_for_fill == 1" class="badge badge-info text-lg h-auto py-1">FILL</span>
|
||||||
<span v-else>{{ oil.gallons_ordered }}</span>
|
<span v-else class="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">{{ oil.gallons_ordered }} gal</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
@@ -86,11 +144,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<div class="flex items-center justify-end gap-2">
|
<div class="flex items-center justify-end gap-1">
|
||||||
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-sm btn-ghost">View</router-link>
|
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-xs btn-ghost">View</router-link>
|
||||||
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-sm btn-secondary">Edit</router-link>
|
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-xs btn-info btn-outline">Edit</router-link>
|
||||||
<router-link :to="{ name: 'finalizeTicket', params: { id: oil.id } }" v-if="oil.delivery_status != 10" class="btn btn-sm btn-accent">Finalize</router-link>
|
<router-link :to="{ name: 'finalizeTicket', params: { id: oil.id } }" v-if="oil.delivery_status != 10" class="btn btn-xs btn-accent btn-outline">Finalize</router-link>
|
||||||
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-sm btn-success">Print</router-link>
|
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-xs btn-success btn-outline">Print</router-link>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -101,13 +159,22 @@
|
|||||||
|
|
||||||
<!-- MOBILE VIEW: Cards -->
|
<!-- MOBILE VIEW: Cards -->
|
||||||
<div class="xl:hidden space-y-4">
|
<div class="xl:hidden space-y-4">
|
||||||
<template v-for="oil in deliveries" :key="oil.id">
|
<template v-for="oil in filteredDeliveries" :key="oil.id">
|
||||||
<div v-if="oil.id" class="card bg-base-100 shadow-md">
|
<div
|
||||||
<div class="card-body p-4">
|
v-if="oil.id"
|
||||||
|
class="mobile-card"
|
||||||
|
:class="{
|
||||||
|
'mobile-card-urgent': oil.emergency,
|
||||||
|
'mobile-card-prime': oil.prime && !oil.emergency,
|
||||||
|
'mobile-card-sameday': oil.same_day && !oil.prime && !oil.emergency
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<!-- Card content -->
|
||||||
|
<div class="p-3">
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="card-title text-base">{{ oil.customer_name }}</h2>
|
<h2 class="text-base font-bold">{{ oil.customer_name }}</h2>
|
||||||
<p class="text-xs text-gray-400">Delivery #{{ oil.id }}</p>
|
<p class="text-xs text-base-content/60">Delivery #{{ oil.id }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="badge" :class="{
|
<div class="badge" :class="{
|
||||||
'badge-warning': oil.delivery_status == 0,
|
'badge-warning': oil.delivery_status == 0,
|
||||||
@@ -128,23 +195,45 @@
|
|||||||
<div class="flex gap-2 mt-2">
|
<div class="flex gap-2 mt-2">
|
||||||
<div v-if="oil.prime" class="badge badge-error badge-sm">PRIME</div>
|
<div v-if="oil.prime" class="badge badge-error badge-sm">PRIME</div>
|
||||||
<div v-if="oil.same_day" class="badge badge-error badge-sm">SAME DAY</div>
|
<div v-if="oil.same_day" class="badge badge-error badge-sm">SAME DAY</div>
|
||||||
|
<div v-if="oil.emergency" class="badge badge-error badge-sm">EMERGENCY</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
|
<div class="text-sm mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
|
||||||
<p><strong class="font-semibold">Address:</strong> {{ oil.customer_address }}</p>
|
<div>
|
||||||
<p><strong class="font-semibold">Town:</strong> {{ oil.customer_town }}</p>
|
<p class="text-xs text-base-content/50">Address</p>
|
||||||
<p><strong class="font-semibold">Gallons:</strong>
|
<p class="font-medium">{{ oil.customer_address }}</p>
|
||||||
<span v-if="oil.customer_asked_for_fill" class="badge badge-info badge-xs">FILL</span>
|
<p class="text-xs">{{ oil.customer_town }}</p>
|
||||||
<span v-else>{{ oil.gallons_ordered }}</span>
|
</div>
|
||||||
</p>
|
<div>
|
||||||
<p><strong class="font-semibold">Date:</strong> {{ oil.expected_delivery_date }}</p>
|
<p class="text-xs text-base-content/50">Gallons</p>
|
||||||
|
<p class="font-bold text-lg text-success">
|
||||||
|
<span v-if="oil.customer_asked_for_fill" class="badge badge-info badge-xs">FILL</span>
|
||||||
|
<span v-else>{{ oil.gallons_ordered }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-base-content/50">Payment</p>
|
||||||
|
<span v-if="oil.payment_type == 0">Cash</span>
|
||||||
|
<span v-else-if="oil.payment_type == 1">CC</span>
|
||||||
|
<span v-else-if="oil.payment_type == 2">Cash/CC</span>
|
||||||
|
<span v-else-if="oil.payment_type == 3">Check</span>
|
||||||
|
<span v-else-if="oil.payment_type == 4">Other</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-base-content/50">Date</p>
|
||||||
|
<p class="font-medium">{{ oil.expected_delivery_date }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-actions justify-end flex-wrap gap-2 mt-2">
|
<div class="flex gap-2 pt-3 mt-3 border-t border-base-content/10">
|
||||||
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-sm btn-ghost">View</router-link>
|
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-sm btn-ghost flex-1">View</router-link>
|
||||||
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-sm btn-secondary">Edit</router-link>
|
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-sm btn-info btn-outline flex-1">Edit</router-link>
|
||||||
<router-link :to="{ name: 'finalizeTicket', params: { id: oil.id } }" v-if="oil.delivery_status != 10" class="btn btn-sm btn-accent">Finalize</router-link>
|
<router-link :to="{ name: 'finalizeTicket', params: { id: oil.id } }" v-if="oil.delivery_status != 10" class="btn btn-sm btn-accent btn-outline flex-1">Finalize</router-link>
|
||||||
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-sm btn-success">Print</router-link>
|
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-sm btn-success btn-outline">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6.72 13.829c-.24.03-.48.062-.72.096m.72-.096a42.415 42.415 0 0110.56 0m-10.56 0L6.34 18m10.94-4.171c.24.03.48.062.72.096m-.72-.096L17.66 18m0 0l.229 2.523a1.125 1.125 0 01-1.12 1.227H7.231c-.662 0-1.18-.568-1.12-1.227L6.34 18m11.318 0h1.091A2.25 2.25 0 0021 15.75V9.456c0-1.081-.768-2.015-1.837-2.175a48.055 48.055 0 00-1.913-.247M6.34 18H5.25A2.25 2.25 0 013 15.75V9.456c0-1.081.768-2.015 1.837-2.175a48.041 48.041 0 011.913-.247m10.5 0a48.536 48.536 0 00-10.5 0m10.5 0V3.375c0-.621-.504-1.125-1.125-1.125h-8.25c-.621 0-1.125.504-1.125 1.125v3.659M18 10.5h.008v.008H18V10.5zm-3 0h.008v.008H15V10.5z" />
|
||||||
|
</svg>
|
||||||
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -163,7 +252,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, markRaw } from 'vue'
|
import { ref, onMounted, markRaw, computed } from 'vue'
|
||||||
import { deliveryService } from '../../../services/deliveryService'
|
import { deliveryService } from '../../../services/deliveryService'
|
||||||
import authService from '../../../services/authService'
|
import authService from '../../../services/authService'
|
||||||
import { printService } from '../../../services/printService'
|
import { printService } from '../../../services/printService'
|
||||||
@@ -187,15 +276,44 @@ const grand_total = ref(0)
|
|||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const perPage = ref(50)
|
const perPage = ref(50)
|
||||||
const recordsLength = ref(0)
|
const recordsLength = ref(0)
|
||||||
|
const loading = ref(true)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const filterTown = ref('')
|
||||||
const options = ref({
|
const options = ref({
|
||||||
edgeNavigation: false,
|
edgeNavigation: false,
|
||||||
format: false,
|
format: false,
|
||||||
template: markRaw(PaginationComp)
|
template: markRaw(PaginationComp)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const filteredDeliveries = computed(() => {
|
||||||
|
let result = [...deliveries.value]
|
||||||
|
|
||||||
|
// Filter by town
|
||||||
|
if (filterTown.value) {
|
||||||
|
result = result.filter(d => d.customer_town === filterTown.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by search
|
||||||
|
if (searchQuery.value) {
|
||||||
|
const query = searchQuery.value.toLowerCase()
|
||||||
|
result = result.filter(d =>
|
||||||
|
d.customer_name?.toLowerCase().includes(query) ||
|
||||||
|
d.customer_address?.toLowerCase().includes(query) ||
|
||||||
|
d.customer_town?.toLowerCase().includes(query) ||
|
||||||
|
d.id?.toString().includes(query)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
// Functions
|
// Functions
|
||||||
|
const toggleTownFilter = (town: string) => {
|
||||||
|
filterTown.value = filterTown.value === town ? '' : town
|
||||||
|
}
|
||||||
const getPage = (pageVal: any) => {
|
const getPage = (pageVal: any) => {
|
||||||
deliveries.value = [];
|
deliveries.value = [];
|
||||||
|
loading.value = true
|
||||||
get_oil_orders(pageVal)
|
get_oil_orders(pageVal)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,6 +335,8 @@ const get_oil_orders = async (pageVal: number) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching tomorrow deliveries:', error)
|
console.error('Error fetching tomorrow deliveries:', error)
|
||||||
deliveries.value = []
|
deliveries.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,30 +10,88 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content Card -->
|
<!-- Main Content Card -->
|
||||||
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
|
<!-- Page Header with Stats -->
|
||||||
<!-- Header: Title and Count -->
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mt-4 mb-6">
|
||||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-4">
|
<div>
|
||||||
<h2 class="text-lg font-bold">Deliveries Awaiting Dispatch</h2>
|
<h1 class="text-2xl md:text-3xl font-bold flex items-center gap-3">
|
||||||
<!-- <div class="badge badge-ghost">{{ recordsLength }} deliveries found</div> -->
|
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center shadow-lg">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-primary-content">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
Waiting Deliveries
|
||||||
|
</h1>
|
||||||
|
<p class="text-base-content/60 mt-1 ml-13">Awaiting dispatch</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<!-- Quick Stats -->
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
<!-- Total Gallons -->
|
<div class="stat-pill">
|
||||||
<div v-if="grand_total > 0 || totals.length > 0" class="mb-4">
|
<span class="stat-pill-value">{{ deliveries.length }}</span>
|
||||||
<div v-if="grand_total > 0" class="mb-2">
|
<span class="stat-pill-label">Deliveries</span>
|
||||||
<span class="badge badge-accent">Total: {{ grand_total }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="stat-pill stat-pill-success">
|
||||||
<span v-for="total in totals" :key="total.town" class="badge badge-primary">
|
<span class="stat-pill-value">{{ grand_total.toLocaleString() }}</span>
|
||||||
{{ total.town }}: {{ total.gallons }}
|
<span class="stat-pill-label">Total Gallons</span>
|
||||||
</span>
|
</div>
|
||||||
|
<div class="stat-pill stat-pill-info">
|
||||||
|
<span class="stat-pill-value">{{ totals.length }}</span>
|
||||||
|
<span class="stat-pill-label">Towns</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Town Breakdown Bar -->
|
||||||
|
<div v-if="totals.length > 0" class="mb-6 overflow-x-auto">
|
||||||
|
<div class="flex gap-2 pb-2">
|
||||||
|
<button
|
||||||
|
v-for="total in totals"
|
||||||
|
:key="total.town"
|
||||||
|
@click="toggleTownFilter(total.town)"
|
||||||
|
class="town-chip"
|
||||||
|
:class="{ 'town-chip-active': filterTown === total.town }"
|
||||||
|
>
|
||||||
|
<span class="font-semibold">{{ total.town }}</span>
|
||||||
|
<span class="town-chip-count">{{ total.gallons }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="filterTown"
|
||||||
|
@click="filterTown = ''"
|
||||||
|
class="town-chip town-chip-clear"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
Clear Filter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search & Actions Bar -->
|
||||||
|
<div class="flex flex-col sm:flex-row gap-3 mb-4">
|
||||||
|
<div class="relative flex-1 max-w-md">
|
||||||
|
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 text-base-content/40">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="Search by name, address, or delivery #..."
|
||||||
|
class="input input-bordered w-full pl-10 bg-base-200/50 focus:bg-base-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Table Card -->
|
||||||
|
<div class="modern-table-card">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- DESKTOP VIEW: Table -->
|
<!-- DESKTOP VIEW: Table -->
|
||||||
<div class="overflow-x-auto hidden xl:block">
|
<div class="hidden xl:block overflow-x-auto">
|
||||||
<table class="table w-full">
|
<table class="modern-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Delivery #</th>
|
<th>Delivery #</th>
|
||||||
@@ -47,7 +105,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<template v-for="oil in deliveries" :key="oil.id">
|
<template v-for="oil in filteredDeliveries" :key="oil.id">
|
||||||
<tr v-if="oil.id" class="hover:bg-blue-600 hover:text-white">
|
<tr v-if="oil.id" class="hover:bg-blue-600 hover:text-white">
|
||||||
<td>{{ oil.id }}</td>
|
<td>{{ oil.id }}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -66,8 +124,8 @@
|
|||||||
<div class="text-xs opacity-70">{{ oil.customer_address }}</div>
|
<div class="text-xs opacity-70">{{ oil.customer_address }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span v-if="oil.customer_asked_for_fill == 1" class="badge badge-info">FILL</span>
|
<span v-if="oil.customer_asked_for_fill == 1" class="badge badge-info text-lg h-auto py-1">FILL</span>
|
||||||
<span v-else>{{ oil.gallons_ordered }}</span>
|
<span v-else class="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">{{ oil.gallons_ordered }} gal</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ oil.expected_delivery_date }}</td>
|
<td>{{ oil.expected_delivery_date }}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -77,11 +135,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<div class="flex items-center justify-end gap-2">
|
<div class="flex items-center justify-end gap-1">
|
||||||
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-sm btn-ghost">View</router-link>
|
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-xs btn-ghost">View</router-link>
|
||||||
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-sm btn-secondary">Edit</router-link>
|
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-xs btn-info btn-outline">Edit</router-link>
|
||||||
<router-link :to="{ name: 'finalizeTicket', params: { id: oil.id } }" class="btn btn-sm btn-accent">Finalize</router-link>
|
<router-link :to="{ name: 'finalizeTicket', params: { id: oil.id } }" class="btn btn-xs btn-accent btn-outline">Finalize</router-link>
|
||||||
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-sm btn-success">Print</router-link>
|
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-xs btn-success btn-outline">Print</router-link>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -92,13 +150,21 @@
|
|||||||
|
|
||||||
<!-- MOBILE VIEW: Cards -->
|
<!-- MOBILE VIEW: Cards -->
|
||||||
<div class="xl:hidden space-y-4">
|
<div class="xl:hidden space-y-4">
|
||||||
<template v-for="oil in deliveries" :key="oil.id">
|
<template v-for="oil in filteredDeliveries" :key="oil.id">
|
||||||
<div v-if="oil.id" class="card bg-base-100 shadow-md">
|
<div
|
||||||
<div class="card-body p-4">
|
v-if="oil.id"
|
||||||
|
class="mobile-card"
|
||||||
|
:class="{
|
||||||
|
'mobile-card-urgent': oil.emergency,
|
||||||
|
'mobile-card-prime': oil.prime && !oil.emergency,
|
||||||
|
'mobile-card-sameday': oil.same_day && !oil.prime && !oil.emergency
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="p-3">
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="card-title text-base">{{ oil.customer_name }}</h2>
|
<h2 class="text-base font-bold">{{ oil.customer_name }}</h2>
|
||||||
<p class="text-xs text-gray-400">Delivery #{{ oil.id }}</p>
|
<p class="text-xs text-base-content/60">Delivery #{{ oil.id }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="badge badge-warning">
|
<div class="badge badge-warning">
|
||||||
Waiting
|
Waiting
|
||||||
@@ -110,21 +176,30 @@
|
|||||||
<div v-if="oil.same_day" class="badge badge-error badge-sm">SAME DAY</div>
|
<div v-if="oil.same_day" class="badge badge-error badge-sm">SAME DAY</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
|
<div class="text-sm mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
|
||||||
<p><strong class="font-semibold">Address:</strong> {{ oil.customer_address }}</p>
|
<div>
|
||||||
<p><strong class="font-semibold">Town:</strong> {{ oil.customer_town }}</p>
|
<p class="text-xs text-base-content/50">Address</p>
|
||||||
<p><strong class="font-semibold">Gallons:</strong>
|
<p class="font-medium">{{ oil.customer_address }}</p>
|
||||||
<span v-if="oil.customer_asked_for_fill" class="badge badge-info badge-xs">FILL</span>
|
<p class="text-xs">{{ oil.customer_town }}</p>
|
||||||
<span v-else>{{ oil.gallons_ordered }}</span>
|
</div>
|
||||||
</p>
|
<div>
|
||||||
<p><strong class="font-semibold">Date:</strong> {{ oil.expected_delivery_date }}</p>
|
<p class="text-xs text-base-content/50">Gallons</p>
|
||||||
|
<p class="font-bold text-lg text-success">
|
||||||
|
<span v-if="oil.customer_asked_for_fill" class="badge badge-info badge-xs">FILL</span>
|
||||||
|
<span v-else>{{ oil.gallons_ordered }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-base-content/50">Date</p>
|
||||||
|
<p class="font-medium">{{ oil.expected_delivery_date }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-actions justify-end flex-wrap gap-2 mt-2">
|
<div class="flex gap-2 pt-3 mt-3 border-t border-base-content/10">
|
||||||
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-sm btn-ghost">View</router-link>
|
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-sm btn-ghost flex-1">View</router-link>
|
||||||
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-sm btn-secondary">Edit</router-link>
|
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-sm btn-info btn-outline flex-1">Edit</router-link>
|
||||||
<router-link :to="{ name: 'finalizeTicket', params: { id: oil.id } }" class="btn btn-sm btn-accent">Finalize</router-link>
|
<router-link :to="{ name: 'finalizeTicket', params: { id: oil.id } }" class="btn btn-sm btn-accent btn-outline flex-1">Finalize</router-link>
|
||||||
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-sm btn-success">Print</router-link>
|
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-sm btn-success btn-outline flex-1">Print</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,7 +218,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, markRaw } from 'vue'
|
import { ref, onMounted, markRaw, computed } from 'vue'
|
||||||
import { deliveryService } from '../../../services/deliveryService'
|
import { deliveryService } from '../../../services/deliveryService'
|
||||||
import authService from '../../../services/authService'
|
import authService from '../../../services/authService'
|
||||||
import { Delivery } from '../../../types/models'
|
import { Delivery } from '../../../types/models'
|
||||||
@@ -166,15 +241,46 @@ const grand_total = ref(0)
|
|||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const perPage = ref(50)
|
const perPage = ref(50)
|
||||||
const recordsLength = ref(0)
|
const recordsLength = ref(0)
|
||||||
|
const loading = ref(true)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const filterTown = ref('')
|
||||||
const options = ref({
|
const options = ref({
|
||||||
edgeNavigation: false,
|
edgeNavigation: false,
|
||||||
format: false,
|
format: false,
|
||||||
template: markRaw(PaginationComp)
|
template: markRaw(PaginationComp)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const filteredDeliveries = computed(() => {
|
||||||
|
let result = [...deliveries.value]
|
||||||
|
|
||||||
|
// Filter by town
|
||||||
|
if (filterTown.value) {
|
||||||
|
result = result.filter(d => d.customer_town === filterTown.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by search
|
||||||
|
if (searchQuery.value) {
|
||||||
|
const query = searchQuery.value.toLowerCase()
|
||||||
|
result = result.filter(d =>
|
||||||
|
d.customer_name?.toLowerCase().includes(query) ||
|
||||||
|
d.customer_address?.toLowerCase().includes(query) ||
|
||||||
|
d.customer_town?.toLowerCase().includes(query) ||
|
||||||
|
d.id?.toString().includes(query)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
// Functions
|
||||||
|
const toggleTownFilter = (town: string) => {
|
||||||
|
filterTown.value = filterTown.value === town ? '' : town
|
||||||
|
}
|
||||||
|
|
||||||
// Functions
|
// Functions
|
||||||
const getPage = (pageVal: any) => {
|
const getPage = (pageVal: any) => {
|
||||||
deliveries.value = [];
|
deliveries.value = [];
|
||||||
|
loading.value = true
|
||||||
get_oil_orders(pageVal)
|
get_oil_orders(pageVal)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +302,8 @@ const get_oil_orders = async (pageVal: number) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching waiting deliveries:', error)
|
console.error('Error fetching waiting deliveries:', error)
|
||||||
deliveries.value = []
|
deliveries.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,4 +353,187 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped>
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
<!-- First Name -->
|
<!-- First Name -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">First Name</span></label>
|
<label class="label"><span class="label-text">First Name</span></label>
|
||||||
<input v-model="CreateEmployeeForm.employee_first_name" type="text" placeholder="First Name" class="input input-bordered input-sm w-full" />
|
<input v-model="CreateEmployeeForm.employee_first_name" type="text" placeholder="First Name" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_first_name.$error }" />
|
||||||
<span v-if="v$.CreateEmployeeForm.employee_first_name.$error" class="text-red-500 text-xs mt-1">
|
<span v-if="v$.CreateEmployeeForm.employee_first_name.$error" class="text-red-500 text-xs mt-1">
|
||||||
{{ v$.CreateEmployeeForm.employee_first_name.$errors[0].$message }}
|
{{ v$.CreateEmployeeForm.employee_first_name.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
<!-- Last Name -->
|
<!-- Last Name -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Last Name</span></label>
|
<label class="label"><span class="label-text">Last Name</span></label>
|
||||||
<input v-model="CreateEmployeeForm.employee_last_name" type="text" placeholder="Last Name" class="input input-bordered input-sm w-full" />
|
<input v-model="CreateEmployeeForm.employee_last_name" type="text" placeholder="Last Name" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_last_name.$error }" />
|
||||||
<span v-if="v$.CreateEmployeeForm.employee_last_name.$error" class="text-red-500 text-xs mt-1">
|
<span v-if="v$.CreateEmployeeForm.employee_last_name.$error" class="text-red-500 text-xs mt-1">
|
||||||
{{ v$.CreateEmployeeForm.employee_last_name.$errors[0].$message }}
|
{{ v$.CreateEmployeeForm.employee_last_name.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
<!-- Phone Number -->
|
<!-- Phone Number -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Phone Number</span></label>
|
<label class="label"><span class="label-text">Phone Number</span></label>
|
||||||
<input v-model="CreateEmployeeForm.employee_phone_number" @input="acceptNumber()" type="text" placeholder="Phone Number" class="input input-bordered input-sm w-full" />
|
<input v-model="CreateEmployeeForm.employee_phone_number" @input="acceptNumber()" type="text" placeholder="Phone Number" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_phone_number.$error }" />
|
||||||
<span v-if="v$.CreateEmployeeForm.employee_phone_number.$error" class="text-red-500 text-xs mt-1">
|
<span v-if="v$.CreateEmployeeForm.employee_phone_number.$error" class="text-red-500 text-xs mt-1">
|
||||||
{{ v$.CreateEmployeeForm.employee_phone_number.$errors[0].$message }}
|
{{ v$.CreateEmployeeForm.employee_phone_number.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
<!-- Employee Type -->
|
<!-- Employee Type -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Employee Role</span></label>
|
<label class="label"><span class="label-text">Employee Role</span></label>
|
||||||
<select v-model="CreateEmployeeForm.employee_type" class="select select-bordered select-sm w-full">
|
<select v-model="CreateEmployeeForm.employee_type" class="select select-bordered select-sm w-full" :class="{ 'select-error': v$.CreateEmployeeForm.employee_type.$error }">
|
||||||
<option disabled :value="0">Select a role</option>
|
<option disabled :value="0">Select a role</option>
|
||||||
<option v-for="employee in employList" :key="employee.value" :value="employee.value">
|
<option v-for="employee in employList" :key="employee.value" :value="employee.value">
|
||||||
{{ employee.text }}
|
{{ employee.text }}
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
<!-- Street Address -->
|
<!-- Street Address -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Street Address</span></label>
|
<label class="label"><span class="label-text">Street Address</span></label>
|
||||||
<input v-model="CreateEmployeeForm.employee_address" type="text" placeholder="Street Address" class="input input-bordered input-sm w-full" />
|
<input v-model="CreateEmployeeForm.employee_address" type="text" placeholder="Street Address" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_address.$error }" />
|
||||||
<span v-if="v$.CreateEmployeeForm.employee_address.$error" class="text-red-500 text-xs mt-1">
|
<span v-if="v$.CreateEmployeeForm.employee_address.$error" class="text-red-500 text-xs mt-1">
|
||||||
{{ v$.CreateEmployeeForm.employee_address.$errors[0].$message }}
|
{{ v$.CreateEmployeeForm.employee_address.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
@@ -80,13 +80,13 @@
|
|||||||
<!-- Apt, Suite, etc. -->
|
<!-- Apt, Suite, etc. -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Apt, Suite, etc. (Optional)</span></label>
|
<label class="label"><span class="label-text">Apt, Suite, etc. (Optional)</span></label>
|
||||||
<input v-model="CreateEmployeeForm.employee_apt" type="text" placeholder="Apt, suite, unit..." class="input input-bordered input-sm w-full" />
|
<input v-model="CreateEmployeeForm.employee_apt" type="text" placeholder="Apt, suite, unit..." class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_apt.$error }" />
|
||||||
<span v-if="v$.CreateEmployeeForm.employee_apt.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
<span v-if="v$.CreateEmployeeForm.employee_apt.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Town -->
|
<!-- Town -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Town</span></label>
|
<label class="label"><span class="label-text">Town</span></label>
|
||||||
<input v-model="CreateEmployeeForm.employee_town" type="text" placeholder="Town" class="input input-bordered input-sm w-full" />
|
<input v-model="CreateEmployeeForm.employee_town" type="text" placeholder="Town" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_town.$error }" />
|
||||||
<span v-if="v$.CreateEmployeeForm.employee_town.$error" class="text-red-500 text-xs mt-1">
|
<span v-if="v$.CreateEmployeeForm.employee_town.$error" class="text-red-500 text-xs mt-1">
|
||||||
{{ v$.CreateEmployeeForm.employee_town.$errors[0].$message }}
|
{{ v$.CreateEmployeeForm.employee_town.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
<!-- State -->
|
<!-- State -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">State</span></label>
|
<label class="label"><span class="label-text">State</span></label>
|
||||||
<select v-model="CreateEmployeeForm.employee_state" class="select select-bordered select-sm w-full">
|
<select v-model="CreateEmployeeForm.employee_state" class="select select-bordered select-sm w-full" :class="{ 'select-error': v$.CreateEmployeeForm.employee_state.$error }">
|
||||||
<option disabled :value="0">Select a state</option>
|
<option disabled :value="0">Select a state</option>
|
||||||
<option v-for="state in stateList" :key="state.value" :value="state.value">
|
<option v-for="state in stateList" :key="state.value" :value="state.value">
|
||||||
{{ state.text }}
|
{{ state.text }}
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
<!-- Zip Code -->
|
<!-- Zip Code -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Zip Code</span></label>
|
<label class="label"><span class="label-text">Zip Code</span></label>
|
||||||
<input v-model="CreateEmployeeForm.employee_zip" type="text" placeholder="Zip Code" class="input input-bordered input-sm w-full" />
|
<input v-model="CreateEmployeeForm.employee_zip" type="text" placeholder="Zip Code" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_zip.$error }" />
|
||||||
<span v-if="v$.CreateEmployeeForm.employee_zip.$error" class="text-red-500 text-xs mt-1">
|
<span v-if="v$.CreateEmployeeForm.employee_zip.$error" class="text-red-500 text-xs mt-1">
|
||||||
{{ v$.CreateEmployeeForm.employee_zip.$errors[0].$message }}
|
{{ v$.CreateEmployeeForm.employee_zip.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
@@ -122,12 +122,12 @@
|
|||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Birthday</span></label>
|
<label class="label"><span class="label-text">Birthday</span></label>
|
||||||
<input v-model="CreateEmployeeForm.employee_birthday" type="date" class="input input-bordered input-sm w-full" />
|
<input v-model="CreateEmployeeForm.employee_birthday" type="date" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_birthday.$error }" />
|
||||||
<span v-if="v$.CreateEmployeeForm.employee_birthday.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
<span v-if="v$.CreateEmployeeForm.employee_birthday.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Start Date</span></label>
|
<label class="label"><span class="label-text">Start Date</span></label>
|
||||||
<input v-model="CreateEmployeeForm.employee_start_date" type="date" class="input input-bordered input-sm w-full" />
|
<input v-model="CreateEmployeeForm.employee_start_date" type="date" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_start_date.$error }" />
|
||||||
<span v-if="v$.CreateEmployeeForm.employee_start_date.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
<span v-if="v$.CreateEmployeeForm.employee_start_date.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
@@ -154,6 +154,7 @@ import axios from 'axios'
|
|||||||
import authHeader from '../../services/auth.header'
|
import authHeader from '../../services/auth.header'
|
||||||
import useValidate from "@vuelidate/core";
|
import useValidate from "@vuelidate/core";
|
||||||
import { minLength, required } from "@vuelidate/validators";
|
import { minLength, required } from "@vuelidate/validators";
|
||||||
|
import { notify } from "@kyvg/vue3-notification";
|
||||||
|
|
||||||
interface SelectOption {
|
interface SelectOption {
|
||||||
text: string;
|
text: string;
|
||||||
@@ -246,7 +247,7 @@ export default defineComponent({
|
|||||||
if (!this.v$.$error) {
|
if (!this.v$.$error) {
|
||||||
this.CreateItem(this.CreateEmployeeForm);
|
this.CreateItem(this.CreateEmployeeForm);
|
||||||
} else {
|
} else {
|
||||||
console.log("Form validation failed.");
|
notify({ title: "Validation Error", text: "Please fill out all required fields correctly.", type: "error" });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getEmployeeTypeList() {
|
getEmployeeTypeList() {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
<!-- First Name -->
|
<!-- First Name -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">First Name</span></label>
|
<label class="label"><span class="label-text">First Name</span></label>
|
||||||
<input v-model="CreateEmployeeForm.employee_first_name" type="text" placeholder="First Name" class="input input-bordered input-sm w-full" />
|
<input v-model="CreateEmployeeForm.employee_first_name" type="text" placeholder="First Name" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_first_name.$error }" />
|
||||||
<span v-if="v$.CreateEmployeeForm.employee_first_name.$error" class="text-red-500 text-xs mt-1">
|
<span v-if="v$.CreateEmployeeForm.employee_first_name.$error" class="text-red-500 text-xs mt-1">
|
||||||
{{ v$.CreateEmployeeForm.employee_first_name.$errors[0].$message }}
|
{{ v$.CreateEmployeeForm.employee_first_name.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
<!-- Last Name -->
|
<!-- Last Name -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Last Name</span></label>
|
<label class="label"><span class="label-text">Last Name</span></label>
|
||||||
<input v-model="CreateEmployeeForm.employee_last_name" type="text" placeholder="Last Name" class="input input-bordered input-sm w-full" />
|
<input v-model="CreateEmployeeForm.employee_last_name" type="text" placeholder="Last Name" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_last_name.$error }" />
|
||||||
<span v-if="v$.CreateEmployeeForm.employee_last_name.$error" class="text-red-500 text-xs mt-1">
|
<span v-if="v$.CreateEmployeeForm.employee_last_name.$error" class="text-red-500 text-xs mt-1">
|
||||||
{{ v$.CreateEmployeeForm.employee_last_name.$errors[0].$message }}
|
{{ v$.CreateEmployeeForm.employee_last_name.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
<!-- Phone Number -->
|
<!-- Phone Number -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Phone Number</span></label>
|
<label class="label"><span class="label-text">Phone Number</span></label>
|
||||||
<input v-model="CreateEmployeeForm.employee_phone_number" @input="acceptNumber()" type="text" placeholder="Phone Number" class="input input-bordered input-sm w-full" />
|
<input v-model="CreateEmployeeForm.employee_phone_number" @input="acceptNumber()" type="text" placeholder="Phone Number" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_phone_number.$error }" />
|
||||||
<span v-if="v$.CreateEmployeeForm.employee_phone_number.$error" class="text-red-500 text-xs mt-1">
|
<span v-if="v$.CreateEmployeeForm.employee_phone_number.$error" class="text-red-500 text-xs mt-1">
|
||||||
{{ v$.CreateEmployeeForm.employee_phone_number.$errors[0].$message }}
|
{{ v$.CreateEmployeeForm.employee_phone_number.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
<!-- Employee Type -->
|
<!-- Employee Type -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Employee Role</span></label>
|
<label class="label"><span class="label-text">Employee Role</span></label>
|
||||||
<select v-model="CreateEmployeeForm.employee_type" class="select select-bordered select-sm w-full">
|
<select v-model="CreateEmployeeForm.employee_type" class="select select-bordered select-sm w-full" :class="{ 'select-error': v$.CreateEmployeeForm.employee_type.$error }">
|
||||||
<option disabled :value="0">Select a role</option>
|
<option disabled :value="0">Select a role</option>
|
||||||
<option v-for="employee in employList" :key="employee.value" :value="employee.value">
|
<option v-for="employee in employList" :key="employee.value" :value="employee.value">
|
||||||
{{ employee.text }}
|
{{ employee.text }}
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
<!-- Street Address -->
|
<!-- Street Address -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Street Address</span></label>
|
<label class="label"><span class="label-text">Street Address</span></label>
|
||||||
<input v-model="CreateEmployeeForm.employee_address" type="text" placeholder="Street Address" class="input input-bordered input-sm w-full" />
|
<input v-model="CreateEmployeeForm.employee_address" type="text" placeholder="Street Address" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_address.$error }" />
|
||||||
<span v-if="v$.CreateEmployeeForm.employee_address.$error" class="text-red-500 text-xs mt-1">
|
<span v-if="v$.CreateEmployeeForm.employee_address.$error" class="text-red-500 text-xs mt-1">
|
||||||
{{ v$.CreateEmployeeForm.employee_address.$errors[0].$message }}
|
{{ v$.CreateEmployeeForm.employee_address.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
<!-- Town -->
|
<!-- Town -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Town</span></label>
|
<label class="label"><span class="label-text">Town</span></label>
|
||||||
<input v-model="CreateEmployeeForm.employee_town" type="text" placeholder="Town" class="input input-bordered input-sm w-full" />
|
<input v-model="CreateEmployeeForm.employee_town" type="text" placeholder="Town" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_town.$error }" />
|
||||||
<span v-if="v$.CreateEmployeeForm.employee_town.$error" class="text-red-500 text-xs mt-1">
|
<span v-if="v$.CreateEmployeeForm.employee_town.$error" class="text-red-500 text-xs mt-1">
|
||||||
{{ v$.CreateEmployeeForm.employee_town.$errors[0].$message }}
|
{{ v$.CreateEmployeeForm.employee_town.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
<!-- State -->
|
<!-- State -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">State</span></label>
|
<label class="label"><span class="label-text">State</span></label>
|
||||||
<select v-model="CreateEmployeeForm.employee_state" class="select select-bordered select-sm w-full">
|
<select v-model="CreateEmployeeForm.employee_state" class="select select-bordered select-sm w-full" :class="{ 'select-error': v$.CreateEmployeeForm.employee_state.$error }">
|
||||||
<option disabled :value="0">Select a state</option>
|
<option disabled :value="0">Select a state</option>
|
||||||
<option v-for="state in stateList" :key="state.value" :value="state.value">
|
<option v-for="state in stateList" :key="state.value" :value="state.value">
|
||||||
{{ state.text }}
|
{{ state.text }}
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
<!-- Zip Code -->
|
<!-- Zip Code -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Zip Code</span></label>
|
<label class="label"><span class="label-text">Zip Code</span></label>
|
||||||
<input v-model="CreateEmployeeForm.employee_zip" type="text" placeholder="Zip Code" class="input input-bordered input-sm w-full" />
|
<input v-model="CreateEmployeeForm.employee_zip" type="text" placeholder="Zip Code" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_zip.$error }" />
|
||||||
<span v-if="v$.CreateEmployeeForm.employee_zip.$error" class="text-red-500 text-xs mt-1">
|
<span v-if="v$.CreateEmployeeForm.employee_zip.$error" class="text-red-500 text-xs mt-1">
|
||||||
{{ v$.CreateEmployeeForm.employee_zip.$errors[0].$message }}
|
{{ v$.CreateEmployeeForm.employee_zip.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
@@ -126,12 +126,12 @@
|
|||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Birthday</span></label>
|
<label class="label"><span class="label-text">Birthday</span></label>
|
||||||
<input v-model="CreateEmployeeForm.employee_birthday" type="date" class="input input-bordered input-sm w-full" />
|
<input v-model="CreateEmployeeForm.employee_birthday" type="date" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_birthday.$error }" />
|
||||||
<span v-if="v$.CreateEmployeeForm.employee_birthday.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
<span v-if="v$.CreateEmployeeForm.employee_birthday.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Start Date</span></label>
|
<label class="label"><span class="label-text">Start Date</span></label>
|
||||||
<input v-model="CreateEmployeeForm.employee_start_date" type="date" class="input input-bordered input-sm w-full" />
|
<input v-model="CreateEmployeeForm.employee_start_date" type="date" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_start_date.$error }" />
|
||||||
<span v-if="v$.CreateEmployeeForm.employee_start_date.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
<span v-if="v$.CreateEmployeeForm.employee_start_date.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
@@ -169,6 +169,7 @@ import axios from 'axios'
|
|||||||
import authHeader from '../../services/auth.header'
|
import authHeader from '../../services/auth.header'
|
||||||
import useValidate from "@vuelidate/core";
|
import useValidate from "@vuelidate/core";
|
||||||
import { minLength, required } from "@vuelidate/validators";
|
import { minLength, required } from "@vuelidate/validators";
|
||||||
|
import { notify } from "@kyvg/vue3-notification";
|
||||||
|
|
||||||
interface SelectOption {
|
interface SelectOption {
|
||||||
text: string;
|
text: string;
|
||||||
@@ -274,18 +275,30 @@ export default defineComponent({
|
|||||||
if (!this.v$.$error) {
|
if (!this.v$.$error) {
|
||||||
this.EditEmployee(this.CreateEmployeeForm);
|
this.EditEmployee(this.CreateEmployeeForm);
|
||||||
} else {
|
} else {
|
||||||
console.log("Form validation failed.");
|
notify({ title: "Validation Error", text: "Please fill out all required fields correctly.", type: "error" });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getEmployeeTypeList() {
|
getEmployeeTypeList() {
|
||||||
const path = import.meta.env.VITE_BASE_URL + "/query/employeetype";
|
const path = import.meta.env.VITE_BASE_URL + "/query/employeetype";
|
||||||
axios.get(path, { withCredentials: true, headers: authHeader() })
|
axios.get(path, { withCredentials: true, headers: authHeader() })
|
||||||
.then((response: any) => { this.employList = response.data; });
|
.then((response: any) => {
|
||||||
|
if (response.data && response.data.employee_types) {
|
||||||
|
this.employList = response.data.employee_types;
|
||||||
|
} else {
|
||||||
|
this.employList = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
getStatesList() {
|
getStatesList() {
|
||||||
const path = import.meta.env.VITE_BASE_URL + "/query/states";
|
const path = import.meta.env.VITE_BASE_URL + "/query/states";
|
||||||
axios.get(path, { withCredentials: true, headers: authHeader() })
|
axios.get(path, { withCredentials: true, headers: authHeader() })
|
||||||
.then((response: any) => { this.stateList = response.data; });
|
.then((response: any) => {
|
||||||
|
if (response.data && response.data.states) {
|
||||||
|
this.stateList = response.data.states;
|
||||||
|
} else {
|
||||||
|
this.stateList = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,23 +9,40 @@
|
|||||||
<li>Employees</li>
|
<li>Employees</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-3xl font-bold mt-4">Employees</h1>
|
<!-- Page Header with Stats -->
|
||||||
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mt-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl md:text-3xl font-bold flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center shadow-lg">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-primary-content">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
Employees
|
||||||
|
</h1>
|
||||||
|
<p class="text-base-content/60 mt-1 ml-13">Manage staff members and roles</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Main Content Card -->
|
<!-- Quick Stats -->
|
||||||
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
|
<div class="flex flex-wrap gap-3 items-center">
|
||||||
<!-- Header: Count and Add Button -->
|
<div class="stat-pill">
|
||||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-4">
|
<span class="stat-pill-value">{{ recordsLength }}</span>
|
||||||
<div class="badge badge-ghost">{{ recordsLength }} employees found</div>
|
<span class="stat-pill-label">Total Staff</span>
|
||||||
<router-link :to="{ name: 'employeeCreate' }" class="btn btn-primary btn-sm">
|
</div>
|
||||||
|
<router-link :to="{ name: 'employeeCreate' }" class="btn btn-primary btn-sm ml-2">
|
||||||
Create New Employee
|
Create New Employee
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content Card -->
|
||||||
|
<div class="modern-table-card">
|
||||||
|
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<!-- DESKTOP VIEW: Table -->
|
<!-- DESKTOP VIEW: Table -->
|
||||||
<div class="overflow-x-auto hidden xl:block">
|
<div class="overflow-x-auto hidden xl:block">
|
||||||
<table class="table w-full">
|
<table class="modern-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Employee ID</th>
|
<th>Employee ID</th>
|
||||||
@@ -37,7 +54,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="person in employees" :key="person.id" class="hover:bg-blue-600 hover:text-white">
|
<tr v-for="person in employees" :key="person.id" class="table-row-hover">
|
||||||
<td>{{ person.id }}</td>
|
<td>{{ person.id }}</td>
|
||||||
<td>{{ person.employee_first_name }} {{ person.employee_last_name }}</td>
|
<td>{{ person.employee_first_name }} {{ person.employee_last_name }}</td>
|
||||||
<td><span class="badge badge-ghost badge-sm">{{ getEmployeeTypeName(person.employee_type) }}</span></td>
|
<td><span class="badge badge-ghost badge-sm">{{ getEmployeeTypeName(person.employee_type) }}</span></td>
|
||||||
@@ -45,10 +62,10 @@
|
|||||||
<td>{{ person.employee_phone_number }}</td>
|
<td>{{ person.employee_phone_number }}</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<div class="flex items-center justify-end gap-2">
|
<div class="flex items-center justify-end gap-2">
|
||||||
<router-link :to="{ name: 'employeeEdit', params: { id: person.user_id || 0 } }" class="btn btn-sm btn-secondary">Edit</router-link>
|
<router-link v-if="person.user_id" :to="{ name: 'employeeEdit', params: { id: person.user_id } }" class="btn btn-sm btn-secondary">Edit</router-link>
|
||||||
<router-link :to="{ name: 'employeeEdit', params: { id: person.user_id || 0 } }" class="btn btn-sm btn-secondary">Edit</router-link>
|
<button v-else class="btn btn-sm btn-disabled">No User</button>
|
||||||
<router-link :to="{ name: 'employeeProfile', params: { id: person.id } }" class="btn btn-sm btn-ghost">View</router-link>
|
<router-link v-if="person.id" :to="{ name: 'employeeProfile', params: { id: person.id } }" class="btn btn-sm btn-ghost">View</router-link>
|
||||||
<router-link v-if="user && user.user_admin === 0" :to="{ name: 'employeeChangePassword', params: { id: person.id } }" class="btn btn-sm btn-warning">Change Password</router-link>
|
<router-link v-if="user && user.user_admin === 0 && person.id" :to="{ name: 'employeeChangePassword', params: { id: person.id } }" class="btn btn-sm btn-warning">Change Password</router-link>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -57,26 +74,32 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- MOBILE VIEW: Cards -->
|
<!-- MOBILE VIEW: Cards -->
|
||||||
<div class="xl:hidden space-y-4">
|
<div class="xl:hidden space-y-4 px-4 pb-4">
|
||||||
<div v-for="person in employees" :key="person.id" class="card bg-base-100 shadow-md">
|
<div v-for="person in employees" :key="person.id" class="mobile-card">
|
||||||
<div class="card-body p-4">
|
<div class="p-3">
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="card-title text-base">{{ person.employee_first_name }} {{ person.employee_last_name }}</h2>
|
<h2 class="text-base font-bold">{{ person.employee_first_name }} {{ person.employee_last_name }}</h2>
|
||||||
<p class="text-xs text-gray-400">ID: #{{ person.id }}</p>
|
<p class="text-xs text-base-content/60">ID: #{{ person.id }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="badge badge-ghost">
|
<div class="badge badge-ghost badge-sm">
|
||||||
{{ getEmployeeTypeName(person.employee_type) }}
|
{{ getEmployeeTypeName(person.employee_type) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm mt-2">
|
<div class="text-sm mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
|
||||||
<p>{{ person.employee_town }}</p>
|
<div>
|
||||||
<p>{{ person.employee_phone_number }}</p>
|
<p class="text-xs text-base-content/50">Town</p>
|
||||||
|
<p>{{ person.employee_town }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-base-content/50">Phone</p>
|
||||||
|
<p>{{ person.employee_phone_number }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-actions justify-end flex-wrap gap-2 mt-2">
|
<div class="flex gap-2 pt-3 mt-3 border-t border-base-content/10 flex-wrap">
|
||||||
<router-link :to="{ name: 'employeeEdit', params: { id: person.user_id || 0 } }" class="btn btn-sm btn-secondary">Edit</router-link>
|
<router-link v-if="person.user_id" :to="{ name: 'employeeEdit', params: { id: person.user_id } }" class="btn btn-sm btn-secondary flex-1">Edit</router-link>
|
||||||
<router-link :to="{ name: 'employeeProfile', params: { id: person.id } }" class="btn btn-sm btn-ghost">View</router-link>
|
<router-link v-if="person.id" :to="{ name: 'employeeProfile', params: { id: person.id } }" class="btn btn-sm btn-ghost flex-1">View</router-link>
|
||||||
<router-link v-if="user && user.user_admin === 0" :to="{ name: 'employeeChangePassword', params: { id: person.id } }" class="btn btn-sm btn-warning">Change Password</router-link>
|
<router-link v-if="user && user.user_admin === 0 && person.id" :to="{ name: 'employeeChangePassword', params: { id: person.id } }" class="btn btn-sm btn-warning flex-1">Change Password</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,14 +169,17 @@ export default defineComponent({
|
|||||||
|
|
||||||
axios.get(path, { headers: authHeader() })
|
axios.get(path, { headers: authHeader() })
|
||||||
.then((response: any) => {
|
.then((response: any) => {
|
||||||
// --- FIX 1: Assign the response data directly, as it's an array ---
|
// Fix: Access the .employees property from the response object
|
||||||
this.employees = response.data;
|
// The API returns { ok: true, employees: [...] }
|
||||||
|
if (response.data.employees) {
|
||||||
|
this.employees = response.data.employees;
|
||||||
|
} else {
|
||||||
|
// Fallback or empty
|
||||||
|
this.employees = [];
|
||||||
|
}
|
||||||
|
|
||||||
// --- FIX 2: Set the recordsLength from the array length ---
|
// Fix: Set recordsLength based on the array length
|
||||||
// NOTE: For full pagination, your API will eventually need to send the *total* count.
|
this.recordsLength = this.employees.length;
|
||||||
// For now, this will show the count of items on the current page.
|
|
||||||
// If you update your API to send { data: [], total_records: X }, this is the only line you'd change.
|
|
||||||
this.recordsLength = response.data.length;
|
|
||||||
})
|
})
|
||||||
.catch((error: any) => {
|
.catch((error: any) => {
|
||||||
console.error("Failed to fetch employees:", error);
|
console.error("Failed to fetch employees:", error);
|
||||||
|
|||||||
@@ -1,102 +1,120 @@
|
|||||||
<!-- src/pages/money/profit_year.vue -->
|
<!-- src/pages/money/profit_year.vue -->
|
||||||
<template>
|
<template>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
|
|
||||||
<div class=" w-full px-10 ">
|
<div class="w-full px-4 md:px-10 py-4">
|
||||||
<div class="text-sm breadcrumbs mb-10">
|
<!-- Breadcrumbs -->
|
||||||
<ul>
|
<div class="text-sm breadcrumbs mb-6">
|
||||||
<li>
|
<ul>
|
||||||
<router-link :to="{ name: 'home' }">
|
<li>
|
||||||
Home
|
<router-link :to="{ name: 'home' }">
|
||||||
</router-link>
|
Home
|
||||||
</li>
|
</router-link>
|
||||||
<li>
|
</li>
|
||||||
<router-link :to="{ name: 'employee' }">
|
<li>Money</li>
|
||||||
employees
|
<li>Profit Year</li>
|
||||||
</router-link>
|
</ul>
|
||||||
</li>
|
</div>
|
||||||
</ul>
|
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl md:text-3xl font-bold flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center shadow-lg">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
||||||
|
stroke="currentColor" class="w-5 h-5 text-primary-content">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
Financial Overview
|
||||||
|
</h1>
|
||||||
|
<p class="text-base-content/60 mt-1 ml-13">Year to date profit analysis</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="flex start pb-10 text-2xl">Profit for Year </div>
|
|
||||||
|
|
||||||
<div class="col-span-12 font-bold ">
|
|
||||||
Profit Year
|
|
||||||
</div>
|
|
||||||
<div class="col-span-12 mb-5 text-sm text-gray-500">
|
|
||||||
{{ profit_year }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="modern-table-card p-8">
|
||||||
|
<div class="stats shadow w-full bg-base-200">
|
||||||
|
<div class="stat place-items-center">
|
||||||
|
<div class="stat-title">Total Profit (Year To Date)</div>
|
||||||
|
<div class="stat-value text-primary text-4xl py-4">{{ new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD'
|
||||||
|
}).format(Number(profit_year || 0)) }}</div>
|
||||||
|
<div class="stat-desc">Calculated from completed deliveries and services</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
|
||||||
|
</template>
|
||||||
<script lang="ts">
|
|
||||||
import { defineComponent } from 'vue'
|
<script lang="ts">
|
||||||
import axios from 'axios'
|
import { defineComponent } from 'vue'
|
||||||
import authHeader from '../../services/auth.header'
|
import axios from 'axios'
|
||||||
import Header from '../../layouts/headers/headerauth.vue'
|
import authHeader from '../../services/auth.header'
|
||||||
import SideBar from '../../layouts/sidebar/sidebar.vue'
|
import Header from '../../layouts/headers/headerauth.vue'
|
||||||
|
import SideBar from '../../layouts/sidebar/sidebar.vue'
|
||||||
export default defineComponent({
|
|
||||||
name: 'MoneyYear',
|
export default defineComponent({
|
||||||
|
name: 'MoneyYear',
|
||||||
components: {
|
|
||||||
Header,
|
components: {
|
||||||
SideBar,
|
Header,
|
||||||
},
|
SideBar,
|
||||||
|
},
|
||||||
data() {
|
|
||||||
return {
|
data() {
|
||||||
token: null,
|
return {
|
||||||
user: null,
|
token: null,
|
||||||
employees: [],
|
user: null,
|
||||||
profit_year: 0,
|
employees: [],
|
||||||
|
profit_year: 0,
|
||||||
}
|
|
||||||
},
|
}
|
||||||
|
},
|
||||||
created() {
|
|
||||||
this.userStatus()
|
created() {
|
||||||
},
|
this.userStatus()
|
||||||
mounted() {
|
},
|
||||||
this.get_profit_year()
|
mounted() {
|
||||||
|
this.get_profit_year()
|
||||||
},
|
|
||||||
methods: {
|
},
|
||||||
|
methods: {
|
||||||
userStatus() {
|
|
||||||
let path = import.meta.env.VITE_BASE_URL + '/auth/whoami';
|
userStatus() {
|
||||||
axios({
|
let path = import.meta.env.VITE_BASE_URL + '/auth/whoami';
|
||||||
method: 'get',
|
axios({
|
||||||
url: path,
|
method: 'get',
|
||||||
withCredentials: true,
|
url: path,
|
||||||
headers: authHeader(),
|
withCredentials: true,
|
||||||
|
headers: authHeader(),
|
||||||
|
})
|
||||||
|
.then((response: any) => {
|
||||||
|
if (response.data.ok) {
|
||||||
|
this.user = response.data.user;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.then((response: any) => {
|
.catch(() => {
|
||||||
if (response.data.ok) {
|
this.user = null
|
||||||
this.user = response.data.user;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
this.user = null
|
|
||||||
})
|
|
||||||
},
|
|
||||||
get_profit_year() {
|
|
||||||
let path = import.meta.env.VITE_BASE_URL + '/money/profit/year';
|
|
||||||
axios({
|
|
||||||
method: 'get',
|
|
||||||
url: path,
|
|
||||||
headers: authHeader(),
|
|
||||||
}).then((response: any) => {
|
|
||||||
this.profit_year = response.data.total
|
|
||||||
})
|
})
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
get_profit_year() {
|
||||||
</script>
|
let path = import.meta.env.VITE_BASE_URL + '/money/profit/year';
|
||||||
|
axios({
|
||||||
<style scoped></style>
|
method: 'get',
|
||||||
|
url: path,
|
||||||
|
headers: authHeader(),
|
||||||
|
}).then((response: any) => {
|
||||||
|
this.profit_year = response.data.total
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -9,16 +9,34 @@
|
|||||||
<li>Service Calls</li>
|
<li>Service Calls</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-3xl font-bold mt-4">Service Calls</h1>
|
<!-- Page Header with Stats -->
|
||||||
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mt-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl md:text-3xl font-bold flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center shadow-lg">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
||||||
|
stroke="currentColor" class="w-5 h-5 text-primary-content">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 11-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 004.486-6.336l-3.276 3.277a3.004 3.004 0 01-2.25-2.25l3.276-3.276a4.5 4.5 0 00-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437l1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
Service Calls
|
||||||
|
</h1>
|
||||||
|
<p class="text-base-content/60 mt-1 ml-13">Manage upcoming and active service calls</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Stats -->
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<div class="stat-pill">
|
||||||
|
<span class="stat-pill-value">{{ services.length }}</span>
|
||||||
|
<span class="stat-pill-label">Active Calls</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Main Content Card -->
|
<!-- Main Content Card -->
|
||||||
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
|
<div class="modern-table-card">
|
||||||
<!-- Header: Title and Count -->
|
|
||||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-4">
|
|
||||||
<h2 class="text-lg font-bold">Upcoming and Active Service Calls</h2>
|
|
||||||
<div v-if="!isLoading" class="badge badge-ghost">{{ services.length }} calls found</div>
|
|
||||||
</div>
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div v-if="isLoading" class="text-center p-10">
|
<div v-if="isLoading" class="text-center p-10">
|
||||||
@@ -35,7 +53,7 @@
|
|||||||
<div v-else>
|
<div v-else>
|
||||||
<!-- DESKTOP VIEW: Table -->
|
<!-- DESKTOP VIEW: Table -->
|
||||||
<div class="overflow-x-auto hidden xl:block">
|
<div class="overflow-x-auto hidden xl:block">
|
||||||
<table class="table w-full">
|
<table class="modern-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
@@ -50,26 +68,25 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<!-- Removed @click from tr to avoid conflicting actions -->
|
<!-- Removed @click from tr to avoid conflicting actions -->
|
||||||
<tr v-for="service in services" :key="service.id" class=" hover:bg-blue-600">
|
<tr v-for="service in services" :key="service.id" class="table-row-hover">
|
||||||
<td class="align-top text-white">{{ service.id }}</td>
|
<td>{{ service.id }}</td>
|
||||||
<td class="align-top text-white">
|
<td>
|
||||||
<div>{{ formatDate(service.scheduled_date) }}</div>
|
<div class="font-medium">{{ formatDate(service.scheduled_date) }}</div>
|
||||||
<div class="text-xs opacity-70">{{ formatTime(service.scheduled_date) }}</div>
|
<div class="text-xs opacity-70">{{ formatTime(service.scheduled_date) }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="align-top">
|
<td>
|
||||||
<router-link
|
<router-link :to="{ name: 'customerProfile', params: { id: service.customer_id } }"
|
||||||
:to="{ name: 'customerProfile', params: { id: service.customer_id } }"
|
class="link link-hover font-medium">
|
||||||
class="text-white hover:text-green-500 hover:underline"
|
|
||||||
>
|
|
||||||
{{ service.customer_name }}
|
{{ service.customer_name }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</td>
|
</td>
|
||||||
<td class="align-top text-white">{{ service.customer_address }}, {{ service.customer_town }}</td>
|
<td>
|
||||||
<td class="align-top">
|
<div>{{ service.customer_town }}</div>
|
||||||
<span
|
<div class="text-xs opacity-70">{{ service.customer_address }}</div>
|
||||||
class="badge badge-sm text-white"
|
</td>
|
||||||
:style="{ 'background-color': getServiceTypeColor(service.type_service_call), 'border-color': getServiceTypeColor(service.type_service_call) }"
|
<td>
|
||||||
>
|
<span class="badge badge-sm border-0 text-white font-medium shadow-sm"
|
||||||
|
:style="{ 'background-color': getServiceTypeColor(service.type_service_call) }">
|
||||||
{{ getServiceTypeName(service.type_service_call) }}
|
{{ getServiceTypeName(service.type_service_call) }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -77,18 +94,26 @@
|
|||||||
<!-- TRUNCATION LOGIC FOR DESKTOP -->
|
<!-- TRUNCATION LOGIC FOR DESKTOP -->
|
||||||
<div v-if="!isLongDescription(service.description) || isExpanded(service.id)">
|
<div v-if="!isLongDescription(service.description) || isExpanded(service.id)">
|
||||||
{{ service.description }}
|
{{ service.description }}
|
||||||
<a v-if="isLongDescription(service.description)" @click.prevent="toggleExpand(service.id)" href="#" class="link link-info link-hover text-xs ml-1 whitespace-nowrap">Show less</a>
|
<a v-if="isLongDescription(service.description)" @click.prevent="toggleExpand(service.id)"
|
||||||
|
href="#" class="link link-info link-hover text-xs ml-1 whitespace-nowrap">Show less</a>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
{{ truncateDescription(service.description) }}
|
{{ truncateDescription(service.description) }}
|
||||||
<a @click.prevent="toggleExpand(service.id)" href="#" class="link link-info link-hover text-xs ml-1 whitespace-nowrap">Read more</a>
|
<a @click.prevent="toggleExpand(service.id)" href="#"
|
||||||
|
class="link link-info link-hover text-xs ml-1 whitespace-nowrap">Read more</a>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right font-mono align-top text-white">{{ formatCurrency(service.service_cost) }}</td>
|
<td class="text-right font-mono align-top text-white">{{ formatCurrency(service.service_cost) }}</td>
|
||||||
<td class="text-right align-top space-x-2">
|
<td class="text-right align-top">
|
||||||
<button @click="openEditModal(service)" class="btn btn-sm btn-primary">View</button>
|
<div class="flex items-center justify-end gap-1">
|
||||||
<router-link v-if="shouldShowChargeButton(service)" :to="{ name: 'payService', params: { id: service.id } }" class="btn btn-sm btn-success">Charge</router-link>
|
<button @click="openEditModal(service)" class="btn btn-xs btn-info btn-outline">Edit</button>
|
||||||
<router-link v-if="shouldShowCaptureButton(service)" :to="{ name: 'chargeServiceAuthorize', params: { id: service.id } }" class="btn btn-sm btn-warning">Capture</router-link>
|
<router-link v-if="shouldShowChargeButton(service)"
|
||||||
|
:to="{ name: 'payService', params: { id: service.id } }"
|
||||||
|
class="btn btn-xs btn-success btn-outline">Charge</router-link>
|
||||||
|
<router-link v-if="shouldShowCaptureButton(service)"
|
||||||
|
:to="{ name: 'chargeServiceAuthorize', params: { id: service.id } }"
|
||||||
|
class="btn btn-xs btn-warning btn-outline">Capture</router-link>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -96,47 +121,62 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- MOBILE VIEW: Cards -->
|
<!-- MOBILE VIEW: Cards -->
|
||||||
<div class="xl:hidden space-y-4">
|
<div class="xl:hidden space-y-4 px-4 pb-4">
|
||||||
<div v-for="service in services" :key="service.id" class="card bg-base-100 shadow-md">
|
<div v-for="service in services" :key="service.id" class="mobile-card">
|
||||||
<div class="card-body p-4">
|
<div class="p-3">
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<router-link
|
<router-link :to="{ name: 'customerProfile', params: { id: service.customer_id } }"
|
||||||
:to="{ name: 'customerProfile', params: { id: service.customer_id } }"
|
class="text-base font-bold link link-hover">
|
||||||
class="card-title text-base text-white hover:text-green-500 hover:underline"
|
|
||||||
>
|
|
||||||
{{ service.customer_name }}
|
{{ service.customer_name }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<p class="text-xs text-gray-500">ID: {{ service.id }}</p>
|
<p class="text-xs text-base-content/60">ID: {{ service.id }}</p>
|
||||||
<p class="text-xs text-gray-400">{{ service.customer_address }}, {{ service.customer_town }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="badge badge-outline text-right" :style="{ 'border-color': getServiceTypeColor(service.type_service_call), color: getServiceTypeColor(service.type_service_call) }">
|
<div class="badge badge-sm border-0 text-white font-medium shadow-sm"
|
||||||
|
:style="{ 'background-color': getServiceTypeColor(service.type_service_call) }">
|
||||||
{{ getServiceTypeName(service.type_service_call) }}
|
{{ getServiceTypeName(service.type_service_call) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
|
<div class="text-sm mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
|
||||||
<p><strong class="font-semibold">Date:</strong> {{ formatDate(service.scheduled_date) }}</p>
|
<div>
|
||||||
<p><strong class="font-semibold">Time:</strong> {{ formatTime(service.scheduled_date) }}</p>
|
<p class="text-xs text-base-content/50">Address</p>
|
||||||
<p><strong class="font-semibold">Cost:</strong> <span class="font-mono">{{ formatCurrency(service.service_cost) }}</span></p>
|
<p class="font-medium">{{ service.customer_address }}</p>
|
||||||
|
<p class="text-xs">{{ service.customer_town }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-base-content/50">Cost</p>
|
||||||
|
<p class="font-mono">{{ formatCurrency(service.service_cost) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<p class="text-xs text-base-content/50">Date</p>
|
||||||
|
<p class="font-medium">{{ formatDate(service.scheduled_date) }} <span
|
||||||
|
class="text-xs opacity-60 ml-1">{{ formatTime(service.scheduled_date) }}</span></p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- TRUNCATION LOGIC FOR MOBILE -->
|
<!-- TRUNCATION LOGIC FOR MOBILE -->
|
||||||
<div v-if="service.description" class="text-sm mt-2 p-2 bg-base-200 rounded-md prose max-w-none">
|
<div v-if="service.description" class="text-sm mt-3 p-2 bg-base-200/50 rounded-md">
|
||||||
<div v-if="!isLongDescription(service.description) || isExpanded(service.id)">
|
<div v-if="!isLongDescription(service.description) || isExpanded(service.id)">
|
||||||
{{ service.description }}
|
{{ service.description }}
|
||||||
<a v-if="isLongDescription(service.description)" @click.prevent="toggleExpand(service.id)" href="#" class="link link-info link-hover text-xs ml-1 whitespace-nowrap">Show less</a>
|
<a v-if="isLongDescription(service.description)" @click.prevent="toggleExpand(service.id)" href="#"
|
||||||
|
class="link link-info link-hover text-xs ml-1 whitespace-nowrap">Show less</a>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
{{ truncateDescription(service.description) }}
|
{{ truncateDescription(service.description) }}
|
||||||
<a @click.prevent="toggleExpand(service.id)" href="#" class="link link-info link-hover text-xs ml-1 whitespace-nowrap">Read more</a>
|
<a @click.prevent="toggleExpand(service.id)" href="#"
|
||||||
|
class="link link-info link-hover text-xs ml-1 whitespace-nowrap">Read more</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-actions justify-end mt-2 space-x-2">
|
<div class="flex gap-2 pt-3 mt-3 border-t border-base-content/10 flex-wrap">
|
||||||
<button @click="openEditModal(service)" class="btn btn-sm btn-primary">View</button>
|
<button @click="openEditModal(service)" class="btn btn-sm btn-info btn-outline flex-1">Edit</button>
|
||||||
<router-link v-if="shouldShowChargeButton(service)" :to="{ name: 'payService', params: { id: service.id } }" class="btn btn-sm btn-success">Charge</router-link>
|
<router-link v-if="shouldShowChargeButton(service)"
|
||||||
<router-link v-if="shouldShowCaptureButton(service)" :to="{ name: 'chargeServiceAuthorize', params: { id: service.id } }" class="btn btn-sm btn-warning">Capture</router-link>
|
:to="{ name: 'payService', params: { id: service.id } }"
|
||||||
|
class="btn btn-sm btn-success btn-outline flex-1">Charge</router-link>
|
||||||
|
<router-link v-if="shouldShowCaptureButton(service)"
|
||||||
|
:to="{ name: 'chargeServiceAuthorize', params: { id: service.id } }"
|
||||||
|
class="btn btn-sm btn-warning btn-outline flex-1">Capture</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,15 +186,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<ServiceEditModal
|
|
||||||
v-if="selectedServiceForEdit"
|
<ServiceEditModal v-if="selectedServiceForEdit" :service="selectedServiceForEdit" @close-modal="closeEditModal"
|
||||||
:service="selectedServiceForEdit"
|
@save-changes="handleSaveChanges" @delete-service="handleDeleteService" />
|
||||||
@close-modal="closeEditModal"
|
|
||||||
@save-changes="handleSaveChanges"
|
|
||||||
@delete-service="handleDeleteService"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
|||||||
@@ -12,13 +12,31 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content Card -->
|
<!-- Main Content Card -->
|
||||||
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
|
<!-- Page Header -->
|
||||||
<!-- Header: Title and Count -->
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mt-4 mb-6">
|
||||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-4">
|
<div>
|
||||||
<h2 class="text-lg font-bold">Service Call History</h2>
|
<h1 class="text-2xl md:text-3xl font-bold flex items-center gap-3">
|
||||||
<div v-if="!isLoading" class="badge badge-ghost">{{ services.length }} calls found</div>
|
<div
|
||||||
|
class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center shadow-lg">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
||||||
|
stroke="currentColor" class="w-5 h-5 text-primary-content">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
Service Call History
|
||||||
|
</h1>
|
||||||
|
<p class="text-base-content/60 mt-1 ml-13">Archive of completed service calls</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="divider"></div>
|
|
||||||
|
<div v-if="!isLoading" class="stat-pill">
|
||||||
|
<span class="stat-pill-value">{{ services.length }}</span>
|
||||||
|
<span class="stat-pill-label">Total Calls</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content Card -->
|
||||||
|
<div class="modern-table-card">
|
||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div v-if="isLoading" class="text-center p-10">
|
<div v-if="isLoading" class="text-center p-10">
|
||||||
@@ -35,7 +53,7 @@
|
|||||||
<div v-else>
|
<div v-else>
|
||||||
<!-- DESKTOP VIEW: Table (Revamped) -->
|
<!-- DESKTOP VIEW: Table (Revamped) -->
|
||||||
<div class="overflow-x-auto hidden xl:block">
|
<div class="overflow-x-auto hidden xl:block">
|
||||||
<table class="table w-full">
|
<table class="modern-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
@@ -49,52 +67,51 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="service in services" :key="service.id" class="hover:bg-blue-600">
|
<tr v-for="service in services" :key="service.id" class="table-row-hover">
|
||||||
<td class="align-top text-white">{{ service.id }}</td>
|
<td class="align-top">{{ service.id }}</td>
|
||||||
<td class="align-top text-white">
|
<td class="align-top">
|
||||||
<div>{{ formatDate(service.scheduled_date) }}</div>
|
<div>{{ formatDate(service.scheduled_date) }}</div>
|
||||||
<div class="text-xs opacity-70">{{ formatTime(service.scheduled_date) }}</div>
|
<div class="text-xs opacity-70">{{ formatTime(service.scheduled_date) }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="align-top">
|
<td class="align-top">
|
||||||
<router-link
|
<router-link :to="{ name: 'customerProfile', params: { id: service.customer_id } }"
|
||||||
:to="{ name: 'customerProfile', params: { id: service.customer_id } }"
|
class="link link-hover font-bold">
|
||||||
class="text-white hover:text-green-500 hover:underline"
|
|
||||||
>
|
|
||||||
{{ service.customer_name }}
|
{{ service.customer_name }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</td>
|
</td>
|
||||||
<td class="align-top text-white">{{ service.customer_address }}, {{ service.customer_town }}</td>
|
<td class="align-top">{{ service.customer_address }}, {{ service.customer_town }}</td>
|
||||||
|
|
||||||
<!--
|
|
||||||
FIX IS HERE: Replaced the colored text with a styled badge.
|
|
||||||
- `badge-sm`: Makes the pill small and compact.
|
|
||||||
- `text-white`: Ensures text is readable against the colored background.
|
|
||||||
- The background color is set dynamically using your existing `getServiceTypeColor` method.
|
|
||||||
-->
|
|
||||||
<td class="align-top">
|
<td class="align-top">
|
||||||
<span
|
<span class="badge badge-sm text-white"
|
||||||
class="badge badge-sm text-white"
|
:style="{ 'background-color': getServiceTypeColor(service.type_service_call), 'border-color': getServiceTypeColor(service.type_service_call) }">
|
||||||
:style="{ 'background-color': getServiceTypeColor(service.type_service_call), 'border-color': getServiceTypeColor(service.type_service_call) }"
|
|
||||||
>
|
|
||||||
{{ getServiceTypeName(service.type_service_call) }}
|
{{ getServiceTypeName(service.type_service_call) }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td class="whitespace-normal text-sm align-top text-white">
|
<td class="whitespace-normal text-sm align-top">
|
||||||
<div v-if="!isLongDescription(service.description) || isExpanded(service.id)">
|
<div v-if="!isLongDescription(service.description) || isExpanded(service.id)">
|
||||||
{{ service.description }}
|
{{ service.description }}
|
||||||
<a v-if="isLongDescription(service.description)" @click.prevent="toggleExpand(service.id)" href="#" class="link link-info link-hover text-xs ml-1 whitespace-nowrap">Show less</a>
|
<a v-if="isLongDescription(service.description)" @click.prevent="toggleExpand(service.id)"
|
||||||
|
href="#" class="link link-primary link-hover text-xs ml-1 whitespace-nowrap">Show less</a>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
{{ truncateDescription(service.description) }}
|
{{ truncateDescription(service.description) }}
|
||||||
<a @click.prevent="toggleExpand(service.id)" href="#" class="link link-info link-hover text-xs ml-1 whitespace-nowrap">Read more</a>
|
<a @click.prevent="toggleExpand(service.id)" href="#"
|
||||||
|
class="link link-primary link-hover text-xs ml-1 whitespace-nowrap">Read more</a>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right font-mono align-top text-white">{{ formatCurrency(service.service_cost) }}</td>
|
<td class="text-right font-mono align-top text-primary font-bold">{{
|
||||||
<td class="text-right align-top space-x-2">
|
formatCurrency(service.service_cost) }}</td>
|
||||||
<button @click="openEditModal(service)" class="btn btn-sm btn-primary">View</button>
|
<td class="text-right align-top">
|
||||||
<router-link v-if="shouldShowChargeButton(service)" :to="{ name: 'payService', params: { id: service.id } }" class="btn btn-sm btn-success">Charge</router-link>
|
<div class="flex items-center justify-end gap-1">
|
||||||
<router-link v-if="shouldShowCaptureButton(service)" :to="{ name: 'chargeServiceAuthorize', params: { id: service.id } }" class="btn btn-sm btn-warning">Capture</router-link>
|
<button @click="openEditModal(service)" class="btn btn-xs btn-info btn-outline">Edit</button>
|
||||||
|
<router-link v-if="shouldShowChargeButton(service)"
|
||||||
|
:to="{ name: 'payService', params: { id: service.id } }"
|
||||||
|
class="btn btn-xs btn-success btn-outline">Charge</router-link>
|
||||||
|
<router-link v-if="shouldShowCaptureButton(service)"
|
||||||
|
:to="{ name: 'chargeServiceAuthorize', params: { id: service.id } }"
|
||||||
|
class="btn btn-xs btn-warning btn-outline">Capture</router-link>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -102,47 +119,58 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- MOBILE VIEW: Cards (Revamped) -->
|
<!-- MOBILE VIEW: Cards (Revamped) -->
|
||||||
<div class="xl:hidden space-y-4">
|
<div class="xl:hidden space-y-4 px-4 pb-4 pt-4">
|
||||||
<div v-for="service in services" :key="service.id" class="card bg-base-100 shadow-md ">
|
<div v-for="service in services" :key="service.id" class="mobile-card">
|
||||||
<div class="card-body p-4">
|
<div class="p-3">
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<router-link
|
<router-link :to="{ name: 'customerProfile', params: { id: service.customer_id } }"
|
||||||
:to="{ name: 'customerProfile', params: { id: service.customer_id } }"
|
class="card-title text-base link link-hover">
|
||||||
class="card-title text-base text-white hover:text-green-500 hover:underline"
|
|
||||||
>
|
|
||||||
{{ service.customer_name }}
|
{{ service.customer_name }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<p class="text-xs text-gray-500">ID: {{ service.id }}</p>
|
<p class="text-xs text-base-content/60">ID: {{ service.id }}</p>
|
||||||
<p class="text-xs text-gray-400">{{ service.customer_address }}, {{ service.customer_town }}</p>
|
<p class="text-xs text-base-content/50">{{ service.customer_address }}, {{ service.customer_town }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- Mobile view already uses a badge, which is great! No changes needed here. -->
|
<!-- Mobile view already uses a badge, which is great! No changes needed here. -->
|
||||||
<div class="badge badge-outline text-right" :style="{ 'border-color': getServiceTypeColor(service.type_service_call), color: getServiceTypeColor(service.type_service_call) }">
|
<div class="badge badge-sm text-white"
|
||||||
|
:style="{ 'background-color': getServiceTypeColor(service.type_service_call), 'border-color': getServiceTypeColor(service.type_service_call) }">
|
||||||
{{ getServiceTypeName(service.type_service_call) }}
|
{{ getServiceTypeName(service.type_service_call) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
|
<div class="text-sm mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
|
||||||
<p><strong class="font-semibold">Date:</strong> {{ formatDate(service.scheduled_date) }}</p>
|
<div>
|
||||||
<p><strong class="font-semibold">Time:</strong> {{ formatTime(service.scheduled_date) }}</p>
|
<p class="text-xs text-base-content/50">Time</p>
|
||||||
<p><strong class="font-semibold">Cost:</strong> <span class="font-mono">{{ formatCurrency(service.service_cost) }}</span></p>
|
<p>{{ formatTime(service.scheduled_date) }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-base-content/50">Cost</p>
|
||||||
|
<p class="font-mono font-bold">{{ formatCurrency(service.service_cost) }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="service.description" class="text-sm mt-2 p-2 bg-base-200 rounded-md prose max-w-none">
|
<div v-if="service.description" class="text-sm mt-3 p-2 bg-base-200/50 rounded-md">
|
||||||
<div v-if="!isLongDescription(service.description) || isExpanded(service.id)">
|
<div v-if="!isLongDescription(service.description) || isExpanded(service.id)">
|
||||||
{{ service.description }}
|
{{ service.description }}
|
||||||
<a v-if="isLongDescription(service.description)" @click.prevent="toggleExpand(service.id)" href="#" class="link link-info link-hover text-xs ml-1 whitespace-nowrap">Show less</a>
|
<a v-if="isLongDescription(service.description)" @click.prevent="toggleExpand(service.id)" href="#"
|
||||||
|
class="link link-primary text-xs ml-1 whitespace-nowrap">Show less</a>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
{{ truncateDescription(service.description) }}
|
{{ truncateDescription(service.description) }}
|
||||||
<a @click.prevent="toggleExpand(service.id)" href="#" class="link link-info link-hover text-xs ml-1 whitespace-nowrap">Read more</a>
|
<a @click.prevent="toggleExpand(service.id)" href="#"
|
||||||
|
class="link link-primary text-xs ml-1 whitespace-nowrap">Read more</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-actions justify-end mt-2 space-x-2">
|
<div class="flex gap-2 pt-3 mt-3 border-t border-base-content/10 flex-wrap">
|
||||||
<button @click="openEditModal(service)" class="btn btn-sm btn-primary">View</button>
|
<button @click="openEditModal(service)" class="btn btn-sm btn-info btn-outline flex-1">Edit</button>
|
||||||
<router-link v-if="shouldShowChargeButton(service)" :to="{ name: 'payService', params: { id: service.id } }" class="btn btn-sm btn-success">Charge</router-link>
|
<router-link v-if="shouldShowChargeButton(service)"
|
||||||
<router-link v-if="shouldShowCaptureButton(service)" :to="{ name: 'chargeServiceAuthorize', params: { id: service.id } }" class="btn btn-sm btn-warning">Capture</router-link>
|
:to="{ name: 'payService', params: { id: service.id } }"
|
||||||
|
class="btn btn-sm btn-success btn-outline flex-1">Charge</router-link>
|
||||||
|
<router-link v-if="shouldShowCaptureButton(service)"
|
||||||
|
:to="{ name: 'chargeServiceAuthorize', params: { id: service.id } }"
|
||||||
|
class="btn btn-sm btn-warning btn-outline flex-1">Capture</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,15 +180,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<ServiceEditModal
|
|
||||||
v-if="selectedServiceForEdit"
|
<ServiceEditModal v-if="selectedServiceForEdit" :service="selectedServiceForEdit" @close-modal="closeEditModal"
|
||||||
:service="selectedServiceForEdit"
|
@save-changes="handleSaveChanges" @delete-service="handleDeleteService" />
|
||||||
@close-modal="closeEditModal"
|
|
||||||
@save-changes="handleSaveChanges"
|
|
||||||
@delete-service="handleDeleteService"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -183,7 +206,7 @@ const expandedIds = ref<number[]>([])
|
|||||||
// Lifecycle
|
// Lifecycle
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (authService) {
|
if (authService) {
|
||||||
userStatus();
|
userStatus();
|
||||||
}
|
}
|
||||||
fetchPastServices();
|
fetchPastServices();
|
||||||
})
|
})
|
||||||
@@ -218,9 +241,9 @@ const fetchPastServices = async (): Promise<void> => {
|
|||||||
try {
|
try {
|
||||||
const response = await serviceService.getPast();
|
const response = await serviceService.getPast();
|
||||||
if (response.data && response.data.services) {
|
if (response.data && response.data.services) {
|
||||||
services.value = response.data.services.sort((a: ServiceCall, b: ServiceCall) => b.id - a.id);
|
services.value = response.data.services.sort((a: ServiceCall, b: ServiceCall) => b.id - a.id);
|
||||||
} else {
|
} else {
|
||||||
services.value = [];
|
services.value = [];
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch past service calls:", error);
|
console.error("Failed to fetch past service calls:", error);
|
||||||
|
|||||||
@@ -10,16 +10,31 @@
|
|||||||
<li>Service Plans</li>
|
<li>Service Plans</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-3xl font-bold mt-4">Service Plans</h1>
|
<!-- Page Header -->
|
||||||
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mt-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl md:text-3xl font-bold flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center shadow-lg">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
||||||
|
stroke="currentColor" class="w-5 h-5 text-primary-content">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
Service Plans
|
||||||
|
</h1>
|
||||||
|
<p class="text-base-content/60 mt-1 ml-13">Manage active service contracts and agreements</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!isLoading" class="stat-pill">
|
||||||
|
<span class="stat-pill-value">{{ servicePlans.length }}</span>
|
||||||
|
<span class="stat-pill-label">Contracts</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Main Content Card -->
|
<!-- Main Content Card -->
|
||||||
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
|
<div class="modern-table-card">
|
||||||
<!-- Header: Title and Count -->
|
|
||||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-4">
|
|
||||||
<h2 class="text-lg font-bold">Active Service Contracts</h2>
|
|
||||||
<div v-if="!isLoading" class="badge badge-ghost">{{ servicePlans.length }} contracts found</div>
|
|
||||||
</div>
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div v-if="isLoading" class="text-center p-10">
|
<div v-if="isLoading" class="text-center p-10">
|
||||||
@@ -36,7 +51,7 @@
|
|||||||
<div v-else>
|
<div v-else>
|
||||||
<!-- DESKTOP VIEW: Table -->
|
<!-- DESKTOP VIEW: Table -->
|
||||||
<div class="overflow-x-auto hidden xl:block">
|
<div class="overflow-x-auto hidden xl:block">
|
||||||
<table class="table w-full">
|
<table class="modern-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Customer</th>
|
<th>Customer</th>
|
||||||
@@ -49,14 +64,14 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="plan in servicePlans" :key="plan.id" class="hover:bg-blue-600">
|
<tr v-for="plan in servicePlans" :key="plan.id" class="table-row-hover">
|
||||||
<td class="align-top">
|
<td class="align-top">
|
||||||
<div class="font-semibold">{{ plan.customer_name }}</div>
|
<div class="font-bold">{{ plan.customer_name }}</div>
|
||||||
<div class="text-sm opacity-70">{{ plan.customer_address }}, {{ plan.customer_town }}</div>
|
<div class="text-sm opacity-70">{{ plan.customer_address }}, {{ plan.customer_town }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="align-top">
|
<td class="align-top">
|
||||||
<span class="badge badge-sm text-white"
|
<span class="badge badge-sm text-white"
|
||||||
:style="{ 'background-color': getPlanColor(plan.contract_plan) }">
|
:style="{ 'background-color': getPlanColor(plan.contract_plan) }">
|
||||||
{{ getPlanName(plan.contract_plan) }}
|
{{ getPlanName(plan.contract_plan) }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -64,16 +79,16 @@
|
|||||||
<td class="align-top">{{ formatDate(plan.contract_start_date) }}</td>
|
<td class="align-top">{{ formatDate(plan.contract_start_date) }}</td>
|
||||||
<td class="align-top">{{ formatEndDate(plan.contract_start_date, plan.contract_years) }}</td>
|
<td class="align-top">{{ formatEndDate(plan.contract_start_date, plan.contract_years) }}</td>
|
||||||
<td class="align-top">
|
<td class="align-top">
|
||||||
<span class="badge" :class="getStatusBadge(plan.contract_start_date, plan.contract_years)">
|
<span class="badge badge-sm" :class="getStatusBadge(plan.contract_start_date, plan.contract_years)">
|
||||||
{{ getStatusText(plan.contract_start_date, plan.contract_years) }}
|
{{ getStatusText(plan.contract_start_date, plan.contract_years) }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right align-top">
|
<td class="text-right align-top">
|
||||||
<div class="flex items-center justify-end gap-1">
|
<div class="flex items-center justify-end gap-1">
|
||||||
<router-link :to="{ name: 'customerProfile', params: { id: plan.customer_id } }"
|
<router-link :to="{ name: 'customerProfile', params: { id: plan.customer_id } }"
|
||||||
class="btn btn-xs btn-ghost">View Profile</router-link>
|
class="btn btn-xs btn-ghost">Profile</router-link>
|
||||||
<router-link :to="{ name: 'servicePlanEdit', params: { id: plan.customer_id } }"
|
<router-link :to="{ name: 'servicePlanEdit', params: { id: plan.customer_id } }"
|
||||||
class="btn btn-xs btn-secondary">Edit Contract</router-link>
|
class="btn btn-xs btn-info btn-outline">Edit</router-link>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -82,35 +97,38 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- MOBILE VIEW: Cards -->
|
<!-- MOBILE VIEW: Cards -->
|
||||||
<div class="xl:hidden space-y-4">
|
<div class="xl:hidden space-y-4 px-4 pb-4">
|
||||||
<div v-for="plan in servicePlans" :key="plan.id" class="card bg-base-100 shadow-md">
|
<div v-for="plan in servicePlans" :key="plan.id" class="mobile-card">
|
||||||
<div class="card-body p-4">
|
<div class="p-3">
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="card-title text-base">{{ plan.customer_name }}</h2>
|
<h2 class="font-bold text-base">{{ plan.customer_name }}</h2>
|
||||||
<p class="text-xs text-gray-400">{{ plan.customer_address }}, {{ plan.customer_town }}</p>
|
<p class="text-xs text-base-content/60">{{ plan.customer_address }}, {{ plan.customer_town }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="badge badge-outline" :style="{ 'border-color': getPlanColor(plan.contract_plan), color: getPlanColor(plan.contract_plan) }">
|
<div class="badge badge-sm text-white"
|
||||||
|
:style="{ 'background-color': getPlanColor(plan.contract_plan) }">
|
||||||
{{ getPlanName(plan.contract_plan) }}
|
{{ getPlanName(plan.contract_plan) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
|
<div class="text-sm mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
|
||||||
<p><strong class="font-semibold">Years:</strong> {{ plan.contract_years }}</p>
|
<p><strong class="font-semibold">Years:</strong> {{ plan.contract_years }}</p>
|
||||||
<p><strong class="font-semibold">Start:</strong> {{ formatDate(plan.contract_start_date) }}</p>
|
<p><strong class="font-semibold">Start:</strong> {{ formatDate(plan.contract_start_date) }}</p>
|
||||||
<p><strong class="font-semibold">End:</strong> {{ formatEndDate(plan.contract_start_date, plan.contract_years) }}</p>
|
<p><strong class="font-semibold">End:</strong> {{ formatEndDate(plan.contract_start_date,
|
||||||
|
plan.contract_years) }}</p>
|
||||||
<p><strong class="font-semibold">Status:</strong>
|
<p><strong class="font-semibold">Status:</strong>
|
||||||
<span class="badge badge-sm ml-1" :class="getStatusBadge(plan.contract_start_date, plan.contract_years)">
|
<span class="badge badge-sm ml-1"
|
||||||
|
:class="getStatusBadge(plan.contract_start_date, plan.contract_years)">
|
||||||
{{ getStatusText(plan.contract_start_date, plan.contract_years) }}
|
{{ getStatusText(plan.contract_start_date, plan.contract_years) }}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-actions justify-end mt-4">
|
<div class="flex gap-2 pt-3 mt-3 border-t border-base-content/10 flex-wrap">
|
||||||
<router-link :to="{ name: 'customerProfile', params: { id: plan.customer_id } }"
|
<router-link :to="{ name: 'customerProfile', params: { id: plan.customer_id } }"
|
||||||
class="btn btn-sm btn-ghost">View Profile</router-link>
|
class="btn btn-sm btn-ghost flex-1">View Profile</router-link>
|
||||||
<router-link :to="{ name: 'servicePlanEdit', params: { id: plan.customer_id } }"
|
<router-link :to="{ name: 'servicePlanEdit', params: { id: plan.customer_id } }"
|
||||||
class="btn btn-sm btn-secondary">Edit Contract</router-link>
|
class="btn btn-sm btn-info btn-outline flex-1">Edit Contract</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,7 +138,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -138,7 +156,7 @@ const isLoading = ref(true)
|
|||||||
// Lifecycle
|
// Lifecycle
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (authService) {
|
if (authService) {
|
||||||
userStatus();
|
userStatus();
|
||||||
}
|
}
|
||||||
fetchServicePlans();
|
fetchServicePlans();
|
||||||
})
|
})
|
||||||
@@ -149,9 +167,9 @@ const fetchServicePlans = async (): Promise<void> => {
|
|||||||
try {
|
try {
|
||||||
const response = await serviceService.plans.getActive();
|
const response = await serviceService.plans.getActive();
|
||||||
if (response.data && response.data.plans) {
|
if (response.data && response.data.plans) {
|
||||||
servicePlans.value = response.data.plans;
|
servicePlans.value = response.data.plans;
|
||||||
} else {
|
} else {
|
||||||
servicePlans.value = [];
|
servicePlans.value = [];
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch service plans:", error);
|
console.error("Failed to fetch service plans:", error);
|
||||||
|
|||||||
@@ -12,13 +12,31 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content Card -->
|
<!-- Main Content Card -->
|
||||||
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
|
<!-- Page Header -->
|
||||||
<!-- Header: Title and Count -->
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mt-4 mb-6">
|
||||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-4">
|
<div>
|
||||||
<h2 class="text-lg font-bold">Today's Service Calls</h2>
|
<h1 class="text-2xl md:text-3xl font-bold flex items-center gap-3">
|
||||||
<div v-if="!isLoading" class="badge badge-ghost">{{ services.length }} calls found</div>
|
<div
|
||||||
|
class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center shadow-lg">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
||||||
|
stroke="currentColor" class="w-5 h-5 text-primary-content">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
Today's Service Calls
|
||||||
|
</h1>
|
||||||
|
<p class="text-base-content/60 mt-1 ml-13">Scheduled maintenance and emergency calls for today</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="divider"></div>
|
|
||||||
|
<div v-if="!isLoading" class="stat-pill">
|
||||||
|
<span class="stat-pill-value">{{ services.length }}</span>
|
||||||
|
<span class="stat-pill-label">Calls Today</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content Card -->
|
||||||
|
<div class="modern-table-card">
|
||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div v-if="isLoading" class="text-center p-10">
|
<div v-if="isLoading" class="text-center p-10">
|
||||||
@@ -35,7 +53,7 @@
|
|||||||
<div v-else>
|
<div v-else>
|
||||||
<!-- DESKTOP VIEW: Table (Revamped) -->
|
<!-- DESKTOP VIEW: Table (Revamped) -->
|
||||||
<div class="overflow-x-auto hidden xl:block">
|
<div class="overflow-x-auto hidden xl:block">
|
||||||
<table class="table w-full">
|
<table class="modern-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
@@ -49,17 +67,15 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="service in services" :key="service.id" class="hover:bg-blue-600">
|
<tr v-for="service in services" :key="service.id" class="table-row-hover">
|
||||||
<td class="align-top">{{ service.id }}</td>
|
<td class="align-top">{{ service.id }}</td>
|
||||||
<td class="align-top">
|
<td class="align-top">
|
||||||
<div>{{ formatDate(service.scheduled_date) }}</div>
|
<div>{{ formatDate(service.scheduled_date) }}</div>
|
||||||
<div class="text-xs opacity-70">{{ formatTime(service.scheduled_date) }}</div>
|
<div class="text-xs opacity-70">{{ formatTime(service.scheduled_date) }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="align-top">
|
<td class="align-top">
|
||||||
<router-link
|
<router-link :to="{ name: 'customerProfile', params: { id: service.customer_id } }"
|
||||||
:to="{ name: 'customerProfile', params: { id: service.customer_id } }"
|
class="link link-hover font-bold">
|
||||||
class="text-white hover:text-green-500 hover:underline"
|
|
||||||
>
|
|
||||||
{{ service.customer_name }}
|
{{ service.customer_name }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</td>
|
</td>
|
||||||
@@ -72,10 +88,8 @@
|
|||||||
- The background color is set dynamically using your existing `getServiceTypeColor` method.
|
- The background color is set dynamically using your existing `getServiceTypeColor` method.
|
||||||
-->
|
-->
|
||||||
<td class="align-top">
|
<td class="align-top">
|
||||||
<span
|
<span class="badge badge-sm text-white"
|
||||||
class="badge badge-sm text-white"
|
:style="{ 'background-color': getServiceTypeColor(service.type_service_call), 'border-color': getServiceTypeColor(service.type_service_call) }">
|
||||||
:style="{ 'background-color': getServiceTypeColor(service.type_service_call), 'border-color': getServiceTypeColor(service.type_service_call) }"
|
|
||||||
>
|
|
||||||
{{ getServiceTypeName(service.type_service_call) }}
|
{{ getServiceTypeName(service.type_service_call) }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -83,18 +97,26 @@
|
|||||||
<td class="whitespace-normal text-sm align-top">
|
<td class="whitespace-normal text-sm align-top">
|
||||||
<div v-if="!isLongDescription(service.description) || isExpanded(service.id)">
|
<div v-if="!isLongDescription(service.description) || isExpanded(service.id)">
|
||||||
{{ service.description }}
|
{{ service.description }}
|
||||||
<a v-if="isLongDescription(service.description)" @click.prevent="toggleExpand(service.id)" href="#" class="link link-info link-hover text-xs ml-1 whitespace-nowrap">Show less</a>
|
<a v-if="isLongDescription(service.description)" @click.prevent="toggleExpand(service.id)"
|
||||||
|
href="#" class="link link-info link-hover text-xs ml-1 whitespace-nowrap">Show less</a>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
{{ truncateDescription(service.description) }}
|
{{ truncateDescription(service.description) }}
|
||||||
<a @click.prevent="toggleExpand(service.id)" href="#" class="link link-info link-hover text-xs ml-1 whitespace-nowrap">Read more</a>
|
<a @click.prevent="toggleExpand(service.id)" href="#"
|
||||||
|
class="link link-info link-hover text-xs ml-1 whitespace-nowrap">Read more</a>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right font-mono align-top">{{ formatCurrency(service.service_cost) }}</td>
|
<td class="text-right font-mono align-top">{{ formatCurrency(service.service_cost) }}</td>
|
||||||
<td class="text-right align-top space-x-2">
|
<td class="text-right align-top">
|
||||||
<button @click="openEditModal(service)" class="btn btn-sm btn-primary">View</button>
|
<div class="flex items-center justify-end gap-1">
|
||||||
<router-link v-if="shouldShowChargeButton(service)" :to="{ name: 'payService', params: { id: service.id } }" class="btn btn-sm btn-success">Charge</router-link>
|
<button @click="openEditModal(service)" class="btn btn-xs btn-info btn-outline">Edit</button>
|
||||||
<router-link v-if="shouldShowCaptureButton(service)" :to="{ name: 'chargeServiceAuthorize', params: { id: service.id } }" class="btn btn-sm btn-warning">Capture</router-link>
|
<router-link v-if="shouldShowChargeButton(service)"
|
||||||
|
:to="{ name: 'payService', params: { id: service.id } }"
|
||||||
|
class="btn btn-xs btn-success btn-outline">Charge</router-link>
|
||||||
|
<router-link v-if="shouldShowCaptureButton(service)"
|
||||||
|
:to="{ name: 'chargeServiceAuthorize', params: { id: service.id } }"
|
||||||
|
class="btn btn-xs btn-warning btn-outline">Capture</router-link>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -102,47 +124,58 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- MOBILE VIEW: Cards (Revamped) -->
|
<!-- MOBILE VIEW: Cards (Revamped) -->
|
||||||
<div class="xl:hidden space-y-4">
|
<div class="xl:hidden space-y-4 px-4 pb-4 pt-4">
|
||||||
<div v-for="service in services" :key="service.id" class="card bg-base-100 shadow-md ">
|
<div v-for="service in services" :key="service.id" class="mobile-card">
|
||||||
<div class="card-body p-4">
|
<div class="p-3">
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<router-link
|
<router-link :to="{ name: 'customerProfile', params: { id: service.customer_id } }"
|
||||||
:to="{ name: 'customerProfile', params: { id: service.customer_id } }"
|
class="card-title text-base link link-hover">
|
||||||
class="card-title text-base text-white hover:text-green-500 hover:underline"
|
|
||||||
>
|
|
||||||
{{ service.customer_name }}
|
{{ service.customer_name }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<p class="text-xs text-gray-500">ID: {{ service.id }}</p>
|
<p class="text-xs text-base-content/60">ID: {{ service.id }}</p>
|
||||||
<p class="text-xs text-gray-400">{{ service.customer_address }}, {{ service.customer_town }}</p>
|
<p class="text-xs text-base-content/50">{{ service.customer_address }}, {{ service.customer_town }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- Mobile view already uses a badge, which is great! No changes needed here. -->
|
<!-- Mobile view already uses a badge, which is great! No changes needed here. -->
|
||||||
<div class="badge badge-outline text-right" :style="{ 'border-color': getServiceTypeColor(service.type_service_call), color: getServiceTypeColor(service.type_service_call) }">
|
<div class="badge badge-sm text-white"
|
||||||
|
:style="{ 'background-color': getServiceTypeColor(service.type_service_call), 'border-color': getServiceTypeColor(service.type_service_call) }">
|
||||||
{{ getServiceTypeName(service.type_service_call) }}
|
{{ getServiceTypeName(service.type_service_call) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
|
<div class="text-sm mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
|
||||||
<p><strong class="font-semibold">Date:</strong> {{ formatDate(service.scheduled_date) }}</p>
|
<div>
|
||||||
<p><strong class="font-semibold">Time:</strong> {{ formatTime(service.scheduled_date) }}</p>
|
<p class="text-xs text-base-content/50">Time</p>
|
||||||
<p><strong class="font-semibold">Cost:</strong> <span class="font-mono">{{ formatCurrency(service.service_cost) }}</span></p>
|
<p>{{ formatTime(service.scheduled_date) }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-base-content/50">Cost</p>
|
||||||
|
<p class="font-mono font-bold">{{ formatCurrency(service.service_cost) }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="service.description" class="text-sm mt-2 p-2 bg-base-200 rounded-md prose max-w-none">
|
<div v-if="service.description" class="text-sm mt-3 p-2 bg-base-200/50 rounded-md">
|
||||||
<div v-if="!isLongDescription(service.description) || isExpanded(service.id)">
|
<div v-if="!isLongDescription(service.description) || isExpanded(service.id)">
|
||||||
{{ service.description }}
|
{{ service.description }}
|
||||||
<a v-if="isLongDescription(service.description)" @click.prevent="toggleExpand(service.id)" href="#" class="link link-info link-hover text-xs ml-1 whitespace-nowrap">Show less</a>
|
<a v-if="isLongDescription(service.description)" @click.prevent="toggleExpand(service.id)" href="#"
|
||||||
|
class="link link-primary text-xs ml-1 whitespace-nowrap">Show less</a>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
{{ truncateDescription(service.description) }}
|
{{ truncateDescription(service.description) }}
|
||||||
<a @click.prevent="toggleExpand(service.id)" href="#" class="link link-info link-hover text-xs ml-1 whitespace-nowrap">Read more</a>
|
<a @click.prevent="toggleExpand(service.id)" href="#"
|
||||||
|
class="link link-primary text-xs ml-1 whitespace-nowrap">Read more</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-actions justify-end mt-2 space-x-2">
|
<div class="flex gap-2 pt-3 mt-3 border-t border-base-content/10 flex-wrap">
|
||||||
<button @click="openEditModal(service)" class="btn btn-sm btn-primary">View</button>
|
<button @click="openEditModal(service)" class="btn btn-sm btn-info btn-outline flex-1">Edit</button>
|
||||||
<router-link v-if="shouldShowChargeButton(service)" :to="{ name: 'payService', params: { id: service.id } }" class="btn btn-sm btn-success">Charge</router-link>
|
<router-link v-if="shouldShowChargeButton(service)"
|
||||||
<router-link v-if="shouldShowCaptureButton(service)" :to="{ name: 'chargeServiceAuthorize', params: { id: service.id } }" class="btn btn-sm btn-warning">Capture</router-link>
|
:to="{ name: 'payService', params: { id: service.id } }"
|
||||||
|
class="btn btn-sm btn-success btn-outline flex-1">Charge</router-link>
|
||||||
|
<router-link v-if="shouldShowCaptureButton(service)"
|
||||||
|
:to="{ name: 'chargeServiceAuthorize', params: { id: service.id } }"
|
||||||
|
class="btn btn-sm btn-warning btn-outline flex-1">Capture</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,15 +185,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<ServiceEditModal
|
|
||||||
v-if="selectedServiceForEdit"
|
<ServiceEditModal v-if="selectedServiceForEdit" :service="selectedServiceForEdit" @close-modal="closeEditModal"
|
||||||
:service="selectedServiceForEdit"
|
@save-changes="handleSaveChanges" @delete-service="handleDeleteService" />
|
||||||
@close-modal="closeEditModal"
|
|
||||||
@save-changes="handleSaveChanges"
|
|
||||||
@delete-service="handleDeleteService"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -183,7 +211,7 @@ const expandedIds = ref<number[]>([])
|
|||||||
// Lifecycle
|
// Lifecycle
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (authService) { // Check if imported correctly
|
if (authService) { // Check if imported correctly
|
||||||
userStatus();
|
userStatus();
|
||||||
}
|
}
|
||||||
fetchTodayServices();
|
fetchTodayServices();
|
||||||
})
|
})
|
||||||
@@ -222,9 +250,9 @@ const fetchTodayServices = async (): Promise<void> => {
|
|||||||
// However, the api unwrap interceptor might put properties directly on data
|
// However, the api unwrap interceptor might put properties directly on data
|
||||||
// Let's assume the response structure follows the type
|
// Let's assume the response structure follows the type
|
||||||
if (response.data && response.data.services) {
|
if (response.data && response.data.services) {
|
||||||
services.value = response.data.services.sort((a: ServiceCall, b: ServiceCall) => b.id - a.id);
|
services.value = response.data.services.sort((a: ServiceCall, b: ServiceCall) => b.id - a.id);
|
||||||
} else {
|
} else {
|
||||||
services.value = [];
|
services.value = [];
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch today's service calls:", error);
|
console.error("Failed to fetch today's service calls:", error);
|
||||||
|
|||||||
282
src/pages/stats/DailyDeliveriesGraph.vue
Normal file
282
src/pages/stats/DailyDeliveriesGraph.vue
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-neutral rounded-lg p-4 sm:p-6">
|
||||||
|
<!-- Controls -->
|
||||||
|
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-4 mb-6">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Time Range</span>
|
||||||
|
</label>
|
||||||
|
<TimeRangeSelector v-model="timeRange" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Compare Years</span>
|
||||||
|
</label>
|
||||||
|
<YearSelector v-model="selectedYears" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-4 items-end">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Start Date</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
v-model="startDate"
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">End Date</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
v-model="endDate"
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ExportButtons :data="exportData" :filename="'gallons-' + timeRange" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="loading" class="flex justify-center items-center h-[400px]">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
<div v-else-if="error" class="alert alert-error">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
<button class="btn btn-sm" @click="fetchData">Retry</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chart -->
|
||||||
|
<DeliveryChart
|
||||||
|
v-else
|
||||||
|
:datasets="chartDatasets"
|
||||||
|
:title="chartTitle"
|
||||||
|
:use-time-scale="timeRange !== 'month'"
|
||||||
|
:x-axis-label="xAxisLabel"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Summary Stats -->
|
||||||
|
<div v-if="!loading && !error" class="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-6">
|
||||||
|
<div v-for="(yearData, index) in summaryStats" :key="yearData.year" class="stat bg-base-100 rounded-lg p-3">
|
||||||
|
<div class="stat-title text-sm">{{ yearData.year }} Total</div>
|
||||||
|
<div class="stat-value text-lg" :style="{ color: getYearColor(index) }">
|
||||||
|
{{ yearData.totalGallons.toLocaleString() }}
|
||||||
|
</div>
|
||||||
|
<div class="stat-desc">{{ yearData.totalDeliveries }} deliveries</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onMounted } from 'vue';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import TimeRangeSelector from './components/TimeRangeSelector.vue';
|
||||||
|
import YearSelector from './components/YearSelector.vue';
|
||||||
|
import DeliveryChart from './components/DeliveryChart.vue';
|
||||||
|
import ExportButtons from './components/ExportButtons.vue';
|
||||||
|
import { statsService } from '../../services/statsService';
|
||||||
|
import type { TimeRange, DailyGallonsYearData, WeeklyGallonsYearData, MonthlyGallonsYearData } from '../../types/stats';
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
// State
|
||||||
|
const timeRange = ref<TimeRange>('day');
|
||||||
|
const selectedYears = ref<number[]>([currentYear, currentYear - 1]);
|
||||||
|
const startDate = ref(dayjs().subtract(30, 'day').format('YYYY-MM-DD'));
|
||||||
|
const endDate = ref(dayjs().format('YYYY-MM-DD'));
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
// Data storage
|
||||||
|
const dailyData = ref<DailyGallonsYearData[]>([]);
|
||||||
|
const weeklyData = ref<WeeklyGallonsYearData[]>([]);
|
||||||
|
const monthlyData = ref<MonthlyGallonsYearData[]>([]);
|
||||||
|
|
||||||
|
// Colors for year lines
|
||||||
|
const yearColors = [
|
||||||
|
{ border: 'rgb(59, 130, 246)', background: 'rgba(59, 130, 246, 0.1)' }, // Blue
|
||||||
|
{ border: 'rgb(16, 185, 129)', background: 'rgba(16, 185, 129, 0.1)' }, // Green
|
||||||
|
{ border: 'rgb(249, 115, 22)', background: 'rgba(249, 115, 22, 0.1)' }, // Orange
|
||||||
|
{ border: 'rgb(139, 92, 246)', background: 'rgba(139, 92, 246, 0.1)' }, // Purple
|
||||||
|
];
|
||||||
|
|
||||||
|
function getYearColor(index: number): string {
|
||||||
|
return yearColors[index % yearColors.length].border;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const chartTitle = computed(() => {
|
||||||
|
const rangeLabels = { day: 'Daily', week: 'Weekly', month: 'Monthly' };
|
||||||
|
return `${rangeLabels[timeRange.value]} Gallons Delivered`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const xAxisLabel = computed(() => {
|
||||||
|
const labels = { day: 'Date', week: 'Week', month: 'Month' };
|
||||||
|
return labels[timeRange.value];
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartDatasets = computed(() => {
|
||||||
|
if (timeRange.value === 'day') {
|
||||||
|
return dailyData.value.map((yearData, index) => ({
|
||||||
|
label: `${yearData.year}`,
|
||||||
|
data: yearData.data.map(d => ({ x: d.date, y: d.gallons })),
|
||||||
|
borderColor: yearColors[index % yearColors.length].border,
|
||||||
|
backgroundColor: yearColors[index % yearColors.length].background
|
||||||
|
}));
|
||||||
|
} else if (timeRange.value === 'week') {
|
||||||
|
return weeklyData.value.map((yearData, index) => ({
|
||||||
|
label: `${yearData.year}`,
|
||||||
|
data: yearData.data.map(d => ({ x: d.week_start, y: d.gallons })),
|
||||||
|
borderColor: yearColors[index % yearColors.length].border,
|
||||||
|
backgroundColor: yearColors[index % yearColors.length].background
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// Monthly - use month names as categories
|
||||||
|
return monthlyData.value.map((yearData, index) => ({
|
||||||
|
label: `${yearData.year}`,
|
||||||
|
data: yearData.data.map(d => ({ x: d.month_name, y: d.gallons })),
|
||||||
|
borderColor: yearColors[index % yearColors.length].border,
|
||||||
|
backgroundColor: yearColors[index % yearColors.length].background
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const summaryStats = computed(() => {
|
||||||
|
let data: { year: number; totalGallons: number; totalDeliveries: number }[] = [];
|
||||||
|
|
||||||
|
if (timeRange.value === 'day') {
|
||||||
|
data = dailyData.value.map(yearData => ({
|
||||||
|
year: yearData.year,
|
||||||
|
totalGallons: yearData.data.reduce((sum, d) => sum + d.gallons, 0),
|
||||||
|
totalDeliveries: yearData.data.reduce((sum, d) => sum + d.deliveries, 0)
|
||||||
|
}));
|
||||||
|
} else if (timeRange.value === 'week') {
|
||||||
|
data = weeklyData.value.map(yearData => ({
|
||||||
|
year: yearData.year,
|
||||||
|
totalGallons: yearData.data.reduce((sum, d) => sum + d.gallons, 0),
|
||||||
|
totalDeliveries: yearData.data.reduce((sum, d) => sum + d.deliveries, 0)
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
data = monthlyData.value.map(yearData => ({
|
||||||
|
year: yearData.year,
|
||||||
|
totalGallons: yearData.data.reduce((sum, d) => sum + d.gallons, 0),
|
||||||
|
totalDeliveries: yearData.data.reduce((sum, d) => sum + d.deliveries, 0)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportData = computed(() => {
|
||||||
|
const rows: Record<string, unknown>[] = [];
|
||||||
|
|
||||||
|
if (timeRange.value === 'day') {
|
||||||
|
dailyData.value.forEach(yearData => {
|
||||||
|
yearData.data.forEach(d => {
|
||||||
|
rows.push({
|
||||||
|
year: yearData.year,
|
||||||
|
date: d.date,
|
||||||
|
gallons: d.gallons,
|
||||||
|
deliveries: d.deliveries
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else if (timeRange.value === 'week') {
|
||||||
|
weeklyData.value.forEach(yearData => {
|
||||||
|
yearData.data.forEach(d => {
|
||||||
|
rows.push({
|
||||||
|
year: yearData.year,
|
||||||
|
week_start: d.week_start,
|
||||||
|
week_end: d.week_end,
|
||||||
|
week_number: d.week_number,
|
||||||
|
gallons: d.gallons,
|
||||||
|
deliveries: d.deliveries
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
monthlyData.value.forEach(yearData => {
|
||||||
|
yearData.data.forEach(d => {
|
||||||
|
rows.push({
|
||||||
|
year: yearData.year,
|
||||||
|
month: d.month,
|
||||||
|
month_name: d.month_name,
|
||||||
|
gallons: d.gallons,
|
||||||
|
deliveries: d.deliveries
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
async function fetchData() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (timeRange.value === 'day') {
|
||||||
|
const response = await statsService.getDailyGallons({
|
||||||
|
start_date: startDate.value,
|
||||||
|
end_date: endDate.value,
|
||||||
|
years: selectedYears.value
|
||||||
|
});
|
||||||
|
if (response.data.ok !== false) {
|
||||||
|
dailyData.value = response.data.years || [];
|
||||||
|
} else {
|
||||||
|
error.value = 'Failed to load daily data';
|
||||||
|
}
|
||||||
|
} else if (timeRange.value === 'week') {
|
||||||
|
const response = await statsService.getWeeklyGallons({
|
||||||
|
start_date: startDate.value,
|
||||||
|
end_date: endDate.value,
|
||||||
|
years: selectedYears.value
|
||||||
|
});
|
||||||
|
if (response.data.ok !== false) {
|
||||||
|
weeklyData.value = response.data.years || [];
|
||||||
|
} else {
|
||||||
|
error.value = 'Failed to load weekly data';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const response = await statsService.getMonthlyGallons({
|
||||||
|
year: selectedYears.value[0],
|
||||||
|
compare_years: selectedYears.value.slice(1)
|
||||||
|
});
|
||||||
|
if (response.data.ok !== false) {
|
||||||
|
monthlyData.value = response.data.years || [];
|
||||||
|
} else {
|
||||||
|
error.value = 'Failed to load monthly data';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching stats data:', err);
|
||||||
|
error.value = 'An error occurred while fetching data';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watchers
|
||||||
|
watch([timeRange, selectedYears, startDate, endDate], () => {
|
||||||
|
fetchData();
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
fetchData();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
51
src/pages/stats/StatsLayout.vue
Normal file
51
src/pages/stats/StatsLayout.vue
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="w-full px-4 md:px-10 py-4">
|
||||||
|
<!-- Breadcrumbs -->
|
||||||
|
<div class="text-sm breadcrumbs">
|
||||||
|
<ul>
|
||||||
|
<li><router-link :to="{ name: 'home' }">Home</router-link></li>
|
||||||
|
<li>Stats</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mt-4 mb-6">
|
||||||
|
<h1 class="text-2xl font-bold">Delivery Statistics</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Navigation -->
|
||||||
|
<div role="tablist" class="tabs tabs-boxed bg-neutral mb-6">
|
||||||
|
<router-link
|
||||||
|
:to="{ name: 'statsDailyDeliveries' }"
|
||||||
|
role="tab"
|
||||||
|
class="tab"
|
||||||
|
:class="{ 'tab-active': $route.name === 'statsDailyDeliveries' }"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 mr-2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
|
||||||
|
</svg>
|
||||||
|
Daily Deliveries
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
:to="{ name: 'statsTotals' }"
|
||||||
|
role="tab"
|
||||||
|
class="tab"
|
||||||
|
:class="{ 'tab-active': $route.name === 'statsTotals' }"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 mr-2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 14.25v2.25m3-4.5v4.5m3-6.75v6.75m3-9v9M6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z" />
|
||||||
|
</svg>
|
||||||
|
Totals Comparison
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Child Route Content -->
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Layout component - no logic needed
|
||||||
|
</script>
|
||||||
207
src/pages/stats/TotalsComparison.vue
Normal file
207
src/pages/stats/TotalsComparison.vue
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-neutral rounded-lg p-4 sm:p-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-bold">Period Totals</h2>
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
Comparing {{ currentYear }} vs {{ compareYear }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ExportButtons :data="exportData" filename="totals-comparison" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="loading" class="flex justify-center items-center h-64">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
<div v-else-if="error" class="alert alert-error">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
<button class="btn btn-sm" @click="fetchData">Retry</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comparison Cards -->
|
||||||
|
<div v-else class="space-y-8">
|
||||||
|
<!-- Today -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold mb-3">Today</h3>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<ComparisonCard
|
||||||
|
v-if="comparisonData?.today"
|
||||||
|
title="Gallons Delivered"
|
||||||
|
:current-value="comparisonData.today.gallons.current"
|
||||||
|
:previous-value="comparisonData.today.gallons.previous"
|
||||||
|
:change-percent="comparisonData.today.gallons.change_percent"
|
||||||
|
:change-direction="comparisonData.today.gallons.change_direction"
|
||||||
|
:compare-year="compareYear"
|
||||||
|
format="gallons"
|
||||||
|
/>
|
||||||
|
<ComparisonCard
|
||||||
|
v-if="comparisonData?.today"
|
||||||
|
title="Deliveries"
|
||||||
|
:current-value="comparisonData.today.deliveries.current"
|
||||||
|
:previous-value="comparisonData.today.deliveries.previous"
|
||||||
|
:change-percent="comparisonData.today.deliveries.change_percent"
|
||||||
|
:change-direction="comparisonData.today.deliveries.change_direction"
|
||||||
|
:compare-year="compareYear"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Week to Date -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold mb-3">Week to Date</h3>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<ComparisonCard
|
||||||
|
v-if="comparisonData?.week_to_date"
|
||||||
|
title="Gallons Delivered"
|
||||||
|
:current-value="comparisonData.week_to_date.gallons.current"
|
||||||
|
:previous-value="comparisonData.week_to_date.gallons.previous"
|
||||||
|
:change-percent="comparisonData.week_to_date.gallons.change_percent"
|
||||||
|
:change-direction="comparisonData.week_to_date.gallons.change_direction"
|
||||||
|
:compare-year="compareYear"
|
||||||
|
format="gallons"
|
||||||
|
/>
|
||||||
|
<ComparisonCard
|
||||||
|
v-if="comparisonData?.week_to_date"
|
||||||
|
title="Deliveries"
|
||||||
|
:current-value="comparisonData.week_to_date.deliveries.current"
|
||||||
|
:previous-value="comparisonData.week_to_date.deliveries.previous"
|
||||||
|
:change-percent="comparisonData.week_to_date.deliveries.change_percent"
|
||||||
|
:change-direction="comparisonData.week_to_date.deliveries.change_direction"
|
||||||
|
:compare-year="compareYear"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Month to Date -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold mb-3">Month to Date</h3>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<ComparisonCard
|
||||||
|
v-if="comparisonData?.month_to_date"
|
||||||
|
title="Gallons Delivered"
|
||||||
|
:current-value="comparisonData.month_to_date.gallons.current"
|
||||||
|
:previous-value="comparisonData.month_to_date.gallons.previous"
|
||||||
|
:change-percent="comparisonData.month_to_date.gallons.change_percent"
|
||||||
|
:change-direction="comparisonData.month_to_date.gallons.change_direction"
|
||||||
|
:compare-year="compareYear"
|
||||||
|
format="gallons"
|
||||||
|
/>
|
||||||
|
<ComparisonCard
|
||||||
|
v-if="comparisonData?.month_to_date"
|
||||||
|
title="Deliveries"
|
||||||
|
:current-value="comparisonData.month_to_date.deliveries.current"
|
||||||
|
:previous-value="comparisonData.month_to_date.deliveries.previous"
|
||||||
|
:change-percent="comparisonData.month_to_date.deliveries.change_percent"
|
||||||
|
:change-direction="comparisonData.month_to_date.deliveries.change_direction"
|
||||||
|
:compare-year="compareYear"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Year to Date -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold mb-3">Year to Date</h3>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<ComparisonCard
|
||||||
|
v-if="comparisonData?.year_to_date"
|
||||||
|
title="Gallons Delivered"
|
||||||
|
:current-value="comparisonData.year_to_date.gallons.current"
|
||||||
|
:previous-value="comparisonData.year_to_date.gallons.previous"
|
||||||
|
:change-percent="comparisonData.year_to_date.gallons.change_percent"
|
||||||
|
:change-direction="comparisonData.year_to_date.gallons.change_direction"
|
||||||
|
:compare-year="compareYear"
|
||||||
|
format="gallons"
|
||||||
|
/>
|
||||||
|
<ComparisonCard
|
||||||
|
v-if="comparisonData?.year_to_date"
|
||||||
|
title="Deliveries"
|
||||||
|
:current-value="comparisonData.year_to_date.deliveries.current"
|
||||||
|
:previous-value="comparisonData.year_to_date.deliveries.previous"
|
||||||
|
:change-percent="comparisonData.year_to_date.deliveries.change_percent"
|
||||||
|
:change-direction="comparisonData.year_to_date.deliveries.change_direction"
|
||||||
|
:compare-year="compareYear"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import ComparisonCard from './components/ComparisonCard.vue';
|
||||||
|
import ExportButtons from './components/ExportButtons.vue';
|
||||||
|
import { statsService } from '../../services/statsService';
|
||||||
|
import type { TotalsComparisonData } from '../../types/stats';
|
||||||
|
|
||||||
|
// State
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
const comparisonData = ref<TotalsComparisonData | null>(null);
|
||||||
|
const currentYear = ref(new Date().getFullYear());
|
||||||
|
const compareYear = ref(new Date().getFullYear() - 1);
|
||||||
|
|
||||||
|
// Export data for CSV
|
||||||
|
const exportData = computed(() => {
|
||||||
|
if (!comparisonData.value) return [];
|
||||||
|
|
||||||
|
const periods = ['today', 'week_to_date', 'month_to_date', 'year_to_date'] as const;
|
||||||
|
const metrics = ['gallons', 'deliveries'] as const;
|
||||||
|
const rows: Record<string, unknown>[] = [];
|
||||||
|
|
||||||
|
periods.forEach(period => {
|
||||||
|
const periodData = comparisonData.value?.[period];
|
||||||
|
if (periodData) {
|
||||||
|
metrics.forEach(metric => {
|
||||||
|
const metricData = periodData[metric];
|
||||||
|
rows.push({
|
||||||
|
period: period.replace(/_/g, ' '),
|
||||||
|
metric,
|
||||||
|
current_year: currentYear.value,
|
||||||
|
current_value: metricData.current,
|
||||||
|
previous_year: compareYear.value,
|
||||||
|
previous_value: metricData.previous,
|
||||||
|
change_percent: metricData.change_percent,
|
||||||
|
change_direction: metricData.change_direction
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
async function fetchData() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await statsService.getTotalsComparison();
|
||||||
|
if (response.data.ok !== false) {
|
||||||
|
comparisonData.value = response.data.comparison;
|
||||||
|
currentYear.value = response.data.current_year;
|
||||||
|
compareYear.value = response.data.compare_year;
|
||||||
|
} else {
|
||||||
|
error.value = 'Failed to load comparison data';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching comparison data:', err);
|
||||||
|
error.value = 'An error occurred while fetching data';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
fetchData();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
81
src/pages/stats/components/ComparisonCard.vue
Normal file
81
src/pages/stats/components/ComparisonCard.vue
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<template>
|
||||||
|
<div class="stat bg-base-100 rounded-lg shadow">
|
||||||
|
<div class="stat-figure" :class="iconColorClass">
|
||||||
|
<svg v-if="changeDirection === 'up'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-8 h-8">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18L9 11.25l4.306 4.307a11.95 11.95 0 015.814-5.519l2.74-1.22m0 0l-5.94-2.28m5.94 2.28l-2.28 5.941" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else-if="changeDirection === 'down'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-8 h-8">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6L9 12.75l4.286-4.286a11.948 11.948 0 014.306 6.43l.776 2.898m0 0l3.182-5.511m-3.182 5.51l-5.511-3.181" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-8 h-8">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12h-15" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-title">{{ title }}</div>
|
||||||
|
<div class="stat-value text-2xl">{{ formattedValue }}</div>
|
||||||
|
<div class="stat-desc" :class="changeColorClass">
|
||||||
|
<span v-if="changeDirection === 'up'">+</span>
|
||||||
|
<span v-else-if="changeDirection === 'down'">-</span>
|
||||||
|
{{ Math.abs(changePercent).toFixed(1) }}% vs {{ compareYear }}
|
||||||
|
<span class="text-base-content/60 ml-1">({{ formattedPrevious }})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
currentValue: number;
|
||||||
|
previousValue: number;
|
||||||
|
changePercent: number;
|
||||||
|
changeDirection: 'up' | 'down' | 'neutral';
|
||||||
|
compareYear: number;
|
||||||
|
format?: 'number' | 'currency' | 'gallons';
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
format: 'number'
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedValue = computed(() => {
|
||||||
|
if (props.format === 'currency') {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
}).format(props.currentValue);
|
||||||
|
}
|
||||||
|
if (props.format === 'gallons') {
|
||||||
|
return props.currentValue.toLocaleString() + ' gal';
|
||||||
|
}
|
||||||
|
return props.currentValue.toLocaleString();
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedPrevious = computed(() => {
|
||||||
|
if (props.format === 'currency') {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
}).format(props.previousValue);
|
||||||
|
}
|
||||||
|
if (props.format === 'gallons') {
|
||||||
|
return props.previousValue.toLocaleString() + ' gal';
|
||||||
|
}
|
||||||
|
return props.previousValue.toLocaleString();
|
||||||
|
});
|
||||||
|
|
||||||
|
const iconColorClass = computed(() => {
|
||||||
|
if (props.changeDirection === 'up') return 'text-success';
|
||||||
|
if (props.changeDirection === 'down') return 'text-error';
|
||||||
|
return 'text-base-content/50';
|
||||||
|
});
|
||||||
|
|
||||||
|
const changeColorClass = computed(() => {
|
||||||
|
if (props.changeDirection === 'up') return 'text-success';
|
||||||
|
if (props.changeDirection === 'down') return 'text-error';
|
||||||
|
return 'text-base-content/50';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
179
src/pages/stats/components/DeliveryChart.vue
Normal file
179
src/pages/stats/components/DeliveryChart.vue
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-[400px] relative">
|
||||||
|
<Line
|
||||||
|
v-if="chartData"
|
||||||
|
:data="chartData as any"
|
||||||
|
:options="chartOptions as any"
|
||||||
|
/>
|
||||||
|
<div v-else class="flex items-center justify-center h-full">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler,
|
||||||
|
TimeScale
|
||||||
|
} from 'chart.js';
|
||||||
|
import { Line } from 'vue-chartjs';
|
||||||
|
import 'chartjs-adapter-dayjs-4';
|
||||||
|
import zoomPlugin from 'chartjs-plugin-zoom';
|
||||||
|
|
||||||
|
// Register Chart.js components
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler,
|
||||||
|
TimeScale,
|
||||||
|
zoomPlugin
|
||||||
|
);
|
||||||
|
|
||||||
|
interface DataPoint {
|
||||||
|
x: string | Date;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Dataset {
|
||||||
|
label: string;
|
||||||
|
data: DataPoint[];
|
||||||
|
borderColor: string;
|
||||||
|
backgroundColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
datasets: Dataset[];
|
||||||
|
title?: string;
|
||||||
|
xAxisLabel?: string;
|
||||||
|
yAxisLabel?: string;
|
||||||
|
useTimeScale?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
title: '',
|
||||||
|
xAxisLabel: 'Date',
|
||||||
|
yAxisLabel: 'Gallons',
|
||||||
|
useTimeScale: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Color palette for different years
|
||||||
|
const colors = [
|
||||||
|
{ border: 'rgb(59, 130, 246)', background: 'rgba(59, 130, 246, 0.1)' }, // Blue
|
||||||
|
{ border: 'rgb(16, 185, 129)', background: 'rgba(16, 185, 129, 0.1)' }, // Green
|
||||||
|
{ border: 'rgb(249, 115, 22)', background: 'rgba(249, 115, 22, 0.1)' }, // Orange
|
||||||
|
{ border: 'rgb(139, 92, 246)', background: 'rgba(139, 92, 246, 0.1)' }, // Purple
|
||||||
|
];
|
||||||
|
|
||||||
|
const chartData = computed(() => {
|
||||||
|
if (!props.datasets || props.datasets.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
datasets: props.datasets.map((ds, index) => ({
|
||||||
|
label: ds.label,
|
||||||
|
data: ds.data,
|
||||||
|
borderColor: ds.borderColor || colors[index % colors.length].border,
|
||||||
|
backgroundColor: ds.backgroundColor || colors[index % colors.length].background,
|
||||||
|
tension: 0.3,
|
||||||
|
fill: false,
|
||||||
|
pointRadius: 3,
|
||||||
|
pointHoverRadius: 6,
|
||||||
|
spanGaps: true
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartOptions = computed(() => ({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
title: {
|
||||||
|
display: !!props.title,
|
||||||
|
text: props.title,
|
||||||
|
font: { size: 16 }
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: (context: { parsed: { y: number | null }; dataset: { label?: string } }) => {
|
||||||
|
const value = context.parsed?.y ?? 0;
|
||||||
|
return `${context.dataset.label || ''}: ${value.toLocaleString()} gallons`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
zoom: {
|
||||||
|
pan: {
|
||||||
|
enabled: true,
|
||||||
|
mode: 'x',
|
||||||
|
},
|
||||||
|
zoom: {
|
||||||
|
wheel: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
pinch: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
mode: 'x',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: props.useTimeScale ? {
|
||||||
|
type: 'time',
|
||||||
|
time: {
|
||||||
|
unit: 'day',
|
||||||
|
displayFormats: {
|
||||||
|
day: 'MMM D',
|
||||||
|
week: 'MMM D',
|
||||||
|
month: 'MMM YYYY'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: props.xAxisLabel
|
||||||
|
}
|
||||||
|
} : {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: props.xAxisLabel
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: props.yAxisLabel
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
callback: (value: string | number) => {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value.toLocaleString();
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
108
src/pages/stats/components/ExportButtons.vue
Normal file
108
src/pages/stats/components/ExportButtons.vue
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline"
|
||||||
|
@click="exportToCSV"
|
||||||
|
:disabled="!data || data.length === 0"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||||
|
</svg>
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline"
|
||||||
|
@click="copyToClipboard"
|
||||||
|
:disabled="!data || data.length === 0"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
|
||||||
|
</svg>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useNotification } from "@kyvg/vue3-notification";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: Record<string, unknown>[];
|
||||||
|
filename?: string;
|
||||||
|
columns?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
filename: 'stats-export',
|
||||||
|
columns: () => []
|
||||||
|
});
|
||||||
|
|
||||||
|
const { notify } = useNotification();
|
||||||
|
|
||||||
|
function getHeaders(): string[] {
|
||||||
|
if (props.columns.length > 0) {
|
||||||
|
return props.columns;
|
||||||
|
}
|
||||||
|
if (props.data.length > 0) {
|
||||||
|
return Object.keys(props.data[0]);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertToCSV(): string {
|
||||||
|
const headers = getHeaders();
|
||||||
|
const headerRow = headers.join(',');
|
||||||
|
|
||||||
|
const rows = props.data.map(row => {
|
||||||
|
return headers.map(header => {
|
||||||
|
const value = row[header];
|
||||||
|
// Escape quotes and wrap in quotes if contains comma
|
||||||
|
if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) {
|
||||||
|
return `"${value.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return value ?? '';
|
||||||
|
}).join(',');
|
||||||
|
});
|
||||||
|
|
||||||
|
return [headerRow, ...rows].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportToCSV() {
|
||||||
|
const csv = convertToCSV();
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
link.setAttribute('href', url);
|
||||||
|
link.setAttribute('download', `${props.filename}-${new Date().toISOString().split('T')[0]}.csv`);
|
||||||
|
link.style.visibility = 'hidden';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
notify({
|
||||||
|
title: 'Export Complete',
|
||||||
|
text: 'CSV file downloaded successfully',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToClipboard() {
|
||||||
|
const csv = convertToCSV();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(csv);
|
||||||
|
notify({
|
||||||
|
title: 'Copied',
|
||||||
|
text: 'Data copied to clipboard',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
notify({
|
||||||
|
title: 'Error',
|
||||||
|
text: 'Failed to copy to clipboard',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
32
src/pages/stats/components/TimeRangeSelector.vue
Normal file
32
src/pages/stats/components/TimeRangeSelector.vue
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<template>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button
|
||||||
|
v-for="option in options"
|
||||||
|
:key="option.value"
|
||||||
|
class="btn btn-sm"
|
||||||
|
:class="{ 'btn-active': modelValue === option.value }"
|
||||||
|
@click="$emit('update:modelValue', option.value)"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TimeRange } from '../../../types/stats';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue: TimeRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>();
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: TimeRange): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ value: 'day' as TimeRange, label: 'Daily' },
|
||||||
|
{ value: 'week' as TimeRange, label: 'Weekly' },
|
||||||
|
{ value: 'month' as TimeRange, label: 'Monthly' }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
60
src/pages/stats/components/YearSelector.vue
Normal file
60
src/pages/stats/components/YearSelector.vue
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<label
|
||||||
|
v-for="year in availableYears"
|
||||||
|
:key="year"
|
||||||
|
class="label cursor-pointer gap-2 p-0"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-sm checkbox-primary"
|
||||||
|
:checked="modelValue.includes(year)"
|
||||||
|
@change="toggleYear(year)"
|
||||||
|
/>
|
||||||
|
<span class="label-text">{{ year }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue: number[];
|
||||||
|
yearsBack?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
yearsBack: 3
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: number[]): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
const availableYears = computed(() => {
|
||||||
|
const years: number[] = [];
|
||||||
|
for (let i = 0; i <= props.yearsBack; i++) {
|
||||||
|
years.push(currentYear - i);
|
||||||
|
}
|
||||||
|
return years;
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleYear(year: number) {
|
||||||
|
const newValue = [...props.modelValue];
|
||||||
|
const index = newValue.indexOf(year);
|
||||||
|
|
||||||
|
if (index === -1) {
|
||||||
|
newValue.push(year);
|
||||||
|
} else if (newValue.length > 1) {
|
||||||
|
// Don't allow deselecting the last year
|
||||||
|
newValue.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort descending
|
||||||
|
newValue.sort((a, b) => b - a);
|
||||||
|
emit('update:modelValue', newValue);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
28
src/pages/stats/routes.ts
Normal file
28
src/pages/stats/routes.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
const StatsLayout = () => import('./StatsLayout.vue');
|
||||||
|
const DailyDeliveriesGraph = () => import('./DailyDeliveriesGraph.vue');
|
||||||
|
const TotalsComparison = () => import('./TotalsComparison.vue');
|
||||||
|
|
||||||
|
const statsRoutes = [
|
||||||
|
{
|
||||||
|
path: '/stats',
|
||||||
|
component: StatsLayout,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
redirect: { name: 'statsDailyDeliveries' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'daily',
|
||||||
|
name: 'statsDailyDeliveries',
|
||||||
|
component: DailyDeliveriesGraph
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'totals',
|
||||||
|
name: 'statsTotals',
|
||||||
|
component: TotalsComparison
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export default statsRoutes;
|
||||||
@@ -10,20 +10,35 @@
|
|||||||
<li>Authorize</li>
|
<li>Authorize</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-3xl font-bold mt-4">Authorize.net</h1>
|
<!-- Page Header with Stats -->
|
||||||
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mt-4 mb-6">
|
||||||
<!-- Main Content Card -->
|
<div>
|
||||||
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
|
<h1 class="text-2xl md:text-3xl font-bold flex items-center gap-3">
|
||||||
<!-- Header: Title and Count -->
|
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center shadow-lg">
|
||||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-4">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-primary-content">
|
||||||
<h2 class="text-lg font-bold">All Transactions</h2>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
Authorize.net Transactions
|
||||||
|
</h1>
|
||||||
|
<p class="text-base-content/60 mt-1 ml-13">View payment gateway history and status</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<!-- Quick Stats -->
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<div class="stat-pill">
|
||||||
|
<span class="stat-pill-value">{{ transactions.length }}</span>
|
||||||
|
<span class="stat-pill-label">Total Records</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content Card -->
|
||||||
|
<div class="modern-table-card">
|
||||||
|
|
||||||
<!-- DESKTOP VIEW: Table -->
|
<!-- DESKTOP VIEW: Table -->
|
||||||
<div class="overflow-x-auto hidden xl:block">
|
<div class="overflow-x-auto hidden xl:block">
|
||||||
<table class="table w-full">
|
<table class="modern-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Transaction #</th>
|
<th>Transaction #</th>
|
||||||
@@ -40,7 +55,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<template v-for="transaction in transactions" :key="transaction.id">
|
<template v-for="transaction in transactions" :key="transaction.id">
|
||||||
<tr class="hover:bg-blue-600 hover:text-white">
|
<tr class="table-row-hover">
|
||||||
|
|
||||||
<td>{{ transaction.id }}</td>
|
<td>{{ transaction.id }}</td>
|
||||||
<td>{{ transaction.auth_net_transaction_id || 'N/A' }}</td>
|
<td>{{ transaction.auth_net_transaction_id || 'N/A' }}</td>
|
||||||
@@ -102,57 +117,79 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- MOBILE VIEW: Cards -->
|
<!-- MOBILE VIEW: Cards -->
|
||||||
<div class="xl:hidden space-y-4">
|
<div class="xl:hidden space-y-4 px-4 pb-4">
|
||||||
<div v-for="transaction in transactions" :key="transaction.id" class="card bg-base-100 shadow-md">
|
<div v-for="transaction in transactions" :key="transaction.id" class="mobile-card">
|
||||||
<div class="card-body p-4">
|
<div class="p-3">
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="card-title text-base">
|
<div class="text-base font-bold">
|
||||||
<router-link v-if="transaction.customer_id" :to="{ name: 'customerProfile', params: { id: transaction.customer_id } }" class="link link-primary">
|
<router-link v-if="transaction.customer_id" :to="{ name: 'customerProfile', params: { id: transaction.customer_id } }" class="link link-hover">
|
||||||
{{ transaction.customer_name || 'N/A' }}
|
{{ transaction.customer_name || 'N/A' }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<span v-else>{{ transaction.customer_name || 'N/A' }}</span>
|
<span v-else>{{ transaction.customer_name || 'N/A' }}</span>
|
||||||
</h2>
|
</div>
|
||||||
<p class="text-xs text-gray-400">Transaction #{{ transaction.id }}</p>
|
<p class="text-xs text-base-content/60">Transaction #{{ transaction.id }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div :class="'badge badge-' + getStatusClass(transaction.status)">
|
<div :class="'badge badge-sm border-0 ' + getStatusClass(transaction.status)">
|
||||||
{{ getStatusText(transaction.status) }}
|
{{ getStatusText(transaction.status) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm mt-2 space-y-1">
|
<div class="text-sm mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
|
||||||
<p><strong>Transaction ID:</strong> {{ transaction.auth_net_transaction_id || 'N/A' }}</p>
|
<div class="col-span-2">
|
||||||
<p><strong>Date:</strong> {{ formatDate(transaction.created_at) }}</p>
|
<p class="text-xs text-base-content/50">Transaction ID</p>
|
||||||
<p><strong>Pre-Auth:</strong> ${{ transaction.preauthorize_amount || '0.00' }}</p>
|
<p class="font-mono text-xs">{{ transaction.auth_net_transaction_id || 'N/A' }}</p>
|
||||||
<p><strong>Charge:</strong> ${{ transaction.charge_amount || '0.00' }}</p>
|
|
||||||
<p><strong>Type:</strong> {{ transaction.transaction_type === 0 ? 'Charge' : transaction.transaction_type === 1 ? 'Auth' : 'Capture' }}</p>
|
|
||||||
<p><strong>Source:</strong> <router-link v-if="transaction.delivery_id" :to="{ name: 'deliveryOrder', params: { id: transaction.delivery_id } }" class="link link-primary">{{ getSourceText(transaction) }}</router-link><span v-else>{{ getSourceText(transaction) }}</span></p>
|
|
||||||
<!-- Rejection Reason in Mobile View -->
|
|
||||||
<div v-if="transaction.rejection_reason && transaction.rejection_reason.trim()" class="bg-transparent border border-gray-300 rounded-md p-3 mt-2">
|
|
||||||
<div class="flex items-start">
|
|
||||||
<svg class="w-4 h-4 mr-2 text-red-500 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
|
|
||||||
</svg>
|
|
||||||
<span class="text-red-700 text-sm">{{ transaction.rejection_reason }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Action Buttons -->
|
<div>
|
||||||
<div class="mt-3 space-y-2">
|
<p class="text-xs text-base-content/50">Date</p>
|
||||||
<router-link v-if="transaction.delivery_id" :to="{ name: 'deliveryOrder', params: { id: transaction.delivery_id } }" class="btn btn-xs btn-info btn-block">
|
<p>{{ formatDate(transaction.created_at) }}</p>
|
||||||
View Delivery
|
|
||||||
</router-link>
|
|
||||||
<router-link v-if="transaction.auto_id" :to="{ name: 'automaticView', params: { id: transaction.auto_id } }" class="btn btn-xs btn-primary btn-block">
|
|
||||||
View Automatic
|
|
||||||
</router-link>
|
|
||||||
<template v-if="(Number(transaction.preauthorize_amount) >= 0 && Number(transaction.charge_amount) === 0 && (transaction.delivery_id || transaction.service_id))">
|
|
||||||
<router-link v-if="!transaction.auth_net_transaction_id" :to="getPreauthRoute(transaction)" class="btn btn-xs btn-warning btn-block">
|
|
||||||
Preauth/Charge
|
|
||||||
</router-link>
|
|
||||||
<router-link v-else-if="Number(transaction.preauthorize_amount) > 0" :to="getCaptureRoute(transaction)" class="btn btn-xs btn-primary btn-block">
|
|
||||||
Capture Payment
|
|
||||||
</router-link>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-base-content/50">Type</p>
|
||||||
|
<p>{{ transaction.transaction_type === 0 ? 'Charge' : transaction.transaction_type === 1 ? 'Auth' : 'Capture' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-base-content/50">Pre-Auth</p>
|
||||||
|
<p class="font-mono">${{ transaction.preauthorize_amount || '0.00' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-base-content/50">Charge</p>
|
||||||
|
<p class="font-mono font-bold">${{ transaction.charge_amount || '0.00' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<p class="text-xs text-base-content/50">Source</p>
|
||||||
|
<p class="text-xs">
|
||||||
|
<router-link v-if="transaction.delivery_id" :to="{ name: 'deliveryOrder', params: { id: transaction.delivery_id } }" class="link link-primary">{{ getSourceText(transaction) }}</router-link><span v-else>{{ getSourceText(transaction) }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rejection Reason in Mobile View -->
|
||||||
|
<div v-if="transaction.rejection_reason && transaction.rejection_reason.trim()" class="bg-error/10 border border-error/20 rounded-lg p-3 mt-3">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<svg class="w-4 h-4 mr-2 text-error mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-error text-sm font-medium">{{ transaction.rejection_reason }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="flex gap-2 pt-3 mt-3 border-t border-base-content/10 flex-wrap">
|
||||||
|
<router-link v-if="transaction.delivery_id" :to="{ name: 'deliveryOrder', params: { id: transaction.delivery_id } }" class="btn btn-xs btn-info flex-1">
|
||||||
|
View Delivery
|
||||||
|
</router-link>
|
||||||
|
<router-link v-if="transaction.auto_id" :to="{ name: 'automaticView', params: { id: transaction.auto_id } }" class="btn btn-xs btn-primary flex-1">
|
||||||
|
View Auto
|
||||||
|
</router-link>
|
||||||
|
<template v-if="(Number(transaction.preauthorize_amount) >= 0 && Number(transaction.charge_amount) === 0 && (transaction.delivery_id || transaction.service_id))">
|
||||||
|
<router-link v-if="!transaction.auth_net_transaction_id" :to="getPreauthRoute(transaction)" class="btn btn-xs btn-warning flex-1">
|
||||||
|
Preauth/Charge
|
||||||
|
</router-link>
|
||||||
|
<router-link v-else-if="Number(transaction.preauthorize_amount) > 0" :to="getCaptureRoute(transaction)" class="btn btn-xs btn-primary flex-1">
|
||||||
|
Capture
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
372
src/pages/transactions/history.vue
Normal file
372
src/pages/transactions/history.vue
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
<!-- src/pages/transactions/history.vue -->
|
||||||
|
<template>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="w-full px-4 md:px-10 py-4">
|
||||||
|
<!-- Breadcrumbs -->
|
||||||
|
<div class="text-sm breadcrumbs">
|
||||||
|
<ul>
|
||||||
|
<li><router-link :to="{ name: 'home' }">Home</router-link></li>
|
||||||
|
<li><router-link :to="{ name: 'transactionsAuthorize' }">Transactions</router-link></li>
|
||||||
|
<li>History</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content Card -->
|
||||||
|
<!-- Page Header with Stats -->
|
||||||
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mt-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl md:text-3xl font-bold flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center shadow-lg">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
||||||
|
stroke="currentColor" class="w-5 h-5 text-primary-content">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
Delivery History
|
||||||
|
</h1>
|
||||||
|
<p class="text-base-content/60 mt-1 ml-13">View past deliveries and performance</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date Range Picker -->
|
||||||
|
<div class="flex gap-2 items-center bg-base-200 p-2 rounded-lg">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-0 px-1">
|
||||||
|
<span class="label-text text-xs font-semibold">Start</span>
|
||||||
|
</label>
|
||||||
|
<input type="date" v-model="startDate" class="input input-bordered input-sm focus:outline-none" />
|
||||||
|
</div>
|
||||||
|
<span class="text-base-content/50">-</span>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-0 px-1">
|
||||||
|
<span class="label-text text-xs font-semibold">End</span>
|
||||||
|
</label>
|
||||||
|
<input type="date" v-model="endDate" class="input input-bordered input-sm focus:outline-none" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary Stats Pills -->
|
||||||
|
<div v-if="!loading && deliveries.length > 0" class="flex flex-wrap gap-3 mb-6">
|
||||||
|
<div class="stat-pill">
|
||||||
|
<span class="stat-pill-value">{{ filteredDeliveries.length }}</span>
|
||||||
|
<span class="stat-pill-label">Deliveries</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-pill bg-secondary/10 border-secondary/20">
|
||||||
|
<span class="stat-pill-value text-secondary">{{ filteredTotalGallons.toLocaleString() }}</span>
|
||||||
|
<span class="stat-pill-label">Gallons</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-pill bg-accent/10 border-accent/20">
|
||||||
|
<span class="stat-pill-value text-accent">{{ Object.keys(townTotals).length }}</span>
|
||||||
|
<span class="stat-pill-label">Towns</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content Card -->
|
||||||
|
<div class="modern-table-card p-4 sm:p-6">
|
||||||
|
<!-- Filter Toggles -->
|
||||||
|
<div class="flex flex-wrap gap-4 mb-4 border-b border-base-content/10 pb-4">
|
||||||
|
<span class="label-text font-bold uppercase text-xs tracking-wider pt-1">Filter:</span>
|
||||||
|
<label class="label cursor-pointer gap-2 hover:bg-base-200 rounded-lg px-2 -ml-2">
|
||||||
|
<input type="checkbox" v-model="showPrime" class="checkbox checkbox-success checkbox-sm" />
|
||||||
|
<span class="label-text text-sm">
|
||||||
|
Prime
|
||||||
|
<span v-if="primeCount > 0" class="badge badge-success badge-sm ml-1">{{ primeCount }}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2 hover:bg-base-200 rounded-lg px-2">
|
||||||
|
<input type="checkbox" v-model="showEmergency" class="checkbox checkbox-error checkbox-sm" />
|
||||||
|
<span class="label-text text-sm">
|
||||||
|
Emergency
|
||||||
|
<span v-if="emergencyCount > 0" class="badge badge-error badge-sm ml-1">{{ emergencyCount }}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2 hover:bg-base-200 rounded-lg px-2">
|
||||||
|
<input type="checkbox" v-model="showSameDay" class="checkbox checkbox-warning checkbox-sm" />
|
||||||
|
<span class="label-text text-sm">
|
||||||
|
Same Day
|
||||||
|
<span v-if="sameDayCount > 0" class="badge badge-warning badge-sm ml-1">{{ sameDayCount }}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Col Toggles (Collapsible maybe? Keeping visible for now) -->
|
||||||
|
<div class="flex flex-wrap gap-4 mb-4 text-xs">
|
||||||
|
<span class="label-text font-bold uppercase tracking-wider pt-1 opacity-60">Columns:</span>
|
||||||
|
<label class="label cursor-pointer gap-2 p-0">
|
||||||
|
<input type="checkbox" v-model="colName" class="checkbox checkbox-xs checkbox-primary" />
|
||||||
|
<span class="label-text text-xs">Name</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2 p-0">
|
||||||
|
<input type="checkbox" v-model="colTown" class="checkbox checkbox-xs checkbox-primary" />
|
||||||
|
<span class="label-text text-xs">Town</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2 p-0">
|
||||||
|
<input type="checkbox" v-model="colAddress" class="checkbox checkbox-xs checkbox-primary" />
|
||||||
|
<span class="label-text text-xs">Address</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2 p-0">
|
||||||
|
<input type="checkbox" v-model="colGallons" class="checkbox checkbox-xs checkbox-primary" />
|
||||||
|
<span class="label-text text-xs">Gallons</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2 p-0">
|
||||||
|
<input type="checkbox" v-model="colFlags" class="checkbox checkbox-xs checkbox-primary" />
|
||||||
|
<span class="label-text text-xs">Flags</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2 p-0">
|
||||||
|
<input type="checkbox" v-model="colActions" class="checkbox checkbox-xs checkbox-primary" />
|
||||||
|
<span class="label-text text-xs">Actions</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary Stats (Legacy removal or keep? I moved main stats up. Keeping Town Totals here) -->
|
||||||
|
<div v-if="!loading && deliveries.length > 0 && Object.keys(townTotals).length > 0" class="mb-4">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span v-for="(data, town) in townTotals" :key="town" class="badge badge-outline badge-sm opacity-70">
|
||||||
|
{{ town }}: {{ data.gallons.toLocaleString() }} gal ({{ data.count }})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="loading" class="flex justify-center items-center py-20">
|
||||||
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
<div v-else-if="error" class="alert alert-error">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
<button class="btn btn-sm" @click="fetchDeliveries">Retry</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-else-if="deliveries.length === 0" class="text-center py-20">
|
||||||
|
<div class="w-16 h-16 bg-base-200 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-base-content/30" fill="none" viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="mt-2 text-sm font-bold">No deliveries found</h3>
|
||||||
|
<p class="mt-1 text-sm text-base-content/60">
|
||||||
|
No completed deliveries
|
||||||
|
<span v-if="startDate === endDate">on {{ formatDate(startDate) }}</span>
|
||||||
|
<span v-else>from {{ formatDate(startDate) }} to {{ formatDate(endDate) }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Deliveries Table (Desktop) -->
|
||||||
|
<div v-else class="overflow-x-auto hidden xl:block">
|
||||||
|
<table class="modern-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th v-if="colName">Name</th>
|
||||||
|
<th v-if="colTown">Town</th>
|
||||||
|
<th v-if="colAddress">Address</th>
|
||||||
|
<th v-if="colGallons" class="text-right">Gallons</th>
|
||||||
|
<th v-if="colFlags">Flags</th>
|
||||||
|
<th v-if="colActions" class="text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="delivery in filteredDeliveries" :key="delivery.id" class="table-row-hover">
|
||||||
|
<td v-if="colName" class="font-bold">
|
||||||
|
<router-link :to="{ name: 'customerProfile', params: { id: delivery.customerId } }"
|
||||||
|
class="link link-hover hover:text-primary">
|
||||||
|
{{ delivery.customerName }}
|
||||||
|
</router-link>
|
||||||
|
</td>
|
||||||
|
<td v-if="colTown">{{ delivery.town }}</td>
|
||||||
|
<td v-if="colAddress" class="max-w-xs text-sm opacity-70">{{ delivery.address }}</td>
|
||||||
|
<td v-if="colGallons" class="text-right font-mono font-bold">{{
|
||||||
|
Number(delivery.gallonsDelivered).toLocaleString() }}</td>
|
||||||
|
<td v-if="colFlags">
|
||||||
|
<div class="flex gap-1 flex-wrap">
|
||||||
|
<span v-if="delivery.prime" class="badge badge-success badge-sm gap-1">
|
||||||
|
P
|
||||||
|
</span>
|
||||||
|
<span v-if="delivery.emergency" class="badge badge-error badge-sm gap-1">
|
||||||
|
E
|
||||||
|
</span>
|
||||||
|
<span v-if="delivery.sameDay" class="badge badge-warning badge-sm gap-1">
|
||||||
|
S
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td v-if="colActions" class="text-right">
|
||||||
|
<router-link :to="{ name: 'deliveryOrder', params: { id: delivery.id } }"
|
||||||
|
class="btn btn-sm btn-ghost">
|
||||||
|
View
|
||||||
|
</router-link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot v-if="colGallons">
|
||||||
|
<tr class="font-bold bg-base-200/50">
|
||||||
|
<td :colspan="visibleColsBeforeGallons">Total</td>
|
||||||
|
<td class="text-right font-mono text-primary">{{ filteredTotalGallons.toLocaleString() }}</td>
|
||||||
|
<td :colspan="visibleColsAfterGallons"></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile Cards -->
|
||||||
|
<div class="xl:hidden space-y-4 pt-4">
|
||||||
|
<div v-for="delivery in filteredDeliveries" :key="delivery.id" class="mobile-card">
|
||||||
|
<div class="p-3">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<router-link :to="{ name: 'customerProfile', params: { id: delivery.customerId } }"
|
||||||
|
class="font-bold text-base link link-hover">
|
||||||
|
{{ delivery.customerName }}
|
||||||
|
</router-link>
|
||||||
|
<p class="text-xs text-base-content/60">{{ delivery.town }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-end">
|
||||||
|
<div class="font-mono font-bold text-primary">{{ Number(delivery.gallonsDelivered).toLocaleString() }}
|
||||||
|
gal</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-1 mt-2">
|
||||||
|
<span v-if="delivery.prime" class="badge badge-success badge-sm">Prime</span>
|
||||||
|
<span v-if="delivery.emergency" class="badge badge-error badge-sm">Emergency</span>
|
||||||
|
<span v-if="delivery.sameDay" class="badge badge-warning badge-sm">Same Day</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 pt-3 border-t border-base-content/10 flex justify-end">
|
||||||
|
<router-link :to="{ name: 'deliveryOrder', params: { id: delivery.id } }"
|
||||||
|
class="btn btn-sm btn-ghost w-full">
|
||||||
|
View Details
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onMounted } from 'vue';
|
||||||
|
import { deliveryService } from '../../services/deliveryService';
|
||||||
|
import type { DeliveryHistoryItem } from '../../types/models';
|
||||||
|
|
||||||
|
// State
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
const deliveries = ref<DeliveryHistoryItem[]>([]);
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const startDate = ref(today);
|
||||||
|
const endDate = ref(today);
|
||||||
|
|
||||||
|
// Filter toggles
|
||||||
|
const showPrime = ref(true);
|
||||||
|
const showEmergency = ref(true);
|
||||||
|
const showSameDay = ref(true);
|
||||||
|
|
||||||
|
// Column visibility toggles
|
||||||
|
const colName = ref(true);
|
||||||
|
const colTown = ref(true);
|
||||||
|
const colAddress = ref(false); // Off by default since most use case is quick view
|
||||||
|
const colGallons = ref(true);
|
||||||
|
const colFlags = ref(true);
|
||||||
|
const colActions = ref(true);
|
||||||
|
|
||||||
|
// Counts from API
|
||||||
|
const primeCount = ref(0);
|
||||||
|
const emergencyCount = ref(0);
|
||||||
|
const sameDayCount = ref(0);
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const visibleColsBeforeGallons = computed(() => {
|
||||||
|
return [colName.value, colTown.value, colAddress.value].filter(Boolean).length || 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleColsAfterGallons = computed(() => {
|
||||||
|
return [colFlags.value, colActions.value].filter(Boolean).length || 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredDeliveries = computed(() => {
|
||||||
|
return deliveries.value.filter(d => {
|
||||||
|
// If delivery has a flag and that filter is off, hide it
|
||||||
|
if (d.prime && !showPrime.value) return false;
|
||||||
|
if (d.emergency && !showEmergency.value) return false;
|
||||||
|
if (d.sameDay && !showSameDay.value) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredTotalGallons = computed(() => {
|
||||||
|
return filteredDeliveries.value.reduce((sum, d) => sum + Number(d.gallonsDelivered), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const townTotals = computed(() => {
|
||||||
|
const totals: Record<string, { gallons: number; count: number }> = {};
|
||||||
|
for (const d of filteredDeliveries.value) {
|
||||||
|
const town = d.town || 'Unknown';
|
||||||
|
if (!totals[town]) {
|
||||||
|
totals[town] = { gallons: 0, count: 0 };
|
||||||
|
}
|
||||||
|
totals[town].gallons += Number(d.gallonsDelivered);
|
||||||
|
totals[town].count += 1;
|
||||||
|
}
|
||||||
|
// Sort by town name
|
||||||
|
const sorted: Record<string, { gallons: number; count: number }> = {};
|
||||||
|
Object.keys(totals).sort().forEach(key => {
|
||||||
|
sorted[key] = totals[key];
|
||||||
|
});
|
||||||
|
return sorted;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const fetchDeliveries = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await deliveryService.getHistory(startDate.value, endDate.value);
|
||||||
|
if (response.data.ok) {
|
||||||
|
deliveries.value = response.data.deliveries || [];
|
||||||
|
primeCount.value = response.data.primeCount || 0;
|
||||||
|
emergencyCount.value = response.data.emergencyCount || 0;
|
||||||
|
sameDayCount.value = response.data.sameDayCount || 0;
|
||||||
|
} else {
|
||||||
|
error.value = 'Failed to fetch delivery history';
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error fetching delivery history:', err);
|
||||||
|
error.value = err.response?.data?.error || 'Failed to fetch delivery history';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
const date = new Date(dateStr + 'T00:00:00');
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watchers
|
||||||
|
watch([startDate, endDate], () => {
|
||||||
|
fetchDeliveries();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
fetchDeliveries();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
const AuthorizePage = () => import('./authorize/index.vue');
|
const AuthorizePage = () => import('./authorize/index.vue');
|
||||||
|
const HistoryPage = () => import('./history.vue');
|
||||||
|
|
||||||
const transactionsRoutes = [
|
const transactionsRoutes = [
|
||||||
{
|
{
|
||||||
@@ -6,6 +7,11 @@ const transactionsRoutes = [
|
|||||||
name: 'transactionsAuthorize',
|
name: 'transactionsAuthorize',
|
||||||
component: AuthorizePage,
|
component: AuthorizePage,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/transactions/history',
|
||||||
|
name: 'transactionsHistory',
|
||||||
|
component: HistoryPage,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default transactionsRoutes;
|
export default transactionsRoutes;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import tickerRoutes from "../pages/ticket/routes.ts";
|
|||||||
import moneyRoutes from "../pages/money/routes.ts";
|
import moneyRoutes from "../pages/money/routes.ts";
|
||||||
import serviceRoutes from "../pages/service/routes.ts";
|
import serviceRoutes from "../pages/service/routes.ts";
|
||||||
import transactionsRoutes from '../pages/transactions/routes.ts';
|
import transactionsRoutes from '../pages/transactions/routes.ts';
|
||||||
|
import statsRoutes from '../pages/stats/routes.ts';
|
||||||
|
|
||||||
// Import your page components
|
// Import your page components
|
||||||
import Home from '../pages/Index.vue';
|
import Home from '../pages/Index.vue';
|
||||||
@@ -56,6 +57,7 @@ const routes = [
|
|||||||
...protectRoutes(adminRoutes),
|
...protectRoutes(adminRoutes),
|
||||||
...protectRoutes(serviceRoutes),
|
...protectRoutes(serviceRoutes),
|
||||||
...protectRoutes(transactionsRoutes),
|
...protectRoutes(transactionsRoutes),
|
||||||
|
...protectRoutes(statsRoutes),
|
||||||
|
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
|
|||||||
69
src/services/addressService.ts
Normal file
69
src/services/addressService.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { AxiosResponse } from '../types/models';
|
||||||
|
|
||||||
|
// Address Checker API
|
||||||
|
const addressApi = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_ADDRESS_CHECKER_URL || 'http://localhost:9618',
|
||||||
|
withCredentials: false, // Address checker doesn't require auth
|
||||||
|
});
|
||||||
|
|
||||||
|
// Response types
|
||||||
|
export interface TownSuggestion {
|
||||||
|
town: string;
|
||||||
|
state: string;
|
||||||
|
state_id: number;
|
||||||
|
customer_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TownSearchResponse {
|
||||||
|
ok: boolean;
|
||||||
|
suggestions: TownSuggestion[];
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreetSuggestion {
|
||||||
|
street_name: string;
|
||||||
|
full_address: string;
|
||||||
|
zip: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreetSearchResponse {
|
||||||
|
ok: boolean;
|
||||||
|
suggestions: StreetSuggestion[];
|
||||||
|
town: string;
|
||||||
|
state: string;
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const addressService = {
|
||||||
|
/**
|
||||||
|
* Search for towns based on partial input.
|
||||||
|
* Returns towns from existing customer data sorted by customer count.
|
||||||
|
*/
|
||||||
|
searchTowns: (query: string, limit: number = 10): Promise<AxiosResponse<TownSearchResponse>> =>
|
||||||
|
addressApi.get('/towns/search', {
|
||||||
|
params: { q: query, limit }
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for streets within a specific town.
|
||||||
|
* Returns matching streets from the StreetReference table or customer addresses.
|
||||||
|
*/
|
||||||
|
searchStreets: (
|
||||||
|
town: string,
|
||||||
|
state: string,
|
||||||
|
query: string,
|
||||||
|
limit: number = 10
|
||||||
|
): Promise<AxiosResponse<StreetSearchResponse>> =>
|
||||||
|
addressApi.get('/streets/search', {
|
||||||
|
params: { town, state, q: query, limit }
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if address checker service is available.
|
||||||
|
*/
|
||||||
|
healthCheck: (): Promise<AxiosResponse<{ status: string; db_connected: boolean }>> =>
|
||||||
|
addressApi.get('/health'),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default addressService;
|
||||||
@@ -110,6 +110,13 @@ export const adminService = {
|
|||||||
|
|
||||||
pendingStatus: () =>
|
pendingStatus: () =>
|
||||||
api.get('/deliverystatus/pending'),
|
api.get('/deliverystatus/pending'),
|
||||||
|
|
||||||
|
// New methods for Stats section
|
||||||
|
getDailyStats: (startDate: string, endDate: string) =>
|
||||||
|
api.get(`/stats/deliveries/daily?start_date=${startDate}&end_date=${endDate}`),
|
||||||
|
|
||||||
|
getTotals: (period: string, date: string) =>
|
||||||
|
api.get(`/stats/deliveries/totals?period=${period}&date=${date}`),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Money & reporting
|
// Money & reporting
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
UpdateDeliveryRequest,
|
UpdateDeliveryRequest,
|
||||||
DeliveriesResponse,
|
DeliveriesResponse,
|
||||||
DeliveryResponse,
|
DeliveryResponse,
|
||||||
|
DeliveriesMapResponse,
|
||||||
|
DeliveryHistoryResponse,
|
||||||
AxiosResponse
|
AxiosResponse
|
||||||
} from '../types/models';
|
} from '../types/models';
|
||||||
|
|
||||||
@@ -23,6 +25,12 @@ export const deliveryService = {
|
|||||||
getOrder: (id: number): Promise<AxiosResponse<DeliveryResponse>> =>
|
getOrder: (id: number): Promise<AxiosResponse<DeliveryResponse>> =>
|
||||||
api.get(`/delivery/order/${id}`),
|
api.get(`/delivery/order/${id}`),
|
||||||
|
|
||||||
|
getForMap: (date: string): Promise<AxiosResponse<DeliveriesMapResponse>> =>
|
||||||
|
api.get(`/delivery/map`, { params: { date } }),
|
||||||
|
|
||||||
|
getHistory: (startDate: string, endDate: string): Promise<AxiosResponse<DeliveryHistoryResponse>> =>
|
||||||
|
api.get(`/delivery/history`, { params: { start_date: startDate, end_date: endDate } }),
|
||||||
|
|
||||||
update: (id: number, data: UpdateDeliveryRequest): Promise<AxiosResponse<DeliveryResponse>> =>
|
update: (id: number, data: UpdateDeliveryRequest): Promise<AxiosResponse<DeliveryResponse>> =>
|
||||||
api.put(`/delivery/edit/${id}`, data),
|
api.put(`/delivery/edit/${id}`, data),
|
||||||
|
|
||||||
|
|||||||
64
src/services/statsService.ts
Normal file
64
src/services/statsService.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import api from './api';
|
||||||
|
import type { AxiosResponse } from '../types/models';
|
||||||
|
import type {
|
||||||
|
DailyGallonsResponse,
|
||||||
|
WeeklyGallonsResponse,
|
||||||
|
MonthlyGallonsResponse,
|
||||||
|
TotalsComparisonResponse,
|
||||||
|
DailyGallonsParams,
|
||||||
|
WeeklyGallonsParams,
|
||||||
|
MonthlyGallonsParams
|
||||||
|
} from '../types/stats';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stats Service
|
||||||
|
* API methods for the stats dashboard charts and comparison cards
|
||||||
|
*/
|
||||||
|
export const statsService = {
|
||||||
|
/**
|
||||||
|
* Get daily gallons delivered for time-series chart
|
||||||
|
* @param params - start_date, end_date, years array
|
||||||
|
*/
|
||||||
|
getDailyGallons: (params: DailyGallonsParams): Promise<AxiosResponse<DailyGallonsResponse>> => {
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
start_date: params.start_date,
|
||||||
|
end_date: params.end_date,
|
||||||
|
years: params.years.join(',')
|
||||||
|
});
|
||||||
|
return api.get(`/stats/gallons/daily?${queryParams.toString()}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get weekly aggregated gallons delivered
|
||||||
|
* @param params - start_date, end_date, years array
|
||||||
|
*/
|
||||||
|
getWeeklyGallons: (params: WeeklyGallonsParams): Promise<AxiosResponse<WeeklyGallonsResponse>> => {
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
start_date: params.start_date,
|
||||||
|
end_date: params.end_date,
|
||||||
|
years: params.years.join(',')
|
||||||
|
});
|
||||||
|
return api.get(`/stats/gallons/weekly?${queryParams.toString()}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get monthly aggregated gallons delivered
|
||||||
|
* @param params - year, compare_years array
|
||||||
|
*/
|
||||||
|
getMonthlyGallons: (params: MonthlyGallonsParams): Promise<AxiosResponse<MonthlyGallonsResponse>> => {
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
year: params.year.toString(),
|
||||||
|
compare_years: params.compare_years.join(',')
|
||||||
|
});
|
||||||
|
return api.get(`/stats/gallons/monthly?${queryParams.toString()}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get totals comparison data (today, WTD, MTD, YTD vs previous year)
|
||||||
|
*/
|
||||||
|
getTotalsComparison: (): Promise<AxiosResponse<TotalsComparisonResponse>> => {
|
||||||
|
return api.get('/stats/totals/comparison');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default statsService;
|
||||||
@@ -819,4 +819,50 @@ export interface ThemeOption {
|
|||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
preview: string; // Primary color for preview swatch
|
preview: string; // Primary color for preview swatch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delivery Map interfaces
|
||||||
|
export interface DeliveryMapItem {
|
||||||
|
id: number;
|
||||||
|
street: string;
|
||||||
|
town: string;
|
||||||
|
state: string;
|
||||||
|
zipcode: string | number;
|
||||||
|
customerName: string;
|
||||||
|
notes: string;
|
||||||
|
latitude: string | null;
|
||||||
|
longitude: string | null;
|
||||||
|
gallonsOrdered: number;
|
||||||
|
isFill: boolean;
|
||||||
|
deliveryStatus: number;
|
||||||
|
customerId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeliveriesMapResponse {
|
||||||
|
ok: boolean;
|
||||||
|
deliveries: DeliveryMapItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delivery History interfaces
|
||||||
|
export interface DeliveryHistoryItem {
|
||||||
|
id: number;
|
||||||
|
customerId: number;
|
||||||
|
customerName: string;
|
||||||
|
town: string;
|
||||||
|
address: string;
|
||||||
|
gallonsDelivered: number;
|
||||||
|
prime: boolean;
|
||||||
|
emergency: boolean;
|
||||||
|
sameDay: boolean;
|
||||||
|
deliveryStatus: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeliveryHistoryResponse {
|
||||||
|
ok: boolean;
|
||||||
|
deliveries: DeliveryHistoryItem[];
|
||||||
|
totalGallons: number;
|
||||||
|
totalDeliveries: number;
|
||||||
|
primeCount: number;
|
||||||
|
emergencyCount: number;
|
||||||
|
sameDayCount: number;
|
||||||
}
|
}
|
||||||
160
src/types/stats.ts
Normal file
160
src/types/stats.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* Stats API TypeScript Interfaces
|
||||||
|
*
|
||||||
|
* Types for the daily/weekly/monthly gallons delivery charts
|
||||||
|
* and totals comparison statistics.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Daily Gallons Endpoint Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface DailyGallonsDataPoint {
|
||||||
|
date: string; // YYYY-MM-DD format
|
||||||
|
gallons: number;
|
||||||
|
deliveries: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyGallonsYearData {
|
||||||
|
year: number;
|
||||||
|
data: DailyGallonsDataPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyGallonsResponse {
|
||||||
|
ok: boolean;
|
||||||
|
years: DailyGallonsYearData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Weekly Gallons Endpoint Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface WeeklyGallonsDataPoint {
|
||||||
|
week_start: string; // YYYY-MM-DD (Monday)
|
||||||
|
week_end: string; // YYYY-MM-DD (Sunday)
|
||||||
|
week_number: number;
|
||||||
|
gallons: number;
|
||||||
|
deliveries: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeeklyGallonsYearData {
|
||||||
|
year: number;
|
||||||
|
data: WeeklyGallonsDataPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeeklyGallonsResponse {
|
||||||
|
ok: boolean;
|
||||||
|
years: WeeklyGallonsYearData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Monthly Gallons Endpoint Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface MonthlyGallonsDataPoint {
|
||||||
|
month: number; // 1-12
|
||||||
|
month_name: string; // "January", "February", etc.
|
||||||
|
gallons: number;
|
||||||
|
deliveries: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonthlyGallonsYearData {
|
||||||
|
year: number;
|
||||||
|
data: MonthlyGallonsDataPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonthlyGallonsResponse {
|
||||||
|
ok: boolean;
|
||||||
|
years: MonthlyGallonsYearData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Totals Comparison Endpoint Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface PeriodComparison {
|
||||||
|
current: number;
|
||||||
|
previous: number;
|
||||||
|
change_percent: number; // Positive = increase, negative = decrease
|
||||||
|
change_direction: 'up' | 'down' | 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TotalsComparisonData {
|
||||||
|
today: {
|
||||||
|
gallons: PeriodComparison;
|
||||||
|
deliveries: PeriodComparison;
|
||||||
|
revenue: PeriodComparison;
|
||||||
|
};
|
||||||
|
week_to_date: {
|
||||||
|
gallons: PeriodComparison;
|
||||||
|
deliveries: PeriodComparison;
|
||||||
|
revenue: PeriodComparison;
|
||||||
|
};
|
||||||
|
month_to_date: {
|
||||||
|
gallons: PeriodComparison;
|
||||||
|
deliveries: PeriodComparison;
|
||||||
|
revenue: PeriodComparison;
|
||||||
|
};
|
||||||
|
year_to_date: {
|
||||||
|
gallons: PeriodComparison;
|
||||||
|
deliveries: PeriodComparison;
|
||||||
|
revenue: PeriodComparison;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TotalsComparisonResponse {
|
||||||
|
ok: boolean;
|
||||||
|
comparison: TotalsComparisonData;
|
||||||
|
current_year: number;
|
||||||
|
compare_year: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Request Parameter Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface DailyGallonsParams {
|
||||||
|
start_date: string; // YYYY-MM-DD
|
||||||
|
end_date: string; // YYYY-MM-DD
|
||||||
|
years: number[]; // e.g., [2024, 2025, 2026]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeeklyGallonsParams {
|
||||||
|
start_date: string; // YYYY-MM-DD
|
||||||
|
end_date: string; // YYYY-MM-DD
|
||||||
|
years: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonthlyGallonsParams {
|
||||||
|
year: number;
|
||||||
|
compare_years: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Chart Data Types (transformed for Chart.js)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface ChartDataset {
|
||||||
|
label: string;
|
||||||
|
data: number[];
|
||||||
|
borderColor: string;
|
||||||
|
backgroundColor: string;
|
||||||
|
tension: number;
|
||||||
|
fill: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartData {
|
||||||
|
labels: string[];
|
||||||
|
datasets: ChartDataset[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Time Range Selection
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export type TimeRange = 'day' | 'week' | 'month';
|
||||||
|
|
||||||
|
export interface DateRange {
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user