Added service plan. Password change
This commit is contained in:
@@ -53,6 +53,11 @@
|
||||
Profile
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link :to="{ name: 'changePassword' }">
|
||||
Change Password
|
||||
</router-link>
|
||||
</li>
|
||||
<li><a @click="logout">Logout</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -3,20 +3,7 @@
|
||||
<template>
|
||||
<div class="WrapperPlain">
|
||||
<div class="container max-w-3xl mx-auto text-white">
|
||||
<div class="mt-5 mb-5 px-10 ">
|
||||
<nav class="rounded-md w-full">
|
||||
<ol class="list-reset flex">
|
||||
<li>
|
||||
<router-link :to="{ name: 'home' }">
|
||||
<a class="text-primary hover:text-primary ">Home</a>
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<span class="text-gray-500 mx-2">/</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-lg flex items-center justify-center mb-10 mt-20 px-5">
|
||||
<form class="bg-neutral rounded-md px-8 pt-6 pb-8 mb-4 w-full" @submit.prevent="onSubmit">
|
||||
<div class="mb-4 text-center text-[28px] ">
|
||||
@@ -56,7 +43,7 @@ import { defineComponent } from "vue";
|
||||
import axios from "axios";
|
||||
import { notify } from "@kyvg/vue3-notification";
|
||||
import useValidate from "@vuelidate/core";
|
||||
import { required, minLength } from "@vuelidate/validators";
|
||||
import { required, minLength, helpers } from "@vuelidate/validators";
|
||||
import Header from "../../layouts/headers/headerauth.vue";
|
||||
import authHeader from "../../services/auth.header";
|
||||
|
||||
@@ -82,7 +69,7 @@ export default defineComponent({
|
||||
return {
|
||||
ChangePasswordForm: {
|
||||
new_password: { required, minLength: minLength(6) },
|
||||
password_confirm: { required, minLength: minLength(6) },
|
||||
password_confirm: { required, minLength: minLength(6), sameAsPassword: helpers.withMessage('Passwords must match', (value: string) => value === this.ChangePasswordForm.new_password) },
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -118,13 +105,14 @@ export default defineComponent({
|
||||
withCredentials: true,
|
||||
headers: authHeader(),
|
||||
}).then((response:any) => {
|
||||
console.log(response)
|
||||
if (response.data.ok) {
|
||||
notify({
|
||||
title: "Authorization",
|
||||
text: "Success!",
|
||||
text: "Password changed",
|
||||
type: "success",
|
||||
});
|
||||
this.$router.push({ name: "login" });
|
||||
this.$router.push({ name: "home" });
|
||||
}
|
||||
if (response.data.error) {
|
||||
notify({
|
||||
@@ -149,6 +137,7 @@ export default defineComponent({
|
||||
new_password: this.ChangePasswordForm.new_password,
|
||||
password_confirm: this.ChangePasswordForm.password_confirm,
|
||||
};
|
||||
|
||||
this.v$.$validate(); // checks all inputs
|
||||
if (this.v$.$invalid) {
|
||||
notify({
|
||||
@@ -163,4 +152,4 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
</script>
|
||||
|
||||
312
src/pages/customer/ServicePlanEdit.vue
Normal file
312
src/pages/customer/ServicePlanEdit.vue
Normal file
@@ -0,0 +1,312 @@
|
||||
<!-- src/pages/customer/ServicePlanEdit.vue -->
|
||||
<template>
|
||||
<div class="flex">
|
||||
<div class="w-full px-4 md:px-10 py-4">
|
||||
<!-- Breadcrumbs & Title -->
|
||||
<div class="text-sm breadcrumbs">
|
||||
<ul>
|
||||
<li><router-link :to="{ name: 'home' }">Home</router-link></li>
|
||||
<li><router-link :to="{ name: 'customer' }">Customers</router-link></li>
|
||||
<li><router-link :to="{ name: 'customerProfile', params: { id: customerId } }">
|
||||
{{ customerName }}
|
||||
</router-link></li>
|
||||
<li>Service Plan</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mt-4">
|
||||
<h1 class="text-3xl font-bold">
|
||||
Service Plan: {{ customerName }}
|
||||
</h1>
|
||||
<router-link :to="{ name: 'customerProfile', params: { id: customerId } }" class="btn btn-secondary btn-sm mt-2 sm:mt-0">
|
||||
Back to Profile
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Main Form Card -->
|
||||
<div class="bg-neutral rounded-lg p-6 mt-6">
|
||||
<form @submit.prevent="onSubmit" class="space-y-6">
|
||||
|
||||
<!-- Current Plan Status -->
|
||||
<div v-if="servicePlan && servicePlan.contract_plan > 0" class="alert alert-info">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<h3 class="font-bold">Current Plan: {{ getPlanName(servicePlan.contract_plan) }}</h3>
|
||||
<p>{{ servicePlan.contract_years }} Year{{ servicePlan.contract_years > 1 ? 's' : '' }} Contract</p>
|
||||
<p class="text-sm">Expires: {{ formatEndDate(servicePlan.contract_start_date, servicePlan.contract_years) }}</p>
|
||||
</div>
|
||||
<div class="badge" :class="getStatusBadge(servicePlan.contract_start_date, servicePlan.contract_years)">
|
||||
{{ getStatusText(servicePlan.contract_start_date, servicePlan.contract_years) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Service Plan Form -->
|
||||
<div>
|
||||
<h2 class="text-lg font-bold">Service Plan Details</h2>
|
||||
<div class="divider mt-2 mb-4"></div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
<!-- Contract Plan -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Service Plan</span></label>
|
||||
<select v-model="formData.contract_plan" class="select select-bordered select-sm w-full" required>
|
||||
<option value="0">No Contract</option>
|
||||
<option value="1">Standard Plan</option>
|
||||
<option value="2">Premium Plan</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Contract Years -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Contract Years</span></label>
|
||||
<input v-model.number="formData.contract_years" type="number" min="1" placeholder="1"
|
||||
class="input input-bordered input-sm w-full" required />
|
||||
</div>
|
||||
|
||||
<!-- Contract Start Date -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Contract Start Date</span></label>
|
||||
<input v-model="formData.contract_start_date" type="date"
|
||||
class="input input-bordered input-sm w-full" required />
|
||||
</div>
|
||||
|
||||
<!-- Contract End Date (Calculated) -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Contract End Date</span></label>
|
||||
<input :value="formatEndDate(formData.contract_start_date, formData.contract_years)"
|
||||
type="text" readonly class="input input-bordered input-sm w-full bg-gray-100" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Renewal Section (only show if there's an existing plan) -->
|
||||
<div v-if="servicePlan && servicePlan.contract_plan > 0">
|
||||
<h2 class="text-lg font-bold">Contract Renewal</h2>
|
||||
<div class="divider mt-2 mb-4"></div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Renewal Date</span></label>
|
||||
<input v-model="renewalDate" type="date" placeholder="Select renewal date"
|
||||
class="input input-bordered input-sm w-full" />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text"> </span></label>
|
||||
<button @click="renewContract" type="button" class="btn btn-sm btn-success w-full">
|
||||
Renew Contract (+1 Year)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
This will add +1 year to the contract and update the start date to the renewal date.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="pt-4 flex gap-4">
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
{{ servicePlan ? 'Update Plan' : 'Create Plan' }}
|
||||
</button>
|
||||
<button v-if="servicePlan" @click="deletePlan" type="button" class="btn btn-error btn-sm">
|
||||
Delete Plan
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import axios from 'axios'
|
||||
import authHeader from '../../services/auth.header'
|
||||
import Footer from '../../layouts/footers/footer.vue'
|
||||
import { notify } from "@kyvg/vue3-notification";
|
||||
|
||||
interface ServicePlan {
|
||||
id: number;
|
||||
customer_id: number;
|
||||
contract_plan: number;
|
||||
contract_years: number;
|
||||
contract_start_date: string;
|
||||
}
|
||||
|
||||
interface Customer {
|
||||
id: number;
|
||||
customer_first_name: string;
|
||||
customer_last_name: string;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ServicePlanEdit',
|
||||
components: { Footer },
|
||||
data() {
|
||||
return {
|
||||
customerId: this.$route.params.id as string,
|
||||
customerName: '',
|
||||
servicePlan: null as ServicePlan | null,
|
||||
formData: {
|
||||
contract_plan: 0,
|
||||
contract_years: 1,
|
||||
contract_start_date: '',
|
||||
},
|
||||
renewalDate: '',
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.loadCustomer();
|
||||
this.loadServicePlan();
|
||||
},
|
||||
methods: {
|
||||
async loadCustomer() {
|
||||
try {
|
||||
const path = `${import.meta.env.VITE_BASE_URL}/customer/${this.customerId}`;
|
||||
const response = await axios.get(path, { headers: authHeader() });
|
||||
const customer: Customer = response.data;
|
||||
this.customerName = `${customer.customer_first_name} ${customer.customer_last_name}`;
|
||||
} catch (error) {
|
||||
console.error('Failed to load customer:', error);
|
||||
notify({ title: "Error", text: "Failed to load customer information.", type: "error" });
|
||||
}
|
||||
},
|
||||
|
||||
async loadServicePlan() {
|
||||
try {
|
||||
const path = `${import.meta.env.VITE_BASE_URL}/service/plans/customer/${this.customerId}`;
|
||||
const response = await axios.get(path, { headers: authHeader() });
|
||||
|
||||
if (response.data && response.data.contract_plan !== undefined) {
|
||||
this.servicePlan = response.data;
|
||||
this.formData = {
|
||||
contract_plan: response.data.contract_plan,
|
||||
contract_years: response.data.contract_years,
|
||||
contract_start_date: response.data.contract_start_date,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// Plan doesn't exist yet, that's okay
|
||||
console.log('No existing service plan found');
|
||||
}
|
||||
},
|
||||
|
||||
async onSubmit() {
|
||||
try {
|
||||
const payload = {
|
||||
customer_id: parseInt(this.customerId),
|
||||
...this.formData
|
||||
};
|
||||
|
||||
let response;
|
||||
if (this.servicePlan) {
|
||||
// Update existing plan
|
||||
const path = `${import.meta.env.VITE_BASE_URL}/service/plans/update/${this.customerId}`;
|
||||
response = await axios.put(path, payload, { headers: authHeader() });
|
||||
} else {
|
||||
// Create new plan
|
||||
const path = `${import.meta.env.VITE_BASE_URL}/service/plans/create`;
|
||||
response = await axios.post(path, payload, { headers: authHeader() });
|
||||
}
|
||||
|
||||
if (response.data.ok) {
|
||||
notify({
|
||||
title: "Success",
|
||||
text: `Service plan ${this.servicePlan ? 'updated' : 'created'} successfully!`,
|
||||
type: "success"
|
||||
});
|
||||
// Redirect to profile page after successful submission
|
||||
this.$router.push({ name: 'customerProfile', params: { id: this.customerId } });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save service plan:', error);
|
||||
notify({ title: "Error", text: "Failed to save service plan.", type: "error" });
|
||||
}
|
||||
},
|
||||
|
||||
async deletePlan() {
|
||||
if (!confirm('Are you sure you want to delete this service plan?')) return;
|
||||
|
||||
try {
|
||||
const path = `${import.meta.env.VITE_BASE_URL}/service/plans/delete/${this.customerId}`;
|
||||
const response = await axios.delete(path, { headers: authHeader() });
|
||||
|
||||
if (response.data.ok) {
|
||||
notify({ title: "Success", text: "Service plan deleted successfully!", type: "success" });
|
||||
this.servicePlan = null;
|
||||
this.formData = {
|
||||
contract_plan: 0,
|
||||
contract_years: 1,
|
||||
contract_start_date: '',
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete service plan:', error);
|
||||
notify({ title: "Error", text: "Failed to delete service plan.", type: "error" });
|
||||
}
|
||||
},
|
||||
|
||||
renewContract() {
|
||||
if (!this.renewalDate) {
|
||||
notify({ title: "Error", text: "Please select a renewal date.", type: "error" });
|
||||
return;
|
||||
}
|
||||
|
||||
this.formData.contract_years += 1;
|
||||
this.formData.contract_start_date = this.renewalDate;
|
||||
this.renewalDate = '';
|
||||
|
||||
notify({
|
||||
title: "Success",
|
||||
text: "Contract renewed! Years increased by 1 and start date updated.",
|
||||
type: "success"
|
||||
});
|
||||
},
|
||||
|
||||
getPlanName(planType: number): string {
|
||||
const planNames: { [key: number]: string } = {
|
||||
1: 'Standard Plan',
|
||||
2: 'Premium Plan'
|
||||
};
|
||||
return planNames[planType] || 'No Plan';
|
||||
},
|
||||
|
||||
formatEndDate(startDate: string, years: number): string {
|
||||
if (!startDate) return 'N/A';
|
||||
const date = new Date(startDate);
|
||||
date.setFullYear(date.getFullYear() + years);
|
||||
return date.toISOString().split('T')[0];
|
||||
},
|
||||
|
||||
getStatusText(startDate: string, years: number): string {
|
||||
if (!startDate) return 'Unknown';
|
||||
const endDate = new Date(startDate);
|
||||
endDate.setFullYear(endDate.getFullYear() + years);
|
||||
const now = new Date();
|
||||
if (now > endDate) {
|
||||
return 'Expired';
|
||||
} else if (now > new Date(endDate.getTime() - 30 * 24 * 60 * 60 * 1000)) {
|
||||
return 'Expiring Soon';
|
||||
} else {
|
||||
return 'Active';
|
||||
}
|
||||
},
|
||||
|
||||
getStatusBadge(startDate: string, years: number): string {
|
||||
if (!startDate) return 'badge-ghost';
|
||||
const endDate = new Date(startDate);
|
||||
endDate.setFullYear(endDate.getFullYear() + years);
|
||||
const now = new Date();
|
||||
if (now > endDate) {
|
||||
return 'badge-error';
|
||||
} else if (now > new Date(endDate.getTime() - 30 * 24 * 60 * 60 * 1000)) {
|
||||
return 'badge-warning';
|
||||
} else {
|
||||
return 'badge-success';
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -143,6 +143,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- SUBMIT BUTTON -->
|
||||
<div class="pt-4">
|
||||
@@ -226,7 +228,13 @@ export default defineComponent({
|
||||
customer_description: "",
|
||||
customer_fill_location: 0,
|
||||
},
|
||||
servicePlan: {
|
||||
contract_plan: 0,
|
||||
contract_years: 1,
|
||||
contract_start_date: "",
|
||||
},
|
||||
},
|
||||
renewalDate: "",
|
||||
}
|
||||
},
|
||||
validations() {
|
||||
@@ -343,8 +351,12 @@ export default defineComponent({
|
||||
})
|
||||
},
|
||||
onSubmit() {
|
||||
// Create payload directly from the form object
|
||||
this.editItem(this.CreateCustomerForm.basicInfo);
|
||||
// Create payload with both basic info and service plan data
|
||||
const payload = {
|
||||
...this.CreateCustomerForm.basicInfo,
|
||||
service_plan: this.CreateCustomerForm.servicePlan
|
||||
};
|
||||
this.editItem(payload);
|
||||
},
|
||||
getCustomerTypeList() {
|
||||
let path = import.meta.env.VITE_BASE_URL + "/query/customertype";
|
||||
@@ -360,7 +372,22 @@ export default defineComponent({
|
||||
this.stateList = response.data;
|
||||
});
|
||||
},
|
||||
formatEndDate(startDate: string, years: number): string {
|
||||
if (!startDate) return 'N/A';
|
||||
const date = new Date(startDate);
|
||||
date.setFullYear(date.getFullYear() + years);
|
||||
return date.toISOString().split('T')[0];
|
||||
},
|
||||
renewContract() {
|
||||
if (!this.renewalDate) {
|
||||
alert('Please select a renewal date');
|
||||
return;
|
||||
}
|
||||
this.CreateCustomerForm.servicePlan.contract_years += 1;
|
||||
this.CreateCustomerForm.servicePlan.contract_start_date = this.renewalDate;
|
||||
this.renewalDate = '';
|
||||
alert('Contract renewed! Years increased by 1 and start date updated.');
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -3,8 +3,33 @@
|
||||
<div class="w-full min-h-screen bg-base-200 px-4 md:px-10">
|
||||
<!-- ... breadcrumbs ... -->
|
||||
|
||||
<div v-if="customer && customer.id" class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
|
||||
|
||||
<div v-if="customer && customer.id" class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
|
||||
|
||||
<!-- Current Plan Status Banner - Same as ServicePlanEdit.vue -->
|
||||
<div v-if="servicePlan && servicePlan.contract_plan > 0"
|
||||
class="alert alert-info mb-6"
|
||||
:class="servicePlan.contract_plan === 2 ? 'border-4 border-yellow-400 bg-yellow-50' : ''">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="font-bold">Current Plan: {{ getPlanName(servicePlan.contract_plan) }}</h3>
|
||||
<!-- Premium Star Icon -->
|
||||
<svg v-if="servicePlan.contract_plan === 2"
|
||||
class="w-8 h-8 text-yellow-500 fill-current"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p>{{ servicePlan.contract_years }} Year{{ servicePlan.contract_years > 1 ? 's' : '' }} Contract</p>
|
||||
<p class="text-sm">Expires: {{ formatEndDate(servicePlan.contract_start_date, servicePlan.contract_years) }}</p>
|
||||
</div>
|
||||
<div class="badge" :class="getStatusBadge(servicePlan.contract_start_date, servicePlan.contract_years)">
|
||||
{{ getStatusText(servicePlan.contract_start_date, servicePlan.contract_years) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FIX: Changed `lg:` to `xl:` for a later breakpoint -->
|
||||
<div class="grid grid-cols-1 xl:grid-cols-12 gap-6">
|
||||
|
||||
@@ -177,6 +202,14 @@ interface ServiceParts {
|
||||
oil_nozzle_2: string;
|
||||
}
|
||||
|
||||
interface ServicePlan {
|
||||
id: number;
|
||||
customer_id: number;
|
||||
contract_plan: number;
|
||||
contract_years: number;
|
||||
contract_start_date: string;
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default defineComponent({
|
||||
@@ -225,6 +258,7 @@ export default defineComponent({
|
||||
selectedServiceForEdit: null as ServiceCall | null,
|
||||
isPartsModalOpen: false,
|
||||
currentParts: null as ServiceParts | null,
|
||||
servicePlan: null as ServicePlan | null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -282,6 +316,7 @@ export default defineComponent({
|
||||
this.getCustomerLastDelivery(this.customer.id);
|
||||
this.getServiceCalls(this.customer.id);
|
||||
this.fetchCustomerParts(this.customer.id);
|
||||
this.loadServicePlan(this.customer.id);
|
||||
|
||||
}).catch((error: any) => {
|
||||
console.error("CRITICAL: Failed to fetch main customer data. Aborting other calls.", error);
|
||||
@@ -615,6 +650,78 @@ onSubmitSocial(commentText: string) {
|
||||
getServiceTypeColor(typeId: number): string {
|
||||
const colorMap: { [key: number]: string } = { 0: 'blue', 1: 'red', 2: 'green', 3: '#B58900', 4: 'black' };
|
||||
return colorMap[typeId] || 'gray';
|
||||
},
|
||||
formatEndDate(startDate: string, years: number): string {
|
||||
if (!startDate) return 'N/A';
|
||||
return dayjs(startDate).add(years, 'year').format('MMM D, YYYY');
|
||||
},
|
||||
getPlanStatusText(startDate: string, years: number): string {
|
||||
if (!startDate) return 'Unknown';
|
||||
const endDate = dayjs(startDate).add(years, 'year');
|
||||
const now = dayjs();
|
||||
if (now.isAfter(endDate)) {
|
||||
return 'Expired';
|
||||
} else if (now.isAfter(endDate.subtract(30, 'day'))) {
|
||||
return 'Expiring Soon';
|
||||
} else {
|
||||
return 'Active';
|
||||
}
|
||||
},
|
||||
getPlanStatusBadge(startDate: string, years: number): string {
|
||||
if (!startDate) return 'badge-ghost';
|
||||
const endDate = dayjs(startDate).add(years, 'year');
|
||||
const now = dayjs();
|
||||
if (now.isAfter(endDate)) {
|
||||
return 'badge-error';
|
||||
} else if (now.isAfter(endDate.subtract(30, 'day'))) {
|
||||
return 'badge-warning';
|
||||
} else {
|
||||
return 'badge-success';
|
||||
}
|
||||
},
|
||||
getPlanName(planType: number): string {
|
||||
const planNames: { [key: number]: string } = {
|
||||
1: 'Standard Plan',
|
||||
2: 'Premium Plan'
|
||||
};
|
||||
return planNames[planType] || 'No Plan';
|
||||
},
|
||||
getStatusText(startDate: string, years: number): string {
|
||||
if (!startDate) return 'Unknown';
|
||||
const endDate = dayjs(startDate).add(years, 'year');
|
||||
const now = dayjs();
|
||||
if (now.isAfter(endDate)) {
|
||||
return 'Expired';
|
||||
} else if (now.isAfter(endDate.subtract(30, 'day'))) {
|
||||
return 'Expiring Soon';
|
||||
} else {
|
||||
return 'Active';
|
||||
}
|
||||
},
|
||||
getStatusBadge(startDate: string, years: number): string {
|
||||
if (!startDate) return 'badge-ghost';
|
||||
const endDate = dayjs(startDate).add(years, 'year');
|
||||
const now = dayjs();
|
||||
if (now.isAfter(endDate)) {
|
||||
return 'badge-error';
|
||||
} else if (now.isAfter(endDate.subtract(30, 'day'))) {
|
||||
return 'badge-warning';
|
||||
} else {
|
||||
return 'badge-success';
|
||||
}
|
||||
},
|
||||
async loadServicePlan(customerId: number) {
|
||||
try {
|
||||
const path = `${import.meta.env.VITE_BASE_URL}/service/plans/customer/${customerId}`;
|
||||
const response = await axios.get(path, { headers: authHeader() });
|
||||
|
||||
if (response.data && response.data.contract_plan !== undefined) {
|
||||
this.servicePlan = response.data;
|
||||
}
|
||||
} catch (error) {
|
||||
// Plan doesn't exist yet, that's okay
|
||||
console.log('No existing service plan found');
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -2,18 +2,7 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body p-4 sm:p-6">
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-2">
|
||||
<h2 class="card-title text-2xl">{{ customer.account_number }}</h2>
|
||||
<div class="flex flex-wrap gap-2 justify-start sm:justify-end">
|
||||
<router-link :to="{ name: 'deliveryCreate', params: { id: customer.id } }" class="btn btn-sm btn-primary">New Delivery</router-link>
|
||||
<router-link :to="{ name: 'CalenderCustomer', params: { id: customer.id } }" class="btn btn-sm btn-info">New Service</router-link>
|
||||
<router-link :to="{ name: 'customerEdit', params: { id: customer.id } }" class="btn btn-sm btn-secondary">Edit Customer</router-link>
|
||||
<button @click="$emit('toggleAutomatic', customer.id)" class="btn btn-sm"
|
||||
:class="automatic_status === 1 ? 'btn-success' : 'btn-warning'">
|
||||
{{ automatic_status === 1 ? 'Set to Will Call' : 'Set to Automatic' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="card-title text-2xl">{{ customer.account_number }}</h2>
|
||||
<div class="divider my-2"></div>
|
||||
<div class="rounded-lg overflow-hidden" style="height:400px; width:100%">
|
||||
<l-map ref="map" v-model:zoom="zoom" :center="[customer.customer_latitude, customer.customer_longitude]">
|
||||
@@ -35,4 +24,4 @@ defineProps({
|
||||
});
|
||||
defineEmits(['toggleAutomatic']);
|
||||
const zoom = ref(14);
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -2,13 +2,50 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-xl h-full">
|
||||
<div class="card-body p-4 sm:p-6">
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<router-link :to="{ name: 'deliveryCreate', params: { id: customer.id } }" class="btn btn-sm btn-primary">New Delivery</router-link>
|
||||
<router-link :to="{ name: 'CalenderCustomer', params: { id: customer.id } }" class="btn btn-sm btn-info">New Service</router-link>
|
||||
<router-link :to="{ name: 'customerEdit', params: { id: customer.id } }" class="btn btn-sm btn-secondary">Edit</router-link>
|
||||
<button @click="$emit('toggleAutomatic', customer.id)" class="btn btn-sm" :class="automatic_status === 1 ? 'btn-success' : 'btn-warning'">
|
||||
{{ automatic_status === 1 ? 'Set to Will Call' : 'Set to Automatic' }}
|
||||
<!-- Action Buttons - Two Row Layout -->
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<!-- Row 1 -->
|
||||
<router-link :to="{ name: 'deliveryCreate', params: { id: customer.id } }"
|
||||
class="btn btn-primary min-h-[3rem] flex flex-col items-center justify-center gap-1">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
||||
</svg>
|
||||
<span class="text-xs font-medium">Delivery</span>
|
||||
</router-link>
|
||||
|
||||
<router-link :to="{ name: 'CalenderCustomer', params: { id: customer.id } }"
|
||||
class="btn btn-info min-h-[3rem] flex flex-col items-center justify-center gap-1">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
<span class="text-xs font-medium">Service</span>
|
||||
</router-link>
|
||||
|
||||
<router-link :to="{ name: 'customerEdit', params: { id: customer.id } }"
|
||||
class="btn btn-secondary min-h-[3rem] flex flex-col items-center justify-center gap-1">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
<span class="text-xs font-medium">Edit</span>
|
||||
</router-link>
|
||||
|
||||
<!-- Row 2 -->
|
||||
<router-link :to="{ name: 'servicePlanEdit', params: { id: customer.id } }"
|
||||
class="btn btn-accent min-h-[3rem] flex flex-col items-center justify-center gap-1">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
<span class="text-xs font-medium">Contract</span>
|
||||
</router-link>
|
||||
|
||||
<button @click="$emit('toggleAutomatic', customer.id)"
|
||||
class="btn min-h-[3rem] flex flex-col items-center justify-center gap-1 col-span-2"
|
||||
:class="automatic_status === 1 ? 'btn-success' : 'btn-warning'">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
|
||||
</svg>
|
||||
<span class="text-xs font-medium">{{ automatic_status === 1 ? 'Set to Will Call' : 'Set to Auto' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -61,4 +98,4 @@ defineEmits(['toggleAutomatic']);
|
||||
|
||||
const stateName = (id: number) => ['MA', 'RI', 'NH', 'ME', 'VT', 'CT', 'NY'][id] || 'N/A';
|
||||
const homeTypeName = (id: number) => ['Residential', 'Apartment', 'Condo', 'Commercial', 'Business', 'Construction', 'Container'][id] || 'Unknown';
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -29,6 +29,11 @@ const customerRoutes = [
|
||||
name: 'customerProfile',
|
||||
component: CustomerProfile,
|
||||
},
|
||||
{
|
||||
path: '/customer/:id/service-plan',
|
||||
name: 'servicePlanEdit',
|
||||
component: () => import('./ServicePlanEdit.vue'),
|
||||
},
|
||||
{
|
||||
path: '/tank/edit/:id',
|
||||
name: 'TankEdit',
|
||||
|
||||
193
src/pages/employee/changepassword.vue
Normal file
193
src/pages/employee/changepassword.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<!-- src/pages/employee/changepassword.vue -->
|
||||
<template>
|
||||
<div class="WrapperPlain">
|
||||
<div class="container max-w-3xl mx-auto text-white">
|
||||
|
||||
<!-- Employee Info Section -->
|
||||
<div class="mx-auto max-w-lg mb-6 px-5">
|
||||
<div class="bg-neutral rounded-md px-8 pt-6 pb-6 mb-4 w-full">
|
||||
<h2 class="text-xl font-bold mb-4">Employee Information</h2>
|
||||
<div v-if="employee">
|
||||
<p><strong>Name:</strong> {{ employee.employee_first_name }} {{ employee.employee_last_name }}</p>
|
||||
<p><strong>ID:</strong> {{ employee.id }}</p>
|
||||
<p><strong>Role:</strong> {{ getEmployeeTypeName(employee.employee_type) }}</p>
|
||||
<p><strong>Town:</strong> {{ employee.employee_town }}</p>
|
||||
<p><strong>Phone:</strong> {{ employee.employee_phone_number }}</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p>Loading employee data...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-lg flex items-center justify-center mb-10 mt-20 px-5">
|
||||
<form class="bg-neutral rounded-md px-8 pt-6 pb-8 mb-4 w-full" @submit.prevent="onSubmit">
|
||||
<div class="mb-4 text-center text-[28px] ">
|
||||
Change Password
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-white text-sm font-bold mb-2">Enter New Password</label>
|
||||
<input v-model="ChangePasswordForm.new_password" class="rounded w-full py-2 px-3 input-primary text-black"
|
||||
id="password" type="password" placeholder="Password" />
|
||||
<span v-if="v$.ChangePasswordForm.new_password.$error" class="text-red-600 text-center">
|
||||
{{ v$.ChangePasswordForm.new_password.$errors[0].$message }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label class="block text-white text-sm font-bold mb-2">Confirm New Password</label>
|
||||
<input v-model="ChangePasswordForm.password_confirm" class="rounded w-full py-2 px-3 input-primary text-black"
|
||||
id="passwordtwo" type="password" autocomplete="off" placeholder="Confirm Password" />
|
||||
<span v-if="v$.ChangePasswordForm.password_confirm.$error" class="text-red-600 text-center">
|
||||
{{ v$.ChangePasswordForm.password_confirm.$errors[0].$message }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-center mb-6">
|
||||
<button
|
||||
class="bg-primary hover:bg-zinc-400 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
|
||||
type="submit">
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import axios from "axios";
|
||||
import { notify } from "@kyvg/vue3-notification";
|
||||
import useValidate from "@vuelidate/core";
|
||||
import { required, minLength, helpers } from "@vuelidate/validators";
|
||||
import Header from "../../layouts/headers/headerauth.vue";
|
||||
import authHeader from "../../services/auth.header";
|
||||
|
||||
export default defineComponent({
|
||||
name: "EmployeeChangePassword",
|
||||
components: {
|
||||
Header,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
v$: useValidate(),
|
||||
user: null,
|
||||
user_admin: 0,
|
||||
loaded: false,
|
||||
employee: null as any,
|
||||
ChangePasswordForm: {
|
||||
new_password: "",
|
||||
password_confirm: "",
|
||||
},
|
||||
};
|
||||
},
|
||||
validations() {
|
||||
return {
|
||||
ChangePasswordForm: {
|
||||
new_password: { required, minLength: minLength(6) },
|
||||
password_confirm: { required, minLength: minLength(6), sameAsPassword: helpers.withMessage('Passwords must match', (value: string) => value === this.ChangePasswordForm.new_password) },
|
||||
},
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.userStatus();
|
||||
},
|
||||
mounted() {
|
||||
this.getEmployee();
|
||||
},
|
||||
methods: {
|
||||
userStatus() {
|
||||
let path = import.meta.env.VITE_BASE_URL + '/auth/whoami';
|
||||
axios({
|
||||
method: "get",
|
||||
url: path,
|
||||
withCredentials: true,
|
||||
headers: authHeader(),
|
||||
})
|
||||
.then((response: any) => {
|
||||
if (response.data.ok) {
|
||||
this.user = response.data.user;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.$router.push({ name: "login" });
|
||||
});
|
||||
},
|
||||
getEmployee() {
|
||||
const employeeId = this.$route.params.id;
|
||||
const path = `${import.meta.env.VITE_BASE_URL}/employee/byid/${employeeId}`;
|
||||
axios.get(path, { headers: authHeader() })
|
||||
.then((response: any) => {
|
||||
this.employee = response.data;
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error("Failed to fetch employee:", error);
|
||||
notify({
|
||||
title: "Error",
|
||||
text: "Failed to load employee data",
|
||||
type: "error",
|
||||
});
|
||||
});
|
||||
},
|
||||
getEmployeeTypeName(typeId: number | string): string {
|
||||
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';
|
||||
},
|
||||
sendWordRequest(payLoad: { employee_id: string; new_password: string; password_confirm: string }) {
|
||||
let path = import.meta.env.VITE_BASE_URL + "/auth/admin-change-password";
|
||||
axios({
|
||||
method: "post",
|
||||
url: path,
|
||||
data: payLoad,
|
||||
withCredentials: true,
|
||||
headers: authHeader(),
|
||||
}).then((response: any) => {
|
||||
console.log(response)
|
||||
if (response.data.ok) {
|
||||
notify({
|
||||
title: "Authorization",
|
||||
text: "Password changed successfully",
|
||||
type: "success",
|
||||
});
|
||||
this.$router.push({ name: "employee" });
|
||||
}
|
||||
if (response.data.error) {
|
||||
notify({
|
||||
title: "Authorization Error",
|
||||
text: response.data.error,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
notify({
|
||||
title: "Authorization",
|
||||
text: "Invalid Credentials.",
|
||||
type: "error",
|
||||
});
|
||||
});
|
||||
},
|
||||
onSubmit() {
|
||||
const payLoad = {
|
||||
employee_id: this.$route.params.id as string,
|
||||
new_password: this.ChangePasswordForm.new_password,
|
||||
password_confirm: this.ChangePasswordForm.password_confirm,
|
||||
};
|
||||
|
||||
this.v$.$validate(); // checks all inputs
|
||||
if (this.v$.$invalid) {
|
||||
notify({
|
||||
title: "Authorization",
|
||||
text: "Form Failure",
|
||||
type: "error",
|
||||
});
|
||||
} else {
|
||||
this.sendWordRequest(payLoad);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -141,6 +141,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SECTION 4: Fired or Current -->
|
||||
<div>
|
||||
<h2 class="text-lg font-bold">Fired or Current</h2>
|
||||
<div class="divider mt-2 mb-4"></div>
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text">Active Employee</span>
|
||||
<input v-model="CreateEmployeeForm.active" type="checkbox" class="checkbox checkbox-primary" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SUBMIT BUTTON -->
|
||||
<div class="pt-4">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save Changes</button>
|
||||
@@ -189,6 +201,7 @@ export default defineComponent({
|
||||
employee_end_date: "",
|
||||
employee_type: 0,
|
||||
employee_state: 0,
|
||||
active: true,
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -237,7 +250,9 @@ export default defineComponent({
|
||||
},
|
||||
EditEmployee(payload: any) {
|
||||
const path = `${import.meta.env.VITE_BASE_URL}/employee/edit/${this.employee_id}`;
|
||||
axios.post(path, payload, { withCredentials: true, headers: authHeader() })
|
||||
// Convert active from boolean to integer for API
|
||||
const apiPayload = { ...payload, active: payload.active ? 1 : 0 };
|
||||
axios.post(path, apiPayload, { withCredentials: true, headers: authHeader() })
|
||||
.then((response: any) => {
|
||||
if (response.data.ok) {
|
||||
this.$router.push({ name: "employeeProfile", params: { id: this.employee_id } });
|
||||
@@ -250,7 +265,10 @@ export default defineComponent({
|
||||
const path = `${import.meta.env.VITE_BASE_URL}/employee/${userid}`;
|
||||
axios.get(path, { withCredentials: true, headers: authHeader() })
|
||||
.then((response: any) => {
|
||||
this.CreateEmployeeForm = response.data;
|
||||
const data = response.data;
|
||||
// Convert active from integer to boolean
|
||||
data.active = data.active === 1;
|
||||
this.CreateEmployeeForm = data;
|
||||
});
|
||||
},
|
||||
onSubmit() {
|
||||
@@ -273,4 +291,4 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -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">
|
||||
@@ -47,6 +47,7 @@
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<router-link :to="{ name: 'employeeEdit', params: { id: person.id } }" class="btn btn-sm btn-secondary">Edit</router-link>
|
||||
<router-link :to="{ name: 'employeeProfile', params: { id: person.id } }" class="btn btn-sm btn-ghost">View</router-link>
|
||||
<router-link v-if="user && user.user_admin === 0" :to="{ name: 'employeeChangePassword', params: { id: person.id } }" class="btn btn-sm btn-warning">Change Password</router-link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -74,6 +75,7 @@
|
||||
<div class="card-actions justify-end flex-wrap gap-2 mt-2">
|
||||
<router-link :to="{ name: 'employeeEdit', params: { id: person.id } }" class="btn btn-sm btn-secondary">Edit</router-link>
|
||||
<router-link :to="{ name: 'employeeProfile', params: { id: person.id } }" class="btn btn-sm btn-ghost">View</router-link>
|
||||
<router-link v-if="user && user.user_admin === 0" :to="{ name: 'employeeChangePassword', params: { id: person.id } }" class="btn btn-sm btn-warning">Change Password</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,7 +105,7 @@ export default defineComponent({
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
user: null,
|
||||
user: {} as any,
|
||||
employees: [] as any[],
|
||||
page: 1,
|
||||
perPage: 50,
|
||||
@@ -166,4 +168,4 @@ export default defineComponent({
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -4,6 +4,7 @@ import EmployeeHome from '../employee/home.vue';
|
||||
import EmployeeCreate from "../employee/create.vue";
|
||||
import EmployeeEdit from "../employee/edit.vue";
|
||||
import EmployeeProfile from "../employee/profile/home.vue";
|
||||
import EmployeeChangePassword from "../employee/changepassword.vue";
|
||||
|
||||
const employeeRoutes = [
|
||||
{
|
||||
@@ -26,6 +27,11 @@ const employeeRoutes = [
|
||||
name: 'employeeProfile',
|
||||
component: EmployeeProfile,
|
||||
},
|
||||
{
|
||||
path: '/employee/changepassword/:id',
|
||||
name: 'employeeChangePassword',
|
||||
component: EmployeeChangePassword,
|
||||
},
|
||||
]
|
||||
|
||||
export default employeeRoutes
|
||||
|
||||
230
src/pages/service/ServicePlans.vue
Normal file
230
src/pages/service/ServicePlans.vue
Normal file
@@ -0,0 +1,230 @@
|
||||
<!-- src/pages/service/ServicePlans.vue -->
|
||||
<template>
|
||||
<div class="flex">
|
||||
<div class="w-full px-4 md:px-10 py-4">
|
||||
<!-- Breadcrumbs & Title -->
|
||||
<div class="text-sm breadcrumbs">
|
||||
<ul>
|
||||
<li><router-link :to="{ name: 'home' }">Home</router-link></li>
|
||||
<li><router-link :to="{ name: 'ServiceHome' }">Service</router-link></li>
|
||||
<li>Service Plans</li>
|
||||
</ul>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold mt-4">Service Plans</h1>
|
||||
|
||||
<!-- Main Content Card -->
|
||||
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
|
||||
<!-- Header: Title and Count -->
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-4">
|
||||
<h2 class="text-lg font-bold">Active Service Contracts</h2>
|
||||
<div v-if="!isLoading" class="badge badge-ghost">{{ servicePlans.length }} contracts found</div>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="text-center p-10">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
<p class="mt-2">Loading service plans...</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="servicePlans.length === 0" class="text-center p-10">
|
||||
<p>No active service contracts found.</p>
|
||||
</div>
|
||||
|
||||
<!-- Data Display -->
|
||||
<div v-else>
|
||||
<!-- DESKTOP VIEW: Table -->
|
||||
<div class="overflow-x-auto hidden xl:block">
|
||||
<table class="table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Customer</th>
|
||||
<th>Plan Type</th>
|
||||
<th>Contract Years</th>
|
||||
<th>Start Date</th>
|
||||
<th>End Date</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="plan in servicePlans" :key="plan.id" class="hover:bg-blue-600">
|
||||
<td class="align-top">
|
||||
<div class="font-semibold">{{ plan.customer_name }}</div>
|
||||
<div class="text-sm opacity-70">{{ plan.customer_address }}, {{ plan.customer_town }}</div>
|
||||
</td>
|
||||
<td class="align-top">
|
||||
<span class="badge badge-sm text-white"
|
||||
:style="{ 'background-color': getPlanColor(plan.contract_plan) }">
|
||||
{{ getPlanName(plan.contract_plan) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="align-top">{{ plan.contract_years }} year{{ plan.contract_years > 1 ? 's' : '' }}</td>
|
||||
<td class="align-top">{{ formatDate(plan.contract_start_date) }}</td>
|
||||
<td class="align-top">{{ formatEndDate(plan.contract_start_date, plan.contract_years) }}</td>
|
||||
<td class="align-top">
|
||||
<span class="badge" :class="getStatusBadge(plan.contract_start_date, plan.contract_years)">
|
||||
{{ getStatusText(plan.contract_start_date, plan.contract_years) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- MOBILE VIEW: Cards -->
|
||||
<div class="xl:hidden space-y-4">
|
||||
<div v-for="plan in servicePlans" :key="plan.id" class="card bg-base-100 shadow-md">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 class="card-title text-base">{{ plan.customer_name }}</h2>
|
||||
<p class="text-xs text-gray-400">{{ plan.customer_address }}, {{ plan.customer_town }}</p>
|
||||
</div>
|
||||
<div class="badge badge-outline" :style="{ 'border-color': getPlanColor(plan.contract_plan), color: getPlanColor(plan.contract_plan) }">
|
||||
{{ getPlanName(plan.contract_plan) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
|
||||
<p><strong class="font-semibold">Years:</strong> {{ plan.contract_years }}</p>
|
||||
<p><strong class="font-semibold">Start:</strong> {{ formatDate(plan.contract_start_date) }}</p>
|
||||
<p><strong class="font-semibold">End:</strong> {{ formatEndDate(plan.contract_start_date, plan.contract_years) }}</p>
|
||||
<p><strong class="font-semibold">Status:</strong>
|
||||
<span class="badge badge-sm ml-1" :class="getStatusBadge(plan.contract_start_date, plan.contract_years)">
|
||||
{{ getStatusText(plan.contract_start_date, plan.contract_years) }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import axios from 'axios'
|
||||
import authHeader from '../../services/auth.header'
|
||||
import Footer from '../../layouts/footers/footer.vue'
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface ServicePlan {
|
||||
id: number;
|
||||
customer_id: number;
|
||||
customer_name: string;
|
||||
customer_address: string;
|
||||
customer_town: string;
|
||||
contract_plan: number;
|
||||
contract_years: number;
|
||||
contract_start_date: string;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ServicePlans',
|
||||
components: { Footer },
|
||||
data() {
|
||||
return {
|
||||
user: null,
|
||||
servicePlans: [] as ServicePlan[],
|
||||
isLoading: true,
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.userStatus();
|
||||
this.fetchServicePlans();
|
||||
},
|
||||
methods: {
|
||||
async fetchServicePlans(): Promise<void> {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const path = import.meta.env.VITE_BASE_URL + '/service/plans/active';
|
||||
const response = await axios.get(path, {
|
||||
headers: authHeader(),
|
||||
withCredentials: true,
|
||||
});
|
||||
this.servicePlans = response.data;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch service plans:", error);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
userStatus() {
|
||||
let path = import.meta.env.VITE_BASE_URL + '/auth/whoami';
|
||||
axios({
|
||||
method: 'get',
|
||||
url: path,
|
||||
withCredentials: true,
|
||||
headers: authHeader(),
|
||||
})
|
||||
.then((response: any) => {
|
||||
if (response.data.ok) {
|
||||
this.user = response.data.user;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.user = null
|
||||
})
|
||||
},
|
||||
|
||||
getPlanName(planType: number): string {
|
||||
const planNames: { [key: number]: string } = {
|
||||
1: 'Standard',
|
||||
2: 'Premium'
|
||||
};
|
||||
return planNames[planType] || 'Unknown';
|
||||
},
|
||||
|
||||
getPlanColor(planType: number): string {
|
||||
const planColors: { [key: number]: string } = {
|
||||
1: 'blue',
|
||||
2: 'gold'
|
||||
};
|
||||
return planColors[planType] || 'gray';
|
||||
},
|
||||
|
||||
formatDate(dateString: string): string {
|
||||
if (!dateString) return 'N/A';
|
||||
return dayjs(dateString).format('MMM D, YYYY');
|
||||
},
|
||||
|
||||
formatEndDate(startDate: string, years: number): string {
|
||||
if (!startDate) return 'N/A';
|
||||
return dayjs(startDate).add(years, 'year').format('MMM D, YYYY');
|
||||
},
|
||||
|
||||
getStatusText(startDate: string, years: number): string {
|
||||
if (!startDate) return 'Unknown';
|
||||
const endDate = dayjs(startDate).add(years, 'year');
|
||||
const now = dayjs();
|
||||
if (now.isAfter(endDate)) {
|
||||
return 'Expired';
|
||||
} else if (now.isAfter(endDate.subtract(30, 'day'))) {
|
||||
return 'Expiring Soon';
|
||||
} else {
|
||||
return 'Active';
|
||||
}
|
||||
},
|
||||
|
||||
getStatusBadge(startDate: string, years: number): string {
|
||||
if (!startDate) return 'badge-ghost';
|
||||
const endDate = dayjs(startDate).add(years, 'year');
|
||||
const now = dayjs();
|
||||
if (now.isAfter(endDate)) {
|
||||
return 'badge-error';
|
||||
} else if (now.isAfter(endDate.subtract(30, 'day'))) {
|
||||
return 'badge-warning';
|
||||
} else {
|
||||
return 'badge-success';
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -4,6 +4,7 @@ import ServicePast from './ServicePast.vue'
|
||||
import CalendarCustomer from './calender/CalendarCustomer.vue'
|
||||
import ServiceCalendar from './ServiceCalendar.vue'
|
||||
import ServiceToday from './ServiceToday.vue'
|
||||
import ServicePlans from './ServicePlans.vue'
|
||||
|
||||
const serviceRoutes = [
|
||||
{
|
||||
@@ -34,6 +35,11 @@ const serviceRoutes = [
|
||||
name: 'ServiceToday',
|
||||
component: ServiceToday,
|
||||
},
|
||||
{
|
||||
path: '/service/plans',
|
||||
name: 'ServicePlans',
|
||||
component: ServicePlans,
|
||||
},
|
||||
]
|
||||
|
||||
export default serviceRoutes
|
||||
|
||||
Reference in New Issue
Block a user