big update

This commit is contained in:
Casey 2026-01-15 17:31:53 -06:00
parent 73d235b7bc
commit 0380dd10d8
18 changed files with 951 additions and 490 deletions

View file

@ -173,21 +173,26 @@
draggable="true"
@dragstart="handleDragStart($event, meeting)"
@dragend="handleDragEnd($event)"
@click="showMeetingDetails(meeting)"
>
<v-card-text class="pa-3">
<div class="meeting-title">{{ meeting.address }}</div>
<div class="meeting-status">
<v-card-text class="pa-2">
<div class="unscheduled-address">
<v-icon size="x-small" class="mr-1">mdi-map-marker</v-icon>
{{ meeting.address?.fullAddress || meeting.address }}
</div>
<div v-if="meeting.contact?.name" class="unscheduled-contact">
<v-icon size="x-small" class="mr-1">mdi-account</v-icon>
{{ meeting.contact.name }}
</div>
<div v-if="meeting.projectTemplate" class="unscheduled-project">
<v-icon size="x-small" class="mr-1">mdi-file-document</v-icon>
{{ meeting.projectTemplate }}
</div>
<div class="unscheduled-status">
<v-chip size="x-small" :color="getStatusColor(meeting.status)">
{{ meeting.status }}
</v-chip>
</div>
<div v-if="meeting.notes" class="meeting-notes">
{{ meeting.notes }}
</div>
<div v-if="meeting.assigned_employee" class="meeting-employee">
<v-icon size="x-small" class="mr-1">mdi-account</v-icon>
{{ meeting.assigned_employee }}
</div>
</v-card-text>
</v-card>
</div>
@ -876,17 +881,12 @@ const loadWeekMeetings = async () => {
? `${startDateTime.getHours().toString().padStart(2, "0")}:${startDateTime.getMinutes().toString().padStart(2, "0")}`
: null;
// Return the full meeting object with calendar-specific fields added
return {
...meeting, // Keep all original fields
id: meeting.name,
name: meeting.name,
date: date,
scheduledTime: time,
address: meeting.address,
notes: meeting.notes,
assigned_employee: meeting.assignedEmployee,
status: meeting.status,
startTime: meeting.startTime,
endTime: meeting.endTime,
};
})
.filter((meeting) => meeting.date && meeting.scheduledTime); // Only include meetings with valid date/time
@ -1274,6 +1274,30 @@ watch(
align-items: center;
}
.unscheduled-address,
.unscheduled-contact,
.unscheduled-project {
font-size: 0.8em;
color: #666;
margin-bottom: 4px;
display: flex;
align-items: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.unscheduled-address {
font-weight: 600;
color: #1976d2;
}
.unscheduled-status {
margin-top: 6px;
display: flex;
justify-content: flex-start;
}
.calendar-section {
flex: 1;
overflow: auto;

View file

@ -1,6 +1,7 @@
<template>
<div class="form-section">
<div class="section-header">
<i class="pi pi-map-marker" style="color: var(--primary-color); font-size: 1.2rem;"></i>
<h3>Property Address Information</h3>
</div>
<div class="form-grid">
@ -10,20 +11,23 @@
class="address-item"
>
<div class="address-header">
<h4>Address {{ index + 1 }}</h4>
<div class="address-title">
<i class="pi pi-home" style="font-size: 0.9rem; color: var(--primary-color);"></i>
<h4>Address {{ index + 1 }}</h4>
</div>
<Button
v-if="localFormData.addresses.length > 1"
@click="removeAddress(index)"
size="small"
severity="danger"
label="Delete"
icon="pi pi-trash"
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>
<i class="pi pi-map" style="font-size: 0.75rem; margin-right: 0.25rem;"></i>Address Line 1 <span class="required">*</span>
</label>
<InputText
:id="`address-line1-${index}`"
@ -35,7 +39,7 @@
/>
</div>
<div class="form-field full-width">
<label :for="`address-line2-${index}`">Address Line 2</label>
<label :for="`address-line2-${index}`"><i class="pi pi-map" style="font-size: 0.75rem; margin-right: 0.25rem;"></i>Address Line 2</label>
<InputText
:id="`address-line2-${index}`"
v-model="address.addressLine2"
@ -53,12 +57,12 @@
:disabled="isSubmitting"
style="margin-top: 0"
/>
<label :for="`isBilling-${index}`">Is Billing Address</label>
<label :for="`isBilling-${index}`"><i class="pi pi-dollar" style="font-size: 0.75rem; margin-right: 0.25rem;"></i>Is Billing Address</label>
</div>
<div class="form-row">
<div class="form-field">
<label :for="`zipcode-${index}`">
Zip Code <span class="required">*</span>
<i class="pi pi-hashtag" style="font-size: 0.75rem; margin-right: 0.25rem;"></i>Zip Code <span class="required">*</span>
</label>
<InputText
:id="`zipcode-${index}`"
@ -72,7 +76,7 @@
</div>
<div class="form-field">
<label :for="`city-${index}`">
City <span class="required">*</span>
<i class="pi pi-building" style="font-size: 0.75rem; margin-right: 0.25rem;"></i>City <span class="required">*</span>
</label>
<InputText
:id="`city-${index}`"
@ -84,7 +88,7 @@
</div>
<div class="form-field">
<label :for="`state-${index}`">
State <span class="required">*</span>
<i class="pi pi-flag" style="font-size: 0.75rem; margin-right: 0.25rem;"></i>State <span class="required">*</span>
</label>
<InputText
:id="`state-${index}`"
@ -97,7 +101,7 @@
</div>
<div class="form-row">
<div class="form-field">
<label :for="`contacts-${index}`">Assigned Contacts</label>
<label :for="`contacts-${index}`"><i class="pi pi-users" style="font-size: 0.75rem; margin-right: 0.25rem;"></i>Assigned Contacts</label>
<MultiSelect
:id="`contacts-${index}`"
v-model="address.contacts"
@ -111,7 +115,7 @@
/>
</div>
<div class="form-field">
<label :for="`primaryContact-${index}`">Primary Contact</label>
<label :for="`primaryContact-${index}`"><i class="pi pi-star-fill" style="font-size: 0.75rem; margin-right: 0.25rem;"></i>Primary Contact</label>
<Select
:id="`primaryContact-${index}`"
v-model="address.primaryContact"
@ -294,51 +298,72 @@ const handleZipcodeInput = async (index, event) => {
<style scoped>
.form-section {
background: var(--surface-card);
border-radius: 8px;
padding: 1.5rem;
border-radius: 6px;
padding: 1rem;
border: 1px solid var(--surface-border);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.2s ease;
}
.form-section:hover {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
gap: 0.5rem;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--surface-border);
}
.section-header h3 {
margin: 0;
color: var(--text-color);
font-size: 1.25rem;
font-size: 1.1rem;
font-weight: 600;
}
.form-grid {
display: flex;
flex-direction: column;
gap: 1rem;
gap: 0.75rem;
}
.address-item {
border: 1px solid var(--surface-border);
border-radius: 6px;
padding: 1rem;
margin-bottom: 1rem;
padding: 0.75rem;
margin-bottom: 0.75rem;
background: var(--surface-section);
transition: all 0.2s ease;
}
.address-item:hover {
border-color: var(--primary-color);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
.address-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--surface-border);
}
.address-title {
display: flex;
align-items: center;
gap: 0.5rem;
}
.address-header h4 {
margin: 0;
color: var(--text-color);
font-size: 1.1rem;
font-size: 0.95rem;
font-weight: 600;
}
@ -349,18 +374,18 @@ const handleZipcodeInput = async (index, event) => {
.address-fields {
display: flex;
flex-direction: column;
gap: 1rem;
gap: 0.625rem;
}
.form-row {
display: flex;
gap: 1rem;
gap: 0.625rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: 0.375rem;
flex: 1;
}
@ -370,8 +395,10 @@ const handleZipcodeInput = async (index, event) => {
.form-field label {
font-weight: 500;
color: var(--text-color-secondary);
font-size: 0.9rem;
color: var(--text-color);
font-size: 0.85rem;
display: flex;
align-items: center;
}
.required {

View file

@ -2,11 +2,12 @@
<div>
<div class="form-section">
<div class="section-header">
<i class="pi pi-user" style="color: var(--primary-color); font-size: 1.2rem;"></i>
<h3>Client Information</h3>
</div>
<div class="form-grid">
<div class="form-field">
<label for="customer-type"> Customer Type <span class="required">*</span> </label>
<label for="customer-type"><i class="pi pi-building" style="font-size: 0.75rem; margin-right: 0.25rem;"></i>Customer Type <span class="required">*</span></label>
<Select
id="customer-type"
v-model="localFormData.customerType"
@ -17,7 +18,7 @@
/>
</div>
<div class="form-field">
<label for="customer-name"> Customer Name <span class="required">*</span> </label>
<label for="customer-name"><i class="pi pi-id-card" style="font-size: 0.75rem; margin-right: 0.25rem;"></i>Customer Name <span class="required">*</span></label>
<div class="input-with-button">
<InputText
id="customer-name"
@ -27,13 +28,13 @@
class="w-full"
/>
<Button
label="Check Client"
label="Check"
size="small"
icon="pi pi-user-check"
icon="pi pi-check-circle"
class="check-btn"
@click="checkCustomerExists"
:disabled="isSubmitting"
>Check</Button>
/>
<Button
v-if="!isNewClient && !isEditMode"
@click="searchCustomers"
@ -41,7 +42,7 @@
size="small"
icon="pi pi-search"
class="search-btn"
></Button>
/>
</div>
</div>
</div>
@ -252,23 +253,30 @@ defineExpose({
<style scoped>
.form-section {
background: var(--surface-card);
border-radius: 8px;
padding: 1.5rem;
/*border: 1px solid var(--surface-border);*/
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border-radius: 6px;
padding: 1rem;
border: 1px solid var(--surface-border);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.2s ease;
}
.form-section:hover {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
gap: 0.5rem;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--surface-border);
}
.section-header h3 {
margin: 0;
color: var(--text-color);
font-size: 1.25rem;
font-size: 1.1rem;
font-weight: 600;
}
@ -289,20 +297,22 @@ defineExpose({
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: 0.375rem;
}
.form-field label {
font-weight: 500;
color: var(--text-color-secondary);
font-size: 0.9rem;
color: var(--text-color);
font-size: 0.85rem;
display: flex;
align-items: center;
}
.required {
@ -311,7 +321,8 @@ defineExpose({
.input-with-button {
display: flex;
gap: 0.5rem;
gap: 0.375rem;
align-items: stretch;
}
.w-full {
@ -396,45 +407,19 @@ defineExpose({
}
.check-btn {
border: 1px solid var(--primary-color);
color: white;
background: var(--primary-color);
padding: 0.25rem 0.5rem;
min-width: 5rem;
justify-content: center;
white-space: nowrap;
flex-shrink: 0;
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
}
.check-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.check-btn:hover:not(:disabled) {
background: var(--surface-hover);
height: 100%;
}
.search-btn {
background: var(--primary-color);
border: 1px solid var(--primary-color);
padding: 0.25rem 0.5rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background 0.2s;
color: white;
min-width: 8rem;
}
.search-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.search-btn:hover:not(:disabled) {
background: var(--surface-hover);
white-space: nowrap;
flex-shrink: 0;
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
height: 100%;
}
@media (max-width: 768px) {

View file

@ -1,6 +1,7 @@
<template>
<div class="form-section">
<div class="section-header">
<i class="pi pi-users" style="color: var(--primary-color); font-size: 1.2rem;"></i>
<h3>Contact Information</h3>
</div>
<div class="form-grid">
@ -10,7 +11,10 @@
class="contact-item"
>
<div class="contact-header">
<h4>Contact {{ index + 1 }}</h4>
<div class="contact-title">
<i class="pi pi-user" style="font-size: 0.9rem; color: var(--primary-color);"></i>
<h4>Contact {{ index + 1 }}</h4>
</div>
<div class="interactables">
<div class="form-field header-row">
<input
@ -23,7 +27,7 @@
@change="setPrimary(index)"
/>
<label :for="`checkbox-${index}`">
Client Primary Contact
<i class="pi pi-star-fill" style="font-size: 0.7rem; margin-right: 0.25rem;"></i>Primary
</label>
</div>
<Button
@ -31,7 +35,7 @@
@click="removeContact(index)"
size="small"
severity="danger"
label="Delete"
icon="pi pi-trash"
class="remove-btn"
/>
</div>
@ -40,32 +44,32 @@
<div class="form-row">
<div class="form-field">
<label :for="`first-name-${index}`">
First Name <span class="required">*</span>
<i class="pi pi-user" style="font-size: 0.75rem; margin-right: 0.25rem;"></i>First Name <span class="required">*</span>
</label>
<InputText
:id="`first-name-${index}`"
v-model="contact.firstName"
:disabled="isSubmitting"
placeholder="Enter first name"
placeholder="First name"
class="w-full"
@input="formatName(index, 'firstName', $event)"
/>
</div>
<div class="form-field">
<label :for="`last-name-${index}`">
Last Name <span class="required">*</span>
<i class="pi pi-user" style="font-size: 0.75rem; margin-right: 0.25rem;"></i>Last Name <span class="required">*</span>
</label>
<InputText
:id="`last-name-${index}`"
v-model="contact.lastName"
:disabled="isSubmitting"
placeholder="Enter last name"
placeholder="Last name"
class="w-full"
@input="formatName(index, 'lastName', $event)"
/>
</div>
<div class="form-field">
<label :for="`contact-role-${index}`">Role</label>
<label :for="`contact-role-${index}`"><i class="pi pi-briefcase" style="font-size: 0.75rem; margin-right: 0.25rem;"></i>Role</label>
<Select
:id="`contact-role-${index}`"
v-model="contact.contactRole"
@ -73,14 +77,14 @@
optionLabel="label"
optionValue="value"
:disabled="isSubmitting"
placeholder="Select a role"
placeholder="Select role"
class="w-full"
/>
</div>
</div>
<div class="form-row">
<div class="form-field">
<label :for="`email-${index}`">Email</label>
<label :for="`email-${index}`"><i class="pi pi-envelope" style="font-size: 0.75rem; margin-right: 0.25rem;"></i>Email</label>
<InputText
:id="`email-${index}`"
v-model="contact.email"
@ -91,7 +95,7 @@
/>
</div>
<div class="form-field">
<label :for="`phone-number-${index}`">Phone</label>
<label :for="`phone-number-${index}`"><i class="pi pi-phone" style="font-size: 0.75rem; margin-right: 0.25rem;"></i>Phone</label>
<InputText
:id="`phone-number-${index}`"
v-model="contact.phoneNumber"
@ -280,46 +284,67 @@ defineExpose({});
<style scoped>
.form-section {
background: var(--surface-card);
border-radius: 8px;
padding: 1.5rem;
border-radius: 6px;
padding: 1rem;
border: 1px solid var(--surface-border);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.2s ease;
}
.form-section:hover {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
gap: 0.5rem;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--surface-border);
}
.section-header h3 {
margin: 0;
color: var(--text-color);
font-size: 1.25rem;
font-size: 1.1rem;
font-weight: 600;
}
.contact-item {
border: 1px solid var(--surface-border);
border-radius: 6px;
padding: 1rem;
margin-bottom: 1rem;
padding: 0.75rem;
margin-bottom: 0.75rem;
background: var(--surface-section);
min-width: 33%;
transition: all 0.2s ease;
}
.contact-item:hover {
border-color: var(--primary-color);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
.contact-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--surface-border);
}
.contact-title {
display: flex;
align-items: center;
gap: 0.5rem;
}
.contact-header h4 {
margin: 0;
color: var(--text-color);
font-size: 1.1rem;
font-size: 0.95rem;
font-weight: 600;
}
@ -334,35 +359,32 @@ defineExpose({});
margin-left: auto;
}
.contact-item .form-grid {
display: flex;
gap: 1rem;
}
.form-rows {
display: flex;
flex-direction: column;
gap: 1rem;
gap: 0.625rem;
}
.form-row {
display: flex;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
gap: 0.625rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: 0.375rem;
flex: 1;
}
.form-field.full-width {
grid-column: 1 / -1;
width: 100%;
}
.form-field label {
font-weight: 500;
color: var(--text-color);
font-size: 0.85rem;
color: var(--text-color-secondary);
font-size: 0.9rem;
}

View file

@ -75,46 +75,69 @@
<!-- Display Mode (existing client view) -->
<template v-else>
<!-- Address Info Card -->
<div class="info-card w-half" v-if="selectedAddressData">
<h3>General Information</h3>
<div class="info-grid">
<div class="info-item full-width">
<label>Address Title:</label>
<span>{{ selectedAddressData.addressTitle || "N/A" }}</span>
<div class="compact-card" v-if="selectedAddressData">
<div class="card-header-compact">
<i class="pi pi-home"></i>
<h4>Property Details</h4>
</div>
<div class="compact-grid">
<div class="compact-item">
<i class="pi pi-tag"></i>
<div class="item-content">
<span class="item-label">Title</span>
<span class="item-value">{{ selectedAddressData.addressTitle || "N/A" }}</span>
</div>
</div>
<div class="info-item full-width">
<label>Full Address:</label>
<span>{{ fullAddress }}</span>
<div class="compact-item">
<i class="pi pi-map-marker"></i>
<div class="item-content">
<span class="item-label">Address</span>
<span class="item-value">{{ fullAddress }}</span>
</div>
</div>
<div class="info-item full-width">
<label>City:</label>
<span>{{ selectedAddressData.city || "N/A" }}</span>
</div>
<div class="info-item full-width">
<label>State:</label>
<span>{{ selectedAddressData.state || "N/A" }}</span>
</div>
<div class="info-item full-width">
<label>Zip Code:</label>
<span>{{ selectedAddressData.pincode || "N/A" }}</span>
<div class="compact-row">
<div class="compact-item-inline">
<i class="pi pi-building"></i>
<span class="item-value">{{ selectedAddressData.city || "N/A" }}</span>
</div>
<div class="compact-item-inline">
<i class="pi pi-flag"></i>
<span class="item-value">{{ selectedAddressData.state || "N/A" }}</span>
</div>
<div class="compact-item-inline">
<i class="pi pi-envelope"></i>
<span class="item-value">{{ selectedAddressData.pincode || "N/A" }}</span>
</div>
</div>
</div>
</div>
<!-- Client Basic Info Card -->
<div class="info-card w-half">
<h3>Contact Information</h3>
<div class="compact-card">
<div class="card-header-compact">
<i class="pi pi-users"></i>
<h4>Contact Information</h4>
</div>
<template v-if="contactsForAddress.length > 0">
<div class="info-grid">
<div class="info-item">
<label>Customer Name:</label>
<span>{{ clientData?.customerName || "N/A" }}</span>
<div class="compact-grid">
<div class="compact-row">
<div class="compact-item-inline">
<i class="pi pi-user"></i>
<div class="item-content">
<span class="item-label">Customer</span>
<span class="item-value">{{ clientData?.customerName || "N/A" }}</span>
</div>
</div>
<div class="compact-item-inline">
<i class="pi pi-briefcase"></i>
<div class="item-content">
<span class="item-label">Type</span>
<span class="item-value">{{ clientData?.customerType || "N/A" }}</span>
</div>
</div>
</div>
<div class="info-item">
<label>Customer Type:</label>
<span>{{ clientData?.customerType || "N/A" }}</span>
</div>
<div v-if="contactsForAddress.length > 1" class="contact-selector">
<div v-if="contactsForAddress.length > 1" class="contact-selector-compact">
<Dropdown
v-model="selectedContactIndex"
:options="contactOptions"
@ -122,44 +145,76 @@
option-value="value"
placeholder="Select Contact"
class="w-full"
size="small"
/>
</div>
<div class="info-grid">
<div class="info-item">
<label>Contact Name:</label>
<span>{{ contactFullName }}</span>
<div class="contact-details">
<div class="compact-item">
<i class="pi pi-user-edit"></i>
<div class="item-content">
<span class="item-label">Contact</span>
<span class="item-value">{{ contactFullName }}</span>
</div>
</div>
<div class="info-item">
<label>Phone:</label>
<span>{{ primaryContactPhone }}</span>
<div class="compact-item">
<i class="pi pi-phone"></i>
<div class="item-content">
<span class="item-label">Phone</span>
<span class="item-value">{{ primaryContactPhone }}</span>
</div>
</div>
<div class="info-item">
<label>Email:</label>
<span>{{ primaryContactEmail }}</span>
<div class="compact-item">
<i class="pi pi-at"></i>
<div class="item-content">
<span class="item-label">Email</span>
<span class="item-value">{{ primaryContactEmail }}</span>
</div>
</div>
</div>
<div class="info-item">
<label>Customer Group:</label>
<span>{{ clientData?.customerGroup || "N/A" }}</span>
</div>
<div class="info-item">
<label>Territory:</label>
<span>{{ clientData?.territory || "N/A" }}</span>
<div class="compact-row" v-if="clientData?.customerGroup || clientData?.territory">
<div class="compact-item-inline" v-if="clientData?.customerGroup">
<i class="pi pi-sitemap"></i>
<div class="item-content">
<span class="item-label">Group</span>
<span class="item-value">{{ clientData.customerGroup }}</span>
</div>
</div>
<div class="compact-item-inline" v-if="clientData?.territory">
<i class="pi pi-globe"></i>
<div class="item-content">
<span class="item-label">Territory</span>
<span class="item-value">{{ clientData.territory }}</span>
</div>
</div>
</div>
</div>
</template>
<template v-else>
<p>No contacts available for this address.</p>
<div class="empty-state">
<i class="pi pi-user-minus"></i>
<p>No contacts available for this address.</p>
</div>
</template>
</div>
<!-- Financials At a Glance -->
<div class="info-card w-half">
<h3>Open Balances</h3>
<span>$3200.00</span>
<button class="sidebar-button" @click="navigateTo('/invoices')">
Go to Invoices
</button>
<div class="compact-card financial-card">
<div class="card-header-compact">
<i class="pi pi-dollar"></i>
<h4>Open Balances</h4>
</div>
<div class="financial-amount">$3,200.00</div>
<Button
@click="navigateTo('/invoices')"
icon="pi pi-arrow-right"
label="View Invoices"
severity="secondary"
outlined
size="small"
class="w-full"
/>
</div>
</template>
</div>
@ -757,36 +812,189 @@ const handleCancel = () => {
display: flex;
flex-direction: column;
gap: 1rem;
/*padding: 1rem;*/
}
.quick-action-bar {
display: flex;
flex-direction: row;
gap: 1rem;
gap: 0.75rem;
justify-content: flex-end;
}
.install-status-section {
display: flex;
justify-content: flex-start;
margin-bottom: 1rem;
margin-bottom: 0.75rem;
}
.info-card,
/* Compact Cards */
.compact-card {
background: var(--surface-card);
border-radius: 10px;
padding: 1rem;
border: 1px solid var(--surface-border);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
flex: 1 1 280px;
min-width: 280px;
transition: box-shadow 0.2s ease, transform 0.2s ease;
}
.compact-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
transform: translateY(-2px);
}
.card-header-compact {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--surface-border);
}
.card-header-compact i {
font-size: 1.1rem;
color: var(--primary-color);
}
.card-header-compact h4 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-color);
}
.compact-grid {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.compact-item {
display: flex;
align-items: flex-start;
gap: 0.625rem;
}
.compact-item > i {
font-size: 0.9rem;
color: var(--text-color-secondary);
margin-top: 0.15rem;
min-width: 16px;
}
.item-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.item-label {
font-size: 0.75rem;
color: var(--text-color-secondary);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.item-value {
font-size: 0.875rem;
color: var(--text-color);
font-weight: 500;
word-break: break-word;
}
.compact-row {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.compact-item-inline {
display: flex;
align-items: center;
gap: 0.4rem;
flex: 1;
min-width: 120px;
}
.compact-item-inline > i {
font-size: 0.85rem;
color: var(--text-color-secondary);
}
.compact-item-inline .item-content {
gap: 0.125rem;
}
.contact-selector-compact {
margin: 0.25rem 0;
}
.contact-details {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem;
background: var(--surface-ground);
border-radius: 6px;
}
/* Financial Card Specific */
.financial-card {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.financial-amount {
font-size: 2rem;
font-weight: 700;
color: var(--primary-color);
text-align: center;
padding: 0.75rem 0;
background: linear-gradient(135deg, var(--primary-50) 0%, var(--primary-100) 100%);
border-radius: 8px;
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.5rem;
text-align: center;
color: var(--text-color-secondary);
}
.empty-state i {
font-size: 2rem;
opacity: 0.5;
}
.empty-state p {
margin: 0;
font-size: 0.875rem;
}
/* Map Card - Keep Full Size */
.map-card {
background: var(--surface-card);
border-radius: 8px;
padding: 1.5rem;
border-radius: 10px;
padding: 1rem;
border: 1px solid var(--surface-border);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.info-card {
flex: 1 1 300px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
flex: 1 1 600px;
}
.map-card {
flex: 1 1 600px;
.map-card h3 {
margin: 0 0 1rem 0;
color: var(--text-color);
font-size: 1.25rem;
font-weight: 600;
}
.card-header {
@ -800,8 +1008,16 @@ const handleCancel = () => {
margin: 0;
}
.info-card h3,
.map-card h3 {
.info-card {
background: var(--surface-card);
border-radius: 10px;
padding: 1rem;
border: 1px solid var(--surface-border);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
flex: 1 1 300px;
}
.info-card h3 {
margin: 0 0 1rem 0;
color: var(--text-color);
font-size: 1.25rem;
@ -915,45 +1131,4 @@ const handleCancel = () => {
.w-full {
width: 100% !important;
}
/*.w-half {
width: 45% !important;
}*/
@media (max-width: 768px) {
.overview-container {
padding: 0.5rem;
gap: 1rem;
}
.info-card,
.map-card {
padding: 1rem;
}
.info-grid {
grid-template-columns: 1fr;
}
.status-cards {
grid-template-columns: repeat(2, 1fr);
}
.form-actions {
padding: 1rem;
flex-direction: column;
}
.card-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
}
@media (max-width: 480px) {
.status-cards {
grid-template-columns: 1fr;
}
}
</style>

View file

@ -110,7 +110,7 @@ const handleBidMeetingClick = () => {
const handleEstimateClick = () => {
if (props.estimateSentStatus === "Not Started") {
router.push(`/estimate?new=true&address=${encodeURIComponent(props.fullAddress)}`);
router.push(`/estimate?new=true&address=${encodeURIComponent(props.fullAddress)}&template=SNW%20Install`);
} else {
router.push(`/estimate?name=${encodeURIComponent(props.estimate)}`);
}

View file

@ -1,97 +1,135 @@
<template>
<Modal :visible="showModal" @update:visible="showModal = $event" :options="modalOptions" @confirm="handleClose">
<template #title>Meeting Details</template>
<template #title>
<div class="modal-header">
<i class="pi pi-calendar" style="color: var(--primary-color); font-size: 1.2rem; margin-right: 0.5rem;"></i>
Meeting Details
</div>
</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>Address:</strong>
<span class="detail-value">{{ meeting.address?.fullAddress || meeting.address }}</span>
</div>
<!-- Contact -->
<div class="detail-row" v-if="meeting.contact">
<v-icon class="mr-2">mdi-account</v-icon>
<strong>Contact:</strong>
<span class="detail-value">{{ meeting.contact }}</span>
</div>
<!-- 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>
<!-- 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>
<!-- 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>
<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)">
<!-- Status Badge -->
<div class="status-section">
<div class="status-badge" :class="`status-${meeting.status?.toLowerCase()}`">
<i class="pi pi-circle-fill"></i>
{{ meeting.status }}
</v-chip>
</div>
</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>
<!-- Key Information Grid -->
<div class="info-grid">
<!-- Customer Name -->
<div class="info-card" v-if="customerName">
<div class="info-label">
<i class="pi pi-user"></i>
Customer
</div>
<div class="info-value">{{ customerName }}</div>
<div class="info-meta" v-if="meeting.partyType">{{ meeting.partyType }}</div>
</div>
<!-- Project Template -->
<div class="info-card" v-if="meeting.projectTemplate">
<div class="info-label">
<i class="pi pi-folder"></i>
Project Type
</div>
<div class="info-value">{{ meeting.projectTemplate }}</div>
</div>
<!-- Scheduled Time -->
<div class="info-card" v-if="meeting.startTime">
<div class="info-label">
<i class="pi pi-clock"></i>
Scheduled
</div>
<div class="info-value">{{ formatDateTime(meeting.startTime) }}</div>
<div class="info-meta" v-if="meeting.endTime">Duration: {{ calculateDuration(meeting.startTime, meeting.endTime) }} min</div>
</div>
<!-- Assigned Employee -->
<div class="info-card" v-if="meeting.assignedEmployee">
<div class="info-label">
<i class="pi pi-user-edit"></i>
Assigned To
</div>
<div class="info-value">{{ meeting.assignedEmployee }}</div>
</div>
</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>
<!-- Address Section -->
<div class="section-divider">
<i class="pi pi-map-marker"></i>
<span>Location</span>
</div>
<div class="address-section">
<div class="address-text">
<strong>{{ addressText }}</strong>
<div class="meeting-id">ID: {{ meeting.name }}</div>
</div>
<div v-if="hasCoordinates" class="map-container">
<iframe
:src="mapUrl"
width="100%"
height="200"
frameborder="0"
style="border: 1px solid var(--surface-border); border-radius: 6px;"
></iframe>
</div>
</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>
<!-- Contact Information -->
<div class="section-divider" v-if="contactInfo">
<i class="pi pi-phone"></i>
<span>Contact Information</span>
</div>
<div class="contact-section" v-if="contactInfo">
<div class="contact-item">
<i class="pi pi-user"></i>
<span class="contact-label">Name:</span>
<span class="contact-value">{{ contactInfo.fullName }}</span>
</div>
<div class="contact-item" v-if="contactInfo.role">
<i class="pi pi-briefcase"></i>
<span class="contact-label">Role:</span>
<span class="contact-value">{{ contactInfo.role }}</span>
</div>
<div class="contact-item" v-if="contactInfo.phone">
<i class="pi pi-phone"></i>
<span class="contact-label">Phone:</span>
<a :href="`tel:${contactInfo.phone}`" class="contact-value contact-link">{{ contactInfo.phone }}</a>
</div>
<div class="contact-item" v-if="contactInfo.email">
<i class="pi pi-envelope"></i>
<span class="contact-label">Email:</span>
<a :href="`mailto:${contactInfo.email}`" class="contact-value contact-link">{{ contactInfo.email }}</a>
</div>
</div>
<!-- Notes -->
<div class="detail-row" v-if="meeting.notes">
<v-icon class="mr-2">mdi-note-text</v-icon>
<strong>Notes:</strong>
<span class="detail-value">{{ meeting.notes }}</span>
<div v-if="meeting.notes" class="notes-section">
<div class="section-divider">
<i class="pi pi-file-edit"></i>
<span>Notes</span>
</div>
<div class="notes-content">{{ meeting.notes }}</div>
</div>
<!-- Additional Info -->
<div class="additional-info" v-if="meeting.company || meeting.completedBy">
<div class="info-item" v-if="meeting.company">
<i class="pi pi-building"></i>
<span>{{ meeting.company }}</span>
</div>
<div class="info-item" v-if="meeting.completedBy">
<i class="pi pi-check-circle"></i>
<span>Completed by: {{ meeting.completedBy }}</span>
</div>
</div>
<!-- Action Buttons -->
<div class="action-buttons">
<v-btn
v-if="meeting.status !== 'Completed'"
v-if="meeting.status !== 'Completed' && meeting.status !== 'Unscheduled'"
@click="handleMarkComplete"
color="success"
variant="elevated"
@ -154,12 +192,75 @@ const showModal = computed({
// Modal options
const modalOptions = computed(() => ({
maxWidth: "700px",
maxWidth: "800px",
showCancelButton: false,
confirmButtonText: "Close",
confirmButtonColor: "primary",
}));
// Computed properties for data extraction
const customerName = computed(() => {
if (props.meeting?.address?.customerName) {
return props.meeting.address.customerName;
}
if (props.meeting?.partyName) {
return props.meeting.partyName;
}
return null;
});
const addressText = computed(() => {
return props.meeting?.address?.fullAddress || props.meeting?.address || "";
});
const hasCoordinates = computed(() => {
const lat = props.meeting?.address?.customLatitude || props.meeting?.address?.latitude;
const lon = props.meeting?.address?.customLongitude || props.meeting?.address?.longitude;
return lat && lon && parseFloat(lat) !== 0 && parseFloat(lon) !== 0;
});
const mapUrl = computed(() => {
if (!hasCoordinates.value) return "";
const lat = parseFloat(props.meeting?.address?.customLatitude || props.meeting?.address?.latitude);
const lon = parseFloat(props.meeting?.address?.customLongitude || props.meeting?.address?.longitude);
const zoom = 15;
// Using OpenStreetMap embed with marker
return `https://www.openstreetmap.org/export/embed.html?bbox=${lon - 0.01},${lat - 0.01},${lon + 0.01},${lat + 0.01}&layer=mapnik&marker=${lat},${lon}`;
});
const contactInfo = computed(() => {
console.log('=== CONTACT DEBUG ===');
console.log('Full meeting object:', props.meeting);
console.log('Meeting contact value:', props.meeting?.contact);
console.log('Contact type:', typeof props.meeting?.contact);
const contact = props.meeting?.contact;
if (!contact) {
console.log('No contact found - returning null');
return null;
}
// Handle both string and object contact
if (typeof contact === 'string') {
console.log('Contact is a string:', contact);
return { fullName: contact };
}
// Log the contact object to see what properties are available
console.log('Contact object keys:', Object.keys(contact));
console.log('Contact object:', contact);
const contactData = {
fullName: contact.name || contact.fullName || contact.contactName || `${contact.firstName || ''} ${contact.lastName || ''}`.trim() || '',
phone: contact.phone || contact.mobileNo || contact.mobile || contact.phoneNos?.[0]?.phone || '',
email: contact.emailId || contact.email || contact.emailAddress || contact.emailIds?.[0]?.emailId || '',
role: contact.role || contact.designation || '',
};
console.log('Extracted contact data:', contactData);
return contactData;
});
// Methods
const handleClose = () => {
emit("close");
@ -204,32 +305,20 @@ const handleCreateEstimate = () => {
const addressText = props.meeting.address?.fullAddress || props.meeting.address || "";
const template = props.meeting.projectTemplate || "";
const fromMeeting = props.meeting.name || "";
const contactName = props.meeting.contact?.name || "";
router.push({
path: "/estimate",
query: {
new: "true",
address: addressText,
"from-meeting": fromMeeting,
template: template,
from: fromMeeting,
contact: contactName,
},
});
};
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);
@ -238,28 +327,9 @@ const calculateDuration = (startTime, endTime) => {
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);
const displayHour = hours > 12 ? hours - 12 : hours === 0 ? 12 : hours;
const ampm = hours >= 12 ? "PM" : "AM";
return `${displayHour}:${minutes.toString().padStart(2, "0")} ${ampm}`;
};
const formatDate = (dateStr) => {
if (!dateStr) return "";
const date = new Date(dateStr);
return date.toLocaleDateString("en-US", {
const formatDateTime = (dateString) => {
if (!dateString) return "";
return new Date(dateString).toLocaleString("en-US", {
weekday: "long",
year: "numeric",
month: "long",

View file

@ -370,6 +370,9 @@ const company = useCompanyStore();
const addressQuery = computed(() => route.query.address || "");
const nameQuery = computed(() => route.query.name || "");
const templateQuery = computed(() => route.query.template || "");
const fromMeetingQuery = computed(() => route.query["from-meeting"] || "");
const contactQuery = computed(() => route.query.contact || "");
const isNew = computed(() => route.query.new === "true");
const isSubmitting = ref(false);
@ -383,6 +386,7 @@ const formData = reactive({
estimateName: null,
requiresHalfPayment: false,
projectTemplate: null,
fromMeeting: null,
});
const selectedAddress = ref(null);
@ -450,6 +454,16 @@ const fetchTemplates = async () => {
try {
const result = await Api.getEstimateTemplates(company.currentCompany);
templates.value = result;
// Check if template query param exists and set it after templates are loaded
const templateParam = route.query.template;
if (templateParam) {
console.log("DEBUG: Setting template from query param:", templateParam);
console.log("DEBUG: Available templates:", templates.value.map(t => t.name));
selectedTemplate.value = templateParam;
// Trigger template change to load items and project template
onTemplateChange();
}
} catch (error) {
console.error("Error fetching templates:", error);
notificationStore.addNotification("Failed to fetch templates", "error");
@ -570,7 +584,13 @@ const selectAddress = async (address) => {
const primary = contacts.value.find((c) => c.isPrimaryContact);
console.log("DEBUG: Selected address contacts:", contacts.value);
const existingContactName = estimate.value ? contacts.value.find((c) => c.fullName === estimate.value.partyName)?.name || "" : null;
formData.contact = estimate.value ? existingContactName : primary ? primary.name : contacts.value[0]?.name || "";
// Check for contact query param, then existing contact, then primary, then first contact
if (contactQuery.value) {
const contactFromQuery = contacts.value.find((c) => c.name === contactQuery.value);
formData.contact = contactFromQuery ? contactFromQuery.name : (primary ? primary.name : contacts.value[0]?.name || "");
} else {
formData.contact = estimate.value ? existingContactName : primary ? primary.name : contacts.value[0]?.name || "";
}
showAddressModal.value = false;
};
@ -651,6 +671,7 @@ const saveDraft = async () => {
estimateName: formData.estimateName,
requiresHalfPayment: formData.requiresHalfPayment,
projectTemplate: formData.projectTemplate,
fromMeeting: formData.fromMeeting,
company: company.currentCompany
};
estimate.value = await Api.createEstimate(data);
@ -819,6 +840,8 @@ watch(
formData.contact = estimate.value.contactPerson;
selectedContact.value = contacts.value.find((c) => c.name === estimate.value.contactPerson) || null;
formData.projectTemplate = estimate.value.customProjectTemplate || estimate.value.custom_project_template || null;
if (estimate.value.items && estimate.value.items.length > 0) {
selectedItems.value = estimate.value.items.map(item => {
const fullItem = quotationItems.value.find(qi => qi.itemCode === item.itemCode);
@ -857,10 +880,15 @@ onMounted(async () => {
} catch (error) {
console.error("Error loading quotation items:", error);
}
fetchProjectTemplates();
await fetchProjectTemplates();
if (isNew.value) {
fetchTemplates();
await fetchTemplates();
// Handle from-meeting query parameter
if (fromMeetingQuery.value) {
formData.fromMeeting = fromMeetingQuery.value;
}
}
if (addressQuery.value && isNew.value) {
@ -891,6 +919,8 @@ onMounted(async () => {
formData.contact = estimate.value.contactPerson;
selectedContact.value = contacts.value.find((c) => c.name === estimate.value.contactPerson) || null;
formData.projectTemplate = estimate.value.customProjectTemplate || estimate.value.custom_project_template || null;
// Populate items from the estimate
if (estimate.value.items && estimate.value.items.length > 0) {
selectedItems.value = estimate.value.items.map(item => {