From e67805c01fd9d07216e9744566a44550de26f05e Mon Sep 17 00:00:00 2001 From: Casey Date: Wed, 21 Jan 2026 08:44:20 -0600 Subject: [PATCH] update calendar --- custom_ui/api/db/estimates.py | 5 +- custom_ui/api/db/jobs.py | 33 +- custom_ui/events/jobs.py | 21 +- custom_ui/fixtures/doctype.json | 92 +-- custom_ui/hooks.py | 3 +- custom_ui/install.py | 6 + frontend/src/api.js | 14 +- .../calendar/jobs/SNWProjectCalendar.vue | 698 +++++++++++------- .../src/components/modals/JobDetailsModal.vue | 299 ++++++++ frontend/src/components/pages/Estimate.vue | 2 +- 10 files changed, 848 insertions(+), 325 deletions(-) create mode 100644 frontend/src/components/modals/JobDetailsModal.vue diff --git a/custom_ui/api/db/estimates.py b/custom_ui/api/db/estimates.py index dcfc579..25149e3 100644 --- a/custom_ui/api/db/estimates.py +++ b/custom_ui/api/db/estimates.py @@ -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"), diff --git a/custom_ui/api/db/jobs.py b/custom_ui/api/db/jobs.py index 5fa2475..082733e 100644 --- a/custom_ui/api/db/jobs.py +++ b/custom_ui/api/db/jobs.py @@ -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) \ No newline at end of file diff --git a/custom_ui/events/jobs.py b/custom_ui/events/jobs.py index 7801611..13b328f 100644 --- a/custom_ui/events/jobs.py +++ b/custom_ui/events/jobs.py @@ -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 - ) \ No newline at end of file + 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 + ) \ No newline at end of file diff --git a/custom_ui/fixtures/doctype.json b/custom_ui/fixtures/doctype.json index d2859ca..db8c42d 100644 --- a/custom_ui/fixtures/doctype.json +++ b/custom_ui/fixtures/doctype.json @@ -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": "", diff --git a/custom_ui/hooks.py b/custom_ui/hooks.py index 04541cf..0f5911b 100644 --- a/custom_ui/hooks.py +++ b/custom_ui/hooks.py @@ -263,7 +263,8 @@ fixtures = [ "dt": "Property Setter", "filters": [ ["doc_type", "=", "Lead"], - ["doc_type", "=", "Project"] + ["doc_type", "=", "Project"], + ["doc_type", "=", "Address"] ] } diff --git a/custom_ui/install.py b/custom_ui/install.py index d264307..ab74967 100644 --- a/custom_ui/install.py +++ b/custom_ui/install.py @@ -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": [ diff --git a/frontend/src/api.js b/frontend/src/api.js index 18c1ae0..86e37f1 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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) { diff --git a/frontend/src/components/calendar/jobs/SNWProjectCalendar.vue b/frontend/src/components/calendar/jobs/SNWProjectCalendar.vue index 152938a..9e24b16 100644 --- a/frontend/src/components/calendar/jobs/SNWProjectCalendar.vue +++ b/frontend/src/components/calendar/jobs/SNWProjectCalendar.vue @@ -23,9 +23,9 @@ variant="outlined" size="small" > - This Week + + This Week + -
-
{{ job.serviceType }}
-
{{ job.customer }}
-
- mdi-arrow-right -
+ :style="getJobStyle(job, day.date)" + draggable="true" + @click.stop="showEventDetails({ event: job })" + @dragstart="handleDragStart(job, $event)" + @dragend="handleDragEnd" + @mousedown="startResize($event, job, day.date)" + > + mdi-arrow-left +
+
{{ job.projectTemplate || job.serviceType }}
+
{{ stripAddress(job.address || job.jobAddress) }}
+ mdi-arrow-right +
+ + + + @@ -261,41 +268,32 @@ @dragend="handleDragEnd" > -
- - {{ service.priority.toUpperCase() }} - - ${{ service.estimatedCost.toLocaleString() }} -
+
{{ service.projectTemplate || service.serviceType }}
+
+ mdi-map-marker + {{ stripAddress(service.address || service.jobAddress) }} +
+
+ mdi-account + {{ service.customer }} +
-
{{ service.serviceType }}
-
{{ service.customer }}
+
+ {{ service.notes }} +
-
-
- mdi-clock - {{ formatDuration(service.duration) }} -
-
- -
- {{ service.notes }} -
- - - mdi-calendar-plus - Schedule - -
+ + mdi-calendar-plus + Schedule + + @@ -306,67 +304,17 @@ - - - - - {{ selectedEvent.title }} - - {{ selectedEvent.priority.toUpperCase() }} - - + + - -
-
- mdi-account - Customer: {{ selectedEvent.customer }} -
-
- mdi-map-marker - Address: {{ selectedEvent.address }} -
-
- mdi-wrench - Service Type: {{ selectedEvent.serviceType }} -
-
- mdi-calendar - Date: {{ selectedEvent.scheduledDate }} -
-
- mdi-clock - Time: {{ selectedEvent.scheduledTime }} ({{ - formatDuration(selectedEvent.duration) - }}) -
-
- mdi-account-hard-hat - Crew: {{ selectedEvent.foreman || "Not assigned" }} -
-
- mdi-currency-usd - Estimated Cost: ${{ - selectedEvent.estimatedCost.toLocaleString() - }} -
-
- mdi-note-text - Notes: {{ selectedEvent.notes }} -
-
-
- - - - Close - -
-
- - -
- {{ tooltip }} + +
+ ➜ Extend to next week
@@ -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; } \ No newline at end of file diff --git a/frontend/src/components/modals/JobDetailsModal.vue b/frontend/src/components/modals/JobDetailsModal.vue new file mode 100644 index 0000000..4c33beb --- /dev/null +++ b/frontend/src/components/modals/JobDetailsModal.vue @@ -0,0 +1,299 @@ + + + + + diff --git a/frontend/src/components/pages/Estimate.vue b/frontend/src/components/pages/Estimate.vue index e2e6a7a..75d0d50 100644 --- a/frontend/src/components/pages/Estimate.vue +++ b/frontend/src/components/pages/Estimate.vue @@ -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; }