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:
2026-02-27 18:45:12 -05:00
parent 1a53e50d91
commit 203fbc2175
26 changed files with 871 additions and 205 deletions

View File

@@ -34,6 +34,7 @@
<script setup lang="ts">
import { useSearchStore } from '../stores/search';
import { useOilPriceStore } from '../stores/oilPrice'; // [NEW]
import { useSettingsStore } from '../stores/settings';
import HeaderAuth from './headers/headerauth.vue';
import SideBar from './sidebar/sidebar.vue';
import Footer from './footers/footer.vue';
@@ -43,8 +44,10 @@ import { onMounted } from 'vue'; // [NEW]
const searchStore = useSearchStore();
const oilPriceStore = useOilPriceStore(); // [NEW]
const settingsStore = useSettingsStore();
onMounted(() => {
oilPriceStore.fetchPrices(); // [NEW] Global check
settingsStore.fetchSettings();
});
</script>

View File

@@ -2,17 +2,13 @@
<footer class="footer p-10 bg-secondary text-neutral-content">
<nav>
<h6 class="footer-title">Social</h6>
<a class="link link-hover" href="https://www.facebook.com/auburnoil">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 class="link link-hover" href="https://auburnoil.com">Website</a>
<a v-if="settingsStore.settings.link_facebook" class="link link-hover" :href="settingsStore.settings.link_facebook" target="_blank">Facebook</a>
<a v-if="settingsStore.settings.link_google" class="link link-hover" :href="settingsStore.settings.link_google" target="_blank">Google</a>
<a v-if="settingsStore.settings.link_website" class="link link-hover" :href="settingsStore.settings.link_website" target="_blank">Website</a>
</nav>
<nav>
<h6 class="footer-title">Quick Call</h6>
<div class="">WB Hill Tank Springfield - (413) 525-3678</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>
<div v-for="(call, idx) in quickCalls" :key="idx">{{ call.name }} - {{ call.phone }}</div>
</nav>
<nav>
<h6 class="footer-title">Search Shortcuts</h6>
@@ -35,12 +31,12 @@
</div>
</div>
</nav>
<nav>
<nav v-if="settingsStore.settings.link_google_review">
<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>
<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>
</div>
</div>
@@ -50,10 +46,24 @@
<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 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)
if (navigator.clipboard && window.isSecureContext) {

View File

@@ -239,7 +239,7 @@
</div>
</div>
<!-- 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 />
</div>
@@ -351,7 +351,8 @@ import authHeader from '../../services/auth.header'
import { useSearchStore } from '../../stores/search' // Adjust path if needed
import { useAuthStore } from '../../stores/auth'
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
interface User {
@@ -440,6 +441,7 @@ const testResponse = ref(null as any)
// Stores
const themeStore = useThemeStore()
const settingsStore = useSettingsStore()
// Computed properties
const searchStore = computed(() => useSearchStore())

View File

@@ -3,7 +3,8 @@
<ul class="menu p-4 w-64 min-h-full bg-base-100 text-base-content">
<li class="mb-4 lg-hidden">
<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>
</li>
@@ -83,7 +84,7 @@
</li>
<!-- Service Section -->
<li>
<li v-if="settingsStore.settings.show_service">
<details open>
<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">
@@ -112,7 +113,7 @@
</li>
<!-- Automatics Section -->
<li>
<li v-if="settingsStore.settings.show_automatics">
<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">
@@ -170,12 +171,13 @@
<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: 'promo' }" exact-active-class="active">Promos</router-link></li>
<li><router-link :to="{ name: 'settings' }" exact-active-class="active">Settings</router-link></li>
</ul>
</details>
</li>
<!-- Stats Section -->
<li>
<li v-if="settingsStore.settings.show_stats">
<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">
@@ -194,10 +196,11 @@
<script setup lang="ts">
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 settingsStore = useSettingsStore();
// When the sidebar is first mounted, fetch all the counts
onMounted(() => {

View File

@@ -30,7 +30,7 @@
<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">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<!-- Today's Deliveries -->
<div class="stat-card group">
<div class="flex items-center justify-between mb-2">
@@ -188,7 +188,7 @@
</svg>
Quick Actions
</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">
<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" />
@@ -612,7 +612,13 @@ const fetchWeeklyChartData = async () => {
}
.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 */

View File

@@ -7,6 +7,7 @@ const PromoCreate = () => import('../admin/promo/create.vue');
const PromoEdit = () => import('../admin/promo/edit.vue');
const StatsHome = () => import('../admin/stats/StatsHome.vue');
const SettingsPage = () => import('../admin/settings/SettingsPage.vue');
const adminRoutes = [
@@ -37,6 +38,11 @@ const adminRoutes = [
name: 'promo',
component: Promo,
},
{
path: '/settings',
name: 'settings',
component: SettingsPage,
},
]

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

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

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

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

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

View File

@@ -49,14 +49,7 @@
{{ v$.CreateCustomerForm.customer_phone_number.$errors[0].$message }}
</span>
</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 -->
<div class="form-control">
<label class="label"><span class="label-text">Customer Type</span></label>
@@ -131,7 +124,7 @@
<div class="form-control">
<label class="label"><span class="label-text">State</span></label>
<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">
{{ state.text }}
</option>
@@ -280,7 +273,7 @@ const CreateCustomerForm = ref({
customer_address: "",
customer_apt: "",
customer_zip: "",
customer_email: "",
customer_phone_number: "",
customer_description: "",
customer_home_type: 0,
@@ -315,7 +308,7 @@ const rules = {
customer_first_name: { required, minLength: minLength(1) },
customer_town: { required, minLength: minLength(1) },
customer_zip: { required, minLength: minLength(5) },
customer_email: { email },
customer_phone_number: { required },
customer_home_type: { required },
customer_state: { required },

View File

@@ -32,6 +32,14 @@
<span class="stat-pill-value">{{ customer_count }}</span>
<span class="stat-pill-label">Total Customers</span>
</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>
@@ -148,7 +156,7 @@
<div class="flex justify-between items-start">
<div>
<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 }}
</router-link>
<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" />
</svg>
<div>
<p>{{ person.customer_address }}</p>
<p class="text-xs opacity-70">{{ person.customer_town }}, {{ getStateAbbr(person.customer_state) }}
<p class="text-base-content">{{ person.customer_address }}</p>
<p class="text-xs text-base-content/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">
target="_blank" class="text-base-content/60 hover:text-base-content text-xs mt-1 block">
Open in Maps
</a>
</div>
@@ -239,6 +247,8 @@ const token = ref(null)
const user = ref(null)
const customers = ref<Customer[]>([])
const customer_count = ref(0)
const active_past_year_count = ref(0)
const dedicated_count = ref(0)
const page = ref(1)
const perPage = ref(50)
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) => {
customerService.delete(user_id).then(() => {
get_customers(1)
@@ -315,6 +347,8 @@ const deleteCustomer = (user_id: number) => {
onMounted(() => {
userStatus()
getPage(page.value)
get_active_past_year_count()
get_dedicated_count()
})
</script>

View File

@@ -482,16 +482,22 @@ const userAutomatic = (userid: number) => {
// Toggle status: 1 -> 0, 0 -> 1
const newStatus = automatic_status.value === 1 ? 0 : 1;
customerService.assignAutomatic(userid, { status: newStatus }).then((response: AxiosResponse<any>) => {
// Update local status from response or the requested value
if (response.data && typeof response.data.status !== 'undefined') {
automatic_status.value = response.data.status;
} else {
automatic_status.value = newStatus;
const returnedStatus = response.data?.status;
if (returnedStatus === 2) {
// Backend returns 2 when customer has no main credit card on file
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);
} else {
automatic_status.value = 0;
}
checktotalOil(customer.value.id);
notify({
title: "Automatic Status Updated",

View File

@@ -117,71 +117,15 @@
</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>
<!-- Address List for Copy/Paste -->
<div class="bg-base-100 rounded-lg p-4">
<div class="flex justify-between items-center mb-3">
<h3 class="text-md font-semibold">Addresses</h3>
<button @click="copyAddresses" class="btn btn-sm btn-primary">
{{ copied ? '✓ Copied!' : 'Copy All' }}
</button>
</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>
@@ -195,6 +139,7 @@ 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';
import { STATE_ID_TO_ABBR } from '../../constants/states';
// State
const loading = ref(false);
@@ -237,21 +182,13 @@ const grandTotal = computed(() => {
return townTotals.value.reduce((sum, t) => sum + t.gallons, 0);
});
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 addressText = computed(() => {
return deliveries.value
.map(d => {
const stateAbbr = STATE_ID_TO_ABBR[Number(d.state)] || d.state;
return `${d.street} ${d.town} ${stateAbbr}`;
})
.join('\n');
});
const mapCenter = computed<[number, number]>(() => {
@@ -266,7 +203,20 @@ const mapCenter = computed<[number, number]>(() => {
return [avgLat, avgLng];
});
// State for copy button
const copied = ref(false);
// 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 () => {
loading.value = true;
error.value = null;

View File

@@ -355,8 +355,9 @@ const finalChargeAmount = computed((): number => {
// If promo is active, use server-calculated totals with fees added
if (promo_active.value && total_amount_after_discount.value > 0) {
let total = total_amount_after_discount.value;
if (deliveryOrder.value.prime === 1) total += Number(pricing.value.price_prime);
if (deliveryOrder.value.same_day === 1) total += Number(pricing.value.price_same_day);
if (deliveryOrder.value.prime === 1) total += Number(getTierPrice('prime', deliveryOrder.value.pricing_tier_prime));
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;
}

View File

@@ -356,7 +356,7 @@ const getAutoTicket = async (delivery_id: any) => {
const getAutoDelivery = async (delivery_id: any) => {
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;
if (delivery && delivery.customer_id) {
autoDelivery.value = delivery;

View File

@@ -38,16 +38,16 @@
<span>${{ calculateSubtotal() }}</span>
</div>
<div v-if="delivery.prime == 1" class="flex justify-between text-sm">
<span>Prime Fee:</span>
<span>+ ${{ pricing.price_prime || 0 }}</span>
<span>Prime Fee (Tier {{ delivery.pricing_tier_prime || 1 }}):</span>
<span>+ ${{ Number(getTierPrice('prime', delivery.pricing_tier_prime)).toFixed(2) }}</span>
</div>
<div v-if="delivery.same_day == 1" class="flex justify-between text-sm">
<span>Same Day Fee:</span>
<span>+ ${{ pricing.price_same_day || 0 }}</span>
<span>Same Day Fee (Tier {{ delivery.pricing_tier_same_day || 1 }}):</span>
<span>+ ${{ Number(getTierPrice('same_day', delivery.pricing_tier_same_day)).toFixed(2) }}</span>
</div>
<div v-if="delivery.emergency == 1" class="flex justify-between text-sm">
<span>Emergency Fee:</span>
<span>+ ${{ pricing.price_emergency || 0 }}</span>
<span>Emergency Fee (Tier {{ delivery.pricing_tier_emergency || 1 }}):</span>
<span>+ ${{ Number(getTierPrice('emergency', delivery.pricing_tier_emergency)).toFixed(2) }}</span>
</div>
<div v-if="promo_active" class="flex justify-between text-success">
<span>{{ promo.name_of_promotion }}:</span>
@@ -347,10 +347,7 @@ const updatestatus = () => {
const updateChargeAmount = () => {
// Only update if we have all necessary data
if (total_amount_after_discount.value > 0 &&
pricing.value.price_prime !== undefined &&
pricing.value.price_same_day !== undefined &&
pricing.value.price_emergency !== undefined) {
if (total_amount_after_discount.value > 0 && pricing.value) {
chargeAmount.value = calculateTotalAsNumber();
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 gallons = delivery.value.gallons_ordered || 0
const pricePerGallon = delivery.value.customer_price || 0
@@ -515,14 +520,14 @@ const calculateTotalAmount = () => {
return '0.00';
}
if (delivery.value && delivery.value.prime == 1 && pricing.value && pricing.value.price_prime) {
totalNum += Number(pricing.value.price_prime) || 0;
if (delivery.value && delivery.value.prime == 1) {
totalNum += getTierPrice('prime', delivery.value.pricing_tier_prime);
}
if (delivery.value && delivery.value.same_day == 1 && pricing.value && pricing.value.price_same_day) {
totalNum += Number(pricing.value.price_same_day) || 0;
if (delivery.value && delivery.value.same_day == 1) {
totalNum += getTierPrice('same_day', delivery.value.pricing_tier_same_day);
}
if (delivery.value && delivery.value.emergency == 1 && pricing.value && pricing.value.price_emergency) {
totalNum += Number(pricing.value.price_emergency) || 0;
if (delivery.value && delivery.value.emergency == 1) {
totalNum += getTierPrice('emergency', delivery.value.pricing_tier_emergency);
}
return totalNum.toFixed(2);
@@ -538,14 +543,14 @@ const calculateTotalAsNumber = () => {
return 0;
}
if (delivery.value && delivery.value.prime == 1 && pricing.value && pricing.value.price_prime) {
totalNum += Number(pricing.value.price_prime) || 0;
if (delivery.value && delivery.value.prime == 1) {
totalNum += getTierPrice('prime', delivery.value.pricing_tier_prime);
}
if (delivery.value && delivery.value.same_day == 1 && pricing.value && pricing.value.price_same_day) {
totalNum += Number(pricing.value.price_same_day) || 0;
if (delivery.value && delivery.value.same_day == 1) {
totalNum += getTierPrice('same_day', delivery.value.pricing_tier_same_day);
}
if (delivery.value && delivery.value.emergency == 1 && pricing.value && pricing.value.price_emergency) {
totalNum += Number(pricing.value.price_emergency) || 0;
if (delivery.value && delivery.value.emergency == 1) {
totalNum += getTierPrice('emergency', delivery.value.pricing_tier_emergency);
}
return totalNum;

View File

@@ -194,16 +194,16 @@
</span>
</div>
<div v-if="delivery.prime == 1" class="flex justify-between text-sm">
<span>Prime Fee</span>
<span>+ ${{ pricing.price_prime }}</span>
<span>Prime Fee (Tier {{ delivery.pricing_tier_prime || 1 }})</span>
<span>+ ${{ Number(getTierPrice('prime', delivery.pricing_tier_prime)).toFixed(2) }}</span>
</div>
<div v-if="delivery.emergency == 1" class="flex justify-between text-sm">
<span>Emergency Fee</span>
<span>+ ${{ pricing.price_emergency }}</span>
<span>Emergency Fee (Tier {{ delivery.pricing_tier_emergency || 1 }})</span>
<span>+ ${{ Number(getTierPrice('emergency', delivery.pricing_tier_emergency)).toFixed(2) }}</span>
</div>
<div v-if="delivery.same_day == 1" class="flex justify-between text-sm">
<span>Same Day Fee</span>
<span>+ ${{ pricing.price_same_day }}</span>
<span>Same Day Fee (Tier {{ delivery.pricing_tier_same_day || 1 }})</span>
<span>+ ${{ Number(getTierPrice('same_day', delivery.pricing_tier_same_day)).toFixed(2) }}</span>
</div>
<div v-if="promo_active" class="flex justify-between text-sm text-success">
<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 = () => {
if (total_amount_after_discount.value == null || total_amount_after_discount.value === undefined) {
return '0.00';
@@ -656,14 +664,14 @@ const calculateTotalAmount = () => {
return '0.00';
}
if (delivery.value && delivery.value.prime == 1 && pricing.value && pricing.value.price_prime) {
totalNum += Number(pricing.value.price_prime) || 0;
if (delivery.value && delivery.value.prime == 1) {
totalNum += getTierPrice('prime', delivery.value.pricing_tier_prime);
}
if (delivery.value && delivery.value.same_day == 1 && pricing.value && pricing.value.price_same_day) {
totalNum += Number(pricing.value.price_same_day) || 0;
if (delivery.value && delivery.value.same_day == 1) {
totalNum += getTierPrice('same_day', delivery.value.pricing_tier_same_day);
}
if (delivery.value && delivery.value.emergency == 1 && pricing.value && pricing.value.price_emergency) {
totalNum += Number(pricing.value.price_emergency) || 0;
if (delivery.value && delivery.value.emergency == 1) {
totalNum += getTierPrice('emergency', delivery.value.pricing_tier_emergency);
}
return totalNum.toFixed(2);

View File

@@ -36,9 +36,21 @@
<div class="col-span-6">
<div class="grid grid-cols-12">
<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 text-lg">Credit Card</div>
<div class="col-span-12" v-if="promo">{{ promo_text }}</div>
<div class="col-span-12 " v-if="delivery.prime == 1">PRIME</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 class="col-span-6 border-2" v-if="delivery.dispatcher_notes">
@@ -67,8 +79,10 @@
<div class="col-span-6 ">
<div v-if="past_deliveries.length > 0">
<div class="col-span-6" v-for="past_delivery in past_deliveries"
:key="past_delivery.fill_date">
{{ past_delivery.fill_date }} - {{ past_delivery.gallons_delivered }}
:key="past_delivery.id">
<div v-if="past_delivery.gallons_delivered != 0.00">
{{ past_delivery.when_delivered }} - {{ past_delivery.gallons_delivered }}
</div>
</div>
</div>
<div v-else>
@@ -82,11 +96,30 @@
<div class="col-span-6 ">
<div class="col-span-4 ">
<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"></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-4"> </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">{{ delivery.expected_delivery_date }}</div>
<div class="col-span-12 h-7 pl-4 pt-2" v-if="delivery.customer_asked_for_fill == 0">
{{ 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"></div>
<div class="col-span-12 h-7 pl-8"></div>
@@ -101,6 +134,8 @@
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import axios from 'axios'
import authHeader from '../../services/auth.header'
import { notify } from "@kyvg/vue3-notification"
import { deliveryService } from '../../services/deliveryService'
import { customerService } from '../../services/customerService'
@@ -108,8 +143,9 @@ import { adminService } from '../../services/adminService'
import { queryService } from '../../services/queryService'
interface PastDelivery {
id: number
gallons_delivered: number
fill_date: string
when_delivered: string
}
const route = useRoute()
@@ -117,6 +153,11 @@ const route = useRoute()
// State
const loaded = ref(false)
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>({
id: '',
customer_id: 0,
@@ -188,8 +229,10 @@ async function getOrder(deliveryId: string | number) {
const response = await deliveryService.getById(Number(deliveryId))
delivery.value = (response.data as any)?.delivery || response.data
getCustomer(delivery.value.customer_id)
if (delivery.value.promo_id) {
if (delivery.value.promo_id != null) {
getPromo(delivery.value.promo_id)
promo_active.value = true
getPromoPrice(deliveryId)
}
} catch (error) {
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
watch(() => route.params.id, (newId) => {
if (newId) {
@@ -277,6 +348,7 @@ onMounted(() => {
if (route.params.id) {
getOrder(route.params.id as string)
getTodayPrice()
sumdelivery(route.params.id as string)
}
})
</script>

View File

@@ -131,6 +131,26 @@ export const adminService = {
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: {
getPosts: (customerId: number, page: number = 1) =>

View File

@@ -37,6 +37,12 @@ export const customerService = {
getCount: (): Promise<AxiosResponse<CountResponse>> =>
api.get('/customer/count'),
getActiveCount: (): Promise<AxiosResponse<CountResponse>> =>
api.get('/customer/count/active'),
getDedicatedCount: (): Promise<AxiosResponse<CountResponse>> =>
api.get('/customer/count/dedicated'),
// Profile & details
getDescription: (id: number): Promise<AxiosResponse<DescriptionResponse>> =>
api.get(`/customer/description/${id}`),

94
src/stores/settings.ts Normal file
View 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,
}
})

View File

@@ -5,14 +5,18 @@ import { defineStore } from 'pinia'
import type { ThemeOption } from '../types/models'
const STORAGE_KEY = 'user_theme'
const DEFAULT_THEME = 'ocean'
const DEFAULT_THEME = 'dark'
export const AVAILABLE_THEMES: ThemeOption[] = [
{ name: 'ocean', label: 'Ocean', preview: '#ff6600' },
{ name: 'forest', label: 'Forest', preview: '#4ade80' },
{ name: 'sunset', label: 'Sunset', preview: '#fb923c' },
{ name: 'dark', label: 'Dark', preview: '#ff6600' },
{ name: 'vscode-dark', label: 'VS Code Dark', preview: '#569CD6' },
{ name: 'grok-dark', label: 'Grok Dark', preview: '#F05A28' },
{ name: 'arctic', label: 'Arctic', preview: '#06b6d4' },
{ 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', () => {
@@ -29,15 +33,16 @@ export const useThemeStore = defineStore('theme', () => {
}
}
function initTheme() {
function initTheme(serverDefault?: string) {
// Validate stored theme is still valid
const storedTheme = localStorage.getItem(STORAGE_KEY)
const validTheme = AVAILABLE_THEMES.find(t => t.name === storedTheme)
if (validTheme) {
currentTheme.value = storedTheme!
} else {
currentTheme.value = DEFAULT_THEME
localStorage.setItem(STORAGE_KEY, DEFAULT_THEME)
const fallback = serverDefault || DEFAULT_THEME
currentTheme.value = fallback
localStorage.setItem(STORAGE_KEY, fallback)
}
document.documentElement.setAttribute('data-theme', currentTheme.value)
}

View File

@@ -880,3 +880,32 @@ export interface DeliveryHistoryResponse {
emergencyCount: 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;
}

View File

@@ -5,7 +5,7 @@ module.exports = {
daisyui: {
themes: [
{
ocean: {
dark: {
"primary": "#010409",
"secondary": "#161B22",
"accent": "#ff6600",
@@ -20,33 +20,33 @@ module.exports = {
},
},
{
forest: {
"primary": "#1a472a",
"secondary": "#2d5a3d",
"accent": "#4ade80",
"neutral": "#1e3a2f",
"base-100": "#0f1f14",
"base-200": "#162a1c",
"base-300": "#1e3a2f",
"info": "#67e8f9",
"success": "#22c55e",
"warning": "#eab308",
"error": "#ef4444",
'vscode-dark': {
"primary": "#264F78",
"secondary": "#37373D",
"accent": "#569CD6",
"neutral": "#2D2D2D",
"base-100": "#1E1E1E",
"base-200": "#252526",
"base-300": "#2D2D2D",
"info": "#4FC1FF",
"success": "#6A9955",
"warning": "#CCA700",
"error": "#F44747",
},
},
{
sunset: {
"primary": "#44403c",
"secondary": "#57534e",
"accent": "#fb923c",
"neutral": "#292524",
"base-100": "#1c1917",
"base-200": "#292524",
"base-300": "#44403c",
"info": "#38bdf8",
"success": "#4ade80",
"warning": "#fbbf24",
"error": "#f87171",
'grok-dark': {
"primary": "#1A1A1A",
"secondary": "#2A2A2A",
"accent": "#F05A28",
"neutral": "#1F1F1F",
"base-100": "#0A0A0A",
"base-200": "#141414",
"base-300": "#1F1F1F",
"info": "#6CB4EE",
"success": "#4ADE80",
"warning": "#FACC15",
"error": "#EF4444",
},
},
{
@@ -79,6 +79,66 @@ module.exports = {
"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')],