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

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

View File

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

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

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

View File

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

View File

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

View File

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

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

View File

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