feat: add admin settings UI and integrate dynamic configuration
Add settings page with 4 tabs (Logo, Company, Visibility, Theme) for managing company branding, social links, sidebar section visibility, and color themes. Integrate settings store globally so sidebar, footer, header, and theme respond to admin configuration. Add active/dedicated customer stat cards to dashboard. Wire up quick-call contacts and Google review links from settings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useSearchStore } from '../stores/search';
|
import { useSearchStore } from '../stores/search';
|
||||||
import { useOilPriceStore } from '../stores/oilPrice'; // [NEW]
|
import { useOilPriceStore } from '../stores/oilPrice'; // [NEW]
|
||||||
|
import { useSettingsStore } from '../stores/settings';
|
||||||
import HeaderAuth from './headers/headerauth.vue';
|
import HeaderAuth from './headers/headerauth.vue';
|
||||||
import SideBar from './sidebar/sidebar.vue';
|
import SideBar from './sidebar/sidebar.vue';
|
||||||
import Footer from './footers/footer.vue';
|
import Footer from './footers/footer.vue';
|
||||||
@@ -43,8 +44,10 @@ import { onMounted } from 'vue'; // [NEW]
|
|||||||
|
|
||||||
const searchStore = useSearchStore();
|
const searchStore = useSearchStore();
|
||||||
const oilPriceStore = useOilPriceStore(); // [NEW]
|
const oilPriceStore = useOilPriceStore(); // [NEW]
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
oilPriceStore.fetchPrices(); // [NEW] Global check
|
oilPriceStore.fetchPrices(); // [NEW] Global check
|
||||||
|
settingsStore.fetchSettings();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,17 +2,13 @@
|
|||||||
<footer class="footer p-10 bg-secondary text-neutral-content">
|
<footer class="footer p-10 bg-secondary text-neutral-content">
|
||||||
<nav>
|
<nav>
|
||||||
<h6 class="footer-title">Social</h6>
|
<h6 class="footer-title">Social</h6>
|
||||||
<a class="link link-hover" href="https://www.facebook.com/auburnoil">Facebook</a>
|
<a v-if="settingsStore.settings.link_facebook" class="link link-hover" :href="settingsStore.settings.link_facebook" target="_blank">Facebook</a>
|
||||||
<a class="link link-hover" href="https://www.google.com/search?client=firefox-b-1-d&sca_esv=02c44965d6d4b280&sca_upv=1&cs=1&output=search&kgmid=/g/11wcbqrx5l&q=Auburn+Oil&shndl=30&shem=lsde&source=sh/x/loc/act/m1/1&kgs=52995d809762cd61">Google</a>
|
<a v-if="settingsStore.settings.link_google" class="link link-hover" :href="settingsStore.settings.link_google" target="_blank">Google</a>
|
||||||
<a class="link link-hover" href="https://auburnoil.com">Website</a>
|
<a v-if="settingsStore.settings.link_website" class="link link-hover" :href="settingsStore.settings.link_website" target="_blank">Website</a>
|
||||||
</nav>
|
</nav>
|
||||||
<nav>
|
<nav>
|
||||||
<h6 class="footer-title">Quick Call</h6>
|
<h6 class="footer-title">Quick Call</h6>
|
||||||
<div class="">WB Hill Tank Springfield - (413) 525-3678</div>
|
<div v-for="(call, idx) in quickCalls" :key="idx">{{ call.name }} - {{ call.phone }}</div>
|
||||||
<div class="">LW Tank Uxbridge - (508) 234-6000</div>
|
|
||||||
<div class="">Trask Tank Worcester - (508) 791-5064</div>
|
|
||||||
<div class="">David Mechanic - (774) 239-3776</div>
|
|
||||||
<div class="">Spring Rebuilders - (508) 799-9342</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
<nav>
|
<nav>
|
||||||
<h6 class="footer-title">Search Shortcuts</h6>
|
<h6 class="footer-title">Search Shortcuts</h6>
|
||||||
@@ -35,12 +31,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<nav>
|
<nav v-if="settingsStore.settings.link_google_review">
|
||||||
<h6 class="footer-title">Google Review</h6>
|
<h6 class="footer-title">Google Review</h6>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<img src="../../assets/images/googlereview.png" alt="Google Review QR" class="h-16 w-auto rounded" />
|
<img src="../../assets/images/googlereview.png" alt="Google Review QR" class="h-16 w-auto rounded" />
|
||||||
<div class="flex flex-col gap-1">
|
<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>
|
<a class="link link-hover text-xs break-all max-w-32">{{ reviewShortUrl }}</a>
|
||||||
<button @click="copyReviewLink" class="btn btn-outline btn-xs">Copy Link</button>
|
<button @click="copyReviewLink" class="btn btn-outline btn-xs">Copy Link</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,10 +46,24 @@
|
|||||||
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
|
import { useSettingsStore } from '../../stores/settings';
|
||||||
|
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
|
||||||
|
const quickCalls = computed(() => settingsStore.parseQuickCalls());
|
||||||
|
|
||||||
|
const reviewShortUrl = computed(() => {
|
||||||
|
const url = settingsStore.settings.link_google_review || '';
|
||||||
|
try {
|
||||||
|
return url.replace(/^https?:\/\//, '');
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const copyReviewLink = async () => {
|
const copyReviewLink = async () => {
|
||||||
const textToCopy = 'https://g.page/r/CZHnPQ85LsMUEBM/review';
|
const textToCopy = settingsStore.settings.link_google_review || '';
|
||||||
|
|
||||||
// Try the modern Clipboard API first (works in secure contexts like HTTPS or localhost)
|
// Try the modern Clipboard API first (works in secure contexts like HTTPS or localhost)
|
||||||
if (navigator.clipboard && window.isSecureContext) {
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
|||||||
@@ -239,7 +239,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Row 3: Stock Ticker -->
|
<!-- Row 3: Stock Ticker -->
|
||||||
<div class="w-full bg-base-300/50 border-b border-base-200 py-1 overflow-hidden">
|
<div v-if="settingsStore.settings.show_ticker" class="w-full bg-base-300/50 border-b border-base-200 py-1 overflow-hidden">
|
||||||
<GlobalMarketTicker />
|
<GlobalMarketTicker />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -351,7 +351,8 @@ import authHeader from '../../services/auth.header'
|
|||||||
import { useSearchStore } from '../../stores/search' // Adjust path if needed
|
import { useSearchStore } from '../../stores/search' // Adjust path if needed
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
import { useThemeStore, AVAILABLE_THEMES } from '../../stores/theme'
|
import { useThemeStore, AVAILABLE_THEMES } from '../../stores/theme'
|
||||||
import GlobalMarketTicker from '../../components/GlobalMarketTicker.vue' // Import Global Ticker
|
import { useSettingsStore } from '../../stores/settings'
|
||||||
|
import GlobalMarketTicker from '../../components/GlobalMarketTicker.vue'
|
||||||
|
|
||||||
// Define the shape of your data for internal type safety
|
// Define the shape of your data for internal type safety
|
||||||
interface User {
|
interface User {
|
||||||
@@ -440,6 +441,7 @@ const testResponse = ref(null as any)
|
|||||||
|
|
||||||
// Stores
|
// Stores
|
||||||
const themeStore = useThemeStore()
|
const themeStore = useThemeStore()
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
const searchStore = computed(() => useSearchStore())
|
const searchStore = computed(() => useSearchStore())
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
<ul class="menu p-4 w-64 min-h-full bg-base-100 text-base-content">
|
<ul class="menu p-4 w-64 min-h-full bg-base-100 text-base-content">
|
||||||
<li class="mb-4 lg-hidden">
|
<li class="mb-4 lg-hidden">
|
||||||
<router-link :to="{ name: 'home' }">
|
<router-link :to="{ name: 'home' }">
|
||||||
<img src="../../assets/images/1.png" alt="Company Logo" class="h-10 w-auto" />
|
<img v-if="settingsStore.logoDataUrl()" :src="settingsStore.logoDataUrl()!" alt="Company Logo" class="h-10 w-auto" />
|
||||||
|
<img v-else src="../../assets/images/1.png" alt="Company Logo" class="h-10 w-auto" />
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
@@ -83,7 +84,7 @@
|
|||||||
</li>
|
</li>
|
||||||
|
|
||||||
<!-- Service Section -->
|
<!-- Service Section -->
|
||||||
<li>
|
<li v-if="settingsStore.settings.show_service">
|
||||||
<details open>
|
<details open>
|
||||||
<summary class="font-bold text-lg gap-3">
|
<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">
|
<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">
|
||||||
@@ -112,7 +113,7 @@
|
|||||||
</li>
|
</li>
|
||||||
|
|
||||||
<!-- Automatics Section -->
|
<!-- Automatics Section -->
|
||||||
<li>
|
<li v-if="settingsStore.settings.show_automatics">
|
||||||
<details>
|
<details>
|
||||||
<summary class="font-bold text-lg gap-3">
|
<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">
|
<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">
|
||||||
@@ -170,12 +171,13 @@
|
|||||||
<li><router-link :to="{ name: 'employee' }" exact-active-class="active">Employees</router-link></li>
|
<li><router-link :to="{ name: 'employee' }" exact-active-class="active">Employees</router-link></li>
|
||||||
<li><router-link :to="{ name: 'oilprice' }" exact-active-class="active">Oil Pricing</router-link></li>
|
<li><router-link :to="{ name: 'oilprice' }" exact-active-class="active">Oil Pricing</router-link></li>
|
||||||
<li><router-link :to="{ name: 'promo' }" exact-active-class="active">Promos</router-link></li>
|
<li><router-link :to="{ name: 'promo' }" exact-active-class="active">Promos</router-link></li>
|
||||||
|
<li><router-link :to="{ name: 'settings' }" exact-active-class="active">Settings</router-link></li>
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<!-- Stats Section -->
|
<!-- Stats Section -->
|
||||||
<li>
|
<li v-if="settingsStore.settings.show_stats">
|
||||||
<details>
|
<details>
|
||||||
<summary class="font-bold text-lg gap-3">
|
<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">
|
<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">
|
||||||
@@ -194,10 +196,11 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue';
|
import { onMounted } from 'vue';
|
||||||
import { useCountsStore } from '../../stores/counts'; // Adjust path if needed
|
import { useCountsStore } from '../../stores/counts';
|
||||||
|
import { useSettingsStore } from '../../stores/settings';
|
||||||
|
|
||||||
// Get a reference to our new store
|
|
||||||
const countsStore = useCountsStore();
|
const countsStore = useCountsStore();
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
|
||||||
// When the sidebar is first mounted, fetch all the counts
|
// When the sidebar is first mounted, fetch all the counts
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
<div class="lg:col-span-8 space-y-6">
|
<div class="lg:col-span-8 space-y-6">
|
||||||
|
|
||||||
<!-- Stats Row: Quick Glance Cards -->
|
<!-- Stats Row: Quick Glance Cards -->
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<!-- Today's Deliveries -->
|
<!-- Today's Deliveries -->
|
||||||
<div class="stat-card group">
|
<div class="stat-card group">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Quick Actions
|
Quick Actions
|
||||||
</h3>
|
</h3>
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
<router-link :to="{ name: 'customerCreate' }" class="quick-action-btn">
|
<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">
|
<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" />
|
<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" />
|
||||||
@@ -612,7 +612,13 @@ const fetchWeeklyChartData = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.quick-action-btn {
|
.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;
|
@apply flex flex-row items-center gap-2 p-3 rounded-lg bg-base-200/50 hover:bg-primary/10 hover:text-primary transition-all duration-200 text-sm font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.quick-action-btn {
|
||||||
|
@apply flex-col justify-center gap-1 text-xs;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Leaflet fixes */
|
/* Leaflet fixes */
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const PromoCreate = () => import('../admin/promo/create.vue');
|
|||||||
const PromoEdit = () => import('../admin/promo/edit.vue');
|
const PromoEdit = () => import('../admin/promo/edit.vue');
|
||||||
|
|
||||||
const StatsHome = () => import('../admin/stats/StatsHome.vue');
|
const StatsHome = () => import('../admin/stats/StatsHome.vue');
|
||||||
|
const SettingsPage = () => import('../admin/settings/SettingsPage.vue');
|
||||||
|
|
||||||
|
|
||||||
const adminRoutes = [
|
const adminRoutes = [
|
||||||
@@ -37,6 +38,11 @@ const adminRoutes = [
|
|||||||
name: 'promo',
|
name: 'promo',
|
||||||
component: Promo,
|
component: Promo,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
name: 'settings',
|
||||||
|
component: SettingsPage,
|
||||||
|
},
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
112
src/pages/admin/settings/SettingsCompany.vue
Normal file
112
src/pages/admin/settings/SettingsCompany.vue
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h3 class="text-lg font-bold">Company Info & Links</h3>
|
||||||
|
|
||||||
|
<!-- Company Name -->
|
||||||
|
<div class="form-control w-full max-w-md">
|
||||||
|
<label class="label"><span class="label-text font-semibold">Company Name</span></label>
|
||||||
|
<input type="text" v-model="form.company_name" class="input input-bordered w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Social Links -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Facebook URL</span></label>
|
||||||
|
<input type="url" v-model="form.link_facebook" class="input input-bordered" placeholder="https://facebook.com/..." />
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Google URL</span></label>
|
||||||
|
<input type="url" v-model="form.link_google" class="input input-bordered" placeholder="https://google.com/..." />
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Website URL</span></label>
|
||||||
|
<input type="url" v-model="form.link_website" class="input input-bordered" placeholder="https://..." />
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Google Review URL</span></label>
|
||||||
|
<input type="url" v-model="form.link_google_review" class="input input-bordered" placeholder="https://g.page/..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Calls -->
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold mb-2">Quick Call Numbers</h4>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div v-for="(call, index) in quickCalls" :key="index" class="flex gap-2">
|
||||||
|
<input type="text" v-model="call.name" class="input input-bordered input-sm flex-1" placeholder="Name" />
|
||||||
|
<input type="text" v-model="call.phone" class="input input-bordered input-sm w-40" placeholder="Phone" />
|
||||||
|
<button class="btn btn-ghost btn-sm btn-square" @click="removeCall(index)">
|
||||||
|
<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 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button v-if="quickCalls.length < 10" class="btn btn-ghost btn-sm mt-2" @click="addCall">+ Add Number</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button class="btn btn-primary" @click="save" :disabled="saving">
|
||||||
|
<span v-if="saving" class="loading loading-spinner loading-xs"></span>
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="error" class="alert alert-error alert-sm">{{ error }}</div>
|
||||||
|
<div v-if="success" class="alert alert-success alert-sm">{{ success }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { useSettingsStore } from '../../../stores/settings'
|
||||||
|
import type { QuickCallEntry } from '../../../types/models'
|
||||||
|
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
const saving = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const success = ref('')
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
company_name: '',
|
||||||
|
link_facebook: '',
|
||||||
|
link_google: '',
|
||||||
|
link_website: '',
|
||||||
|
link_google_review: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const quickCalls = ref<QuickCallEntry[]>([])
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
form.company_name = settingsStore.settings.company_name || ''
|
||||||
|
form.link_facebook = settingsStore.settings.link_facebook || ''
|
||||||
|
form.link_google = settingsStore.settings.link_google || ''
|
||||||
|
form.link_website = settingsStore.settings.link_website || ''
|
||||||
|
form.link_google_review = settingsStore.settings.link_google_review || ''
|
||||||
|
quickCalls.value = settingsStore.parseQuickCalls()
|
||||||
|
})
|
||||||
|
|
||||||
|
function addCall() {
|
||||||
|
quickCalls.value.push({ name: '', phone: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCall(index: number) {
|
||||||
|
quickCalls.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
error.value = ''
|
||||||
|
success.value = ''
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await settingsStore.updateSettings({
|
||||||
|
...form,
|
||||||
|
quick_calls: JSON.stringify(quickCalls.value.filter(c => c.name || c.phone)),
|
||||||
|
} as Record<string, unknown>)
|
||||||
|
success.value = 'Settings saved'
|
||||||
|
} catch {
|
||||||
|
error.value = 'Failed to save settings'
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
77
src/pages/admin/settings/SettingsLogo.vue
Normal file
77
src/pages/admin/settings/SettingsLogo.vue
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="text-lg font-bold">Logo</h3>
|
||||||
|
|
||||||
|
<!-- Preview -->
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-24 h-24 border border-base-300 rounded-lg flex items-center justify-center bg-base-200 overflow-hidden">
|
||||||
|
<img v-if="settingsStore.logoDataUrl()" :src="settingsStore.logoDataUrl()!" alt="Logo" class="max-w-full max-h-full object-contain" />
|
||||||
|
<img v-else src="../../../assets/images/1.png" alt="Default Logo" class="max-w-full max-h-full object-contain" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-sm opacity-70">Upload a custom logo (PNG, JPG, SVG). Max 2MB.</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="file" ref="fileInput" accept="image/*" class="hidden" @change="onFileSelected" />
|
||||||
|
<button class="btn btn-primary btn-sm" @click="($refs.fileInput as HTMLInputElement).click()" :disabled="uploading">
|
||||||
|
<span v-if="uploading" class="loading loading-spinner loading-xs"></span>
|
||||||
|
Upload
|
||||||
|
</button>
|
||||||
|
<button v-if="settingsStore.settings.logo_base64" class="btn btn-error btn-sm btn-outline" @click="removeLogo" :disabled="uploading">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="error" class="alert alert-error alert-sm">{{ error }}</div>
|
||||||
|
<div v-if="success" class="alert alert-success alert-sm">{{ success }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useSettingsStore } from '../../../stores/settings'
|
||||||
|
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const uploading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const success = ref('')
|
||||||
|
|
||||||
|
async function onFileSelected(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
if (!input.files || !input.files[0]) return
|
||||||
|
|
||||||
|
const file = input.files[0]
|
||||||
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
|
error.value = 'File too large. Maximum size is 2MB.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
error.value = ''
|
||||||
|
success.value = ''
|
||||||
|
uploading.value = true
|
||||||
|
try {
|
||||||
|
await settingsStore.uploadLogo(file)
|
||||||
|
success.value = 'Logo uploaded successfully'
|
||||||
|
} catch {
|
||||||
|
error.value = 'Failed to upload logo'
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeLogo() {
|
||||||
|
error.value = ''
|
||||||
|
success.value = ''
|
||||||
|
uploading.value = true
|
||||||
|
try {
|
||||||
|
await settingsStore.deleteLogo()
|
||||||
|
success.value = 'Logo removed'
|
||||||
|
} catch {
|
||||||
|
error.value = 'Failed to remove logo'
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
33
src/pages/admin/settings/SettingsPage.vue
Normal file
33
src/pages/admin/settings/SettingsPage.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<h1 class="text-2xl font-bold mb-6">Settings</h1>
|
||||||
|
|
||||||
|
<!-- DaisyUI Boxed Tabs -->
|
||||||
|
<div role="tablist" class="tabs tabs-boxed mb-6">
|
||||||
|
<a role="tab" class="tab" :class="{ 'tab-active': activeTab === 'logo' }" @click="activeTab = 'logo'">Logo</a>
|
||||||
|
<a role="tab" class="tab" :class="{ 'tab-active': activeTab === 'company' }" @click="activeTab = 'company'">Company</a>
|
||||||
|
<a role="tab" class="tab" :class="{ 'tab-active': activeTab === 'visibility' }" @click="activeTab = 'visibility'">Visibility</a>
|
||||||
|
<a role="tab" class="tab" :class="{ 'tab-active': activeTab === 'theme' }" @click="activeTab = 'theme'">Theme</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Content -->
|
||||||
|
<div class="card bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<SettingsLogo v-if="activeTab === 'logo'" />
|
||||||
|
<SettingsCompany v-if="activeTab === 'company'" />
|
||||||
|
<SettingsVisibility v-if="activeTab === 'visibility'" />
|
||||||
|
<SettingsTheme v-if="activeTab === 'theme'" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import SettingsLogo from './SettingsLogo.vue'
|
||||||
|
import SettingsCompany from './SettingsCompany.vue'
|
||||||
|
import SettingsVisibility from './SettingsVisibility.vue'
|
||||||
|
import SettingsTheme from './SettingsTheme.vue'
|
||||||
|
|
||||||
|
const activeTab = ref('logo')
|
||||||
|
</script>
|
||||||
53
src/pages/admin/settings/SettingsTheme.vue
Normal file
53
src/pages/admin/settings/SettingsTheme.vue
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h3 class="text-lg font-bold">Default Theme</h3>
|
||||||
|
<p class="text-sm opacity-70">Set the default theme for users who haven't chosen one. Users can still override with their own preference.</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<label v-for="theme in AVAILABLE_THEMES" :key="theme.name"
|
||||||
|
class="flex items-center gap-2 p-3 rounded-lg border-2 cursor-pointer transition-colors"
|
||||||
|
:class="selectedTheme === theme.name ? 'border-primary bg-primary/10' : 'border-base-300 hover:border-base-content/30'">
|
||||||
|
<input type="radio" :value="theme.name" v-model="selectedTheme" class="radio radio-primary radio-sm" />
|
||||||
|
<span class="w-5 h-5 rounded-full border border-base-content/20" :style="{ background: theme.preview }"></span>
|
||||||
|
<span class="font-medium">{{ theme.label }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary" @click="save" :disabled="saving">
|
||||||
|
<span v-if="saving" class="loading loading-spinner loading-xs"></span>
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
<div v-if="error" class="alert alert-error alert-sm">{{ error }}</div>
|
||||||
|
<div v-if="success" class="alert alert-success alert-sm">{{ success }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useSettingsStore } from '../../../stores/settings'
|
||||||
|
import { AVAILABLE_THEMES } from '../../../stores/theme'
|
||||||
|
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
const saving = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const success = ref('')
|
||||||
|
const selectedTheme = ref('dark')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
selectedTheme.value = settingsStore.settings.default_theme || 'dark'
|
||||||
|
})
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
error.value = ''
|
||||||
|
success.value = ''
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await settingsStore.updateSettings({ default_theme: selectedTheme.value })
|
||||||
|
success.value = 'Default theme saved'
|
||||||
|
} catch {
|
||||||
|
error.value = 'Failed to save theme'
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
78
src/pages/admin/settings/SettingsVisibility.vue
Normal file
78
src/pages/admin/settings/SettingsVisibility.vue
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h3 class="text-lg font-bold">Section Visibility</h3>
|
||||||
|
<p class="text-sm opacity-70">Toggle which sidebar sections are visible to all users.</p>
|
||||||
|
|
||||||
|
<div class="space-y-4 max-w-md">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label cursor-pointer">
|
||||||
|
<span class="label-text font-medium">Automatics Section</span>
|
||||||
|
<input type="checkbox" v-model="form.show_automatics" class="toggle toggle-primary" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label cursor-pointer">
|
||||||
|
<span class="label-text font-medium">Service Section</span>
|
||||||
|
<input type="checkbox" v-model="form.show_service" class="toggle toggle-primary" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label cursor-pointer">
|
||||||
|
<span class="label-text font-medium">Stats Section</span>
|
||||||
|
<input type="checkbox" v-model="form.show_stats" class="toggle toggle-primary" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label cursor-pointer">
|
||||||
|
<span class="label-text font-medium">Market Ticker (Header)</span>
|
||||||
|
<input type="checkbox" v-model="form.show_ticker" class="toggle toggle-primary" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary" @click="save" :disabled="saving">
|
||||||
|
<span v-if="saving" class="loading loading-spinner loading-xs"></span>
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
<div v-if="error" class="alert alert-error alert-sm">{{ error }}</div>
|
||||||
|
<div v-if="success" class="alert alert-success alert-sm">{{ success }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { useSettingsStore } from '../../../stores/settings'
|
||||||
|
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
const saving = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const success = ref('')
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
show_automatics: true,
|
||||||
|
show_service: true,
|
||||||
|
show_stats: true,
|
||||||
|
show_ticker: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
form.show_automatics = settingsStore.settings.show_automatics
|
||||||
|
form.show_service = settingsStore.settings.show_service
|
||||||
|
form.show_stats = settingsStore.settings.show_stats
|
||||||
|
form.show_ticker = settingsStore.settings.show_ticker
|
||||||
|
})
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
error.value = ''
|
||||||
|
success.value = ''
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await settingsStore.updateSettings({ ...form })
|
||||||
|
success.value = 'Visibility settings saved'
|
||||||
|
} catch {
|
||||||
|
error.value = 'Failed to save settings'
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -49,14 +49,7 @@
|
|||||||
{{ v$.CreateCustomerForm.customer_phone_number.$errors[0].$message }}
|
{{ v$.CreateCustomerForm.customer_phone_number.$errors[0].$message }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- 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="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>
|
|
||||||
</div>
|
|
||||||
<!-- Customer Type -->
|
<!-- Customer Type -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Customer Type</span></label>
|
<label class="label"><span class="label-text">Customer Type</span></label>
|
||||||
@@ -131,7 +124,7 @@
|
|||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">State</span></label>
|
<label class="label"><span class="label-text">State</span></label>
|
||||||
<select v-model="CreateCustomerForm.customer_state" :class="selectClasses(v$.CreateCustomerForm.customer_state)">
|
<select v-model="CreateCustomerForm.customer_state" :class="selectClasses(v$.CreateCustomerForm.customer_state)">
|
||||||
<option disabled :value="0">Select a state</option>
|
<option disabled value="">Select a state</option>
|
||||||
<option v-for="state in stateList" :key="state.value" :value="state.value">
|
<option v-for="state in stateList" :key="state.value" :value="state.value">
|
||||||
{{ state.text }}
|
{{ state.text }}
|
||||||
</option>
|
</option>
|
||||||
@@ -280,7 +273,7 @@ const CreateCustomerForm = ref({
|
|||||||
customer_address: "",
|
customer_address: "",
|
||||||
customer_apt: "",
|
customer_apt: "",
|
||||||
customer_zip: "",
|
customer_zip: "",
|
||||||
customer_email: "",
|
|
||||||
customer_phone_number: "",
|
customer_phone_number: "",
|
||||||
customer_description: "",
|
customer_description: "",
|
||||||
customer_home_type: 0,
|
customer_home_type: 0,
|
||||||
@@ -315,7 +308,7 @@ const rules = {
|
|||||||
customer_first_name: { required, minLength: minLength(1) },
|
customer_first_name: { required, minLength: minLength(1) },
|
||||||
customer_town: { required, minLength: minLength(1) },
|
customer_town: { required, minLength: minLength(1) },
|
||||||
customer_zip: { required, minLength: minLength(5) },
|
customer_zip: { required, minLength: minLength(5) },
|
||||||
customer_email: { email },
|
|
||||||
customer_phone_number: { required },
|
customer_phone_number: { required },
|
||||||
customer_home_type: { required },
|
customer_home_type: { required },
|
||||||
customer_state: { required },
|
customer_state: { required },
|
||||||
|
|||||||
@@ -32,6 +32,14 @@
|
|||||||
<span class="stat-pill-value">{{ customer_count }}</span>
|
<span class="stat-pill-value">{{ customer_count }}</span>
|
||||||
<span class="stat-pill-label">Total Customers</span>
|
<span class="stat-pill-label">Total Customers</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-pill">
|
||||||
|
<span class="stat-pill-value">{{ active_past_year_count }}</span>
|
||||||
|
<span class="stat-pill-label">Active Past Year</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-pill">
|
||||||
|
<span class="stat-pill-value">{{ dedicated_count }}</span>
|
||||||
|
<span class="stat-pill-label">Dedicated</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -148,7 +156,7 @@
|
|||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<router-link v-if="person.id" :to="{ name: 'customerProfile', params: { id: person.id } }"
|
<router-link v-if="person.id" :to="{ name: 'customerProfile', params: { id: person.id } }"
|
||||||
class="font-bold text-base link link-hover text-primary">
|
class="font-bold text-base link-hover text-base-content">
|
||||||
{{ person.customer_first_name }} {{ person.customer_last_name }}
|
{{ person.customer_first_name }} {{ person.customer_last_name }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<div v-else class="font-bold text-base">{{ person.customer_first_name }} {{ person.customer_last_name
|
<div v-else class="font-bold text-base">{{ person.customer_first_name }} {{ person.customer_last_name
|
||||||
@@ -173,11 +181,11 @@
|
|||||||
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" />
|
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>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<p>{{ person.customer_address }}</p>
|
<p class="text-base-content">{{ person.customer_address }}</p>
|
||||||
<p class="text-xs opacity-70">{{ person.customer_town }}, {{ getStateAbbr(person.customer_state) }}
|
<p class="text-xs text-base-content/70">{{ person.customer_town }}, {{ getStateAbbr(person.customer_state) }}
|
||||||
{{ person.customer_zip }}</p>
|
{{ 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}`)}`"
|
<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">
|
target="_blank" class="text-base-content/60 hover:text-base-content text-xs mt-1 block">
|
||||||
Open in Maps
|
Open in Maps
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -239,6 +247,8 @@ const token = ref(null)
|
|||||||
const user = ref(null)
|
const user = ref(null)
|
||||||
const customers = ref<Customer[]>([])
|
const customers = ref<Customer[]>([])
|
||||||
const customer_count = ref(0)
|
const customer_count = ref(0)
|
||||||
|
const active_past_year_count = ref(0)
|
||||||
|
const dedicated_count = ref(0)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const perPage = ref(50)
|
const perPage = ref(50)
|
||||||
const recordsLength = ref(0)
|
const recordsLength = ref(0)
|
||||||
@@ -305,6 +315,28 @@ const get_customer_count = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const get_active_past_year_count = async () => {
|
||||||
|
try {
|
||||||
|
const response = await customerService.getActiveCount()
|
||||||
|
if (response.data) {
|
||||||
|
active_past_year_count.value = response.data.count
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching active past year count:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const get_dedicated_count = async () => {
|
||||||
|
try {
|
||||||
|
const response = await customerService.getDedicatedCount()
|
||||||
|
if (response.data) {
|
||||||
|
dedicated_count.value = response.data.count
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching dedicated count:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const deleteCustomer = (user_id: number) => {
|
const deleteCustomer = (user_id: number) => {
|
||||||
customerService.delete(user_id).then(() => {
|
customerService.delete(user_id).then(() => {
|
||||||
get_customers(1)
|
get_customers(1)
|
||||||
@@ -315,6 +347,8 @@ const deleteCustomer = (user_id: number) => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
userStatus()
|
userStatus()
|
||||||
getPage(page.value)
|
getPage(page.value)
|
||||||
|
get_active_past_year_count()
|
||||||
|
get_dedicated_count()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -482,16 +482,22 @@ const userAutomatic = (userid: number) => {
|
|||||||
// Toggle status: 1 -> 0, 0 -> 1
|
// Toggle status: 1 -> 0, 0 -> 1
|
||||||
const newStatus = automatic_status.value === 1 ? 0 : 1;
|
const newStatus = automatic_status.value === 1 ? 0 : 1;
|
||||||
customerService.assignAutomatic(userid, { status: newStatus }).then((response: AxiosResponse<any>) => {
|
customerService.assignAutomatic(userid, { status: newStatus }).then((response: AxiosResponse<any>) => {
|
||||||
// Update local status from response or the requested value
|
const returnedStatus = response.data?.status;
|
||||||
if (response.data && typeof response.data.status !== 'undefined') {
|
|
||||||
automatic_status.value = response.data.status;
|
if (returnedStatus === 2) {
|
||||||
} else {
|
// Backend returns 2 when customer has no main credit card on file
|
||||||
automatic_status.value = newStatus;
|
notify({ title: "Cannot Enable Automatic", text: "Customer must have a main credit card on file to enable automatic delivery.", type: "warning" });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (automatic_status.value === 1) {
|
// Normalize: backend returns 1 for automatic, 3 for will call
|
||||||
|
if (returnedStatus === 1) {
|
||||||
|
automatic_status.value = 1;
|
||||||
getCustomerAutoDelivery(customer.value.id);
|
getCustomerAutoDelivery(customer.value.id);
|
||||||
|
} else {
|
||||||
|
automatic_status.value = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
checktotalOil(customer.value.id);
|
checktotalOil(customer.value.id);
|
||||||
notify({
|
notify({
|
||||||
title: "Automatic Status Updated",
|
title: "Automatic Status Updated",
|
||||||
|
|||||||
@@ -117,71 +117,15 @@
|
|||||||
</l-map>
|
</l-map>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Grouped List -->
|
<!-- Address List for Copy/Paste -->
|
||||||
<div class="space-y-4">
|
<div class="bg-base-100 rounded-lg p-4">
|
||||||
<div v-for="(townDeliveries, town) in groupedByTown" :key="town" class="collapse collapse-arrow bg-base-100">
|
<div class="flex justify-between items-center mb-3">
|
||||||
<input type="checkbox" checked />
|
<h3 class="text-md font-semibold">Addresses</h3>
|
||||||
<div class="collapse-title text-lg font-medium">
|
<button @click="copyAddresses" class="btn btn-sm btn-primary">
|
||||||
{{ town }}
|
{{ copied ? '✓ Copied!' : 'Copy All' }}
|
||||||
<span class="badge badge-primary ml-2">{{ townDeliveries.length }}</span>
|
</button>
|
||||||
</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>
|
||||||
|
<pre ref="addressList" class="whitespace-pre-wrap text-sm font-mono select-all bg-base-200 rounded p-3">{{ addressText }}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -195,6 +139,7 @@ import "leaflet/dist/leaflet.css";
|
|||||||
import { LMap, LTileLayer, LMarker, LPopup } from "@vue-leaflet/vue-leaflet";
|
import { LMap, LTileLayer, LMarker, LPopup } from "@vue-leaflet/vue-leaflet";
|
||||||
import { deliveryService } from '../../services/deliveryService';
|
import { deliveryService } from '../../services/deliveryService';
|
||||||
import { DeliveryMapItem } from '../../types/models';
|
import { DeliveryMapItem } from '../../types/models';
|
||||||
|
import { STATE_ID_TO_ABBR } from '../../constants/states';
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -237,21 +182,13 @@ const grandTotal = computed(() => {
|
|||||||
return townTotals.value.reduce((sum, t) => sum + t.gallons, 0);
|
return townTotals.value.reduce((sum, t) => sum + t.gallons, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
const groupedByTown = computed(() => {
|
const addressText = computed(() => {
|
||||||
const groups: Record<string, DeliveryMapItem[]> = {};
|
return deliveries.value
|
||||||
for (const delivery of deliveries.value) {
|
.map(d => {
|
||||||
const town = delivery.town || 'Unknown';
|
const stateAbbr = STATE_ID_TO_ABBR[Number(d.state)] || d.state;
|
||||||
if (!groups[town]) {
|
return `${d.street} ${d.town} ${stateAbbr}`;
|
||||||
groups[town] = [];
|
})
|
||||||
}
|
.join('\n');
|
||||||
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]>(() => {
|
const mapCenter = computed<[number, number]>(() => {
|
||||||
@@ -266,7 +203,20 @@ const mapCenter = computed<[number, number]>(() => {
|
|||||||
return [avgLat, avgLng];
|
return [avgLat, avgLng];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// State for copy button
|
||||||
|
const copied = ref(false);
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
|
const copyAddresses = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(addressText.value);
|
||||||
|
copied.value = true;
|
||||||
|
setTimeout(() => { copied.value = false; }, 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy addresses:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchDeliveries = async () => {
|
const fetchDeliveries = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
|||||||
@@ -355,8 +355,9 @@ const finalChargeAmount = computed((): number => {
|
|||||||
// If promo is active, use server-calculated totals with fees added
|
// If promo is active, use server-calculated totals with fees added
|
||||||
if (promo_active.value && total_amount_after_discount.value > 0) {
|
if (promo_active.value && total_amount_after_discount.value > 0) {
|
||||||
let total = total_amount_after_discount.value;
|
let total = total_amount_after_discount.value;
|
||||||
if (deliveryOrder.value.prime === 1) total += Number(pricing.value.price_prime);
|
if (deliveryOrder.value.prime === 1) total += Number(getTierPrice('prime', deliveryOrder.value.pricing_tier_prime));
|
||||||
if (deliveryOrder.value.same_day === 1) total += Number(pricing.value.price_same_day);
|
if (deliveryOrder.value.same_day === 1) total += Number(getTierPrice('same_day', deliveryOrder.value.pricing_tier_same_day));
|
||||||
|
if (deliveryOrder.value.emergency === 1) total += Number(getTierPrice('emergency', deliveryOrder.value.pricing_tier_emergency));
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -356,7 +356,7 @@ const getAutoTicket = async (delivery_id: any) => {
|
|||||||
|
|
||||||
const getAutoDelivery = async (delivery_id: any) => {
|
const getAutoDelivery = async (delivery_id: any) => {
|
||||||
try {
|
try {
|
||||||
const response = await deliveryService.auto.findDelivery(Number(delivery_id));
|
const response = await deliveryService.auto.getDelivery(Number(delivery_id));
|
||||||
const delivery = response.data?.delivery || response.data as any;
|
const delivery = response.data?.delivery || response.data as any;
|
||||||
if (delivery && delivery.customer_id) {
|
if (delivery && delivery.customer_id) {
|
||||||
autoDelivery.value = delivery;
|
autoDelivery.value = delivery;
|
||||||
|
|||||||
@@ -38,16 +38,16 @@
|
|||||||
<span>${{ calculateSubtotal() }}</span>
|
<span>${{ calculateSubtotal() }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="delivery.prime == 1" class="flex justify-between text-sm">
|
<div v-if="delivery.prime == 1" class="flex justify-between text-sm">
|
||||||
<span>Prime Fee:</span>
|
<span>Prime Fee (Tier {{ delivery.pricing_tier_prime || 1 }}):</span>
|
||||||
<span>+ ${{ pricing.price_prime || 0 }}</span>
|
<span>+ ${{ Number(getTierPrice('prime', delivery.pricing_tier_prime)).toFixed(2) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="delivery.same_day == 1" class="flex justify-between text-sm">
|
<div v-if="delivery.same_day == 1" class="flex justify-between text-sm">
|
||||||
<span>Same Day Fee:</span>
|
<span>Same Day Fee (Tier {{ delivery.pricing_tier_same_day || 1 }}):</span>
|
||||||
<span>+ ${{ pricing.price_same_day || 0 }}</span>
|
<span>+ ${{ Number(getTierPrice('same_day', delivery.pricing_tier_same_day)).toFixed(2) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="delivery.emergency == 1" class="flex justify-between text-sm">
|
<div v-if="delivery.emergency == 1" class="flex justify-between text-sm">
|
||||||
<span>Emergency Fee:</span>
|
<span>Emergency Fee (Tier {{ delivery.pricing_tier_emergency || 1 }}):</span>
|
||||||
<span>+ ${{ pricing.price_emergency || 0 }}</span>
|
<span>+ ${{ Number(getTierPrice('emergency', delivery.pricing_tier_emergency)).toFixed(2) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="promo_active" class="flex justify-between text-success">
|
<div v-if="promo_active" class="flex justify-between text-success">
|
||||||
<span>{{ promo.name_of_promotion }}:</span>
|
<span>{{ promo.name_of_promotion }}:</span>
|
||||||
@@ -347,10 +347,7 @@ const updatestatus = () => {
|
|||||||
|
|
||||||
const updateChargeAmount = () => {
|
const updateChargeAmount = () => {
|
||||||
// Only update if we have all necessary data
|
// Only update if we have all necessary data
|
||||||
if (total_amount_after_discount.value > 0 &&
|
if (total_amount_after_discount.value > 0 && pricing.value) {
|
||||||
pricing.value.price_prime !== undefined &&
|
|
||||||
pricing.value.price_same_day !== undefined &&
|
|
||||||
pricing.value.price_emergency !== undefined) {
|
|
||||||
chargeAmount.value = calculateTotalAsNumber();
|
chargeAmount.value = calculateTotalAsNumber();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -499,6 +496,14 @@ const getCustomer = (userid: number) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getTierPrice = (serviceType: string, tier: number | undefined): number => {
|
||||||
|
const tierNum = tier || 1;
|
||||||
|
const key = `price_${serviceType}_tier${tierNum}`;
|
||||||
|
const legacyKey = `price_${serviceType}`;
|
||||||
|
const price = (pricing.value as any)[key] !== undefined ? (pricing.value as any)[key] : (pricing.value as any)[legacyKey];
|
||||||
|
return Number(price || 0);
|
||||||
|
}
|
||||||
|
|
||||||
const calculateSubtotal = () => {
|
const calculateSubtotal = () => {
|
||||||
const gallons = delivery.value.gallons_ordered || 0
|
const gallons = delivery.value.gallons_ordered || 0
|
||||||
const pricePerGallon = delivery.value.customer_price || 0
|
const pricePerGallon = delivery.value.customer_price || 0
|
||||||
@@ -515,14 +520,14 @@ const calculateTotalAmount = () => {
|
|||||||
return '0.00';
|
return '0.00';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (delivery.value && delivery.value.prime == 1 && pricing.value && pricing.value.price_prime) {
|
if (delivery.value && delivery.value.prime == 1) {
|
||||||
totalNum += Number(pricing.value.price_prime) || 0;
|
totalNum += getTierPrice('prime', delivery.value.pricing_tier_prime);
|
||||||
}
|
}
|
||||||
if (delivery.value && delivery.value.same_day == 1 && pricing.value && pricing.value.price_same_day) {
|
if (delivery.value && delivery.value.same_day == 1) {
|
||||||
totalNum += Number(pricing.value.price_same_day) || 0;
|
totalNum += getTierPrice('same_day', delivery.value.pricing_tier_same_day);
|
||||||
}
|
}
|
||||||
if (delivery.value && delivery.value.emergency == 1 && pricing.value && pricing.value.price_emergency) {
|
if (delivery.value && delivery.value.emergency == 1) {
|
||||||
totalNum += Number(pricing.value.price_emergency) || 0;
|
totalNum += getTierPrice('emergency', delivery.value.pricing_tier_emergency);
|
||||||
}
|
}
|
||||||
|
|
||||||
return totalNum.toFixed(2);
|
return totalNum.toFixed(2);
|
||||||
@@ -538,14 +543,14 @@ const calculateTotalAsNumber = () => {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (delivery.value && delivery.value.prime == 1 && pricing.value && pricing.value.price_prime) {
|
if (delivery.value && delivery.value.prime == 1) {
|
||||||
totalNum += Number(pricing.value.price_prime) || 0;
|
totalNum += getTierPrice('prime', delivery.value.pricing_tier_prime);
|
||||||
}
|
}
|
||||||
if (delivery.value && delivery.value.same_day == 1 && pricing.value && pricing.value.price_same_day) {
|
if (delivery.value && delivery.value.same_day == 1) {
|
||||||
totalNum += Number(pricing.value.price_same_day) || 0;
|
totalNum += getTierPrice('same_day', delivery.value.pricing_tier_same_day);
|
||||||
}
|
}
|
||||||
if (delivery.value && delivery.value.emergency == 1 && pricing.value && pricing.value.price_emergency) {
|
if (delivery.value && delivery.value.emergency == 1) {
|
||||||
totalNum += Number(pricing.value.price_emergency) || 0;
|
totalNum += getTierPrice('emergency', delivery.value.pricing_tier_emergency);
|
||||||
}
|
}
|
||||||
|
|
||||||
return totalNum;
|
return totalNum;
|
||||||
|
|||||||
@@ -194,16 +194,16 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="delivery.prime == 1" class="flex justify-between text-sm">
|
<div v-if="delivery.prime == 1" class="flex justify-between text-sm">
|
||||||
<span>Prime Fee</span>
|
<span>Prime Fee (Tier {{ delivery.pricing_tier_prime || 1 }})</span>
|
||||||
<span>+ ${{ pricing.price_prime }}</span>
|
<span>+ ${{ Number(getTierPrice('prime', delivery.pricing_tier_prime)).toFixed(2) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="delivery.emergency == 1" class="flex justify-between text-sm">
|
<div v-if="delivery.emergency == 1" class="flex justify-between text-sm">
|
||||||
<span>Emergency Fee</span>
|
<span>Emergency Fee (Tier {{ delivery.pricing_tier_emergency || 1 }})</span>
|
||||||
<span>+ ${{ pricing.price_emergency }}</span>
|
<span>+ ${{ Number(getTierPrice('emergency', delivery.pricing_tier_emergency)).toFixed(2) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="delivery.same_day == 1" class="flex justify-between text-sm">
|
<div v-if="delivery.same_day == 1" class="flex justify-between text-sm">
|
||||||
<span>Same Day Fee</span>
|
<span>Same Day Fee (Tier {{ delivery.pricing_tier_same_day || 1 }})</span>
|
||||||
<span>+ ${{ pricing.price_same_day }}</span>
|
<span>+ ${{ Number(getTierPrice('same_day', delivery.pricing_tier_same_day)).toFixed(2) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="promo_active" class="flex justify-between text-sm text-success">
|
<div v-if="promo_active" class="flex justify-between text-sm text-success">
|
||||||
<span>Promo: {{ promo.name_of_promotion }}</span>
|
<span>Promo: {{ promo.name_of_promotion }}</span>
|
||||||
@@ -646,6 +646,14 @@ const getCustomer = (userid: number) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getTierPrice = (serviceType: string, tier: number | undefined): number => {
|
||||||
|
const tierNum = tier || 1;
|
||||||
|
const key = `price_${serviceType}_tier${tierNum}` as keyof PricingData;
|
||||||
|
const legacyKey = `price_${serviceType}` as keyof PricingData;
|
||||||
|
const price = (pricing.value as any)[key] !== undefined ? (pricing.value as any)[key] : (pricing.value as any)[legacyKey];
|
||||||
|
return Number(price || 0);
|
||||||
|
}
|
||||||
|
|
||||||
const calculateTotalAmount = () => {
|
const calculateTotalAmount = () => {
|
||||||
if (total_amount_after_discount.value == null || total_amount_after_discount.value === undefined) {
|
if (total_amount_after_discount.value == null || total_amount_after_discount.value === undefined) {
|
||||||
return '0.00';
|
return '0.00';
|
||||||
@@ -656,14 +664,14 @@ const calculateTotalAmount = () => {
|
|||||||
return '0.00';
|
return '0.00';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (delivery.value && delivery.value.prime == 1 && pricing.value && pricing.value.price_prime) {
|
if (delivery.value && delivery.value.prime == 1) {
|
||||||
totalNum += Number(pricing.value.price_prime) || 0;
|
totalNum += getTierPrice('prime', delivery.value.pricing_tier_prime);
|
||||||
}
|
}
|
||||||
if (delivery.value && delivery.value.same_day == 1 && pricing.value && pricing.value.price_same_day) {
|
if (delivery.value && delivery.value.same_day == 1) {
|
||||||
totalNum += Number(pricing.value.price_same_day) || 0;
|
totalNum += getTierPrice('same_day', delivery.value.pricing_tier_same_day);
|
||||||
}
|
}
|
||||||
if (delivery.value && delivery.value.emergency == 1 && pricing.value && pricing.value.price_emergency) {
|
if (delivery.value && delivery.value.emergency == 1) {
|
||||||
totalNum += Number(pricing.value.price_emergency) || 0;
|
totalNum += getTierPrice('emergency', delivery.value.pricing_tier_emergency);
|
||||||
}
|
}
|
||||||
|
|
||||||
return totalNum.toFixed(2);
|
return totalNum.toFixed(2);
|
||||||
|
|||||||
@@ -36,9 +36,21 @@
|
|||||||
<div class="col-span-6">
|
<div class="col-span-6">
|
||||||
<div class="grid grid-cols-12">
|
<div class="grid grid-cols-12">
|
||||||
<div class="col-span-12 ">{{ customer_description.description }}</div>
|
<div class="col-span-12 ">{{ customer_description.description }}</div>
|
||||||
|
<div class="col-span-12 " v-if="delivery.promo_id !== null">Promo: {{ promo_text }}</div>
|
||||||
<div class="col-span-12 "></div>
|
<div class="col-span-12 "></div>
|
||||||
<div class="col-span-12 text-lg">Credit Card</div>
|
<div class="col-span-12 " v-if="delivery.prime == 1">PRIME</div>
|
||||||
<div class="col-span-12" v-if="promo">{{ promo_text }}</div>
|
<div class="col-span-12 " v-if="delivery.same_day == 1">SAME DAY</div>
|
||||||
|
<div class="col-span-12 " v-if="delivery.emergency == 1">EMERGENCY</div>
|
||||||
|
|
||||||
|
<div class="col-span-12 text-lg" v-if="delivery.payment_type == 0">CASH</div>
|
||||||
|
<div class="col-span-12 text-lg" v-else-if="delivery.payment_type == 1">Credit Card</div>
|
||||||
|
<div class="col-span-12 text-lg" v-else-if="delivery.payment_type == 2">Credit Card/Cash</div>
|
||||||
|
<div class="col-span-12 text-lg" v-else-if="delivery.payment_type == 3">Check</div>
|
||||||
|
<div class="col-span-12 text-lg" v-else-if="delivery.payment_type == 4">Other</div>
|
||||||
|
<div class="col-span-12" v-else></div>
|
||||||
|
<div class="col-span-12 " v-if="delivery.customer_asked_for_fill == 0">
|
||||||
|
{{ delivery.gallons_ordered }}</div>
|
||||||
|
<div class="col-span-12 " v-else>Fill</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-6 border-2" v-if="delivery.dispatcher_notes">
|
<div class="col-span-6 border-2" v-if="delivery.dispatcher_notes">
|
||||||
@@ -67,8 +79,10 @@
|
|||||||
<div class="col-span-6 ">
|
<div class="col-span-6 ">
|
||||||
<div v-if="past_deliveries.length > 0">
|
<div v-if="past_deliveries.length > 0">
|
||||||
<div class="col-span-6" v-for="past_delivery in past_deliveries"
|
<div class="col-span-6" v-for="past_delivery in past_deliveries"
|
||||||
:key="past_delivery.fill_date">
|
:key="past_delivery.id">
|
||||||
{{ past_delivery.fill_date }} - {{ past_delivery.gallons_delivered }}
|
<div v-if="past_delivery.gallons_delivered != 0.00">
|
||||||
|
{{ past_delivery.when_delivered }} - {{ past_delivery.gallons_delivered }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
@@ -82,11 +96,30 @@
|
|||||||
<div class="col-span-6 ">
|
<div class="col-span-6 ">
|
||||||
<div class="col-span-4 ">
|
<div class="col-span-4 ">
|
||||||
<div class="grid grid-cols-12 ">
|
<div class="grid grid-cols-12 ">
|
||||||
<div class="col-span-12 h-7 pl-4 pt-2"></div>
|
<div class="col-span-12 h-7 pl-4 pt-2">{{ delivery.when_ordered }}</div>
|
||||||
<div class="col-span-12 h-7 pl-4 pt-2"></div>
|
<div class="col-span-12 h-7 pl-4 pt-2">{{ delivery.expected_delivery_date }}</div>
|
||||||
<div class="col-span-12 h-7 pl-4 pt-2"></div>
|
|
||||||
<div class="col-span-12 h-7 pl-4 pt-2"></div>
|
<div class="col-span-12 h-7 pl-4 pt-2" v-if="delivery.customer_asked_for_fill == 0">
|
||||||
<div class="col-span-12 h-7 pl-4 pt-4"> </div>
|
{{ delivery.gallons_ordered }}</div>
|
||||||
|
<div class="col-span-12 h-7 pl-4 pt-2" v-else></div>
|
||||||
|
|
||||||
|
<div class="col-span-12 h-7 pl-4 pt-2" v-if="promo_active">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="line-through"> {{ delivery.customer_price }}</div> ({{ promoprice}})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 h-7 pl-4 pt-2" v-else>{{ delivery.customer_price }}</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="col-span-12 h-7 pl-4 pt-4" v-if="delivery.customer_asked_for_fill == 0">
|
||||||
|
<div v-if="promo_active">
|
||||||
|
{{ total_amount_after_discount }}
|
||||||
|
</div>
|
||||||
|
<div v-else> {{ total_amount }}</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 h-7 pl-4 pt-4" v-else></div>
|
||||||
|
|
||||||
<div class="col-span-12 h-7 pt-6"></div>
|
<div class="col-span-12 h-7 pt-6"></div>
|
||||||
<div class="col-span-12 h-7"></div>
|
<div class="col-span-12 h-7"></div>
|
||||||
<div class="col-span-12 h-7 pl-8"></div>
|
<div class="col-span-12 h-7 pl-8"></div>
|
||||||
@@ -101,6 +134,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted } from 'vue'
|
import { ref, watch, onMounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
import axios from 'axios'
|
||||||
|
import authHeader from '../../services/auth.header'
|
||||||
import { notify } from "@kyvg/vue3-notification"
|
import { notify } from "@kyvg/vue3-notification"
|
||||||
import { deliveryService } from '../../services/deliveryService'
|
import { deliveryService } from '../../services/deliveryService'
|
||||||
import { customerService } from '../../services/customerService'
|
import { customerService } from '../../services/customerService'
|
||||||
@@ -108,8 +143,9 @@ import { adminService } from '../../services/adminService'
|
|||||||
import { queryService } from '../../services/queryService'
|
import { queryService } from '../../services/queryService'
|
||||||
|
|
||||||
interface PastDelivery {
|
interface PastDelivery {
|
||||||
|
id: number
|
||||||
gallons_delivered: number
|
gallons_delivered: number
|
||||||
fill_date: string
|
when_delivered: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -117,6 +153,11 @@ const route = useRoute()
|
|||||||
// State
|
// State
|
||||||
const loaded = ref(false)
|
const loaded = ref(false)
|
||||||
const past_deliveries = ref<PastDelivery[]>([])
|
const past_deliveries = ref<PastDelivery[]>([])
|
||||||
|
const promo_active = ref(false)
|
||||||
|
const promoprice = ref(0)
|
||||||
|
const total_amount = ref(0)
|
||||||
|
const discount = ref(0)
|
||||||
|
const total_amount_after_discount = ref(0)
|
||||||
const delivery = ref<any>({
|
const delivery = ref<any>({
|
||||||
id: '',
|
id: '',
|
||||||
customer_id: 0,
|
customer_id: 0,
|
||||||
@@ -188,8 +229,10 @@ async function getOrder(deliveryId: string | number) {
|
|||||||
const response = await deliveryService.getById(Number(deliveryId))
|
const response = await deliveryService.getById(Number(deliveryId))
|
||||||
delivery.value = (response.data as any)?.delivery || response.data
|
delivery.value = (response.data as any)?.delivery || response.data
|
||||||
getCustomer(delivery.value.customer_id)
|
getCustomer(delivery.value.customer_id)
|
||||||
if (delivery.value.promo_id) {
|
if (delivery.value.promo_id != null) {
|
||||||
getPromo(delivery.value.promo_id)
|
getPromo(delivery.value.promo_id)
|
||||||
|
promo_active.value = true
|
||||||
|
getPromoPrice(deliveryId)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notify({
|
notify({
|
||||||
@@ -264,6 +307,34 @@ async function getPastDeliveries(userId: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sumdelivery(deliveryId: string | number) {
|
||||||
|
try {
|
||||||
|
const response = await deliveryService.getTotal(Number(deliveryId))
|
||||||
|
if ((response.data as any).ok) {
|
||||||
|
total_amount.value = (response.data as any).total_amount
|
||||||
|
discount.value = (response.data as any).discount
|
||||||
|
total_amount_after_discount.value = (response.data as any).total_amount_after_discount
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch delivery total:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPromoPrice(deliveryId: string | number) {
|
||||||
|
try {
|
||||||
|
const path = import.meta.env.VITE_BASE_URL + '/promo/promoprice/' + deliveryId
|
||||||
|
const response = await axios.get(path, {
|
||||||
|
withCredentials: true,
|
||||||
|
headers: authHeader()
|
||||||
|
})
|
||||||
|
if (response.data) {
|
||||||
|
promoprice.value = response.data.price
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch promo price:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Watchers
|
// Watchers
|
||||||
watch(() => route.params.id, (newId) => {
|
watch(() => route.params.id, (newId) => {
|
||||||
if (newId) {
|
if (newId) {
|
||||||
@@ -277,6 +348,7 @@ onMounted(() => {
|
|||||||
if (route.params.id) {
|
if (route.params.id) {
|
||||||
getOrder(route.params.id as string)
|
getOrder(route.params.id as string)
|
||||||
getTodayPrice()
|
getTodayPrice()
|
||||||
|
sumdelivery(route.params.id as string)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -131,6 +131,26 @@ export const adminService = {
|
|||||||
api.get('/report/customers/list'),
|
api.get('/report/customers/list'),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Admin Settings
|
||||||
|
settings: {
|
||||||
|
get: () =>
|
||||||
|
api.get('/admin/settings'),
|
||||||
|
|
||||||
|
update: (data: Record<string, unknown>) =>
|
||||||
|
api.put('/admin/settings', data),
|
||||||
|
|
||||||
|
uploadLogo: (file: File) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('logo', file);
|
||||||
|
return api.post('/admin/settings/logo', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteLogo: () =>
|
||||||
|
api.delete('/admin/settings/logo'),
|
||||||
|
},
|
||||||
|
|
||||||
// Social/comments
|
// Social/comments
|
||||||
social: {
|
social: {
|
||||||
getPosts: (customerId: number, page: number = 1) =>
|
getPosts: (customerId: number, page: number = 1) =>
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ export const customerService = {
|
|||||||
getCount: (): Promise<AxiosResponse<CountResponse>> =>
|
getCount: (): Promise<AxiosResponse<CountResponse>> =>
|
||||||
api.get('/customer/count'),
|
api.get('/customer/count'),
|
||||||
|
|
||||||
|
getActiveCount: (): Promise<AxiosResponse<CountResponse>> =>
|
||||||
|
api.get('/customer/count/active'),
|
||||||
|
|
||||||
|
getDedicatedCount: (): Promise<AxiosResponse<CountResponse>> =>
|
||||||
|
api.get('/customer/count/dedicated'),
|
||||||
|
|
||||||
// Profile & details
|
// Profile & details
|
||||||
getDescription: (id: number): Promise<AxiosResponse<DescriptionResponse>> =>
|
getDescription: (id: number): Promise<AxiosResponse<DescriptionResponse>> =>
|
||||||
api.get(`/customer/description/${id}`),
|
api.get(`/customer/description/${id}`),
|
||||||
|
|||||||
94
src/stores/settings.ts
Normal file
94
src/stores/settings.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { adminService } from '../services/adminService'
|
||||||
|
import { useThemeStore } from './theme'
|
||||||
|
import type { AdminSettings, QuickCallEntry } from '../types/models'
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: AdminSettings = {
|
||||||
|
id: 0,
|
||||||
|
updated_at: null,
|
||||||
|
logo_base64: null,
|
||||||
|
logo_mime_type: null,
|
||||||
|
company_name: 'Auburn Oil',
|
||||||
|
link_facebook: null,
|
||||||
|
link_google: null,
|
||||||
|
link_website: null,
|
||||||
|
link_google_review: null,
|
||||||
|
quick_calls: null,
|
||||||
|
show_automatics: true,
|
||||||
|
show_stats: true,
|
||||||
|
show_service: true,
|
||||||
|
show_ticker: true,
|
||||||
|
default_theme: 'dark',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSettingsStore = defineStore('settings', () => {
|
||||||
|
const settings = ref<AdminSettings>({ ...DEFAULT_SETTINGS })
|
||||||
|
const loaded = ref(false)
|
||||||
|
|
||||||
|
function parseQuickCalls(): QuickCallEntry[] {
|
||||||
|
if (!settings.value.quick_calls) return []
|
||||||
|
try {
|
||||||
|
return JSON.parse(settings.value.quick_calls) as QuickCallEntry[]
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logoDataUrl(): string | null {
|
||||||
|
if (settings.value.logo_base64 && settings.value.logo_mime_type) {
|
||||||
|
return `data:${settings.value.logo_mime_type};base64,${settings.value.logo_base64}`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSettings() {
|
||||||
|
try {
|
||||||
|
const response = await adminService.settings.get()
|
||||||
|
if (response.data?.settings) {
|
||||||
|
settings.value = response.data.settings
|
||||||
|
loaded.value = true
|
||||||
|
// Re-init theme with server default for users without a localStorage preference
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
themeStore.initTheme(settings.value.default_theme)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.error('Failed to fetch admin settings')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateSettings(data: Partial<AdminSettings>) {
|
||||||
|
const response = await adminService.settings.update(data as Record<string, unknown>)
|
||||||
|
if (response.data?.settings) {
|
||||||
|
settings.value = response.data.settings
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadLogo(file: File) {
|
||||||
|
const response = await adminService.settings.uploadLogo(file)
|
||||||
|
if (response.data?.settings) {
|
||||||
|
settings.value = response.data.settings
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteLogo() {
|
||||||
|
const response = await adminService.settings.deleteLogo()
|
||||||
|
if (response.data?.settings) {
|
||||||
|
settings.value = response.data.settings
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
settings,
|
||||||
|
loaded,
|
||||||
|
parseQuickCalls,
|
||||||
|
logoDataUrl,
|
||||||
|
fetchSettings,
|
||||||
|
updateSettings,
|
||||||
|
uploadLogo,
|
||||||
|
deleteLogo,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -5,14 +5,18 @@ import { defineStore } from 'pinia'
|
|||||||
import type { ThemeOption } from '../types/models'
|
import type { ThemeOption } from '../types/models'
|
||||||
|
|
||||||
const STORAGE_KEY = 'user_theme'
|
const STORAGE_KEY = 'user_theme'
|
||||||
const DEFAULT_THEME = 'ocean'
|
const DEFAULT_THEME = 'dark'
|
||||||
|
|
||||||
export const AVAILABLE_THEMES: ThemeOption[] = [
|
export const AVAILABLE_THEMES: ThemeOption[] = [
|
||||||
{ name: 'ocean', label: 'Ocean', preview: '#ff6600' },
|
{ name: 'dark', label: 'Dark', preview: '#ff6600' },
|
||||||
{ name: 'forest', label: 'Forest', preview: '#4ade80' },
|
{ name: 'vscode-dark', label: 'VS Code Dark', preview: '#569CD6' },
|
||||||
{ name: 'sunset', label: 'Sunset', preview: '#fb923c' },
|
{ name: 'grok-dark', label: 'Grok Dark', preview: '#F05A28' },
|
||||||
{ name: 'arctic', label: 'Arctic', preview: '#06b6d4' },
|
{ name: 'arctic', label: 'Arctic', preview: '#06b6d4' },
|
||||||
{ name: 'midnight', label: 'Midnight', preview: '#a78bfa' },
|
{ name: 'midnight', label: 'Midnight', preview: '#a78bfa' },
|
||||||
|
{ name: 'high-contrast', label: 'High Contrast', preview: '#FFD700' },
|
||||||
|
{ name: 'atom-one-dark', label: 'Atom One Dark', preview: '#61AFEF' },
|
||||||
|
{ name: 'cobalt2', label: 'Cobalt2', preview: '#FFC600' },
|
||||||
|
{ name: 'jellyfish', label: 'Jellyfish', preview: '#FF6AC1' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export const useThemeStore = defineStore('theme', () => {
|
export const useThemeStore = defineStore('theme', () => {
|
||||||
@@ -29,15 +33,16 @@ export const useThemeStore = defineStore('theme', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function initTheme() {
|
function initTheme(serverDefault?: string) {
|
||||||
// Validate stored theme is still valid
|
// Validate stored theme is still valid
|
||||||
const storedTheme = localStorage.getItem(STORAGE_KEY)
|
const storedTheme = localStorage.getItem(STORAGE_KEY)
|
||||||
const validTheme = AVAILABLE_THEMES.find(t => t.name === storedTheme)
|
const validTheme = AVAILABLE_THEMES.find(t => t.name === storedTheme)
|
||||||
if (validTheme) {
|
if (validTheme) {
|
||||||
currentTheme.value = storedTheme!
|
currentTheme.value = storedTheme!
|
||||||
} else {
|
} else {
|
||||||
currentTheme.value = DEFAULT_THEME
|
const fallback = serverDefault || DEFAULT_THEME
|
||||||
localStorage.setItem(STORAGE_KEY, DEFAULT_THEME)
|
currentTheme.value = fallback
|
||||||
|
localStorage.setItem(STORAGE_KEY, fallback)
|
||||||
}
|
}
|
||||||
document.documentElement.setAttribute('data-theme', currentTheme.value)
|
document.documentElement.setAttribute('data-theme', currentTheme.value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -880,3 +880,32 @@ export interface DeliveryHistoryResponse {
|
|||||||
emergencyCount: number;
|
emergencyCount: number;
|
||||||
sameDayCount: number;
|
sameDayCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admin Settings interfaces
|
||||||
|
export interface QuickCallEntry {
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminSettings {
|
||||||
|
id: number;
|
||||||
|
updated_at: string | null;
|
||||||
|
logo_base64: string | null;
|
||||||
|
logo_mime_type: string | null;
|
||||||
|
company_name: string;
|
||||||
|
link_facebook: string | null;
|
||||||
|
link_google: string | null;
|
||||||
|
link_website: string | null;
|
||||||
|
link_google_review: string | null;
|
||||||
|
quick_calls: string | null;
|
||||||
|
show_automatics: boolean;
|
||||||
|
show_stats: boolean;
|
||||||
|
show_service: boolean;
|
||||||
|
show_ticker: boolean;
|
||||||
|
default_theme: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminSettingsResponse {
|
||||||
|
ok: boolean;
|
||||||
|
settings: AdminSettings;
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ module.exports = {
|
|||||||
daisyui: {
|
daisyui: {
|
||||||
themes: [
|
themes: [
|
||||||
{
|
{
|
||||||
ocean: {
|
dark: {
|
||||||
"primary": "#010409",
|
"primary": "#010409",
|
||||||
"secondary": "#161B22",
|
"secondary": "#161B22",
|
||||||
"accent": "#ff6600",
|
"accent": "#ff6600",
|
||||||
@@ -20,33 +20,33 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
forest: {
|
'vscode-dark': {
|
||||||
"primary": "#1a472a",
|
"primary": "#264F78",
|
||||||
"secondary": "#2d5a3d",
|
"secondary": "#37373D",
|
||||||
"accent": "#4ade80",
|
"accent": "#569CD6",
|
||||||
"neutral": "#1e3a2f",
|
"neutral": "#2D2D2D",
|
||||||
"base-100": "#0f1f14",
|
"base-100": "#1E1E1E",
|
||||||
"base-200": "#162a1c",
|
"base-200": "#252526",
|
||||||
"base-300": "#1e3a2f",
|
"base-300": "#2D2D2D",
|
||||||
"info": "#67e8f9",
|
"info": "#4FC1FF",
|
||||||
"success": "#22c55e",
|
"success": "#6A9955",
|
||||||
"warning": "#eab308",
|
"warning": "#CCA700",
|
||||||
"error": "#ef4444",
|
"error": "#F44747",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sunset: {
|
'grok-dark': {
|
||||||
"primary": "#44403c",
|
"primary": "#1A1A1A",
|
||||||
"secondary": "#57534e",
|
"secondary": "#2A2A2A",
|
||||||
"accent": "#fb923c",
|
"accent": "#F05A28",
|
||||||
"neutral": "#292524",
|
"neutral": "#1F1F1F",
|
||||||
"base-100": "#1c1917",
|
"base-100": "#0A0A0A",
|
||||||
"base-200": "#292524",
|
"base-200": "#141414",
|
||||||
"base-300": "#44403c",
|
"base-300": "#1F1F1F",
|
||||||
"info": "#38bdf8",
|
"info": "#6CB4EE",
|
||||||
"success": "#4ade80",
|
"success": "#4ADE80",
|
||||||
"warning": "#fbbf24",
|
"warning": "#FACC15",
|
||||||
"error": "#f87171",
|
"error": "#EF4444",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -79,6 +79,66 @@ module.exports = {
|
|||||||
"error": "#f87171",
|
"error": "#f87171",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
'high-contrast': {
|
||||||
|
"primary": "#FFFFFF",
|
||||||
|
"secondary": "#1A1A1A",
|
||||||
|
"accent": "#FFD700",
|
||||||
|
"neutral": "#1A1A1A",
|
||||||
|
"base-100": "#000000",
|
||||||
|
"base-200": "#0A0A0A",
|
||||||
|
"base-300": "#1A1A1A",
|
||||||
|
"info": "#00BFFF",
|
||||||
|
"success": "#00FF00",
|
||||||
|
"warning": "#FFD700",
|
||||||
|
"error": "#FF0000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'atom-one-dark': {
|
||||||
|
"primary": "#3A3F4B",
|
||||||
|
"secondary": "#4B5263",
|
||||||
|
"accent": "#61AFEF",
|
||||||
|
"neutral": "#3A3F4B",
|
||||||
|
"base-100": "#282C34",
|
||||||
|
"base-200": "#21252B",
|
||||||
|
"base-300": "#2C313A",
|
||||||
|
"info": "#56B6C2",
|
||||||
|
"success": "#98C379",
|
||||||
|
"warning": "#E5C07B",
|
||||||
|
"error": "#E06C75",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'cobalt2': {
|
||||||
|
"primary": "#1A2B4A",
|
||||||
|
"secondary": "#223B6E",
|
||||||
|
"accent": "#FFC600",
|
||||||
|
"neutral": "#1A2B4A",
|
||||||
|
"base-100": "#193549",
|
||||||
|
"base-200": "#122738",
|
||||||
|
"base-300": "#1A2B4A",
|
||||||
|
"info": "#0088FF",
|
||||||
|
"success": "#3AD900",
|
||||||
|
"warning": "#FFC600",
|
||||||
|
"error": "#FF628C",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'jellyfish': {
|
||||||
|
"primary": "#1B1B3A",
|
||||||
|
"secondary": "#2E2E5E",
|
||||||
|
"accent": "#FF6AC1",
|
||||||
|
"neutral": "#232346",
|
||||||
|
"base-100": "#0E0E23",
|
||||||
|
"base-200": "#161636",
|
||||||
|
"base-300": "#232346",
|
||||||
|
"info": "#79E8E8",
|
||||||
|
"success": "#A9DC76",
|
||||||
|
"warning": "#FFD866",
|
||||||
|
"error": "#FF6188",
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
plugins: [require('daisyui')],
|
plugins: [require('daisyui')],
|
||||||
|
|||||||
Reference in New Issue
Block a user