chore: add Engagement Task Type, Team Template, auto-title, reordered form
This commit is contained in:
parent
b6103cbb01
commit
6c7402da0c
19 changed files with 371 additions and 72 deletions
24
AGENTS.md
24
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)
|
||||
|
||||
|
|
|
|||
18
README.md
18
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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
@ -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
|
||||
# -------
|
||||
|
|
|
|||
0
service_factory/service_factory/service_factory/.frappe
Normal file
0
service_factory/service_factory/service_factory/.frappe
Normal file
|
|
@ -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");
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
Loading…
Reference in a new issue