Added service plan. Password change

This commit is contained in:
2025-09-06 12:28:44 -04:00
parent 9d86b4a60e
commit 3282229116
14 changed files with 977 additions and 51 deletions

View 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">&nbsp;</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>

View File

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

View File

@@ -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');
}
}
},
})

View File

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

View File

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

View File

@@ -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',