Compare commits

...

3 Commits

Author SHA1 Message Date
6c28c0c2d2 feat(ui): Massive frontend modernization including customer table redesign, new map features, and consistent styling 2026-02-06 20:35:18 -05:00
421ba896a0 fix(home): replace revenue stat with tomorrow's deliveries
Remove profit/revenue display from home page dashboard as this
information should not be visible to all employees. Replace with
tomorrow's deliveries count which is more useful for daily planning.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:05:04 -05:00
9a4d5dd07b feat(frontend): redesign home page with charts, map, and pricing dropdown
- Header: Replace date with oil price dropdown showing all pricing tiers
  (regular, same day, prime, emergency) and service pricing
- Footer: Add search shortcuts reference (@, !, #, $)
- Home page complete redesign:
  - Animated stat cards (today's deliveries, week gallons, deliveries, revenue)
  - 28-day delivery trend chart using Chart.js
  - Mini map showing today's delivery routes with Leaflet
  - Quick actions grid for common tasks
  - Town distribution visualization with progress bars
  - Gradient styling and fade-in animations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 20:57:11 -05:00
71 changed files with 8183 additions and 1486 deletions

8
.env.local Normal file
View 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'

View File

@@ -6,7 +6,7 @@ ENV VITE_MONEY_URL="http://localhost:9513"
ENV VITE_AUTHORIZE_URL="http://localhost:9516"
ENV VITE_VOIPMS_URL="http://localhost:9517"
ENV VITE_SERVICE_URL="http://localhost:9515"
ENV VITE_ADDRESS_CHECKER_URL="http://localhost:9618"
ENV VITE_VOIPMS_TOKEN="my_secret_token"
ENV VITE_COMPANY_ID='1'

View File

@@ -6,6 +6,7 @@ ENV VITE_MONEY_URL="http://192.168.1.204:9613"
ENV VITE_AUTHORIZE_URL="http://192.168.1.204:9616"
ENV VITE_VOIPMS_URL="http://192.168.1.204:9617"
ENV VITE_SERVICE_URL="http://192.168.1.204:9615"
ENV VITE_ADDRESS_CHECKER_URL="http://192.168.1.204:9618"
ENV VITE_COMPANY_ID='1'

View File

@@ -10,6 +10,7 @@ ENV VITE_MONEY_URL="https://apimoney.edwineames.com"
ENV VITE_AUTHORIZE_URL="https://apicard.edwineames.com"
ENV VITE_VOIPMS_URL="https://apiphone.edwineames.com"
ENV VITE_SERVICE_URL="https://apiservice.edwineames.com"
ENV VITE_ADDRESS_CHECKER_URL="https://apiaddress.edwineames.com"
ENV VITE_VOIPMS_TOKEN="my_secret_token"

66
package-lock.json generated
View File

@@ -16,12 +16,16 @@
"@vuelidate/validators": "^2.0.4",
"@vueuse/core": "^10.7.0",
"axios": "^1.11.0",
"chart.js": "^4.5.1",
"chartjs-adapter-dayjs-4": "^1.0.4",
"chartjs-plugin-zoom": "^2.2.0",
"dayjs": "^1.11.13",
"html-to-image": "^1.11.11",
"html2canvas": "^1.4.1",
"pinia": "^2.3.1",
"v-pagination-3": "^0.1.7",
"vue": "^3.3.11",
"vue-chartjs": "^5.3.3",
"vue-debounce": "^5.0.0",
"vue-router": "^4.2.5",
"vue3-pdfmake": "^2.2.0"
@@ -586,6 +590,11 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="
},
"node_modules/@kyvg/vue3-notification": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/@kyvg/vue3-notification/-/vue3-notification-3.4.1.tgz",
@@ -1215,6 +1224,11 @@
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true
},
"node_modules/@types/hammerjs": {
"version": "2.0.46",
"resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz",
"integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw=="
},
"node_modules/@types/leaflet": {
"version": "1.9.20",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.20.tgz",
@@ -1800,6 +1814,41 @@
}
]
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chartjs-adapter-dayjs-4": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/chartjs-adapter-dayjs-4/-/chartjs-adapter-dayjs-4-1.0.4.tgz",
"integrity": "sha512-yy9BAYW4aNzPVrCWZetbILegTRb7HokhgospPoC3b5iZ5qdlqNmXts2KdSp6AqnjkPAp/YWyHDxLvIvwt5x81w==",
"engines": {
"node": ">=10"
},
"peerDependencies": {
"chart.js": ">=4.0.1",
"dayjs": "^1.9.7"
}
},
"node_modules/chartjs-plugin-zoom": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.2.0.tgz",
"integrity": "sha512-in6kcdiTlP6npIVLMd4zXZ08PDUXC52gZ4FAy5oyjk1zX3gKarXMAof7B9eFiisf9WOC3bh2saHg+J5WtLXZeA==",
"dependencies": {
"@types/hammerjs": "^2.0.45",
"hammerjs": "^2.0.8"
},
"peerDependencies": {
"chart.js": ">=3.2.0"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@@ -2366,6 +2415,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hammerjs": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz",
"integrity": "sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -3676,6 +3733,15 @@
}
}
},
"node_modules/vue-chartjs": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.3.tgz",
"integrity": "sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==",
"peerDependencies": {
"chart.js": "^4.1.1",
"vue": "^3.0.0-0 || ^2.7.0"
}
},
"node_modules/vue-debounce": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/vue-debounce/-/vue-debounce-5.0.1.tgz",

View File

@@ -17,12 +17,16 @@
"@vuelidate/validators": "^2.0.4",
"@vueuse/core": "^10.7.0",
"axios": "^1.11.0",
"chart.js": "^4.5.1",
"chartjs-adapter-dayjs-4": "^1.0.4",
"chartjs-plugin-zoom": "^2.2.0",
"dayjs": "^1.11.13",
"html-to-image": "^1.11.11",
"html2canvas": "^1.4.1",
"pinia": "^2.3.1",
"v-pagination-3": "^0.1.7",
"vue": "^3.3.11",
"vue-chartjs": "^5.3.3",
"vue-debounce": "^5.0.0",
"vue-router": "^4.2.5",
"vue3-pdfmake": "^2.2.0"

184
src/assets/modern.css Normal file
View 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;
}

View 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,
};
}

View 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,
};
}

View File

