update calendar

This commit is contained in:
Casey 2026-01-21 08:44:20 -06:00
parent 7395d3e048
commit e67805c01f
10 changed files with 848 additions and 325 deletions

View file

@ -404,7 +404,7 @@ def upsert_estimate(data):
# estimate.custom_job_address = data.get("address_name")
# estimate.party_name = data.get("customer")
# estimate.contact_person = data.get("contact_name")
estimate.custom_requires_half_payment = data.get("requires_half_payment", 0)
estimate.requires_half_payment = data.get("requires_half_payment", 0)
estimate.custom_project_template = project_template
estimate.custom_quotation_template = data.get("quotation_template", None)
# estimate.company = data.get("company")
@ -427,6 +427,7 @@ def upsert_estimate(data):
})
estimate.save()
frappe.db.commit()
estimate_dict = estimate.as_dict()
estimate_dict["history"] = get_doc_history("Quotation", estimate_name)
print(f"DEBUG: Estimate updated: {estimate.name}")
@ -444,7 +445,7 @@ def upsert_estimate(data):
# print("DEBUG: No billing address found for client:", client_doc.name)
new_estimate = frappe.get_doc({
"doctype": "Quotation",
"custom_requires_half_payment": data.get("requires_half_payment", 0),
"requires_half_payment": data.get("requires_half_payment", 0),
"custom_job_address": data.get("address_name"),
"custom_current_status": "Draft",
"contact_email": data.get("contact_email"),

View file

@ -1,6 +1,7 @@
import frappe, json
from custom_ui.db_utils import process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, build_error_response
from custom_ui.services import AddressService, ClientService
from frappe.utils import getdate
# ===============================================================================
# JOB MANAGEMENT API METHODS
@ -177,21 +178,26 @@ def upsert_job(data):
return {"status": "error", "message": str(e)}
@frappe.whitelist()
def get_projects_for_calendar(date, company=None, project_templates=[]):
def get_projects_for_calendar(start_date, end_date, company=None, project_templates=[]):
"""Get install projects for the calendar."""
# Parse project_templates if it's a JSON string
if isinstance(project_templates, str):
project_templates = json.loads(project_templates)
# put some emojis in the print to make it stand out
print("📅📅📅", date, "company:", company, "project_templates:", project_templates, "type:", type(project_templates))
print("📅📅📅", start_date, end_date, " company:", company, "project_templates:", project_templates, "type:", type(project_templates))
try:
filters = {"company": company} if company else {}
filters = {
"company": company
} if company else {}
if project_templates and len(project_templates) > 0:
filters["project_template"] = ["in", project_templates]
unscheduled_filters = filters.copy()
unscheduled_filters["is_scheduled"] = 0
filters["expected_start_date"] = date
# add to filter for if expected_start_date is between start_date and end_date OR expected_end_date is between start_date and end_date
filters["expected_start_date"] = ["<=", getdate(end_date)]
filters["expected_end_date"] = [">=", getdate(start_date)]
# If date range provided, we could filter, but for now let's fetch all open/active ones
# or maybe filter by status not Closed/Completed if we want active ones.
# The user said "unscheduled" are those with status "Open" (and no date).
@ -206,3 +212,22 @@ def get_projects_for_calendar(date, company=None, project_templates=[]):
return build_success_response({ "projects": projects, "unscheduled_projects": unscheduled_projects })
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def update_job_scheduled_dates(job_name: str, new_start_date: str = None, new_end_date: str = None, foreman_name: str = None):
"""Update job (project) schedule dates."""
print("DEBUG: Updating job schedule:", job_name, new_start_date, new_end_date, foreman_name)
try:
project = frappe.get_doc("Project", job_name)
project.expected_start_date = getdate(new_start_date) if new_start_date else None
project.expected_end_date = getdate(new_end_date) if new_end_date else None
if new_start_date and new_end_date:
project.is_scheduled = 1
else:
project.is_scheduled = 0
if foreman_name:
project.custom_foreman = foreman_name
project.save()
return build_success_response(project.as_dict())
except Exception as e:
return build_error_response(str(e), 500)

View file

@ -1,6 +1,6 @@
import frappe
from custom_ui.services import AddressService, ClientService
from datetime import timedelta
def after_insert(doc, method):
print("DEBUG: After Insert Triggered for Project")
@ -30,8 +30,13 @@ def before_insert(doc, method):
def before_save(doc, method):
print("DEBUG: Before Save Triggered for Project:", doc.name)
if doc.expected_start_date and doc.expected_end_date:
print("DEBUG: Project has expected start and end dates, marking as scheduled")
doc.is_scheduled = 1
else:
while frappe.db.exists("Holiday", {"holiday_date": doc.expected_end_date}):
print("DEBUG: Expected end date falls on a holiday, extending end date by 1 day")
doc.expected_end_date += timedelta(days=1)
elif not doc.expected_start_date or not doc.expected_end_date:
print("DEBUG: Project missing expected start or end date, marking as unscheduled")
doc.is_scheduled = 0
def after_save(doc, method):
@ -44,8 +49,10 @@ def after_save(doc, method):
"Closed": "Completed"
}
new_status = status_mapping.get(doc.status, "In Progress")
AddressService.update_value(
doc.job_address,
"job_status",
new_status
)
if frappe.db.get_value("Address", doc.job_address, "job_status") != new_status:
print("DEBUG: Updating Address job_status to:", new_status)
AddressService.update_value(
doc.job_address,
"job_status",
new_status
)

View file

@ -178,8 +178,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": null,
"modified": "2026-01-19 18:10:16.782664",
"migration_hash": "9792f2dfc070efd283d24244c612c2d4",
"modified": "2026-01-20 12:56:39.681180",
"module": "Custom UI",
"name": "Customer Task Link",
"naming_rule": "",
@ -396,8 +396,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": null,
"modified": "2026-01-19 18:10:02.359022",
"migration_hash": "9792f2dfc070efd283d24244c612c2d4",
"modified": "2026-01-20 12:56:39.752798",
"module": "Custom UI",
"name": "Address Task Link",
"naming_rule": "",
@ -550,8 +550,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "0df0ede31f640435231ba887f40eca91",
"modified": "2026-01-19 20:52:17.097017",
"migration_hash": "9792f2dfc070efd283d24244c612c2d4",
"modified": "2026-01-20 12:56:39.827207",
"module": "Custom",
"name": "Lead Companies Link",
"naming_rule": "",
@ -768,8 +768,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "0df0ede31f640435231ba887f40eca91",
"modified": "2026-01-19 20:52:17.150584",
"migration_hash": "9792f2dfc070efd283d24244c612c2d4",
"modified": "2026-01-20 12:56:39.914772",
"module": "Custom",
"name": "Address Project Link",
"naming_rule": "",
@ -986,8 +986,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "0df0ede31f640435231ba887f40eca91",
"modified": "2026-01-19 20:52:17.203403",
"migration_hash": "9792f2dfc070efd283d24244c612c2d4",
"modified": "2026-01-20 12:56:40.049882",
"module": "Custom",
"name": "Address Quotation Link",
"naming_rule": "",
@ -1204,8 +1204,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "0df0ede31f640435231ba887f40eca91",
"modified": "2026-01-19 20:52:17.255846",
"migration_hash": "9792f2dfc070efd283d24244c612c2d4",
"modified": "2026-01-20 12:56:40.145083",
"module": "Custom",
"name": "Address On-Site Meeting Link",
"naming_rule": "",
@ -1422,8 +1422,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "0df0ede31f640435231ba887f40eca91",
"modified": "2026-01-19 20:52:17.309600",
"migration_hash": "9792f2dfc070efd283d24244c612c2d4",
"modified": "2026-01-20 12:56:40.270888",
"module": "Custom",
"name": "Address Sales Order Link",
"naming_rule": "",
@ -1576,8 +1576,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "0df0ede31f640435231ba887f40eca91",
"modified": "2026-01-19 20:52:17.361237",
"migration_hash": "9792f2dfc070efd283d24244c612c2d4",
"modified": "2026-01-20 12:56:40.346187",
"module": "Custom",
"name": "Contact Address Link",
"naming_rule": "",
@ -1730,8 +1730,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "0df0ede31f640435231ba887f40eca91",
"modified": "2026-01-19 20:52:17.412683",
"migration_hash": "9792f2dfc070efd283d24244c612c2d4",
"modified": "2026-01-20 12:56:40.418889",
"module": "Custom",
"name": "Lead On-Site Meeting Link",
"naming_rule": "",
@ -2332,8 +2332,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "0df0ede31f640435231ba887f40eca91",
"modified": "2026-01-19 20:52:17.483924",
"migration_hash": "9792f2dfc070efd283d24244c612c2d4",
"modified": "2026-01-20 12:56:40.507246",
"module": "Selling",
"name": "Quotation Template",
"naming_rule": "",
@ -2830,8 +2830,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "0df0ede31f640435231ba887f40eca91",
"modified": "2026-01-19 20:52:17.558008",
"migration_hash": "9792f2dfc070efd283d24244c612c2d4",
"modified": "2026-01-20 12:56:40.595751",
"module": "Selling",
"name": "Quotation Template Item",
"naming_rule": "",
@ -2984,8 +2984,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "0df0ede31f640435231ba887f40eca91",
"modified": "2026-01-19 20:52:17.609372",
"migration_hash": "9792f2dfc070efd283d24244c612c2d4",
"modified": "2026-01-20 12:56:40.676141",
"module": "Custom UI",
"name": "Customer Company Link",
"naming_rule": "",
@ -3138,8 +3138,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "0df0ede31f640435231ba887f40eca91",
"modified": "2026-01-19 20:52:17.660893",
"migration_hash": "9792f2dfc070efd283d24244c612c2d4",
"modified": "2026-01-20 12:56:40.749303",
"module": "Custom UI",
"name": "Customer Address Link",
"naming_rule": "",
@ -3292,8 +3292,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "0df0ede31f640435231ba887f40eca91",
"modified": "2026-01-19 20:52:17.712878",
"migration_hash": "9792f2dfc070efd283d24244c612c2d4",
"modified": "2026-01-20 12:56:40.836296",
"module": "Custom UI",
"name": "Customer Contact Link",
"naming_rule": "",
@ -3446,8 +3446,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "0df0ede31f640435231ba887f40eca91",
"modified": "2026-01-19 20:52:17.765849",
"migration_hash": "9792f2dfc070efd283d24244c612c2d4",
"modified": "2026-01-20 12:56:40.900156",
"module": "Custom",
"name": "Address Contact Link",
"naming_rule": "",
@ -3600,8 +3600,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "0df0ede31f640435231ba887f40eca91",
"modified": "2026-01-19 20:52:17.818352",
"migration_hash": "9792f2dfc070efd283d24244c612c2d4",
"modified": "2026-01-20 12:56:40.986399",
"module": "Custom",
"name": "Customer On-Site Meeting Link",
"naming_rule": "",
@ -3754,8 +3754,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "0df0ede31f640435231ba887f40eca91",
"modified": "2026-01-19 20:52:17.870984",
"migration_hash": "9792f2dfc070efd283d24244c612c2d4",
"modified": "2026-01-20 12:56:41.054749",
"module": "Custom",
"name": "Customer Project Link",
"naming_rule": "",
@ -3908,8 +3908,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "0df0ede31f640435231ba887f40eca91",
"modified": "2026-01-19 20:52:17.922695",
"migration_hash": "9792f2dfc070efd283d24244c612c2d4",
"modified": "2026-01-20 12:56:41.114144",
"module": "Custom",
"name": "Customer Quotation Link",
"naming_rule": "",
@ -4062,8 +4062,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "0df0ede31f640435231ba887f40eca91",
"modified": "2026-01-19 20:52:17.975165",
"migration_hash": "9792f2dfc070efd283d24244c612c2d4",
"modified": "2026-01-20 12:56:41.170115",
"module": "Custom",
"name": "Customer Sales Order Link",
"naming_rule": "",
@ -4216,8 +4216,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "0df0ede31f640435231ba887f40eca91",
"modified": "2026-01-19 20:52:18.027046",
"migration_hash": "9792f2dfc070efd283d24244c612c2d4",
"modified": "2026-01-20 12:56:41.226997",
"module": "Custom",
"name": "Lead Address Link",
"naming_rule": "",
@ -4370,8 +4370,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "0df0ede31f640435231ba887f40eca91",
"modified": "2026-01-19 20:52:18.078476",
"migration_hash": "9792f2dfc070efd283d24244c612c2d4",
"modified": "2026-01-20 12:56:41.283969",
"module": "Custom",
"name": "Lead Contact Link",
"naming_rule": "",
@ -4524,8 +4524,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "0df0ede31f640435231ba887f40eca91",
"modified": "2026-01-19 20:52:18.170095",
"migration_hash": "9792f2dfc070efd283d24244c612c2d4",
"modified": "2026-01-20 12:56:41.344731",
"module": "Custom",
"name": "Lead Quotation Link",
"naming_rule": "",
@ -4678,8 +4678,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "0df0ede31f640435231ba887f40eca91",
"modified": "2026-01-19 20:52:18.238066",
"migration_hash": "9792f2dfc070efd283d24244c612c2d4",
"modified": "2026-01-20 12:56:41.401007",
"module": "Custom",
"name": "Address Company Link",
"naming_rule": "",

View file

@ -263,7 +263,8 @@ fixtures = [
"dt": "Property Setter",
"filters": [
["doc_type", "=", "Lead"],
["doc_type", "=", "Project"]
["doc_type", "=", "Project"],
["doc_type", "=", "Address"]
]
}

View file

@ -598,6 +598,12 @@ def add_custom_fields():
options="Company",
insert_after="project_type",
description="The company associated with this project template."
),
dict(
fieldname="calendar_color",
label="Calendar Color",
fieldtype="Color",
insert_after="company"
)
],
"Task": [

View file

@ -23,6 +23,7 @@ const FRAPPE_GET_JOB_TASK_LIST_METHOD = "custom_ui.api.db.jobs.get_job_task_list
const FRAPPE_GET_INSTALL_PROJECTS_METHOD = "custom_ui.api.db.jobs.get_install_projects";
const FRAPPE_GET_JOBS_FOR_CALENDAR_METHOD = "custom_ui.api.db.jobs.get_projects_for_calendar";
const FRAPPE_GET_JOB_TEMPLATES_METHOD = "custom_ui.api.db.jobs.get_job_templates";
const FRAPPE_UPDATE_JOB_SCHEDULED_DATES_METHOD = "custom_ui.api.db.jobs.update_job_scheduled_dates";
// Task methods
const FRAPPE_GET_TASKS_METHOD = "custom_ui.api.db.tasks.get_tasks_table_data";
const FRAPPE_GET_TASKS_STATUS_OPTIONS = "custom_ui.api.db.tasks.get_task_status_options";
@ -319,8 +320,17 @@ class Api {
return result;
}
static async getJobsForCalendar(date, company = null, projectTemplates = []) {
return await this.request(FRAPPE_GET_JOBS_FOR_CALENDAR_METHOD, { date, company, projectTemplates });
static async getJobsForCalendar(startDate, endDate, company = null, projectTemplates = []) {
return await this.request(FRAPPE_GET_JOBS_FOR_CALENDAR_METHOD, { startDate, endDate, company, projectTemplates });
}
static async updateJobScheduledDates(jobName, newStartDate, newEndDate, foremanName) {
return await this.request(FRAPPE_UPDATE_JOB_SCHEDULED_DATES_METHOD, {
jobName,
newStartDate,
newEndDate,
foremanName,
});
}
static async getJob(jobName) {

View file

@ -23,9 +23,9 @@
variant="outlined"
size="small"
></v-btn>
<v-btn @click="goToThisWeek" variant="outlined" size="small" class="ml-4"
>This Week</v-btn
>
<v-btn @click="goToThisWeek" variant="outlined" size="small" class="ml-4">
This Week
</v-btn>
<v-menu
v-model="showTemplateMenu"
:close-on-content-click="false"
@ -205,24 +205,31 @@
v-for="job in getJobsForCell(foreman.name, day.date)"
:key="job.id"
class="calendar-job"
:class="getPriorityClass(job.priority)"
:style="getJobStyle(job, day.date)"
draggable="true"
@click.stop="showEventDetails({ event: job })"
@dragstart="handleDragStart(job, $event)"
@dragend="handleDragEnd"
@mousedown="startResize($event, job, day.date)"
>
<div class="job-content">
<div class="job-title">{{ job.serviceType }}</div>
<div class="job-customer">{{ job.customer }}</div>
</div>
<v-icon v-if="jobSpansToNextWeek(job)" size="small" class="spans-arrow">mdi-arrow-right</v-icon>
<div
class="resize-handle"
@mousedown.stop="startResize($event, job, day.date)"
></div>
:style="getJobStyle(job, day.date)"
draggable="true"
@click.stop="showEventDetails({ event: job })"
@dragstart="handleDragStart(job, $event)"
@dragend="handleDragEnd"
@mousedown="startResize($event, job, 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 || job.serviceType }}</div>
<div class="job-address">{{ stripAddress(job.address || job.jobAddress) }}</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>
<!-- 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.id}`"
class="holiday-connector"
:class="getPriorityClass(job.priority)"
></div>
</template>
</div>
</div>
</div>
@ -261,41 +268,32 @@
@dragend="handleDragEnd"
>
<v-card-text class="pa-3">
<div class="d-flex justify-space-between align-center mb-2">
<v-chip :color="getPriorityColor(service.priority)" size="x-small">
{{ service.priority.toUpperCase() }}
</v-chip>
<span class="text-caption text-medium-emphasis"
>${{ service.estimatedCost.toLocaleString() }}</span
>
</div>
<div class="service-title-compact mb-2">{{ service.projectTemplate || service.serviceType }}</div>
<div class="service-address mb-1">
<v-icon size="x-small" class="mr-1">mdi-map-marker</v-icon>
{{ stripAddress(service.address || service.jobAddress) }}
</div>
<div class="service-customer mb-2">
<v-icon size="x-small" class="mr-1">mdi-account</v-icon>
{{ service.customer }}
</div>
<div class="service-title-compact">{{ service.serviceType }}</div>
<div class="service-customer">{{ service.customer }}</div>
<div v-if="service.notes" class="service-notes-compact mt-2">
<span class="text-caption">{{ service.notes }}</span>
</div>
<div class="service-compact-details mt-1">
<div class="text-caption">
<v-icon size="x-small" class="mr-1">mdi-clock</v-icon>
{{ formatDuration(service.duration) }}
</div>
</div>
<div v-if="service.notes" class="service-notes-compact mt-2">
<span class="text-caption">{{ service.notes }}</span>
</div>
<v-btn
color="primary"
size="x-small"
variant="outlined"
class="mt-2"
block
@click="scheduleService(service)"
>
<v-icon left size="x-small">mdi-calendar-plus</v-icon>
Schedule
</v-btn>
</v-card-text>
<v-btn
color="primary"
size="x-small"
variant="outlined"
class="mt-2"
block
@click="scheduleService(service)"
>
<v-icon left size="x-small">mdi-calendar-plus</v-icon>
Schedule
</v-btn>
</v-card-text>
</v-card>
</div>
@ -306,67 +304,17 @@
</div>
</div>
<!-- Event Details Dialog -->
<v-dialog v-model="eventDialog" max-width="600px">
<v-card v-if="selectedEvent">
<v-card-title class="d-flex justify-space-between align-center">
<span>{{ selectedEvent.title }}</span>
<v-chip :color="getPriorityColor(selectedEvent.priority)" small>
{{ selectedEvent.priority.toUpperCase() }}
</v-chip>
</v-card-title>
<!-- Event Details Modal -->
<JobDetailsModal
v-model:visible="eventDialog"
:job="selectedEvent"
:foremen="foremen"
@close="eventDialog = false"
/>
<v-card-text>
<div class="event-details">
<div class="detail-row">
<v-icon class="mr-2">mdi-account</v-icon>
<strong>Customer:</strong> {{ selectedEvent.customer }}
</div>
<div class="detail-row">
<v-icon class="mr-2">mdi-map-marker</v-icon>
<strong>Address:</strong> {{ selectedEvent.address }}
</div>
<div class="detail-row">
<v-icon class="mr-2">mdi-wrench</v-icon>
<strong>Service Type:</strong> {{ selectedEvent.serviceType }}
</div>
<div class="detail-row">
<v-icon class="mr-2">mdi-calendar</v-icon>
<strong>Date:</strong> {{ selectedEvent.scheduledDate }}
</div>
<div class="detail-row">
<v-icon class="mr-2">mdi-clock</v-icon>
<strong>Time:</strong> {{ selectedEvent.scheduledTime }} ({{
formatDuration(selectedEvent.duration)
}})
</div>
<div class="detail-row">
<v-icon class="mr-2">mdi-account-hard-hat</v-icon>
<strong>Crew:</strong> {{ selectedEvent.foreman || "Not assigned" }}
</div>
<div class="detail-row">
<v-icon class="mr-2">mdi-currency-usd</v-icon>
<strong>Estimated Cost:</strong> ${{
selectedEvent.estimatedCost.toLocaleString()
}}
</div>
<div v-if="selectedEvent.notes" class="detail-row">
<v-icon class="mr-2">mdi-note-text</v-icon>
<strong>Notes:</strong> {{ selectedEvent.notes }}
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" @click="eventDialog = false">Close</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Tooltip -->
<div v-if="tooltipVisible" class="tooltip" :style="{ left: tooltipPosition.x + 10 + 'px', top: tooltipPosition.y - 30 + 'px' }">
{{ tooltip }}
<!-- Extend to next week popup -->
<div v-if="showExtendToNextWeekPopup && resizingJob" class="extend-popup">
Extend to next week
</div>
</div>
</template>
@ -376,6 +324,7 @@ import { ref, onMounted, computed, watch } from "vue";
import Api from "../../../api";
import { useNotificationStore } from "../../../stores/notifications-primevue";
import { useCompanyStore } from "../../../stores/company";
import JobDetailsModal from "../../modals/JobDetailsModal.vue";
const notifications = useNotificationStore();
const companyStore = useCompanyStore();
@ -393,10 +342,8 @@ const draggedService = ref(null);
const isDragOver = ref(false);
const dragOverCell = ref(null);
// Tooltip state
const tooltip = ref('');
const tooltipVisible = ref(false);
const tooltipPosition = ref({ x: 0, y: 0 });
// Extend to next week popup state
const showExtendToNextWeekPopup = ref(false);
// Resize state
const resizingJob = ref(null);
@ -495,7 +442,13 @@ function daysBetween(startDate, endDate) {
// Helper function to check if date is a holiday
function isHoliday(dateStr) {
return holidays.value.some(holiday => holiday.date === dateStr);
return holidays.value.some(holiday => holiday.holidayDate === dateStr);
}
// Helper function to get holiday description for a date
function getHolidayDescription(dateStr) {
const holiday = holidays.value.find(h => h.holidayDate === dateStr);
return holiday ? holiday.description : null;
}
// Helper function to check if date is Sunday
@ -532,6 +485,90 @@ function jobSpansToNextWeek(job) {
return end > weekEnd;
}
// Helper function to check if job starts before current week
function jobStartsBeforeWeek(job) {
const startDate = job.expectedStartDate || job.scheduledDate;
const start = parseLocalDate(startDate);
const weekStart = parseLocalDate(weekStartDate.value);
return start < weekStart;
}
// Helper function to strip address after "-#-"
function stripAddress(address) {
if (!address) return '';
const index = address.indexOf('-#-');
return index > -1 ? address.substring(0, index).trim() : address;
}
// Helper function to get crew name from foreman ID
function getCrewName(foremanId) {
if (!foremanId) return 'Not assigned';
const foreman = foremen.value.find(f => f.name === foremanId);
if (!foreman) return foremanId;
return foreman.customCrew ? `${foreman.employeeName} (Crew ${foreman.customCrew})` : foreman.employeeName;
}
// Helper function to get all holidays in a date range
function getHolidaysInRange(startDate, endDate) {
const start = parseLocalDate(startDate);
const end = parseLocalDate(endDate);
const holidaysInRange = [];
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
const dateStr = toLocalDateString(d);
if (isHoliday(dateStr)) {
holidaysInRange.push(dateStr);
}
}
return holidaysInRange;
}
// Helper function to calculate job segments (parts between holidays/Sundays)
function getJobSegments(job) {
const startDate = job.scheduledDate;
const endDate = job.scheduledEndDate || job.scheduledDate;
const weekEndDate = addDays(weekStartDate.value, 6); // Saturday
const segments = [];
let segmentStart = startDate;
let currentDate = startDate;
const start = parseLocalDate(startDate);
const end = parseLocalDate(endDate);
const weekEnd = parseLocalDate(weekEndDate);
// Don't render segments beyond the current week's Saturday
const effectiveEnd = end <= weekEnd ? end : weekEnd;
for (let d = new Date(start); d <= effectiveEnd; d.setDate(d.getDate() + 1)) {
const dateStr = toLocalDateString(d);
// If we hit a holiday or Sunday, close the current segment
if (isHoliday(dateStr) || isSunday(dateStr)) {
// Close previous segment if it exists
if (segmentStart !== null) {
const prevDate = toLocalDateString(new Date(d.getTime() - 86400000)); // Previous day
if (parseLocalDate(prevDate) >= parseLocalDate(startDate)) {
segments.push({ start: segmentStart, end: prevDate });
}
segmentStart = null;
}
} else {
// Start a new segment if we don't have one
if (segmentStart === null) {
segmentStart = dateStr;
}
}
}
// Close the final segment if there's an open one
if (segmentStart !== null) {
const finalEnd = toLocalDateString(effectiveEnd);
segments.push({ start: segmentStart, end: finalEnd });
}
return segments;
}
// Computed properties
const weekDisplayText = computed(() => {
const startDate = parseLocalDate(weekStartDate.value);
@ -612,8 +649,8 @@ const formatDuration = (minutes) => {
// Get jobs for a specific foreman and date
const getJobsForCell = (foremanId, date) => {
// Don't render jobs on Sunday
if (isSunday(date)) return [];
// Don't render jobs on Sunday or holidays
if (isSunday(date) || isHoliday(date)) return [];
return scheduledServices.value.filter((job) => {
if (job.foreman !== foremanId) return false;
@ -622,7 +659,24 @@ const getJobsForCell = (foremanId, date) => {
const jobEnd = job.scheduledEndDate || job.scheduledDate;
// Check if this date falls within the job's date range
return date >= jobStart && date <= jobEnd;
// AND that it's a valid segment start date
const segments = getJobSegments(job);
return segments.some(seg => seg.start === date);
});
};
// Get jobs that need a connector line on this holiday
const getJobsWithConnector = (foremanId, date) => {
if (!isHoliday(date)) return [];
return scheduledServices.value.filter((job) => {
if (job.foreman !== foremanId) return false;
const jobStart = job.scheduledDate;
const jobEnd = job.scheduledEndDate || job.scheduledDate;
// Check if this holiday date falls within the job's range
return date > jobStart && date < jobEnd;
});
};
@ -630,27 +684,65 @@ const getJobsForCell = (foremanId, date) => {
const getJobStyle = (job, currentDate) => {
const jobStart = job.scheduledDate;
const jobEnd = job.scheduledEndDate || job.scheduledDate;
const segments = getJobSegments(job);
// Calculate how many days this job spans
const duration = daysBetween(jobStart, jobEnd) + 1;
// Find which segment (if any) should be rendered at currentDate
const segmentIndex = segments.findIndex(seg => seg.start === currentDate);
// Only render the job on its starting day
if (currentDate !== jobStart) {
if (segmentIndex === -1) {
return { display: 'none' };
}
const segment = segments[segmentIndex];
// Calculate visual duration for this segment
let visualDays = daysBetween(segment.start, segment.end) + 1;
// For multi-day jobs, calculate width to span cells
// Each additional day adds 100% + 1px for the border
const widthCalc = duration === 1
const widthCalc = visualDays === 1
? 'calc(100% - 8px)' // Single day: full width minus padding
: `calc(${duration * 100}% + ${(duration - 1)}px)`; // Multi-day: span cells accounting for borders
: `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;
}
}
const darkerColor = darkenColor(backgroundColor, 20);
return {
width: widthCalc,
zIndex: 10,
background: `linear-gradient(135deg, ${backgroundColor}, ${darkerColor})`
};
};
// Helper function to darken a color
function darkenColor(color, percent) {
if (!color || color === 'transparent') return '#1976d2';
// Convert hex to RGB
let r, g, b;
if (color.startsWith('#')) {
const hex = color.replace('#', '');
r = parseInt(hex.substring(0, 2), 16);
g = parseInt(hex.substring(2, 4), 16);
b = parseInt(hex.substring(4, 6), 16);
} else {
return color; // Return original if not hex
}
// Darken
r = Math.max(0, Math.floor(r * (1 - percent / 100)));
g = Math.max(0, Math.floor(g * (1 - percent / 100)));
b = Math.max(0, Math.floor(b * (1 - percent / 100)));
return `rgb(${r}, ${g}, ${b})`;
}
// Calendar navigation methods
const previousWeek = () => {
const date = parseLocalDate(weekStartDate.value);
@ -776,7 +868,6 @@ const handleDragEnd = (event) => {
draggedService.value = null;
isDragOver.value = false;
dragOverCell.value = null;
tooltipVisible.value = false;
};
const handleDragOver = (event, foremanId, date) => {
@ -824,7 +915,6 @@ const handleDragLeave = (event) => {
) {
isDragOver.value = false;
dragOverCell.value = null;
tooltipVisible.value = false;
}
}, 10);
};
@ -844,6 +934,16 @@ const handleDrop = async (event, foremanId, date) => {
return;
}
// Prevent dropping on holidays
if (isHoliday(date)) {
const holidayDesc = getHolidayDescription(date);
notifications.addError(`Cannot schedule jobs on ${holidayDesc || 'a holiday'}. Please select a different date.`);
isDragOver.value = false;
dragOverCell.value = null;
draggedService.value = null;
return;
}
// Get foreman details
const foreman = foremen.value.find(f => f.name === foremanId);
if (!foreman) return;
@ -858,7 +958,6 @@ const handleDrop = async (event, foremanId, date) => {
isDragOver.value = false;
dragOverCell.value = null;
draggedService.value = null;
tooltipVisible.value = false;
return;
}
@ -867,20 +966,26 @@ const handleDrop = async (event, foremanId, date) => {
if (serviceIndex !== -1) {
const wasScheduled = services.value[serviceIndex].isScheduled;
services.value[serviceIndex] = {
...services.value[serviceIndex],
isScheduled: true,
scheduledDate: date,
scheduledEndDate: endDate,
foreman: foreman.name
};
const action = wasScheduled ? 'Moved' : 'Scheduled';
console.log(`${action} ${draggedService.value.serviceType} for ${foreman.employeeName} on ${date} to ${endDate}`);
// Call API to persist changes (placeholder for now)
try {
notifications.addWarning("API update feature coming soon!");
await Api.updateJobScheduledDates(
draggedService.value.id,
date,
endDate,
foreman.name
)
services.value[serviceIndex] = {
...services.value[serviceIndex],
isScheduled: true,
scheduledDate: date,
scheduledEndDate: endDate,
foreman: foreman.name
};
notifications.addSuccess("Job scheduled successfully!");
// Future implementation:
// await Api.updateJobSchedule({
// id: draggedService.value.id,
@ -899,7 +1004,6 @@ const handleDrop = async (event, foremanId, date) => {
isDragOver.value = false;
dragOverCell.value = null;
draggedService.value = null;
tooltipVisible.value = false;
};
// Handle dropping scheduled items back to unscheduled
@ -934,7 +1038,13 @@ const handleUnscheduledDrop = async (event) => {
// Call API to persist changes (placeholder for now)
try {
notifications.addWarning("API update feature coming soon!");
await Api.updateJobScheduledDates(
draggedService.value.id,
null,
null,
null
)
notifications.addSuccess("Job unscheduled successfully!");
// Future implementation:
// await Api.updateJobSchedule({
// id: draggedService.value.id,
@ -1001,20 +1111,45 @@ const handleResize = (event) => {
// Calculate how many days to extend with better snapping (0.4 threshold instead of 0.5)
const daysToAdd = Math.floor(deltaX / cellWidth + 0.4);
// Calculate new end date based on current job duration + adjustment
const jobStartDate = resizingJob.value.scheduledDate;
const currentEndDate = originalEndDate.value || jobStartDate;
const currentDuration = daysBetween(jobStartDate, currentEndDate); // Days from start to current end
const totalDays = currentDuration + daysToAdd;
// Calculate new end date based on current end date + adjustment
const currentEndDate = originalEndDate.value;
const currentEndDateObj = parseLocalDate(currentEndDate);
// Minimum is same day (0 days difference)
if (totalDays >= 0) {
let newEndDate = addDays(jobStartDate, totalDays);
let extendsOverSunday = false;
// Check if the new end date or any date in between is Sunday
for (let i = 0; i <= totalDays; i++) {
const checkDate = addDays(jobStartDate, i);
// Calculate proposed end date by adding days to the CURRENT end date
let proposedEndDate = addDays(currentEndDate, daysToAdd);
// Don't allow shrinking before the current end date (minimum stay at current)
if (daysToAdd < 0) {
proposedEndDate = currentEndDate;
}
let newEndDate = proposedEndDate;
let extendsOverSunday = false;
let hasHolidayBlock = false;
const weekEndDate = addDays(weekStartDate.value, 6); // Saturday
const proposedDate = parseLocalDate(proposedEndDate);
const saturdayDate = parseLocalDate(weekEndDate);
// Check for holidays in the EXTENSION range only (from current end to proposed end)
const holidaysInRange = getHolidaysInRange(currentEndDate, proposedEndDate);
if (holidaysInRange.length > 0) {
hasHolidayBlock = true;
// Allow the extension - visual split will be handled by getJobSegments
newEndDate = proposedEndDate;
}
// Check if the proposed end date extends past Saturday or lands on Sunday
if (proposedDate > saturdayDate || isSunday(proposedEndDate)) {
extendsOverSunday = true;
// Set logical end date to next Monday, but visually cap at Saturday
newEndDate = getNextMonday(weekEndDate);
} else if (!hasHolidayBlock && daysToAdd > 0) {
// Check if any date in the EXTENSION range is Sunday (only if no holiday block)
// Only check from current end to proposed end
const startCheck = parseLocalDate(currentEndDate);
const endCheck = parseLocalDate(proposedEndDate);
for (let d = new Date(startCheck.getTime() + 86400000); d <= endCheck; d.setDate(d.getDate() + 1)) {
const checkDate = toLocalDateString(d);
if (isSunday(checkDate)) {
extendsOverSunday = true;
// Extend to next Monday
@ -1022,85 +1157,67 @@ const handleResize = (event) => {
break;
}
}
// Show tooltip if extending over Sunday
if (extendsOverSunday) {
tooltip.value = 'Extend to next Monday';
tooltipVisible.value = true;
tooltipPosition.value = { x: event.clientX, y: event.clientY };
} else {
tooltipVisible.value = false;
}
const serviceIndex = services.value.findIndex(s => s.id === resizingJob.value.id);
if (serviceIndex !== -1) {
services.value[serviceIndex] = {
...services.value[serviceIndex],
scheduledEndDate: newEndDate
};
}
}
// Show popup if extending over Sunday
showExtendToNextWeekPopup.value = extendsOverSunday;
const serviceIndex = services.value.findIndex(s => s.id === resizingJob.value.id);
if (serviceIndex !== -1) {
services.value[serviceIndex] = {
...services.value[serviceIndex],
scheduledEndDate: newEndDate
};
}
};
const stopResize = async () => {
if (!resizingJob.value) return;
// Hide tooltip
tooltipVisible.value = false;
// Hide popup
showExtendToNextWeekPopup.value = false;
const job = resizingJob.value;
const newEndDate = job.scheduledEndDate || job.scheduledDate;
// Check for holidays in the range (excluding the extended-to-Monday case)
const jobStartDate = job.scheduledDate;
const weekEnd = addDays(weekStartDate.value, 6); // Saturday
// Find the updated job in services array (because resizingJob.value is a stale reference)
const serviceIndex = services.value.findIndex(s => s.id === job.id);
if (serviceIndex === -1) return;
// Only check for holidays in the current week range
const endDateToCheck = parseLocalDate(newEndDate) <= parseLocalDate(weekEnd) ? newEndDate : weekEnd;
if (hasHolidayInRange(jobStartDate, endDateToCheck)) {
notifications.addError("Cannot extend job onto a holiday. Resize cancelled.");
// Revert the change
const serviceIndex = services.value.findIndex(s => s.id === job.id);
if (serviceIndex !== -1) {
services.value[serviceIndex] = {
...services.value[serviceIndex],
scheduledEndDate: originalEndDate.value
};
}
// Clean up
document.removeEventListener('mousemove', handleResize);
document.removeEventListener('mouseup', stopResize);
document.body.style.cursor = 'default';
setTimeout(() => {
justFinishedResize.value = false;
}, 150);
resizingJob.value = null;
resizeStartX.value = 0;
resizeStartDate.value = null;
originalEndDate.value = null;
return;
}
const updatedJob = services.value[serviceIndex];
const newEndDate = updatedJob.scheduledEndDate || updatedJob.scheduledDate;
console.log(`Proposed new end date for job ${job.serviceType}: ${newEndDate}`);
// Only update if end date changed
if (newEndDate !== originalEndDate.value) {
const weekEnd = addDays(weekStartDate.value, 6); // Saturday
const extendsToNextWeek = parseLocalDate(newEndDate) > parseLocalDate(weekEnd);
if (extendsToNextWeek) {
notifications.addInfo(`Job extended to next Monday (${formatDate(newEndDate)})`);
}
console.log(`Extended job ${job.serviceType} to ${newEndDate}`);
// Call API to persist changes (placeholder for now)
console.log(`Extended job ${job.serviceType} from ${originalEndDate.value} to ${newEndDate}`);
// Call API to persist changes
try {
notifications.addWarning("API update feature coming soon!");
// Future implementation:
// await Api.updateJobSchedule({
// id: job.id,
// expectedEndDate: newEndDate
// });
await Api.updateJobScheduledDates(
job.id,
job.scheduledDate,
newEndDate,
job.foreman
);
notifications.addSuccess("Job end date updated successfully!");
} catch (error) {
console.error("Error updating job end date:", error);
notifications.addError("Failed to update job");
notifications.addError("Failed to update job end date");
// Revert on error
const serviceIndex = services.value.findIndex(s => s.id === job.id);
if (serviceIndex !== -1) {
services.value[serviceIndex] = {
...services.value[serviceIndex],
scheduledEndDate: originalEndDate.value
};
}
}
}
@ -1127,7 +1244,7 @@ const fetchProjects = async () => {
const startDate = weekStartDate.value;
const endDate = addDays(startDate, 6);
const data = await Api.getJobsForCalendar(startDate, companyStore.currentCompany, selectedProjectTemplates.value);
const data = await Api.getJobsForCalendar(startDate, endDate, companyStore.currentCompany, selectedProjectTemplates.value);
// Transform the API response into the format the component expects
const transformedServices = [];
@ -1136,6 +1253,7 @@ const fetchProjects = async () => {
if (data.projects && Array.isArray(data.projects)) {
data.projects.forEach(project => {
transformedServices.push({
...project, // Include all fields from API
id: project.name,
title: project.projectName || project.jobAddress || 'Unnamed Project',
serviceType: project.projectName || project.jobAddress || 'Install Project',
@ -1147,7 +1265,10 @@ const fetchProjects = async () => {
priority: (project.priority || 'Medium').toLowerCase(),
estimatedCost: project.totalSalesAmount || project.estimatedCosting || 0,
notes: project.notes || '',
isScheduled: true
isScheduled: true,
projectTemplate: project.projectTemplate,
calendarColor: project.calendarColor,
jobAddress: project.jobAddress
});
});
}
@ -1156,6 +1277,7 @@ const fetchProjects = async () => {
if (data.unscheduledProjects && Array.isArray(data.unscheduledProjects)) {
data.unscheduledProjects.forEach(project => {
transformedServices.push({
...project, // Include all fields from API
id: project.name,
title: project.projectName || project.jobAddress || 'Unnamed Project',
serviceType: project.projectName || project.jobAddress || 'Install Project',
@ -1167,7 +1289,10 @@ const fetchProjects = async () => {
priority: (project.priority || 'Medium').toLowerCase(),
estimatedCost: project.totalSalesAmount || project.estimatedCosting || 0,
notes: project.notes || '',
isScheduled: false
isScheduled: false,
projectTemplate: project.projectTemplate,
calendarColor: project.calendarColor,
jobAddress: project.jobAddress
});
});
}
@ -1419,15 +1544,16 @@ onMounted(async () => {
background: linear-gradient(135deg, #2196f3, #1976d2);
color: white;
border-radius: 4px;
padding: 6px 8px;
font-size: 0.8em;
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: 40px;
min-height: 68px;
height: calc(100% - 8px);
max-width: none; /* Allow spanning */
}
@ -1451,6 +1577,9 @@ onMounted(async () => {
.job-content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: center;
}
.job-title {
@ -1458,15 +1587,17 @@ onMounted(async () => {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
margin-bottom: 4px;
font-size: 0.95em;
}
.job-customer {
font-size: 0.75em;
.job-address {
font-size: 0.8em;
opacity: 0.9;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.resize-handle {
@ -1488,20 +1619,20 @@ onMounted(async () => {
border-left: 3px solid rgba(255, 255, 255, 0.9);
}
.calendar-job.priority-urgent {
background: linear-gradient(135deg, #f44336, #d32f2f);
.spans-arrow-left {
position: absolute;
left: 5px;
top: 50%;
transform: translateY(-50%);
color: rgba(255, 255, 255, 0.8);
}
.calendar-job.priority-high {
background: linear-gradient(135deg, #ff9800, #f57c00);
}
.calendar-job.priority-medium {
background: linear-gradient(135deg, #2196f3, #1976d2);
}
.calendar-job.priority-low {
background: linear-gradient(135deg, #4caf50, #388e3c);
.spans-arrow-right {
position: absolute;
right: 25px; /* Position before resize handle */
top: 50%;
transform: translateY(-50%);
color: rgba(255, 255, 255, 0.8);
}
.unscheduled-section {
@ -1612,43 +1743,86 @@ onMounted(async () => {
color: #666;
}
.event-details {
display: flex;
flex-direction: column;
gap: 12px;
}
.detail-row {
display: flex;
align-items: center;
gap: 8px;
}
.day-cell.holiday {
background-color: rgba(255, 193, 7, 0.1); /* yellow tint for holidays */
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 */
}
.tooltip {
position: fixed;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 0.875em;
.holiday-connector {
position: absolute;
left: 4px;
right: 4px;
top: 50%;
height: 3px;
transform: translateY(-50%);
border-top: 3px dotted currentColor;
opacity: 0.6;
pointer-events: none;
z-index: 1000;
white-space: nowrap;
z-index: 5;
}
.spans-arrow {
position: absolute;
right: 25px; /* Position before resize handle */
top: 50%;
transform: translateY(-50%);
color: rgba(255, 255, 255, 0.8);
.holiday-connector.priority-urgent {
color: #f44336;
}
.holiday-connector.priority-high {
color: #ff9800;
}
.holiday-connector.priority-medium {
color: #2196f3;
}
.holiday-connector.priority-low {
color: #4caf50;
}
.extend-popup {
position: fixed;
top: 100px;
left: 50%;
transform: translateX(-50%);
background: rgba(33, 150, 243, 0.95);
color: white;
padding: 12px 20px;
border-radius: 8px;
font-size: 1em;
font-weight: 600;
pointer-events: none;
z-index: 10000;
white-space: nowrap;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
border: 2px solid rgba(255, 255, 255, 0.3);
animation: slideDown 0.2s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateX(-50%) translateY(-10px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
.service-address {
font-size: 0.85em;
color: #555;
display: flex;
align-items: center;
line-height: 1.4;
}
</style>

View file

@ -0,0 +1,299 @@
<template>
<v-dialog v-model="showModal" max-width="900px" scrollable>
<v-card v-if="job">
<v-card-title class="d-flex justify-space-between align-center bg-primary">
<div>
<div class="text-h6">{{ job.projectTemplate || job.serviceType }}</div>
<div class="text-caption">{{ job.name }}</div>
</div>
<v-chip :color="getPriorityColor(job.priority)" size="small">
{{ job.priority }}
</v-chip>
</v-card-title>
<v-card-text class="pa-4">
<v-row>
<!-- Left Column -->
<v-col cols="12" md="6">
<div class="detail-section mb-4">
<h4 class="text-subtitle-1 mb-2">Basic Information</h4>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-account</v-icon>
<strong>Customer:</strong> {{ job.customer }}
</div>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-map-marker</v-icon>
<strong>Address:</strong> {{ stripAddress(job.address || job.jobAddress) }}
</div>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-clipboard-text</v-icon>
<strong>Status:</strong> {{ job.status || 'Open' }}
</div>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-file-document</v-icon>
<strong>Sales Order:</strong> {{ job.salesOrder || 'N/A' }}
</div>
</div>
<div class="detail-section mb-4">
<h4 class="text-subtitle-1 mb-2">Schedule</h4>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-calendar-start</v-icon>
<strong>Start Date:</strong> {{ job.expectedStartDate || job.scheduledDate }}
</div>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-calendar-end</v-icon>
<strong>End Date:</strong> {{ job.expectedEndDate || job.scheduledEndDate }}
</div>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-account-hard-hat</v-icon>
<strong>Assigned Crew:</strong> {{ getCrewName(job.foreman || job.customForeman) }}
</div>
</div>
<div class="detail-section mb-4">
<h4 class="text-subtitle-1 mb-2">Compliance</h4>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-file-certificate</v-icon>
<strong>Permit Status:</strong>
<v-chip size="x-small" :color="getPermitStatusColor(job.customPermitStatus)" class="ml-2">
{{ job.customPermitStatus || 'N/A' }}
</v-chip>
</div>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-map-search</v-icon>
<strong>Utility Locate:</strong>
<v-chip size="x-small" :color="getLocateStatusColor(job.customUtlityLocateStatus)" class="ml-2">
{{ job.customUtlityLocateStatus || 'N/A' }}
</v-chip>
</div>
<div v-if="job.customWarrantyDurationDays" class="detail-row">
<v-icon size="small" class="mr-2">mdi-shield-check</v-icon>
<strong>Warranty:</strong> {{ job.customWarrantyDurationDays }} days
</div>
</div>
</v-col>
<!-- Right Column -->
<v-col cols="12" md="6">
<div class="detail-section mb-4">
<h4 class="text-subtitle-1 mb-3">Progress</h4>
<div class="mb-3">
<div class="d-flex justify-space-between mb-1">
<span class="text-caption">Completion</span>
<span class="text-caption font-weight-bold">{{ job.percentComplete || 0 }}%</span>
</div>
<v-progress-linear
:model-value="job.percentComplete || 0"
color="success"
height="8"
rounded
></v-progress-linear>
</div>
</div>
<div class="detail-section mb-4">
<h4 class="text-subtitle-1 mb-2">Financial Summary</h4>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-currency-usd</v-icon>
<strong>Total Sales:</strong> ${{ (job.totalSalesAmount || 0).toLocaleString() }}
</div>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-receipt</v-icon>
<strong>Billed Amount:</strong> ${{ (job.totalBilledAmount || 0).toLocaleString() }}
</div>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-calculator</v-icon>
<strong>Total Cost:</strong> ${{ (job.totalCostingAmount || 0).toLocaleString() }}
</div>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-chart-line</v-icon>
<strong>Gross Margin:</strong> {{ (job.perGrossMargin || 0).toFixed(1) }}%
</div>
<div class="mt-3">
<div class="d-flex justify-space-between mb-1">
<span class="text-caption">Billing Progress</span>
<span class="text-caption font-weight-bold">
${{ (job.totalBilledAmount || 0).toLocaleString() }} / ${{ (job.totalSalesAmount || 0).toLocaleString() }}
</span>
</div>
<v-progress-linear
:model-value="getBillingProgress(job)"
color="primary"
height="8"
rounded
></v-progress-linear>
</div>
</div>
<!-- Map Display -->
<div v-if="hasCoordinates" class="detail-section mb-4">
<h4 class="text-subtitle-1 mb-2">Location</h4>
<div 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>
<div v-if="job.notes" class="detail-section">
<h4 class="text-subtitle-1 mb-2">Notes</h4>
<p class="text-body-2">{{ job.notes }}</p>
</div>
</v-col>
</v-row>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-4">
<v-btn
color="primary"
variant="flat"
@click="viewJob"
>
<v-icon left>mdi-open-in-new</v-icon>
View Job
</v-btn>
<v-spacer></v-spacer>
<v-btn variant="text" @click="handleClose">Close</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, computed } from "vue";
// Props
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
job: {
type: Object,
default: null,
},
foremen: {
type: Array,
default: () => [],
},
});
// Emits
const emit = defineEmits(["update:visible", "close"]);
// Computed
const showModal = computed({
get() {
return props.visible;
},
set(value) {
emit("update:visible", value);
},
});
const hasCoordinates = computed(() => {
if (!props.job?.jobAddress) return false;
// Check if address has coordinates - you may need to adjust based on your data structure
const lat = props.job.latitude || props.job.customLatitude;
const lon = props.job.longitude || props.job.customLongitude;
return lat && lon && parseFloat(lat) !== 0 && parseFloat(lon) !== 0;
});
const mapUrl = computed(() => {
if (!hasCoordinates.value) return "";
const lat = parseFloat(props.job.latitude || props.job.customLatitude);
const lon = parseFloat(props.job.longitude || props.job.customLongitude);
// 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}`;
});
// Methods
const stripAddress = (address) => {
if (!address) return '';
const index = address.indexOf('-#-');
return index > -1 ? address.substring(0, index).trim() : address;
};
const getCrewName = (foremanId) => {
if (!foremanId) return 'Not assigned';
const foreman = props.foremen.find(f => f.name === foremanId);
if (!foreman) return foremanId;
return foreman.customCrew ? `${foreman.employeeName} (Crew ${foreman.customCrew})` : foreman.employeeName;
};
const getBillingProgress = (job) => {
if (!job.totalSalesAmount || job.totalSalesAmount === 0) return 0;
return Math.min(100, (job.totalBilledAmount / job.totalSalesAmount) * 100);
};
const getPermitStatusColor = (status) => {
if (!status) return 'grey';
if (status.toLowerCase().includes('approved')) return 'success';
if (status.toLowerCase().includes('pending')) return 'warning';
return 'error';
};
const getLocateStatusColor = (status) => {
if (!status) return 'grey';
if (status.toLowerCase().includes('complete')) return 'success';
if (status.toLowerCase().includes('incomplete')) return 'error';
return 'warning';
};
const getPriorityColor = (priority) => {
switch (priority) {
case "urgent":
return "red";
case "high":
return "orange";
case "medium":
return "yellow";
case "low":
return "green";
default:
return "grey";
}
};
const viewJob = () => {
if (props.job?.name) {
window.location.href = `/job?name=${encodeURIComponent(props.job.name)}`;
}
};
const handleClose = () => {
emit("close");
};
</script>
<style scoped>
.detail-section {
background-color: #f8f9fa;
padding: 12px;
border-radius: 8px;
}
.detail-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.detail-row:last-child {
margin-bottom: 0;
}
.map-container {
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
</style>

View file

@ -1006,7 +1006,7 @@ onMounted(async () => {
};
});
}
formData.requiresHalfPayment = estimate.value.custom_requires_half_payment || false;
formData.requiresHalfPayment = estimate.value.requiresHalfPayment || false;
estimateResponse.value = estimate.value.customResponse;
estimateResponseSelection.value = estimate.value.customResponse;
}