feat: 5-tier pricing UI, market ticker, delivery map, and stats dashboard
Full frontend companion to the API updates: - Pricing: Oil price admin page now supports 5-tier configuration for same-day/prime/emergency fees with collapsible tier sections - Market Ticker: Add GlobalMarketTicker and OilPriceTicker components with real-time commodity + competitor prices in header bar - Delivery Map: New interactive Leaflet map view for daily deliveries - Stats: Add PricingHistoryChart component and info pages for market trends with daily/weekly/monthly gallon charts and YoY comparisons - Layout: Refactor header navbar to separate search into navbar-center, add oilPrice Pinia store with polling, update sidebar navigation - Forms: Wire tier selection into delivery create/edit flows, update types and services for new pricing and scraper API endpoints Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<!-- src/pages/employee/home.vue -->
|
||||
<template>
|
||||
<div class="flex">
|
||||
<div class="flex">
|
||||
<div class="w-full px-4 md:px-10 py-4">
|
||||
<!-- Breadcrumbs & Title -->
|
||||
<div class="text-sm breadcrumbs">
|
||||
@@ -13,9 +13,12 @@
|
||||
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mt-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl md:text-3xl font-bold flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center shadow-lg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-primary-content">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center shadow-lg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
||||
stroke="currentColor" class="w-5 h-5 text-primary-content">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
Employees
|
||||
@@ -37,9 +40,6 @@
|
||||
|
||||
<!-- Main Content Card -->
|
||||
<div class="modern-table-card">
|
||||
|
||||
|
||||
|
||||
<!-- DESKTOP VIEW: Table -->
|
||||
<div class="overflow-x-auto hidden xl:block">
|
||||
<table class="modern-table">
|
||||
@@ -62,10 +62,14 @@
|
||||
<td>{{ person.employee_phone_number }}</td>
|
||||
<td class="text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<router-link v-if="person.user_id" :to="{ name: 'employeeEdit', params: { id: person.user_id } }" class="btn btn-sm btn-secondary">Edit</router-link>
|
||||
<router-link v-if="person.user_id" :to="{ name: 'employeeEdit', params: { id: person.user_id } }"
|
||||
class="btn btn-sm btn-secondary">Edit</router-link>
|
||||
<button v-else class="btn btn-sm btn-disabled">No User</button>
|
||||
<router-link v-if="person.id" :to="{ name: 'employeeProfile', params: { id: person.id } }" class="btn btn-sm btn-ghost">View</router-link>
|
||||
<router-link v-if="user && user.user_admin === 0 && person.id" :to="{ name: 'employeeChangePassword', params: { id: person.id } }" class="btn btn-sm btn-warning">Change Password</router-link>
|
||||
<router-link v-if="person.id" :to="{ name: 'employeeProfile', params: { id: person.id } }"
|
||||
class="btn btn-sm btn-ghost">View</router-link>
|
||||
<router-link v-if="user && user.user_admin === 0 && person.id"
|
||||
:to="{ name: 'employeeChangePassword', params: { id: person.id } }"
|
||||
class="btn btn-sm btn-warning">Change Password</router-link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -88,24 +92,28 @@
|
||||
</div>
|
||||
<div class="text-sm mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
<div>
|
||||
<p class="text-xs text-base-content/50">Town</p>
|
||||
<p>{{ person.employee_town }}</p>
|
||||
<p class="text-xs text-base-content/50">Town</p>
|
||||
<p>{{ person.employee_town }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-base-content/50">Phone</p>
|
||||
<p>{{ person.employee_phone_number }}</p>
|
||||
<p class="text-xs text-base-content/50">Phone</p>
|
||||
<p>{{ person.employee_phone_number }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 pt-3 mt-3 border-t border-base-content/10 flex-wrap">
|
||||
<router-link v-if="person.user_id" :to="{ name: 'employeeEdit', params: { id: person.user_id } }" class="btn btn-sm btn-secondary flex-1">Edit</router-link>
|
||||
<router-link v-if="person.id" :to="{ name: 'employeeProfile', params: { id: person.id } }" class="btn btn-sm btn-ghost flex-1">View</router-link>
|
||||
<router-link v-if="user && user.user_admin === 0 && person.id" :to="{ name: 'employeeChangePassword', params: { id: person.id } }" class="btn btn-sm btn-warning flex-1">Change Password</router-link>
|
||||
<router-link v-if="person.user_id" :to="{ name: 'employeeEdit', params: { id: person.user_id } }"
|
||||
class="btn btn-sm btn-secondary flex-1">Edit</router-link>
|
||||
<router-link v-if="person.id" :to="{ name: 'employeeProfile', params: { id: person.id } }"
|
||||
class="btn btn-sm btn-ghost flex-1">View</router-link>
|
||||
<router-link v-if="user && user.user_admin === 0 && person.id"
|
||||
:to="{ name: 'employeeChangePassword', params: { id: person.id } }"
|
||||
class="btn btn-sm btn-warning flex-1">Change Password</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="mt-6 flex justify-center">
|
||||
<pagination @paginate="getPage" :records="recordsLength" v-model="page" :per-page="50" :options="options">
|
||||
@@ -114,85 +122,70 @@
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template><script lang="ts">
|
||||
import { defineComponent, markRaw } from 'vue'
|
||||
import axios from 'axios'
|
||||
import authHeader from '../../services/auth.header'
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, markRaw } from 'vue'
|
||||
import { User, Employee } from '../../types/models'
|
||||
import { adminService } from '../../services/adminService'
|
||||
import { authService } from '../../services/authService'
|
||||
import PaginationComp from '../../components/pagination.vue'
|
||||
import {Employee, User} from '../../types/models'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'EmployeeHome',
|
||||
components: {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
user: {} as User,
|
||||
employees: [] as Employee[],
|
||||
page: 1,
|
||||
perPage: 50,
|
||||
recordsLength: 0,
|
||||
options: {
|
||||
edgeNavigation: false,
|
||||
format: false,
|
||||
template: markRaw(PaginationComp)
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.userStatus();
|
||||
},
|
||||
mounted() {
|
||||
this.getPage(this.page);
|
||||
},
|
||||
methods: {
|
||||
getPage: function (page: number) {
|
||||
this.get_employees(page);
|
||||
},
|
||||
userStatus() {
|
||||
const path = import.meta.env.VITE_BASE_URL + '/auth/whoami';
|
||||
axios.get(path, { withCredentials: true, headers: authHeader() })
|
||||
.then((response: any) => {
|
||||
if (response.data.ok) {
|
||||
this.user = response.data.user;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.user = {} as User;
|
||||
});
|
||||
},
|
||||
// --- METHOD CORRECTED TO MATCH YOUR SIMPLE ARRAY API RESPONSE ---
|
||||
get_employees(page: number) {
|
||||
// Using your original, working URL
|
||||
const path = `${import.meta.env.VITE_BASE_URL}/employee/all/${page}`;
|
||||
|
||||
axios.get(path, { headers: authHeader() })
|
||||
.then((response: any) => {
|
||||
// Fix: Access the .employees property from the response object
|
||||
// The API returns { ok: true, employees: [...] }
|
||||
if (response.data.employees) {
|
||||
this.employees = response.data.employees;
|
||||
} else {
|
||||
// Fallback or empty
|
||||
this.employees = [];
|
||||
}
|
||||
// State
|
||||
const user = ref<User | null>(null)
|
||||
const employees = ref<Employee[]>([])
|
||||
const page = ref(1)
|
||||
const recordsLength = ref(0)
|
||||
const options = {
|
||||
edgeNavigation: false,
|
||||
format: false,
|
||||
template: markRaw(PaginationComp)
|
||||
}
|
||||
|
||||
// Fix: Set recordsLength based on the array length
|
||||
this.recordsLength = this.employees.length;
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error("Failed to fetch employees:", error);
|
||||
});
|
||||
},
|
||||
getEmployeeTypeName(typeId: number | string | undefined): string {
|
||||
if (typeId === undefined) return 'Unknown Role';
|
||||
const typeMap: { [key: string]: string } = {
|
||||
'0': 'Owner', '1': 'Manager', '2': 'Secretary', '3': 'Office',
|
||||
'4': 'Driver', '5': 'Service Tech', '6': 'Contractor', '7': 'Cash Driver', '8': 'Driver/Tech'
|
||||
};
|
||||
return typeMap[String(typeId)] || 'Unknown Role';
|
||||
// Methods
|
||||
async function userStatus() {
|
||||
try {
|
||||
const response = await authService.whoami()
|
||||
if ((response.data as any).ok) {
|
||||
user.value = (response.data as any).user
|
||||
}
|
||||
},
|
||||
} catch (error) {
|
||||
user.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function getEmployees(pageNum: number) {
|
||||
try {
|
||||
const response = await adminService.employees.getAll(pageNum)
|
||||
if ((response.data as any).employees) {
|
||||
employees.value = (response.data as any).employees
|
||||
} else {
|
||||
employees.value = []
|
||||
}
|
||||
recordsLength.value = employees.value.length
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch employees:", error)
|
||||
employees.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function getPage(pageNum: number) {
|
||||
getEmployees(pageNum)
|
||||
}
|
||||
|
||||
function getEmployeeTypeName(typeId: number | string | undefined): string {
|
||||
if (typeId === undefined) return 'Unknown Role'
|
||||
const typeMap: { [key: string]: string } = {
|
||||
'0': 'Owner', '1': 'Manager', '2': 'Secretary', '3': 'Office',
|
||||
'4': 'Driver', '5': 'Service Tech', '6': 'Contractor', '7': 'Cash Driver', '8': 'Driver/Tech'
|
||||
}
|
||||
return typeMap[String(typeId)] || 'Unknown Role'
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
userStatus()
|
||||
getPage(page.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user