This commit is contained in:
Casey 2026-02-18 06:56:19 -06:00
parent e2746b83bb
commit 1610905a43
16 changed files with 3303 additions and 2521 deletions

View file

@ -68,6 +68,7 @@ const FRAPPE_GET_CLIENT_TABLE_DATA_V2_METHOD = "custom_ui.api.db.clients.get_cli
const FRAPPE_GET_CLIENT_METHOD = "custom_ui.api.db.clients.get_client_v2";
const FRAPPE_GET_CLIENT_NAMES_METHOD = "custom_ui.api.db.clients.get_client_names";
const FRAPPE_CHECK_CLIENT_EXISTS_METHOD = "custom_ui.api.db.clients.check_client_exists";
const FRAPPE_ADD_ADDRESSES_CONTACTS_METHOD = "custom_ui.api.db.clients.add_addresses_contacts";
// Employee methods
const FRAPPE_GET_EMPLOYEES_METHOD = "custom_ui.api.db.employees.get_employees";
const FRAPPE_GET_EMPLOYEES_ORGANIZED_METHOD = "custom_ui.api.db.employees.get_employees_organized";
@ -81,7 +82,7 @@ const FRAPPE_UPDATE_SERVICE_APPOINTMENT_SCHEDULED_DATES_METHOD = "custom_ui.api.
const FRAPPE_UPDATE_SERVICE_APPOINTMENT_STATUS_METHOD = "custom_ui.api.db.service_appointments.update_service_appointment_status";
class Api {
// ============================================================================
// CORE REQUEST METHOPD
// CORE REQUEST METHOD
// ============================================================================
static async request(frappeMethod, args = {}) {
@ -183,6 +184,10 @@ class Api {
return result;
}
static async addAddressesAndContacts(clientName, companyName, addresses = [], contacts = []) {
return await this.request(FRAPPE_ADD_ADDRESSES_CONTACTS_METHOD, { clientName, companyName, addresses, contacts });
}
// ============================================================================
// ON-SITE MEETING METHODS
// ============================================================================

View file

@ -57,7 +57,9 @@
<v-card-title class="text-subtitle-1 py-2">
Select Project Templates
</v-card-title>
<v-divider></v-divider>
if (isHoliday(date)) {
classes.push('holiday');
}
<v-card-text class="pa-2">
<v-list density="compact">
<v-list-item @click="toggleAllTemplates">
@ -203,7 +205,7 @@
<div class="calendar-section">
<div class="weekly-calendar">
<!-- Days Header -->
<div class="calendar-header-row" :style="{ gridTemplateColumns: `150px repeat(7, 1fr)` }">
<div class="calendar-header-row" :style="{ gridTemplateColumns: `150px repeat(7, 140px)` }">
<div class="crew-column-header">Crew</div>
<div
v-for="day in weekDays"
@ -218,7 +220,7 @@
<!-- Foremen Grid -->
<div class="calendar-grid">
<div v-for="foreman in visibleForemen" :key="foreman.name" class="foreman-row" :style="{ gridTemplateColumns: `150px repeat(7, 1fr)` }">
<div v-for="foreman in visibleForemen" :key="foreman.name" class="foreman-row" :style="{ gridTemplateColumns: `150px repeat(7, 140px)` }">
<!-- Foreman/Crew Column -->
<div class="crew-column">
<div class="crew-name">{{ foreman.employeeName }}</div>
@ -230,67 +232,65 @@
v-for="day in weekDays"
:key="`${foreman.name}-${day.date}`"
class="day-cell"
:class="{
'today': isToday(day.date),
'holiday': isHoliday(day.date),
'sunday': isSunday(day.date),
'drag-over': isDragOver && dragOverCell?.foremanId === foreman.name && dragOverCell?.date === day.date,
'has-skipped-jobs': getSkippedJobsForCell(foreman.name, day.date).length > 0,
}"
:class="getCellStyling(foreman.name, day.date).classes"
:style="Object.assign({}, getCellStyling(foreman.name, day.date).data.backgroundColor ? { background: getCellStyling(foreman.name, day.date).data.backgroundColor } : {}, getCellStyling(foreman.name, day.date).data.rightBorder ? { borderRight: '2px solid #333' } : {})"
:draggable="getCellStyling(foreman.name, day.date).data.jobs.length > 0"
@dragstart="handleCellDragStart($event, foreman.name, day.date)"
@dragover="handleDragOver($event, foreman.name, day.date)"
@dragleave="handleDragLeave"
@drop="handleDrop($event, foreman.name, day.date)"
@click="skipMode ? handleSkipDayClick(foreman.name, day.date) : null"
@click="skipMode ? handleSkipDayClick(foreman.name, day.date) : handleCellClick(foreman.name, day.date, $event)"
@mousedown="(event) => handleCellMouseDown(event, foreman.name, day.date)"
>
<!-- Jobs in this day -->
<div
v-for="job in getJobsForCell(foreman.name, day.date)"
:key="job.name"
class="calendar-job"
:style="getJobStyle(job, day.date)"
:draggable="job.status === 'Scheduled'"
@click.stop="skipMode ? handleSkipDayClick(foreman.name, day.date, job) : showEventDetails({ event: job })"
@dragstart="job.status === 'Scheduled' ? handleDragStart(job, $event) : null"
@dragend="handleDragEnd"
@mousedown="(job.status === 'Scheduled' || job.status === 'Started') ? startResize($event, job, day.date) : null"
@drop.stop.prevent="handleScheduledDrop(job, $event, foreman.name, day.date)"
@dragover.stop.prevent="handleScheduledDragOver(job, $event, foreman.name, day.date)"
>
<v-icon v-if="jobStartsBeforeWeek(job)" size="small" class="spans-arrow-left">mdi-arrow-left</v-icon>
<div class="job-content">
<div class="job-title">{{ job.projectTemplate }}</div>
<div class="job-address">{{ job.serviceAddress.fullAddress }}</div>
</div>
<v-icon v-if="jobSpansToNextWeek(job)" size="small" class="spans-arrow-right">mdi-arrow-right</v-icon>
<div class="resize-handle"></div>
</div>
<!-- Skipped days -->
<div
v-for="job in getSkippedJobsForCell(foreman.name, day.date)"
:key="`skip-${job.name}`"
class="skipped-day"
:class="getPriorityClass(job.priority)"
>
<span>Skipped</span>
<button
class="remove-skip-btn"
@click.stop="handleRemoveSkip(job, day.date)"
title="Remove skipped day"
>
<v-icon size="small">mdi-close</v-icon>
</button>
</div>
<!-- Job content for scheduled cells -->
<template v-if="getCellStyling(foreman.name, day.date).data.jobs.length > 0 && !getCellStyling(foreman.name, day.date).data.isSkipped">
<div class="job-content">
<!-- Start icon/component -->
<span v-if="getCellStyling(foreman.name, day.date).data.showStartIcon" class="job-start-icon">
<v-icon size="small">mdi-play-circle</v-icon>
</span>
<!-- End icon/component -->
<span v-if="getCellStyling(foreman.name, day.date).data.showEndIcon" class="job-end-icon">
<v-icon size="small">mdi-stop-circle</v-icon>
</span>
<!-- Address info only if showAddress -->
<div v-if="getCellStyling(foreman.name, day.date).data.showAddress" class="job-address">
{{ getCellStyling(foreman.name, day.date).data.jobs[0].serviceAddress.fullAddress }}
</div>
<div class="job-title">{{ getCellStyling(foreman.name, day.date).data.jobs[0].projectTemplate }}</div>
</div>
<!-- Resize handle for job end cells -->
<div
v-if="getCellStyling(foreman.name, day.date).data.showResizeBar"
class="resize-handle"
@mousedown.stop="(event) => startResize(event, getCellStyling(foreman.name, day.date).data.jobs[0], day.date)"
></div>
<!-- Arrows for jobs spanning weeks -->
<v-icon
v-if="jobStartsBeforeWeek(getCellStyling(foreman.name, day.date).data.jobs[0])"
size="small"
class="spans-arrow-left"
>mdi-arrow-left</v-icon>
<v-icon
v-if="jobSpansToNextWeek(getCellStyling(foreman.name, day.date).data.jobs[0])"
size="small"
class="spans-arrow-right"
>mdi-arrow-right</v-icon>
</template>
<!-- Holiday connector line for split jobs -->
<template v-if="isHoliday(day.date)">
<div
v-for="job in getJobsWithConnector(foreman.name, day.date)"
:key="`connector-${job.name}`"
class="holiday-connector"
:class="getPriorityClass(job.priority)"
></div>
</template>
<!-- Skipped day content -->
<template v-if="getCellStyling(foreman.name, day.date).data.isSkipped">
<span>Skipped</span>
<button
class="remove-skip-btn"
@click.stop="handleRemoveSkip(getCellStyling(foreman.name, day.date).data.skipJob, day.date)"
title="Remove skipped day"
>
<v-icon size="small">mdi-close</v-icon>
</button>
</template>
</div>
</div>
</div>
@ -716,24 +716,120 @@ const formatDuration = (minutes) => {
return `${hours}h ${mins}m`;
};
// Get jobs for a specific foreman and date
const getJobsForCell = (foremanId, date) => {
// Don't render jobs on Sunday or holidays
if (isSunday(date) || isHoliday(date)) return [];
// Get cell styling classes and data for a specific foreman and date
const getCellStyling = (foreman, date) => {
const classes = [];
const data = { jobs: [], isSkipped: false, skipJob: null };
return scheduledServices.value.filter((job) => {
if (job.foreman !== foremanId) return false;
const jobStart = job.expectedStartDate;
const jobEnd = job.expectedEndDate || job.expectedStartDate;
// Check if this date falls within the job's date range
// AND that it's a valid segment start date
// AND not in skipDays
const segments = getJobSegments(job);
const isSkipped = job.skipDays && job.skipDays.some(skip => skip.date === date);
return segments.some(seg => seg.start === date) && !isSkipped;
// Check if holiday
if (isHoliday(date)) {
classes.push('holiday');
}
// Check if Sunday
if (isSunday(date)) {
classes.push('sunday');
}
// Check if today
if (isToday(date)) {
classes.push('today');
}
// Get jobs for this cell
const jobs = scheduledServices.value.filter(job => {
if (job.foreman !== foreman) return false;
const start = job.expectedStartDate;
const end = job.expectedEndDate || start;
return date >= start && date <= end;
});
data.jobs = jobs;
// Check for skipped days
const skippedJobs = jobs.filter(job => {
return job.skipDays && job.skipDays.some(skip => skip.date === date);
});
if (skippedJobs.length > 0) {
data.isSkipped = true;
data.skipJob = skippedJobs[0]; // Take first one
classes.push('skipped');
// Add background color for skipped cell
data.backgroundColor = (skippedJobs[0].color || '#2196f3') + '40';
return { classes, data };
}
// If no jobs, return basic styling
if (jobs.length === 0) {
classes.push('empty');
return { classes, data };
}
// Cell is part of a scheduled job - determine styling
const jobForStyling = jobs[0];
const startDate = jobForStyling.expectedStartDate;
const endDate = jobForStyling.expectedEndDate || startDate;
const isStart = date === startDate;
const isEnd = date === endDate;
// Add priority class
classes.push(getPriorityClass(jobForStyling.priority));
// Add job color for all scheduled days
data.backgroundColor = jobForStyling.color || '#2196f3';
// Border styling for start/end
if (isStart) {
classes.push('job-start');
data.showStartIcon = true;
data.showAddress = true;
} else {
classes.push('job-continuation');
// Check if previous day was skipped
const prevDate = addDays(date, -1);
const prevSkipped = jobForStyling.skipDays && jobForStyling.skipDays.some(skip => skip.date === prevDate);
if (prevSkipped) {
classes.push('after-skip');
}
// Check if previous dates are on previous week
const weekStart = parseLocalDate(weekStartDate.value);
const cellDate = parseLocalDate(date);
if (cellDate.getDay() === 1 && cellDate.getTime() > weekStart.getTime()) { // Monday and not first Monday
classes.push('after-week-break');
}
}
// Right side styling
if (isEnd) {
classes.push('job-end');
data.showEndIcon = true;
// Only add right border for end cell
data.rightBorder = true;
} else {
data.rightBorder = false;
}
// Add job-end class on every update
classes.push('job-end');
// Check if next day is skipped
const nextDate = addDays(date, 1);
const nextSkipped = jobForStyling.skipDays && jobForStyling.skipDays.some(skip => skip.date === nextDate);
if (nextSkipped) {
classes.push('before-skip');
}
// Check if job continues to next week
const weekEnd = parseLocalDate(addDays(weekStartDate.value, 6));
const endDateObj = parseLocalDate(endDate);
if (endDateObj > weekEnd) {
classes.push('continues-next-week');
}
// Only show resize bar on end cell
data.showResizeBar = isEnd;
return { classes, data };
};
// Get skipped jobs for a specific foreman and date
@ -788,15 +884,17 @@ const getJobStyle = (job, currentDate) => {
? 'calc(100% - 8px)' // Single day: full width minus padding
: `calc(${visualDays * 100}% + ${(visualDays - 1)}px)`; // Multi-day: span cells accounting for borders
// Get color from project template
let backgroundColor = '#2196f3'; // Default color
if (job.projectTemplate) {
const template = projectTemplates.value.find(t => t.name === job.projectTemplate);
if (template && template.calendarColor) {
backgroundColor = template.calendarColor;
}
// Use job.color for cell background if available
let backgroundColor = job.color || '#2196f3';
// Use higher opacity for skip days
if (job.skipDays && job.skipDays.some(skip => skip.date === currentDate)) {
backgroundColor = backgroundColor.replace(/\)$/,", 0.25)") || backgroundColor + '40';
}
const darkerColor = darkenColor(backgroundColor, 20);
return {
width: widthCalc,
zIndex: 10,
background: backgroundColor
};
return {
width: widthCalc,
@ -909,14 +1007,40 @@ const onDateSelected = (date) => {
}
};
const showEventDetails = (event) => {
// Don't open modal if we just finished resizing
if (justFinishedResize.value) {
justFinishedResize.value = false;
return;
const handleCellDragStart = (event, foremanId, date) => {
const cellData = getCellStyling(foremanId, date);
if (cellData.data.jobs.length > 0 && !cellData.data.isSkipped) {
const job = cellData.data.jobs[0];
if (job.status === 'Scheduled') {
handleDragStart(job, event);
} else {
// Prevent dragging if not scheduled
event.preventDefault();
}
} else {
// Prevent dragging skipped cells or empty cells
event.preventDefault();
}
};
const handleCellMouseDown = (event, foremanId, date) => {
const cellData = getCellStyling(foremanId, date);
if (cellData.data.jobs.length > 0 && !cellData.data.isSkipped) {
const job = cellData.data.jobs[0];
if (job.status === 'Scheduled' || job.status === 'Started') {
// Check if click is on resize handle area (right edge)
const target = event.target;
const rect = target.closest('.day-cell').getBoundingClientRect();
const clickX = event.clientX;
const isNearRightEdge = clickX > rect.right - 20; // 20px from right edge
if (isNearRightEdge && cellData.classes.includes('job-end')) {
event.preventDefault(); // Only prevent default for resize
startResize(event, job, date);
}
// If not near right edge, allow normal drag behavior
}
}
selectedEvent.value = event.event;
eventDialog.value = true;
};
const scheduleService = (service) => {
@ -933,20 +1057,6 @@ const handleDragStart = (service, event) => {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", service.name);
// Get the dimensions of the dragged element
const dragElement = event.target;
const rect = dragElement.getBoundingClientRect();
// Set the drag image offset to center the element horizontally and position cursor at top
const offsetX = rect.width / 2;
const offsetY = 10;
try {
event.dataTransfer.setDragImage(dragElement, offsetX, offsetY);
} catch (e) {
console.log("Could not set custom drag image");
}
// Add visual feedback
event.target.style.opacity = '0.5';
console.log("Drag started for service:", service.projectTemplate);
@ -1315,46 +1425,12 @@ const handleUnscheduledDrop = async (event) => {
// Allow moving scheduled jobs between days/crews if status is 'Scheduled'
const handleScheduledDragOver = (job, event, foremanId, date) => {
if (!draggedService.value) return;
// Only allow if dragging a scheduled job and target is a valid cell
if (draggedService.value.status === 'Scheduled' && job.status === 'Scheduled') {
event.dataTransfer.dropEffect = 'move';
}
// This is now handled by the main handleDragOver function
};
const handleScheduledDrop = async (job, event, foremanId, date) => {
if (!draggedService.value) return;
// Only allow if dragging a scheduled job and target is a valid cell
if (draggedService.value.status !== 'Scheduled') return;
// Prevent dropping on same cell
if (draggedService.value.name === job.name && draggedService.value.foreman === foremanId && draggedService.value.expectedStartDate === date) return;
// Prevent dropping on Sunday or holidays
if (isSunday(date) || isHoliday(date)) return;
// Update job's foreman and date
const serviceIndex = scheduledServices.value.findIndex(s => s.name === draggedService.value.name);
if (serviceIndex !== -1) {
try {
await Api.updateServiceAppointmentScheduledDates(
draggedService.value.name,
date,
draggedService.value.expectedEndDate,
foremanId
);
scheduledServices.value[serviceIndex] = {
...scheduledServices.value[serviceIndex],
expectedStartDate: date,
foreman: foremanId
};
notifications.addSuccess('Job moved successfully!');
} catch (error) {
notifications.addError('Failed to move job');
}
}
isDragOver.value = false;
dragOverCell.value = null;
draggedService.value = null;
// Allow moving scheduled jobs between days/crews if status is 'Scheduled'
const handleScheduledDrop = async (event, foremanId, date) => {
// This is now handled by the main handleDrop function
};
// Resize functionality
@ -1366,28 +1442,28 @@ const startResize = (event, job, date) => {
const target = event.target;
const rect = target.getBoundingClientRect();
const clickX = event.clientX;
// Check if click is on the resize handle or near right edge
const isNearRightEdge = clickX > rect.right - 10;
const isResizeHandle = target.classList.contains('resize-handle');
// Allow repeated extension: remove restriction on job.expectedEndDate
// (no early return)
if (!isNearRightEdge && !isResizeHandle) return;
event.stopPropagation();
event.preventDefault();
// Set flag immediately to prevent modal from opening
justFinishedResize.value = true;
resizingJob.value = job;
resizeStartX.value = event.clientX;
resizeStartDate.value = date;
originalEndDate.value = job.expectedEndDate || job.expectedStartDate;
// Add global mouse move and mouse up listeners
document.addEventListener('mousemove', handleResize);
document.addEventListener('mouseup', stopResize);
// Add visual feedback
document.body.style.cursor = 'ew-resize';
};
@ -1727,7 +1803,7 @@ onMounted(async () => {
}
.weekly-calendar {
min-width: 1000px;
min-width: 1130px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
@ -1812,15 +1888,20 @@ onMounted(async () => {
}
.day-cell {
border-right: 1px solid var(--surface-border);
border: 1px solid #e0e0e0;
position: relative;
cursor: pointer;
transition: background-color 0.2s;
min-height: 80px;
max-height: 80px;
padding: 4px;
overflow: visible; /* Allow multi-day jobs to overflow */
width: 100%; /* Ensure cell doesn't expand */
overflow: hidden; /* Prevent content from expanding cell */
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
flex-shrink: 0; /* Prevent shrinking */
box-sizing: border-box; /* Include padding in width calculation */
}
.day-cell:hover {
@ -1837,67 +1918,148 @@ onMounted(async () => {
box-sizing: border-box;
}
.calendar-job {
position: absolute;
left: 4px;
top: 4px;
background: linear-gradient(135deg, #2196f3, #1976d2);
color: white;
border-radius: 4px;
padding: 8px 10px;
font-size: 0.85em;
cursor: pointer;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: all 0.2s;
display: flex;
align-items: center;
min-height: 68px;
height: calc(100% - 8px);
max-width: none; /* Allow spanning */
}
.calendar-job:hover {
transform: scale(1.02);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
.calendar-job:hover .resize-handle {
opacity: 1;
}
.calendar-job[draggable="true"] {
.day-cell[draggable="true"] {
cursor: grab;
}
.calendar-job[draggable="true"]:active {
.day-cell[draggable="true"]:active {
cursor: grabbing;
}
/* Job styling based on priority */
.day-cell.priority-urgent {
background: linear-gradient(135deg, #f44336, #d32f2f);
color: white;
}
.day-cell.priority-high {
background: linear-gradient(135deg, #ff9800, #f57c00);
color: white;
}
.day-cell.priority-medium {
background: linear-gradient(135deg, #2196f3, #1976d2);
color: white;
}
.day-cell.priority-low {
background: linear-gradient(135deg, #4caf50, #388e3c);
color: white;
}
/* Job border styling - only for cells that contain jobs */
.day-cell.priority-urgent:not(.empty):not(.skipped),
.day-cell.priority-high:not(.empty):not(.skipped),
.day-cell.priority-medium:not(.empty):not(.skipped),
.day-cell.priority-low:not(.empty):not(.skipped) {
border-top: 3px solid currentColor;
border-bottom: 3px solid currentColor;
}
/* Job start/end border styling */
.day-cell.job-start:not(.skipped) {
border-left: 4px solid currentColor;
}
.day-cell.job-end:not(.skipped) {
border-right: 4px solid currentColor;
}
/* Skip day styling */
.day-cell.skipped {
border: 2px dotted #ff0000;
background: rgba(255, 0, 0, 0.1);
color: #ff0000;
font-weight: 500;
justify-content: center;
align-items: center;
}
/* Special week break borders */
.day-cell.after-week-break:not(.skipped) {
border-left: 4px double currentColor;
}
.day-cell.continues-next-week:not(.skipped) {
border-right: 4px double currentColor;
}
/* Skip day adjacent styling */
.day-cell.after-skip:not(.skipped) {
border-left: 2px dashed currentColor;
}
.day-cell.before-skip:not(.skipped) {
border-right: 2px dashed currentColor;
}
/* Holiday and Sunday styling */
.day-cell.holiday {
background: repeating-linear-gradient(
45deg,
rgba(255, 193, 7, 0.15),
rgba(255, 193, 7, 0.15) 10px,
rgba(255, 193, 7, 0.05) 10px,
rgba(255, 193, 7, 0.05) 20px
);
border-left: 3px solid #ffc107;
border-right: 3px solid #ffc107;
}
.day-cell.sunday {
background-color: rgba(200, 200, 200, 0.1); /* light gray for Sunday */
}
/* Skipped styling */
.day-cell.skipped {
background: rgba(255, 0, 0, 0.1);
border-top: 2px dotted #ff0000;
border-bottom: 2px dotted #ff0000;
color: #ff0000;
font-weight: 500;
justify-content: center;
align-items: center;
}
/* Empty cell styling */
.day-cell.empty {
background-color: transparent;
}
.job-content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
width: 100%;
padding: 2px;
box-sizing: border-box;
overflow: hidden;
}
.job-title {
font-weight: 600;
font-size: 0.85em;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
font-size: 0.95em;
max-width: 100%;
box-sizing: border-box;
}
.job-address {
font-size: 0.8em;
font-size: 0.75em;
opacity: 0.9;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
max-width: 100%;
line-height: 1.2;
box-sizing: border-box;
}
.resize-handle {
@ -1921,17 +2083,15 @@ onMounted(async () => {
.spans-arrow-left {
position: absolute;
left: 5px;
top: 50%;
transform: translateY(-50%);
left: 2px;
top: 2px;
color: rgba(255, 255, 255, 0.8);
}
.spans-arrow-right {
position: absolute;
right: 25px; /* Position before resize handle */
top: 50%;
transform: translateY(-50%);
right: 2px;
top: 2px;
color: rgba(255, 255, 255, 0.8);
}

View file

@ -174,6 +174,10 @@ const props = defineProps({
type: Array,
default: () => [],
},
contactOptions: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(["update:formData"]);
@ -205,7 +209,7 @@ const localFormData = computed({
const contactOptions = computed(() => {
if (!localFormData.value.contacts || localFormData.value.contacts.length === 0) {
return [];
return props.contactOptions;
}
return localFormData.value.contacts.map((contact, index) => ({
label: `${contact.firstName || ""} ${contact.lastName || ""}`.trim() || `Contact ${index + 1}`,
@ -231,28 +235,6 @@ onMounted(() => {
];
}
});
const addAddress = () => {
localFormData.value.addresses.push({
addressLine1: "",
addressLine2: "",
isBillingAddress: false,
isServiceAddress: true,
pincode: "",
city: "",
state: "",
contacts: [],
primaryContact: null,
zipcodeLookupDisabled: true,
});
};
const removeAddress = (index) => {
if (localFormData.value.addresses.length > 1) {
localFormData.value.addresses.splice(index, 1);
}
};
const formatAddressLine = (index, field, event) => {
const value = event.target.value;
if (!value) return;

View file

@ -149,24 +149,24 @@ const props = defineProps({
const emit = defineEmits(["update:formData"]);
const localFormData = computed({
get: () => {
if (!props.formData.contacts || props.formData.contacts.length === 0) {
props.formData.contacts = [
{
firstName: "",
lastName: "",
phoneNumber: "",
email: "",
contactRole: "",
isPrimary: true,
},
];
}
return props.formData;
},
get: () => props.formData,
set: (value) => emit("update:formData", value),
});
// Ensure at least one contact always exists
if (!localFormData.value.contacts || localFormData.value.contacts.length === 0) {
localFormData.value.contacts = [
{
firstName: "",
lastName: "",
phoneNumber: "",
email: "",
contactRole: "",
isPrimary: true,
},
];
}
const roleOptions = ref([
{ label: "Owner", value: "Owner" },
{ label: "Property Manager", value: "Property Manager" },

View file

@ -0,0 +1,99 @@
<template>
<Dialog :visible="visible" @update:visible="val => emit('update:visible', val)" modal :closable="false" :style="{ width: '700px', maxWidth: '95vw' }">
<template #header>
<span class="modal-title">Add Contact/Address</span>
</template>
<div class="modal-body">
<ContactInformationForm
:formData="contactFormData.value"
@update:formData="val => contactFormData.value = val"
:isSubmitting="isSubmitting"
:existingContacts="existingContacts"
/>
<AddressInformationForm
:formData="addressFormData.value"
@update:formData="val => addressFormData.value = val"
:isSubmitting="isSubmitting"
:contactOptions="allContactOptions"
:existingAddresses="existingAddresses"
/>
</div>
<template #footer>
<Button label="Cancel" @click="close" severity="secondary" />
<Button label="Create" @click="create" severity="primary" :loading="isSubmitting" />
</template>
</Dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import Dialog from 'primevue/dialog';
import Button from 'primevue/button';
import ContactInformationForm from '../clientSubPages/ContactInformationForm.vue';
import AddressInformationForm from '../clientSubPages/AddressInformationForm.vue';
const props = defineProps({
visible: Boolean,
clientContacts: { type: Array, default: () => [] },
existingContacts: { type: Array, default: () => [] },
existingAddresses: { type: Array, default: () => [] },
isSubmitting: { type: Boolean, default: false },
});
const emit = defineEmits(['update:visible', 'created']);
const contactFormData = ref({ contacts: [] });
const addressFormData = ref({ addresses: [], contacts: [] });
// Keep addressFormData.contacts in sync with new contacts
watch(
() => contactFormData.value.contacts,
(newContacts) => {
addressFormData.value.contacts = newContacts || [];
},
{ deep: true }
);
// All contact options = clientContacts + new contacts
const allContactOptions = computed(() => {
const clientOpts = (props.clientContacts || []).map((c, idx) => ({
label: `${c.firstName || ''} ${c.lastName || ''}`.trim() || `Contact ${idx + 1}`,
value: `client-${idx}`,
...c,
}));
const newOpts = (contactFormData.value.contacts || []).map((c, idx) => ({
label: `${c.firstName || ''} ${c.lastName || ''}`.trim() || `Contact ${idx + 1}`,
value: `new-${idx}`,
...c,
}));
return [...clientOpts, ...newOpts];
});
function close() {
emit('update:visible', false);
}
function create() {
// Dummy create handler
console.log('Create clicked', {
contacts: contactFormData.value.contacts,
addresses: addressFormData.value.addresses,
});
emit('created', {
contacts: contactFormData.value.contacts,
addresses: addressFormData.value.addresses,
});
close();
}
</script>
<style scoped>
.modal-title {
font-size: 1.2rem;
font-weight: 600;
}
.modal-body {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 0.5rem 0;
}
</style>

View file

@ -1,116 +1,123 @@
<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 class="action-buttons">
<v-btn
size="small"
variant="outlined"
color="primary"
@click="addAddress"
>
<v-icon left size="small">mdi-map-marker-plus</v-icon>
Add Address
</v-btn>
<v-btn
size="small"
variant="outlined"
color="primary"
@click="addContact"
>
<v-icon left size="small">mdi-account-plus</v-icon>
Add Contact
</v-btn>
</div>
</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 class="general-client-info">
<div class="info-grid">
<!-- Add Contact/Address Button (always visible) -->
<div class="lead-badge-container">
<template v-if="isLead">
<Badge value="LEAD" severity="warn" size="large" />
</template>
<div class="action-buttons">
<Button size="small" variant="outlined" color="primary" @click="openAddModal">
<v-icon left size="small">mdi-account-plus</v-icon>
Add Contact/Address
</Button>
</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>
<!-- 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>
</div>
<!-- Creation Date -->
<div class="info-section">
<label>Created</label>
<span class="info-value">{{ formattedCreationDate }}</span>
<!-- 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>
<AddContactAddressModal
:visible="showAddModal"
@update:visible="showAddModal = $event"
:clientContacts="clientData.contacts || []"
:existingContacts="clientData.contacts?.map(c => c.fullName || c.name) || []"
:existingAddresses="clientData.addresses?.map(a => a.addressLine1) || []"
/>
</div>
</template>
<script setup>
import { computed } from "vue";
import Badge from "primevue/badge";
import Tag from "primevue/tag";
import AddContactAddressModal from './AddContactAddressModal.vue';
import Button from 'primevue/button';
import { ref } from 'vue';
const showAddModal = ref(false);
const openAddModal = () => {
showAddModal.value = true;
};
const props = defineProps({
clientData: {
@ -166,16 +173,8 @@ const formattedCreationDate = computed(() => {
});
});
// Placeholder methods for adding address and contact
const addAddress = () => {
console.log("Add Address modal would open here");
// TODO: Open add address modal
};
const addContact = () => {
console.log("Add Contact modal would open here");
// TODO: Open add contact modal
};
</script>
<style scoped>