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

View file

@ -1,6 +1,7 @@
import frappe, json 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.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 custom_ui.services import AddressService, ClientService
from frappe.utils import getdate
# =============================================================================== # ===============================================================================
# JOB MANAGEMENT API METHODS # JOB MANAGEMENT API METHODS
@ -177,21 +178,26 @@ def upsert_job(data):
return {"status": "error", "message": str(e)} return {"status": "error", "message": str(e)}
@frappe.whitelist() @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.""" """Get install projects for the calendar."""
# Parse project_templates if it's a JSON string # Parse project_templates if it's a JSON string
if isinstance(project_templates, str): if isinstance(project_templates, str):
project_templates = json.loads(project_templates) project_templates = json.loads(project_templates)
# put some emojis in the print to make it stand out # 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: try:
filters = {"company": company} if company else {} filters = {
"company": company
} if company else {}
if project_templates and len(project_templates) > 0: if project_templates and len(project_templates) > 0:
filters["project_template"] = ["in", project_templates] filters["project_template"] = ["in", project_templates]
unscheduled_filters = filters.copy() unscheduled_filters = filters.copy()
unscheduled_filters["is_scheduled"] = 0 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 # 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. # 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). # 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 }) return build_success_response({ "projects": projects, "unscheduled_projects": unscheduled_projects })
except Exception as e: except Exception as e:
return build_error_response(str(e), 500) 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 import frappe
from custom_ui.services import AddressService, ClientService from custom_ui.services import AddressService, ClientService
from datetime import timedelta
def after_insert(doc, method): def after_insert(doc, method):
print("DEBUG: After Insert Triggered for Project") print("DEBUG: After Insert Triggered for Project")
@ -30,8 +30,13 @@ def before_insert(doc, method):
def before_save(doc, method): def before_save(doc, method):
print("DEBUG: Before Save Triggered for Project:", doc.name) print("DEBUG: Before Save Triggered for Project:", doc.name)
if doc.expected_start_date and doc.expected_end_date: 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 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 doc.is_scheduled = 0
def after_save(doc, method): def after_save(doc, method):
@ -44,8 +49,10 @@ def after_save(doc, method):
"Closed": "Completed" "Closed": "Completed"
} }
new_status = status_mapping.get(doc.status, "In Progress") new_status = status_mapping.get(doc.status, "In Progress")
AddressService.update_value( if frappe.db.get_value("Address", doc.job_address, "job_status") != new_status:
doc.job_address, print("DEBUG: Updating Address job_status to:", new_status)
"job_status", AddressService.update_value(
new_status doc.job_address,
) "job_status",
new_status
)

View file

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

View file

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

View file

@ -598,6 +598,12 @@ def add_custom_fields():
options="Company", options="Company",
insert_after="project_type", insert_after="project_type",
description="The company associated with this project template." description="The company associated with this project template."
),
dict(
fieldname="calendar_color",
label="Calendar Color",
fieldtype="Color",
insert_after="company"
) )
], ],
"Task": [ "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_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_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_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 // Task methods
const FRAPPE_GET_TASKS_METHOD = "custom_ui.api.db.tasks.get_tasks_table_data"; 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"; const FRAPPE_GET_TASKS_STATUS_OPTIONS = "custom_ui.api.db.tasks.get_task_status_options";
@ -319,8 +320,17 @@ class Api {
return result; return result;
} }
static async getJobsForCalendar(date, company = null, projectTemplates = []) { static async getJobsForCalendar(startDate, endDate, company = null, projectTemplates = []) {
return await this.request(FRAPPE_GET_JOBS_FOR_CALENDAR_METHOD, { date, company, 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) { static async getJob(jobName) {

View file

@ -23,9 +23,9 @@
variant="outlined" variant="outlined"
size="small" size="small"
></v-btn> ></v-btn>
<v-btn @click="goToThisWeek" variant="outlined" size="small" class="ml-4" <v-btn @click="goToThisWeek" variant="outlined" size="small" class="ml-4">
>This Week</v-btn This Week
> </v-btn>
<v-menu <v-menu
v-model="showTemplateMenu" v-model="showTemplateMenu"
:close-on-content-click="false" :close-on-content-click="false"
@ -205,24 +205,31 @@
v-for="job in getJobsForCell(foreman.name, day.date)" v-for="job in getJobsForCell(foreman.name, day.date)"
:key="job.id" :key="job.id"
class="calendar-job" class="calendar-job"
:class="getPriorityClass(job.priority)" :style="getJobStyle(job, day.date)"
:style="getJobStyle(job, day.date)" draggable="true"
draggable="true" @click.stop="showEventDetails({ event: job })"
@click.stop="showEventDetails({ event: job })" @dragstart="handleDragStart(job, $event)"
@dragstart="handleDragStart(job, $event)" @dragend="handleDragEnd"
@dragend="handleDragEnd" @mousedown="startResize($event, job, day.date)"
@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-content">
<div class="job-title">{{ job.serviceType }}</div> <div class="job-title">{{ job.projectTemplate || job.serviceType }}</div>
<div class="job-customer">{{ job.customer }}</div> <div class="job-address">{{ stripAddress(job.address || job.jobAddress) }}</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>
</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> </div>
</div> </div>
@ -261,41 +268,32 @@
@dragend="handleDragEnd" @dragend="handleDragEnd"
> >
<v-card-text class="pa-3"> <v-card-text class="pa-3">
<div class="d-flex justify-space-between align-center mb-2"> <div class="service-title-compact mb-2">{{ service.projectTemplate || service.serviceType }}</div>
<v-chip :color="getPriorityColor(service.priority)" size="x-small"> <div class="service-address mb-1">
{{ service.priority.toUpperCase() }} <v-icon size="x-small" class="mr-1">mdi-map-marker</v-icon>
</v-chip> {{ stripAddress(service.address || service.jobAddress) }}
<span class="text-caption text-medium-emphasis" </div>
>${{ service.estimatedCost.toLocaleString() }}</span <div class="service-customer mb-2">
> <v-icon size="x-small" class="mr-1">mdi-account</v-icon>
</div> {{ service.customer }}
</div>
<div class="service-title-compact">{{ service.serviceType }}</div> <div v-if="service.notes" class="service-notes-compact mt-2">
<div class="service-customer">{{ service.customer }}</div> <span class="text-caption">{{ service.notes }}</span>
</div>
<div class="service-compact-details mt-1"> <v-btn
<div class="text-caption"> color="primary"
<v-icon size="x-small" class="mr-1">mdi-clock</v-icon> size="x-small"
{{ formatDuration(service.duration) }} variant="outlined"
</div> class="mt-2"
</div> block
@click="scheduleService(service)"
<div v-if="service.notes" class="service-notes-compact mt-2"> >
<span class="text-caption">{{ service.notes }}</span> <v-icon left size="x-small">mdi-calendar-plus</v-icon>
</div> Schedule
</v-btn>
<v-btn </v-card-text>
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> </v-card>
</div> </div>
@ -306,67 +304,17 @@
</div> </div>
</div> </div>
<!-- Event Details Dialog --> <!-- Event Details Modal -->
<v-dialog v-model="eventDialog" max-width="600px"> <JobDetailsModal
<v-card v-if="selectedEvent"> v-model:visible="eventDialog"
<v-card-title class="d-flex justify-space-between align-center"> :job="selectedEvent"
<span>{{ selectedEvent.title }}</span> :foremen="foremen"
<v-chip :color="getPriorityColor(selectedEvent.priority)" small> @close="eventDialog = false"
{{ selectedEvent.priority.toUpperCase() }} />
</v-chip>
</v-card-title>
<v-card-text> <!-- Extend to next week popup -->
<div class="event-details"> <div v-if="showExtendToNextWeekPopup && resizingJob" class="extend-popup">
<div class="detail-row"> Extend to next week
<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 }}
</div> </div>
</div> </div>
</template> </template>
@ -376,6 +324,7 @@ import { ref, onMounted, computed, watch } from "vue";
import Api from "../../../api"; import Api from "../../../api";
import { useNotificationStore } from "../../../stores/notifications-primevue"; import { useNotificationStore } from "../../../stores/notifications-primevue";
import { useCompanyStore } from "../../../stores/company"; import { useCompanyStore } from "../../../stores/company";
import JobDetailsModal from "../../modals/JobDetailsModal.vue";
const notifications = useNotificationStore(); const notifications = useNotificationStore();
const companyStore = useCompanyStore(); const companyStore = useCompanyStore();
@ -393,10 +342,8 @@ const draggedService = ref(null);
const isDragOver = ref(false); const isDragOver = ref(false);
const dragOverCell = ref(null); const dragOverCell = ref(null);
// Tooltip state // Extend to next week popup state
const tooltip = ref(''); const showExtendToNextWeekPopup = ref(false);
const tooltipVisible = ref(false);
const tooltipPosition = ref({ x: 0, y: 0 });
// Resize state // Resize state
const resizingJob = ref(null); const resizingJob = ref(null);
@ -495,7 +442,13 @@ function daysBetween(startDate, endDate) {
// Helper function to check if date is a holiday // Helper function to check if date is a holiday
function isHoliday(dateStr) { 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 // Helper function to check if date is Sunday
@ -532,6 +485,90 @@ function jobSpansToNextWeek(job) {
return end > weekEnd; 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 // Computed properties
const weekDisplayText = computed(() => { const weekDisplayText = computed(() => {
const startDate = parseLocalDate(weekStartDate.value); const startDate = parseLocalDate(weekStartDate.value);
@ -612,8 +649,8 @@ const formatDuration = (minutes) => {
// Get jobs for a specific foreman and date // Get jobs for a specific foreman and date
const getJobsForCell = (foremanId, date) => { const getJobsForCell = (foremanId, date) => {
// Don't render jobs on Sunday // Don't render jobs on Sunday or holidays
if (isSunday(date)) return []; if (isSunday(date) || isHoliday(date)) return [];
return scheduledServices.value.filter((job) => { return scheduledServices.value.filter((job) => {
if (job.foreman !== foremanId) return false; if (job.foreman !== foremanId) return false;
@ -622,7 +659,24 @@ const getJobsForCell = (foremanId, date) => {
const jobEnd = job.scheduledEndDate || job.scheduledDate; const jobEnd = job.scheduledEndDate || job.scheduledDate;
// Check if this date falls within the job's date range // 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 getJobStyle = (job, currentDate) => {
const jobStart = job.scheduledDate; const jobStart = job.scheduledDate;
const jobEnd = job.scheduledEndDate || job.scheduledDate; const jobEnd = job.scheduledEndDate || job.scheduledDate;
const segments = getJobSegments(job);
// Calculate how many days this job spans // Find which segment (if any) should be rendered at currentDate
const duration = daysBetween(jobStart, jobEnd) + 1; const segmentIndex = segments.findIndex(seg => seg.start === currentDate);
// Only render the job on its starting day if (segmentIndex === -1) {
if (currentDate !== jobStart) {
return { display: 'none' }; 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 // For multi-day jobs, calculate width to span cells
// Each additional day adds 100% + 1px for the border const widthCalc = visualDays === 1
const widthCalc = duration === 1
? 'calc(100% - 8px)' // Single day: full width minus padding ? '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 { return {
width: widthCalc, width: widthCalc,
zIndex: 10, 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 // Calendar navigation methods
const previousWeek = () => { const previousWeek = () => {
const date = parseLocalDate(weekStartDate.value); const date = parseLocalDate(weekStartDate.value);
@ -776,7 +868,6 @@ const handleDragEnd = (event) => {
draggedService.value = null; draggedService.value = null;
isDragOver.value = false; isDragOver.value = false;
dragOverCell.value = null; dragOverCell.value = null;
tooltipVisible.value = false;
}; };
const handleDragOver = (event, foremanId, date) => { const handleDragOver = (event, foremanId, date) => {
@ -824,7 +915,6 @@ const handleDragLeave = (event) => {
) { ) {
isDragOver.value = false; isDragOver.value = false;
dragOverCell.value = null; dragOverCell.value = null;
tooltipVisible.value = false;
} }
}, 10); }, 10);
}; };
@ -844,6 +934,16 @@ const handleDrop = async (event, foremanId, date) => {
return; 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 // Get foreman details
const foreman = foremen.value.find(f => f.name === foremanId); const foreman = foremen.value.find(f => f.name === foremanId);
if (!foreman) return; if (!foreman) return;
@ -858,7 +958,6 @@ const handleDrop = async (event, foremanId, date) => {
isDragOver.value = false; isDragOver.value = false;
dragOverCell.value = null; dragOverCell.value = null;
draggedService.value = null; draggedService.value = null;
tooltipVisible.value = false;
return; return;
} }
@ -867,20 +966,26 @@ const handleDrop = async (event, foremanId, date) => {
if (serviceIndex !== -1) { if (serviceIndex !== -1) {
const wasScheduled = services.value[serviceIndex].isScheduled; 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'; const action = wasScheduled ? 'Moved' : 'Scheduled';
console.log(`${action} ${draggedService.value.serviceType} for ${foreman.employeeName} on ${date} to ${endDate}`); console.log(`${action} ${draggedService.value.serviceType} for ${foreman.employeeName} on ${date} to ${endDate}`);
// Call API to persist changes (placeholder for now) // Call API to persist changes (placeholder for now)
try { 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: // Future implementation:
// await Api.updateJobSchedule({ // await Api.updateJobSchedule({
// id: draggedService.value.id, // id: draggedService.value.id,
@ -899,7 +1004,6 @@ const handleDrop = async (event, foremanId, date) => {
isDragOver.value = false; isDragOver.value = false;
dragOverCell.value = null; dragOverCell.value = null;
draggedService.value = null; draggedService.value = null;
tooltipVisible.value = false;
}; };
// Handle dropping scheduled items back to unscheduled // Handle dropping scheduled items back to unscheduled
@ -934,7 +1038,13 @@ const handleUnscheduledDrop = async (event) => {
// Call API to persist changes (placeholder for now) // Call API to persist changes (placeholder for now)
try { try {
notifications.addWarning("API update feature coming soon!"); await Api.updateJobScheduledDates(
draggedService.value.id,
null,
null,
null
)
notifications.addSuccess("Job unscheduled successfully!");
// Future implementation: // Future implementation:
// await Api.updateJobSchedule({ // await Api.updateJobSchedule({
// id: draggedService.value.id, // 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) // Calculate how many days to extend with better snapping (0.4 threshold instead of 0.5)
const daysToAdd = Math.floor(deltaX / cellWidth + 0.4); const daysToAdd = Math.floor(deltaX / cellWidth + 0.4);
// Calculate new end date based on current job duration + adjustment // Calculate new end date based on current end date + adjustment
const jobStartDate = resizingJob.value.scheduledDate; const currentEndDate = originalEndDate.value;
const currentEndDate = originalEndDate.value || jobStartDate; const currentEndDateObj = parseLocalDate(currentEndDate);
const currentDuration = daysBetween(jobStartDate, currentEndDate); // Days from start to current end
const totalDays = currentDuration + daysToAdd;
// Minimum is same day (0 days difference) // Calculate proposed end date by adding days to the CURRENT end date
if (totalDays >= 0) { let proposedEndDate = addDays(currentEndDate, daysToAdd);
let newEndDate = addDays(jobStartDate, totalDays);
let extendsOverSunday = false; // Don't allow shrinking before the current end date (minimum stay at current)
if (daysToAdd < 0) {
// Check if the new end date or any date in between is Sunday proposedEndDate = currentEndDate;
for (let i = 0; i <= totalDays; i++) { }
const checkDate = addDays(jobStartDate, i);
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)) { if (isSunday(checkDate)) {
extendsOverSunday = true; extendsOverSunday = true;
// Extend to next Monday // Extend to next Monday
@ -1022,85 +1157,67 @@ const handleResize = (event) => {
break; break;
} }
} }
}
// Show tooltip if extending over Sunday
if (extendsOverSunday) { // Show popup if extending over Sunday
tooltip.value = 'Extend to next Monday'; showExtendToNextWeekPopup.value = extendsOverSunday;
tooltipVisible.value = true;
tooltipPosition.value = { x: event.clientX, y: event.clientY }; const serviceIndex = services.value.findIndex(s => s.id === resizingJob.value.id);
} else { if (serviceIndex !== -1) {
tooltipVisible.value = false; services.value[serviceIndex] = {
} ...services.value[serviceIndex],
scheduledEndDate: newEndDate
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 () => { const stopResize = async () => {
if (!resizingJob.value) return; if (!resizingJob.value) return;
// Hide tooltip // Hide popup
tooltipVisible.value = false; showExtendToNextWeekPopup.value = false;
const job = resizingJob.value; const job = resizingJob.value;
const newEndDate = job.scheduledEndDate || job.scheduledDate;
// Check for holidays in the range (excluding the extended-to-Monday case) // Find the updated job in services array (because resizingJob.value is a stale reference)
const jobStartDate = job.scheduledDate; const serviceIndex = services.value.findIndex(s => s.id === job.id);
const weekEnd = addDays(weekStartDate.value, 6); // Saturday if (serviceIndex === -1) return;
// Only check for holidays in the current week range const updatedJob = services.value[serviceIndex];
const endDateToCheck = parseLocalDate(newEndDate) <= parseLocalDate(weekEnd) ? newEndDate : weekEnd; const newEndDate = updatedJob.scheduledEndDate || updatedJob.scheduledDate;
console.log(`Proposed new end date for job ${job.serviceType}: ${newEndDate}`);
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;
}
// Only update if end date changed // Only update if end date changed
if (newEndDate !== originalEndDate.value) { if (newEndDate !== originalEndDate.value) {
const weekEnd = addDays(weekStartDate.value, 6); // Saturday
const extendsToNextWeek = parseLocalDate(newEndDate) > parseLocalDate(weekEnd); const extendsToNextWeek = parseLocalDate(newEndDate) > parseLocalDate(weekEnd);
if (extendsToNextWeek) { if (extendsToNextWeek) {
notifications.addInfo(`Job extended to next Monday (${formatDate(newEndDate)})`); 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 { try {
notifications.addWarning("API update feature coming soon!"); await Api.updateJobScheduledDates(
// Future implementation: job.id,
// await Api.updateJobSchedule({ job.scheduledDate,
// id: job.id, newEndDate,
// expectedEndDate: newEndDate job.foreman
// }); );
notifications.addSuccess("Job end date updated successfully!");
} catch (error) { } catch (error) {
console.error("Error updating job end date:", 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 startDate = weekStartDate.value;
const endDate = addDays(startDate, 6); 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 // Transform the API response into the format the component expects
const transformedServices = []; const transformedServices = [];
@ -1136,6 +1253,7 @@ const fetchProjects = async () => {
if (data.projects && Array.isArray(data.projects)) { if (data.projects && Array.isArray(data.projects)) {
data.projects.forEach(project => { data.projects.forEach(project => {
transformedServices.push({ transformedServices.push({
...project, // Include all fields from API
id: project.name, id: project.name,
title: project.projectName || project.jobAddress || 'Unnamed Project', title: project.projectName || project.jobAddress || 'Unnamed Project',
serviceType: project.projectName || project.jobAddress || 'Install Project', serviceType: project.projectName || project.jobAddress || 'Install Project',
@ -1147,7 +1265,10 @@ const fetchProjects = async () => {
priority: (project.priority || 'Medium').toLowerCase(), priority: (project.priority || 'Medium').toLowerCase(),
estimatedCost: project.totalSalesAmount || project.estimatedCosting || 0, estimatedCost: project.totalSalesAmount || project.estimatedCosting || 0,
notes: project.notes || '', 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)) { if (data.unscheduledProjects && Array.isArray(data.unscheduledProjects)) {
data.unscheduledProjects.forEach(project => { data.unscheduledProjects.forEach(project => {
transformedServices.push({ transformedServices.push({
...project, // Include all fields from API
id: project.name, id: project.name,
title: project.projectName || project.jobAddress || 'Unnamed Project', title: project.projectName || project.jobAddress || 'Unnamed Project',
serviceType: project.projectName || project.jobAddress || 'Install Project', serviceType: project.projectName || project.jobAddress || 'Install Project',
@ -1167,7 +1289,10 @@ const fetchProjects = async () => {
priority: (project.priority || 'Medium').toLowerCase(), priority: (project.priority || 'Medium').toLowerCase(),
estimatedCost: project.totalSalesAmount || project.estimatedCosting || 0, estimatedCost: project.totalSalesAmount || project.estimatedCosting || 0,
notes: project.notes || '', 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); background: linear-gradient(135deg, #2196f3, #1976d2);
color: white; color: white;
border-radius: 4px; border-radius: 4px;
padding: 6px 8px; padding: 8px 10px;
font-size: 0.8em; font-size: 0.85em;
cursor: pointer; cursor: pointer;
overflow: hidden; overflow: hidden;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: all 0.2s; transition: all 0.2s;
display: flex; display: flex;
align-items: center; align-items: center;
min-height: 40px; min-height: 68px;
height: calc(100% - 8px);
max-width: none; /* Allow spanning */ max-width: none; /* Allow spanning */
} }
@ -1451,6 +1577,9 @@ onMounted(async () => {
.job-content { .job-content {
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
display: flex;
flex-direction: column;
justify-content: center;
} }
.job-title { .job-title {
@ -1458,15 +1587,17 @@ onMounted(async () => {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
margin-bottom: 2px; margin-bottom: 4px;
font-size: 0.95em;
} }
.job-customer { .job-address {
font-size: 0.75em; font-size: 0.8em;
opacity: 0.9; opacity: 0.9;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
line-height: 1.3;
} }
.resize-handle { .resize-handle {
@ -1488,20 +1619,20 @@ onMounted(async () => {
border-left: 3px solid rgba(255, 255, 255, 0.9); border-left: 3px solid rgba(255, 255, 255, 0.9);
} }
.calendar-job.priority-urgent { .spans-arrow-left {
background: linear-gradient(135deg, #f44336, #d32f2f); position: absolute;
left: 5px;
top: 50%;
transform: translateY(-50%);
color: rgba(255, 255, 255, 0.8);
} }
.calendar-job.priority-high { .spans-arrow-right {
background: linear-gradient(135deg, #ff9800, #f57c00); position: absolute;
} right: 25px; /* Position before resize handle */
top: 50%;
.calendar-job.priority-medium { transform: translateY(-50%);
background: linear-gradient(135deg, #2196f3, #1976d2); color: rgba(255, 255, 255, 0.8);
}
.calendar-job.priority-low {
background: linear-gradient(135deg, #4caf50, #388e3c);
} }
.unscheduled-section { .unscheduled-section {
@ -1612,43 +1743,86 @@ onMounted(async () => {
color: #666; color: #666;
} }
.event-details {
display: flex;
flex-direction: column;
gap: 12px;
}
.detail-row {
display: flex;
align-items: center;
gap: 8px;
}
.day-cell.holiday { .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 { .day-cell.sunday {
background-color: rgba(200, 200, 200, 0.1); /* light gray for Sunday */ background-color: rgba(200, 200, 200, 0.1); /* light gray for Sunday */
} }
.tooltip { .holiday-connector {
position: fixed; position: absolute;
background: rgba(0, 0, 0, 0.8); left: 4px;
color: white; right: 4px;
padding: 8px 12px; top: 50%;
border-radius: 4px; height: 3px;
font-size: 0.875em; transform: translateY(-50%);
border-top: 3px dotted currentColor;
opacity: 0.6;
pointer-events: none; pointer-events: none;
z-index: 1000; z-index: 5;
white-space: nowrap;
} }
.spans-arrow { .holiday-connector.priority-urgent {
position: absolute; color: #f44336;
right: 25px; /* Position before resize handle */ }
top: 50%;
transform: translateY(-50%); .holiday-connector.priority-high {
color: rgba(255, 255, 255, 0.8); 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> </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; estimateResponse.value = estimate.value.customResponse;
estimateResponseSelection.value = estimate.value.customResponse; estimateResponseSelection.value = estimate.value.customResponse;
} }