From 644a3fc39831a30b497c2a4d6bc55c87d6636d92 Mon Sep 17 00:00:00 2001 From: maniamartial Date: Mon, 17 Nov 2025 12:32:06 +0300 Subject: [PATCH 1/6] feat: update the fields to avoid copy on duplicate --- .../doctype/product_movement/product_movement.json | 4 +++- .../doctype/service_order/service_order.json | 8 +++++--- .../doctype/service_order/service_order.py | 4 ++-- .../doctype/service_request/service_request.json | 6 +++++- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/beveren_fsm/field_service_management/doctype/product_movement/product_movement.json b/beveren_fsm/field_service_management/doctype/product_movement/product_movement.json index 3f7255f..ba4e42c 100644 --- a/beveren_fsm/field_service_management/doctype/product_movement/product_movement.json +++ b/beveren_fsm/field_service_management/doctype/product_movement/product_movement.json @@ -78,6 +78,8 @@ "fetch_from": "movement_type.destination", "fieldname": "destination", "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, "label": "Destination" } ], @@ -85,7 +87,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-11-12 04:12:53.341700", + "modified": "2025-11-17 04:21:43.163670", "modified_by": "Administrator", "module": "Field Service Management", "name": "Product Movement", diff --git a/beveren_fsm/field_service_management/doctype/service_order/service_order.json b/beveren_fsm/field_service_management/doctype/service_order/service_order.json index 0d85a86..fde64e1 100644 --- a/beveren_fsm/field_service_management/doctype/service_order/service_order.json +++ b/beveren_fsm/field_service_management/doctype/service_order/service_order.json @@ -774,7 +774,8 @@ { "fieldname": "service_total", "fieldtype": "Currency", - "label": "Service Total" + "label": "Service Total", + "read_only": 1 }, { "default": "0", @@ -796,7 +797,8 @@ { "fieldname": "spareparts_total", "fieldtype": "Currency", - "label": "SpareParts Total" + "label": "SpareParts Total", + "read_only": 1 }, { "allow_on_submit": 1, @@ -819,7 +821,7 @@ "link_fieldname": "custom_reference_service_document" } ], - "modified": "2025-11-12 03:31:42.698792", + "modified": "2025-11-17 04:08:15.600873", "modified_by": "Administrator", "module": "Field Service Management", "name": "Service Order", diff --git a/beveren_fsm/field_service_management/doctype/service_order/service_order.py b/beveren_fsm/field_service_management/doctype/service_order/service_order.py index e3fef51..3d905aa 100644 --- a/beveren_fsm/field_service_management/doctype/service_order/service_order.py +++ b/beveren_fsm/field_service_management/doctype/service_order/service_order.py @@ -8,8 +8,8 @@ from frappe.utils import flt, today LOCATION_STATUS_MAP = { - "delivered to customer": "Completed", - "deliver to customer": "Completed", # fallback for legacy value + "delivered to customer": "Review", + "deliver to customer": "Review", # fallback for legacy value "receive from vendor": "In Progress", "receive from customer": "In Progress", "received from customer": "In Progress", diff --git a/beveren_fsm/field_service_management/doctype/service_request/service_request.json b/beveren_fsm/field_service_management/doctype/service_request/service_request.json index 555ab68..d607288 100644 --- a/beveren_fsm/field_service_management/doctype/service_request/service_request.json +++ b/beveren_fsm/field_service_management/doctype/service_request/service_request.json @@ -310,6 +310,7 @@ "label": "Warranty Expiry Date" }, { + "depends_on": "amc_contract", "fetch_from": "serial_no.amc_expiry_date", "fetch_if_empty": 1, "fieldname": "amc_expiry_date", @@ -335,6 +336,7 @@ "fieldname": "repair_vendor", "fieldtype": "Link", "label": "Repair Vendor", + "no_copy": 1, "options": "Supplier" }, { @@ -343,6 +345,7 @@ "fieldname": "current_product_location", "fieldtype": "Link", "label": "Current Product Location", + "no_copy": 1, "options": "Product Location" }, { @@ -355,6 +358,7 @@ "fieldname": "product_movement", "fieldtype": "Table", "label": "Product Movement", + "no_copy": 1, "options": "Product Movement", "read_only": 1 } @@ -371,7 +375,7 @@ "link_fieldname": "service_request" } ], - "modified": "2025-11-17 03:13:18.909917", + "modified": "2025-11-17 04:17:34.797562", "modified_by": "Administrator", "module": "Field Service Management", "name": "Service Request", From 1cbd3ffec042f4ad301f7f1134cb1595287a752c Mon Sep 17 00:00:00 2001 From: maniamartial Date: Mon, 17 Nov 2025 12:51:45 +0300 Subject: [PATCH 2/6] feat: push teh date filter on teh map to the left side --- .../src/components/schedule/maps-view.tsx | 7 +- .../schedule/schedule-right-panel.tsx | 189 ++++++++++-------- 2 files changed, 109 insertions(+), 87 deletions(-) diff --git a/schedule/src/components/schedule/maps-view.tsx b/schedule/src/components/schedule/maps-view.tsx index 8a9732a..e39d3d6 100644 --- a/schedule/src/components/schedule/maps-view.tsx +++ b/schedule/src/components/schedule/maps-view.tsx @@ -14,7 +14,7 @@ interface MapsViewProps { statusFilter?: string; technicianSearch?: string; searchQuery?: string; - durationFilter?: "thisWeek" | "thisMonth" | "thisYear"; + durationFilter?: "today" | "thisWeek" | "thisMonth" | "thisYear"; } const STATUS_COLORS: Record = { @@ -88,6 +88,11 @@ export function MapsView({ // Otherwise use duration filter const today = new Date(); switch (durationFilter) { + case "today": + return { + start: startOfDay(today), + end: endOfDay(today), + }; case "thisWeek": return { start: startOfWeek(today, { weekStartsOn: 1 }), // Monday diff --git a/schedule/src/components/schedule/schedule-right-panel.tsx b/schedule/src/components/schedule/schedule-right-panel.tsx index ae18785..291c749 100644 --- a/schedule/src/components/schedule/schedule-right-panel.tsx +++ b/schedule/src/components/schedule/schedule-right-panel.tsx @@ -46,7 +46,7 @@ export function ScheduleRightPanel({ const [technicianSearch, setTechnicianSearch] = useState(""); const [calendarMonth, setCalendarMonth] = useState(selectedDate); const [mapSearchQuery, setMapSearchQuery] = useState(""); - const [mapDurationFilter, setMapDurationFilter] = useState<"thisWeek" | "thisMonth" | "thisYear" | "">("thisWeek"); + const [mapDurationFilter, setMapDurationFilter] = useState<"today" | "thisWeek" | "thisMonth" | "thisYear" | "">("thisWeek"); const [theme, setTheme] = useState<"light" | "dark">(() => { if (typeof window !== "undefined") { const savedTheme = localStorage.getItem("theme") as "light" | "dark" | null; @@ -115,6 +115,8 @@ export function ScheduleRightPanel({ onDateChange(newDate); }; + const shouldShowDateControls = viewType !== "maps" || mapDurationFilter === ""; + return (
{/* Section 1: View Type Switcher (Top) */} @@ -177,101 +179,116 @@ export function ScheduleRightPanel({
- {viewType === "calendar" ? ( - <> - -

- {format(calendarMonth, "MMMM yyyy")} -

- - - ) : ( - <> - - - - - - - { - if (date) { - onDateChange(date); - setDatePickerOpen(false); - } - }} - initialFocus - /> - - - - - )} -
- - {/* Right side controls */} -
- {/* Map Duration Filter - Only show in maps view */} {viewType === "maps" && ( - { + if (value === "date") { + setMapDurationFilter(""); + } else { + setMapDurationFilter(value); + } + }} + > - + - Selected Date + Select Date + Today This Week This Month This Year )} + {shouldShowDateControls && + (viewType === "calendar" ? ( + <> + +

+ {format(calendarMonth, "MMMM yyyy")} +

+ + + ) : ( + <> + + + + + + + { + if (date) { + onDateChange(date); + setDatePickerOpen(false); + } + }} + initialFocus + /> + + + + + ))} +
+ + {/* Right side controls */} +
{/* Map Search - Only show in maps view */} {viewType === "maps" && (
From 034d22c1c12182b887f84a0843ccfee356c09533 Mon Sep 17 00:00:00 2001 From: maniamartial Date: Mon, 17 Nov 2025 13:04:42 +0300 Subject: [PATCH 3/6] feat: Improve on teh serach in the map --- .../src/components/schedule/maps-view.tsx | 4 ++-- .../schedule/schedule-right-panel.tsx | 24 +++++-------------- 2 files changed, 8 insertions(+), 20 deletions(-) diff --git a/schedule/src/components/schedule/maps-view.tsx b/schedule/src/components/schedule/maps-view.tsx index e39d3d6..028589d 100644 --- a/schedule/src/components/schedule/maps-view.tsx +++ b/schedule/src/components/schedule/maps-view.tsx @@ -14,7 +14,7 @@ interface MapsViewProps { statusFilter?: string; technicianSearch?: string; searchQuery?: string; - durationFilter?: "today" | "thisWeek" | "thisMonth" | "thisYear"; + durationFilter?: "date" | "today" | "thisWeek" | "thisMonth" | "thisYear"; } const STATUS_COLORS: Record = { @@ -78,7 +78,7 @@ export function MapsView({ // Calculate date range based on duration filter or selected date const dateRange = useMemo(() => { // If no duration filter, use selected date (single day) - if (!durationFilter) { + if (!durationFilter || durationFilter === "date") { return { start: startOfDay(selectedDate), end: endOfDay(selectedDate), diff --git a/schedule/src/components/schedule/schedule-right-panel.tsx b/schedule/src/components/schedule/schedule-right-panel.tsx index 291c749..d9ecd6b 100644 --- a/schedule/src/components/schedule/schedule-right-panel.tsx +++ b/schedule/src/components/schedule/schedule-right-panel.tsx @@ -46,7 +46,7 @@ export function ScheduleRightPanel({ const [technicianSearch, setTechnicianSearch] = useState(""); const [calendarMonth, setCalendarMonth] = useState(selectedDate); const [mapSearchQuery, setMapSearchQuery] = useState(""); - const [mapDurationFilter, setMapDurationFilter] = useState<"today" | "thisWeek" | "thisMonth" | "thisYear" | "">("thisWeek"); + const [mapDurationFilter, setMapDurationFilter] = useState<"date" | "today" | "thisWeek" | "thisMonth" | "thisYear">("thisWeek"); const [theme, setTheme] = useState<"light" | "dark">(() => { if (typeof window !== "undefined") { const savedTheme = localStorage.getItem("theme") as "light" | "dark" | null; @@ -56,14 +56,6 @@ export function ScheduleRightPanel({ return "light"; }); - // Auto-clear duration filter when a specific date is selected (not today) - // Only clear if duration filter is currently set (not already empty) - useEffect(() => { - if (viewType === "maps" && !isToday(selectedDate) && mapDurationFilter) { - setMapDurationFilter(""); - } - }, [selectedDate, viewType]); - // Apply theme to document useEffect(() => { const root = document.documentElement; @@ -115,7 +107,7 @@ export function ScheduleRightPanel({ onDateChange(newDate); }; - const shouldShowDateControls = viewType !== "maps" || mapDurationFilter === ""; + const shouldShowDateControls = viewType !== "maps" || mapDurationFilter === "date"; return (
@@ -181,20 +173,16 @@ export function ScheduleRightPanel({
{viewType === "maps" && ( setSearchQuery(e.target.value)} - className="pl-9" - /> -
-
- {/* Table */}
diff --git a/schedule/src/components/schedule/schedule-right-panel.tsx b/schedule/src/components/schedule/schedule-right-panel.tsx index d9ecd6b..44aa43a 100644 --- a/schedule/src/components/schedule/schedule-right-panel.tsx +++ b/schedule/src/components/schedule/schedule-right-panel.tsx @@ -44,6 +44,7 @@ export function ScheduleRightPanel({ }: ScheduleRightPanelProps) { const [datePickerOpen, setDatePickerOpen] = useState(false); const [technicianSearch, setTechnicianSearch] = useState(""); + const [gridSearch, setGridSearch] = useState(""); const [calendarMonth, setCalendarMonth] = useState(selectedDate); const [mapSearchQuery, setMapSearchQuery] = useState(""); const [mapDurationFilter, setMapDurationFilter] = useState<"date" | "today" | "thisWeek" | "thisMonth" | "thisYear">("thisWeek"); @@ -289,8 +290,21 @@ export function ScheduleRightPanel({ /> )} - {/* Technician Search - Hidden in calendar and maps view */} - {viewType !== "calendar" && viewType !== "maps" && ( + {/* Grid appointment search (includes technicians) */} + {viewType === "grid" ? ( +
+ + setGridSearch(e.target.value)} + className="pl-9" + /> +
+ ) : ( + /* Technician search for gantt view */ + viewType !== "calendar" && + viewType !== "maps" && (
+ ) )} @@ -331,6 +346,7 @@ export function ScheduleRightPanel({ appointments={appointments} selectedDate={selectedDate} onAppointmentClick={onAppointmentSelect} + searchQuery={gridSearch} /> )} {viewType === "calendar" && ( diff --git a/schedule/src/components/schedule/settings-view.tsx b/schedule/src/components/schedule/settings-view.tsx index 5296f50..2da783c 100644 --- a/schedule/src/components/schedule/settings-view.tsx +++ b/schedule/src/components/schedule/settings-view.tsx @@ -152,15 +152,21 @@ export function SettingsView({ onBack }: SettingsViewProps) { const currentLanguage = languages.find(l => l.code === language) || languages[0]; return ( -
- {onBack && ( - - )} - -
-

Settings

+
+
+
+
+ {onBack && ( + + )} +
+
+

Settings

+
+
+
From 1738230c5eb8117c27b8940f9fe08d6c43cf5b91 Mon Sep 17 00:00:00 2001 From: maniamartial Date: Mon, 17 Nov 2025 17:47:20 +0300 Subject: [PATCH 6/6] feat: add margins --- .../field_service_management/api/schedule.py | 22 + .../api/service_request.py | 77 ++ .../field_service_management/page/__init__.py | 0 .../page/dispatch/__init__.py | 2 - .../page/dispatch/dispatch.css | 441 ------- .../page/dispatch/dispatch.html | 74 -- .../page/dispatch/dispatch.js | 549 --------- .../page/dispatch/dispatch.json | 18 - .../page/dispatch/dispatch.py | 232 ---- .../page/service_scheduling/__init__.py | 0 .../service_scheduling/service_scheduling.css | 114 -- .../service_scheduling.html | 16 - .../service_scheduling/service_scheduling.js | 1026 ----------------- .../service_scheduling.json | 18 - .../service_scheduling/service_scheduling.py | 164 --- .../src/components/layout/sidebar-menu.tsx | 23 +- .../src/components/schedule/gantt-view.tsx | 4 +- .../schedule/schedule-left-panel.tsx | 712 ++++++++---- .../schedule/service-order-detail-sheet.tsx | 194 ++++ .../service-request/service-requests-view.tsx | 336 ++++++ schedule/src/hooks/use-appointments.ts | 68 +- schedule/src/hooks/use-service-requests.ts | 60 + schedule/src/pages/schedule/schedule.tsx | 190 ++- schedule/src/pages/schedule/types.ts | 61 + schedule/src/store/schedule-store.ts | 26 +- 25 files changed, 1452 insertions(+), 2975 deletions(-) create mode 100644 beveren_fsm/field_service_management/api/service_request.py delete mode 100644 beveren_fsm/field_service_management/page/__init__.py delete mode 100644 beveren_fsm/field_service_management/page/dispatch/__init__.py delete mode 100644 beveren_fsm/field_service_management/page/dispatch/dispatch.css delete mode 100644 beveren_fsm/field_service_management/page/dispatch/dispatch.html delete mode 100644 beveren_fsm/field_service_management/page/dispatch/dispatch.js delete mode 100644 beveren_fsm/field_service_management/page/dispatch/dispatch.json delete mode 100644 beveren_fsm/field_service_management/page/dispatch/dispatch.py delete mode 100644 beveren_fsm/field_service_management/page/service_scheduling/__init__.py delete mode 100644 beveren_fsm/field_service_management/page/service_scheduling/service_scheduling.css delete mode 100644 beveren_fsm/field_service_management/page/service_scheduling/service_scheduling.html delete mode 100644 beveren_fsm/field_service_management/page/service_scheduling/service_scheduling.js delete mode 100644 beveren_fsm/field_service_management/page/service_scheduling/service_scheduling.json delete mode 100644 beveren_fsm/field_service_management/page/service_scheduling/service_scheduling.py create mode 100644 schedule/src/components/schedule/service-order-detail-sheet.tsx create mode 100644 schedule/src/components/service-request/service-requests-view.tsx create mode 100644 schedule/src/hooks/use-service-requests.ts diff --git a/beveren_fsm/field_service_management/api/schedule.py b/beveren_fsm/field_service_management/api/schedule.py index 177f495..1bf7d60 100644 --- a/beveren_fsm/field_service_management/api/schedule.py +++ b/beveren_fsm/field_service_management/api/schedule.py @@ -3,6 +3,28 @@ import frappe +@frappe.whitelist() +def get_unassigned_service_orders(limit=50): + filters = {"docstatus": 1} + assigned_orders = frappe.get_all( + "Service Appointment", + filters={"service_order": ["is", "set"], "docstatus": ["!=", 2]}, + pluck="service_order", + limit_page_length=0, + ) + if assigned_orders: + filters["name"] = ["not in", assigned_orders] + + orders = frappe.get_all( + "Service Order", + fields=["name", "customer", "priority", "posting_date", "status", "type"], + filters=filters, + order_by="posting_date desc", + limit_page_length=limit, + ) + return orders + + @frappe.whitelist() def create_appointment_from_api( posting_date, diff --git a/beveren_fsm/field_service_management/api/service_request.py b/beveren_fsm/field_service_management/api/service_request.py new file mode 100644 index 0000000..e85b1b4 --- /dev/null +++ b/beveren_fsm/field_service_management/api/service_request.py @@ -0,0 +1,77 @@ +import frappe +from frappe.utils import getdate + + +@frappe.whitelist() +def get_service_requests( + status=None, + start_date=None, + end_date=None, + search=None, + limit_page_length: int | None = 50, +): + filters = {"docstatus": ["!=", 2]} + + if status and status != "all": + filters["status"] = status + + if start_date and end_date: + filters["posting_date"] = ["between", [getdate(start_date), getdate(end_date)]] + elif start_date: + filters["posting_date"] = [">=", getdate(start_date)] + elif end_date: + filters["posting_date"] = ["<=", getdate(end_date)] + + or_filters = [] + if search: + search_term = f"%{search.strip()}%" + or_filters = [ + ["Service Request", "name", "like", search_term], + ["Service Request", "customer", "like", search_term], + ["Service Request", "serial_no", "like", search_term], + ["Service Request", "item_code", "like", search_term], + ] + + fields = [ + "name", + "subject", + "customer", + "status", + "posting_date", + "due_date", + "serial_no", + "item_code", + "item_name", + "current_product_location", + "description", + ] + + limit = int(limit_page_length) if limit_page_length else None + + requests = frappe.get_all( + "Service Request", + filters=filters, + or_filters=or_filters, + fields=fields, + order_by="posting_date desc", + limit_page_length=limit, + ) + + results = [] + for req in requests: + doc = frappe.get_doc("Service Request", req.name) + req["product_movement"] = [ + { + "name": movement.name, + "movement_type": movement.movement_type, + "destination": movement.destination, + "movement_date": movement.movement_date, + "linked_document_type": movement.linked_document_type, + "linked_document": movement.linked_document, + "handled_by": movement.handled_by, + } + for movement in doc.product_movement + ] + results.append(req) + + return results diff --git a/beveren_fsm/field_service_management/page/__init__.py b/beveren_fsm/field_service_management/page/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/beveren_fsm/field_service_management/page/dispatch/__init__.py b/beveren_fsm/field_service_management/page/dispatch/__init__.py deleted file mode 100644 index 2d638b2..0000000 --- a/beveren_fsm/field_service_management/page/dispatch/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -def get_context(context): - pass diff --git a/beveren_fsm/field_service_management/page/dispatch/dispatch.css b/beveren_fsm/field_service_management/page/dispatch/dispatch.css deleted file mode 100644 index 4c26eab..0000000 --- a/beveren_fsm/field_service_management/page/dispatch/dispatch.css +++ /dev/null @@ -1,441 +0,0 @@ - -/* Full height layout */ -html, body { - height: 100%; - margin: 0; - padding: 0; -} - -.dispatch-page { - font-family: "Open Sans", "Roboto", "Helvetica Neue", Arial, sans-serif; - background-color: #f5f7fa; - color: #444; - height: 100%; - display: flex; - flex-direction: column; -} - -/* Navbar */ -.dispatch-navbar { - display: flex; - justify-content: space-between; - align-items: center; - background-color: #fff; - border-bottom: 1px solid #e0e0e0; - box-shadow: 0 2px 3px rgba(0, 0, 0, 0.05); - height: 56px; - padding: 0 1rem; - flex-shrink: 0; -} - -.sidebar-toggle-btn { - background: transparent; - border: none; - color: #666; - font-size: 1.2rem; - cursor: pointer; - padding: 6px 8px; - border-radius: 4px; - transition: background-color 0.2s, color 0.2s; -} - -.sidebar-toggle-btn:hover { - background-color: #e8f2ff; - color: #0080ff; -} - -.nav-view-list { - list-style: none; - display: flex; - margin: 0; - padding: 0; -} - -.nav-view-list li { - margin-left: 0.75rem; -} - -.nav-view-btn { - background-color: transparent; - border: none; - font-weight: 500; - padding: 8px 12px; - border-radius: 4px; - color: #555; - cursor: pointer; - transition: background-color 0.2s, color 0.2s, border-bottom 0.2s; - outline: none; - font-size: 0.95rem; -} - -.nav-view-btn:hover { - background-color: #e8f2ff; - color: #0080ff; -} - -.nav-view-btn.active { - color: #0080ff; - border-bottom: 2px solid #0080ff; -} - -/* Body layout */ -.dispatch-body { - flex: 1; - display: flex; - height: calc(100% - 56px); - overflow: hidden; -} - -/* Sidebar with its own scroller */ -.dispatch-sidebar { - width: 300px; - background-color: #fff; - border-right: 1px solid #e0e0e0; - max-height: calc(100vh - 56px); - overflow-y: auto; - transition: width 0.3s ease, padding 0.3s ease; - position: relative; - box-shadow: 0 0 2px rgba(0, 0, 0, 0.06); - flex-shrink: 0; - padding: 0 0.75rem; -} - -.dispatch-sidebar.collapsed { - width: 0; - padding: 0; -} - -/* Sidebar header with more padding */ -.sidebar-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.75rem 0; - border-bottom: 1px solid #eee; - position: sticky; - top: 0; - background: #fff; - z-index: 10; -} - -.sidebar-heading-group { - display: flex; - align-items: center; -} - -.sidebar-heading { - margin: 0; - font-size: 1rem; - font-weight: 600; - color: #333; - display: inline-block; -} - -.sidebar-caret { - margin-left: 0.5rem; - cursor: pointer; - color: #666; - transition: color 0.2s; -} - -.sidebar-caret:hover { - color: #0080ff; -} - -.sidebar-icons { - display: flex; - align-items: center; - gap: 0.25rem; -} - -.sidebar-icon { - background: #fff; - border: 1px solid #ddd; - border-radius: 4px; - padding: 4px 6px; - cursor: pointer; - transition: background-color 0.2s, border-color 0.2s, color 0.2s; -} - -.sidebar-icon i { - font-size: 1rem; - color: #666; -} - -.sidebar-icon:hover { - background-color: #e8f2ff; - border-color: #0080ff; - color: #0080ff; -} - -/* Dropdown */ -.sidebar-dropdown { - position: absolute; - top: 60px; - left: 0; - right: 0; - background-color: #fff; - border: 1px solid #ddd; - box-shadow: 0 4px 8px rgba(0,0,0,0.1); - padding: 0.75rem; - display: none; - z-index: 100; -} - -.sidebar-dropdown.open { - display: block; -} - -.sidebar-dropdown-tabs { - display: flex; - gap: 1rem; - margin-bottom: 0.5rem; -} - -.dropdown-tab { - font-weight: 500; - cursor: pointer; - color: #555; - position: relative; -} - -.dropdown-tab.active { - color: #0080ff; - border-bottom: 2px solid #0080ff; -} - -.sidebar-dropdown-search { - margin-bottom: 0.75rem; -} - -.sidebar-dropdown-search input { - width: 100%; - padding: 6px 8px; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 0.9rem; -} - -.sidebar-inline-search { - display: none; - margin: 0.75rem 0; -} - -.sidebar-inline-search.visible { - display: block; -} - -.sidebar-inline-search input { - width: 100%; - padding: 6px 8px; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 0.9rem; -} - -/* Sidebar list items */ -.sidebar-list { - list-style: none; - margin: 0.5rem 0 0 0; - padding: 0; -} - -.sidebar-list li { - margin-bottom: 0.5rem; -} - -.sidebar-list li a { - display: block; - color: #444; - text-decoration: none; - padding: 6px 8px; - border-radius: 4px; - transition: background-color 0.2s; -} - -.sidebar-list li a:hover { - background-color: #f0f4f8; - color: #0080ff; -} - -.small-text { - font-size: 0.85rem; - color: #777; -} - -/* Pill for priority/status, smaller and rounder */ -.pill { - display: inline-block; - color: #fff; - padding: 2px 6px; - border-radius: 12px; - font-size: 0.75rem; - margin-top: 2px; -} - -/* Main content */ -.dispatch-content { - flex: 1; - padding: 1rem; - overflow-y: auto; - display: flex; - flex-direction: column; - min-width: 0; -} - -.dispatch-view-content { - background-color: #fff; - border: 1px solid #e0e0e0; - border-radius: 6px; - flex: 1; - padding: 1.5rem; - box-shadow: 0 2px 3px rgba(0, 0, 0, 0.04); -} - -.placeholder-content { - text-align: center; - margin-top: 2rem; -} - -/* The tooltip for right-click on sidebar items */ -.sidebar-tooltip { - position: absolute; - background: #ffffff; - border: 1px solid #ccc; - border-radius: 6px; - box-shadow: 0 6px 12px rgba(0,0,0,0.15); - padding: 0.75rem; - width: 300px; - display: none; - z-index: 9999; -} - -.tooltip-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.5rem; -} - -.tooltip-buttons .btn { - margin-left: 4px; - padding: 4px 6px; - font-size: 0.75rem; -} - -.tooltip-body p { - margin: 0.25rem 0; - font-size: 0.85rem; -} - -/* Gantt view */ -.gantt-view .schedule-grid-container { - position: relative; - width: 100%; - overflow-x: auto; - overflow-y: hidden; -} - -/* Timeline header, time labels, etc. */ -.gantt-view .timeline-header { - position: relative; - margin-left: 20%; - width: 80%; - height: 40px; - border-bottom: 1px solid #ddd; -} - -.gantt-view .time-label { - position: absolute; - transform: translateX(-50%); - font-size: 12px; - color: #555; -} - -/* Technician rows */ -.gantt-view .technician-row { - display: flex; - height: 50px; - border-bottom: 1px solid #ddd; -} - -.gantt-view .technician-name { - width: 20%; - background: #f0f0f0; - text-align: center; - line-height: 50px; - border-right: 1px solid #ddd; -} - -/* Timeline cell */ -.gantt-view .timeline-cell { - width: 80%; - position: relative; -} - -/* Timeline background */ -.gantt-view .timeline-background { - position: absolute; - left: 20%; - top: 40px; - width: 80%; - bottom: 0; - pointer-events: none; -} - -/* Schedule event styling */ -.gantt-view .schedule-event { - background: #007bff; - color: white; - padding: 2px; - border-radius: 3px; - cursor: grab; - overflow: hidden; - font-size: 10px; - text-align: center; - z-index: 10; - opacity: 0.9; - display: flex; - align-items: center; - justify-content: center; - position: absolute; - top: 0; - height: 100%; -} - -/* For resizing and dragging */ -.gantt-view .schedule-event.dragging { - border: 2px dashed #ff9900; - opacity: 0.7; -} - -.gantt-view .resize-handle { - position: absolute; - background: rgba(0, 0, 0, 0.3); - top: 0; - bottom: 0; - cursor: ew-resize; - z-index: 20; - width: 3px; -} - -.gantt-view .left-handle { - left: 0; -} - -.gantt-view .right-handle { - right: 0; -} - -/* Date table styling */ -.gantt-view .selected-date { - background-color: #343a40 !important; - color: white !important; - font-weight: bold; - border-radius: 4px; -} - -.gantt-view #date-table th { - text-align: center; - vertical-align: middle; -} diff --git a/beveren_fsm/field_service_management/page/dispatch/dispatch.html b/beveren_fsm/field_service_management/page/dispatch/dispatch.html deleted file mode 100644 index 71a2af8..0000000 --- a/beveren_fsm/field_service_management/page/dispatch/dispatch.html +++ /dev/null @@ -1,74 +0,0 @@ -
- - - -
- - - - -
-
-
-

Welcome to Dispatch

-

Select a view (Gantt, Calendar, or Map) to get started.

-
-
-
-
- - - -
diff --git a/beveren_fsm/field_service_management/page/dispatch/dispatch.js b/beveren_fsm/field_service_management/page/dispatch/dispatch.js deleted file mode 100644 index 4db2b2b..0000000 --- a/beveren_fsm/field_service_management/page/dispatch/dispatch.js +++ /dev/null @@ -1,549 +0,0 @@ -frappe.pages["dispatch"].on_page_load = function (wrapper) { - frappe.ui.make_app_page({ - parent: wrapper, - single_column: true, - }); - - $(wrapper).html(frappe.render_template("dispatch")); - - frappe.require( - [ - "assets/beveren_fsm/js/dispatch/gantt.js", - // Calendar file - // Map file - ], - () => { - new DispatchController(); - } - ); -}; - -class DispatchController { - constructor() { - this.sidebarMode = "Service Order"; - this.sidebarCollapsed = false; - this.currentView = "gantt"; - this.cachedDocs = {}; - - // Flags for view initialization - this.viewInitialized = { - gantt: false, - calendar: false, - map: false, - }; - - this.setup_navbar_buttons(); - this.setup_sidebar_header_events(); - - // Create containers for each view - this.createViewContainers(); - - // Load sidebar items for default mode - this.loadSidebarItems("Service Order"); - - // Initialize default view (Gantt) - this.switchView("gantt"); - } - - createViewContainers() { - // Create and append containers for each view; they all reside in #view-content. - const viewContent = $("#view-content"); - viewContent.empty(); - - this.$ganttView = $(` - - `); - - this.$calendarView = $(` - - `); - this.$mapView = $(` - - `); - - viewContent.append(this.$ganttView, this.$calendarView, this.$mapView); - } - - // NAVBAR - setup_navbar_buttons() { - $("#sidebar-toggle-btn").on("click", () => { - this.toggleSidebar(); - }); - $("#btn-gantt").on("click", () => { - this.switchView("gantt"); - }); - $("#btn-calendar").on("click", () => { - this.switchView("calendar"); - }); - $("#btn-map").on("click", () => { - this.switchView("map"); - }); - } - - toggleSidebar() { - this.sidebarCollapsed = !this.sidebarCollapsed; - const $sidebar = $("#dispatch-sidebar"); - if (this.sidebarCollapsed) { - $sidebar.addClass("collapsed"); - $("#sidebar-toggle-btn i") - .removeClass("fa-angle-double-left") - .addClass("fa-angle-double-right"); - } else { - $sidebar.removeClass("collapsed"); - $("#sidebar-toggle-btn i") - .removeClass("fa-angle-double-right") - .addClass("fa-angle-double-left"); - } - } - - // Instead of re-building views on every switch, we hide all and then show the target view. - switchView(view) { - $(".nav-view-btn").removeClass("active"); - $(`#btn-${view}`).addClass("active"); - this.currentView = view; - - // Hide all views - $(".view-container").hide(); - - if (view === "gantt") { - this.$ganttView.show(); - if (!this.viewInitialized.gantt) { - // Load Gantt from separate file (gantt.js) - init_gantt("#gantt-page-body"); - this.viewInitialized.gantt = true; - } - } else if (view === "calendar") { - this.$calendarView.show(); - if (!this.viewInitialized.calendar) { - init_calendar_code("#calendar-page-body"); - this.viewInitialized.calendar = true; - } - } else if (view === "map") { - this.$mapView.show(); - if (!this.viewInitialized.map) { - // Initialize your Map view if needed. - this.viewInitialized.map = true; - } - } - } - - // SIDEBAR and other methods below remain largely unchanged... - setup_sidebar_header_events() { - $("#sidebar-dropdown-toggle").on("click", () => { - $("#sidebar-dropdown").toggleClass("open"); - }); - - $("#sidebar-add-icon").on("click", () => { - this.open_create_schedule_dialog(); - }); - - $("#sidebar-search-toggle").on("click", () => { - $("#sidebar-inline-search").toggleClass("visible"); - if ($("#sidebar-inline-search").hasClass("visible")) { - $("#sidebar-search-input").focus(); - } - }); - - $("#sidebar-refresh").on("click", () => { - this.loadSidebarItems(this.sidebarMode, true); - }); - - $(".dropdown-tab").on("click", (e) => { - let mode = $(e.currentTarget).data("mode"); - this.switchSidebarMode(mode); - - $(".dropdown-tab").removeClass("active"); - $(e.currentTarget).addClass("active"); - }); - - $("#sidebar-search-input").on("keyup", () => { - let val = $("#sidebar-search-input").val().toLowerCase(); - this.filterSidebarDocs(val); - }); - } - - switchSidebarMode(mode) { - this.sidebarMode = mode; - $("#sidebar-selected-mode").text(mode); - $("#sidebar-dropdown").removeClass("open"); - this.loadSidebarItems(mode, true); - - let placeholderText = - mode === "Service Order" - ? "Search Service Orders..." - : "Search Service Appointments..."; - $("#sidebar-search-input").attr("placeholder", placeholderText); - } - - loadSidebarItems(mode, forceRefresh = false) { - let $sidebarList = $("#sidebar-list"); - $sidebarList.empty(); - - frappe.call({ - method: - "beveren_fsm.field_service_management.page.dispatch.dispatch.get_sidebar_data", - args: { mode }, - callback: (r) => { - if (!r.exc) { - this.cachedDocs[mode] = r.message || []; - this.renderSidebarDocs(this.cachedDocs[mode], mode); - } - }, - }); - } - - renderSidebarDocs(docs, mode) { - let $sidebarList = $("#sidebar-list"); - $sidebarList.empty(); - - docs.forEach((doc) => { - let pillHtml = this.getPillHtml(doc, mode); - let moreInfoHtml = this.getMoreInfoHtml(doc, mode); - - let li = $(` - - `); - $sidebarList.append(li); - }); - - this.setupSidebarRightClick(); - } - - filterSidebarDocs(searchVal) { - let filtered = (this.cachedDocs[this.sidebarMode] || []).filter((doc) => { - let docStr = JSON.stringify(doc).toLowerCase(); - return docStr.includes(searchVal); - }); - this.renderSidebarDocs(filtered, this.sidebarMode); - } - - setupSidebarRightClick() { - $(document) - .off("click.sidebarTooltip") - .on("click.sidebarTooltip", () => { - $("#sidebar-tooltip").hide(); - }); - - $(".sidebar-doc-item") - .off("contextmenu") - .on("contextmenu", (e) => { - e.preventDefault(); - let $li = $(e.currentTarget); - let docStr = $li.attr("data-doc"); - let doc = JSON.parse(docStr || "{}"); - let mode = $li.attr("data-mode"); - this.showSidebarTooltip(e.pageX, e.pageY, doc, mode); - }); - } - - getPillHtml(doc, mode) { - if (mode === "Service Order") { - let priority = (doc.priority || "").toLowerCase(); - let color = "#6c757d"; - if (priority === "high") color = "#dc3545"; - if (priority === "medium") color = "#ffc107"; - if (priority === "low") color = "#28a745"; - return `${ - doc.priority || "" - }`; - } else { - let status = (doc.status || "").toLowerCase(); - let color = "#6c757d"; - if (status === "scheduled") color = "#007bff"; - if (status === "in progress") color = "#ffc107"; - if (status === "completed") color = "#28a745"; - if (status === "cancelled") color = "#dc3545"; - if (status === "dispatched") color = "#17a2b8"; - return `${ - doc.status || "" - }`; - } - } - - getMoreInfoHtml(doc, mode) { - if (mode === "Service Order") { - let date = doc.posting_date || ""; - let customer = doc.customer || ""; - return `${date} - ${customer}
`; - } else { - let date = doc.posting_date || ""; - let start = doc.scheduled_start_datetime - ? doc.scheduled_start_datetime.split(" ")[1] - : ""; - let end = doc.scheduled_finish_datetime - ? doc.scheduled_finish_datetime.split(" ")[1] - : ""; - return `${date} - ${start} to ${end}
`; - } - } - - showSidebarTooltip(x, y, doc, mode) { - let tooltip = $("#sidebar-tooltip"); - tooltip.empty().show(); - - let itemsIcon = ``; - let techsIcon = ``; - let addIcon = ``; - - let itemsBtn = ``; - let techsBtn = - mode === "Service Appointment" - ? `` - : ``; - let addBtn = ``; - if (mode === "Service Order") { - if ((doc.status || "").toLowerCase() !== "completed") { - addBtn = ``; - } - } else { - addBtn = ``; - } - - let headerHtml = ` -
- ${doc.name} -
- ${itemsBtn} - ${techsBtn} - ${addBtn} -
-
- `; - - let bodyHtml = `
`; - if (mode === "Service Order") { - bodyHtml += ` -

Customer: ${doc.customer || "N/A"}

-

Date: ${doc.posting_date || ""}

-

Status: ${doc.status || ""}

-

Priority: ${doc.priority || ""}

- `; - } else { - bodyHtml += ` -

Posting Date: ${doc.posting_date || ""}

-

Status: ${doc.status || ""}

-

Start: ${ - doc.scheduled_start_datetime || "" - }

-

Finish: ${ - doc.scheduled_finish_datetime || "" - }

- `; - } - bodyHtml += `
`; - - tooltip.html(headerHtml + bodyHtml); - - let tooltipWidth = tooltip.outerWidth(); - let tooltipHeight = tooltip.outerHeight(); - let finalX = x + 10; - let finalY = y; - if (finalX + tooltipWidth > $(window).width()) { - finalX = x - tooltipWidth - 10; - } - if (finalY + tooltipHeight > $(window).height()) { - finalY = $(window).height() - tooltipHeight - 20; - } - tooltip.css({ left: finalX + "px", top: finalY + "px" }); - - tooltip.find("[data-action='view-items']").on("click", () => { - this.showItemsDialog(doc, mode); - }); - if (mode === "Service Appointment") { - tooltip.find("[data-action='view-techs']").on("click", () => { - this.showTechniciansDialog(doc); - }); - } - tooltip.find("[data-action='add-item']").on("click", () => { - if (mode === "Service Order") { - this.create_event_for_order(doc); - } else { - frappe.confirm("Create an invoice for this appointment?", () => { - frappe.msgprint("Proceed with invoice creation..."); - }); - } - }); - } - - showItemsDialog(doc) { - let dialog = new frappe.ui.Dialog({ - title: "Service Items", - fields: [ - { - fieldname: "service_items", - fieldtype: "Table", - label: __("Service Items"), - in_place_edit: true, - reqd: 1, - fields: [ - { - fieldname: "item_code", - label: __("Item"), - fieldtype: "Link", - options: "Item", - reqd: 1, - in_list_view: 1, - }, - { - fieldname: "qty", - label: __("Qty"), - fieldtype: "Data", - reqd: 1, - in_list_view: 1, - }, - { - fieldname: "invoice_status", - label: __("Invoice Status"), - fieldtype: "Data", - reqd: 1, - in_list_view: 1, - }, - ], - }, - ], - primary_action_label: "Close", - primary_action: () => dialog.hide(), - }); - - let tableField = dialog.get_field("service_items"); - tableField.df.data = doc.items; - tableField.grid.refresh(); - - dialog.show(); - } - - showTechniciansDialog(doc) { - let dialog = new frappe.ui.Dialog({ - title: "Technicians", - fields: [ - { - fieldname: "service_technicians", - fieldtype: "Table", - label: __("Service Technicians"), - options: "Service Technician Item", - in_place_edit: true, - reqd: 1, - fields: [ - { - fieldname: "service_technician", - label: __("Item"), - fieldtype: "Link", - options: "Item", - reqd: 1, - in_list_view: 1, - }, - { - fieldname: "full_name", - label: __("Full Name"), - fieldtype: "Data", - fetch_from: "service_technician.full_name", - reqd: 1, - in_list_view: 1, - }, - ], - }, - ], - primary_action_label: "Close", - primary_action: () => dialog.hide(), - }); - - let tableField = dialog.get_field("service_technicians"); - tableField.df.data = doc.service_technicians; - tableField.grid.refresh(); - - dialog.show(); - } - - create_event_for_order(orderDoc = null) { - if (!orderDoc) { - frappe.prompt( - [ - { - fieldname: "service_order", - label: "Service Order", - fieldtype: "Link", - options: "Service Order", - reqd: 1, - }, - ], - (values) => { - this.open_create_schedule_dialog(values.service_order); - }, - "Create Schedule", - "Create" - ); - } else { - this.open_create_schedule_dialog(orderDoc.name); - } - } - - open_create_schedule_dialog(OrderName = "") { - let selectedDate = frappe.datetime.get_today(); - let d = new frappe.ui.Dialog({ - title: "Create Schedule", - fields: [ - { - fieldname: "service_order", - fieldtype: "Link", - options: "Service Order", - label: "Service Order", - default: OrderName, - reqd: 1, - }, - { fieldtype: "Column Break" }, - { - fieldname: "selected_date", - fieldtype: "Date", - label: "Selected Date", - default: selectedDate, - }, - { - fieldname: "start_time", - fieldtype: "Time", - label: "Start Time", - default: "09:00", - }, - { - fieldname: "finish_time", - fieldtype: "Time", - label: "Finish Time", - default: "10:00", - }, - ], - primary_action_label: "Schedule & Dispatch", - primary_action: (values) => { - let scheduledStart = values.selected_date + " " + values.start_time; - let scheduledFinish = values.selected_date + " " + values.finish_time; - create_appointment( - values.selected_date, - values.service_order, - scheduledStart, - scheduledFinish, - "TECH-0001", - (r) => { - frappe.msgprint("Appointment created for " + values.service_order); - } - ); - d.hide(); - }, - }); - d.show(); - } -} diff --git a/beveren_fsm/field_service_management/page/dispatch/dispatch.json b/beveren_fsm/field_service_management/page/dispatch/dispatch.json deleted file mode 100644 index c51fb02..0000000 --- a/beveren_fsm/field_service_management/page/dispatch/dispatch.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "content": null, - "creation": "2025-02-17 20:56:52.652328", - "docstatus": 0, - "doctype": "Page", - "idx": 0, - "modified": "2025-02-17 20:56:52.652328", - "modified_by": "Administrator", - "module": "Field Service Management", - "name": "dispatch", - "owner": "Administrator", - "page_name": "dispatch", - "roles": [], - "script": null, - "standard": "Yes", - "style": null, - "system_page": 0 -} diff --git a/beveren_fsm/field_service_management/page/dispatch/dispatch.py b/beveren_fsm/field_service_management/page/dispatch/dispatch.py deleted file mode 100644 index a6dc64e..0000000 --- a/beveren_fsm/field_service_management/page/dispatch/dispatch.py +++ /dev/null @@ -1,232 +0,0 @@ -import frappe -from frappe.utils import get_datetime, getdate - - -@frappe.whitelist() -def get_schedule_data(selected_date, all_dates=False): - technicians = frappe.get_all("Service Technician", fields=["name", "full_name"]) - - selected_date = getdate(selected_date) - - if all_dates: - appointments = frappe.get_all( - "Service Appointment", - fields=[ - "name", - "posting_date", - "service_order", - "scheduled_start_datetime", - "scheduled_finish_datetime", - "status", - ], - filters={"docstatus": 1}, - order_by="posting_date desc", - ) - else: - appointments = frappe.get_all( - "Service Appointment", - filters={ - "posting_date": selected_date, - }, - fields=[ - "name", - "posting_date", - "service_order", - "scheduled_start_datetime", - "scheduled_finish_datetime", - "status", - ], - ) - - meta = frappe.get_meta("Service Appointment") - appointments_with_technicians = [] - for appointment in appointments: - appointment_doc = frappe.get_doc("Service Appointment", appointment.name) - service_technicians = [ - frappe.get_doc("Service Technician", tech.service_technician).name - for tech in appointment_doc.service_technicians - ] - start_time = get_datetime(appointment_doc.scheduled_start_datetime) - finish_time = get_datetime(appointment_doc.scheduled_finish_datetime) - - state_color = None - if meta.states: - for s in meta.states: - if s.title == appointment_doc.status: - state_color = s.color - break - - structured_appointment = { - "name": appointment.name, - "posting_date": appointment.posting_date, - "service_order": appointment.service_order, - "start_time": start_time.strftime("%H:%M"), - "finish_time": finish_time.strftime("%H:%M"), - "service_technicians": service_technicians, - "status": appointment_doc.status, - "color": state_color, - } - appointments_with_technicians.append(structured_appointment) - - return {"technicians": technicians, "appointments": appointments_with_technicians} - - -@frappe.whitelist() -def create_service_appointment( - selected_date, service_order, scheduled_start_datetime, scheduled_finish_datetime, technician, dispatch=0 -): - from frappe.utils import get_datetime, getdate - - scheduled_start_datetime = get_datetime(scheduled_start_datetime) - scheduled_finish_datetime = get_datetime(scheduled_finish_datetime) - - appointment_list = frappe.get_all( - "Service Appointment", - filters={ - "posting_date": getdate(selected_date), - "service_order": service_order, - "scheduled_start_datetime": scheduled_start_datetime, - "scheduled_finish_datetime": scheduled_finish_datetime, - }, - limit=100, - ) - - found = None - for app in appointment_list: - app_doc = frappe.get_doc("Service Appointment", app.name) - for row in app_doc.get("service_technicians"): - if row.service_technician == technician: - found = app_doc - break - if found: - break - - if found: - appointment = found - else: - service_order_doc = frappe.get_doc("Service Order", service_order) - appointment = frappe.new_doc("Service Appointment") - appointment.posting_date = getdate(selected_date) - appointment.service_order = service_order - appointment.scheduled_start_datetime = scheduled_start_datetime - appointment.scheduled_finish_datetime = scheduled_finish_datetime - appointment.customer = service_order_doc.customer - for item in service_order_doc.get("items") or []: - appointment.append( - "items", - { - "item_code": item.item_code, - "qty": item.qty, - "uom": item.uom, - "invoice_status": item.invoice_status, - }, - ) - appointment.append("service_technicians", {"service_technician": technician}) - appointment.save() - appointment.submit() - # Dispatch - if int(dispatch) == 1: - appointment.status = "Dispatched" - appointment.save() - - meta = frappe.get_meta("Service Appointment") - state_color = None - if meta.states: - for s in meta.states: - if s.title == appointment.status: - state_color = s.color - break - - return {"name": appointment.name, "status": appointment.status, "color": state_color} - - -@frappe.whitelist() -def update_service_appointment( - appointment_id, - selected_date, - service_order, - scheduled_start_datetime, - scheduled_finish_datetime, - technician, -): - from frappe.utils import get_datetime, getdate - - appointment = frappe.get_doc("Service Appointment", appointment_id) - - appointment.update( - { - "posting_date": getdate(selected_date), - "service_order": service_order, - "scheduled_start_datetime": get_datetime(scheduled_start_datetime), - "scheduled_finish_datetime": get_datetime(scheduled_finish_datetime), - } - ) - - appointment.set("service_technicians", []) - appointment.append("service_technicians", {"service_technician": technician}) - - appointment.save() - return appointment.name - - -@frappe.whitelist() -def start_work(appointment_id): - appointment = frappe.get_doc("Service Appointment", appointment_id) - if appointment.status == "Dispatched": - appointment.status = "In Progress" - appointment.save(ignore_permissions=True) - return appointment.name - else: - frappe.throw("Appointment is not in Dispatched status. Cannot start work.") - - -@frappe.whitelist() -def get_sidebar_data(mode): - if mode == "Service Order": - orders = frappe.get_all( - "Service Order", - fields=["name", "customer", "priority", "posting_date", "status"], - filters={"docstatus": 1}, - order_by="posting_date desc", - limit=30, - ) - for o in orders: - items = frappe.get_all( - "Service Order Item", - filters={"parent": o.name}, - fields=["item_code", "item_name", "qty", "uom", "invoice_status"], - ) - o["items"] = items - return orders - - elif mode == "Service Appointment": - appointments = frappe.get_all( - "Service Appointment", - fields=[ - "name", - "posting_date", - "status", - "scheduled_start_datetime", - "scheduled_finish_datetime", - ], - filters={"docstatus": 1}, - order_by="scheduled_start_datetime desc", - limit=100, - ) - for a in appointments: - items = frappe.get_all( - "Service Order Item", - filters={"parent": a.name}, - fields=["item_code", "item_name", "qty", "uom", "invoice_status"], - ) - techs = frappe.get_all( - "Service Technician Item", - filters={"parent": a.name}, - fields=["service_technician", "full_name"], - ) - a["items"] = items - a["service_technicians"] = techs - return appointments - - else: - return [] diff --git a/beveren_fsm/field_service_management/page/service_scheduling/__init__.py b/beveren_fsm/field_service_management/page/service_scheduling/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/beveren_fsm/field_service_management/page/service_scheduling/service_scheduling.css b/beveren_fsm/field_service_management/page/service_scheduling/service_scheduling.css deleted file mode 100644 index e2a4a41..0000000 --- a/beveren_fsm/field_service_management/page/service_scheduling/service_scheduling.css +++ /dev/null @@ -1,114 +0,0 @@ -/* Container for the entire schedule grid */ -.schedule-grid-container { - position: relative; - width: 100%; - } - - /* Timeline header that holds the time labels */ - .timeline-header { - position: relative; - margin-left: 20%; - width: 80%; - height: 40px; - border-bottom: 1px solid #ddd; - } - - /* Time labels in the timeline header */ - .time-label { - position: absolute; - transform: translateX(-50%); - font-size: 12px; - color: #555; - } - - /* Technician rows container */ - .technician-rows { } - - /* Each technician row */ - .technician-row { - display: flex; - height: 50px; - border-bottom: 1px solid #ddd; - } - - /* Technician name column */ - .technician-name { - width: 20%; - background: #f0f0f0; - text-align: center; - line-height: 50px; - border-right: 1px solid #ddd; - } - - /* Timeline cell */ - .timeline-cell { - width: 80%; - position: relative; - } - - /* Timeline background */ - .timeline-background { - position: absolute; - left: 20%; - top: 40px; - width: 80%; - bottom: 0; - pointer-events: none; - } - - /* Schedule event styling */ - .schedule-event { - background: #007bff; - color: white; - padding: 2px; - border-radius: 3px; - cursor: grab; - overflow: hidden; - font-size: 10px; - text-align: center; - z-index: 10; - opacity: 0.9; - display: flex; - align-items: center; - justify-content: center; - position: absolute; - top: 0; - height: 100%; - } - - /* Styling for the selected date */ - .selected-date { - background-color: #343a40 !important; - color: white !important; - font-weight: bold; - border-radius: 4px; - } - - .schedule-event.dragging { - border: 2px dashed #ff9900; - opacity: 0.7; - } - - #date-table th { - text-align: center; - vertical-align: middle; - } - - /* Resize handles (moved from inline to CSS) */ - .resize-handle { - position: absolute; - background: rgba(0, 0, 0, 0.3); - top: 0; - bottom: 0; - cursor: ew-resize; - z-index: 20; - width: 3px; - } - - .left-handle { - left: 0; - } - - .right-handle { - right: 0; - } diff --git a/beveren_fsm/field_service_management/page/service_scheduling/service_scheduling.html b/beveren_fsm/field_service_management/page/service_scheduling/service_scheduling.html deleted file mode 100644 index 27ce433..0000000 --- a/beveren_fsm/field_service_management/page/service_scheduling/service_scheduling.html +++ /dev/null @@ -1,16 +0,0 @@ -
-
-

-
- - -
-
-
-
-
- -
-
-
- diff --git a/beveren_fsm/field_service_management/page/service_scheduling/service_scheduling.js b/beveren_fsm/field_service_management/page/service_scheduling/service_scheduling.js deleted file mode 100644 index c2ce0d1..0000000 --- a/beveren_fsm/field_service_management/page/service_scheduling/service_scheduling.js +++ /dev/null @@ -1,1026 +0,0 @@ -// Global constants and variables -const START_TIME_MINUTES = 420; // 07:00 in minutes -const END_TIME_MINUTES = 1140; // 19:00 in minutes -const TOTAL_WORKING_MINUTES = END_TIME_MINUTES - START_TIME_MINUTES; // 720 minutes - -var status_colors = { - Scheduled: "#007bff", - Rescheduled: "#28a745", - Completed: "#6c757d", - Cancelled: "#dc3545", -}; - -let isResizing = false; -let justResized = false; -let currentSelectedDate = frappe.datetime.get_today(); - -// Mobile detection and date range helpers -function isMobile() { - return window.innerWidth < 768; -} - -function generate_date_range(selected_date) { - let dates = []; - if (isMobile()) { - // Show 5 days: 2 before, current, 2 after. - for (let i = -2; i <= 2; i++) { - dates.push(frappe.datetime.add_days(selected_date, i)); - } - } else { - // Default range: from -10 to +9 days (20 days) - let today_index = 10; - for (let i = -today_index; i <= 20 - today_index - 1; i++) { - dates.push(frappe.datetime.add_days(selected_date, i)); - } - } - return dates; -} - -function openDatePicker() { - let d = new frappe.ui.Dialog({ - title: "Select Date", - fields: [ - { - fieldname: "selected_date", - fieldtype: "Date", - label: "Date", - default: currentSelectedDate, - }, - ], - primary_action_label: "Go", - primary_action: (values) => { - currentSelectedDate = values.selected_date; - load_schedule(values.selected_date); - d.hide(); - }, - }); - d.show(); -} - -// Helper functions -const timeStringToMinutes = (timeStr) => { - const parts = timeStr.split(":"); - return parseInt(parts[0]) * 60 + parseInt(parts[1]); -}; - -const minutesToTimeString = (minutes) => { - const hrs = Math.floor(minutes / 60); - const mins = minutes % 60; - return ("0" + hrs).slice(-2) + ":" + ("0" + mins).slice(-2); -}; - -const roundToNearestTen = (mins) => Math.round(mins / 10) * 10; - -const formatDatetime = (date, timeStr) => { - const parts = timeStr.split(":"); - if (parts.length === 2) { - return date + " " + timeStr + ":00"; - } - return date + " " + timeStr; -}; - -const calculatePosition = (startMins, endMins) => { - const leftPercent = - ((startMins - START_TIME_MINUTES) / TOTAL_WORKING_MINUTES) * 100; - const widthPercent = ((endMins - startMins) / TOTAL_WORKING_MINUTES) * 100; - return { leftPercent, widthPercent }; -}; - -const debounce = (func, delay) => { - let timeout; - return function (...args) { - clearTimeout(timeout); - timeout = setTimeout(() => func.apply(this, args), delay); - }; -}; - -// Transform a service order number. -// For example, "SVC-APP-2025-00043" becomes "ORD-00043". -function transformServiceOrder(order) { - if (!order) return ""; - let parts = order.split("-"); - if (parts.length >= 1) { - return "ORD-" + parts[parts.length - 1]; - } - return order; -} - -// Consolidated drag & drop functions -function drag(event) { - event.dataTransfer.setData("text", event.target.outerHTML); - event.target.classList.add("dragging"); -} - -function dragEnd(event) { - event.target.classList.remove("dragging"); -} - -const allowDrop = (event) => { - event.preventDefault(); -}; - -function is_overlapping(technician, startMins, endMins) { - let overlapping = false; - $(`.technician-row[data-tech='${technician}'] .schedule-event`).each( - function () { - const eventStart = timeStringToMinutes($(this).attr("data-start")); - const eventEnd = timeStringToMinutes($(this).attr("data-end")); - if (startMins < eventEnd && endMins > eventStart) { - overlapping = true; - return false; - } - } - ); - return overlapping; -} - -function is_overlapping_excluding(technician, startMins, endMins, excludeId) { - let overlapping = false; - $(`.technician-row[data-tech='${technician}'] .schedule-event`).each( - function () { - if ($(this).data("appointment") == excludeId) return; - const eventStart = timeStringToMinutes($(this).attr("data-start")); - const eventEnd = timeStringToMinutes($(this).attr("data-end")); - if (startMins < eventEnd && endMins > eventStart) { - overlapping = true; - return false; - } - } - ); - return overlapping; -} - -// Page load initialization -frappe.pages["service-scheduling"].on_page_load = function (wrapper) { - let page = frappe.ui.make_app_page({ - parent: wrapper, - single_column: true, - }); - - // Breadcrumbs - $( - '' - ).appendTo(page.body); - - // Header controls with "Select Date" button (using calendar icon) - let header_controls = $(` -
-

-
- - - -
-
- `).appendTo(page.body); - - $("#select-date-btn").on("click", () => { - openDatePicker(); - }); - - let month_row = $( - `
` - ).appendTo(page.body); - let date_table = $(`
`).appendTo(page.body); - - // The merged header (with search and time labels) will be built in render_schedule_grid. - let schedule_grid = $(`
`); - // For mobile, allow horizontal scrolling. - if (isMobile()) { - schedule_grid.css("overflow-x", "auto"); - } - schedule_grid.appendTo(page.body); - - currentSelectedDate = frappe.datetime.get_today(); - load_schedule(currentSelectedDate); - - $("#today-btn").on("click", () => { - const selected_date = frappe.datetime.get_today(); - currentSelectedDate = selected_date; - load_schedule(selected_date); - }); - - $("#tomorrow-btn").on("click", () => { - const selected_date = frappe.datetime.add_days( - frappe.datetime.get_today(), - 1 - ); - currentSelectedDate = selected_date; - load_schedule(selected_date); - }); -}; - -// Event resizing functions -function startResizing(e, eventElement, side) { - e.stopPropagation(); - isResizing = true; - $(eventElement).attr("draggable", false); - const initialX = e.pageX; - const initialStart = timeStringToMinutes($(eventElement).data("start")); - const initialEnd = timeStringToMinutes($(eventElement).data("end")); - - const onMouseMove = (e) => { - const delta = e.pageX - initialX; - const timelineWidth = $(eventElement).parent().width(); - const deltaMins = (delta / timelineWidth) * TOTAL_WORKING_MINUTES; - if (side === "left") { - let newStart = initialStart + deltaMins; - if (newStart < START_TIME_MINUTES) newStart = START_TIME_MINUTES; - if (newStart >= initialEnd - 30) newStart = initialEnd - 30; - $(eventElement).attr("data-start", minutesToTimeString(newStart)); - const pos = calculatePosition(newStart, initialEnd); - $(eventElement).css({ - left: pos.leftPercent + "%", - width: pos.widthPercent + "%", - }); - } else if (side === "right") { - let newEnd = initialEnd + deltaMins; - if (newEnd > END_TIME_MINUTES) newEnd = END_TIME_MINUTES; - if (newEnd <= initialStart + 30) newEnd = initialStart + 30; - $(eventElement).attr("data-end", minutesToTimeString(newEnd)); - const pos = calculatePosition(initialStart, newEnd); - $(eventElement).css({ width: pos.widthPercent + "%" }); - } - }; - - const onMouseUp = (e) => { - $(document).off("mousemove", onMouseMove); - $(document).off("mouseup", onMouseUp); - $(eventElement).attr("draggable", true); - isResizing = false; - justResized = true; - setTimeout(() => { - justResized = false; - }, 300); - - const newStartStr = $(eventElement).attr("data-start"); - const newEndStr = $(eventElement).attr("data-end"); - if (!newStartStr || !newEndStr) { - frappe.msgprint("Error reading time data after resizing."); - return; - } - const newStart = timeStringToMinutes(newStartStr); - const newEnd = timeStringToMinutes(newEndStr); - const technician = $(eventElement).data("tech"); - const appointmentId = $(eventElement).data("appointment"); - if (is_overlapping_excluding(technician, newStart, newEnd, appointmentId)) { - frappe.msgprint( - "Time overlap detected during resizing. Reverting changes." - ); - $(eventElement).attr("data-start", minutesToTimeString(initialStart)); - $(eventElement).attr("data-end", minutesToTimeString(initialEnd)); - const pos = calculatePosition(initialStart, initialEnd); - $(eventElement).css({ - left: pos.leftPercent + "%", - width: pos.widthPercent + "%", - }); - return; - } - update_appointment( - appointmentId, - currentSelectedDate, - $(eventElement).data("service-order"), - formatDatetime(currentSelectedDate, minutesToTimeString(newStart)), - formatDatetime(currentSelectedDate, minutesToTimeString(newEnd)), - $(eventElement).data("tech") - ); - }; - - $(document).on("mousemove", onMouseMove); - $(document).on("mouseup", onMouseUp); -} - -function attachResizeHandles(eventElement) { - if ($(eventElement).find(".resize-handle").length === 0) { - $(eventElement).append('
'); - $(eventElement).append('
'); - } - $(eventElement) - .find(".left-handle") - .on("mousedown", (e) => { - startResizing(e, eventElement, "left"); - }); - $(eventElement) - .find(".right-handle") - .on("mousedown", (e) => { - startResizing(e, eventElement, "right"); - }); -} - -// Create event function -function create_event(event, timelineCell) { - if ($(event.target).hasClass("schedule-event") || isResizing || justResized) - return; - const technician = $(timelineCell).closest(".technician-row").data("tech"); - const timelineOffset = $(timelineCell).offset(); - const clickX = event.pageX - timelineOffset.left; - const timelineWidth = $(timelineCell).width(); - let minutesFromStart = (clickX / timelineWidth) * TOTAL_WORKING_MINUTES; - minutesFromStart = roundToNearestTen(minutesFromStart); - const eventStartMinutes = START_TIME_MINUTES + minutesFromStart; - const eventEndMinutes = eventStartMinutes + 30; - const selectedDate = currentSelectedDate || frappe.datetime.get_today(); - - const d = new frappe.ui.Dialog({ - title: "Create Schedule", - fields: [ - { - fieldname: "appointment", - fieldtype: "Link", - options: "Service Appointment", - label: "Appointment", - read_only: 1, - default: "", - hidden: 1, - }, - { - fieldname: "service_order", - fieldtype: "Link", - options: "Service Order", - label: "Service Order", - reqd: 1, - }, - { - fieldname: "technician", - fieldtype: "Link", - options: "Service Technician", - label: "Technician", - read_only: 1, - default: technician, - }, - { fieldtype: "Column Break" }, - { - fieldname: "selected_date", - fieldtype: "Date", - label: "Selected Date", - default: selectedDate, - read_only: 1, - }, - { - fieldname: "start_time", - fieldtype: "Time", - label: "Start Time", - default: minutesToTimeString(eventStartMinutes), - reqd: 1, - }, - { - fieldname: "finish_time", - fieldtype: "Time", - label: "Finish Time", - default: minutesToTimeString(eventEndMinutes), - reqd: 1, - }, - ], - primary_action_label: "Schedule & Dispatch", - primary_action: (values) => { - const startMins = timeStringToMinutes(values.start_time); - const endMins = timeStringToMinutes(values.finish_time); - if ( - startMins >= endMins || - startMins < START_TIME_MINUTES || - endMins > END_TIME_MINUTES - ) { - frappe.msgprint( - "Invalid time range. Select a valid start and finish time between 07:00 - 19:00." - ); - return; - } - if (endMins - startMins < 30) { - frappe.msgprint("Time range must be at least 30 minutes."); - return; - } - if (is_overlapping(technician, startMins, endMins)) { - frappe.msgprint( - "Time overlap detected! Please select a different time." - ); - return; - } - const scheduledStartDatetime = formatDatetime( - values.selected_date, - values.start_time - ); - const scheduledFinishDatetime = formatDatetime( - values.selected_date, - values.finish_time - ); - create_appointment( - values.selected_date, - values.service_order, - scheduledStartDatetime, - scheduledFinishDatetime, - technician, - (appointment_data) => { - const appointment_id = appointment_data.name; - const appointment_status = appointment_data.status || "Dispatched"; - const event_color = - appointment_data.color || - status_colors[appointment_status] || - "#007bff"; - const duration = endMins - startMins; - const pos = calculatePosition(startMins, endMins); - const $eventEl = $(` -
- ${transformServiceOrder(values.service_order)} (${values.start_time} - ${ - values.finish_time - }) -
- `); - $eventEl.css({ - background: event_color, - left: pos.leftPercent + "%", - width: pos.widthPercent + "%", - }); - $(timelineCell).append($eventEl); - attachResizeHandles($eventEl); - d.set_value("appointment", appointment_id); - d.hide(); - } - ); - }, - }); - d.show(); -} - -// Render schedule grid -function render_schedule_grid(technicians, selected_date, appointments) { - const gridContainer = $(` -
- `); - - // Create a merged header row with search input and bold time labels. - const headerRow = $(` -
-
- -
-
-
-
- `); - // Insert bold time labels inside the timeline-cell; on mobile display only the hour (e.g., "7") with a smaller font. - for (let m = 0; m < TOTAL_WORKING_MINUTES; m += 60) { - const rawTime = minutesToTimeString(START_TIME_MINUTES + m); - const displayTime = isMobile() ? parseInt(rawTime.split(":")[0]) : rawTime; - const leftPercent = (m / TOTAL_WORKING_MINUTES) * 100; - const labelDiv = $(` -
- ${displayTime} -
- `); - headerRow.find(".timeline-cell").append(labelDiv); - } - gridContainer.append(headerRow); - - // Separator: horizontal line spanning full width. - const separator = $(` -
- `); - gridContainer.append(separator); - - // Technician rows container. - const techRowsContainer = $(`
`); - technicians.forEach((tech) => { - const techRow = $(` -
-
- ${tech.full_name} -
-
-
- `); - techRowsContainer.append(techRow); - }); - gridContainer.append(techRowsContainer); - $("#schedule-grid").html(gridContainer); - - // Timeline background: vertical grid lines now start below the header row. - const timelineBackground = $(` -
- `); - for (let m = 0; m <= TOTAL_WORKING_MINUTES; m += 10) { - const leftPercent = (m / TOTAL_WORKING_MINUTES) * 100; - const lineWidth = m % 60 === 0 ? 2 : 1; - const lineColor = m % 60 === 0 ? "#aaa" : "#ddd"; - const lineDiv = $(` -
- `); - timelineBackground.append(lineDiv); - } - gridContainer.append(timelineBackground); - - // Filter technician rows but exclude the header row so that the search input stays visible. - $("#technician-search") - .off("keyup") - .on( - "keyup", - debounce(function () { - const value = $(this).val().toLowerCase(); - $(".technician-row") - .not(".header-row") - .filter(function () { - $(this).toggle($(this).text().toLowerCase().includes(value)); - }); - }, 300) - ); - - appointments.forEach((appointment) => { - appointment.service_technicians.forEach((technicianName) => { - technicianName = technicianName.trim(); - const $techRow = $(`.technician-row[data-tech='${technicianName}']`); - if ($techRow.length === 0) return; - const timelineCell = $techRow.find(".timeline-cell"); - const startMins = timeStringToMinutes(appointment.start_time); - const endMins = timeStringToMinutes(appointment.finish_time); - const pos = calculatePosition(startMins, endMins); - const appointment_status = appointment.status || "Dispatched"; - const event_color = - appointment.color || status_colors[appointment_status] || "#007bff"; - const $ev = $(` -
- ${transformServiceOrder(appointment.service_order)} (${ - appointment.start_time - } - ${appointment.finish_time}) -
- `); - $ev.css({ - background: event_color, - left: pos.leftPercent + "%", - width: pos.widthPercent + "%", - }); - timelineCell.append($ev); - attachResizeHandles($ev); - }); - }); -} - -// Edit event dialog -function edit_event(eventElement) { - if (justResized) return; - const appointmentId = $(eventElement).data("appointment"); - const service_order = $(eventElement).data("service-order"); - const start_time = $(eventElement).attr("data-start"); - const finish_time = $(eventElement).attr("data-end"); - const technician = $(eventElement).data("tech"); - const selectedDate = currentSelectedDate || frappe.datetime.get_today(); - const d = new frappe.ui.Dialog({ - title: "Edit Schedule", - fields: [ - { - fieldname: "appointment", - fieldtype: "Link", - options: "Service Appointment", - label: "Appointment", - default: appointmentId, - read_only: 1, - }, - { - fieldname: "service_order", - fieldtype: "Link", - options: "Service Order", - label: "Service Order", - default: service_order, - read_only: 1, - }, - { - fieldname: "technician", - fieldtype: "Link", - options: "Service Technician", - label: "Technician", - default: technician, - read_only: 1, - }, - { fieldtype: "Column Break" }, - { - fieldname: "selected_date", - fieldtype: "Date", - label: "Selected Date", - default: selectedDate, - read_only: 1, - }, - { - fieldname: "start_time", - fieldtype: "Time", - label: "Start Time", - default: start_time, - reqd: 1, - }, - { - fieldname: "finish_time", - fieldtype: "Time", - label: "Finish Time", - default: finish_time, - reqd: 1, - }, - ], - primary_action_label: "Update", - primary_action: (values) => { - const newStart = timeStringToMinutes(values.start_time); - const newEnd = timeStringToMinutes(values.finish_time); - if ( - newStart >= newEnd || - newStart < START_TIME_MINUTES || - newEnd > END_TIME_MINUTES - ) { - frappe.msgprint( - "Invalid time range. Select a valid start and finish time between 07:00 - 19:00." - ); - return; - } - if (newEnd - newStart < 30) { - frappe.msgprint("Time range must be at least 30 minutes."); - return; - } - const pos = calculatePosition(newStart, newEnd); - $(eventElement).css({ - left: pos.leftPercent + "%", - width: pos.widthPercent + "%", - }); - $(eventElement).attr("data-start", values.start_time); - $(eventElement).attr("data-end", values.finish_time); - $(eventElement).html( - `${transformServiceOrder(values.service_order)} (${ - values.start_time - } - ${values.finish_time})` - ); - - const scheduledStartDatetime = formatDatetime( - values.selected_date, - values.start_time - ); - const scheduledFinishDatetime = formatDatetime( - values.selected_date, - values.finish_time - ); - update_appointment( - values.appointment, - values.selected_date, - values.service_order, - scheduledStartDatetime, - scheduledFinishDatetime, - $(eventElement).data("tech") - ); - d.hide(); - }, - }); - d.show(); -} - -// Drag & drop handler -function drop(event, timelineCell) { - event.preventDefault(); - const draggedHtml = event.dataTransfer.getData("text"); - const draggedElement = $(draggedHtml); - const oldStart = timeStringToMinutes(draggedElement.attr("data-start")); - const oldEnd = timeStringToMinutes(draggedElement.attr("data-end")); - const duration = oldEnd - oldStart; - const timelineOffset = $(timelineCell).offset(); - const dropX = event.pageX - timelineOffset.left; - const timelineWidth = $(timelineCell).width(); - let minutesFromStart = (dropX / timelineWidth) * TOTAL_WORKING_MINUTES; - minutesFromStart = roundToNearestTen(minutesFromStart); - let newStartMins = START_TIME_MINUTES + minutesFromStart; - let newEndMins = newStartMins + duration; - if (newStartMins < START_TIME_MINUTES) { - newStartMins = START_TIME_MINUTES; - newEndMins = newStartMins + duration; - } - if (newEndMins > END_TIME_MINUTES) { - newStartMins = END_TIME_MINUTES - duration; - newEndMins = END_TIME_MINUTES; - } - const technician = $(timelineCell).closest(".technician-row").data("tech"); - if ( - is_overlapping_excluding( - technician, - newStartMins, - newEndMins, - draggedElement.data("appointment") - ) - ) { - frappe.msgprint( - "Time overlap detected after drop! Please choose a different position." - ); - return; - } - const serviceOrder = draggedElement.data("service-order"); - if (!serviceOrder) { - frappe.msgprint("Service Order is required. Please create an event first."); - return; - } - const newStartTime = minutesToTimeString(newStartMins); - const newEndTime = minutesToTimeString(newEndMins); - $( - `.schedule-event[data-appointment='${draggedElement.data("appointment")}']` - ).remove(); - const pos = calculatePosition(newStartMins, newEndMins); - const event_color = - draggedElement.data("color") || - status_colors[draggedElement.data("status")] || - "#007bff"; - const event_html = ` -
- ${transformServiceOrder(serviceOrder)} (${newStartTime} - ${newEndTime}) -
- `; - const $newEvent = $(event_html); - $newEvent.css({ - background: event_color, - left: pos.leftPercent + "%", - width: pos.widthPercent + "%", - }); - $(timelineCell).append($newEvent); - const selectedDate = currentSelectedDate || frappe.datetime.get_today(); - const scheduledStartDatetime = formatDatetime(selectedDate, newStartTime); - const scheduledFinishDatetime = formatDatetime(selectedDate, newEndTime); - update_appointment( - draggedElement.data("appointment"), - selectedDate, - serviceOrder, - scheduledStartDatetime, - scheduledFinishDatetime, - technician - ); -} - -// --- Context Menu --- -// For "Dispatched": show [Start Work, Invoice]. -// For "Scheduled": show [Reschedule, Invoice]. - -// Desktop right-click context menu. -$(document).on("contextmenu", ".schedule-event", function (e) { - var $this = $(this); - var status = $this.data("status"); - if (status !== "Dispatched" && status !== "Scheduled") { - return; - } - e.preventDefault(); - $("#custom-context-menu").remove(); - var appointmentId = $this.data("appointment"); - var menuItems = ""; - if (status === "Dispatched") { - menuItems = ` -
Start Work
-
Invoice
- `; - } else if (status === "Scheduled") { - menuItems = ` -
Reschedule
-
Invoice
- `; - } - var menu = $(` -
- ${menuItems} -
- `); - menu.data("appointmentId", appointmentId); - menu.data("eventElement", this); - menu.css({ top: e.pageY + "px", left: e.pageX + "px" }); - $("body").append(menu); -}); - -$(document) - .on("mouseenter", "#custom-context-menu .context-menu-item", function () { - $(this).css("background", "#f5f5f5"); - }) - .on("mouseleave", "#custom-context-menu .context-menu-item", function () { - $(this).css("background", "#fff"); - }); - -$(document).on("click", ".context-menu-item", function (e) { - e.stopPropagation(); - var action = $(this).data("action"); - var appointmentId = $("#custom-context-menu").data("appointmentId"); - var eventElement = $("#custom-context-menu").data("eventElement"); - $("#custom-context-menu").remove(); - if (action === "start_work") { - startWork(appointmentId); - } else if (action === "reschedule") { - edit_event(eventElement); - } else if (action === "invoice") { - invoiceAppointment(appointmentId); - } -}); - -$(document).on("click", function (e) { - $("#custom-context-menu").remove(); -}); - -// Mobile long-press context menu. -var touchTimer; -$(document) - .on("touchstart", ".schedule-event", function (e) { - var $this = $(this); - touchTimer = setTimeout(function () { - var status = $this.data("status"); - if (status !== "Dispatched" && status !== "Scheduled") { - return; - } - $("#custom-context-menu").remove(); - var appointmentId = $this.data("appointment"); - var touch = e.originalEvent.touches[0]; - var menuItems = ""; - if (status === "Dispatched") { - menuItems = ` -
Start Work
-
Invoice
- `; - } else if (status === "Scheduled") { - menuItems = ` -
Reschedule
-
Invoice
- `; - } - var menu = $(` -
- ${menuItems} -
- `); - menu.data("appointmentId", appointmentId); - menu.data("eventElement", $this.get(0)); - menu.css({ top: touch.pageY + "px", left: touch.pageX + "px" }); - $("body").append(menu); - }, 800); - }) - .on("touchend touchcancel", ".schedule-event", function (e) { - clearTimeout(touchTimer); - }); - -// Backend actions for context menu -function startWork(appointmentId) { - frappe.call({ - method: - "beveren_fsm.field_service_management.page.service_scheduling.service_scheduling.start_work", - args: { appointment_id: appointmentId }, - callback: function (r) { - if (!r.exc) { - frappe.msgprint("Appointment started"); - refresh_schedule_grid(currentSelectedDate); - } - }, - }); -} - -function invoiceAppointment(appointmentId) { - frappe.set_route("Form", "Service Appointment", appointmentId); -} - -// Date table & refresh functions -function render_date_table(selected_date) { - const dates = generate_date_range(selected_date); - let table_html = `
`; - dates.forEach((date) => { - const isSelected = date === selected_date ? "selected-date" : ""; - const monthName = new Date(date) - .toLocaleString("en-us", { month: "short" }) - .toUpperCase(); - table_html += ``; - }); - table_html += `
-
${format_date(date)}
-
${monthName}
-
`; - $("#date-table").html(table_html); -} - -function filter_by_date(date) { - $(".date-header").removeClass("selected-date"); - $(`.date-header[data-date='${date}']`).addClass("selected-date"); - currentSelectedDate = date; - load_schedule(date); -} - -function format_date(date) { - const dateObj = new Date(date); - const dayName = dateObj - .toLocaleString("en-us", { weekday: "short" }) - .toUpperCase(); - const dayNum = dateObj.getDate(); - return `
${dayName}
${dayNum}
`; -} - -function refresh_schedule_grid(selected_date) { - $("#schedule-grid").html(` -
-
-
- `); - setTimeout(() => { - load_schedule(selected_date); - }, 100); -} - -// Backend calls -function create_appointment( - selected_date, - service_order, - scheduled_start_datetime, - scheduled_finish_datetime, - technician, - callback -) { - frappe.call({ - method: - "beveren_fsm.field_service_management.page.service_scheduling.service_scheduling.create_service_appointment", - args: { - selected_date, - service_order, - scheduled_start_datetime, - scheduled_finish_datetime, - technician, - }, - callback: (r) => { - if (!r.exc) { - if (callback) { - callback(r.message); - } - refresh_schedule_grid(selected_date); - } else { - frappe.msgprint("Failed to update schedule."); - } - }, - }); -} - -function update_appointment( - appointment_id, - selected_date, - service_order, - start_time, - end_time, - technician -) { - frappe.call({ - method: - "beveren_fsm.field_service_management.page.service_scheduling.service_scheduling.update_service_appointment", - args: { - appointment_id, - selected_date, - service_order, - scheduled_start_datetime: start_time, - scheduled_finish_datetime: end_time, - technician, - }, - callback: (r) => { - if (!r.exc) { - refresh_schedule_grid(selected_date); - } - }, - }); -} - -function load_schedule(selected_date) { - currentSelectedDate = selected_date; - frappe.call({ - method: - "beveren_fsm.field_service_management.page.service_scheduling.service_scheduling.get_schedule_data", - args: { selected_date }, - callback: (r) => { - if (r.message) { - render_date_table(selected_date); - render_schedule_grid( - r.message.technicians, - selected_date, - r.message.appointments - ); - } else if (r.exc) { - console.error("Error fetching schedule data", r.exc); - frappe.msgprint("An error occurred while loading the schedule."); - } - }, - }); -} diff --git a/beveren_fsm/field_service_management/page/service_scheduling/service_scheduling.json b/beveren_fsm/field_service_management/page/service_scheduling/service_scheduling.json deleted file mode 100644 index a3c81b9..0000000 --- a/beveren_fsm/field_service_management/page/service_scheduling/service_scheduling.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "content": null, - "creation": "2025-02-12 12:35:28.281192", - "docstatus": 0, - "doctype": "Page", - "idx": 0, - "modified": "2025-02-12 12:35:28.281192", - "modified_by": "Administrator", - "module": "Field Service Management", - "name": "service-scheduling", - "owner": "Administrator", - "page_name": "service-scheduling", - "roles": [], - "script": null, - "standard": "Yes", - "style": null, - "system_page": 0 -} diff --git a/beveren_fsm/field_service_management/page/service_scheduling/service_scheduling.py b/beveren_fsm/field_service_management/page/service_scheduling/service_scheduling.py deleted file mode 100644 index 8924215..0000000 --- a/beveren_fsm/field_service_management/page/service_scheduling/service_scheduling.py +++ /dev/null @@ -1,164 +0,0 @@ -import frappe -from frappe.utils import get_datetime, getdate - - -@frappe.whitelist() -def get_schedule_data(selected_date): - # Fetch Technicians - technicians = frappe.get_all("Service Technician", fields=["name", "full_name"]) - - # Convert selected_date to a date object - selected_date = getdate(selected_date) - - # Fetch Service Appointments for the selected date - appointments = frappe.get_all( - "Service Appointment", - filters={ - "posting_date": selected_date, - }, - fields=[ - "name", - "posting_date", - "service_order", - "scheduled_start_datetime", - "scheduled_finish_datetime", - ], - ) - - # Get metadata for Service Appointment to fetch workflow states and their colors - meta = frappe.get_meta("Service Appointment") - - appointments_with_technicians = [] - for appointment in appointments: - # Get the full document to access child table and status. - appointment_doc = frappe.get_doc("Service Appointment", appointment.name) - - # Fetch service technicians from child table - service_technicians = [ - frappe.get_doc("Service Technician", tech.service_technician).name - for tech in appointment_doc.service_technicians - ] - - # Convert scheduled datetime fields to proper datetime objects - start_time = get_datetime(appointment_doc.scheduled_start_datetime) - finish_time = get_datetime(appointment_doc.scheduled_finish_datetime) - - # Get the color for the current state using meta.states - state_color = None - if meta.states: - for s in meta.states: - if s.title == appointment_doc.status: - state_color = s.color - break - - # Prepare the structured appointment data - structured_appointment = { - "name": appointment.name, - "service_order": appointment.service_order, - "start_time": start_time.strftime("%H:%M"), - "finish_time": finish_time.strftime("%H:%M"), - "service_technicians": service_technicians, - "status": appointment_doc.status, - "color": state_color, - } - appointments_with_technicians.append(structured_appointment) - - return {"technicians": technicians, "appointments": appointments_with_technicians} - - -@frappe.whitelist() -def create_service_appointment( - selected_date, service_order, scheduled_start_datetime, scheduled_finish_datetime, technician -): - # This function only creates a new appointment if one does NOT already exist - # for the same posting date, service order, scheduled times, and where the specified technician exists. - - # Convert the datetime strings to datetime objects. - scheduled_start_datetime = get_datetime(scheduled_start_datetime) - scheduled_finish_datetime = get_datetime(scheduled_finish_datetime) - - # Search for an existing Service Appointment using the main filters. - appointment_list = frappe.get_all( - "Service Appointment", - filters={ - "posting_date": getdate(selected_date), - "service_order": service_order, - "scheduled_start_datetime": scheduled_start_datetime, - "scheduled_finish_datetime": scheduled_finish_datetime, - }, - limit=10, - ) - - found = None - for app in appointment_list: - app_doc = frappe.get_doc("Service Appointment", app.name) - # Check if the technician is in the child table. - for row in app_doc.get("service_technicians"): - if row.service_technician == technician: - found = app_doc - break - if found: - break - - if found: - appointment = found - else: - # Create a new Service Appointment. - appointment = frappe.new_doc("Service Appointment") - appointment.posting_date = getdate(selected_date) - appointment.service_order = service_order - appointment.scheduled_start_datetime = scheduled_start_datetime - appointment.scheduled_finish_datetime = scheduled_finish_datetime - appointment.append("service_technicians", {"service_technician": technician}) - appointment.save() - appointment.submit() - - # Get metadata to fetch the workflow state color. - meta = frappe.get_meta("Service Appointment") - state_color = None - if meta.states: - for s in meta.states: - if s.title == appointment.status: - state_color = s.color - break - - return {"name": appointment.name, "status": appointment.status, "color": state_color} - - -@frappe.whitelist() -def update_service_appointment( - appointment_id, - selected_date, - service_order, - scheduled_start_datetime, - scheduled_finish_datetime, - technician, -): - appointment = frappe.get_doc("Service Appointment", appointment_id) - - appointment.update( - { - "posting_date": getdate(selected_date), - "service_order": service_order, - "scheduled_start_datetime": get_datetime(scheduled_start_datetime), - "scheduled_finish_datetime": get_datetime(scheduled_finish_datetime), - } - ) - - # Clear existing technicians and add the new one. - appointment.set("service_technicians", []) - appointment.append("service_technicians", {"service_technician": technician}) - - appointment.save() - return appointment.name - - -@frappe.whitelist() -def start_work(appointment_id): - appointment = frappe.get_doc("Service Appointment", appointment_id) - if appointment.status == "Dispatched": - appointment.status = "In Progress" - appointment.save(ignore_permissions=True) - return appointment.name - else: - frappe.throw("Appointment is not in Dispatched status. Cannot start work.") diff --git a/schedule/src/components/layout/sidebar-menu.tsx b/schedule/src/components/layout/sidebar-menu.tsx index 3fc0128..a2b028b 100644 --- a/schedule/src/components/layout/sidebar-menu.tsx +++ b/schedule/src/components/layout/sidebar-menu.tsx @@ -1,29 +1,38 @@ "use client"; -import { Home, Users, Settings } from "lucide-react"; +import { Home, Users, Settings, ClipboardList } from "lucide-react"; import { cn } from "../../lib/utils"; import { useState } from "react"; interface MenuItem { icon: React.ComponentType<{ className?: string }>; label: string; - active?: boolean; + key: "home" | "requests" | "technicians" | "settings"; onClick?: () => void; } interface SidebarMenuProps { + activeMenu: "home" | "requests" | "technicians" | "settings"; onTechniciansClick?: () => void; onScheduleClick?: () => void; + onRequestsClick?: () => void; onSettingsClick?: () => void; } -export function SidebarMenu({ onTechniciansClick, onScheduleClick, onSettingsClick }: SidebarMenuProps) { +export function SidebarMenu({ + activeMenu, + onTechniciansClick, + onScheduleClick, + onRequestsClick, + onSettingsClick, +}: SidebarMenuProps) { const [hoveredItem, setHoveredItem] = useState(null); const menuItems: MenuItem[] = [ - { icon: Home, label: "Home", active: true, onClick: onScheduleClick }, - { icon: Users, label: "Technicians", onClick: onTechniciansClick }, - { icon: Settings, label: "Settings", onClick: onSettingsClick }, + { icon: Home, label: "Home", key: "home", onClick: onScheduleClick }, + { icon: ClipboardList, label: "Requests", key: "requests", onClick: onRequestsClick }, + { icon: Users, label: "Technicians", key: "technicians", onClick: onTechniciansClick }, + { icon: Settings, label: "Settings", key: "settings", onClick: onSettingsClick }, ]; return ( @@ -42,7 +51,7 @@ export function SidebarMenu({ onTechniciansClick, onScheduleClick, onSettingsCli - - -
-
-

Date Range Filter

- - {/* Start Date */} -
- - - - - - - { - if (date) { - onDateRangeChange({ - ...appointmentDateRange, - startDate: date, - }); - setStartDatePickerOpen(false); - } - }} - initialFocus - /> - - -
- - {/* End Date */} -
- - - - - - - { - if (date) { - onDateRangeChange({ - ...appointmentDateRange, - endDate: date, - }); - setEndDatePickerOpen(false); - } - }} - initialFocus - /> - - -
-
-
-
- +

+ {isOrderMode ? "Service Orders" : "Service Appointments"} +

+
+
+ + {isOrderMode ? serviceOrders.length : appointments.length} + + +
+
- {/* Status Filter - Always Visible */} -
- All Statuses - {STATUS_OPTIONS.map((status) => ( + {orderStatusOptions.map((status) => ( {status} @@ -245,155 +332,282 @@ export function ScheduleLeftPanel({
-
+ ) : ( + <> + {/* Filter Section */} +
+ {/* Filter Menu Button */} + + + + + +
+
+

Date Range Filter

- {/* Mass Actions */} - {selectedAppointments.length > 0 && ( -
- -
+ {/* Start Date */} +
+ + + + + + + { + if (date) { + onDateRangeChange({ + ...appointmentDateRange, + startDate: date, + }); + setStartDatePickerOpen(false); + } + }} + initialFocus + /> + + +
+ + {/* End Date */} +
+ + + + + + + { + if (date) { + onDateRangeChange({ + ...appointmentDateRange, + endDate: date, + }); + setEndDatePickerOpen(false); + } + }} + initialFocus + /> + + +
+
+
+
+
+ + {/* Status Filter - Always Visible */} +
+ +
+
+ + {/* Mass Actions */} + {selectedAppointments.length > 0 && ( +
+ +
+ )} + )}
{/* Appointments List */} -
- {/* Select All Checkbox */} - {appointments.length > 0 && ( -
-
- - {someSelected && !allSelected && ( -
-
+ {isOrderMode ? ( + renderServiceOrders() + ) : ( +
+ {/* Select All Checkbox */} + {appointments.length > 0 && ( +
+
+ + {someSelected && !allSelected && ( +
+
+
+ )} +
+ + {selectedAppointments.length > 0 + ? `${selectedAppointments.length} selected` + : "Select all"} + +
+ )} + + {/* Loading State */} + {loading && ( +
+ {[1, 2, 3, 4].map((i) => ( +
+ + +
- )} + ))}
- - {selectedAppointments.length > 0 - ? `${selectedAppointments.length} selected` - : "Select all"} - -
- )} - - {/* Loading State */} - {loading && ( -
- {[1, 2, 3, 4].map((i) => ( -
- - - -
- ))} -
- )} + )} - {/* Appointments */} - {!loading && appointments.length === 0 && ( -
-

No appointments found

-
- )} - - {!loading && - appointments.map((appointment) => { - const isSelected = selectedAppointments.includes(appointment.name); - const isCompleted = appointment.status === "Completed"; - return ( -
+

No appointments found

+
+ )} + + {!loading && + appointments.map((appointment) => { + const isSelected = selectedAppointments.includes(appointment.name); + const isCompleted = appointment.status === "Completed"; + return ( +
handleAppointmentClick(appointment)} - draggable={!isCompleted} - onDragStart={(e) => { - if (isCompleted) { - e.preventDefault(); - return; - } - // Package minimal data for drop target: id, duration, current start - const start = appointment.scheduled_start_datetime - ? new Date(appointment.scheduled_start_datetime).toISOString() - : null; - const end = appointment.scheduled_finish_datetime - ? new Date(appointment.scheduled_finish_datetime).toISOString() - : null; - const durationMin = start && end ? Math.max(0, Math.round((new Date(end).getTime() - new Date(start).getTime()) / 60000)) : 60; - e.dataTransfer.setData( - "application/json", - JSON.stringify({ - type: "appointment", - id: appointment.name, - durationMinutes: durationMin, - }) - ); - }} - > -
- - onAppointmentSelect(appointment.name, checked as boolean) + onClick={() => handleAppointmentClick(appointment)} + draggable={!isCompleted} + onDragStart={(e) => { + if (isCompleted) { + e.preventDefault(); + return; } - onClick={(e) => e.stopPropagation()} - className="mt-1" - /> -
-
- - {appointment.service_order || appointment.name} - - - {appointment.status} - -
-

- {getShortDescription(appointment)} -

-
- {appointment.customer && ( - {appointment.customer} - )} - {appointment.scheduled_start_datetime && ( - <> - - {formatDate(appointment.scheduled_start_datetime)} - - )} + // Package minimal data for drop target: id, duration, current start + const start = appointment.scheduled_start_datetime + ? new Date(appointment.scheduled_start_datetime).toISOString() + : null; + const end = appointment.scheduled_finish_datetime + ? new Date(appointment.scheduled_finish_datetime).toISOString() + : null; + const durationMin = start && end ? Math.max(0, Math.round((new Date(end).getTime() - new Date(start).getTime()) / 60000)) : 60; + e.dataTransfer.setData( + "application/json", + JSON.stringify({ + type: "appointment", + id: appointment.name, + durationMinutes: durationMin, + }) + ); + }} + > +
+ + onAppointmentSelect(appointment.name, checked as boolean) + } + onClick={(e) => e.stopPropagation()} + className="mt-1" + /> +
+
+ + {appointment.service_order || appointment.name} + + + {appointment.status} + +
+

+ {getShortDescription(appointment)} +

+
+ {appointment.customer && ( + {appointment.customer} + )} + {appointment.scheduled_start_datetime && ( + <> + + {formatDate(appointment.scheduled_start_datetime)} + + )} +
+ {appointment.service_technicians && + appointment.service_technicians.length > 0 && ( +
+ + {appointment.service_technicians + .map((t) => t.full_name) + .join(", ")} + +
+ )}
- {appointment.service_technicians && - appointment.service_technicians.length > 0 && ( -
- - {appointment.service_technicians - .map((t) => t.full_name) - .join(", ")} - -
- )}
-
- ); - })} -
+ ); + })} +
+ )}
@@ -405,6 +619,18 @@ export function ScheduleLeftPanel({ onOpenChange={(open) => !open && setSelectedAppointment(null)} /> )} + + { + setServiceOrderSheetOpen(open); + if (!open) { + setSelectedServiceOrder(null); + } + }} + /> ); } diff --git a/schedule/src/components/schedule/service-order-detail-sheet.tsx b/schedule/src/components/schedule/service-order-detail-sheet.tsx new file mode 100644 index 0000000..e3c037a --- /dev/null +++ b/schedule/src/components/schedule/service-order-detail-sheet.tsx @@ -0,0 +1,194 @@ +"use client"; + +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "../ui/sheet"; +import { Badge } from "../ui/badge"; +import { Separator } from "../ui/separator"; +import { format } from "date-fns"; +import { ServiceOrderDetail } from "../../pages/schedule/types"; +import { ScrollArea } from "../ui/scroll-area"; + +interface ServiceOrderDetailSheetProps { + order: ServiceOrderDetail | null; + open: boolean; + loading?: boolean; + onOpenChange: (open: boolean) => void; +} + +const getStatusBadgeColor = (status?: string) => { + if (!status) return "border-border text-muted-foreground"; + const normalized = status.toLowerCase(); + if (normalized.includes("open") || normalized.includes("pending")) { + return "bg-blue-100 text-blue-800 border-blue-200"; + } + if (normalized.includes("progress") || normalized.includes("dispatch")) { + return "bg-orange-100 text-orange-800 border-orange-200"; + } + if (normalized.includes("complete") || normalized.includes("close")) { + return "bg-green-100 text-green-800 border-green-200"; + } + if (normalized.includes("cancel")) { + return "bg-gray-100 text-gray-700 border-gray-200"; + } + return "border-border text-muted-foreground"; +}; + +const formatDate = (dateString?: string) => { + if (!dateString) return null; + try { + return format(new Date(dateString), "PPP"); + } catch { + return dateString; + } +}; + +export function ServiceOrderDetailSheet({ + order, + open, + loading, + onOpenChange, +}: ServiceOrderDetailSheetProps) { + return ( + + + +
+
+ Service Order Details + {order?.name} +
+ {order?.status && ( + + {order.status} + + )} +
+
+ + + {loading || !order ? ( +
Loading order details…
+ ) : ( +
+
+

Summary

+
+
+ Customer + {order.customer || "—"} +
+
+ Priority + {order.priority || "—"} +
+ {order.type && ( +
+ Service Type + {order.type} +
+ )} + {formatDate(order.posting_date) && ( +
+ Posting Date + {formatDate(order.posting_date)} +
+ )} +
+
+ + + +
+

Linked Documents

+
+
+ Service Request + {order.service_request || "—"} +
+
+ Service Quotation + {order.service_quotation || "—"} +
+
+
+ + + +
+

Financials

+
+
+ Service Total + + {order.service_total !== undefined ? order.service_total : "—"} + +
+
+ Spare Parts Total + + {order.spareparts_total !== undefined ? order.spareparts_total : "—"} + +
+
+ Grand Total + + {order.grand_total !== undefined ? order.grand_total : "—"} + +
+
+
+ + + + {order.items && order.items.length > 0 && ( +
+

Items

+
+ {order.items.map((item, idx) => ( +
+
+ {item.item_name || item.item_code} + + {item.qty} {item.uom || ""} + +
+ {item.description && ( +

{item.description}

+ )} + {item.invoice_status && ( +

+ Invoice Status: {item.invoice_status} +

+ )} +
+ ))} +
+
+ )} + + {order.notes && ( + <> + +
+

Notes

+

+ {order.notes} +

+
+ + )} +
+ )} +
+
+
+ ); +} diff --git a/schedule/src/components/service-request/service-requests-view.tsx b/schedule/src/components/service-request/service-requests-view.tsx new file mode 100644 index 0000000..18131a8 --- /dev/null +++ b/schedule/src/components/service-request/service-requests-view.tsx @@ -0,0 +1,336 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { ScrollArea } from "../ui/scroll-area"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table"; +import { Skeleton } from "../ui/skeleton"; +import { RefreshCcw, Search } from "lucide-react"; +import { fetchServiceRequests } from "../../hooks/use-service-requests"; +import { ServiceRequest } from "../../pages/schedule/types"; +import { format } from "date-fns"; + +const STATUS_OPTIONS = [ + "Open", + "Quotation", + "Converted", + "Due Soon", + "Overdue", + "On Hold", + "Closed", +]; + +const statusColors: Record = { + Open: "bg-blue-100 text-blue-800 border-blue-300", + "Due Soon": "bg-amber-100 text-amber-800 border-amber-300", + Overdue: "bg-red-100 text-red-800 border-red-300", + Converted: "bg-emerald-100 text-emerald-800 border-emerald-300", + "On Hold": "bg-gray-200 text-gray-800 border-gray-300", + Quotation: "bg-indigo-100 text-indigo-800 border-indigo-300", + Closed: "bg-slate-100 text-slate-800 border-slate-300", +}; + +const formatDate = (value?: string) => { + if (!value) return "—"; + try { + return format(new Date(value), "MMM d, yyyy"); + } catch { + return value; + } +}; + +export function ServiceRequestsView() { + const [requests, setRequests] = useState([]); + const [selectedRequestId, setSelectedRequestId] = useState(null); + const [statusFilter, setStatusFilter] = useState("all"); + const [searchQuery, setSearchQuery] = useState(""); + const [loading, setLoading] = useState(false); + + useEffect(() => { + loadRequests(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [statusFilter]); + + const loadRequests = async () => { + try { + setLoading(true); + const data = await fetchServiceRequests({ + status: statusFilter, + limit: 100, + }); + setRequests(data); + if (!selectedRequestId && data.length) { + setSelectedRequestId(data[0].name); + } else if (selectedRequestId && !data.find((req) => req.name === selectedRequestId)) { + setSelectedRequestId(data[0]?.name ?? null); + } + } catch (error) { + console.error("Failed to load service requests", error); + } finally { + setLoading(false); + } + }; + + const filteredRequests = useMemo(() => { + if (!searchQuery.trim()) { + return requests; + } + const term = searchQuery.toLowerCase(); + return requests.filter((req) => { + return ( + req.name?.toLowerCase().includes(term) || + req.subject?.toLowerCase().includes(term) || + req.customer?.toLowerCase().includes(term) || + req.serial_no?.toLowerCase().includes(term) || + req.item_code?.toLowerCase().includes(term) || + req.product_movement?.some( + (mv) => + mv.destination?.toLowerCase().includes(term) || + mv.movement_type?.toLowerCase().includes(term) + ) + ); + }); + }, [requests, searchQuery]); + + useEffect(() => { + if (!filteredRequests.length) { + setSelectedRequestId(null); + return; + } + if (!selectedRequestId || !filteredRequests.find((req) => req.name === selectedRequestId)) { + setSelectedRequestId(filteredRequests[0].name); + } + }, [filteredRequests, selectedRequestId]); + + const selectedRequest = + filteredRequests.find((req) => req.name === selectedRequestId) || filteredRequests[0] || null; + + return ( +
+ {/* Left Pane */} +
+
+
+
+

Service Requests

+

+ Track requests and their movement history +

+
+ {filteredRequests.length} +
+
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+ +
+ +
+ +
+ {loading && + [1, 2, 3, 4].map((i) => ( +
+ + + +
+ ))} + + {!loading && filteredRequests.length === 0 && ( +
+ No service requests found +
+ )} + + {!loading && + filteredRequests.map((req) => { + const isActive = selectedRequest?.name === req.name; + const badgeColor = + statusColors[req.status] || "bg-gray-100 text-gray-800 border-gray-300"; + return ( + + ); + })} +
+
+
+ + {/* Right Pane */} +
+ {selectedRequest ? ( +
+
+
+

+ Service Request +

+

+ {selectedRequest.subject || selectedRequest.name} +

+

+ {selectedRequest.customer || "No customer specified"} +

+
+ + {selectedRequest.status} + +
+ +
+
+
+

Serial No

+

+ {selectedRequest.serial_no || "Not linked"} +

+
+
+

Item

+

+ {selectedRequest.item_code || "Not set"} +

+
+
+

Current Location

+

+ {selectedRequest.current_product_location || "Unknown"} +

+
+
+

Due Date

+

{formatDate(selectedRequest.due_date)}

+
+
+ + {selectedRequest.description && ( +
+
+

Notes

+
+

+ {selectedRequest.description} +

+
+ )} + +
+
+
+

Movement Tracker

+

+ Latest known location changes for this item +

+
+ + {selectedRequest.product_movement?.length || 0} entries + +
+ + {selectedRequest.product_movement && selectedRequest.product_movement.length ? ( +
+ + + + Date + Current Location + Destination + Linked Document + Handled By + + + + {[...selectedRequest.product_movement] + .sort((a, b) => { + const aTime = a.movement_date ? new Date(a.movement_date).getTime() : 0; + const bTime = b.movement_date ? new Date(b.movement_date).getTime() : 0; + return bTime - aTime; + }) + .map((movement) => ( + + {formatDate(movement.movement_date)} + {movement.movement_type || "—"} + {movement.destination || "—"} + + {movement.linked_document ? ( + + {movement.linked_document_type || ""}{" "} + {movement.linked_document} + + ) : ( + "—" + )} + + {movement.handled_by || "—"} + + ))} + +
+
+ ) : ( +
+ No movement entries recorded yet for this request. +
+ )} +
+
+
+
+ ) : ( +
+ No service request selected +
+ )} +
+
+ ); +} diff --git a/schedule/src/hooks/use-appointments.ts b/schedule/src/hooks/use-appointments.ts index 21f3ac9..e432cbb 100644 --- a/schedule/src/hooks/use-appointments.ts +++ b/schedule/src/hooks/use-appointments.ts @@ -1,4 +1,4 @@ -import { Appointment } from "../pages/schedule/types"; +import { Appointment, ServiceOrderDetail } from "../pages/schedule/types"; export async function fetchAppointmentsWithFilter( startDate: Date | null, @@ -178,15 +178,77 @@ export interface CreateAppointmentTechnician { export async function fetchServiceOrders(): Promise { //eslint-disable-next-line @typescript-eslint/no-explicit-any const csrfToken = (window as any).csrf_token; - const url = '/api/resource/Service Order?fields=["name","customer","type"]&limit_page_length=50'; + + const params = new URLSearchParams({ + fields: JSON.stringify(["name", "customer", "status", "priority", "posting_date", "type"]), + filters: JSON.stringify([["docstatus", "=", 1]]), + order_by: "posting_date desc", + limit_page_length: "50", + }); + + const url = `/api/resource/Service Order?${params.toString()}`; + const resp = await fetch(url, { - headers: { Accept: "application/json", "Content-Type": "application/json", "X-Frappe-CSRF-Token": csrfToken }, + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "X-Frappe-CSRF-Token": csrfToken, + }, credentials: "include", }); + + if (!resp.ok) { + throw new Error(`Failed to fetch service orders: ${resp.statusText}`); + } + const json = await resp.json(); return json.data || []; } +export async function fetchServiceOrderDetail(name: string): Promise { + //eslint-disable-next-line @typescript-eslint/no-explicit-any + const csrfToken = (window as any).csrf_token; + + const url = `/api/resource/Service Order/${encodeURIComponent(name)}`; + + const resp = await fetch(url, { + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "X-Frappe-CSRF-Token": csrfToken, + }, + credentials: "include", + }); + + if (!resp.ok) { + throw new Error(`Failed to fetch service order: ${resp.statusText}`); + } + + const json = await resp.json(); + return json.data || json.data?.data || json; +} + +export async function fetchAvailableServiceOrders(): Promise { + //eslint-disable-next-line @typescript-eslint/no-explicit-any + const csrfToken = (window as any).csrf_token; + const resp = await fetch( + "/api/method/beveren_fsm.field_service_management.api.schedule.get_unassigned_service_orders", + { + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "X-Frappe-CSRF-Token": csrfToken, + }, + credentials: "include", + } + ); + if (!resp.ok) { + throw new Error(`Failed to fetch available service orders: ${resp.statusText}`); + } + const json = await resp.json(); + return json.message || []; +} + //eslint-disable-next-line @typescript-eslint/no-explicit-any export async function fetchCustomers(): Promise { //eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/schedule/src/hooks/use-service-requests.ts b/schedule/src/hooks/use-service-requests.ts new file mode 100644 index 0000000..6939c67 --- /dev/null +++ b/schedule/src/hooks/use-service-requests.ts @@ -0,0 +1,60 @@ +import { ServiceRequest } from "../pages/schedule/types"; + +interface ServiceRequestFilters { + status?: string; + startDate?: Date | null; + endDate?: Date | null; + search?: string; + limit?: number; +} + +export async function fetchServiceRequests(filters: ServiceRequestFilters = {}): Promise { + try { + //eslint-disable-next-line @typescript-eslint/no-explicit-any + const csrfToken = (window as any).csrf_token; + const params = new URLSearchParams(); + + if (filters.status && filters.status !== "all") { + params.append("status", filters.status); + } + + if (filters.startDate) { + params.append("start_date", filters.startDate.toISOString().split("T")[0]); + } + + if (filters.endDate) { + params.append("end_date", filters.endDate.toISOString().split("T")[0]); + } + + if (filters.search) { + params.append("search", filters.search); + } + + if (filters.limit) { + params.append("limit_page_length", String(filters.limit)); + } + + const url = `/api/method/beveren_fsm.field_service_management.api.service_request.get_service_requests?${ + params.toString() + }`; + + const response = await fetch(url, { + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "X-Frappe-CSRF-Token": csrfToken, + }, + credentials: "include", + }); + + if (!response.ok) { + throw new Error(`Failed to fetch service requests: ${response.statusText}`); + } + + const result = await response.json(); + return result.message || []; + } catch (error) { + console.error("Error fetching service requests:", error); + throw error; + } +} diff --git a/schedule/src/pages/schedule/schedule.tsx b/schedule/src/pages/schedule/schedule.tsx index 9e83faa..ae671be 100644 --- a/schedule/src/pages/schedule/schedule.tsx +++ b/schedule/src/pages/schedule/schedule.tsx @@ -6,9 +6,10 @@ import { TechniciansView } from "../../components/schedule/technicians-view"; import { SettingsView } from "../../components/schedule/settings-view"; import { SidebarMenu } from "../../components/layout/sidebar-menu"; import { useScheduleStore } from "../../store"; -import { fetchAppointmentsWithFilter } from "../../hooks/use-appointments"; +import { fetchAppointmentsWithFilter, fetchServiceOrders } from "../../hooks/use-appointments"; import { Toaster } from "../../components/ui/sonner"; import { useEffect, useState } from "react"; +import { ServiceRequestsView } from "../../components/service-request/service-requests-view"; export default function SchedulePage() { const [direction, setDirection] = useState<'ltr' | 'rtl'>('ltr'); @@ -39,20 +40,30 @@ export default function SchedulePage() { selectedDate, appointmentDateRange, statusFilter, + serviceOrderStatusFilter, viewType, selectedAppointment, leftPanelView, + leftListMode, settingsView, + requestsView, + serviceOrders, + serviceOrdersLoading, setAppointments, setLoading, setSelectedAppointments, setSelectedDate, setAppointmentDateRange, setStatusFilter, + setServiceOrderStatusFilter, setViewType, setSelectedAppointment, setLeftPanelView, + setLeftListMode, setSettingsView, + setRequestsView, + setServiceOrders, + setServiceOrdersLoading, toggleAppointmentSelection, selectAllAppointments, clearSelectedAppointments, @@ -62,6 +73,10 @@ export default function SchedulePage() { loadAppointments(); }, [appointmentDateRange.startDate, appointmentDateRange.endDate, statusFilter]); + useEffect(() => { + loadServiceOrders(); + }, []); + const loadAppointments = async () => { try { setLoading(true); @@ -78,6 +93,18 @@ export default function SchedulePage() { } }; + const loadServiceOrders = async () => { + try { + setServiceOrdersLoading(true); + const data = await fetchServiceOrders(); + setServiceOrders(data); + } catch (error) { + console.error("Error loading service orders:", error); + } finally { + setServiceOrdersLoading(false); + } + }; + const handleAppointmentSelect = (appointmentId: string, checked: boolean) => { if (checked) { if (!selectedAppointments.includes(appointmentId)) { @@ -101,70 +128,107 @@ export default function SchedulePage() { loadAppointments(); }; + const activeMenu = settingsView + ? "settings" + : requestsView + ? "requests" + : leftPanelView === "technicians" + ? "technicians" + : "home"; + return ( -
- {/* Left Sidebar Menu */} - { - setLeftPanelView("technicians"); - setSettingsView(false); - }} - onScheduleClick={() => { - setLeftPanelView("appointments"); - setSettingsView(false); - }} - onSettingsClick={() => { - setSettingsView(true); - }} - /> - - {settingsView ? ( - /* Settings View - Full Width */ -
- setSettingsView(false)} /> +
+
+
+
+ {/* Left Sidebar Menu */} + { + setRequestsView(false); + setLeftPanelView("technicians"); + setSettingsView(false); + }} + onScheduleClick={() => { + setRequestsView(false); + setLeftPanelView("appointments"); + setSettingsView(false); + }} + onRequestsClick={() => { + setRequestsView(true); + setSettingsView(false); + }} + onSettingsClick={() => { + setRequestsView(false); + setSettingsView(true); + }} + /> + + {settingsView ? ( + /* Settings View - Full Width */ +
+ setSettingsView(false)} /> +
+ ) : requestsView ? ( +
+ +
+ ) : ( + <> + {/* Left Panel - 20% */} +
+ {leftPanelView === "appointments" ? ( + + setLeftListMode(leftListMode === "orders" ? "appointments" : "orders") + } + serviceOrders={serviceOrders} + serviceOrdersLoading={serviceOrdersLoading} + /> + ) : ( + + )} +
+ + {/* Right Panel - 75% */} +
+ +
+ + )}
- ) : ( - <> - {/* Left Panel - 20% */} -
- {leftPanelView === "appointments" ? ( - - ) : ( - - )} -
- - {/* Right Panel - 75% */} -
- -
- - )} +
+ +
+ Powered By Beveren Software +
diff --git a/schedule/src/pages/schedule/types.ts b/schedule/src/pages/schedule/types.ts index 138b3db..3487e8a 100644 --- a/schedule/src/pages/schedule/types.ts +++ b/schedule/src/pages/schedule/types.ts @@ -37,3 +37,64 @@ export type AppointmentStatus = | "In Progress" | "Completed" | "Cancelled"; + +export interface ServiceRequestMovement { + name: string; + movement_type?: string; + destination?: string; + movement_date?: string; + linked_document_type?: string; + linked_document?: string; + handled_by?: string; +} + +export interface ServiceRequest { + name: string; + subject?: string; + customer?: string; + status: string; + posting_date?: string; + due_date?: string; + serial_no?: string; + item_code?: string; + item_name?: string; + current_product_location?: string; + description?: string; + product_movement?: ServiceRequestMovement[]; +} + +export interface ServiceOrderSummary { + name: string; + customer?: string; + status?: string; + priority?: string; + posting_date?: string; + type?: string; +} + +export interface ServiceOrderItem { + item_code?: string; + item_name?: string; + qty?: number; + uom?: string; + invoice_status?: string; + description?: string; +} + +export interface ServiceOrderDetail extends ServiceOrderSummary { + service_request?: string; + service_quotation?: string; + service_total?: number; + spareparts_total?: number; + grand_total?: number; + company?: string; + contact_person?: string; + contact_email?: string; + service_area?: string; + items?: ServiceOrderItem[]; + notes?: string; + priority?: string; + status?: string; + customer_address?: string; + posting_date?: string; +} diff --git a/schedule/src/store/schedule-store.ts b/schedule/src/store/schedule-store.ts index df3b326..510a6b2 100644 --- a/schedule/src/store/schedule-store.ts +++ b/schedule/src/store/schedule-store.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import { Appointment } from "../pages/schedule/types"; +import { Appointment, ServiceOrderSummary } from "../pages/schedule/types"; // Helper function to get initial viewType from localStorage const getInitialViewType = (): "gantt" | "grid" | "maps" | "calendar" => { @@ -17,18 +17,25 @@ interface ScheduleState { selectedAppointments: string[]; selectedAppointment: Appointment | null; loading: boolean; + serviceOrders: ServiceOrderSummary[]; + serviceOrdersLoading: boolean; // Filters selectedDate: Date; // For right panel (Gantt view) appointmentDateRange: { startDate: Date | null; endDate: Date | null }; // For left panel appointments list statusFilter: string; + serviceOrderStatusFilter: string; viewType: "gantt" | "grid" | "maps" | "calendar"; - leftPanelView: "appointments" | "technicians"; // New: track left panel view mode + leftPanelView: "appointments" | "technicians"; // Track left panel view mode + leftListMode: "orders" | "appointments"; // Track list content within schedule view settingsView: boolean; // Track if settings view is open + requestsView: boolean; // Track if service requests view is active // Actions setAppointments: (appointments: Appointment[]) => void; setLoading: (loading: boolean) => void; + setServiceOrders: (orders: ServiceOrderSummary[]) => void; + setServiceOrdersLoading: (loading: boolean) => void; setSelectedAppointments: (selectedAppointments: string[]) => void; toggleAppointmentSelection: (appointmentId: string) => void; selectAllAppointments: (appointmentIds: string[]) => void; @@ -37,9 +44,12 @@ interface ScheduleState { setSelectedDate: (date: Date) => void; setAppointmentDateRange: (range: { startDate: Date | null; endDate: Date | null }) => void; setStatusFilter: (filter: string) => void; + setServiceOrderStatusFilter: (filter: string) => void; setViewType: (view: "gantt" | "grid" | "maps" | "calendar") => void; setLeftPanelView: (view: "appointments" | "technicians") => void; + setLeftListMode: (mode: "orders" | "appointments") => void; setSettingsView: (open: boolean) => void; + setRequestsView: (open: boolean) => void; // Helper getters isAppointmentSelected: (appointmentId: string) => boolean; @@ -51,19 +61,26 @@ export const useScheduleStore = create((set, get) => ({ selectedAppointments: [], selectedAppointment: null, loading: false, + serviceOrders: [], + serviceOrdersLoading: false, selectedDate: new Date(), appointmentDateRange: { startDate: new Date(new Date().getFullYear(), 0, 1), // Start of year endDate: new Date(), // Today }, statusFilter: "all", + serviceOrderStatusFilter: "all", viewType: getInitialViewType(), - leftPanelView: "appointments", // Default to appointments view + leftPanelView: "appointments", + leftListMode: "orders", settingsView: false, // Settings view closed by default + requestsView: false, // Actions setAppointments: (appointments) => set({ appointments }), setLoading: (loading) => set({ loading }), + setServiceOrders: (orders) => set({ serviceOrders: orders }), + setServiceOrdersLoading: (loading) => set({ serviceOrdersLoading: loading }), setSelectedAppointments: (selectedAppointments) => set({ selectedAppointments }), toggleAppointmentSelection: (appointmentId) => set((state) => { @@ -85,6 +102,7 @@ export const useScheduleStore = create((set, get) => ({ setSelectedDate: (date) => set({ selectedDate: date }), setAppointmentDateRange: (range) => set({ appointmentDateRange: range }), setStatusFilter: (filter) => set({ statusFilter: filter }), + setServiceOrderStatusFilter: (filter) => set({ serviceOrderStatusFilter: filter }), setViewType: (view) => { set({ viewType: view }); // Save to localStorage @@ -93,7 +111,9 @@ export const useScheduleStore = create((set, get) => ({ } }, setLeftPanelView: (view) => set({ leftPanelView: view }), + setLeftListMode: (mode) => set({ leftListMode: mode }), setSettingsView: (open) => set({ settingsView: open }), + setRequestsView: (open) => set({ requestsView: open }), // Helper getters isAppointmentSelected: (appointmentId) => {