chore: add Engagement Task Type, Team Template, auto-title, reordered form

This commit is contained in:
root 2026-07-16 09:56:42 +00:00
parent b6103cbb01
commit 6c7402da0c
19 changed files with 371 additions and 72 deletions

View file

@ -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)

View file

@ -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
```

View file

@ -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

View file

@ -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"
}
]

View file

@ -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
# -------

View 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");
},

View file

@ -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",

View file

@ -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"})

View file

@ -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
}

View file

@ -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

View file

@ -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
}

View file

@ -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()

View file

@ -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
}

View file

@ -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