From 6c7402da0c684ec49b48fb7fef497e58d987f17f Mon Sep 17 00:00:00 2001 From: root Date: Thu, 16 Jul 2026 09:56:42 +0000 Subject: [PATCH] chore: add Engagement Task Type, Team Template, auto-title, reordered form --- AGENTS.md | 24 +++--- README.md | 18 ++-- .../deploy/frappe-bench-web.service | 2 +- .../fixtures/engagement_task_type.json | 27 ++++++ service_factory/service_factory/hooks.py | 4 +- .../service_factory/service_factory/.frappe | 0 .../doctype/engagement_card/.frappe | 0 .../engagement_card/engagement_card.js | 28 +++++-- .../engagement_card/engagement_card.json | 83 +++++++++++-------- .../engagement_card/engagement_card.py | 52 ++++++++++-- .../doctype/engagement_task_type/__init__.py | 0 .../engagement_task_type.json | 61 ++++++++++++++ .../engagement_task_type.py | 8 ++ .../doctype/team_template/__init__.py | 0 .../doctype/team_template/team_template.json | 71 ++++++++++++++++ .../doctype/team_template/team_template.py | 14 ++++ .../doctype/team_template_member/__init__.py | 0 .../team_template_member.json | 43 ++++++++++ .../team_template_member.py | 8 ++ 19 files changed, 371 insertions(+), 72 deletions(-) create mode 100644 service_factory/service_factory/fixtures/engagement_task_type.json create mode 100644 service_factory/service_factory/service_factory/.frappe create mode 100644 service_factory/service_factory/service_factory/doctype/engagement_card/.frappe create mode 100644 service_factory/service_factory/service_factory/doctype/engagement_task_type/__init__.py create mode 100644 service_factory/service_factory/service_factory/doctype/engagement_task_type/engagement_task_type.json create mode 100644 service_factory/service_factory/service_factory/doctype/engagement_task_type/engagement_task_type.py create mode 100644 service_factory/service_factory/service_factory/doctype/team_template/__init__.py create mode 100644 service_factory/service_factory/service_factory/doctype/team_template/team_template.json create mode 100644 service_factory/service_factory/service_factory/doctype/team_template/team_template.py create mode 100644 service_factory/service_factory/service_factory/doctype/team_template_member/__init__.py create mode 100644 service_factory/service_factory/service_factory/doctype/team_template_member/team_template_member.json create mode 100644 service_factory/service_factory/service_factory/doctype/team_template_member/team_template_member.py diff --git a/AGENTS.md b/AGENTS.md index 1b79190..8129540 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Service Factory — ERPNext Custom App Project -> **Current Version: V0.1.0010** — Each completed prompt/step increments the patch number by 1 (V0.1.0000 → V0.1.0001 → V0.1.0002…). The agent automatically commits and pushes after every step. +> **Current Version: V0.1.0011** — Each completed prompt/step increments the patch number by 1 (V0.1.0000 → V0.1.0001 → V0.1.0002…). The agent automatically commits and pushes after every step. ## Project Overview @@ -96,19 +96,19 @@ The **actual live app** lives at: | — | Deployment config with systemd, nginx, Zoraxy proxy | ✅ Done | | — | App icon + fixtures (seed data) | ✅ Done | -### DocTypes Created (5 + 3 child tables) +### DocTypes Created -**Master / Submittable: No** -- `Resource Center` — Team/unit with cost/billing rates - - Child: `Resource Center Member` — Employee allocation -- `Engagement Recipe` — Service template (like BOM) - - Child: `Engagement Recipe Step` — Ordered steps with dependencies +**Setup (Master)** +- `Engagement Task Type` — Task categories (Demo, POC, Deployment, RFP/RFI, Other) +- `Team Template` — Pre-defined teams by department + - Child: `Team Template Member` — Employee assignment with role -**Transaction / Submittable: Yes** -- `Engagement Order` — Active project run - - Child: `Engagement Order Run Step` — Per-step tracking -- `Execution Card` — Atomic work tracking - - Child: `Execution Card Log` — Time entries +**Transaction** +- `Engagement Card` — Universal work card (TEC) + - Child: `Engagement Card Assignment` — Multi-person team allocation + - Child: `Engagement Card Activity` — Activity log with milestones + - Child: `Engagement Card Time Log` — Per-employee hours tracking + - Child: `Engagement Card Subtask` — Delegatable breakdown items ### Business Logic (server-side) diff --git a/README.md b/README.md index 9714009..0bb9bdb 100644 --- a/README.md +++ b/README.md @@ -75,15 +75,17 @@ bench install-app service_factory ## DocTypes -### Engagement Card (the core) +### Setup (Master) +- **Engagement Task Type** — Task categories (Demo, POC, Deployment, RFP/RFI, Other) +- **Team Template** — Pre-defined teams by department + - Child: **Team Template Member** — Employee assignment with role -The primary work tracking DocType with: -- **Assignments** — Multi-person team allocation -- **Activity Log** — Timestamped comments, milestones, notes -- **Time Log** — Per-employee hours tracking -- **Subtasks** — Delegatable breakdown items - -Card types: SE (Sales Engineering), PS (Professional Services), MS (Managed Services), Support +### Transaction +- **Engagement Card** — Universal work card (TEC) + - Child: **Engagement Card Assignment** — Multi-person team allocation + - Child: **Engagement Card Activity** — Activity log with milestones + - Child: **Engagement Card Time Log** — Per-employee hours tracking + - Child: **Engagement Card Subtask** — Delegatable breakdown items ### Status Flow ``` diff --git a/service_factory/deploy/frappe-bench-web.service b/service_factory/deploy/frappe-bench-web.service index 2ed9db8..e5b7b90 100644 --- a/service_factory/deploy/frappe-bench-web.service +++ b/service_factory/deploy/frappe-bench-web.service @@ -9,7 +9,7 @@ Type=simple User=erpnext Group=erpnext WorkingDirectory=/opt/erpnext/frappe-bench -ExecStart=/opt/erpnext/frappe-bench/bench-env.sh bench serve --port 8000 --proxy +ExecStart=/opt/erpnext/frappe-bench/bench-env.sh bench serve --port 8001 --proxy Restart=always RestartSec=10 StandardOutput=append:/opt/erpnext/frappe-bench/logs/web.log diff --git a/service_factory/service_factory/fixtures/engagement_task_type.json b/service_factory/service_factory/fixtures/engagement_task_type.json new file mode 100644 index 0000000..636633b --- /dev/null +++ b/service_factory/service_factory/fixtures/engagement_task_type.json @@ -0,0 +1,27 @@ +[ + { + "name": "Demo", + "task_name": "Demo", + "doctype": "Engagement Task Type" + }, + { + "name": "POC", + "task_name": "POC", + "doctype": "Engagement Task Type" + }, + { + "name": "Deployment", + "task_name": "Deployment", + "doctype": "Engagement Task Type" + }, + { + "name": "RFP / RFI", + "task_name": "RFP / RFI", + "doctype": "Engagement Task Type" + }, + { + "name": "Other", + "task_name": "Other", + "doctype": "Engagement Task Type" + } +] diff --git a/service_factory/service_factory/hooks.py b/service_factory/service_factory/hooks.py index fc132ba..99c3e99 100644 --- a/service_factory/service_factory/hooks.py +++ b/service_factory/service_factory/hooks.py @@ -171,7 +171,9 @@ doctype_js = {"Engagement Card" : "doctype/engagement_card/engagement_card.js"} # Add new DocType records here to make them portable # across all server instances. # ----------------------------------------- -fixtures = [] +fixtures = [ + {"dt": "Engagement Task Type"}, +] # Testing # ------- diff --git a/service_factory/service_factory/service_factory/.frappe b/service_factory/service_factory/service_factory/.frappe new file mode 100644 index 0000000..e69de29 diff --git a/service_factory/service_factory/service_factory/doctype/engagement_card/.frappe b/service_factory/service_factory/service_factory/doctype/engagement_card/.frappe new file mode 100644 index 0000000..e69de29 diff --git a/service_factory/service_factory/service_factory/doctype/engagement_card/engagement_card.js b/service_factory/service_factory/service_factory/doctype/engagement_card/engagement_card.js index 3613be1..73df545 100644 --- a/service_factory/service_factory/service_factory/doctype/engagement_card/engagement_card.js +++ b/service_factory/service_factory/service_factory/doctype/engagement_card/engagement_card.js @@ -3,7 +3,7 @@ frappe.ui.form.on("Engagement Card", { refresh: function (frm) { - // Add quick actions in the form toolbar + // Quick status actions in toolbar if (frm.doc.status === "Discovery") { frm.add_custom_button(__("Move to Delivery"), function () { frm.set_value("status", "Delivery"); @@ -22,7 +22,7 @@ frappe.ui.form.on("Engagement Card", { }); } - // Add log activity quick button + // Log Activity quick action frm.add_custom_button(__("Log Activity"), function () { let d = new frappe.ui.Dialog({ title: __("Log Activity"), @@ -61,16 +61,30 @@ frappe.ui.form.on("Engagement Card", { d.show(); }); }, -}); -frappe.ui.form.on("Engagement Card Time Log", { - hours: function (frm, cdt, cdn) { - // Trigger parent rollup when hours change in the grid - frm.trigger("update_total_hours"); + // Filter opportunities by selected customer + customer: function (frm) { + if (frm.doc.customer) { + frm.set_query("opportunity", function () { + return { + filters: { + customer: frm.doc.customer, + }, + }; + }); + // Clear opportunity if it doesn't match the new customer + frm.set_value("opportunity", null); + } else { + frm.set_query("opportunity", null); + } }, }); +// Time log hours rollup frappe.ui.form.on("Engagement Card Time Log", { + hours: function (frm, cdt, cdn) { + frm.trigger("update_total_hours"); + }, time_log_remove: function (frm) { frm.trigger("update_total_hours"); }, diff --git a/service_factory/service_factory/service_factory/doctype/engagement_card/engagement_card.json b/service_factory/service_factory/service_factory/doctype/engagement_card/engagement_card.json index 328edcc..3f280b0 100644 --- a/service_factory/service_factory/service_factory/doctype/engagement_card/engagement_card.json +++ b/service_factory/service_factory/service_factory/doctype/engagement_card/engagement_card.json @@ -6,20 +6,24 @@ "allow_import": 1, "allow_rename": 0, "autoname": "naming_series:", - "creation": "2026-07-15 23:00:00.000000", + "creation": "2026-07-16 10:00:00.000000", "doctype": "DocType", "document_type": "Document", "editable_grid": 1, "engine": "InnoDB", "field_order": [ "naming_series", - "title", "card_type", - "status", + "task", + "title", "column_break_1", "priority", "customer", "opportunity", + "section_break_team", + "team", + "section_break_desc", + "description", "section_break_dates", "start_date", "target_end_date", @@ -30,8 +34,6 @@ "total_logged_hours", "column_break_budget_2", "sales_order", - "section_break_desc", - "description", "section_break_assignments", "assignments", "section_break_activity", @@ -45,7 +47,7 @@ { "fieldname": "naming_series", "fieldtype": "Select", - "hidden": 0, + "hidden": 1, "label": "Series", "no_copy": 1, "options": "TEC-.YYYY.-", @@ -53,15 +55,6 @@ "reqd": 1, "set_only_once": 1 }, - { - "fieldname": "title", - "fieldtype": "Data", - "in_global_search": 1, - "in_list_view": 1, - "in_standard_filter": 1, - "label": "Title", - "reqd": 1 - }, { "fieldname": "card_type", "fieldtype": "Select", @@ -72,28 +65,37 @@ "reqd": 1 }, { - "fieldname": "status", - "fieldtype": "Select", + "fieldname": "task", + "fieldtype": "Link", "in_list_view": 1, "in_standard_filter": 1, - "label": "Status", - "options": "Discovery\nDelivery\nComplete\nArchived", - "reqd": 1, - "default": "Discovery" + "label": "Task", + "options": "Engagement Task Type", + "reqd": 1 + }, + { + "fieldname": "title", + "fieldtype": "Data", + "in_global_search": 1, + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Title", + "no_copy": 1, + "read_only": 1 }, { "fieldname": "column_break_1", "fieldtype": "Column Break" }, { + "default": "Normal", "fieldname": "priority", "fieldtype": "Select", "in_list_view": 1, "in_standard_filter": 1, "label": "Priority", - "options": "Low\nMedium\nHigh\nCritical", - "reqd": 1, - "default": "Medium" + "options": "Low\nNormal\nHigh\nCritical", + "reqd": 1 }, { "fieldname": "customer", @@ -110,6 +112,27 @@ "label": "Opportunity", "options": "Opportunity" }, + { + "fieldname": "section_break_team", + "fieldtype": "Section Break", + "label": "Team" + }, + { + "fieldname": "team", + "fieldtype": "Link", + "label": "Team", + "options": "Team Template" + }, + { + "fieldname": "section_break_desc", + "fieldtype": "Section Break", + "label": "Description" + }, + { + "fieldname": "description", + "fieldtype": "Text Editor", + "label": "Description" + }, { "fieldname": "section_break_dates", "fieldtype": "Section Break", @@ -161,16 +184,6 @@ "label": "Sales Order", "options": "Sales Order" }, - { - "fieldname": "section_break_desc", - "fieldtype": "Section Break", - "label": "Description" - }, - { - "fieldname": "description", - "fieldtype": "Text Editor", - "label": "Description" - }, { "fieldname": "section_break_assignments", "fieldtype": "Section Break", @@ -218,7 +231,7 @@ } ], "links": [], - "modified": "2026-07-15 23:00:00.000000", + "modified": "2026-07-16 10:00:00.000000", "modified_by": "Administrator", "module": "Service Factory", "name": "Engagement Card", diff --git a/service_factory/service_factory/service_factory/doctype/engagement_card/engagement_card.py b/service_factory/service_factory/service_factory/doctype/engagement_card/engagement_card.py index 680fb4c..204142d 100644 --- a/service_factory/service_factory/service_factory/doctype/engagement_card/engagement_card.py +++ b/service_factory/service_factory/service_factory/doctype/engagement_card/engagement_card.py @@ -9,12 +9,44 @@ from frappe import _ class EngagementCard(Document): def validate(self): self.set_title() + self.sync_team_members() self.update_logged_hours() def set_title(self): - """Set card title if not provided.""" - if not self.title: - self.title = f"{self.card_type} - {self.customer or 'Unnamed'}" + """Auto-generate title: {customer} | {opportunity} | {task}""" + parts = [] + if self.customer: + customer_name = frappe.db.get_value("Customer", self.customer, "customer_name") + parts.append(customer_name or self.customer) + if self.opportunity: + opp_name = frappe.db.get_value("Opportunity", self.opportunity, "opportunity_name") + parts.append(opp_name or self.opportunity) + if self.task: + task_name = frappe.db.get_value("Engagement Task Type", self.task, "task_name") + parts.append(task_name or self.task) + + if parts: + self.title = " | ".join(parts) + else: + self.title = f"EC-{self.name or 'new'}" + + def sync_team_members(self): + """Populate assignments from selected Team Template.""" + if not self.team: + return + + # Only sync if team changed (not on every save) + if not self.has_value_changed("team"): + return + + template = frappe.get_doc("Team Template", self.team) + self.set("assignments", []) + for member in template.members: + row = self.append("assignments", {}) + row.employee = member.employee + row.role = member.role + row.allocated_hours = 0 + row.logged_hours = 0 def update_logged_hours(self): """Roll up total logged hours from time log child table.""" @@ -25,7 +57,6 @@ class EngagementCard(Document): self.total_logged_hours = total def on_update(self): - """Update rollups on assignment table when time log changes.""" self.sync_assignment_hours() def sync_assignment_hours(self): @@ -33,25 +64,30 @@ class EngagementCard(Document): if not self.get("assignments") or not self.get("time_log"): return - # Build employee -> total hours from time log employee_hours = {} for entry in self.time_log: emp = entry.get("employee") if emp: employee_hours[emp] = employee_hours.get(emp, 0) + (entry.get("hours", 0) or 0) - # Update assignment rows for assignment in self.assignments: emp = assignment.get("employee") if emp and emp in employee_hours: assignment.logged_hours = employee_hours[emp] +@frappe.whitelist() +def get_teams_by_department(department=None): + """Return team templates optionally filtered by department.""" + filters = {} + if department: + filters["department"] = department + return frappe.get_all("Team Template", filters=filters, fields=["name", "team_name", "department"]) + + @frappe.whitelist() def get_kpi_data(): """Return dashboard KPI data for Engagement Cards.""" - from frappe.desk.doctype.number_card.number_card import get_number_card - total_cards = frappe.db.count("Engagement Card") in_delivery = frappe.db.count("Engagement Card", {"status": "Delivery"}) in_discovery = frappe.db.count("Engagement Card", {"status": "Discovery"}) diff --git a/service_factory/service_factory/service_factory/doctype/engagement_task_type/__init__.py b/service_factory/service_factory/service_factory/doctype/engagement_task_type/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/service_factory/service_factory/service_factory/doctype/engagement_task_type/engagement_task_type.json b/service_factory/service_factory/service_factory/doctype/engagement_task_type/engagement_task_type.json new file mode 100644 index 0000000..a2de7be --- /dev/null +++ b/service_factory/service_factory/service_factory/doctype/engagement_task_type/engagement_task_type.json @@ -0,0 +1,61 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:task_name", + "creation": "2026-07-16 10:00:00.000000", + "doctype": "DocType", + "document_type": "Setup", + "engine": "InnoDB", + "field_order": [ + "task_name" + ], + "fields": [ + { + "fieldname": "task_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Task Name", + "reqd": 1, + "unique": 1 + } + ], + "links": [], + "modified": "2026-07-16 10:00:00.000000", + "modified_by": "Administrator", + "module": "Service Factory", + "name": "Engagement Task Type", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "select": 1, + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "All", + "select": 1, + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/service_factory/service_factory/service_factory/doctype/engagement_task_type/engagement_task_type.py b/service_factory/service_factory/service_factory/doctype/engagement_task_type/engagement_task_type.py new file mode 100644 index 0000000..d42fc83 --- /dev/null +++ b/service_factory/service_factory/service_factory/doctype/engagement_task_type/engagement_task_type.py @@ -0,0 +1,8 @@ +# Copyright (c) 2026, cclohmar and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class EngagementTaskType(Document): + pass diff --git a/service_factory/service_factory/service_factory/doctype/team_template/__init__.py b/service_factory/service_factory/service_factory/doctype/team_template/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/service_factory/service_factory/service_factory/doctype/team_template/team_template.json b/service_factory/service_factory/service_factory/doctype/team_template/team_template.json new file mode 100644 index 0000000..8cf7b1c --- /dev/null +++ b/service_factory/service_factory/service_factory/doctype/team_template/team_template.json @@ -0,0 +1,71 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:team_name", + "creation": "2026-07-16 10:00:00.000000", + "doctype": "DocType", + "document_type": "Setup", + "engine": "InnoDB", + "field_order": [ + "team_name", + "department", + "section_break_members", + "members" + ], + "fields": [ + { + "fieldname": "team_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Team Name", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "department", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Department", + "options": "Department" + }, + { + "fieldname": "section_break_members", + "fieldtype": "Section Break", + "label": "Team Members" + }, + { + "fieldname": "members", + "fieldtype": "Table", + "label": "Members", + "options": "Team Template Member", + "reqd": 0 + } + ], + "links": [], + "modified": "2026-07-16 10:00:00.000000", + "modified_by": "Administrator", + "module": "Service Factory", + "name": "Team Template", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "select": 1, + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/service_factory/service_factory/service_factory/doctype/team_template/team_template.py b/service_factory/service_factory/service_factory/doctype/team_template/team_template.py new file mode 100644 index 0000000..a7258dd --- /dev/null +++ b/service_factory/service_factory/service_factory/doctype/team_template/team_template.py @@ -0,0 +1,14 @@ +# Copyright (c) 2026, cclohmar and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document + + +class TeamTemplate(Document): + def on_update(self): + """Clear team cache when template changes.""" + frappe.cache.delete_key("team_templates") + + def after_insert(self): + self.on_update() diff --git a/service_factory/service_factory/service_factory/doctype/team_template_member/__init__.py b/service_factory/service_factory/service_factory/doctype/team_template_member/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/service_factory/service_factory/service_factory/doctype/team_template_member/team_template_member.json b/service_factory/service_factory/service_factory/doctype/team_template_member/team_template_member.json new file mode 100644 index 0000000..01847c7 --- /dev/null +++ b/service_factory/service_factory/service_factory/doctype/team_template_member/team_template_member.json @@ -0,0 +1,43 @@ +{ + "actions": [], + "allow_rename": 0, + "autoname": "hash", + "creation": "2026-07-16 10:00:00.000000", + "doctype": "DocType", + "document_type": "Document", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "employee", + "role" + ], + "fields": [ + { + "fieldname": "employee", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Employee", + "options": "Employee", + "reqd": 1 + }, + { + "fieldname": "role", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Role" + } + ], + "istable": 1, + "links": [], + "modified": "2026-07-16 10:00:00.000000", + "modified_by": "Administrator", + "module": "Service Factory", + "name": "Team Template Member", + "naming_rule": "Random", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 0 +} diff --git a/service_factory/service_factory/service_factory/doctype/team_template_member/team_template_member.py b/service_factory/service_factory/service_factory/doctype/team_template_member/team_template_member.py new file mode 100644 index 0000000..3047eba --- /dev/null +++ b/service_factory/service_factory/service_factory/doctype/team_template_member/team_template_member.py @@ -0,0 +1,8 @@ +# Copyright (c) 2026, cclohmar and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class TeamTemplateMember(Document): + pass