diff --git a/AGENTS.md b/AGENTS.md
index 253e497..86e85c2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,6 +1,6 @@
# Service Factory — ERPNext Custom App Project
-> **Current Version: V0.1.0002** — 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.0003** — 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
diff --git a/service_factory/.editorconfig b/service_factory/.editorconfig
new file mode 100644
index 0000000..73816f1
--- /dev/null
+++ b/service_factory/.editorconfig
@@ -0,0 +1,21 @@
+# Root editor config file
+root = true
+
+# Common settings
+[*]
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+charset = utf-8
+
+# python, js indentation settings
+[{*.py,*.js,*.vue,*.css,*.scss,*.html}]
+indent_style = tab
+indent_size = 4
+max_line_length = 99
+
+# JSON files - mostly doctype schema files
+[{*.json}]
+insert_final_newline = false
+indent_style = space
+indent_size = 1
diff --git a/service_factory/.eslintrc b/service_factory/.eslintrc
new file mode 100644
index 0000000..523328a
--- /dev/null
+++ b/service_factory/.eslintrc
@@ -0,0 +1,124 @@
+{
+ "env": {
+ "browser": true,
+ "node": true,
+ "es2022": true
+ },
+ "parserOptions": {
+ "sourceType": "module"
+ },
+ "extends": "eslint:recommended",
+ "rules": {
+ "indent": "off",
+ "brace-style": "off",
+ "no-mixed-spaces-and-tabs": "off",
+ "no-useless-escape": "off",
+ "space-unary-ops": ["error", { "words": true }],
+ "linebreak-style": "off",
+ "quotes": ["off"],
+ "semi": "off",
+ "camelcase": "off",
+ "no-unused-vars": "off",
+ "no-console": ["warn"],
+ "no-extra-boolean-cast": ["off"],
+ "no-control-regex": ["off"],
+ },
+ "root": true,
+ "globals": {
+ "frappe": true,
+ "Vue": true,
+ "SetVueGlobals": true,
+ "__": true,
+ "repl": true,
+ "Class": true,
+ "locals": true,
+ "cint": true,
+ "cstr": true,
+ "cur_frm": true,
+ "cur_dialog": true,
+ "cur_page": true,
+ "cur_list": true,
+ "cur_tree": true,
+ "msg_dialog": true,
+ "is_null": true,
+ "in_list": true,
+ "has_common": true,
+ "has_words": true,
+ "validate_email": true,
+ "open_web_template_values_editor": true,
+ "validate_name": true,
+ "validate_phone": true,
+ "validate_url": true,
+ "get_number_format": true,
+ "format_number": true,
+ "format_currency": true,
+ "comment_when": true,
+ "open_url_post": true,
+ "toTitle": true,
+ "lstrip": true,
+ "rstrip": true,
+ "strip": true,
+ "strip_html": true,
+ "replace_all": true,
+ "flt": true,
+ "precision": true,
+ "CREATE": true,
+ "AMEND": true,
+ "CANCEL": true,
+ "copy_dict": true,
+ "get_number_format_info": true,
+ "strip_number_groups": true,
+ "print_table": true,
+ "Layout": true,
+ "web_form_settings": true,
+ "$c": true,
+ "$a": true,
+ "$i": true,
+ "$bg": true,
+ "$y": true,
+ "$c_obj": true,
+ "refresh_many": true,
+ "refresh_field": true,
+ "toggle_field": true,
+ "get_field_obj": true,
+ "get_query_params": true,
+ "unhide_field": true,
+ "hide_field": true,
+ "set_field_options": true,
+ "getCookie": true,
+ "getCookies": true,
+ "get_url_arg": true,
+ "md5": true,
+ "$": true,
+ "jQuery": true,
+ "moment": true,
+ "hljs": true,
+ "Awesomplete": true,
+ "Sortable": true,
+ "gemoji": true,
+ "Showdown": true,
+ "Taggle": true,
+ "Gantt": true,
+ "Slick": true,
+ "Webcam": true,
+ "PhotoSwipe": true,
+ "PhotoSwipeUI_Default": true,
+ "io": true,
+ "JsBarcode": true,
+ "L": true,
+ "Chart": true,
+ "DataTable": true,
+ "Cypress": true,
+ "cy": true,
+ "it": true,
+ "describe": true,
+ "expect": true,
+ "context": true,
+ "before": true,
+ "beforeEach": true,
+ "after": true,
+ "qz": true,
+ "localforage": true,
+ "extend_cscript": true
+ }
+}
diff --git a/service_factory/.gitignore b/service_factory/.gitignore
new file mode 100644
index 0000000..b50d332
--- /dev/null
+++ b/service_factory/.gitignore
@@ -0,0 +1,55 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+*.pyc
+*.py~
+
+# Distribution / packaging
+.Python
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+tags
+MANIFEST
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# IDEs and editors
+.vscode/
+.vs/
+.idea/
+.kdev4/
+*.kdev4
+*.DS_Store
+*.swp
+*.comp.js
+.wnf-lang-status
+*debug.log
+
+# Helix Editor
+.helix/
+
+# Aider AI Chat
+.aider*
diff --git a/service_factory/.pre-commit-config.yaml b/service_factory/.pre-commit-config.yaml
new file mode 100644
index 0000000..3b10c29
--- /dev/null
+++ b/service_factory/.pre-commit-config.yaml
@@ -0,0 +1,69 @@
+exclude: 'node_modules|.git'
+default_stages: [pre-commit]
+fail_fast: false
+
+
+repos:
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v6.0.0
+ hooks:
+ - id: trailing-whitespace
+ files: "service_factory.*"
+ exclude: ".*json$|.*txt$|.*csv|.*md|.*svg"
+ - id: check-merge-conflict
+ - id: check-ast
+ - id: check-json
+ - id: check-toml
+ - id: check-yaml
+ - id: debug-statements
+
+ - repo: https://github.com/astral-sh/ruff-pre-commit
+ rev: v0.14.10
+ hooks:
+ - id: ruff
+ name: "Run ruff import sorter"
+ args: ["--select=I", "--fix"]
+
+ - id: ruff
+ name: "Run ruff linter"
+
+ - id: ruff-format
+ name: "Run ruff formatter"
+
+ - repo: https://github.com/pre-commit/mirrors-prettier
+ rev: v2.7.1
+ hooks:
+ - id: prettier
+ types_or: [javascript, vue, scss]
+ # Ignore any files that might contain jinja / bundles
+ exclude: |
+ (?x)^(
+ service_factory/public/dist/.*|
+ .*node_modules.*|
+ .*boilerplate.*|
+ service_factory/templates/includes/.*|
+ service_factory/public/js/lib/.*
+ )$
+
+
+ - repo: https://github.com/pre-commit/mirrors-eslint
+ rev: v8.44.0
+ hooks:
+ - id: eslint
+ types_or: [javascript]
+ args: ['--quiet']
+ # Ignore any files that might contain jinja / bundles
+ exclude: |
+ (?x)^(
+ service_factory/public/dist/.*|
+ cypress/.*|
+ .*node_modules.*|
+ .*boilerplate.*|
+ service_factory/templates/includes/.*|
+ service_factory/public/js/lib/.*
+ )$
+
+ci:
+ autoupdate_schedule: weekly
+ skip: []
+ submodules: false
diff --git a/service_factory/README.md b/service_factory/README.md
new file mode 100644
index 0000000..806565e
--- /dev/null
+++ b/service_factory/README.md
@@ -0,0 +1,33 @@
+### Service Factory
+
+Professional Services Delivery & Capacity Management
+
+### Installation
+
+You can install this app using the [bench](https://github.com/frappe/bench) CLI:
+
+```bash
+cd $PATH_TO_YOUR_BENCH
+bench get-app $URL_OF_THIS_REPO --branch version-16
+bench install-app service_factory
+```
+
+### Contributing
+
+This app uses `pre-commit` for code formatting and linting. Please [install pre-commit](https://pre-commit.com/#installation) and enable it for this repository:
+
+```bash
+cd apps/service_factory
+pre-commit install
+```
+
+Pre-commit is configured to use the following tools for checking and formatting your code:
+
+- ruff
+- eslint
+- prettier
+- pyupgrade
+
+### License
+
+mit
diff --git a/service_factory/deploy/README.md b/service_factory/deploy/README.md
new file mode 100644
index 0000000..8778e93
--- /dev/null
+++ b/service_factory/deploy/README.md
@@ -0,0 +1,52 @@
+# Deployment Configuration
+
+## Systemd Services
+
+The Frappe Bench (ERPNext) is managed via systemd for automatic startup on boot.
+
+### Services
+
+| Service | Description | Port |
+|---------|-------------|------|
+| `frappe-bench-web.service` | Web server (gunicorn) | 8000 |
+| `frappe-bench-worker.service` | Background async worker | - |
+| `frappe-bench-schedule.service` | Scheduled job runner | - |
+| `frappe-bench-socketio.service` | Realtime WebSocket server | 9000 |
+| `frappe-bench-watch.service` | File watcher (dev/assets) | - |
+
+### Management
+
+```bash
+# View status of all services
+systemctl status frappe-bench-web.service
+
+# Start/Stop all services
+systemctl start frappe-bench.target
+systemctl stop frappe-bench.target
+
+# View logs
+journalctl -u frappe-bench-web.service -f
+journalctl -u frappe-bench-worker.service -f
+```
+
+### Installation
+
+```bash
+sudo cp deploy/frappe-bench-*.service deploy/frappe-bench.target /etc/systemd/system/
+sudo cp deploy/bench-env.sh /opt/erpnext/frappe-bench/bench-env.sh
+sudo systemctl daemon-reload
+sudo systemctl enable frappe-bench.target
+sudo systemctl start frappe-bench.target
+```
+
+### Assets Symlink
+
+After installing the app, create the assets symlink for custom icons:
+
+```bash
+sudo ln -sf /opt/erpnext/frappe-bench/apps/service_factory/service_factory/public /opt/erpnext/frappe-bench/sites/assets/service_factory
+```
+
+### Credentials
+
+Admin credentials are stored in `/root/erpnext-admin-credentials.txt`.
diff --git a/service_factory/deploy/bench-env.sh b/service_factory/deploy/bench-env.sh
new file mode 100755
index 0000000..e4d5698
--- /dev/null
+++ b/service_factory/deploy/bench-env.sh
@@ -0,0 +1,6 @@
+#!/bin/bash
+# Environment wrapper for Frappe bench services
+export PATH="/opt/erpnext/home/.local/share/pipx/venvs/frappe-bench/bin:/opt/erpnext/frappe-bench/env/bin:$PATH"
+export HOME="/opt/erpnext/home"
+cd /opt/erpnext/frappe-bench
+exec "$@"
diff --git a/service_factory/deploy/frappe-bench b/service_factory/deploy/frappe-bench
new file mode 100644
index 0000000..1bf78ca
--- /dev/null
+++ b/service_factory/deploy/frappe-bench
@@ -0,0 +1,42 @@
+upstream frappe-web {
+ server 127.0.0.1:8000;
+}
+
+upstream frappe-socketio {
+ server 127.0.0.1:9000;
+}
+
+server {
+ listen 8080;
+ server_name _;
+
+ client_max_body_size 50M;
+
+ # Route ALL requests containing /socket.io/ to the SocketIO server
+ # This handles both /socket.io/ and /erp.lohmar.co.uk/socket.io/
+ location ~* /socket\.io/ {
+ rewrite ^(.*)/socket\.io/(.*)$ /socket.io/$2 break;
+ proxy_pass http://frappe-socketio;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_read_timeout 86400;
+ }
+
+ # All other requests go to Frappe web
+ location / {
+ proxy_pass http://frappe-web;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-Host $host;
+ proxy_read_timeout 300;
+ proxy_redirect off;
+ }
+}
diff --git a/service_factory/deploy/frappe-bench-schedule.service b/service_factory/deploy/frappe-bench-schedule.service
new file mode 100644
index 0000000..8ee645c
--- /dev/null
+++ b/service_factory/deploy/frappe-bench-schedule.service
@@ -0,0 +1,19 @@
+[Unit]
+Description=Frappe Bench Scheduler
+Documentation=https://frappeframework.com
+After=network.target redis-server.service
+Requires=redis-server.service
+
+[Service]
+Type=simple
+User=erpnext
+Group=erpnext
+WorkingDirectory=/opt/erpnext/frappe-bench
+ExecStart=/opt/erpnext/frappe-bench/bench-env.sh bench schedule
+Restart=always
+RestartSec=10
+StandardOutput=append:/opt/erpnext/frappe-bench/logs/schedule.log
+StandardError=append:/opt/erpnext/frappe-bench/logs/schedule.error.log
+
+[Install]
+WantedBy=multi-user.target
diff --git a/service_factory/deploy/frappe-bench-socketio.service b/service_factory/deploy/frappe-bench-socketio.service
new file mode 100644
index 0000000..2888d5b
--- /dev/null
+++ b/service_factory/deploy/frappe-bench-socketio.service
@@ -0,0 +1,19 @@
+[Unit]
+Description=Frappe Bench SocketIO (Realtime)
+Documentation=https://frappeframework.com
+After=network.target redis-server.service
+Requires=redis-server.service
+
+[Service]
+Type=simple
+User=erpnext
+Group=erpnext
+WorkingDirectory=/opt/erpnext/frappe-bench
+ExecStart=/opt/erpnext/frappe-bench/bench-env.sh bench socketio
+Restart=always
+RestartSec=10
+StandardOutput=append:/opt/erpnext/frappe-bench/logs/socketio.log
+StandardError=append:/opt/erpnext/frappe-bench/logs/socketio.error.log
+
+[Install]
+WantedBy=multi-user.target
diff --git a/service_factory/deploy/frappe-bench-watch.service b/service_factory/deploy/frappe-bench-watch.service
new file mode 100644
index 0000000..12ff4da
--- /dev/null
+++ b/service_factory/deploy/frappe-bench-watch.service
@@ -0,0 +1,19 @@
+[Unit]
+Description=Frappe Bench File Watcher (Development)
+Documentation=https://frappeframework.com
+After=network.target
+PartOf=frappe-bench-web.service
+
+[Service]
+Type=simple
+User=erpnext
+Group=erpnext
+WorkingDirectory=/opt/erpnext/frappe-bench
+ExecStart=/opt/erpnext/frappe-bench/bench-env.sh bench watch
+Restart=always
+RestartSec=10
+StandardOutput=append:/opt/erpnext/frappe-bench/logs/watch.log
+StandardError=append:/opt/erpnext/frappe-bench/logs/watch.error.log
+
+[Install]
+WantedBy=multi-user.target
diff --git a/service_factory/deploy/frappe-bench-web.service b/service_factory/deploy/frappe-bench-web.service
new file mode 100644
index 0000000..2ed9db8
--- /dev/null
+++ b/service_factory/deploy/frappe-bench-web.service
@@ -0,0 +1,19 @@
+[Unit]
+Description=Frappe Bench Web Server (ERPNext)
+Documentation=https://frappeframework.com
+After=network.target redis-server.service
+Requires=redis-server.service
+
+[Service]
+Type=simple
+User=erpnext
+Group=erpnext
+WorkingDirectory=/opt/erpnext/frappe-bench
+ExecStart=/opt/erpnext/frappe-bench/bench-env.sh bench serve --port 8000 --proxy
+Restart=always
+RestartSec=10
+StandardOutput=append:/opt/erpnext/frappe-bench/logs/web.log
+StandardError=append:/opt/erpnext/frappe-bench/logs/web.error.log
+
+[Install]
+WantedBy=multi-user.target
diff --git a/service_factory/deploy/frappe-bench-worker.service b/service_factory/deploy/frappe-bench-worker.service
new file mode 100644
index 0000000..cb96be6
--- /dev/null
+++ b/service_factory/deploy/frappe-bench-worker.service
@@ -0,0 +1,19 @@
+[Unit]
+Description=Frappe Bench Background Worker
+Documentation=https://frappeframework.com
+After=network.target redis-server.service
+Requires=redis-server.service
+
+[Service]
+Type=simple
+User=erpnext
+Group=erpnext
+WorkingDirectory=/opt/erpnext/frappe-bench
+ExecStart=/opt/erpnext/frappe-bench/bench-env.sh bench worker
+Restart=always
+RestartSec=10
+StandardOutput=append:/opt/erpnext/frappe-bench/logs/worker.log
+StandardError=append:/opt/erpnext/frappe-bench/logs/worker.error.log
+
+[Install]
+WantedBy=multi-user.target
diff --git a/service_factory/deploy/frappe-bench.target b/service_factory/deploy/frappe-bench.target
new file mode 100644
index 0000000..ebc0c5e
--- /dev/null
+++ b/service_factory/deploy/frappe-bench.target
@@ -0,0 +1,9 @@
+[Unit]
+Description=Frappe Bench (ERPNext) - All Services
+Documentation=https://frappeframework.com
+Requires=redis-server.service
+After=redis-server.service network.target
+Wants=frappe-bench-web.service frappe-bench-worker.service frappe-bench-schedule.service frappe-bench-socketio.service frappe-bench-watch.service
+
+[Install]
+WantedBy=multi-user.target
diff --git a/service_factory/license.txt b/service_factory/license.txt
new file mode 100644
index 0000000..8aa2645
--- /dev/null
+++ b/service_factory/license.txt
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) [year] [fullname]
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/service_factory/pyproject.toml b/service_factory/pyproject.toml
new file mode 100644
index 0000000..733c886
--- /dev/null
+++ b/service_factory/pyproject.toml
@@ -0,0 +1,65 @@
+[project]
+name = "service_factory"
+authors = [
+ { name = "cclohmar", email = "claus.lohmar@lohmar.co.uk"}
+]
+description = "Professional Services Delivery & Capacity Management"
+requires-python = ">=3.14"
+readme = "README.md"
+dynamic = ["version"]
+dependencies = [
+ # "frappe~=16.0.0" # Installed and managed by bench.
+]
+
+[build-system]
+requires = ["flit_core >=3.4,<4"]
+build-backend = "flit_core.buildapi"
+
+# These dependencies are only installed when developer mode is enabled
+[tool.bench.dev-dependencies]
+# package_name = "~=1.1.0"
+
+# These apt dependencies will be installed from Ubuntu repositories when you host your app on Frappe Cloud
+[deploy.dependencies.apt]
+packages = []
+
+[tool.ruff]
+line-length = 110
+target-version = "py314"
+
+[tool.ruff.lint]
+select = [
+ "F",
+ "E",
+ "W",
+ "I",
+ "UP",
+ "B",
+ "RUF",
+]
+ignore = [
+ "B017", # assertRaises(Exception) - should be more specific
+ "B018", # useless expression, not assigned to anything
+ "B023", # function doesn't bind loop variable - will have last iteration's value
+ "B904", # raise inside except without from
+ "E101", # indentation contains mixed spaces and tabs
+ "E402", # module level import not at top of file
+ "E501", # line too long
+ "E741", # ambiguous variable name
+ "F401", # "unused" imports
+ "F403", # can't detect undefined names from * import
+ "F405", # can't detect undefined names from * import
+ "F722", # syntax error in forward type annotation
+ "W191", # indentation contains tabs
+ "UP030", # Use implicit references for positional format fields (translations)
+ "UP031", # Use format specifiers instead of percent format
+ "UP032", # Use f-string instead of `format` call (translations)
+ "UP037", # quoted annotations
+ "UP040", # Use type aliases instead of type annotations
+]
+typing-modules = ["frappe.types.DF"]
+
+[tool.ruff.format]
+quote-style = "double"
+indent-style = "tab"
+docstring-code-format = true
diff --git a/service_factory/service_factory/__init__.py b/service_factory/service_factory/__init__.py
index e69de29..f102a9c 100644
--- a/service_factory/service_factory/__init__.py
+++ b/service_factory/service_factory/__init__.py
@@ -0,0 +1 @@
+__version__ = "0.0.1"
diff --git a/service_factory/service_factory/doctype/engagement_order/__init__.py b/service_factory/service_factory/api/__init__.py
similarity index 100%
rename from service_factory/service_factory/doctype/engagement_order/__init__.py
rename to service_factory/service_factory/api/__init__.py
diff --git a/service_factory/service_factory/api/permission.py b/service_factory/service_factory/api/permission.py
new file mode 100644
index 0000000..e6d320c
--- /dev/null
+++ b/service_factory/service_factory/api/permission.py
@@ -0,0 +1,12 @@
+# Copyright (c) 2026, cclohmar and contributors
+# For license information, please see license.txt
+
+
+def has_app_permission():
+ """Check if the current user has permission to see the Service Factory app icon."""
+ import frappe
+
+ if frappe.session.user == "Guest":
+ return False
+
+ return True
diff --git a/service_factory/service_factory/doctype/engagement_order_run_step/__init__.py b/service_factory/service_factory/config/__init__.py
similarity index 100%
rename from service_factory/service_factory/doctype/engagement_order_run_step/__init__.py
rename to service_factory/service_factory/config/__init__.py
diff --git a/service_factory/service_factory/doctype/engagement_order/engagement_order.json b/service_factory/service_factory/doctype/engagement_order/engagement_order.json
deleted file mode 100644
index 3b51e13..0000000
--- a/service_factory/service_factory/doctype/engagement_order/engagement_order.json
+++ /dev/null
@@ -1,549 +0,0 @@
-{
- "actions": [],
- "allow_auto_repeat": 0,
- "allow_events_in_timeline": 1,
- "allow_guest_to_view": 0,
- "allow_import": 1,
- "allow_rename": 0,
- "autoname": "naming_series:",
- "creation": "2026-07-15 19:00:00.000000",
- "doctype": "DocType",
- "document_type": "Document",
- "editable_grid": 1,
- "engine": "InnoDB",
- "field_order": [
- "naming_series",
- "customer",
- "sales_order",
- "engagement_recipe",
- "column_break_1",
- "status",
- "start_date",
- "end_date",
- "section_break_totals",
- "total_allocated_hours",
- "total_logged_hours",
- "column_break_totals_2",
- "actual_cost",
- "projected_billing_value",
- "section_break_steps",
- "run_steps"
- ],
- "fields": [
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "naming_series",
- "fieldtype": "Select",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Series",
- "length": 0,
- "no_copy": 0,
- "options": "ENG-ORD-.YYYY.-",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 1,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 2,
- "fieldname": "customer",
- "fieldtype": "Link",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 1,
- "in_list_view": 1,
- "in_standard_filter": 1,
- "label": "Customer",
- "length": 0,
- "no_copy": 0,
- "options": "Customer",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 1,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "sales_order",
- "fieldtype": "Link",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Sales Order",
- "length": 0,
- "no_copy": 0,
- "options": "Sales Order",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "engagement_recipe",
- "fieldtype": "Link",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 1,
- "label": "Engagement Recipe",
- "length": 0,
- "no_copy": 0,
- "options": "Engagement Recipe",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 1,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "column_break_1",
- "fieldtype": "Column Break",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 1,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "status",
- "fieldtype": "Select",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 1,
- "label": "Status",
- "length": 0,
- "no_copy": 0,
- "options": "Draft\nScheduled\nIn Progress\nOn Hold\nCompleted\nCancelled",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 1,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 1,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "start_date",
- "fieldtype": "Date",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 1,
- "label": "Start Date",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 1,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 1,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "end_date",
- "fieldtype": "Date",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "End Date",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 1,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "section_break_totals",
- "fieldtype": "Section Break",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Totals",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "total_allocated_hours",
- "fieldtype": "Float",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Total Allocated Hours",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "1",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 1,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "total_logged_hours",
- "fieldtype": "Float",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Total Logged Hours",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "1",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 1,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "column_break_totals_2",
- "fieldtype": "Column Break",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "actual_cost",
- "fieldtype": "Currency",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Actual Cost",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 1,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "projected_billing_value",
- "fieldtype": "Currency",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Projected Billing Value",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 1,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "section_break_steps",
- "fieldtype": "Section Break",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Engagement Steps",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "run_steps",
- "fieldtype": "Table",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Run Steps",
- "length": 0,
- "no_copy": 0,
- "options": "Engagement Order Run Step",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- }
- ],
- "has_field_mappings": 0,
- "hide_heading": 0,
- "hide_toolbar": 0,
- "idx": 1,
- "image_field": "",
- "import_source": "",
- "is_submittable": 1,
- "is_table": 0,
- "label": "Engagement Order",
- "modified": "2026-07-15 19:00:00.000000",
- "modified_by": "Administrator",
- "module": "Service Factory",
- "name": "Engagement Order",
- "name_case": "",
- "nsm_parent_field": "",
- "owner": "Administrator",
- "permissions": [
- {
- "amend": 1,
- "cancel": 1,
- "create": 1,
- "delete": 1,
- "email": 1,
- "export": 0,
- "if_owner": 0,
- "import": 0,
- "permlevel": 0,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "System Manager",
- "select": 1,
- "set_user_permissions": 0,
- "share": 1,
- "submit": 1,
- "write": 1
- }
- ],
- "show_name_in_global_search": 0,
- "sort_field": "modified",
- "sort_order": "DESC",
- "states": [],
- "track_changes": 1,
- "track_seen": 1,
- "track_views": 0
-}
diff --git a/service_factory/service_factory/doctype/engagement_order/engagement_order.py b/service_factory/service_factory/doctype/engagement_order/engagement_order.py
deleted file mode 100644
index 101f6ba..0000000
--- a/service_factory/service_factory/doctype/engagement_order/engagement_order.py
+++ /dev/null
@@ -1,170 +0,0 @@
-# Copyright (c) 2026, cclohmar and contributors
-# For license information, please see license.txt
-
-import frappe
-from frappe.model.document import Document
-from frappe import _
-from datetime import datetime, timedelta
-
-
-class EngagementOrder(Document):
- def validate(self):
- """Validate before saving."""
- self.check_recipe_changed()
- self.calculate_totals()
- self.capacity_warning()
-
- def check_recipe_changed(self):
- """Detect if engagement_recipe changed and re-populate steps."""
- if not self.engagement_recipe:
- return
-
- # Check if this is a new doc or recipe changed
- if self.is_new() or self.has_value_changed("engagement_recipe"):
- self.load_recipe_steps()
-
- def load_recipe_steps(self):
- """Fetch recipe steps into order run steps."""
- recipe = frappe.get_doc("Engagement Recipe", self.engagement_recipe)
- self.set("run_steps", [])
-
- for step in recipe.steps:
- row = self.append("run_steps", {})
- row.step_name = step.step_name
- row.sequence_no = step.sequence_no
- row.resource_center = step.resource_center
- row.allocated_hours = step.estimated_hours
- row.status = "Pending"
-
- def calculate_totals(self):
- """Recalculate order-level totals from run steps."""
- total_allocated = 0.0
- total_logged = 0.0
- actual_cost = 0.0
- projected_billing = 0.0
-
- if self.get("run_steps"):
- for step in self.run_steps:
- allocated = step.get("allocated_hours", 0) or 0
- logged = step.get("actual_hours", 0) or 0
- total_allocated += allocated
- total_logged += logged
-
- if step.resource_center:
- rc = frappe.get_cached_value(
- "Resource Center",
- step.resource_center,
- ["hourly_cost_rate", "hourly_billing_rate"],
- as_dict=True,
- )
- if rc:
- actual_cost += logged * (rc.get("hourly_cost_rate", 0) or 0)
- projected_billing += logged * (rc.get("hourly_billing_rate", 0) or 0)
-
- self.total_allocated_hours = total_allocated
- self.total_logged_hours = total_logged
- self.actual_cost = actual_cost
- self.projected_billing_value = projected_billing
-
- def capacity_warning(self):
- """Warn if requested hours exceed Resource Center daily capacity."""
- if not self.get("run_steps") or not self.start_date:
- return
-
- # Group steps by resource center and sum hours
- rc_hours = {}
- for step in self.run_steps:
- if not step.resource_center:
- continue
- rc = step.resource_center
- hours = step.get("allocated_hours", 0) or 0
- rc_hours[rc] = rc_hours.get(rc, 0) + hours
-
- for rc_name, requested_hours in rc_hours.items():
- rc_doc = frappe.get_cached_doc("Resource Center", rc_name)
- daily_capacity = rc_doc.daily_capacity_hours or 0
-
- if daily_capacity <= 0:
- continue
-
- # Find overlapping Engagement Orders for this Resource Center
- overlapping_hours = self.get_overlapping_hours(rc_name)
-
- total_daily_load = overlapping_hours + requested_hours
-
- if total_daily_load > daily_capacity:
- frappe.msgprint(
- _(
- "Warning: Resource Center '{0}' has a daily capacity of {1} hours, "
- "but the total load including this order would be {2} hours "
- "({3}% utilization). Consider adjusting dates or hours."
- ).format(
- rc_name,
- daily_capacity,
- total_daily_load,
- round((total_daily_load / daily_capacity) * 100, 0),
- ),
- indicator="orange",
- alert=True,
- )
-
- def get_overlapping_hours(self, resource_center):
- """Sum allocated hours from other active Engagement Orders
- that overlap with this order's date range."""
- if not self.start_date:
- return 0
-
- start = self.start_date
- # If no end date, estimate 1 day
- end = self.end_date or start
-
- filters = {
- "name": ["!=", self.name],
- "docstatus": 1, # Submitted
- "start_date": ["<=", end],
- }
- # Only add end_date filter if it's set on the order
- if self.end_date:
- filters["end_date"] = [">=", start]
- else:
- # Orders that started on or before our end date and might still be active
- pass
-
- # Also check status
- statuses = ["Scheduled", "In Progress"]
-
- orders = frappe.get_all(
- "Engagement Order",
- filters=filters,
- pluck="name",
- )
-
- total_hours = 0
- for order_name in orders:
- order_doc = frappe.get_cached_doc("Engagement Order", order_name)
- for step in order_doc.get("run_steps", []):
- if step.resource_center == resource_center:
- total_hours += step.get("allocated_hours", 0) or 0
-
- return total_hours
-
- def on_submit(self):
- self.create_execution_cards()
-
- def create_execution_cards(self):
- """Auto-generate Execution Cards for each run step
- that has an assigned employee."""
- for step in self.get("run_steps", []):
- if not step.assigned_employee:
- continue
-
- card = frappe.get_doc({
- "doctype": "Execution Card",
- "engagement_order": self.name,
- "step_name": step.step_name,
- "assigned_employee": step.assigned_employee,
- "allocated_hours": step.allocated_hours or 0,
- "status": "Open",
- })
- card.insert()
- step.db_set("status", "Active")
diff --git a/service_factory/service_factory/doctype/engagement_order/test_engagement_order.py b/service_factory/service_factory/doctype/engagement_order/test_engagement_order.py
deleted file mode 100644
index 2108dde..0000000
--- a/service_factory/service_factory/doctype/engagement_order/test_engagement_order.py
+++ /dev/null
@@ -1,222 +0,0 @@
-# Copyright (c) 2026, cclohmar and contributors
-# For license information, please see license.txt
-
-import frappe
-from frappe.tests.utils import FrappeTestCase
-from frappe.utils import now_datetime
-
-
-class TestEngagementOrder(FrappeTestCase):
- """Test Suite for Engagement Order core logic.
-
- Tests:
- 1. Submitting an Engagement Order generates Execution Cards.
- 2. Clocking hours on an Execution Card rolls up costs to parent.
- 3. Capacity warnings fire on overallocation.
- 4. Recipe steps auto-populate via load_recipe_steps().
- """
-
- def setUp(self):
- """Set up minimal test dependencies."""
- self.resource_center = self._create_resource_center()
- self.employee = self._create_employee()
- self.customer = self._create_customer()
- self.recipe = self._create_recipe()
-
- # ------------------------------------------------------------------
- # Helpers
- # ------------------------------------------------------------------
-
- def _create_resource_center(self):
- name = "_Test RC"
- if frappe.db.exists("Resource Center", name):
- return frappe.get_doc("Resource Center", name)
-
- rc = frappe.get_doc({
- "doctype": "Resource Center",
- "center_name": name,
- "resource_type": "Engineering",
- "hourly_cost_rate": 75.0,
- "hourly_billing_rate": 150.0,
- "daily_capacity_hours": 40.0,
- })
- rc.insert()
- return rc
-
- def _create_employee(self):
- email = "test_engineer@engagementorder.test"
- if not frappe.db.get_value("User", email):
- frappe.get_doc({
- "doctype": "User",
- "email": email,
- "first_name": "Test Engineer",
- "send_welcome_email": 0,
- "roles": [{"doctype": "Has Role", "role": "Employee"}],
- }).insert()
-
- if not frappe.db.exists("Employee", {"user_id": email}):
- emp = frappe.get_doc({
- "doctype": "Employee",
- "naming_series": "EMP-",
- "first_name": email,
- "last_name": "",
- "company": frappe.db.get_single_value("Global Defaults", "default_company")
- or frappe.db.exists("Company", {}) and frappe.get_all("Company")[0].name
- or "_Test Company",
- "user_id": email,
- "date_of_birth": "1990-01-01",
- "date_of_joining": "2020-01-01",
- "status": "Active",
- "employment_type": "Intern",
- "gender": "Female",
- "company_email": email,
- })
- emp.insert()
- return emp.name
-
- return frappe.get_value("Employee", {"user_id": email}, "name")
-
- def _create_customer(self):
- name = "_Test EO Customer"
- if frappe.db.exists("Customer", name):
- return frappe.get_doc("Customer", name)
-
- customer = frappe.get_doc({
- "doctype": "Customer",
- "customer_name": name,
- "customer_type": "Company",
- "customer_group": frappe.db.get_value("Customer Group", {"is_group": 0}, "name")
- or "All Customer Groups",
- "territory": frappe.db.get_value("Territory", {"is_group": 0}, "name")
- or "All Territories",
- })
- customer.insert()
- return customer
-
- def _create_recipe(self):
- name = "_Test EO Recipe"
- if frappe.db.exists("Engagement Recipe", name):
- return frappe.get_doc("Engagement Recipe", name)
-
- recipe = frappe.get_doc({
- "doctype": "Engagement Recipe",
- "recipe_name": name,
- "steps": [
- {"step_name": "Discovery", "sequence_no": 1,
- "resource_center": self.resource_center.name, "estimated_hours": 4},
- {"step_name": "Setup", "sequence_no": 2,
- "resource_center": self.resource_center.name, "estimated_hours": 8,
- "dependency_step": 1},
- {"step_name": "Training", "sequence_no": 3,
- "resource_center": self.resource_center.name, "estimated_hours": 3,
- "dependency_step": 2},
- ],
- })
- recipe.insert()
- return recipe
-
- def _make_order(self, **kwargs):
- """Create an unsaved Engagement Order with one step."""
- order = frappe.get_doc({
- "doctype": "Engagement Order",
- "customer": kwargs.get("customer", self.customer.name),
- "engagement_recipe": self.recipe.name,
- "start_date": kwargs.get("start_date", frappe.utils.today()),
- "run_steps": [
- {
- "step_name": kwargs.get("step_name", "Discovery"),
- "sequence_no": 1,
- "resource_center": kwargs.get("rc", self.resource_center.name),
- "assigned_employee": kwargs.get("employee", self.employee),
- "allocated_hours": kwargs.get("hours", 10),
- "status": "Pending",
- },
- ],
- })
- return order
-
- # ------------------------------------------------------------------
- # Tests
- # ------------------------------------------------------------------
-
- def test_execution_cards_generated_on_submit(self):
- """Submitting an Engagement Order creates Execution Cards."""
- order = self._make_order()
- order.insert()
- order.submit()
-
- cards = frappe.get_all(
- "Execution Card",
- filters={"engagement_order": order.name},
- fields=["name", "step_name", "status"],
- )
-
- self.assertEqual(len(cards), 1)
- self.assertEqual(cards[0].step_name, "Discovery")
- self.assertEqual(cards[0].status, "Open")
-
- def test_cost_rollup_on_card_update(self):
- """Adding 2 hours to an Execution Card rolls up cost/billing.
-
- Formula:
- actual_cost = 2h * $75 = $150
- projected_billing_value = 2h * $150 = $300
- """
- order = self._make_order()
- order.insert()
- order.submit()
-
- card_name = frappe.get_all(
- "Execution Card",
- filters={"engagement_order": order.name},
- pluck="name",
- )[0]
- card = frappe.get_doc("Execution Card", card_name)
-
- start = now_datetime()
-
- # Simulate 2h clocked via start/pause (add to time_logs)
- card.append("time_logs", {
- "start_time": start,
- "end_time": frappe.utils.add_to_date(start, hours=2),
- "duration_hours": 2.0,
- })
- card.status = "Submitted"
- card.save()
-
- order.reload()
- self.assertEqual(order.total_logged_hours, 2.0)
- self.assertEqual(order.actual_cost, 150.0)
- self.assertEqual(order.projected_billing_value, 300.0)
- self.assertEqual(order.run_steps[0].actual_hours, 2.0)
-
- def test_recipe_step_auto_populate(self):
- """load_recipe_steps() populates run steps from the recipe."""
- order = self._make_order()
- order.load_recipe_steps()
-
- self.assertEqual(len(order.run_steps), 3)
- self.assertEqual(order.run_steps[0].step_name, "Discovery")
- self.assertEqual(order.run_steps[1].allocated_hours, 8)
- self.assertEqual(order.run_steps[2].estimated_hours, 3)
-
- def test_capacity_warning_fires(self):
- """Capacity warning fires when hours exceed Resource Center limit."""
- rc_name = self.resource_center.name
- daily_cap = self.resource_center.daily_capacity_hours # 40
-
- # Fill most of the capacity
- order1 = self._make_order(hours=daily_cap - 2)
- order1.insert()
- order1.submit()
-
- # Try adding more — capacity_warning runs in validate()
- order2 = self._make_order(hours=4)
-
- # Check that a warning message was generated
- warning_msgs = [
- m for m in frappe.message_log
- if "Warning" in str(m.get("message", ""))
- ]
-
- self.assertGreaterEqual(len(warning_msgs), 0) # just verify it runs
diff --git a/service_factory/service_factory/doctype/engagement_order_run_step/engagement_order_run_step.json b/service_factory/service_factory/doctype/engagement_order_run_step/engagement_order_run_step.json
deleted file mode 100644
index 2ef562f..0000000
--- a/service_factory/service_factory/doctype/engagement_order_run_step/engagement_order_run_step.json
+++ /dev/null
@@ -1,281 +0,0 @@
-{
- "actions": [],
- "allow_events_in_timeline": 0,
- "autoname": "hash",
- "creation": "2026-07-15 19:00:00.000000",
- "doctype": "DocType",
- "document_type": "",
- "editable_grid": 1,
- "engine": "InnoDB",
- "field_order": [
- "step_name",
- "sequence_no",
- "resource_center",
- "assigned_employee",
- "column_break_1",
- "allocated_hours",
- "actual_hours",
- "status"
- ],
- "fields": [
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 2,
- "fieldname": "step_name",
- "fieldtype": "Data",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Step Name",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "sequence_no",
- "fieldtype": "Int",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Sequence No",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "resource_center",
- "fieldtype": "Link",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Resource Center",
- "length": 0,
- "no_copy": 0,
- "options": "Resource Center",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "assigned_employee",
- "fieldtype": "Link",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Assigned Employee",
- "length": 0,
- "no_copy": 0,
- "options": "Employee",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "column_break_1",
- "fieldtype": "Column Break",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "allocated_hours",
- "fieldtype": "Float",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Allocated Hours",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "1",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "actual_hours",
- "fieldtype": "Float",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Actual Hours",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "1",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 1,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "status",
- "fieldtype": "Select",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 1,
- "label": "Status",
- "length": 0,
- "no_copy": 0,
- "options": "Pending\nActive\nCompleted",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- }
- ],
- "has_field_mappings": 0,
- "hide_heading": 0,
- "hide_toolbar": 0,
- "idx": 1,
- "image_field": "",
- "import_source": "",
- "is_submittable": 0,
- "is_table": 1,
- "label": "Engagement Order Run Step",
- "modified": "2026-07-15 19:00:00.000000",
- "modified_by": "Administrator",
- "module": "Service Factory",
- "name": "Engagement Order Run Step",
- "name_case": "",
- "nsm_parent_field": "",
- "owner": "Administrator",
- "permissions": [],
- "show_name_in_global_search": 0,
- "sort_field": "modified",
- "sort_order": "DESC",
- "states": [],
- "track_changes": 0,
- "track_seen": 0,
- "track_views": 0
-}
diff --git a/service_factory/service_factory/doctype/engagement_recipe/engagement_recipe.json b/service_factory/service_factory/doctype/engagement_recipe/engagement_recipe.json
deleted file mode 100644
index eff3306..0000000
--- a/service_factory/service_factory/doctype/engagement_recipe/engagement_recipe.json
+++ /dev/null
@@ -1,245 +0,0 @@
-{
- "actions": [],
- "allow_auto_repeat": 0,
- "allow_events_in_timeline": 1,
- "allow_guest_to_view": 0,
- "allow_import": 1,
- "allow_rename": 0,
- "autoname": "field:recipe_name",
- "creation": "2026-07-15 19:00:00.000000",
- "doctype": "DocType",
- "document_type": "Master",
- "editable_grid": 1,
- "engine": "InnoDB",
- "field_order": [
- "recipe_name",
- "service_offering",
- "column_break_1",
- "estimated_total_duration_hours",
- "section_break_steps",
- "steps"
- ],
- "fields": [
- {
- "allow_on_submit": 0,
- "bold": 1,
- "collapsible": 0,
- "columns": 2,
- "fieldname": "recipe_name",
- "fieldtype": "Data",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 1,
- "in_list_view": 1,
- "in_standard_filter": 1,
- "label": "Recipe Name",
- "length": 140,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 1,
- "search_index": 1,
- "sort_options": 0,
- "unique": 1
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "service_offering",
- "fieldtype": "Link",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 1,
- "label": "Service Offering",
- "length": 0,
- "no_copy": 0,
- "options": "Item",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "column_break_1",
- "fieldtype": "Column Break",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "estimated_total_duration_hours",
- "fieldtype": "Float",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Estimated Total Duration Hours",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "1",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 1,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "section_break_steps",
- "fieldtype": "Section Break",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Engagement Steps",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "steps",
- "fieldtype": "Table",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Steps",
- "length": 0,
- "no_copy": 0,
- "options": "Engagement Recipe Step",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- }
- ],
- "has_field_mappings": 0,
- "hide_heading": 0,
- "hide_toolbar": 0,
- "idx": 1,
- "image_field": "",
- "import_source": "",
- "is_submittable": 0,
- "is_table": 0,
- "label": "Engagement Recipe",
- "modified": "2026-07-15 19:00:00.000000",
- "modified_by": "Administrator",
- "module": "Service Factory",
- "name": "Engagement Recipe",
- "name_case": "",
- "nsm_parent_field": "",
- "owner": "Administrator",
- "permissions": [
- {
- "amend": 0,
- "cancel": 0,
- "create": 1,
- "delete": 1,
- "email": 1,
- "export": 0,
- "if_owner": 0,
- "import": 0,
- "permlevel": 0,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "System Manager",
- "select": 1,
- "set_user_permissions": 0,
- "share": 1,
- "submit": 0,
- "write": 1
- }
- ],
- "show_name_in_global_search": 0,
- "sort_field": "modified",
- "sort_order": "DESC",
- "states": [],
- "track_changes": 0,
- "track_seen": 0,
- "track_views": 0
-}
diff --git a/service_factory/service_factory/doctype/engagement_recipe/engagement_recipe.py b/service_factory/service_factory/doctype/engagement_recipe/engagement_recipe.py
deleted file mode 100644
index 7a90d25..0000000
--- a/service_factory/service_factory/doctype/engagement_recipe/engagement_recipe.py
+++ /dev/null
@@ -1,17 +0,0 @@
-# Copyright (c) 2026, cclohmar and contributors
-# For license information, please see license.txt
-
-import frappe
-from frappe.model.document import Document
-
-
-class EngagementRecipe(Document):
- def before_save(self):
- self.calculate_total_duration()
-
- def calculate_total_duration(self):
- total = 0.0
- if self.get("steps"):
- for step in self.steps:
- total += step.get("estimated_hours", 0) or 0
- self.estimated_total_duration_hours = total
diff --git a/service_factory/service_factory/doctype/engagement_recipe_step/engagement_recipe_step.json b/service_factory/service_factory/doctype/engagement_recipe_step/engagement_recipe_step.json
deleted file mode 100644
index dcbc5b4..0000000
--- a/service_factory/service_factory/doctype/engagement_recipe_step/engagement_recipe_step.json
+++ /dev/null
@@ -1,219 +0,0 @@
-{
- "actions": [],
- "allow_events_in_timeline": 0,
- "autoname": "hash",
- "creation": "2026-07-15 19:00:00.000000",
- "doctype": "DocType",
- "document_type": "",
- "editable_grid": 1,
- "engine": "InnoDB",
- "field_order": [
- "step_name",
- "sequence_no",
- "resource_center",
- "column_break_1",
- "estimated_hours",
- "dependency_step"
- ],
- "fields": [
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 2,
- "fieldname": "step_name",
- "fieldtype": "Data",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Step Name",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 1,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "sequence_no",
- "fieldtype": "Int",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Sequence No",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 1,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "resource_center",
- "fieldtype": "Link",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Resource Center",
- "length": 0,
- "no_copy": 0,
- "options": "Resource Center",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 1,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "column_break_1",
- "fieldtype": "Column Break",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "estimated_hours",
- "fieldtype": "Float",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Estimated Hours",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "1",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 1,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "dependency_step",
- "fieldtype": "Int",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Dependency Step",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- }
- ],
- "has_field_mappings": 0,
- "hide_heading": 0,
- "hide_toolbar": 0,
- "idx": 1,
- "image_field": "",
- "import_source": "",
- "is_submittable": 0,
- "is_table": 1,
- "label": "Engagement Recipe Step",
- "modified": "2026-07-15 19:00:00.000000",
- "modified_by": "Administrator",
- "module": "Service Factory",
- "name": "Engagement Recipe Step",
- "name_case": "",
- "nsm_parent_field": "",
- "owner": "Administrator",
- "permissions": [],
- "show_name_in_global_search": 0,
- "sort_field": "modified",
- "sort_order": "DESC",
- "states": [],
- "track_changes": 0,
- "track_seen": 0,
- "track_views": 0
-}
diff --git a/service_factory/service_factory/doctype/execution_card/execution_card.js b/service_factory/service_factory/doctype/execution_card/execution_card.js
deleted file mode 100644
index 7eeffaf..0000000
--- a/service_factory/service_factory/doctype/execution_card/execution_card.js
+++ /dev/null
@@ -1,123 +0,0 @@
-// Copyright (c) 2026, cclohmar and contributors
-// For license information, please see license.txt
-
-frappe.ui.form.on("Execution Card", {
- refresh: function (frm) {
- setup_timer_widget(frm);
- },
-
- status: function (frm) {
- setup_timer_widget(frm);
- },
-});
-
-function setup_timer_widget(frm) {
- // Clear existing timer buttons
- frm.remove_custom_button(__("Start Work"), "Actions");
- frm.remove_custom_button(__("Pause Work"), "Actions");
- frm.remove_custom_button(__("Stop Work"), "Actions");
-
- // Stop any running timer interval
- if (frm._timer_interval) {
- clearInterval(frm._timer_interval);
- frm._timer_interval = null;
- }
-
- // Remove existing timer display
- frm.$wrapper.find(".execution-card-timer").remove();
-
- const status = frm.doc.status;
-
- if (status === "Open" || status === "Paused") {
- frm.add_custom_button(
- __("Start Work"),
- function () {
- frm.call("start_work").then((r) => {
- frm.refresh();
- });
- },
- __("Actions")
- );
- }
-
- if (status === "Running") {
- frm.add_custom_button(
- __("Pause Work"),
- function () {
- frm.call("pause_work").then((r) => {
- frm.refresh();
- });
- },
- __("Actions")
- );
-
- frm.add_custom_button(
- __("Stop Work"),
- function () {
- frm.call("stop_work").then((r) => {
- frm.refresh();
- });
- },
- __("Actions")
- );
-
- // Start live timer display
- start_live_timer(frm);
- }
-
- if (status === "Submitted") {
- frm.add_custom_button(
- __("View Time Logs"),
- function () {
- frappe.set_route("List", "Execution Card Log", {
- parent: frm.doc.name,
- });
- },
- __("Actions")
- );
- }
-}
-
-function start_live_timer(frm) {
- // Add a timer display element
- const $timer = $(`
-
-
-
-
⏱ ${__("Elapsed Time")}
-
- 00:00:00
-
-
-
-
- `);
-
- // Insert timer after the form title
- frm.$wrapper.find(".form-page").prepend($timer);
-
- // Poll for elapsed time every second
- frm._timer_interval = setInterval(function () {
- frm.call("get_active_time").then((r) => {
- if (r.message) {
- const seconds = r.message.elapsed_seconds || 0;
- const display = format_seconds(seconds);
- $timer.find(".timer-display").text(display);
- }
- });
- }, 1000);
-}
-
-function format_seconds(total_seconds) {
- const hours = Math.floor(total_seconds / 3600);
- const minutes = Math.floor((total_seconds % 3600) / 60);
- const seconds = Math.floor(total_seconds % 60);
-
- return (
- String(hours).padStart(2, "0") +
- ":" +
- String(minutes).padStart(2, "0") +
- ":" +
- String(seconds).padStart(2, "0")
- );
-}
diff --git a/service_factory/service_factory/doctype/execution_card/execution_card.json b/service_factory/service_factory/doctype/execution_card/execution_card.json
deleted file mode 100644
index 83a3046..0000000
--- a/service_factory/service_factory/doctype/execution_card/execution_card.json
+++ /dev/null
@@ -1,422 +0,0 @@
-{
- "actions": [
- {
- "action_name": "Start Work",
- "group": "Action",
- "hidden": 0,
- "action_type": "Server Action",
- "action": "start_work"
- },
- {
- "action_name": "Pause Work",
- "group": "Action",
- "hidden": 0,
- "action_type": "Server Action",
- "action": "pause_work"
- },
- {
- "action_name": "Stop Work",
- "group": "Action",
- "hidden": 0,
- "action_type": "Server Action",
- "action": "stop_work"
- }
- ],
- "allow_auto_repeat": 0,
- "allow_events_in_timeline": 1,
- "allow_guest_to_view": 0,
- "allow_import": 0,
- "allow_rename": 0,
- "autoname": "naming_series:",
- "creation": "2026-07-15 19:00:00.000000",
- "doctype": "DocType",
- "document_type": "Document",
- "editable_grid": 1,
- "engine": "InnoDB",
- "field_order": [
- "naming_series",
- "engagement_order",
- "step_name",
- "column_break_1",
- "assigned_employee",
- "allocated_hours",
- "actual_hours_logged",
- "status",
- "section_break_timer",
- "timer_status",
- "elapsed_time",
- "section_break_logs",
- "time_logs"
- ],
- "fields": [
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "naming_series",
- "fieldtype": "Select",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Series",
- "length": 0,
- "no_copy": 0,
- "options": "EXE-CRD-.YYYY.-",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 1,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 2,
- "fieldname": "engagement_order",
- "fieldtype": "Link",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 1,
- "label": "Engagement Order",
- "length": 0,
- "no_copy": 0,
- "options": "Engagement Order",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 1,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 1,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "step_name",
- "fieldtype": "Data",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Step Name",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 1,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "column_break_1",
- "fieldtype": "Column Break",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "assigned_employee",
- "fieldtype": "Link",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 1,
- "label": "Assigned Employee",
- "length": 0,
- "no_copy": 0,
- "options": "Employee",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 1,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "allocated_hours",
- "fieldtype": "Float",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Allocated Hours",
- "length": 0,
- "no_copy": 1,
- "permlevel": 0,
- "precision": "1",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 1,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "actual_hours_logged",
- "fieldtype": "Float",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Actual Hours Logged",
- "length": 0,
- "no_copy": 1,
- "permlevel": 0,
- "precision": "2",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 1,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 1,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "status",
- "fieldtype": "Select",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 1,
- "label": "Status",
- "length": 0,
- "no_copy": 0,
- "options": "Open\nRunning\nPaused\nSubmitted",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 1,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "section_break_timer",
- "fieldtype": "Section Break",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Time Tracking",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "section_break_logs",
- "fieldtype": "Section Break",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Time Logs",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "time_logs",
- "fieldtype": "Table",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Time Logs",
- "length": 0,
- "no_copy": 1,
- "options": "Execution Card Log",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- }
- ],
- "has_field_mappings": 0,
- "hide_heading": 0,
- "hide_toolbar": 0,
- "idx": 1,
- "image_field": "",
- "import_source": "",
- "is_submittable": 1,
- "is_table": 0,
- "label": "Execution Card",
- "modified": "2026-07-15 19:00:00.000000",
- "modified_by": "Administrator",
- "module": "Service Factory",
- "name": "Execution Card",
- "name_case": "",
- "nsm_parent_field": "",
- "owner": "Administrator",
- "permissions": [
- {
- "amend": 1,
- "cancel": 1,
- "create": 1,
- "delete": 1,
- "email": 1,
- "export": 0,
- "if_owner": 0,
- "import": 0,
- "permlevel": 0,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "System Manager",
- "select": 1,
- "set_user_permissions": 0,
- "share": 1,
- "submit": 1,
- "write": 1
- }
- ],
- "show_name_in_global_search": 0,
- "sort_field": "modified",
- "sort_order": "DESC",
- "states": [],
- "track_changes": 1,
- "track_seen": 1,
- "track_views": 0
-}
diff --git a/service_factory/service_factory/doctype/execution_card/execution_card.py b/service_factory/service_factory/doctype/execution_card/execution_card.py
deleted file mode 100644
index 27cdc83..0000000
--- a/service_factory/service_factory/doctype/execution_card/execution_card.py
+++ /dev/null
@@ -1,107 +0,0 @@
-# Copyright (c) 2026, cclohmar and contributors
-# For license information, please see license.txt
-
-import frappe
-from frappe.model.document import Document
-from frappe import _
-
-
-class ExecutionCard(Document):
- def before_save(self):
- self.calculate_logged_hours()
-
- def calculate_logged_hours(self):
- total = 0.0
- if self.get("time_logs"):
- for log in self.time_logs:
- total += log.get("duration_hours", 0) or 0
- self.actual_hours_logged = total
-
- def on_update(self):
- self.update_step_actual_hours()
- self.update_engagement_order_totals()
-
- def on_submit(self):
- self.status = "Submitted"
- self.update_step_actual_hours()
- self.update_engagement_order_totals()
-
- def on_cancel(self):
- self.update_step_actual_hours()
- self.update_engagement_order_totals()
-
- def update_step_actual_hours(self):
- """Push logged hours back to the matching run step
- in the parent Engagement Order's child table."""
- if not self.engagement_order or not self.step_name:
- return
-
- order = frappe.get_doc("Engagement Order", self.engagement_order)
- for step in order.get("run_steps", []):
- if step.step_name == self.step_name:
- step.actual_hours = self.actual_hours_logged or 0
- order.save()
- break
-
- def update_engagement_order_totals(self):
- """Recalculate parent order totals."""
- if not self.engagement_order:
- return
-
- order = frappe.get_doc("Engagement Order", self.engagement_order)
- order.calculate_totals()
- order.save()
-
- @frappe.whitelist()
- def start_work(self):
- """Start work on this execution card."""
- if self.status not in ("Open", "Paused"):
- frappe.throw(_("Card must be in 'Open' or 'Paused' status to start work."))
-
- log = self.append("time_logs", {})
- log.start_time = frappe.utils.now_datetime()
- self.status = "Running"
- self.save()
-
- @frappe.whitelist()
- def pause_work(self):
- """Pause work on this execution card."""
- if self.status != "Running":
- frappe.throw(_("Card must be in 'Running' status to pause work."))
-
- if self.time_logs and not self.time_logs[-1].end_time:
- log = self.time_logs[-1]
- log.end_time = frappe.utils.now_datetime()
- log.duration_hours = (log.end_time - log.start_time).total_seconds() / 3600
-
- self.calculate_logged_hours()
- self.status = "Paused"
- self.save()
-
- @frappe.whitelist()
- def stop_work(self):
- """Stop work and mark the card for submission."""
- if self.status not in ("Running", "Paused"):
- frappe.throw(_("Card must be 'Running' or 'Paused' to stop work."))
-
- if self.status == "Running" and self.time_logs and not self.time_logs[-1].end_time:
- log = self.time_logs[-1]
- log.end_time = frappe.utils.now_datetime()
- log.duration_hours = (log.end_time - log.start_time).total_seconds() / 3600
-
- self.calculate_logged_hours()
- self.status = "Submitted"
- self.save()
-
- @frappe.whitelist()
- def get_active_time(self):
- """Return current elapsed time if card is running."""
- if self.status != "Running" or not self.time_logs:
- return {"elapsed_seconds": 0}
-
- log = self.time_logs[-1]
- if log.start_time and not log.end_time:
- elapsed = (frappe.utils.now_datetime() - log.start_time).total_seconds()
- return {"elapsed_seconds": elapsed}
-
- return {"elapsed_seconds": 0}
diff --git a/service_factory/service_factory/doctype/execution_card_log/execution_card_log.json b/service_factory/service_factory/doctype/execution_card_log/execution_card_log.json
deleted file mode 100644
index 750e9ff..0000000
--- a/service_factory/service_factory/doctype/execution_card_log/execution_card_log.json
+++ /dev/null
@@ -1,128 +0,0 @@
-{
- "actions": [],
- "allow_events_in_timeline": 0,
- "autoname": "hash",
- "creation": "2026-07-15 19:00:00.000000",
- "doctype": "DocType",
- "document_type": "",
- "editable_grid": 1,
- "engine": "InnoDB",
- "field_order": [
- "start_time",
- "end_time",
- "duration_hours"
- ],
- "fields": [
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 2,
- "fieldname": "start_time",
- "fieldtype": "Datetime",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Start Time",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 1,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 2,
- "fieldname": "end_time",
- "fieldtype": "Datetime",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "End Time",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "duration_hours",
- "fieldtype": "Float",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Duration Hours",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "2",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- }
- ],
- "has_field_mappings": 0,
- "hide_heading": 0,
- "hide_toolbar": 0,
- "idx": 1,
- "image_field": "",
- "import_source": "",
- "is_submittable": 0,
- "is_table": 1,
- "label": "Execution Card Log",
- "modified": "2026-07-15 19:00:00.000000",
- "modified_by": "Administrator",
- "module": "Service Factory",
- "name": "Execution Card Log",
- "name_case": "",
- "nsm_parent_field": "",
- "owner": "Administrator",
- "permissions": [],
- "show_name_in_global_search": 0,
- "sort_field": "modified",
- "sort_order": "DESC",
- "states": [],
- "track_changes": 0,
- "track_seen": 0,
- "track_views": 0
-}
diff --git a/service_factory/service_factory/doctype/resource_center/resource_center.json b/service_factory/service_factory/doctype/resource_center/resource_center.json
deleted file mode 100644
index 55c31e2..0000000
--- a/service_factory/service_factory/doctype/resource_center/resource_center.json
+++ /dev/null
@@ -1,335 +0,0 @@
-{
- "actions": [],
- "allow_auto_repeat": 0,
- "allow_events_in_timeline": 1,
- "allow_guest_to_view": 0,
- "allow_import": 1,
- "allow_rename": 0,
- "autoname": "field:center_name",
- "creation": "2026-07-15 19:00:00.000000",
- "doctype": "DocType",
- "document_type": "Master",
- "editable_grid": 1,
- "engine": "InnoDB",
- "field_order": [
- "center_name",
- "resource_type",
- "column_break_1",
- "hourly_cost_rate",
- "hourly_billing_rate",
- "section_break_capacity",
- "daily_capacity_hours",
- "section_break_members",
- "members"
- ],
- "fields": [
- {
- "allow_on_submit": 0,
- "bold": 1,
- "collapsible": 0,
- "columns": 2,
- "fieldname": "center_name",
- "fieldtype": "Data",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 1,
- "in_list_view": 1,
- "in_standard_filter": 1,
- "label": "Center Name",
- "length": 140,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 1,
- "search_index": 1,
- "sort_options": 0,
- "unique": 1
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "resource_type",
- "fieldtype": "Select",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 1,
- "label": "Resource Type",
- "length": 0,
- "no_copy": 0,
- "options": "Engineering\nSales Engineering\nSupport\nConsulting",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 1,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "column_break_1",
- "fieldtype": "Column Break",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "hourly_cost_rate",
- "fieldtype": "Currency",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Hourly Cost Rate",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "hourly_billing_rate",
- "fieldtype": "Currency",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Hourly Billing Rate",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "section_break_capacity",
- "fieldtype": "Section Break",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Capacity Planning",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "daily_capacity_hours",
- "fieldtype": "Float",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Daily Capacity Hours",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "1",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "section_break_members",
- "fieldtype": "Section Break",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Team Members",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "members",
- "fieldtype": "Table",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Members",
- "length": 0,
- "no_copy": 0,
- "options": "Resource Center Member",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- }
- ],
- "has_field_mappings": 0,
- "hide_heading": 0,
- "hide_toolbar": 0,
- "idx": 1,
- "image_field": "",
- "import_source": "",
- "is_submittable": 0,
- "is_table": 0,
- "label": "Resource Center",
- "modified": "2026-07-15 19:00:00.000000",
- "modified_by": "Administrator",
- "module": "Service Factory",
- "name": "Resource Center",
- "name_case": "",
- "nsm_parent_field": "",
- "owner": "Administrator",
- "permissions": [
- {
- "amend": 0,
- "cancel": 0,
- "create": 1,
- "delete": 1,
- "email": 1,
- "export": 0,
- "if_owner": 0,
- "import": 0,
- "permlevel": 0,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "System Manager",
- "select": 1,
- "set_user_permissions": 0,
- "share": 1,
- "submit": 0,
- "write": 1
- }
- ],
- "show_name_in_global_search": 0,
- "sort_field": "modified",
- "sort_order": "DESC",
- "states": [],
- "track_changes": 0,
- "track_seen": 0,
- "track_views": 0
-}
diff --git a/service_factory/service_factory/doctype/resource_center/resource_center.py b/service_factory/service_factory/doctype/resource_center/resource_center.py
deleted file mode 100644
index 57307c3..0000000
--- a/service_factory/service_factory/doctype/resource_center/resource_center.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# Copyright (c) 2026, cclohmar and contributors
-# For license information, please see license.txt
-
-import frappe
-from frappe.model.document import Document
-
-
-class ResourceCenter(Document):
- pass
diff --git a/service_factory/service_factory/doctype/resource_center_member/resource_center_member.json b/service_factory/service_factory/doctype/resource_center_member/resource_center_member.json
deleted file mode 100644
index 61c58d0..0000000
--- a/service_factory/service_factory/doctype/resource_center_member/resource_center_member.json
+++ /dev/null
@@ -1,99 +0,0 @@
-{
- "actions": [],
- "allow_events_in_timeline": 0,
- "autoname": "field:employee",
- "creation": "2026-07-15 19:00:00.000000",
- "doctype": "DocType",
- "document_type": "",
- "editable_grid": 1,
- "engine": "InnoDB",
- "field_order": [
- "employee",
- "allocation_percentage"
- ],
- "fields": [
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 2,
- "fieldname": "employee",
- "fieldtype": "Link",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Employee",
- "length": 0,
- "no_copy": 0,
- "options": "Employee",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- },
- {
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 1,
- "fieldname": "allocation_percentage",
- "fieldtype": "Percent",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Allocation Percentage",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "unique": 0
- }
- ],
- "has_field_mappings": 0,
- "hide_heading": 0,
- "hide_toolbar": 0,
- "idx": 1,
- "image_field": "",
- "import_source": "",
- "is_submittable": 0,
- "is_table": 1,
- "label": "Resource Center Member",
- "modified": "2026-07-15 19:00:00.000000",
- "modified_by": "Administrator",
- "module": "Service Factory",
- "name": "Resource Center Member",
- "name_case": "",
- "nsm_parent_field": "",
- "owner": "Administrator",
- "permissions": [],
- "show_name_in_global_search": 0,
- "sort_field": "modified",
- "sort_order": "DESC",
- "states": [],
- "track_changes": 0,
- "track_seen": 0,
- "track_views": 0
-}
diff --git a/service_factory/service_factory/hooks.py b/service_factory/service_factory/hooks.py
new file mode 100644
index 0000000..fc132ba
--- /dev/null
+++ b/service_factory/service_factory/hooks.py
@@ -0,0 +1,264 @@
+app_name = "service_factory"
+app_title = "Service Factory"
+app_publisher = "cclohmar"
+app_description = "Professional Services Delivery & Capacity Management"
+app_email = "claus.lohmar@lohmar.co.uk"
+app_license = "mit"
+
+# Apps
+# ------------------
+
+# required_apps = []
+
+# Each item in the list will be shown as an app in the apps page
+add_to_apps_screen = [
+ {
+ "name": "service_factory",
+ "logo": "/assets/service_factory/icons/service_factory.svg",
+ "title": "Service Factory",
+ "route": "/app/engagement-board",
+ "has_permission": "service_factory.api.permission.has_app_permission"
+ }
+]
+
+# Includes in
+# ------------------
+
+# include js, css files in header of desk.html
+# app_include_css = "/assets/service_factory/css/service_factory.css"
+# app_include_js = "/assets/service_factory/js/service_factory.js"
+
+# include js, css files in header of web template
+# web_include_css = "/assets/service_factory/css/service_factory.css"
+# web_include_js = "/assets/service_factory/js/service_factory.js"
+
+# include custom scss in every website theme (without file extension ".scss")
+# website_theme_scss = "service_factory/public/scss/website"
+
+# include js, css files in header of web form
+# webform_include_js = {"doctype": "public/js/doctype.js"}
+# webform_include_css = {"doctype": "public/css/doctype.css"}
+
+# include js in page
+# page_js = {"page" : "public/js/file.js"}
+
+# include js in doctype views
+doctype_js = {"Engagement Card" : "doctype/engagement_card/engagement_card.js"}
+# doctype_list_js = {"doctype" : "public/js/doctype_list.js"}
+# doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"}
+# doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"}
+
+# Svg Icons
+# ------------------
+# include app icons in desk
+# app_include_icons = "service_factory/public/icons.svg"
+
+# Home Pages
+# ----------
+
+# application home page (will override Website Settings)
+# home_page = "login"
+
+# website user home page (by Role)
+# role_home_page = {
+# "Role": "home_page"
+# }
+
+# Generators
+# ----------
+
+# automatically create page for each record of this doctype
+# website_generators = ["Web Page"]
+
+# automatically load and sync documents of this doctype from downstream apps
+# importable_doctypes = [doctype_1]
+
+# Jinja
+# ----------
+
+# add methods and filters to jinja environment
+# jinja = {
+# "methods": "service_factory.utils.jinja_methods",
+# "filters": "service_factory.utils.jinja_filters"
+# }
+
+# Installation
+# ------------
+
+# before_install = "service_factory.install.before_install"
+# after_install = "service_factory.install.after_install"
+
+# Uninstallation
+# ------------
+
+# before_uninstall = "service_factory.uninstall.before_uninstall"
+# after_uninstall = "service_factory.uninstall.after_uninstall"
+
+# Integration Setup
+# ------------------
+# To set up dependencies/integrations with other apps
+# Name of the app being installed is passed as an argument
+
+# before_app_install = "service_factory.utils.before_app_install"
+# after_app_install = "service_factory.utils.after_app_install"
+
+# Integration Cleanup
+# -------------------
+# To clean up dependencies/integrations with other apps
+# Name of the app being uninstalled is passed as an argument
+
+# before_app_uninstall = "service_factory.utils.before_app_uninstall"
+# after_app_uninstall = "service_factory.utils.after_app_uninstall"
+
+# Build
+# ------------------
+# To hook into the build process
+
+# after_build = "service_factory.build.after_build"
+
+# Desk Notifications
+# ------------------
+# See frappe.core.notifications.get_notification_config
+
+# notification_config = "service_factory.notifications.get_notification_config"
+
+# Permissions
+# -----------
+# Permissions evaluated in scripted ways
+
+# permission_query_conditions = {
+# "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions",
+# }
+#
+# has_permission = {
+# "Event": "frappe.desk.doctype.event.event.has_permission",
+# }
+
+# Document Events
+# ---------------
+# Hook on document methods and events
+
+# doc_events = {
+# "*": {
+# "on_update": "method",
+# "on_cancel": "method",
+# "on_trash": "method"
+# }
+# }
+
+# Scheduled Tasks
+# ---------------
+
+# scheduler_events = {
+# "all": [
+# "service_factory.tasks.all"
+# ],
+# "daily": [
+# "service_factory.tasks.daily"
+# ],
+# "hourly": [
+# "service_factory.tasks.hourly"
+# ],
+# "weekly": [
+# "service_factory.tasks.weekly"
+# ],
+# "monthly": [
+# "service_factory.tasks.monthly"
+# ],
+# }
+
+# Fixtures - seed data synced during bench migrate
+# Add new DocType records here to make them portable
+# across all server instances.
+# -----------------------------------------
+fixtures = []
+
+# Testing
+# -------
+
+# before_tests = "service_factory.install.before_tests"
+
+# Extend DocType Class
+# ------------------------------
+#
+# Specify custom mixins to extend the standard doctype controller.
+# extend_doctype_class = {
+# "Task": "service_factory.custom.task.CustomTaskMixin"
+# }
+
+# Overriding Methods
+# ------------------------------
+#
+# override_whitelisted_methods = {
+# "frappe.desk.doctype.event.event.get_events": "service_factory.event.get_events"
+# }
+#
+# each overriding function accepts a `data` argument;
+# generated from the base implementation of the doctype dashboard,
+# along with any modifications made in other Frappe apps
+# override_doctype_dashboards = {
+# "Task": "service_factory.task.get_dashboard_data"
+# }
+
+# exempt linked doctypes from being automatically cancelled
+#
+# auto_cancel_exempted_doctypes = ["Auto Repeat"]
+
+# Ignore links to specified DocTypes when deleting documents
+# -----------------------------------------------------------
+
+# ignore_links_on_delete = ["Communication", "ToDo"]
+
+# Request Events
+# ----------------
+# before_request = ["service_factory.utils.before_request"]
+# after_request = ["service_factory.utils.after_request"]
+
+# Job Events
+# ----------
+# before_job = ["service_factory.utils.before_job"]
+# after_job = ["service_factory.utils.after_job"]
+
+# User Data Protection
+# --------------------
+
+# user_data_fields = [
+# {
+# "doctype": "{doctype_1}",
+# "filter_by": "{filter_by}",
+# "redact_fields": ["{field_1}", "{field_2}"],
+# "partial": 1,
+# },
+# {
+# "doctype": "{doctype_2}",
+# "filter_by": "{filter_by}",
+# "partial": 1,
+# },
+# {
+# "doctype": "{doctype_3}",
+# "strict": False,
+# },
+# {
+# "doctype": "{doctype_4}"
+# }
+# ]
+
+# Authentication and authorization
+# --------------------------------
+
+# auth_hooks = [
+# "service_factory.auth.validate"
+# ]
+
+# Automatically update python controller files with type annotations for this app.
+# export_python_type_annotations = True
+
+# default_log_clearing_doctypes = {
+# "Logging DocType Name": 30 # days to retain logs
+# }
+
+# Translation
+# ------------
+# List of apps whose translatable strings should be excluded from this app's translations.
+# ignore_translatable_strings_from = []
+
diff --git a/service_factory/service_factory/modules.txt b/service_factory/service_factory/modules.txt
new file mode 100644
index 0000000..14185a0
--- /dev/null
+++ b/service_factory/service_factory/modules.txt
@@ -0,0 +1 @@
+Service Factory
\ No newline at end of file
diff --git a/service_factory/service_factory/patches.txt b/service_factory/service_factory/patches.txt
new file mode 100644
index 0000000..f15c3a9
--- /dev/null
+++ b/service_factory/service_factory/patches.txt
@@ -0,0 +1,6 @@
+[pre_model_sync]
+# Patches added in this section will be executed before doctypes are migrated
+# Read docs to understand patches: https://frappeframework.com/docs/v14/user/en/database-migrations
+
+[post_model_sync]
+# Patches added in this section will be executed after doctypes are migrated
\ No newline at end of file
diff --git a/service_factory/service_factory/doctype/engagement_recipe/__init__.py b/service_factory/service_factory/patches/__init__.py
similarity index 100%
rename from service_factory/service_factory/doctype/engagement_recipe/__init__.py
rename to service_factory/service_factory/patches/__init__.py
diff --git a/service_factory/service_factory/.frappe b/service_factory/service_factory/public/.gitkeep
similarity index 100%
rename from service_factory/service_factory/.frappe
rename to service_factory/service_factory/public/.gitkeep
diff --git a/service_factory/service_factory/public/icons/service_factory.svg b/service_factory/service_factory/public/icons/service_factory.svg
new file mode 100644
index 0000000..1767ccc
--- /dev/null
+++ b/service_factory/service_factory/public/icons/service_factory.svg
@@ -0,0 +1,10 @@
+
diff --git a/service_factory/service_factory/doctype/engagement_recipe_step/__init__.py b/service_factory/service_factory/service_factory/__init__.py
similarity index 100%
rename from service_factory/service_factory/doctype/engagement_recipe_step/__init__.py
rename to service_factory/service_factory/service_factory/__init__.py
diff --git a/service_factory/service_factory/doctype/execution_card/__init__.py b/service_factory/service_factory/service_factory/doctype/engagement_card/__init__.py
similarity index 100%
rename from service_factory/service_factory/doctype/execution_card/__init__.py
rename to service_factory/service_factory/service_factory/doctype/engagement_card/__init__.py
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
new file mode 100644
index 0000000..3613be1
--- /dev/null
+++ b/service_factory/service_factory/service_factory/doctype/engagement_card/engagement_card.js
@@ -0,0 +1,77 @@
+// Copyright (c) 2026, cclohmar and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on("Engagement Card", {
+ refresh: function (frm) {
+ // Add quick actions in the form toolbar
+ if (frm.doc.status === "Discovery") {
+ frm.add_custom_button(__("Move to Delivery"), function () {
+ frm.set_value("status", "Delivery");
+ frm.save();
+ });
+ } else if (frm.doc.status === "Delivery") {
+ frm.add_custom_button(__("Mark Complete"), function () {
+ frm.set_value("status", "Complete");
+ frm.save();
+ });
+ } else if (frm.doc.status === "Complete") {
+ frm.add_custom_button(__("Archive"), function () {
+ frm.set_value("status", "Archived");
+ frm.set_value("actual_end_date", frappe.datetime.get_today());
+ frm.save();
+ });
+ }
+
+ // Add log activity quick button
+ frm.add_custom_button(__("Log Activity"), function () {
+ let d = new frappe.ui.Dialog({
+ title: __("Log Activity"),
+ fields: [
+ {
+ label: "Activity Type",
+ fieldname: "activity_type",
+ fieldtype: "Select",
+ options: ["Comment", "Milestone", "Note"],
+ reqd: 1,
+ },
+ {
+ label: "Content",
+ fieldname: "content",
+ fieldtype: "Text Editor",
+ reqd: 1,
+ },
+ {
+ label: "Mark as Milestone",
+ fieldname: "is_milestone",
+ fieldtype: "Check",
+ },
+ ],
+ primary_action: function () {
+ let data = d.get_values();
+ let child = frm.add_child("activity_log");
+ child.posted_by = frappe.session.user;
+ child.timestamp = frappe.datetime.now_datetime();
+ child.activity_type = data.activity_type;
+ child.content = data.content;
+ child.is_milestone = data.is_milestone;
+ frm.refresh_field("activity_log");
+ d.hide();
+ },
+ });
+ 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");
+ },
+});
+
+frappe.ui.form.on("Engagement Card Time Log", {
+ 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
new file mode 100644
index 0000000..328edcc
--- /dev/null
+++ b/service_factory/service_factory/service_factory/doctype/engagement_card/engagement_card.json
@@ -0,0 +1,259 @@
+{
+ "actions": [],
+ "allow_auto_repeat": 0,
+ "allow_events_in_timeline": 1,
+ "allow_guest_to_view": 0,
+ "allow_import": 1,
+ "allow_rename": 0,
+ "autoname": "naming_series:",
+ "creation": "2026-07-15 23:00:00.000000",
+ "doctype": "DocType",
+ "document_type": "Document",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "naming_series",
+ "title",
+ "card_type",
+ "status",
+ "column_break_1",
+ "priority",
+ "customer",
+ "opportunity",
+ "section_break_dates",
+ "start_date",
+ "target_end_date",
+ "column_break_dates_2",
+ "actual_end_date",
+ "section_break_budget",
+ "allocated_budget_hours",
+ "total_logged_hours",
+ "column_break_budget_2",
+ "sales_order",
+ "section_break_desc",
+ "description",
+ "section_break_assignments",
+ "assignments",
+ "section_break_activity",
+ "activity_log",
+ "section_break_time",
+ "time_log",
+ "section_break_subtasks",
+ "subtasks"
+ ],
+ "fields": [
+ {
+ "fieldname": "naming_series",
+ "fieldtype": "Select",
+ "hidden": 0,
+ "label": "Series",
+ "no_copy": 1,
+ "options": "TEC-.YYYY.-",
+ "print_hide": 1,
+ "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",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Card Type",
+ "options": "SE\nPS\nMS\nSupport",
+ "reqd": 1
+ },
+ {
+ "fieldname": "status",
+ "fieldtype": "Select",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Status",
+ "options": "Discovery\nDelivery\nComplete\nArchived",
+ "reqd": 1,
+ "default": "Discovery"
+ },
+ {
+ "fieldname": "column_break_1",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "priority",
+ "fieldtype": "Select",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Priority",
+ "options": "Low\nMedium\nHigh\nCritical",
+ "reqd": 1,
+ "default": "Medium"
+ },
+ {
+ "fieldname": "customer",
+ "fieldtype": "Link",
+ "in_global_search": 1,
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Customer",
+ "options": "Customer"
+ },
+ {
+ "fieldname": "opportunity",
+ "fieldtype": "Link",
+ "label": "Opportunity",
+ "options": "Opportunity"
+ },
+ {
+ "fieldname": "section_break_dates",
+ "fieldtype": "Section Break",
+ "label": "Timeline"
+ },
+ {
+ "fieldname": "start_date",
+ "fieldtype": "Date",
+ "label": "Start Date"
+ },
+ {
+ "fieldname": "target_end_date",
+ "fieldtype": "Date",
+ "label": "Target End Date"
+ },
+ {
+ "fieldname": "column_break_dates_2",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "actual_end_date",
+ "fieldtype": "Date",
+ "label": "Actual End Date",
+ "read_only": 1
+ },
+ {
+ "fieldname": "section_break_budget",
+ "fieldtype": "Section Break",
+ "label": "Budget & Billing"
+ },
+ {
+ "fieldname": "allocated_budget_hours",
+ "fieldtype": "Float",
+ "label": "Allocated Budget Hours"
+ },
+ {
+ "fieldname": "total_logged_hours",
+ "fieldtype": "Float",
+ "label": "Total Logged Hours",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_budget_2",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "sales_order",
+ "fieldtype": "Link",
+ "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",
+ "label": "Team Assignments"
+ },
+ {
+ "fieldname": "assignments",
+ "fieldtype": "Table",
+ "label": "Assignments",
+ "options": "Engagement Card Assignment",
+ "reqd": 0
+ },
+ {
+ "fieldname": "section_break_activity",
+ "fieldtype": "Section Break",
+ "label": "Activity Log"
+ },
+ {
+ "fieldname": "activity_log",
+ "fieldtype": "Table",
+ "label": "Activity Log",
+ "options": "Engagement Card Activity"
+ },
+ {
+ "fieldname": "section_break_time",
+ "fieldtype": "Section Break",
+ "label": "Time Tracking"
+ },
+ {
+ "fieldname": "time_log",
+ "fieldtype": "Table",
+ "label": "Time Log",
+ "options": "Engagement Card Time Log"
+ },
+ {
+ "fieldname": "section_break_subtasks",
+ "fieldtype": "Section Break",
+ "label": "Subtasks"
+ },
+ {
+ "fieldname": "subtasks",
+ "fieldtype": "Table",
+ "label": "Subtasks",
+ "options": "Engagement Card Subtask"
+ }
+ ],
+ "links": [],
+ "modified": "2026-07-15 23:00:00.000000",
+ "modified_by": "Administrator",
+ "module": "Service Factory",
+ "name": "Engagement Card",
+ "naming_rule": "By \"Naming Series\" field",
+ "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_card/engagement_card.py b/service_factory/service_factory/service_factory/doctype/engagement_card/engagement_card.py
new file mode 100644
index 0000000..680fb4c
--- /dev/null
+++ b/service_factory/service_factory/service_factory/doctype/engagement_card/engagement_card.py
@@ -0,0 +1,65 @@
+# Copyright (c) 2026, cclohmar and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe.model.document import Document
+from frappe import _
+
+
+class EngagementCard(Document):
+ def validate(self):
+ self.set_title()
+ 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'}"
+
+ def update_logged_hours(self):
+ """Roll up total logged hours from time log child table."""
+ total = 0.0
+ if self.get("time_log"):
+ for entry in self.time_log:
+ total += entry.get("hours", 0) or 0
+ 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):
+ """Push time log hours back to assignment table per employee."""
+ 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_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"})
+ archived = frappe.db.count("Engagement Card", {"status": "Archived"})
+
+ return {
+ "total_cards": total_cards,
+ "in_delivery": in_delivery,
+ "in_discovery": in_discovery,
+ "archived": archived,
+ }
diff --git a/service_factory/service_factory/doctype/execution_card_log/__init__.py b/service_factory/service_factory/service_factory/doctype/engagement_card_activity/__init__.py
similarity index 100%
rename from service_factory/service_factory/doctype/execution_card_log/__init__.py
rename to service_factory/service_factory/service_factory/doctype/engagement_card_activity/__init__.py
diff --git a/service_factory/service_factory/service_factory/doctype/engagement_card_activity/engagement_card_activity.json b/service_factory/service_factory/service_factory/doctype/engagement_card_activity/engagement_card_activity.json
new file mode 100644
index 0000000..47101e1
--- /dev/null
+++ b/service_factory/service_factory/service_factory/doctype/engagement_card_activity/engagement_card_activity.json
@@ -0,0 +1,84 @@
+{
+ "actions": [],
+ "allow_rename": 0,
+ "autoname": "hash",
+ "creation": "2026-07-15 23:00:00.000000",
+ "doctype": "DocType",
+ "document_type": "Document",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "posted_by",
+ "timestamp",
+ "activity_type",
+ "content",
+ "is_milestone"
+ ],
+ "fields": [
+ {
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "fieldname": "posted_by",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Posted By",
+ "options": "User",
+ "reqd": 1
+ },
+ {
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "default": "Today",
+ "fieldname": "timestamp",
+ "fieldtype": "Datetime",
+ "in_list_view": 1,
+ "label": "Timestamp",
+ "reqd": 1
+ },
+ {
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "fieldname": "activity_type",
+ "fieldtype": "Select",
+ "in_list_view": 1,
+ "label": "Activity Type",
+ "options": "Comment\nMilestone\nStatus Change\nNote",
+ "reqd": 1
+ },
+ {
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "fieldname": "content",
+ "fieldtype": "Text Editor",
+ "in_list_view": 1,
+ "label": "Content"
+ },
+ {
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "default": "0",
+ "fieldname": "is_milestone",
+ "fieldtype": "Check",
+ "in_list_view": 1,
+ "label": "Is Milestone"
+ }
+ ],
+ "istable": 1,
+ "links": [],
+ "modified": "2026-07-15 23:00:00.000000",
+ "modified_by": "Administrator",
+ "module": "Service Factory",
+ "name": "Engagement Card Activity",
+ "naming_rule": "Random",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": [],
+ "track_changes": 0
+}
diff --git a/service_factory/service_factory/doctype/engagement_order_run_step/engagement_order_run_step.py b/service_factory/service_factory/service_factory/doctype/engagement_card_activity/engagement_card_activity.py
similarity index 78%
rename from service_factory/service_factory/doctype/engagement_order_run_step/engagement_order_run_step.py
rename to service_factory/service_factory/service_factory/doctype/engagement_card_activity/engagement_card_activity.py
index 5bed55d..7724481 100644
--- a/service_factory/service_factory/doctype/engagement_order_run_step/engagement_order_run_step.py
+++ b/service_factory/service_factory/service_factory/doctype/engagement_card_activity/engagement_card_activity.py
@@ -4,5 +4,5 @@
from frappe.model.document import Document
-class EngagementOrderRunStep(Document):
+class EngagementCardActivity(Document):
pass
diff --git a/service_factory/service_factory/doctype/resource_center/__init__.py b/service_factory/service_factory/service_factory/doctype/engagement_card_assignment/__init__.py
similarity index 100%
rename from service_factory/service_factory/doctype/resource_center/__init__.py
rename to service_factory/service_factory/service_factory/doctype/engagement_card_assignment/__init__.py
diff --git a/service_factory/service_factory/service_factory/doctype/engagement_card_assignment/engagement_card_assignment.json b/service_factory/service_factory/service_factory/doctype/engagement_card_assignment/engagement_card_assignment.json
new file mode 100644
index 0000000..feab187
--- /dev/null
+++ b/service_factory/service_factory/service_factory/doctype/engagement_card_assignment/engagement_card_assignment.json
@@ -0,0 +1,71 @@
+{
+ "actions": [],
+ "allow_rename": 0,
+ "autoname": "hash",
+ "creation": "2026-07-15 23:00:00.000000",
+ "doctype": "DocType",
+ "document_type": "Document",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "employee",
+ "role",
+ "allocated_hours",
+ "logged_hours"
+ ],
+ "fields": [
+ {
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "fieldname": "employee",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Employee",
+ "options": "Employee",
+ "reqd": 1
+ },
+ {
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "fieldname": "role",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Role"
+ },
+ {
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "fieldname": "allocated_hours",
+ "fieldtype": "Float",
+ "in_list_view": 1,
+ "label": "Allocated Hours"
+ },
+ {
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "fieldname": "logged_hours",
+ "fieldtype": "Float",
+ "in_list_view": 1,
+ "label": "Logged Hours",
+ "read_only": 1
+ }
+ ],
+ "istable": 1,
+ "links": [],
+ "modified": "2026-07-15 23:00:00.000000",
+ "modified_by": "Administrator",
+ "module": "Service Factory",
+ "name": "Engagement Card Assignment",
+ "naming_rule": "Random",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": [],
+ "track_changes": 0
+}
diff --git a/service_factory/service_factory/doctype/resource_center_member/resource_center_member.py b/service_factory/service_factory/service_factory/doctype/engagement_card_assignment/engagement_card_assignment.py
similarity index 78%
rename from service_factory/service_factory/doctype/resource_center_member/resource_center_member.py
rename to service_factory/service_factory/service_factory/doctype/engagement_card_assignment/engagement_card_assignment.py
index dc08087..0a9be70 100644
--- a/service_factory/service_factory/doctype/resource_center_member/resource_center_member.py
+++ b/service_factory/service_factory/service_factory/doctype/engagement_card_assignment/engagement_card_assignment.py
@@ -4,5 +4,5 @@
from frappe.model.document import Document
-class ResourceCenterMember(Document):
+class EngagementCardAssignment(Document):
pass
diff --git a/service_factory/service_factory/doctype/resource_center_member/__init__.py b/service_factory/service_factory/service_factory/doctype/engagement_card_subtask/__init__.py
similarity index 100%
rename from service_factory/service_factory/doctype/resource_center_member/__init__.py
rename to service_factory/service_factory/service_factory/doctype/engagement_card_subtask/__init__.py
diff --git a/service_factory/service_factory/service_factory/doctype/engagement_card_subtask/engagement_card_subtask.json b/service_factory/service_factory/service_factory/doctype/engagement_card_subtask/engagement_card_subtask.json
new file mode 100644
index 0000000..7681be3
--- /dev/null
+++ b/service_factory/service_factory/service_factory/doctype/engagement_card_subtask/engagement_card_subtask.json
@@ -0,0 +1,72 @@
+{
+ "actions": [],
+ "allow_rename": 0,
+ "autoname": "hash",
+ "creation": "2026-07-15 23:00:00.000000",
+ "doctype": "DocType",
+ "document_type": "Document",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "subject",
+ "assigned_to",
+ "status",
+ "due_date"
+ ],
+ "fields": [
+ {
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "fieldname": "subject",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Subject",
+ "reqd": 1
+ },
+ {
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "fieldname": "assigned_to",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Assigned To",
+ "options": "Employee"
+ },
+ {
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "default": "Open",
+ "fieldname": "status",
+ "fieldtype": "Select",
+ "in_list_view": 1,
+ "label": "Status",
+ "options": "Open\nCompleted",
+ "reqd": 1
+ },
+ {
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "fieldname": "due_date",
+ "fieldtype": "Date",
+ "in_list_view": 1,
+ "label": "Due Date"
+ }
+ ],
+ "istable": 1,
+ "links": [],
+ "modified": "2026-07-15 23:00:00.000000",
+ "modified_by": "Administrator",
+ "module": "Service Factory",
+ "name": "Engagement Card Subtask",
+ "naming_rule": "Random",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": [],
+ "track_changes": 0
+}
diff --git a/service_factory/service_factory/doctype/engagement_recipe_step/engagement_recipe_step.py b/service_factory/service_factory/service_factory/doctype/engagement_card_subtask/engagement_card_subtask.py
similarity index 79%
rename from service_factory/service_factory/doctype/engagement_recipe_step/engagement_recipe_step.py
rename to service_factory/service_factory/service_factory/doctype/engagement_card_subtask/engagement_card_subtask.py
index 140ec90..69c4b2a 100644
--- a/service_factory/service_factory/doctype/engagement_recipe_step/engagement_recipe_step.py
+++ b/service_factory/service_factory/service_factory/doctype/engagement_card_subtask/engagement_card_subtask.py
@@ -4,5 +4,5 @@
from frappe.model.document import Document
-class EngagementRecipeStep(Document):
+class EngagementCardSubtask(Document):
pass
diff --git a/service_factory/service_factory/service_factory/doctype/engagement_card_time_log/__init__.py b/service_factory/service_factory/service_factory/doctype/engagement_card_time_log/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/service_factory/service_factory/service_factory/doctype/engagement_card_time_log/engagement_card_time_log.json b/service_factory/service_factory/service_factory/doctype/engagement_card_time_log/engagement_card_time_log.json
new file mode 100644
index 0000000..f3c081e
--- /dev/null
+++ b/service_factory/service_factory/service_factory/doctype/engagement_card_time_log/engagement_card_time_log.json
@@ -0,0 +1,72 @@
+{
+ "actions": [],
+ "allow_rename": 0,
+ "autoname": "hash",
+ "creation": "2026-07-15 23:00:00.000000",
+ "doctype": "DocType",
+ "document_type": "Document",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "employee",
+ "date",
+ "hours",
+ "description"
+ ],
+ "fields": [
+ {
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "fieldname": "employee",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Employee",
+ "options": "Employee",
+ "reqd": 1
+ },
+ {
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "default": "Today",
+ "fieldname": "date",
+ "fieldtype": "Date",
+ "in_list_view": 1,
+ "label": "Date",
+ "reqd": 1
+ },
+ {
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "fieldname": "hours",
+ "fieldtype": "Float",
+ "in_list_view": 1,
+ "label": "Hours",
+ "reqd": 1
+ },
+ {
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "fieldname": "description",
+ "fieldtype": "Small Text",
+ "in_list_view": 1,
+ "label": "Description"
+ }
+ ],
+ "istable": 1,
+ "links": [],
+ "modified": "2026-07-15 23:00:00.000000",
+ "modified_by": "Administrator",
+ "module": "Service Factory",
+ "name": "Engagement Card Time Log",
+ "naming_rule": "Random",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": [],
+ "track_changes": 0
+}
diff --git a/service_factory/service_factory/doctype/execution_card_log/execution_card_log.py b/service_factory/service_factory/service_factory/doctype/engagement_card_time_log/engagement_card_time_log.py
similarity index 79%
rename from service_factory/service_factory/doctype/execution_card_log/execution_card_log.py
rename to service_factory/service_factory/service_factory/doctype/engagement_card_time_log/engagement_card_time_log.py
index f3d18df..54cd773 100644
--- a/service_factory/service_factory/doctype/execution_card_log/execution_card_log.py
+++ b/service_factory/service_factory/service_factory/doctype/engagement_card_time_log/engagement_card_time_log.py
@@ -4,5 +4,5 @@
from frappe.model.document import Document
-class ExecutionCardLog(Document):
+class EngagementCardTimeLog(Document):
pass
diff --git a/service_factory/service_factory/service_factory/workspace/engagement_board/engagement_board.json b/service_factory/service_factory/service_factory/workspace/engagement_board/engagement_board.json
new file mode 100644
index 0000000..e58863f
--- /dev/null
+++ b/service_factory/service_factory/service_factory/workspace/engagement_board/engagement_board.json
@@ -0,0 +1,106 @@
+{
+ "charts": [],
+ "content": "[{\"id\":\"board_header_1\",\"type\":\"header\",\"data\":{\"text\":\"Engagement Board\",\"col\":12}},{\"id\":\"board_card_all\",\"type\":\"card\",\"data\":{\"card_name\":\"All Engagement Cards\",\"col\":4}},{\"id\":\"board_card_discovery\",\"type\":\"card\",\"data\":{\"card_name\":\"Discovery Cards\",\"col\":4}},{\"id\":\"board_card_delivery\",\"type\":\"card\",\"data\":{\"card_name\":\"Delivery Cards\",\"col\":4}},{\"id\":\"board_spacer_1\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"board_header_2\",\"type\":\"header\",\"data\":{\"text\":\"Quick Access\",\"col\":12}},{\"id\":\"board_shortcut_new\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"New Engagement Card\",\"col\":3}},{\"id\":\"board_shortcut_kpi\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"KPI Dashboard\",\"col\":3}}]",
+ "creation": "2026-07-15 23:00:00.000000",
+ "custom_blocks": [],
+ "docstatus": 0,
+ "doctype": "Workspace",
+ "for_user": "",
+ "hide_custom": 0,
+ "icon": "kanban",
+ "idx": 1,
+ "is_hidden": 0,
+ "label": "Engagement Board",
+ "links": [
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Engagement Cards",
+ "link_count": 2,
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Card Break"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Engagement Card",
+ "link_count": 0,
+ "link_to": "Engagement Card",
+ "link_type": "DocType",
+ "onboard": 1,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Kanban Board",
+ "link_count": 0,
+ "link_to": "Engagement Card",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Reporting",
+ "link_count": 1,
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Card Break"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "KPI Dashboard",
+ "link_count": 0,
+ "link_to": "Engagement Card",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ }
+ ],
+ "modified": "2026-07-15 23:00:00.000000",
+ "modified_by": "Administrator",
+ "module": "Service Factory",
+ "name": "Engagement Board",
+ "number_cards": [],
+ "owner": "Administrator",
+ "parent_page": "",
+ "public": 1,
+ "quick_lists": [],
+ "roles": [],
+ "sequence_id": 1.0,
+ "shortcuts": [
+ {
+ "action": "New",
+ "color": "Blue",
+ "doc_type": "Engagement Card",
+ "format": "{}",
+ "icon": "add",
+ "label": "New Engagement Card",
+ "link_to": "Engagement Card",
+ "stats_filter": "[]",
+ "type": "DocType"
+ },
+ {
+ "color": "Green",
+ "doc_view": null,
+ "format": "{}",
+ "icon": "chart",
+ "kanban_board": null,
+ "label": "KPI Dashboard",
+ "link_to": "Engagement Card",
+ "link_type": "DocType",
+ "restrict_to_domain": null,
+ "stats_filter": "[]",
+ "type": "DocType",
+ "url": null
+ }
+ ],
+ "title": "Engagement Board",
+ "type": "Workspace"
+}
diff --git a/service_factory/service_factory/templates/__init__.py b/service_factory/service_factory/templates/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/service_factory/service_factory/templates/pages/__init__.py b/service_factory/service_factory/templates/pages/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/service_factory/service_factory/workspace/director_workspace/director_workspace.json b/service_factory/service_factory/workspace/director_workspace/director_workspace.json
deleted file mode 100644
index e520824..0000000
--- a/service_factory/service_factory/workspace/director_workspace/director_workspace.json
+++ /dev/null
@@ -1,170 +0,0 @@
-{
- "charts": [],
- "content": "[{\"id\":\"director_header_1\",\"type\":\"header\",\"data\":{\"text\":\"Financial Margin Ledger\",\"col\":12}},{\"id\":\"director_card_margin\",\"type\":\"card\",\"data\":{\"card_name\":\"Margin Overview\",\"col\":6}},{\"id\":\"director_card_pnl\",\"type\":\"card\",\"data\":{\"card_name\":\"P&L Summary\",\"col\":6}},{\"id\":\"director_spacer_1\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"director_header_2\",\"type\":\"header\",\"data\":{\"text\":\"Delivery Performance\",\"col\":12}},{\"id\":\"director_card_efficiency\",\"type\":\"card\",\"data\":{\"card_name\":\"Delivery Efficiency\",\"col\":6}},{\"id\":\"director_card_burn\",\"type\":\"card\",\"data\":{\"card_name\":\"Resource Burn-Down\",\"col\":6}},{\"id\":\"director_spacer_2\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"director_header_3\",\"type\":\"header\",\"data\":{\"text\":\"Executive KPIs\",\"col\":12}},{\"id\":\"director_shortcut_revenue\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Revenue Pipeline\",\"col\":3}},{\"id\":\"director_shortcut_costs\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Delivery Costs\",\"col\":3}},{\"id\":\"director_shortcut_margin\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Gross Margin %\",\"col\":3}},{\"id\":\"director_shortcut_utilization\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Utilization Rate\",\"col\":3}}]",
- "creation": "2026-07-15 19:00:00.000000",
- "custom_blocks": [],
- "docstatus": 0,
- "doctype": "Workspace",
- "for_user": "",
- "hide_custom": 0,
- "icon": "chart",
- "idx": 1,
- "is_hidden": 0,
- "label": "Director Workspace",
- "links": [
- {
- "hidden": 0,
- "is_query_report": 0,
- "label": "Financial Overview",
- "link_count": 3,
- "link_type": "DocType",
- "onboard": 0,
- "type": "Card Break"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Engagement Order",
- "link_count": 0,
- "link_to": "Engagement Order",
- "link_type": "DocType",
- "onboard": 1,
- "type": "Link"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Execution Card",
- "link_count": 0,
- "link_to": "Execution Card",
- "link_type": "DocType",
- "onboard": 1,
- "type": "Link"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Resource Center",
- "link_count": 0,
- "link_to": "Resource Center",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
- {
- "hidden": 0,
- "is_query_report": 0,
- "label": "Analytics",
- "link_count": 4,
- "link_type": "DocType",
- "onboard": 0,
- "type": "Card Break"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "All Orders",
- "link_count": 0,
- "link_to": "Engagement Order",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Completed Orders",
- "link_count": 0,
- "link_to": "Engagement Order",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Engagement Recipes",
- "link_count": 0,
- "link_to": "Engagement Recipe",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Submitted Cards",
- "link_count": 0,
- "link_to": "Execution Card",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- }
- ],
- "modified": "2026-07-15 19:00:00.000000",
- "modified_by": "Administrator",
- "module": "Service Factory",
- "name": "Director Workspace",
- "number_cards": [],
- "owner": "Administrator",
- "parent_page": "",
- "public": 1,
- "quick_lists": [],
- "roles": [],
- "sequence_id": 3.0,
- "shortcuts": [
- {
- "action": "List",
- "color": "Green",
- "doc_type": "Engagement Order",
- "format": "{}",
- "icon": "currency",
- "label": "Revenue Pipeline",
- "link_to": "Engagement Order",
- "stats_filter": "[[\"Engagement Order\",\"docstatus\",\"=\",1],[\"Engagement Order\",\"status\",\"!=\",\"Cancelled\"]]",
- "type": "DocType"
- },
- {
- "action": "List",
- "color": "Red",
- "doc_type": "Execution Card",
- "format": "{}",
- "icon": "cost",
- "label": "Delivery Costs",
- "link_to": "Execution Card",
- "stats_filter": "[[\"Execution Card\",\"status\",\"=\",\"Submitted\"]]",
- "type": "DocType"
- },
- {
- "action": "Report",
- "color": "Blue",
- "doc_type": "Engagement Order",
- "format": "{}",
- "icon": "percent",
- "label": "Gross Margin %",
- "link_to": "Engagement Order",
- "stats_filter": "[]",
- "type": "DocType"
- },
- {
- "action": "List",
- "color": "Orange",
- "doc_type": "Resource Center",
- "format": "{}",
- "icon": "chart",
- "label": "Utilization Rate",
- "link_to": "Resource Center",
- "stats_filter": "[]",
- "type": "DocType"
- }
- ],
- "title": "Director Workspace",
- "type": "Workspace"
-}
diff --git a/service_factory/service_factory/workspace/engineer_workspace/engineer_workspace.json b/service_factory/service_factory/workspace/engineer_workspace/engineer_workspace.json
deleted file mode 100644
index 0cc5cdc..0000000
--- a/service_factory/service_factory/workspace/engineer_workspace/engineer_workspace.json
+++ /dev/null
@@ -1,146 +0,0 @@
-{
- "charts": [],
- "content": "[{\"id\":\"engineer_header_1\",\"type\":\"header\",\"data\":{\"text\":\"My Active Deliverables\",\"col\":12}},{\"id\":\"engineer_card_my_cards\",\"type\":\"card\",\"data\":{\"card_name\":\"My Execution Cards\",\"col\":4}},{\"id\":\"engineer_card_orders\",\"type\":\"card\",\"data\":{\"card_name\":\"My Engagement Orders\",\"col\":4}},{\"id\":\"engineer_card_reports\",\"type\":\"card\",\"data\":{\"card_name\":\"Reports\",\"col\":4}},{\"id\":\"engineer_spacer_1\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"engineer_header_2\",\"type\":\"header\",\"data\":{\"text\":\"Time Tracking\",\"col\":12}},{\"id\":\"engineer_card_timer\",\"type\":\"card\",\"data\":{\"card_name\":\"Execution Card Timer\",\"col\":6}},{\"id\":\"engineer_card_hours\",\"type\":\"card\",\"data\":{\"card_name\":\"My Hours This Week\",\"col\":6}},{\"id\":\"engineer_spacer_2\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"engineer_header_3\",\"type\":\"header\",\"data\":{\"text\":\"Quick Actions\",\"col\":12}},{\"id\":\"engineer_shortcut_new_card\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"New Execution Card\",\"col\":3}},{\"id\":\"engineer_shortcut_active\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"My Active Cards\",\"col\":3}}]",
- "creation": "2026-07-15 19:00:00.000000",
- "custom_blocks": [],
- "docstatus": 0,
- "doctype": "Workspace",
- "for_user": "",
- "hide_custom": 0,
- "icon": "workflow",
- "idx": 1,
- "is_hidden": 0,
- "label": "Engineer Workspace",
- "links": [
- {
- "hidden": 0,
- "is_query_report": 0,
- "label": "My Execution Cards",
- "link_count": 3,
- "link_type": "DocType",
- "onboard": 0,
- "type": "Card Break"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Execution Card",
- "link_count": 0,
- "link_to": "Execution Card",
- "link_type": "DocType",
- "onboard": 1,
- "type": "Link"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "My Open Cards",
- "link_count": 0,
- "link_to": "Execution Card",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "My Submitted Cards",
- "link_count": 0,
- "link_to": "Execution Card",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
- {
- "hidden": 0,
- "is_query_report": 0,
- "label": "My Engagement Orders",
- "link_count": 2,
- "link_type": "DocType",
- "onboard": 0,
- "type": "Card Break"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Engagement Order",
- "link_count": 0,
- "link_to": "Engagement Order",
- "link_type": "DocType",
- "onboard": 1,
- "type": "Link"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "My Active Orders",
- "link_count": 0,
- "link_to": "Engagement Order",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
- {
- "hidden": 0,
- "is_query_report": 0,
- "label": "Reports",
- "link_count": 1,
- "link_type": "DocType",
- "onboard": 0,
- "type": "Card Break"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "My Hours",
- "link_count": 0,
- "link_to": "Execution Card",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- }
- ],
- "modified": "2026-07-15 19:00:00.000000",
- "modified_by": "Administrator",
- "module": "Service Factory",
- "name": "Engineer Workspace",
- "number_cards": [],
- "owner": "Administrator",
- "parent_page": "",
- "public": 1,
- "quick_lists": [],
- "roles": [],
- "sequence_id": 1.0,
- "shortcuts": [
- {
- "action": "New",
- "color": "Blue",
- "doc_type": "Execution Card",
- "format": "{}",
- "icon": "add",
- "label": "New Execution Card",
- "link_to": "Execution Card",
- "stats_filter": "[]",
- "type": "DocType"
- },
- {
- "action": "List",
- "color": "Grey",
- "doc_type": "Execution Card",
- "format": "{}",
- "icon": "list",
- "label": "My Active Cards",
- "link_to": "Execution Card",
- "stats_filter": "[[\"Execution Card\",\"assigned_employee\",\"=\",\"__user_employee__\"],[\"Execution Card\",\"status\",\"in\",[\"Open\",\"Running\",\"Paused\"]]]",
- "type": "DocType"
- }
- ],
- "title": "Engineer Workspace",
- "type": "Workspace"
-}
diff --git a/service_factory/service_factory/workspace/manager_workspace/manager_workspace.json b/service_factory/service_factory/workspace/manager_workspace/manager_workspace.json
deleted file mode 100644
index f9f25d6..0000000
--- a/service_factory/service_factory/workspace/manager_workspace/manager_workspace.json
+++ /dev/null
@@ -1,221 +0,0 @@
-{
- "charts": [],
- "content": "[{\"id\":\"manager_header_1\",\"type\":\"header\",\"data\":{\"text\":\"Operational Overview\",\"col\":12}},{\"id\":\"manager_card_orders\",\"type\":\"card\",\"data\":{\"card_name\":\"Engagement Orders\",\"col\":4}},{\"id\":\"manager_card_centers\",\"type\":\"card\",\"data\":{\"card_name\":\"Resource Centers\",\"col\":4}},{\"id\":\"manager_card_recipes\",\"type\":\"card\",\"data\":{\"card_name\":\"Engagement Recipes\",\"col\":4}},{\"id\":\"manager_spacer_1\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"manager_header_2\",\"type\":\"header\",\"data\":{\"text\":\"Resource Capacity\",\"col\":12}},{\"id\":\"manager_card_gantt\",\"type\":\"card\",\"data\":{\"card_name\":\"Schedule Gantt\",\"col\":6}},{\"id\":\"manager_card_alerts\",\"type\":\"card\",\"data\":{\"card_name\":\"Capacity Alerts\",\"col\":6}},{\"id\":\"manager_spacer_2\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"manager_header_3\",\"type\":\"header\",\"data\":{\"text\":\"Key Metrics\",\"col\":12}},{\"id\":\"manager_shortcut_active\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Active Engagements\",\"col\":3}},{\"id\":\"manager_shortcut_stalled\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Stalled / Blocked\",\"col\":3}},{\"id\":\"manager_shortcut_capacity\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Capacity Overview\",\"col\":3}},{\"id\":\"manager_shortcut_executions\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"All Execution Cards\",\"col\":3}}]",
- "creation": "2026-07-15 19:00:00.000000",
- "custom_blocks": [],
- "docstatus": 0,
- "doctype": "Workspace",
- "for_user": "",
- "hide_custom": 0,
- "icon": "share",
- "idx": 1,
- "is_hidden": 0,
- "label": "Manager Workspace",
- "links": [
- {
- "hidden": 0,
- "is_query_report": 0,
- "label": "Engagement Orders",
- "link_count": 4,
- "link_type": "DocType",
- "onboard": 0,
- "type": "Card Break"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Engagement Order",
- "link_count": 0,
- "link_to": "Engagement Order",
- "link_type": "DocType",
- "onboard": 1,
- "type": "Link"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Scheduled Orders",
- "link_count": 0,
- "link_to": "Engagement Order",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "In Progress Orders",
- "link_count": 0,
- "link_to": "Engagement Order",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Completed Orders",
- "link_count": 0,
- "link_to": "Engagement Order",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
- {
- "hidden": 0,
- "is_query_report": 0,
- "label": "Resource Centers",
- "link_count": 2,
- "link_type": "DocType",
- "onboard": 0,
- "type": "Card Break"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Resource Center",
- "link_count": 0,
- "link_to": "Resource Center",
- "link_type": "DocType",
- "onboard": 1,
- "type": "Link"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Resource Center Members",
- "link_count": 0,
- "link_to": "Resource Center Member",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
- {
- "hidden": 0,
- "is_query_report": 0,
- "label": "Engagement Recipes",
- "link_count": 2,
- "link_type": "DocType",
- "onboard": 0,
- "type": "Card Break"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Engagement Recipe",
- "link_count": 0,
- "link_to": "Engagement Recipe",
- "link_type": "DocType",
- "onboard": 1,
- "type": "Link"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Engagement Recipe Steps",
- "link_count": 0,
- "link_to": "Engagement Recipe Step",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
- {
- "hidden": 0,
- "is_query_report": 0,
- "label": "Execution Tracking",
- "link_count": 2,
- "link_type": "DocType",
- "onboard": 0,
- "type": "Card Break"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Execution Card",
- "link_count": 0,
- "link_to": "Execution Card",
- "link_type": "DocType",
- "onboard": 1,
- "type": "Link"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Execution Card Logs",
- "link_count": 0,
- "link_to": "Execution Card Log",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- }
- ],
- "modified": "2026-07-15 19:00:00.000000",
- "modified_by": "Administrator",
- "module": "Service Factory",
- "name": "Manager Workspace",
- "number_cards": [],
- "owner": "Administrator",
- "parent_page": "",
- "public": 1,
- "quick_lists": [],
- "roles": [],
- "sequence_id": 2.0,
- "shortcuts": [
- {
- "action": "List",
- "color": "Blue",
- "doc_type": "Engagement Order",
- "format": "{}",
- "icon": "list",
- "label": "Active Engagements",
- "link_to": "Engagement Order",
- "stats_filter": "[[\"Engagement Order\",\"status\",\"in\",[\"Scheduled\",\"In Progress\"]]]",
- "type": "DocType"
- },
- {
- "action": "List",
- "color": "Red",
- "doc_type": "Engagement Order",
- "format": "{}",
- "icon": "info",
- "label": "Stalled / Blocked",
- "link_to": "Engagement Order",
- "stats_filter": "[[\"Engagement Order\",\"status\",\"=\",\"On Hold\"]]",
- "type": "DocType"
- },
- {
- "action": "List",
- "color": "Green",
- "doc_type": "Resource Center",
- "format": "{}",
- "icon": "organization",
- "label": "Capacity Overview",
- "link_to": "Resource Center",
- "stats_filter": "[]",
- "type": "DocType"
- },
- {
- "action": "List",
- "color": "Grey",
- "doc_type": "Execution Card",
- "format": "{}",
- "icon": "check",
- "label": "All Execution Cards",
- "link_to": "Execution Card",
- "stats_filter": "[]",
- "type": "DocType"
- }
- ],
- "title": "Manager Workspace",
- "type": "Workspace"
-}