@@ -15,11 +15,35 @@
<div class="">Spring Rebuilders - (508) 799-9342</div>
</nav>
<nav>
<h6 class="footer-title">Google Review link / qrcode</h6>
<a class="link link-hover">https://g.page/r/CZHnPQ85LsMUEBM/review</a>
<button @click="copyReviewLink" class="btn btn-outline btn-sm ml-2">Copy Link</button>
<h6 class="link link-hover"> <img src="../../assets/images/googlereview.png" alt="Company Logo" class="h-10 w-auto" /></h6>
<a class="link link-hover"></a>
<h6 class="footer-title">Search Shortcuts</h6>
<div class="grid grid-cols-2 gap-x-4 gap-y-2">
<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">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>
</footer>
</template>
@@ -29,13 +53,45 @@
import { ref } from 'vue';
const copyReviewLink = async () => {
const textToCopy = 'https://g.page/r/CZHnPQ85LsMUEBM/review';
// Try the modern Clipboard API first (works in secure contexts like HTTPS or localhost)
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText('https://g.page/r/CZHnPQ85LsMUEBM/review')
alert('Link copied to clipboard!')
await navigator.clipboard.writeText(textToCopy);
alert('Link copied to clipboard!');
return;
} catch (err) {
console.error('Failed to copy text: ', err)
alert('Failed to copy link. Please try again.')
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>
<style></style>

View File

@@ -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" />
</svg>
</label>
<div class="flex flex-col items-center">
<span class="text-xs text-base-content/60">{{ dayOfWeek }}</span>
<span class="normal-case text-xl font-bold">
{{ currentDate }}
<!-- Oil Price Dropdown (replaces date display) -->
<div class="dropdown dropdown-hover">
<label tabindex="0" class="btn btn-ghost gap-2 hover:bg-warning/10 transition-colors">
<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>
@@ -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">
<li class="p-2 font-semibold">{{ user.user_name }}</li>
<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>
<div class="divider my-0"></div>
<li class="menu-title text-xs opacity-60">Theme</li>
@@ -245,9 +332,27 @@ interface RoutingOption {
// Router
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
const user = ref({} as User)
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([
{ value: 'main', label: '407323' },
{ value: 'sip', label: '407323_auburnoil' },
@@ -290,12 +395,45 @@ const dayOfWeek = computed((): string => {
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
onMounted(() => {
userStatus()
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
const userStatus = async () => {
let path = import.meta.env.VITE_BASE_URL + '/auth/whoami';

View File

@@ -44,30 +44,31 @@
<li><router-link :to="{ name: 'delivery' }" exact-active-class="active">Home</router-link></li>
<li>
<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>
</router-link>
</li>
<li>
<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>
</router-link>
</li>
<li>
<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>
</router-link>
</li>
<li><router-link :to="{ name: 'deliveryIssue' }" exact-active-class="active">Issue Tickets</router-link></li>
<li>
<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>
</router-link>
</li>
<li><router-link :to="{ name: 'deliveryFinalized' }" exact-active-class="active">Finalized Tickets</router-link></li>
</ul>
</details>
</li>
@@ -137,6 +138,11 @@
<span v-if="countsStore.transaction > 0" class="badge badge-secondary">{{ countsStore.transaction }}</span>
</router-link>
</li>
<li>
<router-link :to="{ name: 'transactionsHistory' }" exact-active-class="active">
History
</router-link>
</li>
</ul>
</details>
</li>
@@ -159,6 +165,22 @@
</ul>
</details>
</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>
</template>

View File

@@ -1,5 +1,6 @@
import { createApp } from 'vue';
import './assets/tailwind.css'
import './assets/modern.css'
// Import api early to register global axios interceptors
import './services/api';
import App from './App.vue';

View File

@@ -1,186 +1,296 @@
<!-- src/pages/Index.vue -->
<template>
<div class="flex">
<div class="w-full px-4 md:px-10 ">
<!-- Breadcrumbs & Welcome Header -->
<div class="text-sm breadcrumbs">
<ul>
<li><router-link :to="{ name: 'home' }">Home</router-link></li>
</ul>
</div>
<h1 class="text-3xl font-bold mt-4">
Welcome, {{ employee.employee_first_name }}!
<div class="w-full px-4 md:px-10 py-4">
<!-- Welcome Header with Greeting -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6">
<div>
<h1 class="text-3xl md:text-4xl font-bold bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent">
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>
<!-- 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">
<!-- Left Column: Stats & Chart -->
<div class="lg:col-span-8 space-y-6">
<!-- Stats Row: Quick Glance Cards -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<!-- Today'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">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="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 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>
<!-- Weekly Trend Chart -->
<div class="card-glass p-6">
<div class="flex items-center justify-between mb-4">
<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>
<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>
<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 -->
<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">Oil Pricing</h3>
<div class="w-12 h-12 rounded-full bg-warning/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-warning">
<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" />
<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="space-y-3">
<div class="flex justify-between items-center">
<span class="text-sm text-base-content/70">Per Gallon</span>
<span class="text-2xl font-bold font-mono">${{ today_oil_price }}</span>
</div>
<div class="divider my-2"></div>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-base-content/70">Same Day</span>
<span class="font-mono font-semibold">${{ price_same_day }}</span>
</div>
<div class="flex justify-between">
<span class="text-base-content/70">Prime</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 class="h-64">
<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>
</div>
</div>
<!-- Card 3: Service Pricing -->
<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">Service Pricing</h3>
<div class="w-12 h-12 rounded-full bg-info/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-info">
<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" />
<!-- Right Column: Map & Quick Actions -->
<div class="lg:col-span-4 space-y-6">
<!-- Mini Delivery Map -->
<div class="card-glass p-4 overflow-hidden">
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold 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-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>
</div>
</div>
<div class="space-y-4">
<div class="flex justify-between items-center">
<span class="text-sm text-base-content/70">Per Hour</span>
<span class="text-2xl font-bold font-mono">$125</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-base-content/70">Emergency</span>
<span class="text-2xl font-bold font-mono">$200</span>
</div>
</div>
Today's Routes
</h3>
<router-link :to="{ name: 'deliveryMap' }" class="btn btn-xs btn-ghost">
Full Map
</router-link>
</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" />
<!-- 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>
</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>
<p class="text-sm">No deliveries mapped</p>
</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>
<!-- Quick Actions -->
<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-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>
<!-- 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>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
import authHeader from '../services/auth.header'
import Header from '../layouts/headers/headerauth.vue'
import SideBar from '../layouts/sidebar/sidebar.vue'
import { deliveryService } from '../services/deliveryService'
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
const props = defineProps<{
clickCount?: number
}>()
// Register Chart.js components
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
)
// Router
const router = useRouter()
// Stores
const countsStore = useCountsStore()
// Reactive data
const token = ref(null)
const call_count = ref(0)
const delivery_count = ref(0)
const delivery_count_delivered = ref(0)
const price_from_supplier = ref(0)
const today_oil_price = 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 total_gallons_past_week = ref(0)
const total_deliveries = ref(0)
const user = ref({
user_id: 0,
user_name: '',
@@ -201,24 +311,130 @@ const employee = ref({
employee_type: '',
employee_state: '',
})
const total_gallons_past_week = ref(0)
const total_profit_past_week = ref(0)
const total_deliveries = ref(0)
const loaded = ref(false)
// Map data
const mapDeliveries = ref<DeliveryMapItem[]>([])
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
onMounted(() => {
userStatus()
today_delivery_count()
today_delivery_delivered()
today_price_oil()
totalgallonsweek()
totalprofitweek()
totaldeliveriesweek()
countsStore.fetchSidebarCounts()
fetchMapDeliveries()
fetchWeeklyChartData()
})
// Functions
// API Functions
const userStatus = () => {
let path = import.meta.env.VITE_BASE_URL + '/auth/whoami';
const path = import.meta.env.VITE_BASE_URL + '/auth/whoami'
axios({
method: "get",
url: path,
@@ -227,44 +443,17 @@ const userStatus = () => {
})
.then((response: any) => {
if (response.data.ok) {
user.value = response.data.user;
user.value = response.data.user
employeeStatus()
} else {
localStorage.removeItem('user');
router.push('/login');
localStorage.removeItem('user')
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 = () => {
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({
method: "get",
url: path,
@@ -272,26 +461,12 @@ const employeeStatus = () => {
headers: authHeader(),
})
.then((response: any) => {
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;
employee.value = response.data?.employee || response.data
})
}
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({
method: "get",
url: path,
@@ -299,12 +474,12 @@ const today_delivery_count = () => {
headers: authHeader(),
})
.then((response: any) => {
delivery_count.value = response.data.data;
delivery_count.value = response.data.data
})
}
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({
method: "get",
url: path,
@@ -312,13 +487,12 @@ const today_delivery_delivered = () => {
headers: authHeader(),
})
.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 = () => {
let path = import.meta.env.VITE_BASE_URL + '/info/price/oil'
const totalgallonsweek = () => {
const path = import.meta.env.VITE_BASE_URL + '/stats/gallons/week'
axios({
method: "get",
url: path,
@@ -326,12 +500,122 @@ const today_price_oil = () => {
headers: authHeader(),
})
.then((response: any) => {
price_from_supplier.value = response.data.price_from_supplier;
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;
total_gallons_past_week.value = response.data.total
})
}
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>
<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>

View File

@@ -10,12 +10,26 @@
</ul>
</div>
<h1 class="text-3xl font-bold mt-4">
<!-- 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="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 -->
<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">
<!-- SECTION 1: Base Pricing -->
@@ -29,7 +43,8 @@
<label class="label"><span class="label-text">Price from Supplier</span></label>
<label class="input-group input-group-sm">
<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>
</div>
<!-- Price for Customer -->
@@ -37,7 +52,8 @@
<label class="label"><span class="label-text">Price for Customer</span></label>
<label class="input-group input-group-sm">
<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>
</div>
<!-- Price for Employee -->
@@ -45,7 +61,8 @@
<label class="label"><span class="label-text">Price for Employee</span></label>
<label class="input-group input-group-sm">
<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>
</div>
</div>
@@ -62,7 +79,8 @@
<label class="label"><span class="label-text">Same Day Fee</span></label>
<label class="input-group input-group-sm">
<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>
</div>
<!-- Price Prime -->
@@ -70,7 +88,8 @@
<label class="label"><span class="label-text">Prime Fee</span></label>
<label class="input-group input-group-sm">
<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>
</div>
<!-- Price Emergency -->
@@ -78,7 +97,8 @@
<label class="label"><span class="label-text">Emergency Fee</span></label>
<label class="input-group input-group-sm">
<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>
</div>
</div>

View File

@@ -10,12 +10,23 @@
</ul>
</div>
<h1 class="text-3xl font-bold mt-4">
Set Today's Oil Pricing
<!-- 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="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 -->
<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">
<!-- SECTION 1: Base Pricing -->
@@ -134,8 +145,16 @@ const getCurrentPrices = async () => {
if (response.data) {
OilForm.value = response.data;
}
} catch (err) {
} catch (err: unknown) {
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",
});
}
}
};

View File

@@ -13,66 +13,120 @@
</ul>
</div>
<div class="flex start pb-10 text-2xl">Promos </div>
<div class="flex justify-end pb-5">
<router-link :to="{ name: 'promocreate' }">
<button class="btn btn-secondary btn-sm">Create Promo</button>
</router-link>
<!-- 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 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>
<div class="overflow-x-auto">
<table class="table">
<!-- head -->
<thead class=" bg-neutral">
<!-- 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>
<!-- Main Content Card -->
<div class="modern-table-card">
<!-- DESKTOP VIEW: Table -->
<div class="overflow-x-auto hidden xl:block">
<table class="modern-table">
<thead>
<tr>
<th>Id</th>
<th>ID</th>
<th>Name</th>
<th>Pennys off gallon</th>
<th>Discount</th>
<th>Description</th>
<th>text_on_ticket</th>
<th></th>
<th>Ticket Text</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody class="bg-neutral">
<!-- row 1 -->
<tr v-for="promo in promos" :key="promo['id']">
<tbody>
<tr v-for="promo in promos" :key="promo['id']" class="table-row-hover">
<td>{{ promo['id'] }}</td>
<router-link :to="{ name: 'promoedit', params: { id: promo['id'] } }">
<td>
<div class="hover:text-accent">{{ promo['name_of_promotion'] }} </div>
</td>
<td class="font-bold">
<router-link :to="{ name: 'promoedit', params: { id: promo['id'] } }"
class="link link-hover">
{{ promo['name_of_promotion'] }}
</router-link>
<td>
{{ promo['money_off_delivery'] }}
</td>
<td>
{{ promo['description'] }}
<span class="badge badge-success badge-sm">${{ promo['money_off_delivery'] }}
off/gal</span>
</td>
<td>
{{ promo['text_on_ticket'] }}
</td>
<td class="flex gap-2">
<router-link :to="{ name: 'promoedit', params: { id: promo['id'] } }">
<button class="btn btn-secondary btn-sm">Edit Promo</button>
<td>{{ promo['description'] }}</td>
<td>{{ promo['text_on_ticket'] }}</td>
<td class="text-right">
<div class="flex items-center justify-end gap-2">
<router-link :to="{ name: 'promoedit', params: { id: promo['id'] } }"
class="btn btn-sm btn-secondary">
Edit
</router-link>
<button @click.prevent="deletepromo(promo['id'])" class="btn btn-error btn-sm">
<button @click.prevent="deletepromo(promo['id'])"
class="btn btn-sm btn-error btn-outline">
Delete
</button>
</div>
</td>
</tr>
</tbody>
</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>
</template>
<script lang="ts">
@@ -132,7 +186,11 @@ export default defineComponent({
url: path,
headers: authHeader(),
}).then((response: any) => {
this.promos = response.data
if (response.data && response.data.promos) {
this.promos = response.data.promos;
} else {
this.promos = [];
}
})
},

View File

@@ -6,6 +6,8 @@ const Promo = () => import('../admin/promo/promo.vue');
const PromoCreate = () => import('../admin/promo/create.vue');
const PromoEdit = () => import('../admin/promo/edit.vue');
const StatsHome = () => import('../admin/stats/StatsHome.vue');
const adminRoutes = [
{
@@ -16,7 +18,7 @@ const adminRoutes = [
{
path: '/promo/edit:id',
path: '/promo/edit/:id',
name: 'promoedit',
component: PromoEdit,
},
@@ -25,6 +27,11 @@ const adminRoutes = [
name: 'promocreate',
component: PromoCreate,
},
{
path: '/stats',
name: 'stats',
component: StatsHome,
},
{
path: '/promo',
name: 'promo',

View 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>

View 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>

View 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>

View File

@@ -12,7 +12,7 @@
<div class="mb-4">
<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"
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">
{{ v$.ChangePasswordForm.new_password.$errors[0].$message }}
</span>
@@ -20,7 +20,7 @@
<div class="mb-6">
<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"
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">
{{ v$.ChangePasswordForm.password_confirm.$errors[0].$message }}
</span>

View File

@@ -14,7 +14,7 @@
<label class="block text-white text-sm font-bold mb-2">Username</label>
<input
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"
type="text"
placeholder="Username"
@@ -33,8 +33,7 @@
</label>
<input
v-model="loginForm.password"
class="rounded w-full py-2 px-3
input-primary text-black"
:class="inputClasses(v$.loginForm.password, 'rounded w-full py-2 px-3 input-primary text-black')"
id="password"
type="password"
autocomplete="off"
@@ -87,6 +86,9 @@ import { required, minLength } from "@vuelidate/validators";
import { useAuthStore } from "../../stores/auth";
import { authService } from "../../services/authService";
import { AxiosResponse, AxiosError } from "../../types/models";
import { useFormValidation } from "../../composables/useFormValidation";
const { inputClasses, validateForm } = useFormValidation();
// Stores & Utilities
const authStore = useAuthStore();

View File

@@ -25,14 +25,14 @@
</div>
<div class="my-5">
<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">
{{ v$.ForgotForm.username.$errors[0].$message }}
</span>
</div>
<div class="my-5">
<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">
{{ v$.ForgotForm.email.$errors[0].$message }}
</span>

View File

@@ -9,7 +9,7 @@
<div class="mb-4">
<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"
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">
{{ v$.registerForm.username.$errors[0].$message }}
</span>
@@ -18,7 +18,7 @@
<div class="mb-4">
<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"
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">
{{ v$.registerForm.email.$errors[0].$message }}
</span>
@@ -26,7 +26,7 @@
<div class="mb-4">
<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"
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">
{{ v$.registerForm.password.$errors[0].$message }}
</span>
@@ -34,7 +34,7 @@
<div class="mb-4">
<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"
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">
{{ v$.registerForm.password_confirm.$errors[0].$message }}
</span>

View File

@@ -9,49 +9,82 @@
<li>Automatic Deliveries</li>
</ul>
</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 -->
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
<!-- 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>
<div class="modern-table-card">
<!-- Data Display -->
<div>
<!-- DESKTOP VIEW: Sortable Table -->
<div class="overflow-x-auto hidden xl:block">
<table class="table w-full">
<table class="modern-table">
<thead>
<tr>
<!-- SORTABLE HEADERS -->
<th @click="sortBy('tank_level_percent')"
:class="sortKey === 'tank_level_percent' ? 'cursor-pointer hover:text-white bg-orange-500' : 'cursor-pointer hover:text-white'">
<th @click="sortBy('tank_level_percent')" class="sort-header cursor-pointer select-none"
:class="{ 'text-primary': sortKey === 'tank_level_percent' }">
<div class="flex items-center gap-2">
Tank Level
<span v-if="sortKey === 'tank_level_percent'">{{ sortAsc ? '' : '' }}</span>
<span v-else class="opacity-30 text-xs"></span>
</div>
</th>
<th @click="sortBy('days_since_last_fill')"
:class="sortKey === 'days_since_last_fill' ? 'cursor-pointer hover:text-white bg-orange-500' : 'cursor-pointer hover:text-white'">
<th @click="sortBy('days_since_last_fill')" class="sort-header cursor-pointer select-none"
:class="{ 'text-primary': sortKey === 'days_since_last_fill' }">
<div class="flex items-center gap-2">
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 @click="sortBy('customer_full_name')"
:class="sortKey === 'customer_full_name' ? 'cursor-pointer hover:text-white bg-orange-500' : 'cursor-pointer hover:text-white'">
<th @click="sortBy('customer_full_name')" class="sort-header cursor-pointer select-none"
:class="{ 'text-primary': sortKey === 'customer_full_name' }">
<div class="flex items-center gap-2">
Name
<span v-if="sortKey === 'customer_full_name'">{{ sortAsc ? '' : '' }}</span>
<span v-else class="opacity-30 text-xs"></span>
</div>
</th>
<th @click="sortBy('house_factor')"
:class="sortKey === 'house_factor' ? 'cursor-pointer hover:text-white bg-orange-500' : 'cursor-pointer hover:text-white'">
<th @click="sortBy('house_factor')" class="sort-header cursor-pointer select-none"
:class="{ 'text-primary': sortKey === 'house_factor' }">
<div class="flex items-center gap-2">
Usage Factor
<span v-if="sortKey === 'house_factor'">{{ sortAsc ? '' : '' }}</span>
<span v-else class="opacity-30 text-xs"></span>
</div>
</th>
<th @click="sortBy('hot_water_summer')"
:class="sortKey === 'hot_water_summer' ? 'cursor-pointer hover:text-white bg-orange-500' : 'cursor-pointer hover:text-white'">
<th @click="sortBy('hot_water_summer')" class="sort-header cursor-pointer select-none"
:class="{ 'text-primary': sortKey === 'hot_water_summer' }">
<div class="flex items-center gap-2">
Hot Water Tank
<span v-if="sortKey === 'hot_water_summer'">{{ sortAsc ? '' : '' }}</span>
<span v-else class="opacity-30 text-xs"></span>
</div>
</th>
<th>Address</th>
<th class="text-right">Actions</th>
@@ -59,8 +92,8 @@
</thead>
<tbody>
<!-- Loop over the new 'sortedDeliveries' computed property -->
<tr v-for="oil in sortedDeliveries" :key="oil.id" class="hover:bg-blue-600 hover:text-white"
:class="{ 'bg-yellow-400 text-black': oil.auto_status == 3 }">
<tr v-for="oil in sortedDeliveries" :key="oil.id" class="table-row-hover"
:class="{ 'row-urgent': oil.auto_status == 3 }">
<td>
<div v-if="oil.last_fill === null" class="text-gray-500">New Auto</div>
<div v-else class="flex items-center gap-3">
@@ -108,33 +141,34 @@
</div>
<!-- MOBILE VIEW: Cards -->
<div class="xl:hidden space-y-4">
<div v-for="oil in sortedDeliveries" :key="oil.id" class="card bg-base-100 shadow-md">
<div class="card-body p-4">
<div class="xl:hidden space-y-4 px-4 pb-4">
<div v-for="oil in sortedDeliveries" :key="oil.id" class="mobile-card"
:class="{ 'mobile-card-urgent': oil.auto_status == 3 }">
<div class="p-3">
<div class="flex justify-between items-start">
<div>
<h2 class="card-title text-base">{{ oil.customer_full_name }}</h2>
<p class="text-xs text-gray-400">{{ oil.customer_address }}, {{ oil.customer_town }}</p>
<h2 class="text-base font-bold">{{ oil.customer_full_name }}</h2>
<p class="text-xs text-base-content/60">{{ oil.customer_address }}, {{ oil.customer_town }}</p>
</div>
<div class="text-right">
<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 class="mt-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label p-0 mb-1"><span class="label-text">Usage Factor</span></label>
<div class="text-sm">{{ oil.house_factor }}</div>
<p class="text-xs text-base-content/50">Usage Factor</p>
<div class="text-sm font-medium">{{ oil.house_factor }}</div>
</div>
<div>
<label class="label p-0 mb-1"><span class="label-text">Hot Water Tank</span></label>
<div class="text-sm">{{ oil.hot_water_summer ? 'Yes' : 'No' }}</div>
<p class="text-xs text-base-content/50">Hot Water Tank</p>
<div class="text-sm font-medium">{{ oil.hot_water_summer ? 'Yes' : 'No' }}</div>
</div>
</div>
<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-else>
<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-error': getTankLevelPercentage(oil) < 25
}"></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>
</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 } }"
class="btn btn-sm btn-secondary">Edit Customer</router-link>
<router-link v-if="oil.auto_status != 3"
:to="{ name: 'payAutoAuthorize', params: { id: oil['id'] } }">
<button class="btn btn-primary btn-sm">Preauthorize</button>
class="btn btn-sm btn-ghost flex-1">Edit</router-link>
<router-link v-if="oil.auto_status != 3" :to="{ name: 'payAutoAuthorize', params: { id: oil['id'] } }"
class="flex-1">
<button class="btn btn-primary btn-sm btn-outline w-full">Auth</button>
</router-link>
<router-link :to="{ name: 'finalizeTicketAutoNocc', params: { id: oil['id'] } }">
<button class="btn btn-secondary btn-sm">Finalize</button>
<router-link :to="{ name: 'finalizeTicketAutoNocc', params: { id: oil['id'] } }" class="flex-1">
<button class="btn btn-secondary btn-sm btn-outline w-full">Finalize</button>
</router-link>
<router-link v-if="oil.auto_status == 3"
:to="{ name: 'finalizeTicketAuto', params: { id: oil.open_ticket_id || oil['id'] } }">
<button class="btn btn-secondary btn-sm">Finalize</button>
:to="{ name: 'finalizeTicketAuto', params: { id: oil.open_ticket_id || oil['id'] } }"
class="flex-1">
<button class="btn btn-secondary btn-sm btn-outline w-full">Finalize</button>
</router-link>
<router-link :to="{ name: 'TicketAuto', params: { id: oil['id'] } }">
<button class="btn btn-success btn-sm">
Print Ticket
</button>
<router-link :to="{ name: 'TicketAuto', params: { id: oil['id'] } }" class="flex-1">
<button class="btn btn-success btn-sm btn-outline w-full">Ticket</button>
</router-link>
</div>
</div>

View File

@@ -55,21 +55,21 @@
<!-- Name -->
<div class="form-control">
<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>
</div>
<!-- Card Number -->
<div class="form-control">
<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>
</div>
<!-- CVV (Security Code) -->
<div class="form-control">
<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>
</div>
@@ -77,11 +77,11 @@
<div class="form-control">
<label class="label"><span class="label-text font-bold">Expiration Date</span></label>
<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 v-for="m in 12" :key="m" :value="String(m).padStart(2, '0')">{{ String(m).padStart(2, '0') }}</option>
</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 v-for="y in 10" :key="y" :value="new Date().getFullYear() + y - 1">{{ new Date().getFullYear() + y - 1 }}</option>
</select>
@@ -92,7 +92,7 @@
<!-- Card Type dropdown -->
<div class="form-control">
<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>Visa</option>
<option>MasterCard</option>
@@ -105,7 +105,7 @@
<!-- Billing Zip Code input -->
<div class="form-control">
<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>
</div>

View File

@@ -60,14 +60,14 @@
<!-- Name on Card -->
<div class="form-control">
<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>
</div>
<!-- Card Number -->
<div class="form-control">
<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>
</div>
@@ -75,11 +75,11 @@
<div class="form-control">
<label class="label"><span class="label-text font-bold">Expiration</span></label>
<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 v-for="m in 12" :key="m" :value="String(m).padStart(2, '0')">{{ String(m).padStart(2, '0') }}</option>
</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 v-for="y in 10" :key="y" :value="new Date().getFullYear() + y - 1">{{ new Date().getFullYear() + y - 1 }}</option>
</select>
@@ -90,14 +90,14 @@
<!-- Security Number (CVV) -->
<div class="form-control">
<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>
</div>
<!-- Card Type -->
<div class="form-control">
<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>Visa</option>
<option>MasterCard</option>

View File

@@ -28,7 +28,7 @@
<!-- First Name -->
<div class="form-control">
<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">
{{ v$.CreateCustomerForm.customer_first_name.$errors[0].$message }}
</span>
@@ -36,7 +36,7 @@
<!-- Last Name -->
<div class="form-control">
<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">
{{ v$.CreateCustomerForm.customer_last_name.$errors[0].$message }}
</span>
@@ -44,7 +44,7 @@
<!-- Phone Number -->
<div class="form-control">
<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">
{{ v$.CreateCustomerForm.customer_phone_number.$errors[0].$message }}
</span>
@@ -52,7 +52,7 @@
<!-- Email -->
<div class="form-control">
<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">
{{ v$.CreateCustomerForm.customer_email.$errors[0].$message }}
</span>
@@ -60,8 +60,8 @@
<!-- Customer Type -->
<div class="form-control">
<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">
<option disabled :value="0">Select a type</option>
<select v-model="CreateCustomerForm.customer_home_type" :class="selectClasses(v$.CreateCustomerForm.customer_home_type)">
<option disabled :value="-1">Select a type</option>
<option v-for="customer in custList" :key="customer.value" :value="customer.value">
{{ customer.text }}
</option>
@@ -76,31 +76,61 @@
<h2 class="text-lg font-bold">Address</h2>
<div class="divider mt-2 mb-4"></div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Street Address -->
<div class="form-control">
<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 }}
<!-- Town with Autocomplete -->
<div class="form-control relative">
<label class="label"><span class="label-text">Town</span></label>
<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>
<!-- 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" />
<!-- 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>
<!-- Town -->
<div class="form-control">
<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" />
<span v-if="v$.CreateCustomerForm.customer_town.$error" class="text-red-500 text-xs mt-1">
{{ v$.CreateCustomerForm.customer_town.$errors[0].$message }}
</span>
</div>
<!-- State -->
<div class="form-control">
<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 v-for="state in stateList" :key="state.value" :value="state.value">
{{ state.text }}
@@ -108,10 +138,73 @@
</select>
<span v-if="v$.CreateCustomerForm.customer_state.$error" class="text-red-500 text-xs mt-1">Required.</span>
</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">
<label class="label"><span class="label-text">Zip Code</span></label>
<input v-model="CreateCustomerForm.customer_zip" type="text" placeholder="Zip Code" class="input input-bordered input-sm w-full" />
<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>
<!-- 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">
{{ v$.CreateCustomerForm.customer_zip.$errors[0].$message }}
</span>
@@ -141,22 +234,44 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { authService } from '../../services/authService'
import { customerService } from '../../services/customerService'
import { queryService } from '../../services/queryService'
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 { email, minLength, required } from "@vuelidate/validators";
import { notify } from "@kyvg/vue3-notification";
const { inputClasses, selectClasses, textareaClasses, validateForm } = useFormValidation();
// Reactive data
const router = useRouter()
const user = ref(null)
const stateList = ref<StateOption[]>([])
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
const CreateCustomerForm = ref({
customer_last_name: "",
@@ -172,6 +287,27 @@ const CreateCustomerForm = ref({
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
const rules = {
CreateCustomerForm: {
@@ -190,6 +326,103 @@ const rules = {
// Vuelidate instance
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
const acceptNumber = () => {
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 = () => {
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 = () => {
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) => {

View File

@@ -36,7 +36,7 @@
<!-- First Name -->
<div class="form-control">
<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">
{{ v$.CreateCustomerForm.basicInfo.customer_first_name.$errors[0].$message }}
</span>
@@ -44,7 +44,7 @@
<!-- Last Name -->
<div class="form-control">
<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">
{{ v$.CreateCustomerForm.basicInfo.customer_last_name.$errors[0].$message }}
</span>
@@ -52,7 +52,7 @@
<!-- Phone Number -->
<div class="form-control">
<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">
{{ v$.CreateCustomerForm.basicInfo.customer_phone_number.$errors[0].$message }}
</span>
@@ -60,7 +60,7 @@
<!-- Email -->
<div class="form-control">
<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">
{{ v$.CreateCustomerForm.basicInfo.customer_email.$errors[0].$message }}
</span>
@@ -68,7 +68,7 @@
<!-- Customer Type -->
<div class="form-control">
<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">
{{ customer.text }}
</option>
@@ -82,31 +82,61 @@
<h2 class="text-lg font-bold">Address</h2>
<div class="divider mt-2 mb-4"></div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Street Address -->
<div class="form-control">
<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 }}
<!-- Town with Autocomplete -->
<div class="form-control relative">
<label class="label"><span class="label-text">Town</span></label>
<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>
<!-- 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" />
<!-- 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>
<!-- Town -->
<div class="form-control">
<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" />
<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 }}
</span>
</div>
<!-- State -->
<div class="form-control">
<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">
{{ state.text }}
</option>
@@ -115,10 +145,73 @@
{{ v$.CreateCustomerForm.basicInfo.customer_state.$errors[0].$message }}
</span>
</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">
<label class="label"><span class="label-text">Zip Code</span></label>
<input v-model="CreateCustomerForm.basicInfo.customer_zip" type="text" placeholder="Zip Code" class="input input-bordered input-sm w-full" />
<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>
<!-- 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">
{{ v$.CreateCustomerForm.basicInfo.customer_zip.$errors[0].$message }}
</span>
@@ -163,14 +256,20 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { authService } from '../../services/authService'
import { customerService } from '../../services/customerService'
import { queryService } from '../../services/queryService'
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 { email, minLength, required } from "@vuelidate/validators";
import { notify } from "@kyvg/vue3-notification";
const { inputClasses, selectClasses, validateForm } = useFormValidation();
// Reactive data
const route = useRoute()
@@ -178,6 +277,24 @@ const router = useRouter()
const user = ref(null)
const stateList = ref<StateOption[]>([])
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({
id: 0,
user_id: 0,
@@ -224,6 +341,27 @@ const CreateCustomerForm = 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
const rules = {
CreateCustomerForm: {
@@ -244,6 +382,97 @@ const rules = {
// Vuelidate instance
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
const acceptNumber = () => {
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) {
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 = {
...CreateCustomerForm.value.basicInfo,
service_plan: CreateCustomerForm.value.servicePlan
@@ -354,10 +599,11 @@ const renewContract = () => {
}
// Lifecycle
onMounted(() => {
onMounted(async () => {
userStatus()
// Load states first so we can initialize selectedTown properly
await getStatesList();
await getCustomerTypeList();
getCustomer(route.params.id)
getCustomerTypeList();
getStatesList();
})
</script>

View File

@@ -1,7 +1,7 @@
<!-- src/pages/customer/home.vue -->
<template>
<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 -->
<div class="text-sm breadcrumbs">
<ul>
@@ -9,87 +9,204 @@
<li>Customers</li>
</ul>
</div>
<h1 class="text-3xl font-bold mt-4">Customers</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="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 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>
<!-- Main Content Card -->
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
<!-- Header: Search, Count, and Add Button -->
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-4">
<!-- SEARCH AND COUNT (IMPROVED ALIGNMENT) -->
<div class="form-control">
<label class="label pt-1 pb-0">
<span class="label-text-alt">{{ customer_count }} customers found</span>
</label>
<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>
</div>
<div class="divider"></div>
<!-- DESKTOP VIEW: Table (Now breaks at XL) -->
<!-- DESKTOP VIEW: Table -->
<div class="overflow-x-auto hidden xl:block">
<table class="table w-full">
<table class="modern-table">
<thead>
<tr>
<th>Account #</th>
<th>Name</th>
<th>Town</th>
<th>Automatic</th>
<th>Phone Number</th>
<th>Customer Name</th>
<th>Address</th>
<th>Map</th>
<th>Status</th>
<th>Phone</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<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>
<router-link v-if="person.id" :to="{ name: 'customerProfile', params: { id: person.id } }" class="link link-hover">
{{ person.account_number }}
<router-link v-if="person.id" :to="{ name: 'customerProfile', params: { id: person.id } }"
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>
<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>
<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>
<span v-else>{{ person.customer_first_name }} {{ person.customer_last_name }}</span>
<div class="flex flex-col">
<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>{{ 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>
</tbody>
</table>
</div>
<!-- MOBILE VIEW: Cards (Now breaks at XL) -->
<div class="xl:hidden space-y-4">
<div v-for="person in customers" :key="person.id" class="card bg-base-100 shadow-md">
<div class="card-body p-4">
<!-- MOBILE VIEW: Cards -->
<div class="xl:hidden space-y-4 px-4 pb-4 pt-4">
<div v-for="person in customers" :key="person.id" class="mobile-card">
<div class="p-3">
<div class="flex justify-between items-start">
<div>
<router-link v-if="person.id" :to="{ name: 'customerProfile', params: { id: person.id } }" class="hover:text-green-500">
<h2 class="card-title text-base">{{ person.customer_first_name }} {{ person.customer_last_name }}</h2>
<router-link v-if="person.id" :to="{ name: 'customerProfile', params: { id: person.id } }"
class="font-bold text-base link link-hover text-primary">
{{ person.customer_first_name }} {{ person.customer_last_name }}
</router-link>
<h2 v-else class="card-title text-base">{{ person.customer_first_name }} {{ person.customer_last_name }}</h2>
<p class="text-xs text-gray-400">#{{ person.account_number }}</p>
</div>
<div class="badge" :class="person.customer_automatic ? 'badge-success' : 'badge-ghost'">
{{ person.customer_automatic ? 'Automatic' : 'Will Call' }}
<div v-else class="font-bold text-base">{{ person.customer_first_name }} {{ person.customer_last_name
}}</div>
<div class="text-xs text-base-content/60 flex items-center gap-1 mt-0.5">
Account #{{ person.account_number }}
</div>
</div>
<div class="text-sm mt-2">
<p>{{ person.customer_town }}</p>
<p>{{ person.customer_phone_number }}</p>
<span class="badge badge-sm"
:class="person.customer_automatic ? 'badge-success text-white' : 'badge-ghost opacity-70'">
{{ person.customer_automatic ? 'Auto' : 'Will Call' }}
</span>
</div>
<div v-if="person.id" class="card-actions justify-end flex-wrap gap-2 mt-2">
<router-link :to="{ name: 'deliveryCreate', params: { id: person.id } }" class="btn btn-sm btn-primary">
New Delivery
<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 class="flex gap-2 pt-3 mt-3 border-t border-base-content/10 flex-wrap">
<router-link :to="{ name: 'deliveryCreate', params: { id: person.id } }"
class="btn btn-sm btn-warning btn-outline flex-1">
Delivery
</router-link>
<router-link :to="{ name: 'CalenderCustomer', params: { id: person.id } }" class="btn btn-sm btn-accent">
New Service
<router-link :to="{ name: 'CalenderCustomer', params: { id: person.id } }"
class="btn btn-sm btn-accent flex-1">
Service
</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
</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
</router-link>
</div>
@@ -131,6 +248,23 @@ const options = ref({
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
const getPage = (pageVal: any) => {
customers.value = [];

View File

@@ -53,7 +53,7 @@
<div>
<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"
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">
<button v-for="amount in quickGallonAmounts"
:key="amount"
@@ -132,7 +132,7 @@
<!-- Date, Driver, Promo -->
<div>
<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">
<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 -->
<div>
<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>
</div>
<div>
<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>
</div>
<div>
<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>Visa</option>
<option>MasterCard</option>
@@ -277,7 +277,7 @@
</div>
<div class="md:col-span-1">
<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>
</div>
</div>
@@ -316,7 +316,10 @@ import SideBar from '../../layouts/sidebar/sidebar.vue'
import { useVuelidate } from "@vuelidate/core";
import { notify } from "@kyvg/vue3-notification"
import { minLength, required, requiredIf } from "@vuelidate/validators";
import { useFormValidation } from '../../composables/useFormValidation';
import deliveryService from '../../services/deliveryService';
const { inputClasses, selectClasses, validateForm } = useFormValidation();
import customerService from '../../services/customerService';
import paymentService from '../../services/paymentService';
import adminService from '../../services/adminService';

View File

@@ -60,7 +60,7 @@
<div>
<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"
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">
<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>
@@ -126,7 +126,7 @@
</div>
<div>
<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">
<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>
@@ -249,7 +249,10 @@ import SideBar from '../../layouts/sidebar/sidebar.vue'
import { useVuelidate } from "@vuelidate/core";
import { required, requiredIf } from "@vuelidate/validators";
import { notify } from "@kyvg/vue3-notification";
import { useFormValidation } from '../../composables/useFormValidation';
import deliveryService from '../../services/deliveryService';
const { inputClasses, selectClasses } = useFormValidation();
import customerService from '../../services/customerService';
import paymentService from '../../services/paymentService';
import adminService from '../../services/adminService';

View File

@@ -10,28 +10,41 @@
</ul>
</div>
<!-- Main Content Card -->
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
<!-- Header: Search and Stats -->
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-6 mb-4">
<div class="form-control">
<h2 class="text-lg font-bold">Deliveries </h2>
<!-- 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="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>
<!-- Today's Stats Card -->
<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>
Delivery Overview
</h1>
<p class="text-base-content/60 mt-1 ml-13">Recent delivery activity</p>
</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>
<div class="divider">Recent Deliveries</div>
<!-- Main Table Card -->
<div class="modern-table-card">
<!-- DESKTOP VIEW: Table -->
<div class="overflow-x-auto hidden xl:block">
<table class="table w-full">
<div class="hidden xl:block overflow-x-auto">
<table class="modern-table">
<thead>
<tr>
<th>Delivery #</th>
@@ -76,8 +89,8 @@
<div class="text-xs opacity-70">{{ oil.customer_address }}</div>
</td>
<td>
<span v-if="oil.customer_asked_for_fill == 1" class="badge badge-info">FILL</span>
<span v-else>{{ oil.gallons_ordered }}</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 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>{{ oil.expected_delivery_date }}</td>
<td>
@@ -95,11 +108,11 @@
<span v-else-if="oil.payment_type == 4">Other</span>
</td>
<td class="text-right">
<div class="flex items-center justify-end gap-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="!isFinalizedStatus(oil.delivery_status)" 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 class="flex items-center justify-end gap-1">
<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-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-xs btn-accent btn-outline">Finalize</router-link>
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-xs btn-success btn-outline">Print</router-link>
</div>
</td>
</tr>
@@ -111,14 +124,22 @@
<!-- MOBILE VIEW: Cards -->
<div class="xl:hidden space-y-4">
<template v-for="oil in deliveries" :key="oil.id">
<div v-if="oil.id" class="card bg-base-100 shadow-md">
<div class="card-body p-4">
<div
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>
<h2 class="card-title text-base">{{ oil.customer_name }}</h2>
<p class="text-xs text-gray-400">Delivery #{{ oil.id }}</p>
<h2 class="text-base font-bold">{{ oil.customer_name }}</h2>
<p class="text-xs text-base-content/60">Delivery #{{ oil.id }}</p>
</div>
<div class="badge" :class="{
<div class="badge badge-sm" :class="{
'badge-warning': oil.delivery_status === computedDELIVERY_STATUS.WAITING,
'badge-success': [computedDELIVERY_STATUS.DELIVERED, computedDELIVERY_STATUS.FINALIZED].includes(oil.delivery_status as any),
'badge-info': oil.delivery_status === computedDELIVERY_STATUS.OUT_FOR_DELIVERY,
@@ -126,9 +147,9 @@
}">
<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.OUT_FOR_DELIVERY">Today_Delivery</span>
<span v-else-if="oil.delivery_status === computedDELIVERY_STATUS.TOMORROW">Tommorrow_Delivery</span>
<span v-else-if="oil.delivery_status === computedDELIVERY_STATUS.PARTIAL_DELIVERY">Partial 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">Tomorrow</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.FINALIZED">Finalized</span>
</div>
@@ -140,21 +161,30 @@
<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">Date:</strong> {{ oil.expected_delivery_date }}</p>
<p><strong class="font-semibold">Gallons:</strong>
<div class="text-sm mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
<div>
<p class="text-xs text-base-content/50">Address</p>
<p class="font-medium">{{ oil.customer_address }}</p>
<p class="text-xs">{{ oil.customer_town }}</p>
</div>
<div>
<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 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="!isFinalizedStatus(oil.delivery_status)" 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 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 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 :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 btn-outline flex-1">Print</router-link>
</div>
</div>
</div>
@@ -294,4 +324,187 @@ onMounted(() => {
})
</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
View 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>

View File

@@ -2,6 +2,7 @@ const DeliveryHome = () => import('./home.vue');
const DeliveryCreate = () => import("./create.vue");
const DeliveryEdit = () => import('./edit.vue');
const DeliveryOrder = () => import('./view.vue');
const DeliveryMap = () => import('./map.vue');
const deliveryTicketsMissing = () => import('./update_tickets/missing_data_home.vue');
const deliveryPending = () => import('./viewstatus/pending.vue');
@@ -37,6 +38,11 @@ const deliveryRoutes = [
name: 'deliveryEdit',
component: DeliveryEdit,
},
{
path: '/delivery/map',
name: 'deliveryMap',
component: DeliveryMap,
},
{
path: '/delivery/:id',
name: 'deliveryOrder',

View File

@@ -11,19 +11,35 @@
</div>
<h1 class="text-3xl font-bold mt-4">Cancelled Deliveries</h1>
<!-- Main Content Card -->
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
<!-- 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">Archived Cancelled Deliveries</h2>
<!-- <div class="badge badge-ghost">{{ recordsLength }} items Found</div> -->
<!-- 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="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 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 -->
<div class="overflow-x-auto hidden xl:block">
<table class="table w-full">
<div class="hidden xl:block overflow-x-auto">
<table class="modern-table">
<thead>
<tr>
<th>Delivery #</th>
@@ -54,8 +70,8 @@
<div class="text-xs opacity-70">{{ oil.customer_address }}</div>
</td>
<td>
<span v-if="oil.customer_asked_for_fill == 1" class="badge badge-info">FILL</span>
<span v-else>{{ oil.gallons_ordered }}</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 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>{{ oil.expected_delivery_date }}</td>
<td>
@@ -65,9 +81,9 @@
</div>
</td>
<td class="text-right">
<div class="flex items-center justify-end gap-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>
<div class="flex items-center justify-end gap-1">
<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-xs btn-info btn-outline">Edit</router-link>
</div>
</td>
</tr>
@@ -79,12 +95,20 @@
<!-- MOBILE VIEW: Cards -->
<div class="xl:hidden space-y-4">
<template v-for="oil in deliveries" :key="oil.id">
<div v-if="oil.id" class="card bg-base-100 shadow-md">
<div class="card-body p-4">
<div
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>
<h2 class="card-title text-base">{{ oil.customer_name }}</h2>
<p class="text-xs text-gray-400">Delivery #{{ oil.id }}</p>
<h2 class="text-base font-bold">{{ oil.customer_name }}</h2>
<p class="text-xs text-base-content/60">Delivery #{{ oil.id }}</p>
</div>
<div class="badge badge-error">
Cancelled
@@ -96,19 +120,28 @@
<div v-if="oil.same_day" class="badge badge-error badge-sm">SAME DAY</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>
<div class="text-sm mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
<div>
<p class="text-xs text-base-content/50">Address</p>
<p class="font-medium">{{ oil.customer_address }}</p>
<p class="text-xs">{{ oil.customer_town }}</p>
</div>
<div>
<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>
<p><strong class="font-semibold">Date:</strong> {{ oil.expected_delivery_date }}</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 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>
<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 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>
</div>
</div>
</div>
@@ -211,5 +244,186 @@ onMounted(() => {
</script>
<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>

View File

@@ -11,19 +11,35 @@
</div>
<h1 class="text-3xl font-bold mt-4">Delivered Deliveries</h1>
<!-- Main Content Card -->
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
<!-- 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">Deliveries Awaiting Finalization</h2>
<!-- <div class="badge badge-ghost">{{ recordsLength }} items Found</div> -->
<!-- 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="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 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 -->
<div class="overflow-x-auto hidden xl:block">
<table class="table w-full">
<div class="hidden xl:block overflow-x-auto">
<table class="modern-table">
<thead>
<tr>
<th>Delivery #</th>
@@ -54,8 +70,8 @@
<div class="text-xs opacity-70">{{ oil.customer_address }}</div>
</td>
<td>
<span v-if="oil.customer_asked_for_fill == 1" class="badge badge-info">FILL</span>
<span v-else>{{ oil.gallons_ordered }}</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 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>{{ oil.expected_delivery_date }}</td>
<td>
@@ -65,9 +81,11 @@
</div>
</td>
<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">
<router-link :to="{ name: 'finalizeTicket', params: { id: oil.id } }" class="btn btn-xs btn-accent btn-outline">
Finalize
</router-link>
</div>
</td>
</tr>
</template>
@@ -78,12 +96,20 @@
<!-- MOBILE VIEW: Cards -->
<div class="xl:hidden space-y-4">
<template v-for="oil in deliveries" :key="oil.id">
<div v-if="oil.id" class="card bg-base-100 shadow-md">
<div class="card-body p-4">
<div
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>
<h2 class="card-title text-base">{{ oil.customer_name }}</h2>
<p class="text-xs text-gray-400">Delivery #{{ oil.id }}</p>
<h2 class="text-base font-bold">{{ oil.customer_name }}</h2>
<p class="text-xs text-base-content/60">Delivery #{{ oil.id }}</p>
</div>
<div class="badge badge-success">
Delivered
@@ -95,18 +121,27 @@
<div v-if="oil.same_day" class="badge badge-error badge-sm">SAME DAY</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>
<div class="text-sm mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
<div>
<p class="text-xs text-base-content/50">Address</p>
<p class="font-medium">{{ oil.customer_address }}</p>
<p class="text-xs">{{ oil.customer_town }}</p>
</div>
<div>
<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>
<p><strong class="font-semibold">Date:</strong> {{ oil.expected_delivery_date }}</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 class="card-actions justify-end flex-wrap gap-2 mt-2">
<router-link :to="{ name: 'finalizeTicket', params: { id: oil.id } }" class="btn btn-sm btn-accent">
<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 btn-outline flex-1">
Finalize
</router-link>
</div>
@@ -210,5 +245,186 @@ onMounted(() => {
</script>
<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>

View File

@@ -10,18 +10,35 @@
</ul>
</div>
<!-- Main Content Card -->
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
<!-- 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">Completed and Finalized Deliveries</h2>
<!-- 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="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 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 -->
<div class="overflow-x-auto hidden xl:block">
<table class="table w-full">
<div class="hidden xl:block overflow-x-auto">
<table class="modern-table">
<thead>
<tr>
<th>Ticket #</th>
@@ -53,7 +70,7 @@
</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>{{ oil.expected_delivery_date }}</td>
<td>
@@ -63,10 +80,10 @@
</div>
</td>
<td class="text-right">
<div class="flex items-center justify-end gap-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: 'Ticket', params: { id: oil.id } }" class="btn btn-sm btn-success">Print</router-link>
<div class="flex items-center justify-end gap-1">
<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-xs btn-info btn-outline">Edit</router-link>
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-xs btn-success btn-outline">Print</router-link>
</div>
</td>
</tr>
@@ -78,12 +95,20 @@
<!-- MOBILE VIEW: Cards -->
<div class="xl:hidden space-y-4">
<template v-for="oil in deliveries" :key="oil.id">
<div v-if="oil.id" class="card bg-base-100 shadow-md">
<div class="card-body p-4">
<div
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>
<h2 class="card-title text-base">{{ oil.customer_name }}</h2>
<p class="text-xs text-gray-400">Ticket #{{ oil.id }}</p>
<h2 class="text-base font-bold">{{ oil.customer_name }}</h2>
<p class="text-xs text-base-content/60">Ticket #{{ oil.id }}</p>
</div>
<div class="badge badge-success">
Finalized
@@ -95,20 +120,28 @@
<div v-if="oil.same_day" class="badge badge-error badge-sm">SAME DAY</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>
{{ oil.gallons_delivered }}
<div class="text-sm mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
<div>
<p class="text-xs text-base-content/50">Address</p>
<p class="font-medium">{{ oil.customer_address }}</p>
<p class="text-xs">{{ oil.customer_town }}</p>
</div>
<div>
<p class="text-xs text-base-content/50">Gallons</p>
<p class="font-bold text-lg text-success">
{{ oil.gallons_delivered }} gal
</p>
<p><strong class="font-semibold">Date:</strong> {{ oil.expected_delivery_date }}</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 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: 'Ticket', params: { id: oil.id } }" class="btn btn-sm btn-success">Print</router-link>
<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 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 :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-sm btn-success btn-outline flex-1">Print</router-link>
</div>
</div>
</div>
@@ -210,5 +243,186 @@ onMounted(() => {
</script>
<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>

View File

@@ -10,19 +10,35 @@
</div>
<!-- Main Content Card -->
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
<!-- 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">Deliveries Requiring Attention</h2>
<!-- <div class="badge badge-ghost">{{ recordsLength }} items Found</div> -->
<!-- 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 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 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 -->
<div class="overflow-x-auto hidden xl:block">
<table class="table w-full">
<div class="hidden xl:block overflow-x-auto">
<table class="modern-table">
<thead>
<tr>
<th>Ticket #</th>
@@ -53,8 +69,8 @@
<div class="text-xs opacity-70">{{ oil.customer_address }}</div>
</td>
<td>
<span v-if="oil.customer_asked_for_fill == 1" class="badge badge-info">FILL</span>
<span v-else>{{ oil.gallons_ordered }}</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 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>{{ oil.expected_delivery_date }}</td>
<td>
@@ -64,10 +80,10 @@
</div>
</td>
<td class="text-right">
<div class="flex items-center justify-end gap-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: 'Ticket', params: { id: oil.id } }" class="btn btn-sm btn-success">Print</router-link>
<div class="flex items-center justify-end gap-1">
<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-xs btn-info btn-outline">Edit</router-link>
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-xs btn-success btn-outline">Print</router-link>
</div>
</td>
</tr>
@@ -79,12 +95,20 @@
<!-- MOBILE VIEW: Cards -->
<div class="xl:hidden space-y-4">
<template v-for="oil in deliveries" :key="oil.id">
<div v-if="oil.id" class="card bg-base-100 shadow-md">
<div class="card-body p-4">
<div
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>
<h2 class="card-title text-base">{{ oil.customer_name }}</h2>
<p class="text-xs text-gray-400">Ticket #{{ oil.id }}</p>
<h2 class="text-base font-bold">{{ oil.customer_name }}</h2>
<p class="text-xs text-base-content/60">Ticket #{{ oil.id }}</p>
</div>
<div class="badge badge-error">
Issue
@@ -96,20 +120,29 @@
<div v-if="oil.same_day" class="badge badge-error badge-sm">SAME DAY</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>
<div class="text-sm mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
<div>
<p class="text-xs text-base-content/50">Address</p>
<p class="font-medium">{{ oil.customer_address }}</p>
<p class="text-xs">{{ oil.customer_town }}</p>
</div>
<div>
<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>
<p><strong class="font-semibold">Date:</strong> {{ oil.expected_delivery_date }}</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 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: 'Ticket', params: { id: oil.id } }" class="btn btn-sm btn-success">Print</router-link>
<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 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 :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-sm btn-success btn-outline flex-1">Print</router-link>
</div>
</div>
</div>
@@ -211,5 +244,186 @@ onMounted(() => {
</script>
<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>

View File

@@ -9,19 +9,35 @@
</ul>
</div>
<!-- Main Content Card -->
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
<!-- 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">Deliveries Awaiting Payment</h2>
<!-- <div class="badge badge-ghost">{{ recordsLength }} items Found</div> -->
<!-- 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 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 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 -->
<div class="overflow-x-auto hidden xl:block">
<table class="table w-full">
<div class="hidden xl:block overflow-x-auto">
<table class="modern-table">
<thead>
<tr>
<th>Delivery #</th>
@@ -63,8 +79,8 @@
<div class="text-xs opacity-70">{{ oil.customer_address }}</div>
</td>
<td>
<span v-if="oil.customer_asked_for_fill == 1" class="badge badge-info">FILL</span>
<span v-else>{{ oil.gallons_ordered }}</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 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>
<span v-if="oil.payment_type == 0">Cash</span>
@@ -81,11 +97,11 @@
</div>
</td>
<td class="text-right">
<div class="flex items-center justify-end gap-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 } }" 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 class="flex items-center justify-end gap-1">
<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-xs btn-info btn-outline">Edit</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-xs btn-success btn-outline">Print</router-link>
</div>
</td>
</tr>
@@ -97,12 +113,21 @@
<!-- MOBILE VIEW: Cards -->
<div class="xl:hidden space-y-4">
<template v-for="oil in deliveries" :key="oil.id">
<div v-if="oil.id" class="card bg-base-100 shadow-md">
<div class="card-body p-4">
<div
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>
<h2 class="card-title text-base">{{ oil.customer_name }}</h2>
<p class="text-xs text-gray-400">Delivery #{{ oil.id }}</p>
<h2 class="text-base font-bold">{{ oil.customer_name }}</h2>
<p class="text-xs text-base-content/60">Delivery #{{ oil.id }}</p>
</div>
<div class="badge" :class="{
'badge-warning': oil.delivery_status == 0,
@@ -124,27 +149,38 @@
<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>
<div class="text-sm mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
<div>
<p class="text-xs text-base-content/50">Address</p>
<p class="font-medium">{{ oil.customer_address }}</p>
<p class="text-xs">{{ oil.customer_town }}</p>
</div>
<div>
<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>
<p><strong class="font-semibold">Payment:</strong>
</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>
</p>
</div>
</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 } }" 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 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 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 :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>
@@ -245,4 +281,187 @@ onMounted(() => {
})
</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>

View File

@@ -2,182 +2,347 @@
<template>
<div class="flex">
<div class="w-full px-4 md:px-10 py-4">
<!-- Breadcrumbs & Title -->
<!-- Breadcrumbs -->
<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>Today's Deliveries</li>
</ul>
</div>
<!-- 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="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>
Today's Deliveries
</h1>
<p class="text-base-content/60 mt-1 ml-13">Out for delivery right now</p>
</div>
<!-- Main Content Card -->
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
<!-- Header: Search 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">Todays Deliveries</h2>
<div class="form-control">
<label class="label pt-1 pb-0">
<!-- <span class="label-text-alt">{{ recordsLength }} deliveries found</span> -->
</label>
<!-- 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">Deliveries</span>
</div>
<div class="stat-pill stat-pill-success">
<span class="stat-pill-value">{{ grand_total.toLocaleString() }}</span>
<span class="stat-pill-label">Total Gallons</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 class="divider"></div>
<!-- Total Gallons -->
<div v-if="grand_total > 0 || totals.length > 0" class="mb-4">
<div v-if="grand_total > 0" class="mb-2">
<span class="badge badge-accent">Total: {{ grand_total }}</span>
</div>
<div class="flex flex-wrap gap-2">
<span v-for="total in totals" :key="total.town" class="badge badge-primary">
{{ total.town }}: {{ total.gallons }}
</span>
<!-- 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>
<!-- DESKTOP VIEW: Table -->
<div class="overflow-x-auto hidden xl:block">
<table class="table w-full">
<!-- 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>
<tr>
<th>Delivery #</th>
<th>Name</th>
<th>Status</th>
<th>Town / Address</th>
<th>Gallons</th>
<th class="text-right">Actions</th>
<th class="w-24">
<button @click="toggleSort('id')" class="sort-header">
<span>#</span>
<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' }">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</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>
</thead>
<tbody>
<template v-for="oil in deliveries" :key="oil.id">
<tr v-if="oil.id" class="hover:bg-blue-600 hover:text-white">
<td>{{ oil.id }}</td>
<tr
v-for="oil in filteredDeliveries"
: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>
<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>
</td>
<td>
<div class="flex items-center gap-3">
<div>
<router-link
v-if="oil.customer_id"
:to="{ name: 'customerProfile', params: { id: oil.customer_id } }"
class="font-semibold hover:text-primary transition-colors"
>
{{ oil.customer_name }}
</router-link>
<span v-else>{{ oil.customer_name }}</span>
</td>
<td>
<span class="badge badge-sm" :class="{
'badge-warning': oil.delivery_status == 0,
'badge-success': oil.delivery_status == 10,
'badge-info': oil.delivery_status == 2,
'badge-error': [1, 5].includes(oil.delivery_status),
}">
<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>
<span v-else class="font-semibold">{{ oil.customer_name }}</span>
<!-- Special Tags -->
<div class="flex gap-1 mt-0.5">
<span v-if="oil.emergency" class="special-tag tag-emergency">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-3 h-3">
<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" />
</svg>
EMERGENCY
</span>
</td>
<td>
<div>{{ oil.customer_town }}</div>
<div class="text-xs opacity-70">{{ oil.customer_address }}</div>
</td>
<td>
<span v-if="oil.customer_asked_for_fill == 1" class="badge badge-info">FILL</span>
<span v-else>{{ oil.gallons_ordered }}</span>
</td>
<td>
<div class="flex flex-col gap-1">
<span v-if="oil.prime" class="badge badge-error badge-xs">PRIME</span>
<span v-if="oil.same_day" class="badge badge-error badge-xs">SAME DAY</span>
<span v-if="oil.emergency" class="badge badge-error badge-xs">EMERGENCY</span>
<span v-if="oil.prime" class="special-tag tag-prime">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-3 h-3">
<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" />
</svg>
PRIME
</span>
<span v-if="oil.same_day" class="special-tag tag-sameday">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-3 h-3">
<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" />
</svg>
SAME DAY
</span>
</div>
</div>
</div>
</td>
<td class="text-right">
<div class="flex items-center justify-end gap-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>
<td>
<div class="status-badge" :class="getStatusClass(oil.delivery_status)">
<span class="status-dot"></span>
{{ getStatusText(oil.delivery_status) }}
</div>
</td>
<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>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- MOBILE VIEW: Cards -->
<div class="xl:hidden space-y-4">
<template v-for="oil in deliveries" :key="oil.id">
<div v-if="oil.id" class="card bg-base-100 shadow-md">
<div class="card-body p-4">
<div class="flex justify-between items-start">
<!-- MOBILE CARDS -->
<div class="xl:hidden space-y-3 p-4">
<!-- Mobile Card -->
<div
v-for="oil in filteredDeliveries"
: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>
<h2 class="card-title text-base">{{ oil.customer_name }}</h2>
<p class="text-xs text-gray-400">Delivery #{{ oil.id }}</p>
<router-link
v-if="oil.customer_id"
:to="{ name: 'customerProfile', params: { id: oil.customer_id } }"
class="font-semibold text-lg hover:text-primary"
>
{{ oil.customer_name }}
</router-link>
<span v-else class="font-semibold text-lg">{{ oil.customer_name }}</span>
<p class="text-sm text-base-content/50">#{{ oil.id }}</p>
</div>
<div class="badge" :class="{
'badge-warning': oil.delivery_status == 0,
'badge-success': oil.delivery_status == 10,
'badge-info': oil.delivery_status == 2,
'badge-error': [1, 5].includes(oil.delivery_status),
}">
<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 class="status-badge" :class="getStatusClass(oil.delivery_status)">
<span class="status-dot"></span>
{{ getStatusText(oil.delivery_status) }}
</div>
</div>
<div class="flex gap-2 mt-2">
<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.emergency" class="badge badge-error badge-sm">EMERGENCY</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>
<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>
<!-- 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>
<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>
<!-- 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>
<!-- Pagination -->
<div class="mt-6 flex justify-center">
<pagination @paginate="getPage" :records="recordsLength" v-model="page" :per-page="50" :options="options">
</pagination>
<div v-if="filteredDeliveries.length > 0" class="p-4 border-t border-base-content/10">
<div class="flex items-center justify-between">
<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>
</template>
<script setup lang="ts">
import { ref, onMounted, markRaw } from 'vue'
import { ref, computed, onMounted, markRaw } from 'vue'
import { deliveryService } from '../../../services/deliveryService'
import authService from '../../../services/authService'
import { Delivery } from '../../../types/models'
import Header from '../../../layouts/headers/headerauth.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 {
town: string;
@@ -192,84 +357,141 @@ const grand_total = ref(0)
const page = ref(1)
const perPage = ref(50)
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({
edgeNavigation: false,
format: false,
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
const getPage = (pageVal: any) => {
deliveries.value = [];
const getInitials = (name: string) => {
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)
}
const userStatus = async () => {
try {
const response = await authService.whoami();
const response = await authService.whoami()
if (response.data.ok) {
user.value = response.data.user;
user.value = response.data.user
}
} catch (error) {
user.value = null;
user.value = null
}
}
const mod = (date: any) => new Date(date).getTime()
const get_oil_orders = async (pageVal: number) => {
try {
const response = await deliveryService.getOutForDelivery(pageVal)
const data = response.data?.deliveries || []
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) {
console.error('Error fetching out for delivery:', error)
deliveries.value = []
} finally {
loading.value = false
}
}
const get_totals = async () => {
try {
const response = await deliveryService.getTodayTotals();
totals.value = response.data.totals || [];
grand_total.value = response.data.grand_total || 0;
const response = await deliveryService.getTodayTotals()
totals.value = response.data.totals || []
grand_total.value = response.data.grand_total || 0
} catch (error) {
console.error('Error fetching totals:', error);
console.error('Error fetching totals:', error)
totals.value = []
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
onMounted(() => {
userStatus()
@@ -278,4 +500,187 @@ onMounted(() => {
})
</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>

View File

@@ -10,30 +10,88 @@
</div>
<!-- Main Content Card -->
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
<!-- Header: Title and Count (No Search Input) -->
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-4">
<h2 class="text-lg font-bold">Deliveries Scheduled</h2>
<!-- <div class="badge badge-ghost">{{ recordsLength }} deliveries found</div> -->
<!-- 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="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 class="divider"></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">Deliveries</span>
</div>
<div class="stat-pill stat-pill-success">
<span class="stat-pill-value">{{ grand_total.toLocaleString() }}</span>
<span class="stat-pill-label">Total Gallons</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>
<!-- Total Gallons -->
<div v-if="grand_total > 0 || totals.length > 0" class="mb-4">
<div v-if="grand_total > 0" class="mb-2">
<span class="badge badge-accent">Total: {{ grand_total }}</span>
</div>
<div class="flex flex-wrap gap-2">
<span v-for="total in totals" :key="total.town" class="badge badge-primary">
{{ total.town }}: {{ total.gallons }}
</span>
<!-- 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 -->
<div class="overflow-x-auto hidden xl:block">
<table class="table w-full">
<div class="hidden xl:block overflow-x-auto">
<table class="modern-table">
<thead>
<tr>
<th>Delivery #</th>
@@ -46,7 +104,7 @@
</tr>
</thead>
<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">
<td>{{ oil.id }}</td>
<td>
@@ -76,8 +134,8 @@
<div class="text-xs opacity-70">{{ oil.customer_address }}</div>
</td>
<td>
<span v-if="oil.customer_asked_for_fill == 1" class="badge badge-info">FILL</span>
<span v-else>{{ oil.gallons_ordered }}</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 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>
<div class="flex flex-col gap-1">
@@ -86,11 +144,11 @@
</div>
</td>
<td class="text-right">
<div class="flex items-center justify-end gap-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 class="flex items-center justify-end gap-1">
<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-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-xs btn-accent btn-outline">Finalize</router-link>
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-xs btn-success btn-outline">Print</router-link>
</div>
</td>
</tr>
@@ -101,13 +159,22 @@
<!-- MOBILE VIEW: Cards -->
<div class="xl:hidden space-y-4">
<template v-for="oil in deliveries" :key="oil.id">
<div v-if="oil.id" class="card bg-base-100 shadow-md">
<div class="card-body p-4">
<template v-for="oil in filteredDeliveries" :key="oil.id">
<div
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>
<h2 class="card-title text-base">{{ oil.customer_name }}</h2>
<p class="text-xs text-gray-400">Delivery #{{ oil.id }}</p>
<h2 class="text-base font-bold">{{ oil.customer_name }}</h2>
<p class="text-xs text-base-content/60">Delivery #{{ oil.id }}</p>
</div>
<div class="badge" :class="{
'badge-warning': oil.delivery_status == 0,
@@ -128,23 +195,45 @@
<div class="flex gap-2 mt-2">
<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.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>
<div class="text-sm mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
<div>
<p class="text-xs text-base-content/50">Address</p>
<p class="font-medium">{{ oil.customer_address }}</p>
<p class="text-xs">{{ oil.customer_town }}</p>
</div>
<div>
<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>
<p><strong class="font-semibold">Date:</strong> {{ oil.expected_delivery_date }}</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 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 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 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 :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 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>
@@ -163,7 +252,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, markRaw } from 'vue'
import { ref, onMounted, markRaw, computed } from 'vue'
import { deliveryService } from '../../../services/deliveryService'
import authService from '../../../services/authService'
import { printService } from '../../../services/printService'
@@ -187,15 +276,44 @@ const grand_total = ref(0)
const page = ref(1)
const perPage = ref(50)
const recordsLength = ref(0)
const loading = ref(true)
const searchQuery = ref('')
const filterTown = ref('')
const options = ref({
edgeNavigation: false,
format: false,
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
}
const getPage = (pageVal: any) => {
deliveries.value = [];
loading.value = true
get_oil_orders(pageVal)
}
@@ -217,6 +335,8 @@ const get_oil_orders = async (pageVal: number) => {
} catch (error) {
console.error('Error fetching tomorrow deliveries:', error)
deliveries.value = []
} finally {
loading.value = false
}
}

View File

@@ -10,30 +10,88 @@
</div>
<!-- Main Content Card -->
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
<!-- 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">Deliveries Awaiting Dispatch</h2>
<!-- <div class="badge badge-ghost">{{ recordsLength }} deliveries found</div> -->
<!-- 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 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 class="divider"></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">Deliveries</span>
</div>
<div class="stat-pill stat-pill-success">
<span class="stat-pill-value">{{ grand_total.toLocaleString() }}</span>
<span class="stat-pill-label">Total Gallons</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>
<!-- Total Gallons -->
<div v-if="grand_total > 0 || totals.length > 0" class="mb-4">
<div v-if="grand_total > 0" class="mb-2">
<span class="badge badge-accent">Total: {{ grand_total }}</span>
</div>
<div class="flex flex-wrap gap-2">
<span v-for="total in totals" :key="total.town" class="badge badge-primary">
{{ total.town }}: {{ total.gallons }}
</span>
<!-- 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 -->
<div class="overflow-x-auto hidden xl:block">
<table class="table w-full">
<div class="hidden xl:block overflow-x-auto">
<table class="modern-table">
<thead>
<tr>
<th>Delivery #</th>
@@ -47,7 +105,7 @@
</tr>
</thead>
<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">
<td>{{ oil.id }}</td>
<td>
@@ -66,8 +124,8 @@
<div class="text-xs opacity-70">{{ oil.customer_address }}</div>
</td>
<td>
<span v-if="oil.customer_asked_for_fill == 1" class="badge badge-info">FILL</span>
<span v-else>{{ oil.gallons_ordered }}</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 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>{{ oil.expected_delivery_date }}</td>
<td>
@@ -77,11 +135,11 @@
</div>
</td>
<td class="text-right">
<div class="flex items-center justify-end gap-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 } }" 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 class="flex items-center justify-end gap-1">
<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-xs btn-info btn-outline">Edit</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-xs btn-success btn-outline">Print</router-link>
</div>
</td>
</tr>
@@ -92,13 +150,21 @@
<!-- MOBILE VIEW: Cards -->
<div class="xl:hidden space-y-4">
<template v-for="oil in deliveries" :key="oil.id">
<div v-if="oil.id" class="card bg-base-100 shadow-md">
<div class="card-body p-4">
<template v-for="oil in filteredDeliveries" :key="oil.id">
<div
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>
<h2 class="card-title text-base">{{ oil.customer_name }}</h2>
<p class="text-xs text-gray-400">Delivery #{{ oil.id }}</p>
<h2 class="text-base font-bold">{{ oil.customer_name }}</h2>
<p class="text-xs text-base-content/60">Delivery #{{ oil.id }}</p>
</div>
<div class="badge badge-warning">
Waiting
@@ -110,21 +176,30 @@
<div v-if="oil.same_day" class="badge badge-error badge-sm">SAME DAY</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>
<div class="text-sm mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
<div>
<p class="text-xs text-base-content/50">Address</p>
<p class="font-medium">{{ oil.customer_address }}</p>
<p class="text-xs">{{ oil.customer_town }}</p>
</div>
<div>
<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>
<p><strong class="font-semibold">Date:</strong> {{ oil.expected_delivery_date }}</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 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 } }" 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 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 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 :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 flex-1">Print</router-link>
</div>
</div>
</div>
@@ -143,7 +218,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, markRaw } from 'vue'
import { ref, onMounted, markRaw, computed } from 'vue'
import { deliveryService } from '../../../services/deliveryService'
import authService from '../../../services/authService'
import { Delivery } from '../../../types/models'
@@ -166,15 +241,46 @@ const grand_total = ref(0)
const page = ref(1)
const perPage = ref(50)
const recordsLength = ref(0)
const loading = ref(true)
const searchQuery = ref('')
const filterTown = ref('')
const options = ref({
edgeNavigation: false,
format: false,
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
const getPage = (pageVal: any) => {
deliveries.value = [];
loading.value = true
get_oil_orders(pageVal)
}
@@ -196,6 +302,8 @@ const get_oil_orders = async (pageVal: number) => {
} catch (error) {
console.error('Error fetching waiting deliveries:', error)
deliveries.value = []
} finally {
loading.value = false
}
}
@@ -245,4 +353,187 @@ onMounted(() => {
})
</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>

View File

@@ -27,7 +27,7 @@
<!-- First Name -->
<div class="form-control">
<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">
{{ v$.CreateEmployeeForm.employee_first_name.$errors[0].$message }}
</span>
@@ -35,7 +35,7 @@
<!-- Last Name -->
<div class="form-control">
<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">
{{ v$.CreateEmployeeForm.employee_last_name.$errors[0].$message }}
</span>
@@ -43,7 +43,7 @@
<!-- Phone Number -->
<div class="form-control">
<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">
{{ v$.CreateEmployeeForm.employee_phone_number.$errors[0].$message }}
</span>
@@ -51,7 +51,7 @@
<!-- Employee Type -->
<div class="form-control">
<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 v-for="employee in employList" :key="employee.value" :value="employee.value">
{{ employee.text }}
@@ -72,7 +72,7 @@
<!-- Street Address -->
<div class="form-control">
<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">
{{ v$.CreateEmployeeForm.employee_address.$errors[0].$message }}
</span>
@@ -80,13 +80,13 @@
<!-- Apt, Suite, etc. -->
<div class="form-control">
<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>
</div>
<!-- Town -->
<div class="form-control">
<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">
{{ v$.CreateEmployeeForm.employee_town.$errors[0].$message }}
</span>
@@ -94,7 +94,7 @@
<!-- State -->
<div class="form-control">
<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 v-for="state in stateList" :key="state.value" :value="state.value">
{{ state.text }}
@@ -107,7 +107,7 @@
<!-- Zip Code -->
<div class="form-control">
<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">
{{ v$.CreateEmployeeForm.employee_zip.$errors[0].$message }}
</span>
@@ -122,12 +122,12 @@
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-control">
<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>
</div>
<div class="form-control">
<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>
</div>
<div class="form-control">
@@ -154,6 +154,7 @@ import axios from 'axios'
import authHeader from '../../services/auth.header'
import useValidate from "@vuelidate/core";
import { minLength, required } from "@vuelidate/validators";
import { notify } from "@kyvg/vue3-notification";
interface SelectOption {
text: string;
@@ -246,7 +247,7 @@ export default defineComponent({
if (!this.v$.$error) {
this.CreateItem(this.CreateEmployeeForm);
} else {
console.log("Form validation failed.");
notify({ title: "Validation Error", text: "Please fill out all required fields correctly.", type: "error" });
}
},
getEmployeeTypeList() {

View File

@@ -32,7 +32,7 @@
<!-- First Name -->
<div class="form-control">
<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">
{{ v$.CreateEmployeeForm.employee_first_name.$errors[0].$message }}
</span>
@@ -40,7 +40,7 @@
<!-- Last Name -->
<div class="form-control">
<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">
{{ v$.CreateEmployeeForm.employee_last_name.$errors[0].$message }}
</span>
@@ -48,7 +48,7 @@
<!-- Phone Number -->
<div class="form-control">
<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">
{{ v$.CreateEmployeeForm.employee_phone_number.$errors[0].$message }}
</span>
@@ -56,7 +56,7 @@
<!-- Employee Type -->
<div class="form-control">
<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 v-for="employee in employList" :key="employee.value" :value="employee.value">
{{ employee.text }}
@@ -77,7 +77,7 @@
<!-- Street Address -->
<div class="form-control">
<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">
{{ v$.CreateEmployeeForm.employee_address.$errors[0].$message }}
</span>
@@ -90,7 +90,7 @@
<!-- Town -->
<div class="form-control">
<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">
{{ v$.CreateEmployeeForm.employee_town.$errors[0].$message }}
</span>
@@ -98,7 +98,7 @@
<!-- State -->
<div class="form-control">
<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 v-for="state in stateList" :key="state.value" :value="state.value">
{{ state.text }}
@@ -111,7 +111,7 @@
<!-- Zip Code -->
<div class="form-control">
<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">
{{ v$.CreateEmployeeForm.employee_zip.$errors[0].$message }}
</span>
@@ -126,12 +126,12 @@
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-control">
<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>
</div>
<div class="form-control">
<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>
</div>
<div class="form-control">
@@ -169,6 +169,7 @@ import axios from 'axios'
import authHeader from '../../services/auth.header'
import useValidate from "@vuelidate/core";
import { minLength, required } from "@vuelidate/validators";
import { notify } from "@kyvg/vue3-notification";
interface SelectOption {
text: string;
@@ -274,18 +275,30 @@ export default defineComponent({
if (!this.v$.$error) {
this.EditEmployee(this.CreateEmployeeForm);
} else {
console.log("Form validation failed.");
notify({ title: "Validation Error", text: "Please fill out all required fields correctly.", type: "error" });
}
},
getEmployeeTypeList() {
const path = import.meta.env.VITE_BASE_URL + "/query/employeetype";
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() {
const path = import.meta.env.VITE_BASE_URL + "/query/states";
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 = [];
}
});
},
},
})

View File

@@ -9,23 +9,40 @@
<li>Employees</li>
</ul>
</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 -->
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
<!-- Header: Count and Add Button -->
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-4">
<div class="badge badge-ghost">{{ recordsLength }} employees found</div>
<router-link :to="{ name: 'employeeCreate' }" class="btn btn-primary btn-sm">
<!-- Quick Stats -->
<div class="flex flex-wrap gap-3 items-center">
<div class="stat-pill">
<span class="stat-pill-value">{{ recordsLength }}</span>
<span class="stat-pill-label">Total Staff</span>
</div>
<router-link :to="{ name: 'employeeCreate' }" class="btn btn-primary btn-sm ml-2">
Create New Employee
</router-link>
</div>
</div>
<!-- Main Content Card -->
<div class="modern-table-card">
<div class="divider"></div>
<!-- DESKTOP VIEW: Table -->
<div class="overflow-x-auto hidden xl:block">
<table class="table w-full">
<table class="modern-table">
<thead>
<tr>
<th>Employee ID</th>
@@ -37,7 +54,7 @@
</tr>
</thead>
<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.employee_first_name }} {{ person.employee_last_name }}</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 class="text-right">
<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 :to="{ name: 'employeeEdit', params: { id: person.user_id || 0 } }" class="btn btn-sm btn-secondary">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="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="person.user_id" :to="{ name: 'employeeEdit', params: { id: person.user_id } }" class="btn btn-sm btn-secondary">Edit</router-link>
<button v-else class="btn btn-sm btn-disabled">No User</button>
<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 && person.id" :to="{ name: 'employeeChangePassword', params: { id: person.id } }" class="btn btn-sm btn-warning">Change Password</router-link>
</div>
</td>
</tr>
@@ -57,26 +74,32 @@
</div>
<!-- MOBILE VIEW: Cards -->
<div class="xl:hidden space-y-4">
<div v-for="person in employees" :key="person.id" class="card bg-base-100 shadow-md">
<div class="card-body p-4">
<div class="xl:hidden space-y-4 px-4 pb-4">
<div v-for="person in employees" :key="person.id" class="mobile-card">
<div class="p-3">
<div class="flex justify-between items-start">
<div>
<h2 class="card-title text-base">{{ person.employee_first_name }} {{ person.employee_last_name }}</h2>
<p class="text-xs text-gray-400">ID: #{{ person.id }}</p>
<h2 class="text-base font-bold">{{ person.employee_first_name }} {{ person.employee_last_name }}</h2>
<p class="text-xs text-base-content/60">ID: #{{ person.id }}</p>
</div>
<div class="badge badge-ghost">
<div class="badge badge-ghost badge-sm">
{{ getEmployeeTypeName(person.employee_type) }}
</div>
</div>
<div class="text-sm mt-2">
<div class="text-sm mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
<div>
<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 class="card-actions justify-end flex-wrap gap-2 mt-2">
<router-link :to="{ name: 'employeeEdit', params: { id: person.user_id || 0 } }" class="btn btn-sm btn-secondary">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="user && user.user_admin === 0" :to="{ name: 'employeeChangePassword', params: { id: person.id } }" class="btn btn-sm btn-warning">Change Password</router-link>
</div>
<div class="flex gap-2 pt-3 mt-3 border-t border-base-content/10 flex-wrap">
<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 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 && person.id" :to="{ name: 'employeeChangePassword', params: { id: person.id } }" class="btn btn-sm btn-warning flex-1">Change Password</router-link>
</div>
</div>
</div>
@@ -146,14 +169,17 @@ export default defineComponent({
axios.get(path, { headers: authHeader() })
.then((response: any) => {
// --- FIX 1: Assign the response data directly, as it's an array ---
this.employees = response.data;
// Fix: Access the .employees property from the response object
// 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 ---
// NOTE: For full pagination, your API will eventually need to send the *total* count.
// 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;
// Fix: Set recordsLength based on the array length
this.recordsLength = this.employees.length;
})
.catch((error: any) => {
console.error("Failed to fetch employees:", error);

View File

@@ -2,33 +2,51 @@
<template>
<div class="flex">
<div class=" w-full px-10 ">
<div class="text-sm breadcrumbs mb-10">
<div class="w-full px-4 md:px-10 py-4">
<!-- Breadcrumbs -->
<div class="text-sm breadcrumbs mb-6">
<ul>
<li>
<router-link :to="{ name: 'home' }">
Home
</router-link>
</li>
<li>
<router-link :to="{ name: 'employee' }">
employees
</router-link>
</li>
<li>Money</li>
<li>Profit Year</li>
</ul>
</div>
<div class="flex start pb-10 text-2xl">Profit for Year </div>
<div class="col-span-12 font-bold ">
Profit Year
<!-- 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 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>

View File

@@ -9,16 +9,34 @@
<li>Service Calls</li>
</ul>
</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 -->
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
<!-- 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>
<div class="modern-table-card">
<!-- Loading State -->
<div v-if="isLoading" class="text-center p-10">
@@ -35,7 +53,7 @@
<div v-else>
<!-- DESKTOP VIEW: Table -->
<div class="overflow-x-auto hidden xl:block">
<table class="table w-full">
<table class="modern-table">
<thead>
<tr>
<th>ID</th>
@@ -50,26 +68,25 @@
</thead>
<tbody>
<!-- Removed @click from tr to avoid conflicting actions -->
<tr v-for="service in services" :key="service.id" class=" hover:bg-blue-600">
<td class="align-top text-white">{{ service.id }}</td>
<td class="align-top text-white">
<div>{{ formatDate(service.scheduled_date) }}</div>
<tr v-for="service in services" :key="service.id" class="table-row-hover">
<td>{{ service.id }}</td>
<td>
<div class="font-medium">{{ formatDate(service.scheduled_date) }}</div>
<div class="text-xs opacity-70">{{ formatTime(service.scheduled_date) }}</div>
</td>
<td class="align-top">
<router-link
:to="{ name: 'customerProfile', params: { id: service.customer_id } }"
class="text-white hover:text-green-500 hover:underline"
>
<td>
<router-link :to="{ name: 'customerProfile', params: { id: service.customer_id } }"
class="link link-hover font-medium">
{{ service.customer_name }}
</router-link>
</td>
<td class="align-top text-white">{{ service.customer_address }}, {{ service.customer_town }}</td>
<td class="align-top">
<span
class="badge badge-sm text-white"
:style="{ 'background-color': getServiceTypeColor(service.type_service_call), 'border-color': getServiceTypeColor(service.type_service_call) }"
>
<td>
<div>{{ service.customer_town }}</div>
<div class="text-xs opacity-70">{{ service.customer_address }}</div>
</td>
<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) }}
</span>
</td>
@@ -77,18 +94,26 @@
<!-- TRUNCATION LOGIC FOR DESKTOP -->
<div v-if="!isLongDescription(service.description) || isExpanded(service.id)">
{{ 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 v-else>
{{ 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>
</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">
<button @click="openEditModal(service)" class="btn btn-sm btn-primary">View</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="shouldShowCaptureButton(service)" :to="{ name: 'chargeServiceAuthorize', params: { id: service.id } }" class="btn btn-sm btn-warning">Capture</router-link>
<td class="text-right align-top">
<div class="flex items-center justify-end gap-1">
<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>
</tr>
</tbody>
@@ -96,47 +121,62 @@
</div>
<!-- MOBILE VIEW: Cards -->
<div class="xl:hidden space-y-4">
<div v-for="service in services" :key="service.id" class="card bg-base-100 shadow-md">
<div class="card-body p-4">
<div class="xl:hidden space-y-4 px-4 pb-4">
<div v-for="service in services" :key="service.id" class="mobile-card">
<div class="p-3">
<div class="flex justify-between items-start">
<div>
<router-link
:to="{ name: 'customerProfile', params: { id: service.customer_id } }"
class="card-title text-base text-white hover:text-green-500 hover:underline"
>
<router-link :to="{ name: 'customerProfile', params: { id: service.customer_id } }"
class="text-base font-bold link link-hover">
{{ service.customer_name }}
</router-link>
<p class="text-xs text-gray-500">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/60">ID: {{ service.id }}</p>
</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) }}
</div>
</div>
<div class="text-sm mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
<p><strong class="font-semibold">Date:</strong> {{ formatDate(service.scheduled_date) }}</p>
<p><strong class="font-semibold">Time:</strong> {{ formatTime(service.scheduled_date) }}</p>
<p><strong class="font-semibold">Cost:</strong> <span class="font-mono">{{ formatCurrency(service.service_cost) }}</span></p>
<div class="text-sm mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
<div>
<p class="text-xs text-base-content/50">Address</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>
<!-- 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)">
{{ 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 v-else>
{{ 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 class="card-actions justify-end mt-2 space-x-2">
<button @click="openEditModal(service)" class="btn btn-sm btn-primary">View</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="shouldShowCaptureButton(service)" :to="{ name: 'chargeServiceAuthorize', params: { id: service.id } }" class="btn btn-sm btn-warning">Capture</router-link>
<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-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 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>
@@ -148,13 +188,8 @@
<ServiceEditModal
v-if="selectedServiceForEdit"
:service="selectedServiceForEdit"
@close-modal="closeEditModal"
@save-changes="handleSaveChanges"
@delete-service="handleDeleteService"
/>
<ServiceEditModal v-if="selectedServiceForEdit" :service="selectedServiceForEdit" @close-modal="closeEditModal"
@save-changes="handleSaveChanges" @delete-service="handleDeleteService" />
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'

View File

@@ -12,13 +12,31 @@
</div>
<!-- Main Content Card -->
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
<!-- 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">Service Call History</h2>
<div v-if="!isLoading" class="badge badge-ghost">{{ services.length }} calls found</div>
<!-- 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="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="divider"></div>
Service Call History
</h1>
<p class="text-base-content/60 mt-1 ml-13">Archive of completed service calls</p>
</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 -->
<div v-if="isLoading" class="text-center p-10">
@@ -35,7 +53,7 @@
<div v-else>
<!-- DESKTOP VIEW: Table (Revamped) -->
<div class="overflow-x-auto hidden xl:block">
<table class="table w-full">
<table class="modern-table">
<thead>
<tr>
<th>ID</th>
@@ -49,52 +67,51 @@
</tr>
</thead>
<tbody>
<tr v-for="service in services" :key="service.id" class="hover:bg-blue-600">
<td class="align-top text-white">{{ service.id }}</td>
<td class="align-top text-white">
<tr v-for="service in services" :key="service.id" class="table-row-hover">
<td class="align-top">{{ service.id }}</td>
<td class="align-top">
<div>{{ formatDate(service.scheduled_date) }}</div>
<div class="text-xs opacity-70">{{ formatTime(service.scheduled_date) }}</div>
</td>
<td class="align-top">
<router-link
:to="{ name: 'customerProfile', params: { id: service.customer_id } }"
class="text-white hover:text-green-500 hover:underline"
>
<router-link :to="{ name: 'customerProfile', params: { id: service.customer_id } }"
class="link link-hover font-bold">
{{ service.customer_name }}
</router-link>
</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">
<span
class="badge badge-sm text-white"
:style="{ 'background-color': getServiceTypeColor(service.type_service_call), 'border-color': getServiceTypeColor(service.type_service_call) }"
>
<span 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) }}
</span>
</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)">
{{ 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 v-else>
{{ 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>
</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">
<button @click="openEditModal(service)" class="btn btn-sm btn-primary">View</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="shouldShowCaptureButton(service)" :to="{ name: 'chargeServiceAuthorize', params: { id: service.id } }" class="btn btn-sm btn-warning">Capture</router-link>
<td class="text-right font-mono align-top text-primary font-bold">{{
formatCurrency(service.service_cost) }}</td>
<td class="text-right align-top">
<div class="flex items-center justify-end gap-1">
<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>
</tr>
</tbody>
@@ -102,47 +119,58 @@
</div>
<!-- MOBILE VIEW: Cards (Revamped) -->
<div class="xl:hidden space-y-4">
<div v-for="service in services" :key="service.id" class="card bg-base-100 shadow-md ">
<div class="card-body p-4">
<div class="xl:hidden space-y-4 px-4 pb-4 pt-4">
<div v-for="service in services" :key="service.id" class="mobile-card">
<div class="p-3">
<div class="flex justify-between items-start">
<div>
<router-link
:to="{ name: 'customerProfile', params: { id: service.customer_id } }"
class="card-title text-base text-white hover:text-green-500 hover:underline"
>
<router-link :to="{ name: 'customerProfile', params: { id: service.customer_id } }"
class="card-title text-base link link-hover">
{{ service.customer_name }}
</router-link>
<p class="text-xs text-gray-500">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/60">ID: {{ service.id }}</p>
<p class="text-xs text-base-content/50">{{ service.customer_address }}, {{ service.customer_town }}
</p>
</div>
<!-- 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) }}
</div>
</div>
<div class="text-sm mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
<p><strong class="font-semibold">Date:</strong> {{ formatDate(service.scheduled_date) }}</p>
<p><strong class="font-semibold">Time:</strong> {{ formatTime(service.scheduled_date) }}</p>
<p><strong class="font-semibold">Cost:</strong> <span class="font-mono">{{ formatCurrency(service.service_cost) }}</span></p>
<div class="text-sm mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
<div>
<p class="text-xs text-base-content/50">Time</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 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)">
{{ 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 v-else>
{{ 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 class="card-actions justify-end mt-2 space-x-2">
<button @click="openEditModal(service)" class="btn btn-sm btn-primary">View</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="shouldShowCaptureButton(service)" :to="{ name: 'chargeServiceAuthorize', params: { id: service.id } }" class="btn btn-sm btn-warning">Capture</router-link>
<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-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 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>
@@ -154,13 +182,8 @@
<ServiceEditModal
v-if="selectedServiceForEdit"
:service="selectedServiceForEdit"
@close-modal="closeEditModal"
@save-changes="handleSaveChanges"
@delete-service="handleDeleteService"
/>
<ServiceEditModal v-if="selectedServiceForEdit" :service="selectedServiceForEdit" @close-modal="closeEditModal"
@save-changes="handleSaveChanges" @delete-service="handleDeleteService" />
</template>
<script setup lang="ts">

View File

@@ -10,16 +10,31 @@
<li>Service Plans</li>
</ul>
</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 -->
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
<!-- 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>
<div class="modern-table-card">
<!-- Loading State -->
<div v-if="isLoading" class="text-center p-10">
@@ -36,7 +51,7 @@
<div v-else>
<!-- DESKTOP VIEW: Table -->
<div class="overflow-x-auto hidden xl:block">
<table class="table w-full">
<table class="modern-table">
<thead>
<tr>
<th>Customer</th>
@@ -49,9 +64,9 @@
</tr>
</thead>
<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">
<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>
</td>
<td class="align-top">
@@ -64,16 +79,16 @@
<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">
<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) }}
</span>
</td>
<td class="text-right align-top">
<div class="flex items-center justify-end gap-1">
<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 } }"
class="btn btn-xs btn-secondary">Edit Contract</router-link>
class="btn btn-xs btn-info btn-outline">Edit</router-link>
</div>
</td>
</tr>
@@ -82,35 +97,38 @@
</div>
<!-- MOBILE VIEW: Cards -->
<div class="xl:hidden space-y-4">
<div v-for="plan in servicePlans" :key="plan.id" class="card bg-base-100 shadow-md">
<div class="card-body p-4">
<div class="xl:hidden space-y-4 px-4 pb-4">
<div v-for="plan in servicePlans" :key="plan.id" class="mobile-card">
<div class="p-3">
<div class="flex justify-between items-start">
<div>
<h2 class="card-title text-base">{{ plan.customer_name }}</h2>
<p class="text-xs text-gray-400">{{ plan.customer_address }}, {{ plan.customer_town }}</p>
<h2 class="font-bold text-base">{{ plan.customer_name }}</h2>
<p class="text-xs text-base-content/60">{{ plan.customer_address }}, {{ plan.customer_town }}</p>
</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) }}
</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">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>
<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) }}
</span>
</p>
</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 } }"
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 } }"
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>

View File

@@ -12,13 +12,31 @@
</div>
<!-- Main Content Card -->
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
<!-- 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">Today's Service Calls</h2>
<div v-if="!isLoading" class="badge badge-ghost">{{ services.length }} calls found</div>
<!-- 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="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="divider"></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 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 -->
<div v-if="isLoading" class="text-center p-10">
@@ -35,7 +53,7 @@
<div v-else>
<!-- DESKTOP VIEW: Table (Revamped) -->
<div class="overflow-x-auto hidden xl:block">
<table class="table w-full">
<table class="modern-table">
<thead>
<tr>
<th>ID</th>
@@ -49,17 +67,15 @@
</tr>
</thead>
<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">
<div>{{ formatDate(service.scheduled_date) }}</div>
<div class="text-xs opacity-70">{{ formatTime(service.scheduled_date) }}</div>
</td>
<td class="align-top">
<router-link
:to="{ name: 'customerProfile', params: { id: service.customer_id } }"
class="text-white hover:text-green-500 hover:underline"
>
<router-link :to="{ name: 'customerProfile', params: { id: service.customer_id } }"
class="link link-hover font-bold">
{{ service.customer_name }}
</router-link>
</td>
@@ -72,10 +88,8 @@
- The background color is set dynamically using your existing `getServiceTypeColor` method.
-->
<td class="align-top">
<span
class="badge badge-sm text-white"
:style="{ 'background-color': getServiceTypeColor(service.type_service_call), 'border-color': getServiceTypeColor(service.type_service_call) }"
>
<span 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) }}
</span>
</td>
@@ -83,18 +97,26 @@
<td class="whitespace-normal text-sm align-top">
<div v-if="!isLongDescription(service.description) || isExpanded(service.id)">
{{ 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 v-else>
{{ 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>
</td>
<td class="text-right font-mono align-top">{{ formatCurrency(service.service_cost) }}</td>
<td class="text-right align-top space-x-2">
<button @click="openEditModal(service)" class="btn btn-sm btn-primary">View</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="shouldShowCaptureButton(service)" :to="{ name: 'chargeServiceAuthorize', params: { id: service.id } }" class="btn btn-sm btn-warning">Capture</router-link>
<td class="text-right align-top">
<div class="flex items-center justify-end gap-1">
<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>
</tr>
</tbody>
@@ -102,47 +124,58 @@
</div>
<!-- MOBILE VIEW: Cards (Revamped) -->
<div class="xl:hidden space-y-4">
<div v-for="service in services" :key="service.id" class="card bg-base-100 shadow-md ">
<div class="card-body p-4">
<div class="xl:hidden space-y-4 px-4 pb-4 pt-4">
<div v-for="service in services" :key="service.id" class="mobile-card">
<div class="p-3">
<div class="flex justify-between items-start">
<div>
<router-link
:to="{ name: 'customerProfile', params: { id: service.customer_id } }"
class="card-title text-base text-white hover:text-green-500 hover:underline"
>
<router-link :to="{ name: 'customerProfile', params: { id: service.customer_id } }"
class="card-title text-base link link-hover">
{{ service.customer_name }}
</router-link>
<p class="text-xs text-gray-500">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/60">ID: {{ service.id }}</p>
<p class="text-xs text-base-content/50">{{ service.customer_address }}, {{ service.customer_town }}
</p>
</div>
<!-- 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) }}
</div>
</div>
<div class="text-sm mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
<p><strong class="font-semibold">Date:</strong> {{ formatDate(service.scheduled_date) }}</p>
<p><strong class="font-semibold">Time:</strong> {{ formatTime(service.scheduled_date) }}</p>
<p><strong class="font-semibold">Cost:</strong> <span class="font-mono">{{ formatCurrency(service.service_cost) }}</span></p>
<div class="text-sm mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
<div>
<p class="text-xs text-base-content/50">Time</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 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)">
{{ 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 v-else>
{{ 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 class="card-actions justify-end mt-2 space-x-2">
<button @click="openEditModal(service)" class="btn btn-sm btn-primary">View</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="shouldShowCaptureButton(service)" :to="{ name: 'chargeServiceAuthorize', params: { id: service.id } }" class="btn btn-sm btn-warning">Capture</router-link>
<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-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 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>
@@ -154,13 +187,8 @@
<ServiceEditModal
v-if="selectedServiceForEdit"
:service="selectedServiceForEdit"
@close-modal="closeEditModal"
@save-changes="handleSaveChanges"
@delete-service="handleDeleteService"
/>
<ServiceEditModal v-if="selectedServiceForEdit" :service="selectedServiceForEdit" @close-modal="closeEditModal"
@save-changes="handleSaveChanges" @delete-service="handleDeleteService" />
</template>
<script setup lang="ts">

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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;

View File

@@ -10,20 +10,35 @@
<li>Authorize</li>
</ul>
</div>
<h1 class="text-3xl font-bold mt-4">Authorize.net</h1>
<!-- Main Content Card -->
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
<!-- 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">All Transactions</h2>
<!-- 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="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 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 -->
<div class="overflow-x-auto hidden xl:block">
<table class="table w-full">
<table class="modern-table">
<thead>
<tr>
<th>Transaction #</th>
@@ -40,7 +55,7 @@
</thead>
<tbody>
<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.auth_net_transaction_id || 'N/A' }}</td>
@@ -102,54 +117,77 @@
</div>
<!-- MOBILE VIEW: Cards -->
<div class="xl:hidden space-y-4">
<div v-for="transaction in transactions" :key="transaction.id" class="card bg-base-100 shadow-md">
<div class="card-body p-4">
<div class="xl:hidden space-y-4 px-4 pb-4">
<div v-for="transaction in transactions" :key="transaction.id" class="mobile-card">
<div class="p-3">
<div class="flex justify-between items-start">
<div>
<h2 class="card-title text-base">
<router-link v-if="transaction.customer_id" :to="{ name: 'customerProfile', params: { id: transaction.customer_id } }" class="link link-primary">
<div class="text-base font-bold">
<router-link v-if="transaction.customer_id" :to="{ name: 'customerProfile', params: { id: transaction.customer_id } }" class="link link-hover">
{{ transaction.customer_name || 'N/A' }}
</router-link>
<span v-else>{{ transaction.customer_name || 'N/A' }}</span>
</h2>
<p class="text-xs text-gray-400">Transaction #{{ transaction.id }}</p>
</div>
<div :class="'badge badge-' + getStatusClass(transaction.status)">
<p class="text-xs text-base-content/60">Transaction #{{ transaction.id }}</p>
</div>
<div :class="'badge badge-sm border-0 ' + getStatusClass(transaction.status)">
{{ getStatusText(transaction.status) }}
</div>
</div>
<div class="text-sm mt-2 space-y-1">
<p><strong>Transaction ID:</strong> {{ transaction.auth_net_transaction_id || 'N/A' }}</p>
<p><strong>Date:</strong> {{ formatDate(transaction.created_at) }}</p>
<p><strong>Pre-Auth:</strong> ${{ transaction.preauthorize_amount || '0.00' }}</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>
<div class="text-sm mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
<div class="col-span-2">
<p class="text-xs text-base-content/50">Transaction ID</p>
<p class="font-mono text-xs">{{ transaction.auth_net_transaction_id || 'N/A' }}</p>
</div>
<div>
<p class="text-xs text-base-content/50">Date</p>
<p>{{ formatDate(transaction.created_at) }}</p>
</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-transparent border border-gray-300 rounded-md p-3 mt-2">
<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-red-500 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<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-red-700 text-sm">{{ transaction.rejection_reason }}</span>
<span class="text-error text-sm font-medium">{{ transaction.rejection_reason }}</span>
</div>
</div>
<!-- Action Buttons -->
<div class="mt-3 space-y-2">
<router-link v-if="transaction.delivery_id" :to="{ name: 'deliveryOrder', params: { id: transaction.delivery_id } }" class="btn btn-xs btn-info btn-block">
<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 btn-block">
View Automatic
<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 btn-block">
<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 btn-block">
Capture Payment
<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>
@@ -159,7 +197,6 @@
</div>
</div>
</div>
</div>
</template>

View 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>

View File

@@ -1,4 +1,5 @@
const AuthorizePage = () => import('./authorize/index.vue');
const HistoryPage = () => import('./history.vue');
const transactionsRoutes = [
{
@@ -6,6 +7,11 @@ const transactionsRoutes = [
name: 'transactionsAuthorize',
component: AuthorizePage,
},
{
path: '/transactions/history',
name: 'transactionsHistory',
component: HistoryPage,
},
];
export default transactionsRoutes;

View File

@@ -13,6 +13,7 @@ import tickerRoutes from "../pages/ticket/routes.ts";
import moneyRoutes from "../pages/money/routes.ts";
import serviceRoutes from "../pages/service/routes.ts";
import transactionsRoutes from '../pages/transactions/routes.ts';
import statsRoutes from '../pages/stats/routes.ts';
// Import your page components
import Home from '../pages/Index.vue';
@@ -56,6 +57,7 @@ const routes = [
...protectRoutes(adminRoutes),
...protectRoutes(serviceRoutes),
...protectRoutes(transactionsRoutes),
...protectRoutes(statsRoutes),
{
path: '',

View 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;

View File

@@ -110,6 +110,13 @@ export const adminService = {
pendingStatus: () =>
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

View File

@@ -5,6 +5,8 @@ import {
UpdateDeliveryRequest,
DeliveriesResponse,
DeliveryResponse,
DeliveriesMapResponse,
DeliveryHistoryResponse,
AxiosResponse
} from '../types/models';
@@ -23,6 +25,12 @@ export const deliveryService = {
getOrder: (id: number): Promise<AxiosResponse<DeliveryResponse>> =>
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>> =>
api.put(`/delivery/edit/${id}`, data),

View 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;

View File

@@ -820,3 +820,49 @@ export interface ThemeOption {
label: string;
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
View 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;
}