update calendar
This commit is contained in:
parent
7395d3e048
commit
e67805c01f
10 changed files with 848 additions and 325 deletions
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -263,7 +263,8 @@ fixtures = [
|
|||
"dt": "Property Setter",
|
||||
"filters": [
|
||||
["doc_type", "=", "Lead"],
|
||||
["doc_type", "=", "Project"]
|
||||
["doc_type", "=", "Project"],
|
||||
["doc_type", "=", "Address"]
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
299
frontend/src/components/modals/JobDetailsModal.vue
Normal file
299
frontend/src/components/modals/JobDetailsModal.vue
Normal 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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue