Big update

This commit is contained in:
Casey 2026-01-26 17:20:49 -06:00
parent 124b8775fb
commit b400be3f1a
29 changed files with 31703 additions and 2443 deletions

View file

@ -115,8 +115,8 @@
)"
:key="meeting.id"
class="meeting-event"
:class="getMeetingColorClass(meeting)"
draggable="true"
:class="[getMeetingColorClass(meeting), { 'meeting-completed-locked': meeting.status === 'Completed' }]"
:draggable="meeting.status !== 'Completed'"
@dragstart="handleMeetingDragStart($event, meeting)"
@dragend="handleDragEnd($event)"
@click.stop="showMeetingDetails(meeting)"
@ -633,6 +633,12 @@ const handleDragStart = (event, meeting = null) => {
};
const handleMeetingDragStart = (event, meeting) => {
// Prevent dragging completed meetings
if (meeting.status === 'Completed') {
event.preventDefault();
return;
}
// Handle dragging a scheduled meeting
draggedMeeting.value = {
id: meeting.name,
@ -1561,11 +1567,24 @@ watch(
background: linear-gradient(135deg, #f44336, #d32f2f);
}
.meeting-event.meeting-completed-locked {
cursor: default !important;
opacity: 0.9;
}
.meeting-event.meeting-completed-locked:active {
cursor: default !important;
}
.meeting-event:hover {
transform: scale(1.02);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
.meeting-event.meeting-completed-locked:hover {
transform: none;
}
.event-time {
font-weight: 600;
font-size: 0.8em;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,362 @@
<template>
<div v-if="addresses.length > 1" class="address-selector">
<div class="selector-header">
<h4>Select Address</h4>
<Button
@click="showAddAddressModal = true"
icon="pi pi-plus"
label="Add An Address"
size="small"
severity="secondary"
/>
</div>
<Dropdown
v-model="selectedAddressIndex"
:options="addressOptions"
option-label="label"
option-value="value"
placeholder="Select an address"
class="w-full address-dropdown"
@change="handleAddressChange"
>
<template #value="slotProps">
<div v-if="slotProps.value !== null && slotProps.value !== undefined" class="dropdown-value">
<span class="address-title">{{ addresses[slotProps.value]?.addressTitle || 'Unnamed Address' }}</span>
<div class="address-badges">
<Badge
v-if="addresses[slotProps.value]?.isPrimaryAddress && !addresses[slotProps.value]?.isServiceAddress"
value="Billing Only"
severity="info"
/>
<Badge
v-if="addresses[slotProps.value]?.isPrimaryAddress && addresses[slotProps.value]?.isServiceAddress"
value="Billing & Service"
severity="success"
/>
<Badge
v-if="!addresses[slotProps.value]?.isPrimaryAddress && addresses[slotProps.value]?.isServiceAddress"
value="Service"
severity="secondary"
/>
<Badge
v-if="addresses[slotProps.value]?.isServiceAddress"
:value="`${addresses[slotProps.value]?.projects?.length || 0} Projects`"
severity="contrast"
/>
</div>
</div>
</template>
<template #option="slotProps">
<div class="dropdown-option">
<span class="option-title">{{ slotProps.option.addressTitle || 'Unnamed Address' }}</span>
<div class="option-badges">
<Badge
v-if="slotProps.option.isPrimaryAddress && !slotProps.option.isServiceAddress"
value="Billing Only"
severity="info"
size="small"
/>
<Badge
v-if="slotProps.option.isPrimaryAddress && slotProps.option.isServiceAddress"
value="Billing & Service"
severity="success"
size="small"
/>
<Badge
v-if="!slotProps.option.isPrimaryAddress && slotProps.option.isServiceAddress"
value="Service"
severity="secondary"
size="small"
/>
<Badge
v-if="slotProps.option.isServiceAddress"
:value="`${slotProps.option.projectCount} Projects`"
severity="contrast"
size="small"
/>
</div>
</div>
</template>
</Dropdown>
<!-- Selected Address Info -->
<div v-if="selectedAddress" class="selected-address-info">
<div class="address-status">
<Badge
v-if="selectedAddress.isPrimaryAddress && !selectedAddress.isServiceAddress"
value="Billing Only Address"
severity="info"
/>
<Badge
v-if="selectedAddress.isPrimaryAddress && selectedAddress.isServiceAddress"
value="Billing & Service Address"
severity="success"
/>
<Badge
v-if="!selectedAddress.isPrimaryAddress && selectedAddress.isServiceAddress"
value="Service Address"
severity="secondary"
/>
</div>
<!-- Service Address Details -->
<div v-if="selectedAddress.isServiceAddress" class="service-details">
<div class="detail-item">
<i class="pi pi-briefcase"></i>
<span>{{ selectedAddress.projects?.length || 0 }} Projects</span>
</div>
<div class="detail-item">
<i class="pi pi-calendar"></i>
<span>{{ selectedAddress.onsiteMeetings?.length || 0 }} Bid Meetings</span>
</div>
<div v-if="primaryContact" class="detail-item primary-contact">
<i class="pi pi-user"></i>
<div class="contact-info">
<span class="contact-name">{{ primaryContactName }}</span>
<span class="contact-detail">{{ primaryContactEmail }}</span>
<span class="contact-detail">{{ primaryContactPhone }}</span>
</div>
</div>
</div>
</div>
<!-- Add Address Modal -->
<Dialog
:visible="showAddAddressModal"
@update:visible="showAddAddressModal = $event"
header="Add Address"
:modal="true"
:closable="true"
class="add-address-dialog"
>
<div class="coming-soon">
<i class="pi pi-hourglass"></i>
<p>Feature coming soon</p>
</div>
<template #footer>
<Button
label="Close"
severity="secondary"
@click="showAddAddressModal = false"
/>
</template>
</Dialog>
</div>
</template>
<script setup>
import { computed, ref, watch } from "vue";
import Badge from "primevue/badge";
import Button from "primevue/button";
import Dialog from "primevue/dialog";
import Dropdown from "primevue/dropdown";
import DataUtils from "../../utils";
const props = defineProps({
addresses: {
type: Array,
required: true,
},
selectedAddressIdx: {
type: Number,
default: 0,
},
contacts: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(["update:selectedAddressIdx"]);
const showAddAddressModal = ref(false);
const selectedAddressIndex = ref(props.selectedAddressIdx);
// Watch for external changes to selectedAddressIdx
watch(() => props.selectedAddressIdx, (newVal) => {
selectedAddressIndex.value = newVal;
});
// Selected address object
const selectedAddress = computed(() => {
if (selectedAddressIndex.value >= 0 && selectedAddressIndex.value < props.addresses.length) {
return props.addresses[selectedAddressIndex.value];
}
return null;
});
// Address options for dropdown
const addressOptions = computed(() => {
return props.addresses.map((addr, idx) => ({
label: addr.addressTitle || DataUtils.calculateFullAddress(addr),
value: idx,
addressTitle: addr.addressTitle || 'Unnamed Address',
isPrimaryAddress: addr.isPrimaryAddress,
isServiceAddress: addr.isServiceAddress,
projectCount: addr.projects?.length || 0,
}));
});
// Primary contact for selected address
const primaryContact = computed(() => {
if (!selectedAddress.value?.primaryContact || !props.contacts) return null;
return props.contacts.find(c => c.name === selectedAddress.value.primaryContact);
});
const primaryContactName = computed(() => {
if (!primaryContact.value) return "N/A";
return primaryContact.value.fullName || primaryContact.value.name || "N/A";
});
const primaryContactEmail = computed(() => {
if (!primaryContact.value) return "N/A";
return primaryContact.value.emailId || primaryContact.value.customEmail || "N/A";
});
const primaryContactPhone = computed(() => {
if (!primaryContact.value) return "N/A";
return primaryContact.value.phone || primaryContact.value.mobileNo || "N/A";
});
// Handle address change
const handleAddressChange = () => {
emit("update:selectedAddressIdx", selectedAddressIndex.value);
};
</script>
<style scoped>
.address-selector {
background: var(--surface-card);
border-radius: 12px;
padding: 1.5rem;
border: 1px solid var(--surface-border);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
margin-bottom: 1rem;
}
.selector-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.selector-header h4 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-color);
}
.address-dropdown {
width: 100%;
margin-bottom: 1rem;
}
.dropdown-value,
.dropdown-option {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.25rem 0;
}
.address-title,
.option-title {
font-weight: 600;
color: var(--text-color);
}
.address-badges,
.option-badges {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.selected-address-info {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
background: var(--surface-ground);
border-radius: 8px;
}
.address-status {
display: flex;
gap: 0.5rem;
}
.service-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.detail-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: var(--surface-card);
border-radius: 6px;
}
.detail-item i {
font-size: 1.25rem;
color: var(--primary-color);
}
.detail-item span {
font-size: 0.95rem;
font-weight: 500;
color: var(--text-color);
}
.detail-item.primary-contact {
grid-column: 1 / -1;
}
.contact-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.contact-name {
font-weight: 600;
color: var(--text-color);
font-size: 1rem;
}
.contact-detail {
font-size: 0.875rem;
color: var(--text-color-secondary);
}
.coming-soon {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 2rem;
text-align: center;
}
.coming-soon i {
font-size: 3rem;
color: var(--text-color-secondary);
}
.coming-soon p {
font-size: 1.1rem;
color: var(--text-color-secondary);
margin: 0;
}
.w-full {
width: 100%;
}
</style>

View file

@ -0,0 +1,272 @@
<template>
<div class="general-client-info">
<div class="info-grid">
<!-- Lead Badge -->
<div v-if="isLead" class="lead-badge-container">
<Badge value="LEAD" severity="warn" size="large" />
</div>
<!-- Client Name (only show for Company type) -->
<div v-if="clientData.customerType === 'Company'" class="info-section">
<label>Company Name</label>
<span class="info-value large">{{ displayClientName }}</span>
</div>
<!-- Client Type -->
<div class="info-section">
<label>Client Type</label>
<span class="info-value">{{ clientData.customerType || "N/A" }}</span>
</div>
<!-- Associated Companies -->
<div v-if="associatedCompanies.length > 0" class="info-section">
<label>Associated Companies</label>
<div class="companies-list">
<Tag
v-for="company in associatedCompanies"
:key="company"
:value="company"
severity="info"
/>
</div>
</div>
<!-- Billing Address -->
<div v-if="billingAddress" class="info-section">
<label>Billing Address</label>
<span class="info-value">{{ billingAddress }}</span>
</div>
<!-- Primary Contact Info -->
<div v-if="primaryContact" class="info-section primary-contact">
<label>{{ clientData.customerType === 'Individual' ? 'Contact Information' : 'Primary Contact' }}</label>
<div class="contact-details">
<div class="contact-item">
<i class="pi pi-user"></i>
<span>{{ primaryContact.fullName || primaryContact.name || "N/A" }}</span>
</div>
<div class="contact-item">
<i class="pi pi-envelope"></i>
<span>{{ primaryContact.emailId || primaryContact.customEmail || "N/A" }}</span>
</div>
<div class="contact-item">
<i class="pi pi-phone"></i>
<span>{{ primaryContact.phone || primaryContact.mobileNo || "N/A" }}</span>
</div>
</div>
</div>
<!-- Statistics -->
<div class="info-section stats">
<label>Overview</label>
<div class="stats-grid">
<div class="stat-item">
<i class="pi pi-map-marker"></i>
<span class="stat-value">{{ addressCount }}</span>
<span class="stat-label">Addresses</span>
</div>
<div class="stat-item">
<i class="pi pi-users"></i>
<span class="stat-value">{{ contactCount }}</span>
<span class="stat-label">Contacts</span>
</div>
<div class="stat-item">
<i class="pi pi-briefcase"></i>
<span class="stat-value">{{ projectCount }}</span>
<span class="stat-label">Projects</span>
</div>
</div>
</div>
<!-- Creation Date -->
<div class="info-section">
<label>Created</label>
<span class="info-value">{{ formattedCreationDate }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from "vue";
import Badge from "primevue/badge";
import Tag from "primevue/tag";
const props = defineProps({
clientData: {
type: Object,
required: true,
},
});
// Check if client is a Lead
const isLead = computed(() => props.clientData.doctype === "Lead");
// Strip "-#-" from client name
const displayClientName = computed(() => {
if (!props.clientData.customerName) return "N/A";
return props.clientData.customerName.split("-#-")[0].trim();
});
// Get associated companies
const associatedCompanies = computed(() => {
if (!props.clientData.companies || !Array.isArray(props.clientData.companies)) {
return [];
}
return props.clientData.companies.map(c => c.company).filter(Boolean);
});
// Strip "-#-" from billing address
const billingAddress = computed(() => {
if (!props.clientData.customBillingAddress) return null;
return props.clientData.customBillingAddress.split("-#-")[0].trim();
});
// Get primary contact
const primaryContact = computed(() => {
if (!props.clientData.contacts || !props.clientData.primaryContact) return null;
return props.clientData.contacts.find(
c => c.name === props.clientData.primaryContact
);
});
// Counts
const addressCount = computed(() => props.clientData.addresses?.length || 0);
const contactCount = computed(() => props.clientData.contacts?.length || 0);
const projectCount = computed(() => props.clientData.jobs?.length || 0);
// Format creation date
const formattedCreationDate = computed(() => {
if (!props.clientData.creation) return "N/A";
const date = new Date(props.clientData.creation);
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
});
</script>
<style scoped>
.general-client-info {
background: var(--surface-card);
border-radius: 8px;
padding: 0.75rem 1rem;
border: 1px solid var(--surface-border);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
margin-bottom: 0.75rem;
}
.lead-badge-container {
display: inline-flex;
margin-left: 0.5rem;
vertical-align: middle;
}
.info-grid {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem 2rem;
}
.info-section {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.info-section label {
font-size: 0.7rem;
font-weight: 600;
color: var(--text-color-secondary);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.info-value {
font-size: 0.85rem;
color: var(--text-color);
font-weight: 500;
}
.info-value.large {
font-size: 1.1rem;
font-weight: 600;
color: var(--primary-color);
}
.companies-list {
display: inline-flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.primary-contact {
display: flex;
align-items: center;
gap: 0.5rem;
}
.contact-details {
display: inline-flex;
flex-wrap: wrap;
gap: 1rem;
padding: 0.35rem 0.75rem;
background: var(--surface-ground);
border-radius: 4px;
}
.contact-item {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.contact-item i {
color: var(--primary-color);
font-size: 0.85rem;
}
.contact-item span {
font-size: 0.8rem;
color: var(--text-color);
}
.stats {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.stats-grid {
display: inline-flex;
gap: 1rem;
padding: 0.35rem 0.75rem;
background: var(--surface-ground);
border-radius: 4px;
}
.stat-item {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.stat-item i {
font-size: 0.9rem;
color: var(--primary-color);
}
.stat-value {
font-size: 0.95rem;
font-weight: 600;
color: var(--text-color);
}
.stat-label {
font-size: 0.75rem;
color: var(--text-color-secondary);
font-weight: 500;
}
</style>

View file

@ -0,0 +1,132 @@
<template>
<div class="overview-content">
<!-- New Client Forms -->
<div v-if="isNew" class="new-client-forms">
<ClientInformationForm
:form-data="client"
:is-submitting="false"
:is-edit-mode="false"
@update:formData="handleFormDataUpdate"
@newClientToggle="handleNewClientToggle"
@customerSelected="handleCustomerSelected"
/>
<ContactInformationForm
:form-data="client"
:is-submitting="false"
:is-edit-mode="false"
@update:formData="handleFormDataUpdate"
/>
<AddressInformationForm
:form-data="client"
:is-submitting="false"
:is-edit-mode="false"
@update:formData="handleFormDataUpdate"
/>
</div>
<!-- Quick Actions (only in non-edit mode) -->
<QuickActions
v-if="!editMode && !isNew"
:full-address="fullAddress"
@edit-mode-enabled="handleEditModeEnabled"
/>
<!-- Special Modules Section -->
<SpecialModules
v-if="!isNew && !editMode"
:selected-address="selectedAddress"
:full-address="fullAddress"
/>
<!-- Property Details -->
<PropertyDetails
v-if="!isNew && selectedAddress"
:address-data="selectedAddress"
:all-contacts="allContacts"
:edit-mode="editMode"
@update:address-contacts="handleAddressContactsUpdate"
@update:primary-contact="handlePrimaryContactUpdate"
/>
</div>
</template>
<script setup>
import QuickActions from "./QuickActions.vue";
import SpecialModules from "./SpecialModules.vue";
import PropertyDetails from "./PropertyDetails.vue";
import ClientInformationForm from "../clientSubPages/ClientInformationForm.vue";
import ContactInformationForm from "../clientSubPages/ContactInformationForm.vue";
import AddressInformationForm from "../clientSubPages/AddressInformationForm.vue";
const props = defineProps({
selectedAddress: {
type: Object,
default: null,
},
allContacts: {
type: Array,
default: () => [],
},
editMode: {
type: Boolean,
default: false,
},
isNew: {
type: Boolean,
default: false,
},
fullAddress: {
type: String,
default: "",
},
client: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits([
"edit-mode-enabled",
"update:address-contacts",
"update:primary-contact",
"update:client",
]);
const handleEditModeEnabled = () => {
emit("edit-mode-enabled");
};
const handleAddressContactsUpdate = (contactNames) => {
emit("update:address-contacts", contactNames);
};
const handlePrimaryContactUpdate = (contactName) => {
emit("update:primary-contact", contactName);
};
const handleFormDataUpdate = (newFormData) => {
emit("update:client", newFormData);
};
const handleNewClientToggle = (isNewClient) => {
// Handle if needed
};
const handleCustomerSelected = (customer) => {
// Handle if needed
};
</script>
<style scoped>
.overview-content {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.new-client-forms {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
</style>

View file

@ -0,0 +1,617 @@
<template>
<div class="property-details">
<h3>Property Details</h3>
<div class="details-grid">
<!-- Address Information -->
<div class="detail-section full-width">
<div class="section-header">
<i class="pi pi-map-marker"></i>
<h4>Address</h4>
</div>
<div class="address-info">
<p class="full-address">{{ fullAddress }}</p>
<div class="address-badges">
<Badge
v-if="addressData.isPrimaryAddress && !addressData.isServiceAddress"
value="Billing Only"
severity="info"
/>
<Badge
v-if="addressData.isPrimaryAddress && addressData.isServiceAddress"
value="Billing & Service"
severity="success"
/>
<Badge
v-if="!addressData.isPrimaryAddress && addressData.isServiceAddress"
value="Service Address"
severity="secondary"
/>
</div>
</div>
</div>
<!-- Contacts Section -->
<div class="detail-section full-width">
<div class="section-header">
<i class="pi pi-users"></i>
<h4>Contacts</h4>
</div>
<!-- Display Mode -->
<div v-if="!editMode" class="contacts-display">
<template v-if="addressContacts.length > 0">
<!-- Primary Contact -->
<div v-if="primaryContact" class="contact-card primary">
<div class="contact-badge">
<Badge value="Primary" severity="success" />
</div>
<div class="contact-info">
<h5>{{ primaryContactName }}</h5>
<div class="contact-details">
<div class="contact-detail">
<i class="pi pi-envelope"></i>
<span>{{ primaryContactEmail }}</span>
</div>
<div class="contact-detail">
<i class="pi pi-phone"></i>
<span>{{ primaryContactPhone }}</span>
</div>
<div v-if="primaryContact.role" class="contact-detail">
<i class="pi pi-briefcase"></i>
<span>{{ primaryContact.role }}</span>
</div>
</div>
</div>
</div>
<!-- Other Contacts -->
<div v-if="otherContacts.length > 0" class="other-contacts">
<h6>Other Contacts</h6>
<div class="contacts-grid">
<div
v-for="contact in otherContacts"
:key="contact.name"
class="contact-card small"
>
<div class="contact-info-compact">
<span class="contact-name">{{ getContactName(contact) }}</span>
<span class="contact-email">{{ getContactEmail(contact) }}</span>
<span class="contact-phone">{{ getContactPhone(contact) }}</span>
<span v-if="contact.role" class="contact-role">{{ contact.role }}</span>
</div>
</div>
</div>
</div>
</template>
<template v-else>
<div class="empty-state">
<i class="pi pi-user-minus"></i>
<p>No contacts associated with this address</p>
</div>
</template>
</div>
<!-- Edit Mode -->
<div v-else class="contacts-edit">
<div class="edit-instructions">
<i class="pi pi-info-circle"></i>
<span>Select contacts to associate with this address. One must be marked as primary.</span>
</div>
<div class="contacts-list">
<div
v-for="contact in allContacts"
:key="contact.name"
class="contact-checkbox-item"
:class="{ 'is-selected': isContactSelected(contact) }"
>
<Checkbox
:model-value="isContactSelected(contact)"
:binary="true"
@update:model-value="toggleContact(contact)"
:input-id="`contact-${contact.name}`"
/>
<label :for="`contact-${contact.name}`" class="contact-label">
<div class="contact-info-inline">
<span class="contact-name">{{ getContactName(contact) }}</span>
<span class="contact-email">{{ getContactEmail(contact) }}</span>
<span class="contact-phone">{{ getContactPhone(contact) }}</span>
</div>
</label>
<div v-if="isContactSelected(contact)" class="primary-checkbox">
<Checkbox
:model-value="isPrimaryContact(contact)"
:binary="true"
@update:model-value="setPrimaryContact(contact)"
:input-id="`primary-${contact.name}`"
/>
<label :for="`primary-${contact.name}`">Primary</label>
</div>
</div>
</div>
</div>
</div>
<!-- Companies Section -->
<div class="detail-section full-width">
<div class="section-header">
<i class="pi pi-building"></i>
<h4>Associated Companies</h4>
</div>
<div v-if="associatedCompanies.length > 0" class="companies-list">
<div
v-for="company in associatedCompanies"
:key="company"
class="company-item"
>
<i class="pi pi-building"></i>
<span>{{ company }}</span>
</div>
</div>
<div v-else class="empty-state">
<i class="pi pi-building"></i>
<p>No companies associated with this address</p>
</div>
</div>
<!-- Map Section -->
<div class="detail-section full-width">
<div class="section-header">
<i class="pi pi-map"></i>
<h4>Location</h4>
</div>
<LeafletMap
:latitude="latitude"
:longitude="longitude"
:address-title="addressData.addressTitle || 'Property Location'"
map-height="350px"
:zoom-level="16"
/>
<div v-if="latitude && longitude" class="coordinates-info">
<small>
<strong>Coordinates:</strong>
{{ parseFloat(latitude).toFixed(6) }}, {{ parseFloat(longitude).toFixed(6) }}
</small>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from "vue";
import Badge from "primevue/badge";
import Checkbox from "primevue/checkbox";
import LeafletMap from "../common/LeafletMap.vue";
import DataUtils from "../../utils";
const props = defineProps({
addressData: {
type: Object,
required: true,
},
allContacts: {
type: Array,
default: () => [],
},
editMode: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:addressContacts", "update:primaryContact"]);
// Local state for editing
const selectedContactNames = ref([]);
const selectedPrimaryContactName = ref(null);
// Initialize from props when edit mode is enabled
watch(() => props.editMode, (isEditMode) => {
if (isEditMode) {
// Initialize selected contacts from address
selectedContactNames.value = (props.addressData.contacts || [])
.map(c => c.contact)
.filter(Boolean);
selectedPrimaryContactName.value = props.addressData.primaryContact || null;
}
});
// Full address
const fullAddress = computed(() => {
return DataUtils.calculateFullAddress(props.addressData);
});
// Get contacts associated with this address
const addressContacts = computed(() => {
if (!props.addressData.contacts || !props.allContacts) return [];
const addressContactNames = props.addressData.contacts.map(c => c.contact);
return props.allContacts.filter(c => addressContactNames.includes(c.name));
});
// Primary contact
const primaryContact = computed(() => {
if (!props.addressData.primaryContact || !props.allContacts) return null;
return props.allContacts.find(c => c.name === props.addressData.primaryContact);
});
const primaryContactName = computed(() => {
if (!primaryContact.value) return "N/A";
return primaryContact.value.fullName || primaryContact.value.name || "N/A";
});
const primaryContactEmail = computed(() => {
if (!primaryContact.value) return "N/A";
return primaryContact.value.emailId || primaryContact.value.customEmail || "N/A";
});
const primaryContactPhone = computed(() => {
if (!primaryContact.value) return "N/A";
return primaryContact.value.phone || primaryContact.value.mobileNo || "N/A";
});
// Other contacts (non-primary)
const otherContacts = computed(() => {
return addressContacts.value.filter(c => c.name !== props.addressData.primaryContact);
});
// Map coordinates
const latitude = computed(() => {
return props.addressData.customLatitude || props.addressData.latitude || null;
});
const longitude = computed(() => {
return props.addressData.customLongitude || props.addressData.longitude || null;
});
// Associated companies
const associatedCompanies = computed(() => {
if (!props.addressData.companies) return [];
return props.addressData.companies.map(company => company.company).filter(Boolean);
});
// Helper functions for contact display
const getContactName = (contact) => {
return contact.fullName || contact.name || "N/A";
};
const getContactEmail = (contact) => {
return contact.emailId || contact.customEmail || "N/A";
};
const getContactPhone = (contact) => {
return contact.phone || contact.mobileNo || "N/A";
};
// Edit mode functions
const isContactSelected = (contact) => {
return selectedContactNames.value.includes(contact.name);
};
const isPrimaryContact = (contact) => {
return selectedPrimaryContactName.value === contact.name;
};
const toggleContact = (contact) => {
const index = selectedContactNames.value.indexOf(contact.name);
if (index > -1) {
// Removing contact
selectedContactNames.value.splice(index, 1);
// If this was the primary contact, clear it
if (selectedPrimaryContactName.value === contact.name) {
selectedPrimaryContactName.value = null;
}
} else {
// Adding contact
selectedContactNames.value.push(contact.name);
}
emitChanges();
};
const setPrimaryContact = (contact) => {
if (isContactSelected(contact)) {
selectedPrimaryContactName.value = contact.name;
emitChanges();
}
};
const emitChanges = () => {
emit("update:addressContacts", selectedContactNames.value);
emit("update:primaryContact", selectedPrimaryContactName.value);
};
</script>
<style scoped>
.property-details {
background: var(--surface-card);
border-radius: 12px;
padding: 1.5rem;
border: 1px solid var(--surface-border);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
margin-bottom: 1rem;
}
.property-details > h3 {
margin: 0 0 1.5rem 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--text-color);
}
.details-grid {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.detail-section {
background: var(--surface-ground);
border-radius: 8px;
padding: 1.25rem;
}
.detail-section.full-width {
width: 100%;
}
.section-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 2px solid var(--surface-border);
}
.section-header i {
font-size: 1.25rem;
color: var(--primary-color);
}
.section-header h4 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-color);
}
.address-info {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.full-address {
font-size: 1.1rem;
font-weight: 500;
color: var(--text-color);
margin: 0;
}
.address-badges {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
/* Contacts Display Mode */
.contacts-display {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.contact-card {
background: var(--surface-card);
border-radius: 8px;
padding: 1.25rem;
border: 1px solid var(--surface-border);
}
.contact-card.primary {
border: 2px solid var(--green-500);
box-shadow: 0 2px 8px rgba(34, 197, 94, 0.15);
}
.contact-badge {
margin-bottom: 0.75rem;
}
.contact-info h5 {
margin: 0 0 0.75rem 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-color);
}
.contact-details {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.contact-detail {
display: flex;
align-items: center;
gap: 0.75rem;
}
.contact-detail i {
font-size: 1rem;
color: var(--primary-color);
min-width: 20px;
}
.contact-detail span {
font-size: 0.95rem;
color: var(--text-color);
}
/* Other Contacts */
.other-contacts h6 {
margin: 0 0 1rem 0;
font-size: 0.9rem;
font-weight: 600;
color: var(--text-color-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.contacts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
}
.contact-card.small {
padding: 1rem;
}
.contact-info-compact {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.contact-info-compact .contact-name {
font-weight: 600;
font-size: 0.95rem;
color: var(--text-color);
}
.contact-info-compact .contact-email,
.contact-info-compact .contact-phone,
.contact-info-compact .contact-role {
font-size: 0.85rem;
color: var(--text-color-secondary);
}
/* Contacts Edit Mode */
.contacts-edit {
display: flex;
flex-direction: column;
gap: 1rem;
}
.edit-instructions {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: var(--blue-50);
border-radius: 6px;
border: 1px solid var(--blue-200);
}
.edit-instructions i {
font-size: 1.25rem;
color: var(--blue-500);
}
.edit-instructions span {
font-size: 0.9rem;
color: var(--blue-700);
}
.contacts-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.contact-checkbox-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: var(--surface-card);
border-radius: 6px;
border: 2px solid var(--surface-border);
transition: all 0.2s ease;
}
.contact-checkbox-item.is-selected {
border-color: var(--primary-color);
background: var(--primary-50);
}
.contact-label {
flex: 1;
cursor: pointer;
}
.contact-info-inline {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.contact-info-inline .contact-name {
font-weight: 600;
font-size: 1rem;
color: var(--text-color);
}
.contact-info-inline .contact-email,
.contact-info-inline .contact-phone {
font-size: 0.875rem;
color: var(--text-color-secondary);
}
.primary-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--green-50);
border-radius: 4px;
border: 1px solid var(--green-200);
}
.primary-checkbox label {
font-size: 0.875rem;
font-weight: 600;
color: var(--green-700);
cursor: pointer;
}
/* Companies */
.companies-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.company-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: var(--surface-card);
border-radius: 6px;
border: 1px solid var(--surface-border);
}
.company-item i {
font-size: 1rem;
color: var(--primary-color);
min-width: 20px;
}
.company-item span {
font-size: 0.95rem;
color: var(--text-color);
font-weight: 500;
}
/* Map */
.coordinates-info {
margin-top: 0.75rem;
text-align: center;
color: var(--text-color-secondary);
padding-top: 0.75rem;
border-top: 1px solid var(--surface-border);
}
</style>

View file

@ -0,0 +1,118 @@
<template>
<div class="quick-actions">
<Button
@click="handleEdit"
icon="pi pi-pencil"
label="Edit Information"
size="small"
severity="secondary"
/>
<Button
@click="handleCreateEstimate"
icon="pi pi-file-edit"
label="Create Estimate"
size="small"
severity="secondary"
/>
<Button
@click="handleCreateBidMeeting"
icon="pi pi-calendar-plus"
label="Create Bid Meeting"
size="small"
severity="secondary"
/>
<!-- Edit Confirmation Dialog -->
<Dialog
:visible="showEditConfirmDialog"
@update:visible="showEditConfirmDialog = $event"
header="Confirm Edit"
:modal="true"
:closable="false"
class="confirm-dialog"
>
<p>Are you sure you want to edit this client information? This will enable editing mode.</p>
<template #footer>
<Button
label="Cancel"
severity="secondary"
@click="showEditConfirmDialog = false"
/>
<Button
label="Yes, Edit"
@click="confirmEdit"
/>
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref } from "vue";
import Button from "primevue/button";
import Dialog from "primevue/dialog";
import { useRouter } from "vue-router";
import DataUtils from "../../utils";
const props = defineProps({
fullAddress: {
type: String,
required: true,
},
});
const emit = defineEmits(["edit-mode-enabled"]);
const router = useRouter();
const showEditConfirmDialog = ref(false);
const handleEdit = () => {
showEditConfirmDialog.value = true;
};
const confirmEdit = () => {
showEditConfirmDialog.value = false;
emit("edit-mode-enabled");
};
const handleCreateEstimate = () => {
router.push({
path: "/estimate",
query: {
new: "true",
address: props.fullAddress,
},
});
};
const handleCreateBidMeeting = () => {
router.push({
path: "/calendar",
query: {
tab: "bids",
new: "true",
address: props.fullAddress,
},
});
};
</script>
<style scoped>
.quick-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.confirm-dialog {
max-width: 400px;
}
.confirm-dialog :deep(.p-dialog-footer) {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
</style>

View file

@ -0,0 +1,89 @@
<template>
<div v-if="shouldDisplayModule" class="special-modules">
<!-- SNW Install Module -->
<InstallStatus
v-if="currentCompany === 'Sprinklers Northwest'"
: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>
</template>
<script setup>
import { computed } from "vue";
import { useCompanyStore } from "../../stores/company";
import InstallStatus from "./InstallStatus.vue";
const props = defineProps({
selectedAddress: {
type: Object,
default: null,
},
fullAddress: {
type: String,
default: "",
},
});
const companyStore = useCompanyStore();
const currentCompany = computed(() => companyStore.currentCompany);
// Check if we should display any module
const shouldDisplayModule = computed(() => {
return currentCompany.value === "Sprinklers Northwest";
});
// Computed data for SNW Install status
const snwInstallData = computed(() => {
if (!props.selectedAddress) {
return {
onsiteMeetingStatus: "Not Started",
estimateSentStatus: "Not Started",
jobStatus: "Not Started",
paymentStatus: "Not Started",
onsiteMeeting: "",
estimate: "",
job: "",
payment: "dummy-payment-string",
};
}
const addr = props.selectedAddress;
// Filter for SNW Install template
const snwBidMeeting = addr.onsiteMeetings?.find(
(m) => m.projectTemplate === "SNW Install" && m.status !== "Cancelled"
);
const snwEstimate = addr.quotations?.find(
(q) => q.projectTemplate === "SNW Install" && q.status !== "Cancelled"
);
const snwJob = addr.projects?.find(
(p) => p.projectTemplate === "SNW Install" && p.status !== "Cancelled"
);
return {
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",
};
});
</script>
<style scoped>
.special-modules {
margin-bottom: 1rem;
}
</style>

View file

@ -59,6 +59,7 @@ const props = defineProps({
const mapElement = ref(null);
let map = null;
let marker = null;
let resizeObserver = null;
const initializeMap = async () => {
if (!mapElement.value) return;
@ -75,8 +76,12 @@ const initializeMap = async () => {
// Only create map if we have valid coordinates
if (!isNaN(lat) && !isNaN(lng)) {
// Wait for next tick to ensure DOM is updated
await nextTick();
// Additional delay to ensure container has proper dimensions
await new Promise(resolve => setTimeout(resolve, 50));
// Initialize map
map = L.map(mapElement.value, {
zoomControl: props.interactive,
@ -106,6 +111,28 @@ const initializeMap = async () => {
`,
)
.openPopup();
// Ensure map renders correctly - call invalidateSize multiple times
const invalidateMap = () => {
if (map) {
map.invalidateSize();
}
};
setTimeout(invalidateMap, 100);
setTimeout(invalidateMap, 300);
setTimeout(invalidateMap, 500);
// Set up resize observer to handle container size changes
if (resizeObserver) {
resizeObserver.disconnect();
}
resizeObserver = new ResizeObserver(() => {
if (map) {
map.invalidateSize();
}
});
resizeObserver.observe(mapElement.value);
}
};
@ -117,6 +144,16 @@ const updateMap = () => {
// Update map view
map.setView([lat, lng], props.zoomLevel);
// Ensure map renders correctly after view change
const invalidateMap = () => {
if (map) {
map.invalidateSize();
}
};
setTimeout(invalidateMap, 100);
setTimeout(invalidateMap, 300);
// Update marker
if (marker) {
marker.setLatLng([lat, lng]);
@ -164,6 +201,10 @@ onUnmounted(() => {
map = null;
marker = null;
}
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
});
</script>
@ -178,6 +219,7 @@ onUnmounted(() => {
.map {
width: 100%;
position: relative;
z-index: 1;
}

View file

@ -573,7 +573,7 @@ const loadDoctypeOptions = async () => {
for (const field of fieldsWithDoctype) {
try {
// Use the new API method for fetching docs
let docs = await Api.getEstimateItems();
let docs = await Api.getQuotationItems();
// Deduplicate by value field
const valueField = field.doctypeValueField || 'name';

View file

@ -11,28 +11,6 @@
</div>
<div v-else-if="bidNote" class="notes-content">
<!-- Header Information -->
<div class="notes-header">
<div class="header-info">
<div class="info-item">
<span class="label">Meeting:</span>
<span class="value">{{ bidNote.bidMeeting }}</span>
</div>
<div class="info-item" v-if="bidNote.formTemplate">
<span class="label">Template:</span>
<span class="value">{{ bidNote.formTemplate }}</span>
</div>
<div class="info-item">
<span class="label">Created By:</span>
<span class="value">{{ bidNote.owner }}</span>
</div>
<div class="info-item">
<span class="label">Last Modified:</span>
<span class="value">{{ formatDate(bidNote.modified) }}</span>
</div>
</div>
</div>
<!-- General Notes (if exists) -->
<div v-if="bidNote.notes" class="general-notes">
<div class="section-header">
@ -107,6 +85,20 @@
<i class="pi pi-inbox"></i>
<p>No fields to display</p>
</div>
<!-- Header Information (moved to bottom) -->
<div class="notes-header">
<div class="header-info">
<div class="info-item">
<span class="label">Created By:</span>
<span class="value">{{ bidNote.owner }}</span>
</div>
<div class="info-item">
<span class="label">Submitted on:</span>
<span class="value">{{ formatDate(bidNote.creation) }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
@ -216,46 +208,83 @@ const getItemLabel = (field, item) => {
return item.item || 'Unknown Item';
};
const fetchDoctypeData = async (field, itemId) => {
if (!field.valueDoctype || !itemId) return null;
// Check cache first
const cacheKey = `${field.valueDoctype}:${itemId}`;
if (doctypeCache.value[cacheKey]) {
return doctypeCache.value[cacheKey];
}
try {
// Use the getDetailedDoc method from the API
const data = await Api.getDocsList(field.valueDoctype, ["*"], { "item_group": "SWN-S" });
// Cache the result
doctypeCache.value[cacheKey] = data;
return data;
} catch (error) {
console.error(`Error fetching doctype ${field.valueDoctype}:`, error);
return null;
}
};
const loadDoctypeLabels = async () => {
if (!props.bidNote?.fields) return;
// Check if there are quantities to fetch
if (!props.bidNote.quantities || props.bidNote.quantities.length === 0) {
return;
}
// Find all Multi-Select w/ Quantity fields that have valueDoctype
const quantityFields = props.bidNote.fields.filter(
f => f.type === 'Multi-Select w/ Quantity' && f.valueDoctype
);
if (quantityFields.length === 0) {
return;
}
// For each field type (valueDoctype), collect all item IDs and fetch them in batch
for (const field of quantityFields) {
const items = getParsedMultiSelectQty(field.value);
for (const item of items) {
if (item.item && !item.fetchedLabel) {
const data = await fetchDoctypeData(field, item.item);
if (data && field.doctypeLabelField) {
// Add the fetched label to the item
item.fetchedLabel = data[field.doctypeLabelField] || item.item;
if (items.length === 0) continue;
// Collect all item IDs for this field
const itemIds = items.map(item => item.item).filter(Boolean);
if (itemIds.length === 0) continue;
// Check which items are not already cached
const uncachedItemIds = itemIds.filter(itemId => {
const cacheKey = `${field.valueDoctype}:${itemId}`;
return !doctypeCache.value[cacheKey];
});
// If all items are cached, skip the API call
if (uncachedItemIds.length === 0) {
// Just map the cached data to the items
items.forEach(item => {
if (item.item) {
const cacheKey = `${field.valueDoctype}:${item.item}`;
const cachedData = doctypeCache.value[cacheKey];
if (cachedData && field.doctypeLabelField) {
item.fetchedLabel = cachedData[field.doctypeLabelField] || item.item;
}
}
});
continue;
}
try {
// Build filter to fetch all uncached items at once
const filters = {
name: ['in', uncachedItemIds]
};
// Fetch all items in one API call
const fetchedItems = await Api.getDocsList(field.valueDoctype, ["*"], filters);
// Cache the fetched items
if (Array.isArray(fetchedItems)) {
fetchedItems.forEach(docData => {
const cacheKey = `${field.valueDoctype}:${docData.name}`;
doctypeCache.value[cacheKey] = docData;
});
}
// Now map the labels to all items (including previously cached ones)
items.forEach(item => {
if (item.item) {
const cacheKey = `${field.valueDoctype}:${item.item}`;
const cachedData = doctypeCache.value[cacheKey];
if (cachedData && field.doctypeLabelField) {
item.fetchedLabel = cachedData[field.doctypeLabelField] || item.item;
}
}
});
} catch (error) {
console.error(`Error fetching doctype ${field.valueDoctype}:`, error);
// On error, items will just show their IDs
}
}
};
@ -321,32 +350,37 @@ onMounted(async () => {
.notes-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 8px;
padding: 12px 16px;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-top: 20px;
}
.header-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
display: flex;
flex-wrap: wrap;
gap: 16px;
justify-content: center;
}
.info-item {
display: flex;
flex-direction: column;
gap: 4px;
gap: 2px;
min-width: 120px;
}
.info-item .label {
font-size: 0.85em;
font-size: 0.75em;
opacity: 0.9;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.info-item .value {
font-weight: 600;
font-size: 1em;
font-size: 0.9em;
}
.general-notes {

View file

@ -130,7 +130,7 @@
<!-- Action Buttons -->
<div class="action-buttons">
<v-btn
v-if="meeting.status !== 'Completed' && meeting.status !== 'Unscheduled'"
v-if="meeting.status === 'Scheduled'"
@click="handleMarkComplete"
color="success"
variant="elevated"
@ -160,6 +160,16 @@
<v-icon left>mdi-file-document-outline</v-icon>
Create Estimate
</v-btn>
<v-btn
v-if="meeting.status !== 'Completed'"
@click="showCancelWarning = true"
color="error"
variant="outlined"
>
<v-icon left>mdi-cancel</v-icon>
Cancel Meeting
</v-btn>
</div>
</div>
</Modal>
@ -187,6 +197,53 @@
<span>Loading bid notes...</span>
</div>
</Modal>
<!-- Cancel Meeting Warning Dialog -->
<v-dialog v-model="showCancelWarning" max-width="500px">
<v-card>
<v-card-title class="text-h5 text-error">
<v-icon color="error" class="mr-2">mdi-alert</v-icon>
Cancel Bid Meeting?
</v-card-title>
<v-card-text class="pt-4">
<p class="text-body-1 mb-3">
<strong>Warning:</strong> This will permanently cancel this bid meeting.
</p>
<template v-if="meeting?.status === 'Scheduled'">
<p class="text-body-2 mb-3">
If you want to:
</p>
<ul class="text-body-2 mb-3">
<li><strong>Reschedule:</strong> Drag and drop the meeting to a different time slot</li>
<li><strong>Unschedule:</strong> Drag the meeting back to the unscheduled section</li>
</ul>
<p class="text-body-2 mb-2">
<strong>Note:</strong> Cancelling permanently marks the meeting as cancelled, which is different from rescheduling or unscheduling it.
</p>
</template>
<p class="text-body-1 font-weight-bold">
Are you sure you want to proceed with canceling this meeting?
</p>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
variant="text"
@click="showCancelWarning = false"
>
No, Keep Meeting
</v-btn>
<v-btn
color="error"
variant="elevated"
@click="handleCancelMeeting"
:loading="isCanceling"
>
Yes, Cancel Meeting
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
@ -222,6 +279,8 @@ const showBidNotesModal = ref(false);
const bidNoteData = ref(null);
const loadingBidNotes = ref(false);
const bidNotesError = ref(null);
const showCancelWarning = ref(false);
const isCanceling = ref(false);
const showModal = computed({
get() {
@ -408,6 +467,44 @@ const handleCloseBidNotes = () => {
bidNoteData.value = null;
bidNotesError.value = null;
};
const handleCancelMeeting = async () => {
if (!props.meeting?.name) return;
try {
isCanceling.value = true;
// Update the meeting status to Cancelled
await Api.updateBidMeeting(props.meeting.name, {
status: "Cancelled",
});
showCancelWarning.value = false;
notificationStore.addNotification({
type: "success",
title: "Meeting Cancelled",
message: "The bid meeting has been cancelled successfully.",
duration: 5000,
});
// Emit meeting updated event to refresh the calendar
emit("meetingUpdated");
// Close the modal
handleClose();
} catch (error) {
console.error("Error canceling meeting:", error);
notificationStore.addNotification({
type: "error",
title: "Error",
message: "Failed to cancel meeting. Please try again.",
duration: 5000,
});
} finally {
isCanceling.value = false;
}
};
</script>
<style scoped>

View file

@ -1,31 +1,79 @@
<template>
<div class="client-page">
<!-- Client Header -->
<TopBar :selectedAddressIdx="selectedAddressIdx" :client="client" :nextVisitDate="nextVisitDate" v-if="client.customerName" @update:selectedAddressIdx="selectedAddressIdx = $event" />
<GeneralClientInfo
v-if="client.customerName"
:client-data="client"
/>
<AdditionalInfoBar :address="client.addresses[selectedAddressIdx]" v-if="client.customerName" />
<Tabs value="0">
<!-- Address Selector (only shows if multiple addresses) -->
<AddressSelector
v-if="!isNew && client.addresses && client.addresses.length > 1"
:addresses="client.addresses"
:selected-address-idx="selectedAddressIdx"
:contacts="client.contacts"
@update:selected-address-idx="handleAddressChange"
/>
<!-- Main Content Tabs -->
<Tabs value="0" class="overview-tabs">
<TabList>
<Tab value="0">Overview</Tab>
<Tab value="1">Projects <span class="tab-info-alert">1</span></Tab>
<Tab value="1">Projects</Tab>
<Tab value="2">Financials</Tab>
</TabList>
<TabPanels>
<!-- Overview Tab -->
<TabPanel value="0">
<Overview
:client-data="client"
:selected-address="selectedAddress"
:selected-address="selectedAddressData"
:all-contacts="client.contacts"
:edit-mode="editMode"
:is-new="isNew"
:full-address="fullAddress"
:client="client"
@edit-mode-enabled="enableEditMode"
@update:address-contacts="handleAddressContactsUpdate"
@update:primary-contact="handlePrimaryContactUpdate"
@update:client="handleClientUpdate"
/>
</TabPanel>
<!-- Projects Tab -->
<TabPanel value="1">
<div id="projects-tab"><h3>Project Status</h3></div>
<div class="coming-soon-section">
<i class="pi pi-wrench"></i>
<h3>Projects</h3>
<p>Section coming soon</p>
</div>
</TabPanel>
<!-- Financials Tab -->
<TabPanel value="2">
<div id="financials-tab"><h3>Accounting</h3></div>
<div class="coming-soon-section">
<i class="pi pi-dollar"></i>
<h3>Financials</h3>
<p>Section coming soon</p>
</div>
</TabPanel>
</TabPanels>
</Tabs>
<!-- Form Actions (for edit mode or new client) -->
<div class="form-actions" v-if="editMode || isNew">
<Button
@click="handleCancel"
label="Cancel"
severity="secondary"
:disabled="isSubmitting"
/>
<Button
@click="handleSubmit"
:label="isNew ? 'Create Client' : 'Save Changes'"
:loading="isSubmitting"
/>
</div>
</div>
</template>
<script setup>
@ -35,19 +83,23 @@ import TabList from "primevue/tablist";
import Tab from "primevue/tab";
import TabPanels from "primevue/tabpanels";
import TabPanel from "primevue/tabpanel";
import Button from "primevue/button";
import Api from "../../api";
import { useRoute } from "vue-router";
import { useRoute, useRouter } from "vue-router";
import { useLoadingStore } from "../../stores/loading";
import { useNotificationStore } from "../../stores/notifications-primevue";
import { useCompanyStore } from "../../stores/company";
import DataUtils from "../../utils";
import Overview from "../clientSubPages/Overview.vue";
import ProjectStatus from "../clientSubPages/ProjectStatus.vue";
import TopBar from "../clientView/TopBar.vue";
import AddressSelector from "../clientView/AddressSelector.vue";
import GeneralClientInfo from "../clientView/GeneralClientInfo.vue";
import AdditionalInfoBar from "../clientView/AdditionalInfoBar.vue";
import Overview from "../clientView/Overview.vue";
const route = useRoute();
const router = useRouter();
const loadingStore = useLoadingStore();
const notificationStore = useNotificationStore();
const companyStore = useCompanyStore();
const address = route.query.address || null;
const clientName = route.query.client || null;
@ -73,6 +125,10 @@ const addresses = computed(() => {
const nextVisitDate = ref(null); // Placeholder, update as needed
// Tab and edit state
const editMode = ref(false);
const isSubmitting = ref(false);
const selectedAddressIdx = computed({
get: () => addresses.value.indexOf(selectedAddress.value),
set: (idx) => {
@ -82,6 +138,22 @@ const selectedAddressIdx = computed({
}
});
// Find the address data object that matches the selected address string
const selectedAddressData = computed(() => {
if (!client.value?.addresses || !selectedAddress.value) {
return null;
}
return client.value.addresses.find(
(addr) => DataUtils.calculateFullAddress(addr) === selectedAddress.value
);
});
// Calculate full address for display
const fullAddress = computed(() => {
if (!selectedAddressData.value) return "N/A";
return DataUtils.calculateFullAddress(selectedAddressData.value);
});
const getClientNames = async (type) => {
loadingStore.setLoading(true);
try {
@ -173,6 +245,88 @@ watch(
}
},
);
watch(
() => companyStore.currentCompany,
(newCompany) => {
console.log("############# Company changed to:", newCompany);
let companyIsPresent = false
for (company of selectedAddressData.value.companies || []) {
console.log("Checking address company:", company);
if (company.company === newCompany) {
companyIsPresent = true;
break;
}
}
if (!companyIsPresent) {
notificationStore.addWarning(
`The selected company is not linked to this address.`,
);
}
}
)
// Handle address change
const handleAddressChange = (newIdx) => {
selectedAddressIdx.value = newIdx;
// TODO: Update route query with new address
};
// Enable edit mode
const enableEditMode = () => {
editMode.value = true;
};
// Handle cancel edit or new
const handleCancel = () => {
if (isNew.value) {
// For new client, clear the form data
client.value = {};
} else {
editMode.value = false;
// Restore original data if editing
}
};
// Handle save edit or create new
const handleSubmit = async () => {
isSubmitting.value = true;
try {
if (isNew.value) {
const createdClient = await Api.createClient(client.value);
console.log("Created client:", createdClient);
notificationStore.addSuccess("Client created successfully!");
// Navigate to the created client
window.location.hash = '#/client?client=' + encodeURIComponent(createdClient.name || createdClient.customerName);
} else {
// TODO: Implement save logic
notificationStore.addSuccess("Changes saved successfully!");
editMode.value = false;
}
} catch (error) {
console.error("Error submitting:", error);
notificationStore.addError(isNew.value ? "Failed to create client" : "Failed to save changes");
} finally {
isSubmitting.value = false;
}
};
// Handle address contacts update
const handleAddressContactsUpdate = (contactNames) => {
console.log("Address contacts updated:", contactNames);
// TODO: Store this for saving
};
// Handle primary contact update
const handlePrimaryContactUpdate = (contactName) => {
console.log("Primary contact updated:", contactName);
// TODO: Store this for saving
};
// Handle client update from forms
const handleClientUpdate = (newClientData) => {
client.value = { ...client.value, ...newClientData };
};
</script>
<style lang="css">
.tab-info-alert {
@ -184,4 +338,63 @@ watch(
padding-top: 2px;
padding-bottom: 2px;
}
.overview-tabs {
margin-bottom: 1rem;
}
.coming-soon-section {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 4rem 2rem;
text-align: center;
background: var(--surface-card);
border-radius: 12px;
border: 1px solid var(--surface-border);
}
.coming-soon-section i {
font-size: 4rem;
color: var(--text-color-secondary);
opacity: 0.5;
}
.coming-soon-section h3 {
margin: 0;
font-size: 1.75rem;
font-weight: 600;
color: var(--text-color);
}
.coming-soon-section p {
margin: 0;
font-size: 1.1rem;
color: var(--text-color-secondary);
}
.client-page {
padding-bottom: 5rem; /* Add padding to prevent content from being hidden behind fixed buttons */
}
.form-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: flex-end;
gap: 1rem;
padding: 1.5rem;
background: var(--surface-card);
border-radius: 0;
border: 1px solid var(--surface-border);
border-bottom: none;
border-left: none;
border-right: none;
box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.1);
z-index: 1000;
}
</style>

View file

@ -381,6 +381,34 @@
</div>
</Modal>
<!-- Bid Meeting Notes Side Tab -->
<Button v-if="bidMeeting?.bidNotes" class="bid-notes-side-tab" @click="onTabClick">
<div class="tab-content">
<i class="pi pi-file-edit"></i>
<span class="tab-text">Bid Notes</span>
</div>
</Button>
<!-- Bid Meeting Notes Drawer -->
<Drawer
:visible="showDrawer"
@update:visible="showDrawer = $event"
position="right"
:style="{ width: '1200px' }"
@hide="showDrawer = false"
class="bid-notes-drawer"
>
<template #header>
<div class="drawer-header">
<h3>Bid Meeting Notes</h3>
<Button icon="pi pi-times" @click="showDrawer = false" text rounded />
</div>
</template>
<div class="drawer-content">
<BidMeetingNotes v-if="bidMeeting?.bidNotes" :bid-note="bidMeeting.bidNotes" />
</div>
</Drawer>
</div>
</template>
@ -391,11 +419,13 @@ import Modal from "../common/Modal.vue";
import SaveTemplateModal from "../modals/SaveTemplateModal.vue";
import DataTable from "../common/DataTable.vue";
import DocHistory from "../common/DocHistory.vue";
import BidMeetingNotes from "../modals/BidMeetingNotes.vue";
import InputText from "primevue/inputtext";
import InputNumber from "primevue/inputnumber";
import Button from "primevue/button";
import Select from "primevue/select";
import Tooltip from "primevue/tooltip";
import Drawer from "primevue/drawer";
import Api from "../../api";
import DataUtils from "../../utils";
import { useLoadingStore } from "../../stores/loading";
@ -452,8 +482,10 @@ const showResponseModal = ref(false);
const showSaveTemplateModal = ref(false);
const addressSearchResults = ref([]);
const itemSearchTerm = ref("");
const showDrawer = ref(false);
const estimate = ref(null);
const bidMeeting = ref(null);
// Computed property to determine if fields are editable
const isEditable = computed(() => {
@ -802,6 +834,15 @@ const toggleDiscountType = (item, type) => {
item.discountType = type;
};
const onTabClick = () => {
console.log('Bid notes tab clicked');
console.log('Current showDrawer value:', showDrawer.value);
console.log('bidMeeting:', bidMeeting.value);
console.log('bidMeeting?.bidNotes:', bidMeeting.value?.bidNotes);
showDrawer.value = true;
console.log('Set showDrawer to true');
};
const tableActions = [
{
label: "Add Selected Items",
@ -955,6 +996,12 @@ onMounted(async () => {
// Handle from-meeting query parameter
if (fromMeetingQuery.value) {
formData.fromMeeting = fromMeetingQuery.value;
// Fetch the bid meeting to check for bidNotes
try {
bidMeeting.value = await Api.getBidMeeting(fromMeetingQuery.value);
} catch (error) {
console.error("Error fetching bid meeting:", error);
}
}
}
@ -1034,6 +1081,46 @@ onMounted(async () => {
margin-bottom: 1rem;
}
.bid-notes-side-tab {
position: fixed;
right: -90px;
top: 50%;
transform: translateY(-50%);
background-color: #2196f3;
color: white;
padding: 12px 8px;
width: 110px;
border-radius: 0 4px 4px 0;
cursor: pointer;
box-shadow: -2px 2px 8px rgba(0, 0, 0, 0.2);
transition: all 0.3s ease;
z-index: 1000;
writing-mode: vertical-rl;
text-orientation: upright;
}
.bid-notes-side-tab:hover {
right: -80px;
background-color: #1976d2;
}
.tab-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.tab-content i {
font-size: 1.1em;
}
.tab-text {
font-weight: 500;
font-size: 0.9em;
white-space: nowrap;
}
.address-section,
.contact-section,
.project-template-section,
@ -1365,5 +1452,29 @@ onMounted(async () => {
font-size: 0.85rem;
color: #666;
}
.bid-notes-drawer {
box-shadow: -4px 0 8px rgba(0, 0, 0, 0.1);
}
.drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #e0e0e0;
background-color: #f8f9fa;
}
.drawer-header h3 {
margin: 0;
color: #333;
}
.drawer-content {
padding: 1rem;
height: calc(100vh - 80px);
overflow-y: auto;
}
</style>
<parameter name="filePath"></parameter>