big update
This commit is contained in:
parent
d53ebf9ecd
commit
5c7e93fcc7
26 changed files with 1890 additions and 423 deletions
|
|
@ -40,7 +40,7 @@ const FRAPPE_GET_ADDRESSES_METHOD = "custom_ui.api.db.addresses.get_addresses";
|
|||
const FRAPPE_UPSERT_CLIENT_METHOD = "custom_ui.api.db.clients.upsert_client";
|
||||
const FRAPPE_GET_CLIENT_STATUS_COUNTS_METHOD = "custom_ui.api.db.clients.get_client_status_counts";
|
||||
const FRAPPE_GET_CLIENT_TABLE_DATA_METHOD = "custom_ui.api.db.clients.get_clients_table_data";
|
||||
const FRAPPE_GET_CLIENT_METHOD = "custom_ui.api.db.clients.get_client";
|
||||
const FRAPPE_GET_CLIENT_METHOD = "custom_ui.api.db.clients.get_client_v2";
|
||||
const FRAPPE_GET_CLIENT_NAMES_METHOD = "custom_ui.api.db.clients.get_client_names";
|
||||
|
||||
class Api {
|
||||
|
|
@ -157,10 +157,15 @@ class Api {
|
|||
});
|
||||
}
|
||||
|
||||
static async createBidMeeting(address, notes = "") {
|
||||
static async getBidMeeting(name) {
|
||||
return await this.request("custom_ui.api.db.bid_meetings.get_bid_meeting", {
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
static async createBidMeeting(data) {
|
||||
return await this.request("custom_ui.api.db.bid_meetings.create_bid_meeting", {
|
||||
address,
|
||||
notes,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -196,14 +196,17 @@
|
|||
|
||||
<!-- Meeting Details Modal -->
|
||||
<MeetingDetailsModal
|
||||
v-model:visible="showMeetingModal"
|
||||
:visible="showMeetingModal"
|
||||
@update:visible="showMeetingModal = $event"
|
||||
:meeting="selectedMeeting"
|
||||
@close="closeMeetingModal"
|
||||
@meeting-updated="handleMeetingUpdated"
|
||||
/>
|
||||
|
||||
<!-- New Meeting Modal -->
|
||||
<BidMeetingModal
|
||||
v-model:visible="showNewMeetingModal"
|
||||
:visible="showNewMeetingModal"
|
||||
@update:visible="showNewMeetingModal = $event"
|
||||
:initial-address="queryAddress"
|
||||
@confirm="handleNewMeetingConfirm"
|
||||
@cancel="handleNewMeetingCancel"
|
||||
|
|
@ -228,6 +231,7 @@ const notificationStore = useNotificationStore();
|
|||
// Query parameters
|
||||
const isNewMode = computed(() => route.query.new === "true");
|
||||
const queryAddress = computed(() => route.query.address || "");
|
||||
const queryMeetingName = computed(() => route.query.name || "");
|
||||
|
||||
// Date management
|
||||
const currentWeekStart = ref(new Date());
|
||||
|
|
@ -459,6 +463,12 @@ const closeMeetingModal = () => {
|
|||
selectedMeeting.value = null;
|
||||
};
|
||||
|
||||
const handleMeetingUpdated = async () => {
|
||||
// Reload both scheduled and unscheduled meetings
|
||||
await loadWeekMeetings();
|
||||
await loadUnscheduledMeetings();
|
||||
};
|
||||
|
||||
const openNewMeetingModal = () => {
|
||||
showNewMeetingModal.value = true;
|
||||
};
|
||||
|
|
@ -470,7 +480,7 @@ const handleNewMeetingConfirm = async (meetingData) => {
|
|||
loadingStore.setLoading(true);
|
||||
|
||||
// Create the meeting via API
|
||||
const result = await Api.createBidMeeting(meetingData.address, meetingData.notes || "");
|
||||
const result = await Api.createBidMeeting(meetingData);
|
||||
|
||||
showNewMeetingModal.value = false;
|
||||
|
||||
|
|
@ -955,6 +965,100 @@ const navigateToSpecificMeeting = async () => {
|
|||
}
|
||||
};
|
||||
|
||||
const findAndDisplayMeetingByName = async () => {
|
||||
if (!queryMeetingName.value) return;
|
||||
|
||||
console.log("Searching for meeting:", queryMeetingName.value);
|
||||
|
||||
// First, search in the unscheduled meetings list
|
||||
const unscheduledMeeting = unscheduledMeetings.value.find(
|
||||
(m) => m.name === queryMeetingName.value
|
||||
);
|
||||
|
||||
if (unscheduledMeeting) {
|
||||
console.log("Found in unscheduled meetings:", unscheduledMeeting);
|
||||
// Meeting is unscheduled, just show notification
|
||||
notificationStore.addNotification({
|
||||
type: "info",
|
||||
title: "Unscheduled Meeting",
|
||||
message: "This meeting has not been scheduled yet. Drag it to a time slot to schedule it.",
|
||||
duration: 6000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Not in unscheduled list, fetch from API to get schedule details
|
||||
try {
|
||||
loadingStore.setLoading(true);
|
||||
const meetingData = await Api.getBidMeeting(queryMeetingName.value);
|
||||
|
||||
if (!meetingData) {
|
||||
notificationStore.addNotification({
|
||||
type: "error",
|
||||
title: "Meeting Not Found",
|
||||
message: "Could not find the specified meeting.",
|
||||
duration: 5000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if meeting is scheduled
|
||||
if (!meetingData.startTime) {
|
||||
notificationStore.addNotification({
|
||||
type: "info",
|
||||
title: "Unscheduled Meeting",
|
||||
message: "This meeting has not been scheduled yet.",
|
||||
duration: 5000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the start time to get date and time
|
||||
const startDateTime = new Date(meetingData.startTime);
|
||||
const meetingDate = startDateTime.toISOString().split("T")[0];
|
||||
const meetingTime = `${startDateTime.getHours().toString().padStart(2, "0")}:${startDateTime.getMinutes().toString().padStart(2, "0")}`;
|
||||
|
||||
// Navigate to the week containing this meeting
|
||||
currentWeekStart.value = new Date(
|
||||
startDateTime.getFullYear(),
|
||||
startDateTime.getMonth(),
|
||||
startDateTime.getDate()
|
||||
);
|
||||
|
||||
// Reload meetings for this week
|
||||
await loadWeekMeetings();
|
||||
|
||||
// Find the meeting in the loaded meetings
|
||||
const scheduledMeeting = meetings.value.find(
|
||||
(m) => m.name === queryMeetingName.value
|
||||
);
|
||||
|
||||
if (scheduledMeeting) {
|
||||
// Auto-open the meeting details modal
|
||||
setTimeout(() => {
|
||||
showMeetingDetails(scheduledMeeting);
|
||||
}, 300);
|
||||
} else {
|
||||
notificationStore.addNotification({
|
||||
type: "warning",
|
||||
title: "Meeting Found",
|
||||
message: `Meeting is scheduled for ${formatDate(meetingDate)} at ${formatTimeDisplay(meetingTime)}`,
|
||||
duration: 6000,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching meeting:", error);
|
||||
notificationStore.addNotification({
|
||||
type: "error",
|
||||
title: "Error",
|
||||
message: "Failed to load meeting details.",
|
||||
duration: 5000,
|
||||
});
|
||||
} finally {
|
||||
loadingStore.setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
initializeWeek();
|
||||
|
|
@ -967,6 +1071,9 @@ onMounted(async () => {
|
|||
setTimeout(() => {
|
||||
openNewMeetingModal();
|
||||
}, 500);
|
||||
} else if (queryMeetingName.value) {
|
||||
// Find and display specific meeting by name
|
||||
await findAndDisplayMeetingByName();
|
||||
} else if (queryAddress.value) {
|
||||
// View mode with address - find and show existing meeting details
|
||||
await navigateToSpecificMeeting();
|
||||
|
|
|
|||
|
|
@ -1,76 +1,144 @@
|
|||
<template>
|
||||
<div class="form-section">
|
||||
<h3>Property Address Information</h3>
|
||||
<div class="section-header">
|
||||
<h3>Property Address Information</h3>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<div class="form-field full-width">
|
||||
<label for="address-line1"> Address Line 1 <span class="required">*</span> </label>
|
||||
<InputText
|
||||
id="address-line1"
|
||||
v-model="localFormData.addressLine1"
|
||||
:disabled="isSubmitting"
|
||||
placeholder="Street address"
|
||||
class="w-full"
|
||||
/>
|
||||
<div
|
||||
v-for="(address, index) in localFormData.addresses"
|
||||
:key="index"
|
||||
class="address-item"
|
||||
>
|
||||
<div class="address-header">
|
||||
<h4>Address {{ index + 1 }}</h4>
|
||||
<Button
|
||||
v-if="localFormData.addresses.length > 1"
|
||||
@click="removeAddress(index)"
|
||||
size="small"
|
||||
severity="danger"
|
||||
label="Delete"
|
||||
class="remove-btn"
|
||||
/>
|
||||
</div>
|
||||
<div class="address-fields">
|
||||
<div class="form-field full-width">
|
||||
<label :for="`address-line1-${index}`">
|
||||
Address Line 1 <span class="required">*</span>
|
||||
</label>
|
||||
<InputText
|
||||
:id="`address-line1-${index}`"
|
||||
v-model="address.addressLine1"
|
||||
:disabled="isSubmitting"
|
||||
placeholder="Street address"
|
||||
class="w-full"
|
||||
@input="formatAddressLine(index, 'addressLine1', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-field full-width">
|
||||
<label :for="`address-line2-${index}`">Address Line 2</label>
|
||||
<InputText
|
||||
:id="`address-line2-${index}`"
|
||||
v-model="address.addressLine2"
|
||||
:disabled="isSubmitting"
|
||||
placeholder="Apt, suite, unit, etc."
|
||||
class="w-full"
|
||||
@input="formatAddressLine(index, 'addressLine2', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-field full-width checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
:id="`isBilling-${index}`"
|
||||
v-model="address.isBillingAddress"
|
||||
:disabled="isSubmitting"
|
||||
style="margin-top: 0"
|
||||
/>
|
||||
<label :for="`isBilling-${index}`">Is Billing Address</label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-field">
|
||||
<label :for="`zipcode-${index}`">
|
||||
Zip Code <span class="required">*</span>
|
||||
</label>
|
||||
<InputText
|
||||
:id="`zipcode-${index}`"
|
||||
v-model="address.pincode"
|
||||
:disabled="isSubmitting"
|
||||
@input="handleZipcodeInput(index, $event)"
|
||||
maxlength="5"
|
||||
placeholder="12345"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label :for="`city-${index}`">
|
||||
City <span class="required">*</span>
|
||||
</label>
|
||||
<InputText
|
||||
:id="`city-${index}`"
|
||||
v-model="address.city"
|
||||
:disabled="isSubmitting || address.zipcodeLookupDisabled"
|
||||
placeholder="City"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label :for="`state-${index}`">
|
||||
State <span class="required">*</span>
|
||||
</label>
|
||||
<InputText
|
||||
:id="`state-${index}`"
|
||||
v-model="address.state"
|
||||
:disabled="isSubmitting || address.zipcodeLookupDisabled"
|
||||
placeholder="State"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-field">
|
||||
<label :for="`contacts-${index}`">Assigned Contacts</label>
|
||||
<MultiSelect
|
||||
:id="`contacts-${index}`"
|
||||
v-model="address.contacts"
|
||||
:options="contactOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
:disabled="isSubmitting || contactOptions.length === 0"
|
||||
placeholder="Select contacts"
|
||||
class="w-full"
|
||||
display="chip"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label :for="`primaryContact-${index}`">Primary Contact</label>
|
||||
<Select
|
||||
:id="`primaryContact-${index}`"
|
||||
v-model="address.primaryContact"
|
||||
:options="contactOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
:disabled="isSubmitting || contactOptions.length === 0"
|
||||
placeholder="Select primary contact"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-field full-width">
|
||||
<label for="address-line2">Address Line 2</label>
|
||||
<InputText
|
||||
id="address-line2"
|
||||
v-model="localFormData.addressLine2"
|
||||
:disabled="isSubmitting"
|
||||
placeholder="Apt, suite, unit, etc."
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-field full-width checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isBilling"
|
||||
v-model="localFormData.isBillingAddress"
|
||||
:disabled="isSubmitting"
|
||||
style="margin-top: 0"
|
||||
/>
|
||||
<label for="isBilling">Is Billing Address</label>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="zipcode"> Zip Code <span class="required">*</span> </label>
|
||||
<InputText
|
||||
id="zipcode"
|
||||
v-model="localFormData.pincode"
|
||||
:disabled="isSubmitting"
|
||||
@input="handleZipcodeInput"
|
||||
maxlength="5"
|
||||
placeholder="12345"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="city"> City <span class="required">*</span> </label>
|
||||
<InputText
|
||||
id="city"
|
||||
v-model="localFormData.city"
|
||||
:disabled="isSubmitting || zipcodeLookupDisabled"
|
||||
placeholder="City"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="state"> State <span class="required">*</span> </label>
|
||||
<InputText
|
||||
id="state"
|
||||
v-model="localFormData.state"
|
||||
:disabled="isSubmitting || zipcodeLookupDisabled"
|
||||
placeholder="State"
|
||||
class="w-full"
|
||||
/>
|
||||
<Button label="Add another address" @click="addAddress" :disabled="isSubmitting" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
import InputText from "primevue/inputtext";
|
||||
import Select from "primevue/select";
|
||||
import MultiSelect from "primevue/multiselect";
|
||||
import Button from "primevue/button";
|
||||
import Api from "../../api";
|
||||
import { useNotificationStore } from "../../stores/notifications-primevue";
|
||||
|
||||
|
|
@ -94,19 +162,92 @@ const emit = defineEmits(["update:formData"]);
|
|||
const notificationStore = useNotificationStore();
|
||||
|
||||
const localFormData = computed({
|
||||
get: () => props.formData,
|
||||
get: () => {
|
||||
if (!props.formData.addresses || props.formData.addresses.length === 0) {
|
||||
props.formData.addresses = [
|
||||
{
|
||||
addressLine1: "",
|
||||
addressLine2: "",
|
||||
isBillingAddress: true,
|
||||
pincode: "",
|
||||
city: "",
|
||||
state: "",
|
||||
contacts: [],
|
||||
primaryContact: null,
|
||||
zipcodeLookupDisabled: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
return props.formData;
|
||||
},
|
||||
set: (value) => emit("update:formData", value),
|
||||
});
|
||||
|
||||
const contactOptions = computed(() => {
|
||||
if (!localFormData.value.contacts || localFormData.value.contacts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return localFormData.value.contacts.map((contact, index) => ({
|
||||
label: `${contact.firstName || ""} ${contact.lastName || ""}`.trim() || `Contact ${index + 1}`,
|
||||
value: index,
|
||||
}));
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (localFormData.value.isBillingAddress === undefined) {
|
||||
localFormData.value.isBillingAddress = true;
|
||||
if (!localFormData.value.addresses || localFormData.value.addresses.length === 0) {
|
||||
localFormData.value.addresses = [
|
||||
{
|
||||
addressLine1: "",
|
||||
addressLine2: "",
|
||||
isBillingAddress: true,
|
||||
pincode: "",
|
||||
city: "",
|
||||
state: "",
|
||||
contacts: [],
|
||||
primaryContact: null,
|
||||
zipcodeLookupDisabled: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
const zipcodeLookupDisabled = ref(true);
|
||||
const addAddress = () => {
|
||||
localFormData.value.addresses.push({
|
||||
addressLine1: "",
|
||||
addressLine2: "",
|
||||
isBillingAddress: false,
|
||||
pincode: "",
|
||||
city: "",
|
||||
state: "",
|
||||
contacts: [],
|
||||
primaryContact: null,
|
||||
zipcodeLookupDisabled: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleZipcodeInput = async (event) => {
|
||||
const removeAddress = (index) => {
|
||||
if (localFormData.value.addresses.length > 1) {
|
||||
localFormData.value.addresses.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
const formatAddressLine = (index, field, event) => {
|
||||
const value = event.target.value;
|
||||
if (!value) return;
|
||||
|
||||
// Capitalize first letter of each word
|
||||
const formatted = value
|
||||
.split(' ')
|
||||
.map(word => {
|
||||
if (!word) return word;
|
||||
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
|
||||
})
|
||||
.join(' ');
|
||||
|
||||
localFormData.value.addresses[index][field] = formatted;
|
||||
};
|
||||
|
||||
const handleZipcodeInput = async (index, event) => {
|
||||
const input = event.target.value;
|
||||
|
||||
// Only allow digits
|
||||
|
|
@ -117,13 +258,13 @@ const handleZipcodeInput = async (event) => {
|
|||
return;
|
||||
}
|
||||
|
||||
localFormData.value.pincode = digitsOnly;
|
||||
localFormData.value.addresses[index].pincode = digitsOnly;
|
||||
|
||||
// Reset city/state if zipcode is not complete
|
||||
if (digitsOnly.length < 5 && zipcodeLookupDisabled.value) {
|
||||
localFormData.value.city = "";
|
||||
localFormData.value.state = "";
|
||||
zipcodeLookupDisabled.value = false;
|
||||
if (digitsOnly.length < 5 && localFormData.value.addresses[index].zipcodeLookupDisabled) {
|
||||
localFormData.value.addresses[index].city = "";
|
||||
localFormData.value.addresses[index].state = "";
|
||||
localFormData.value.addresses[index].zipcodeLookupDisabled = false;
|
||||
}
|
||||
|
||||
// Fetch city/state when 5 digits entered
|
||||
|
|
@ -134,14 +275,14 @@ const handleZipcodeInput = async (event) => {
|
|||
console.log("DEBUG: Retrieved places:", places);
|
||||
if (places && places.length > 0) {
|
||||
// Auto-populate city and state
|
||||
localFormData.value.city = places[0]["city"];
|
||||
localFormData.value.state = places[0]["state"];
|
||||
zipcodeLookupDisabled.value = true;
|
||||
localFormData.value.addresses[index].city = places[0]["city"];
|
||||
localFormData.value.addresses[index].state = places[0]["state"];
|
||||
localFormData.value.addresses[index].zipcodeLookupDisabled = true;
|
||||
notificationStore.addSuccess(`Found: ${places[0]["city"]}, ${places[0]["state"]}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Enable manual entry if lookup fails
|
||||
zipcodeLookupDisabled.value = false;
|
||||
localFormData.value.addresses[index].zipcodeLookupDisabled = false;
|
||||
notificationStore.addWarning(
|
||||
"Could not find city/state for this zip code. Please enter manually.",
|
||||
);
|
||||
|
|
@ -159,8 +300,15 @@ const handleZipcodeInput = async (event) => {
|
|||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.form-section h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
margin: 0;
|
||||
color: var(--text-color);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
|
|
@ -169,7 +317,43 @@ const handleZipcodeInput = async (event) => {
|
|||
.form-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.address-item {
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background: var(--surface-section);
|
||||
}
|
||||
|
||||
.address-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.address-header h4 {
|
||||
margin: 0;
|
||||
color: var(--text-color);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.address-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
|
|
@ -177,10 +361,11 @@ const handleZipcodeInput = async (event) => {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.form-field.full-width {
|
||||
grid-column: 1 / -1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-field label {
|
||||
|
|
@ -209,6 +394,10 @@ const handleZipcodeInput = async (event) => {
|
|||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
@change="setPrimary(index)"
|
||||
/>
|
||||
<label :for="`checkbox-${index}`">
|
||||
Primary Contact
|
||||
Client Primary Contact
|
||||
</label>
|
||||
</div>
|
||||
<Button
|
||||
|
|
@ -48,6 +48,7 @@
|
|||
:disabled="isSubmitting"
|
||||
placeholder="Enter first name"
|
||||
class="w-full"
|
||||
@input="formatName(index, 'firstName', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
|
|
@ -60,6 +61,7 @@
|
|||
:disabled="isSubmitting"
|
||||
placeholder="Enter last name"
|
||||
class="w-full"
|
||||
@input="formatName(index, 'lastName', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
|
|
@ -210,6 +212,15 @@ const setPrimary = (index) => {
|
|||
});
|
||||
};
|
||||
|
||||
const formatName = (index, field, event) => {
|
||||
const value = event.target.value;
|
||||
if (!value) return;
|
||||
|
||||
// Capitalize first letter, lowercase the rest
|
||||
const formatted = value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
|
||||
localFormData.value.contacts[index][field] = formatted;
|
||||
};
|
||||
|
||||
const formatPhoneNumber = (value) => {
|
||||
const digits = value.replace(/\D/g, "").slice(0, 10);
|
||||
if (digits.length <= 3) return digits;
|
||||
|
|
|
|||
|
|
@ -16,8 +16,31 @@
|
|||
size="small"
|
||||
severity="secondary"
|
||||
/>
|
||||
<Button
|
||||
@click="handleCreateBidMeeting"
|
||||
icon=""
|
||||
label="Create Bid Meeting"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- SNW Installation Status -->
|
||||
<div v-if="!isNew && !editMode" class="install-status-section">
|
||||
<InstallStatus
|
||||
:onsite-meeting-status="snwInstallData.onsiteMeetingStatus"
|
||||
:estimate-sent-status="snwInstallData.estimateSentStatus"
|
||||
:job-status="snwInstallData.jobStatus"
|
||||
:payment-status="snwInstallData.paymentStatus"
|
||||
:full-address="fullAddress"
|
||||
:bid-meeting="snwInstallData.onsiteMeeting"
|
||||
:estimate="snwInstallData.estimate"
|
||||
:job="snwInstallData.job"
|
||||
:payment="snwInstallData.payment"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="status-cards">
|
||||
<template v-if="isNew || editMode">
|
||||
<ClientInformationForm
|
||||
|
|
@ -217,6 +240,7 @@ import ClientInformationForm from "./ClientInformationForm.vue";
|
|||
import ContactInformationForm from "./ContactInformationForm.vue";
|
||||
import AddressInformationForm from "./AddressInformationForm.vue";
|
||||
import History from "./History.vue";
|
||||
import InstallStatus from "../clientView/InstallStatus.vue";
|
||||
import DataUtils from "../../utils";
|
||||
import Api from "../../api";
|
||||
import { useRouter } from "vue-router";
|
||||
|
|
@ -278,6 +302,7 @@ onMounted(() => {
|
|||
console.log("Mounted in new client mode - initialized empty form");
|
||||
} else if (props.clientData && Object.keys(props.clientData).length > 0) {
|
||||
populateFormFromClientData();
|
||||
checkClientCompanyAssociation(props.clientData);
|
||||
console.log("Mounted with existing client data - populated form");
|
||||
} else {
|
||||
resetForm();
|
||||
|
|
@ -293,6 +318,7 @@ watch(
|
|||
resetForm();
|
||||
} else if (newData && Object.keys(newData).length > 0) {
|
||||
populateFormFromClientData();
|
||||
checkClientCompanyAssociation(newData);
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
|
|
@ -317,6 +343,16 @@ watch(
|
|||
{ immediate: false },
|
||||
);
|
||||
|
||||
// Watch for company changes to re-check association
|
||||
watch(
|
||||
() => companyStore.currentCompany,
|
||||
() => {
|
||||
if (!props.isNew && props.clientData && Object.keys(props.clientData).length > 0) {
|
||||
checkClientCompanyAssociation(props.clientData);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Find the address data object that matches the selected address string
|
||||
const selectedAddressData = computed(() => {
|
||||
if (!props.clientData?.addresses || !props.selectedAddress) {
|
||||
|
|
@ -347,6 +383,48 @@ const fullAddress = computed(() => {
|
|||
return DataUtils.calculateFullAddress(selectedAddressData.value);
|
||||
});
|
||||
|
||||
// Computed data for SNW Install status
|
||||
const snwInstallData = computed(() => {
|
||||
if (!selectedAddressData.value) {
|
||||
return {
|
||||
onsiteMeetingStatus: "Not Started",
|
||||
estimateSentStatus: "Not Started",
|
||||
jobStatus: "Not Started",
|
||||
paymentStatus: "Not Started",
|
||||
bidMeeting: "",
|
||||
estimate: "",
|
||||
job: "",
|
||||
payment: "dummy-payment-string",
|
||||
};
|
||||
}
|
||||
|
||||
const addr = selectedAddressData.value;
|
||||
|
||||
// Filter for SNW Install template
|
||||
const snwBidMeeting = addr.onsiteMeetings?.find(
|
||||
(m) => m.projectTemplate === "SNW Install"
|
||||
);
|
||||
const snwEstimate = addr.quotations?.find(
|
||||
(q) => q.projectTemplate === "SNW Install"
|
||||
);
|
||||
const snwJob = addr.projects?.find(
|
||||
(p) => p.projectTemplate === "SNW Install"
|
||||
);
|
||||
|
||||
const stuff = {
|
||||
onsiteMeetingStatus: addr.onsiteMeetingScheduled || "Not Started",
|
||||
estimateSentStatus: addr.estimateSentStatus || "Not Started",
|
||||
jobStatus: addr.jobStatus || "Not Started",
|
||||
paymentStatus: addr.paymentReceivedStatus || "Not Started",
|
||||
onsiteMeeting: snwBidMeeting?.onsiteMeeting || "",
|
||||
estimate: snwEstimate?.quotation || "",
|
||||
job: snwJob?.project || "",
|
||||
payment: "dummy-payment-string",
|
||||
};
|
||||
console.log("DEBUG: SNW Install Data Computed:", stuff);
|
||||
return stuff;
|
||||
});
|
||||
|
||||
// Get contacts linked to the selected address
|
||||
const contactsForAddress = computed(() => {
|
||||
console.log("DEBUG: props.clientData:", props.clientData);
|
||||
|
|
@ -398,11 +476,8 @@ const primaryContactEmail = computed(() => {
|
|||
const isFormValid = computed(() => {
|
||||
const hasCustomerName = formData.value.customerName?.trim();
|
||||
const hasCustomerType = formData.value.customerType?.trim();
|
||||
const hasAddressLine1 = formData.value.addressLine1?.trim();
|
||||
const hasPincode = formData.value.pincode?.trim();
|
||||
const hasCity = formData.value.city?.trim();
|
||||
const hasState = formData.value.state?.trim();
|
||||
const hasContacts = formData.value.contacts && formData.value.contacts.length > 0;
|
||||
const hasAddresses = formData.value.addresses && formData.value.addresses.length > 0;
|
||||
|
||||
// Check that all contacts have required fields
|
||||
const allContactsValid = formData.value.contacts?.every((contact) => {
|
||||
|
|
@ -415,15 +490,30 @@ const isFormValid = computed(() => {
|
|||
);
|
||||
});
|
||||
|
||||
// Check that all addresses have required fields
|
||||
const allAddressesValid = formData.value.addresses?.every((address) => {
|
||||
const hasRequiredFields =
|
||||
address.addressLine1?.trim() &&
|
||||
address.pincode?.trim() &&
|
||||
address.city?.trim() &&
|
||||
address.state?.trim();
|
||||
|
||||
// Each address must have at least one contact selected
|
||||
const hasContactsAssigned = address.contacts && address.contacts.length > 0;
|
||||
|
||||
// Each address must have a primary contact
|
||||
const hasPrimaryContact = address.primaryContact !== null && address.primaryContact !== undefined;
|
||||
|
||||
return hasRequiredFields && hasContactsAssigned && hasPrimaryContact;
|
||||
});
|
||||
|
||||
return (
|
||||
hasCustomerName &&
|
||||
hasCustomerType &&
|
||||
hasAddressLine1 &&
|
||||
hasPincode &&
|
||||
hasCity &&
|
||||
hasState &&
|
||||
hasContacts &&
|
||||
allContactsValid
|
||||
allContactsValid &&
|
||||
hasAddresses &&
|
||||
allAddressesValid
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -523,6 +613,24 @@ const populateFormFromClientData = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const checkClientCompanyAssociation = (clientData) => {
|
||||
if (!clientData || !companyStore.currentCompany) return;
|
||||
|
||||
// Check if client has companies array
|
||||
if (!clientData.companies || !Array.isArray(clientData.companies)) return;
|
||||
|
||||
// Check if current company is in the client's companies list
|
||||
const isAssociated = clientData.companies.some(
|
||||
(companyObj) => companyObj.company === companyStore.currentCompany
|
||||
);
|
||||
|
||||
if (!isAssociated) {
|
||||
notificationStore.addWarning(
|
||||
`Warning: This client is not associated with the currently selected company (${companyStore.currentCompany}).`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Event handlers
|
||||
const handleNewClientToggle = (isNewClient) => {
|
||||
isNewClientMode.value = isNewClient;
|
||||
|
|
@ -553,6 +661,12 @@ const handleCreateEstimate = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleCreateBidMeeting = () => {
|
||||
if (props.selectedAddress) {
|
||||
router.push(`/calendar?tab=bids&new=true&address=${encodeURIComponent(props.selectedAddress)}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Edit mode methods
|
||||
const toggleEditMode = () => {
|
||||
showEditConfirmDialog.value = true;
|
||||
|
|
@ -579,13 +693,8 @@ const handleSave = async () => {
|
|||
customerName: formData.value.customerName,
|
||||
customerType: formData.value.customerType,
|
||||
companyName: companyStore.currentCompany,
|
||||
addressTitle: formData.value.addressTitle,
|
||||
addressLine1: formData.value.addressLine1,
|
||||
addressLine2: formData.value.addressLine2,
|
||||
pincode: formData.value.pincode,
|
||||
city: formData.value.city,
|
||||
state: formData.value.state,
|
||||
contacts: formData.value.contacts,
|
||||
addresses: formData.value.addresses,
|
||||
};
|
||||
|
||||
console.log("Upserting client with data:", clientData);
|
||||
|
|
@ -593,13 +702,14 @@ const handleSave = async () => {
|
|||
// Call the upsert API
|
||||
const result = await Api.createClient(clientData);
|
||||
|
||||
// Calculate full address for redirect
|
||||
const fullAddressParts = [formData.value.addressLine1];
|
||||
if (formData.value.addressLine2?.trim()) {
|
||||
fullAddressParts.push(formData.value.addressLine2);
|
||||
// Calculate full address for redirect (use first address)
|
||||
const firstAddress = formData.value.addresses[0];
|
||||
const fullAddressParts = [firstAddress.addressLine1];
|
||||
if (firstAddress.addressLine2?.trim()) {
|
||||
fullAddressParts.push(firstAddress.addressLine2);
|
||||
}
|
||||
fullAddressParts.push(`${formData.value.city}, ${formData.value.state}`);
|
||||
fullAddressParts.push(formData.value.pincode);
|
||||
fullAddressParts.push(`${firstAddress.city}, ${firstAddress.state}`);
|
||||
fullAddressParts.push(firstAddress.pincode);
|
||||
const fullAddress = fullAddressParts.join(" ");
|
||||
|
||||
if (props.isNew) {
|
||||
|
|
@ -657,6 +767,12 @@ const handleCancel = () => {
|
|||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.install-status-section {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.info-card,
|
||||
.map-card {
|
||||
background: var(--surface-card);
|
||||
|
|
|
|||
245
frontend/src/components/clientView/InstallStatus.vue
Normal file
245
frontend/src/components/clientView/InstallStatus.vue
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
<template>
|
||||
<div class="install-status-card">
|
||||
<h4>SNW Install</h4>
|
||||
<div class="status-items">
|
||||
<div
|
||||
class="status-item"
|
||||
:class="getStatusClass(onsiteMeetingStatus)"
|
||||
@click="handleBidMeetingClick"
|
||||
>
|
||||
<span class="status-label">Meeting</span>
|
||||
<span class="status-badge">{{ onsiteMeetingStatus }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="status-item"
|
||||
:class="getStatusClass(estimateSentStatus)"
|
||||
@click="handleEstimateClick"
|
||||
>
|
||||
<span class="status-label">Estimate</span>
|
||||
<span class="status-badge">{{ estimateSentStatus }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="status-item"
|
||||
:class="getStatusClass(jobStatus)"
|
||||
@click="handleJobClick"
|
||||
>
|
||||
<span class="status-label">Job</span>
|
||||
<span class="status-badge">{{ jobStatus }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="status-item"
|
||||
:class="getStatusClass(paymentStatus)"
|
||||
@click="handlePaymentClick"
|
||||
>
|
||||
<span class="status-label">Payment</span>
|
||||
<span class="status-badge">{{ paymentStatus }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from "vue-router";
|
||||
import { useNotificationStore } from "../../stores/notifications-primevue";
|
||||
|
||||
const props = defineProps({
|
||||
onsiteMeetingStatus: {
|
||||
type: String,
|
||||
default: "Not Started",
|
||||
},
|
||||
estimateSentStatus: {
|
||||
type: String,
|
||||
default: "Not Started",
|
||||
},
|
||||
jobStatus: {
|
||||
type: String,
|
||||
default: "Not Started",
|
||||
},
|
||||
paymentStatus: {
|
||||
type: String,
|
||||
default: "Not Started",
|
||||
},
|
||||
fullAddress: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
bidMeeting: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
estimate: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
job: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
payment: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const notificationStore = useNotificationStore();
|
||||
|
||||
const getStatusClass = (status) => {
|
||||
switch (status) {
|
||||
case "Not Started":
|
||||
return "status-not-started";
|
||||
case "In Progress":
|
||||
return "status-in-progress";
|
||||
case "Completed":
|
||||
return "status-completed";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleBidMeetingClick = () => {
|
||||
if (props.onsiteMeetingStatus === "Not Started") {
|
||||
router.push(`/calendar?tab=bid&new=true&address=${encodeURIComponent(props.fullAddress)}&template=SNW%20Install`);
|
||||
} else {
|
||||
router.push(`/calendar?tab=bid&name=${encodeURIComponent(props.bidMeeting)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEstimateClick = () => {
|
||||
if (props.estimateSentStatus === "Not Started") {
|
||||
router.push(`/estimate?new=true&address=${encodeURIComponent(props.fullAddress)}`);
|
||||
} else {
|
||||
router.push(`/estimate?name=${encodeURIComponent(props.estimate)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJobClick = () => {
|
||||
if (props.jobStatus === "Not Started") {
|
||||
notificationStore.addWarning(
|
||||
"The job will be created automatically once a quotation has been accepted by the customer."
|
||||
);
|
||||
} else {
|
||||
router.push(`/job?name=${encodeURIComponent(props.job)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaymentClick = () => {
|
||||
if (props.paymentStatus === "Not Started") {
|
||||
notificationStore.addWarning(
|
||||
"An Invoice will be automatically created once the job has been completed."
|
||||
);
|
||||
} else {
|
||||
notificationStore.addWarning("Page coming soon.");
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.install-status-card {
|
||||
background: var(--surface-card);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
min-width: 240px;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.install-status-card h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--text-color);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.status-item:hover {
|
||||
transform: translateX(2px);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Status color variants */
|
||||
.status-not-started {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border-color: rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
|
||||
.status-not-started .status-badge {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-not-started:hover {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border-color: rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
|
||||
.status-in-progress {
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border-color: rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.status-in-progress .status-badge {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-in-progress:hover {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
border-color: rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
background: rgba(34, 197, 94, 0.08);
|
||||
border-color: rgba(34, 197, 94, 0.15);
|
||||
}
|
||||
|
||||
.status-completed .status-badge {
|
||||
background: #22c55e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-completed:hover {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
border-color: rgba(34, 197, 94, 0.25);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.install-status-card {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,83 +1,130 @@
|
|||
<template>
|
||||
<!-- New Meeting Creation Modal -->
|
||||
<Modal
|
||||
v-model:visible="showModal"
|
||||
:options="modalOptions"
|
||||
@confirm="handleConfirm"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<template #title>Schedule New Bid Meeting</template>
|
||||
<div class="new-meeting-form">
|
||||
<div class="form-group">
|
||||
<label for="meeting-address">Address: <span class="required">*</span></label>
|
||||
<div class="address-input-group">
|
||||
<InputText
|
||||
id="meeting-address"
|
||||
v-model="formData.address"
|
||||
class="address-input"
|
||||
placeholder="Enter meeting address"
|
||||
@input="validateForm"
|
||||
<div>
|
||||
<!-- New Meeting Creation Modal -->
|
||||
<Modal
|
||||
:visible="showModal"
|
||||
@update:visible="showModal = $event"
|
||||
:options="modalOptions"
|
||||
@confirm="handleConfirm"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<template #title>Schedule New Bid Meeting</template>
|
||||
<div class="new-meeting-form">
|
||||
<div class="form-group">
|
||||
<label for="meeting-address">Address: <span class="required">*</span></label>
|
||||
<div class="address-input-group">
|
||||
<InputText
|
||||
id="meeting-address"
|
||||
v-model="formData.address"
|
||||
class="address-input"
|
||||
placeholder="Enter meeting address"
|
||||
@input="validateForm"
|
||||
/>
|
||||
<Button
|
||||
label="Search"
|
||||
icon="pi pi-search"
|
||||
size="small"
|
||||
:disabled="!formData.address.trim()"
|
||||
@click="searchAddress"
|
||||
class="search-btn"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="meeting-contact">Contact: <span class="required">*</span></label>
|
||||
<Select
|
||||
id="meeting-contact"
|
||||
v-model="formData.contact"
|
||||
:options="availableContacts"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
:disabled="!formData.addressName || availableContacts.length === 0"
|
||||
placeholder="Select a contact"
|
||||
class="w-full"
|
||||
@change="validateForm"
|
||||
>
|
||||
<template #option="slotProps">
|
||||
<div class="contact-option">
|
||||
<div class="contact-name">{{ slotProps.option.displayName }}</div>
|
||||
<div class="contact-details">
|
||||
<span v-if="slotProps.option.role" class="contact-role">{{ slotProps.option.role }}</span>
|
||||
<span v-if="slotProps.option.email" class="contact-email">{{ slotProps.option.email }}</span>
|
||||
<span v-if="slotProps.option.phone" class="contact-phone">{{ slotProps.option.phone }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="meeting-project-template">Project Template (Optional):</label>
|
||||
<Select
|
||||
id="meeting-project-template"
|
||||
v-model="formData.projectTemplate"
|
||||
:options="availableProjectTemplates"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Select a project template"
|
||||
class="w-full"
|
||||
showClear
|
||||
/>
|
||||
<Button
|
||||
label="Search"
|
||||
icon="pi pi-search"
|
||||
size="small"
|
||||
:disabled="!formData.address.trim()"
|
||||
@click="searchAddress"
|
||||
class="search-btn"
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="meeting-notes">Notes (Optional):</label>
|
||||
<Textarea
|
||||
id="meeting-notes"
|
||||
v-model="formData.notes"
|
||||
class="w-full"
|
||||
placeholder="Additional notes..."
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="meeting-notes">Notes (Optional):</label>
|
||||
<Textarea
|
||||
id="meeting-notes"
|
||||
v-model="formData.notes"
|
||||
class="w-full"
|
||||
placeholder="Additional notes..."
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</Modal>
|
||||
|
||||
<!-- Address Search Results Modal -->
|
||||
<Modal
|
||||
v-model:visible="showAddressSearchModal"
|
||||
:options="searchModalOptions"
|
||||
@confirm="closeAddressSearch"
|
||||
>
|
||||
<template #title>Address Search Results</template>
|
||||
<div class="address-search-results">
|
||||
<div v-if="addressSearchResults.length === 0" class="no-results">
|
||||
<i class="pi pi-info-circle"></i>
|
||||
<p>No addresses found matching your search.</p>
|
||||
</div>
|
||||
<div v-else class="results-list">
|
||||
<div
|
||||
v-for="(address, index) in addressSearchResults"
|
||||
:key="index"
|
||||
class="address-result-item"
|
||||
@click="selectAddress(address)"
|
||||
>
|
||||
<i class="pi pi-map-marker"></i>
|
||||
<span>{{ address }}</span>
|
||||
<!-- Address Search Results Modal -->
|
||||
<Modal
|
||||
:visible="showAddressSearchModal"
|
||||
@update:visible="showAddressSearchModal = $event"
|
||||
:options="searchModalOptions"
|
||||
@confirm="closeAddressSearch"
|
||||
>
|
||||
<template #title>Address Search Results</template>
|
||||
<div class="address-search-results">
|
||||
<div v-if="addressSearchResults.length === 0" class="no-results">
|
||||
<i class="pi pi-info-circle"></i>
|
||||
<p>No addresses found matching your search.</p>
|
||||
</div>
|
||||
<div v-else class="results-list">
|
||||
<div
|
||||
v-for="(address, index) in addressSearchResults"
|
||||
:key="index"
|
||||
class="address-result-item"
|
||||
@click="selectAddress(address)"
|
||||
>
|
||||
<i class="pi pi-map-marker"></i>
|
||||
<span>{{ typeof address === 'string' ? address : (address.fullAddress || address.name) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import Modal from "../common/Modal.vue";
|
||||
import InputText from "primevue/inputtext";
|
||||
import Textarea from "primevue/textarea";
|
||||
import Button from "primevue/button";
|
||||
import Select from "primevue/select";
|
||||
import { useNotificationStore } from "../../stores/notifications-primevue";
|
||||
import { useCompanyStore } from "../../stores/company";
|
||||
import Api from "../../api";
|
||||
|
||||
const notificationStore = useNotificationStore();
|
||||
const companyStore = useCompanyStore();
|
||||
const route = useRoute();
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
|
|
@ -106,11 +153,17 @@ const showModal = computed({
|
|||
|
||||
const showAddressSearchModal = ref(false);
|
||||
const addressSearchResults = ref([]);
|
||||
const availableContacts = ref([]);
|
||||
const availableProjectTemplates = ref([]);
|
||||
const selectedAddressDetails = ref(null);
|
||||
const isFormValid = ref(false);
|
||||
|
||||
// Form data
|
||||
const formData = ref({
|
||||
address: "",
|
||||
addressName: "",
|
||||
contact: "",
|
||||
projectTemplate: "",
|
||||
notes: "",
|
||||
});
|
||||
|
||||
|
|
@ -140,7 +193,9 @@ const searchModalOptions = computed(() => ({
|
|||
// Methods
|
||||
const validateForm = () => {
|
||||
const hasValidAddress = formData.value.address && formData.value.address.trim().length > 0;
|
||||
isFormValid.value = hasValidAddress;
|
||||
const hasValidAddressName = formData.value.addressName && formData.value.addressName.trim().length > 0;
|
||||
const hasValidContact = formData.value.contact && formData.value.contact.trim().length > 0;
|
||||
isFormValid.value = hasValidAddress && hasValidAddressName && hasValidContact;
|
||||
};
|
||||
|
||||
const searchAddress = async () => {
|
||||
|
|
@ -151,8 +206,7 @@ const searchAddress = async () => {
|
|||
const results = await Api.searchAddresses(searchTerm);
|
||||
console.info("Address search results:", results);
|
||||
|
||||
// Ensure results is always an array
|
||||
// const safeResults = Array.isArray(results) ? results : [];
|
||||
// Store full address objects instead of just strings
|
||||
addressSearchResults.value = results;
|
||||
|
||||
if (results.length === 0) {
|
||||
|
|
@ -167,33 +221,125 @@ const searchAddress = async () => {
|
|||
}
|
||||
};
|
||||
|
||||
const selectAddress = (address) => {
|
||||
formData.value.address = address;
|
||||
const selectAddress = async (addressData) => {
|
||||
// Get the address string for the API call
|
||||
const addressString = typeof addressData === 'string' ? addressData : (addressData.fullAddress || addressData.name);
|
||||
|
||||
// Set the display address immediately
|
||||
formData.value.address = addressString;
|
||||
showAddressSearchModal.value = false;
|
||||
validateForm();
|
||||
|
||||
try {
|
||||
// Fetch the full address details with contacts
|
||||
const fullAddressDetails = await Api.getAddressByFullAddress(addressString);
|
||||
console.info("Fetched address details:", fullAddressDetails);
|
||||
|
||||
// Store the fetched address details
|
||||
selectedAddressDetails.value = fullAddressDetails;
|
||||
|
||||
// Set the address name for the API request
|
||||
formData.value.addressName = fullAddressDetails.name;
|
||||
|
||||
// Populate contacts from the fetched address
|
||||
if (fullAddressDetails.contacts && Array.isArray(fullAddressDetails.contacts)) {
|
||||
availableContacts.value = fullAddressDetails.contacts.map(contact => ({
|
||||
label: contact.fullName || `${contact.firstName || ''} ${contact.lastName || ''}`.trim(),
|
||||
value: contact.name,
|
||||
displayName: contact.fullName || `${contact.firstName || ''} ${contact.lastName || ''}`.trim(),
|
||||
role: contact.role || contact.designation || '',
|
||||
email: contact.email || contact.emailId || '',
|
||||
phone: contact.phone || contact.mobileNo || ''
|
||||
}));
|
||||
|
||||
// Auto-select primary contact if available, otherwise first contact if only one
|
||||
if (fullAddressDetails.primaryContact) {
|
||||
formData.value.contact = fullAddressDetails.primaryContact;
|
||||
} else if (availableContacts.value.length === 1) {
|
||||
formData.value.contact = availableContacts.value[0].value;
|
||||
} else {
|
||||
formData.value.contact = "";
|
||||
}
|
||||
} else {
|
||||
availableContacts.value = [];
|
||||
formData.value.contact = "";
|
||||
notificationStore.addWarning("No contacts found for this address.");
|
||||
}
|
||||
|
||||
validateForm();
|
||||
} catch (error) {
|
||||
console.error("Error fetching address details:", error);
|
||||
notificationStore.addError("Failed to fetch address details. Please try again.");
|
||||
|
||||
// Reset on error
|
||||
formData.value.addressName = "";
|
||||
availableContacts.value = [];
|
||||
formData.value.contact = "";
|
||||
selectedAddressDetails.value = null;
|
||||
validateForm();
|
||||
}
|
||||
};
|
||||
|
||||
const closeAddressSearch = () => {
|
||||
showAddressSearchModal.value = false;
|
||||
};
|
||||
|
||||
const fetchProjectTemplates = async () => {
|
||||
try {
|
||||
const company = companyStore.currentCompany;
|
||||
if (!company) {
|
||||
console.warn("No company selected, cannot fetch project templates");
|
||||
return;
|
||||
}
|
||||
|
||||
const templates = await Api.getJobTemplates(company);
|
||||
console.info("Fetched project templates:", templates);
|
||||
|
||||
if (templates && Array.isArray(templates)) {
|
||||
availableProjectTemplates.value = templates.map(template => ({
|
||||
label: template.name,
|
||||
value: template.name
|
||||
}));
|
||||
} else {
|
||||
availableProjectTemplates.value = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching project templates:", error);
|
||||
availableProjectTemplates.value = [];
|
||||
notificationStore.addWarning("Failed to load project templates.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!isFormValid.value) return;
|
||||
|
||||
emit("confirm", { ...formData.value });
|
||||
// Send only the necessary data (addressName and contact, not full address)
|
||||
const confirmData = {
|
||||
address: formData.value.addressName,
|
||||
contact: formData.value.contact,
|
||||
projectTemplate: formData.value.projectTemplate || null,
|
||||
notes: formData.value.notes,
|
||||
};
|
||||
|
||||
console.log("BidMeetingModal - Emitting confirm with data:", confirmData);
|
||||
|
||||
emit("confirm", confirmData);
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
emit("cancel");
|
||||
showModal.value = false;
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
formData.value = {
|
||||
address: props.initialAddress || "",
|
||||
addressName: "",
|
||||
contact: "",
|
||||
projectTemplate: "",
|
||||
notes: "",
|
||||
};
|
||||
availableContacts.value = [];
|
||||
validateForm();
|
||||
};
|
||||
|
||||
|
|
@ -207,11 +353,66 @@ watch(
|
|||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => companyStore.currentCompany,
|
||||
async (newCompany) => {
|
||||
if (newCompany && props.visible) {
|
||||
await fetchProjectTemplates();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(isVisible) => {
|
||||
async (isVisible) => {
|
||||
if (isVisible) {
|
||||
resetForm();
|
||||
|
||||
// Fetch project templates
|
||||
await fetchProjectTemplates();
|
||||
|
||||
// Auto-select template from query parameter if provided
|
||||
if (route.query.template) {
|
||||
const templateName = decodeURIComponent(route.query.template);
|
||||
const templateExists = availableProjectTemplates.value.some(
|
||||
t => t.value === templateName
|
||||
);
|
||||
if (templateExists) {
|
||||
formData.value.projectTemplate = templateName;
|
||||
}
|
||||
}
|
||||
|
||||
// If there's an initial address, automatically search and fetch it
|
||||
if (formData.value.address && formData.value.address.trim()) {
|
||||
try {
|
||||
const results = await Api.searchAddresses(formData.value.address.trim());
|
||||
console.info("Auto-search results for initial address:", results);
|
||||
|
||||
if (results.length === 1) {
|
||||
// Auto-select if only one result
|
||||
await selectAddress(results[0]);
|
||||
} else if (results.length > 1) {
|
||||
// Try to find exact match
|
||||
const exactMatch = results.find(addr => {
|
||||
const addrString = typeof addr === 'string' ? addr : (addr.fullAddress || addr.name);
|
||||
return addrString === formData.value.address;
|
||||
});
|
||||
|
||||
if (exactMatch) {
|
||||
await selectAddress(exactMatch);
|
||||
} else {
|
||||
// Show search results if multiple matches
|
||||
addressSearchResults.value = results;
|
||||
showAddressSearchModal.value = true;
|
||||
}
|
||||
} else {
|
||||
notificationStore.addWarning("No addresses found for the provided address.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error auto-searching address:", error);
|
||||
notificationStore.addError("Failed to load address details.");
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
@ -308,4 +509,42 @@ validateForm();
|
|||
font-size: 0.9em;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.contact-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.contact-name {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.contact-details {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 0.85em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.contact-role {
|
||||
color: #2196f3;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.contact-email,
|
||||
.contact-phone {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.contact-email::before {
|
||||
content: "📧 ";
|
||||
}
|
||||
|
||||
.contact-phone::before {
|
||||
content: "📞 ";
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,41 +2,128 @@
|
|||
<Modal v-model:visible="showModal" :options="modalOptions" @confirm="handleClose">
|
||||
<template #title>Meeting Details</template>
|
||||
<div v-if="meeting" class="meeting-details">
|
||||
<!-- Meeting ID -->
|
||||
<div class="detail-row">
|
||||
<v-icon class="mr-2">mdi-identifier</v-icon>
|
||||
<strong>Meeting ID:</strong>
|
||||
<span class="detail-value">{{ meeting.name }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Address -->
|
||||
<div class="detail-row">
|
||||
<v-icon class="mr-2">mdi-map-marker</v-icon>
|
||||
<strong>Addresss:</strong> {{ meeting.address.fullAddress }}
|
||||
<strong>Address:</strong>
|
||||
<span class="detail-value">{{ meeting.address?.fullAddress || meeting.address }}</span>
|
||||
</div>
|
||||
<div class="detail-row" v-if="meeting.client">
|
||||
|
||||
<!-- Contact -->
|
||||
<div class="detail-row" v-if="meeting.contact">
|
||||
<v-icon class="mr-2">mdi-account</v-icon>
|
||||
<strong>Client:</strong> {{ meeting.client }}
|
||||
<strong>Contact:</strong>
|
||||
<span class="detail-value">{{ meeting.contact }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<v-icon class="mr-2">mdi-calendar</v-icon>
|
||||
<strong>Date:</strong> {{ formatDate(meeting.date) }}
|
||||
|
||||
<!-- Party Name (Customer) -->
|
||||
<div class="detail-row" v-if="meeting.partyName">
|
||||
<v-icon class="mr-2">mdi-account-group</v-icon>
|
||||
<strong>Customer:</strong>
|
||||
<span class="detail-value">{{ meeting.partyName }} ({{ meeting.partyType }})</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<v-icon class="mr-2">mdi-clock</v-icon>
|
||||
<strong>Time:</strong> {{ formatTimeDisplay(meeting.scheduledTime) }}
|
||||
|
||||
<!-- Project Template -->
|
||||
<div class="detail-row" v-if="meeting.projectTemplate">
|
||||
<v-icon class="mr-2">mdi-folder-outline</v-icon>
|
||||
<strong>Template:</strong>
|
||||
<span class="detail-value">{{ meeting.projectTemplate }}</span>
|
||||
</div>
|
||||
<div class="detail-row" v-if="meeting.duration">
|
||||
|
||||
<!-- Scheduled Time -->
|
||||
<div class="detail-row" v-if="meeting.startTime">
|
||||
<v-icon class="mr-2">mdi-calendar-clock</v-icon>
|
||||
<strong>Scheduled:</strong>
|
||||
<span class="detail-value">{{ formatDateTime(meeting.startTime) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Duration -->
|
||||
<div class="detail-row" v-if="meeting.startTime && meeting.endTime">
|
||||
<v-icon class="mr-2">mdi-timer</v-icon>
|
||||
<strong>Duration:</strong> {{ meeting.duration }} minutes
|
||||
<strong>Duration:</strong>
|
||||
<span class="detail-value">{{ calculateDuration(meeting.startTime, meeting.endTime) }} minutes</span>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="detail-row">
|
||||
<v-icon class="mr-2">mdi-check-circle</v-icon>
|
||||
<strong>Status:</strong>
|
||||
<v-chip size="small" :color="getStatusColor(meeting.status)">
|
||||
{{ meeting.status }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<!-- Assigned Employee -->
|
||||
<div class="detail-row" v-if="meeting.assignedEmployee">
|
||||
<v-icon class="mr-2">mdi-account-tie</v-icon>
|
||||
<strong>Assigned To:</strong>
|
||||
<span class="detail-value">{{ meeting.assignedEmployee }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Completed By -->
|
||||
<div class="detail-row" v-if="meeting.completedBy">
|
||||
<v-icon class="mr-2">mdi-account-check</v-icon>
|
||||
<strong>Completed By:</strong>
|
||||
<span class="detail-value">{{ meeting.completedBy }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Company -->
|
||||
<div class="detail-row" v-if="meeting.company">
|
||||
<v-icon class="mr-2">mdi-domain</v-icon>
|
||||
<strong>Company:</strong>
|
||||
<span class="detail-value">{{ meeting.company }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="detail-row" v-if="meeting.notes">
|
||||
<v-icon class="mr-2">mdi-note-text</v-icon>
|
||||
<strong>Notes:</strong> {{ meeting.notes }}
|
||||
<strong>Notes:</strong>
|
||||
<span class="detail-value">{{ meeting.notes }}</span>
|
||||
</div>
|
||||
<div class="detail-row" v-if="meeting.status">
|
||||
<v-icon class="mr-2">mdi-check-circle</v-icon>
|
||||
<strong>Status:</strong> {{ meeting.status }}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="action-buttons">
|
||||
<v-btn
|
||||
v-if="meeting.status !== 'Completed'"
|
||||
@click="handleMarkComplete"
|
||||
color="success"
|
||||
variant="elevated"
|
||||
:loading="isUpdating"
|
||||
>
|
||||
<v-icon left>mdi-check</v-icon>
|
||||
Mark as Completed
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
v-if="meeting.status === 'Completed'"
|
||||
@click="handleCreateEstimate"
|
||||
color="primary"
|
||||
variant="elevated"
|
||||
>
|
||||
<v-icon left>mdi-file-document-outline</v-icon>
|
||||
Create Estimate
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { ref, computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import Modal from "../common/Modal.vue";
|
||||
import Api from "../../api";
|
||||
import { useNotificationStore } from "../../stores/notifications-primevue";
|
||||
|
||||
const router = useRouter();
|
||||
const notificationStore = useNotificationStore();
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
|
|
@ -51,9 +138,11 @@ const props = defineProps({
|
|||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(["update:visible", "close"]);
|
||||
const emit = defineEmits(["update:visible", "close", "meetingUpdated"]);
|
||||
|
||||
// Local state
|
||||
const isUpdating = ref(false);
|
||||
|
||||
const showModal = computed({
|
||||
get() {
|
||||
return props.visible;
|
||||
|
|
@ -65,7 +154,7 @@ const showModal = computed({
|
|||
|
||||
// Modal options
|
||||
const modalOptions = computed(() => ({
|
||||
maxWidth: "600px",
|
||||
maxWidth: "700px",
|
||||
showCancelButton: false,
|
||||
confirmButtonText: "Close",
|
||||
confirmButtonColor: "primary",
|
||||
|
|
@ -76,6 +165,89 @@ const handleClose = () => {
|
|||
emit("close");
|
||||
};
|
||||
|
||||
const handleMarkComplete = async () => {
|
||||
if (!props.meeting?.name) return;
|
||||
|
||||
try {
|
||||
isUpdating.value = true;
|
||||
|
||||
await Api.updateBidMeeting(props.meeting.name, {
|
||||
status: "Completed",
|
||||
});
|
||||
|
||||
notificationStore.addNotification({
|
||||
type: "success",
|
||||
title: "Meeting Completed",
|
||||
message: "The meeting has been marked as completed.",
|
||||
duration: 4000,
|
||||
});
|
||||
|
||||
// Emit event to refresh the calendar
|
||||
emit("meetingUpdated");
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error("Error marking meeting as complete:", error);
|
||||
notificationStore.addNotification({
|
||||
type: "error",
|
||||
title: "Error",
|
||||
message: "Failed to update meeting status.",
|
||||
duration: 5000,
|
||||
});
|
||||
} finally {
|
||||
isUpdating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateEstimate = () => {
|
||||
if (!props.meeting) return;
|
||||
|
||||
const addressText = props.meeting.address?.fullAddress || props.meeting.address || "";
|
||||
const template = props.meeting.projectTemplate || "";
|
||||
const fromMeeting = props.meeting.name || "";
|
||||
|
||||
router.push({
|
||||
path: "/estimate",
|
||||
query: {
|
||||
new: "true",
|
||||
address: addressText,
|
||||
template: template,
|
||||
from: fromMeeting,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const formatDateTime = (dateTimeStr) => {
|
||||
if (!dateTimeStr) return "";
|
||||
const date = new Date(dateTimeStr);
|
||||
return date.toLocaleString("en-US", {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
});
|
||||
};
|
||||
|
||||
const calculateDuration = (startTime, endTime) => {
|
||||
if (!startTime || !endTime) return 0;
|
||||
const start = new Date(startTime);
|
||||
const end = new Date(endTime);
|
||||
const diffMs = end - start;
|
||||
return Math.round(diffMs / (1000 * 60)); // Convert to minutes
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
const statusColors = {
|
||||
Unscheduled: "warning",
|
||||
Scheduled: "info",
|
||||
Completed: "success",
|
||||
Cancelled: "error",
|
||||
};
|
||||
return statusColors[status] || "default";
|
||||
};
|
||||
|
||||
const formatTimeDisplay = (time) => {
|
||||
if (!time) return "";
|
||||
const [hours, minutes] = time.split(":").map(Number);
|
||||
|
|
@ -101,17 +273,39 @@ const formatDate = (dateStr) => {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.detail-row:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detail-row strong {
|
||||
margin-right: 8px;
|
||||
min-width: 120px;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
flex: 1;
|
||||
color: #333;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 2px solid #e0e0e0;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -201,6 +201,21 @@ const tableActions = [
|
|||
router.push(`/estimate?new=true&address=${address}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Action",
|
||||
rowAction: true,
|
||||
type: "menu",
|
||||
menuItems: [
|
||||
{
|
||||
label: "View Client Details",
|
||||
action: (rowData) => {
|
||||
const client = encodeURIComponent(rowData.customerName);
|
||||
const address = encodeURIComponent(rowData.address);
|
||||
router.push(`/client?client=${client}&address=${address}`);
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
// {
|
||||
// label: "Export Selected",
|
||||
// action: (selectedRows) => {
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ const columns = [
|
|||
{ label: "Address", fieldName: "customInstallationAddress", type: "text", sortable: true },
|
||||
{ label: "Customer", fieldName: "customer", type: "text", sortable: true, filterable: true },
|
||||
{ label: "Overall Status", fieldName: "status", type: "status", sortable: true },
|
||||
{ label: "Progress", fieldName: "percentComplete", type: "text", sortable: true },
|
||||
{ label: "Progress", fieldName: "percentComplete", type: "text", sortable: true }
|
||||
];
|
||||
|
||||
const router = useRouter();
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { defineStore } from "pinia";
|
||||
|
||||
const STORAGE_KEY = "selectedCompany";
|
||||
|
||||
export const useCompanyStore = defineStore("company", {
|
||||
state: () => ({
|
||||
companies: ["Sprinklers Northwest", "Nuco Yard Care", "Lowe Fencing", "Veritas Stone", "Daniels Landscape Supplies"],
|
||||
selectedCompany: "Sprinklers Northwest",
|
||||
selectedCompany: localStorage.getItem(STORAGE_KEY) || "Sprinklers Northwest",
|
||||
}),
|
||||
|
||||
getters: {
|
||||
|
|
@ -14,6 +16,7 @@ export const useCompanyStore = defineStore("company", {
|
|||
setSelectedCompany(companyName) {
|
||||
if (this.companies.includes(companyName)) {
|
||||
this.selectedCompany = companyName;
|
||||
localStorage.setItem(STORAGE_KEY, companyName);
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -21,6 +24,9 @@ export const useCompanyStore = defineStore("company", {
|
|||
this.companies = [...companies];
|
||||
if (!this.companies.includes(this.selectedCompany)) {
|
||||
this.selectedCompany = this.companies[0] || null;
|
||||
if (this.selectedCompany) {
|
||||
localStorage.setItem(STORAGE_KEY, this.selectedCompany);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue