Portal, logo, workspace shortcut

This commit is contained in:
KabundiNjuki
2026-10-05 16:04:29 +03:00
parent eafa1242db
commit 72046591a3
95 changed files with 5480 additions and 793 deletions
+20 -244
View File
@@ -5,251 +5,8 @@ app_description = "ERP"
app_email = "josephnjuki833@gmail.com"
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": "makap_backend",
# "logo": "/assets/makap_backend/logo.png",
# "title": "Makap Backend",
# "route": "/makap_backend",
# "has_permission": "makap_backend.api.permission.has_app_permission"
# }
# ]
# Includes in <head>
# ------------------
# include js, css files in header of desk.html
# app_include_css = "/assets/makap_backend/css/makap_backend.css"
# app_include_js = "/assets/makap_backend/js/makap_backend.js"
# include js, css files in header of web template
# web_include_css = "/assets/makap_backend/css/makap_backend.css"
# web_include_js = "/assets/makap_backend/js/makap_backend.js"
# include custom scss in every website theme (without file extension ".scss")
# website_theme_scss = "makap_backend/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 = {"doctype" : "public/js/doctype.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 = "makap_backend/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"]
# Jinja
# ----------
# add methods and filters to jinja environment
# jinja = {
# "methods": "makap_backend.utils.jinja_methods",
# "filters": "makap_backend.utils.jinja_filters"
# }
# Installation
# ------------
# before_install = "makap_backend.install.before_install"
# after_install = "makap_backend.install.after_install"
# Uninstallation
# ------------
# before_uninstall = "makap_backend.uninstall.before_uninstall"
# after_uninstall = "makap_backend.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 = "makap_backend.utils.before_app_install"
# after_app_install = "makap_backend.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 = "makap_backend.utils.before_app_uninstall"
# after_app_uninstall = "makap_backend.utils.after_app_uninstall"
# Desk Notifications
# ------------------
# See frappe.core.notifications.get_notification_config
# notification_config = "makap_backend.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",
# }
# DocType Class
# ---------------
# Override standard doctype classes
# override_doctype_class = {
# "ToDo": "custom_app.overrides.CustomToDo"
# }
# Document Events
# ---------------
# Hook on document methods and events
# doc_events = {
# "*": {
# "on_update": "method",
# "on_cancel": "method",
# "on_trash": "method"
# }
# }
# Scheduled Tasks
# ---------------
# scheduler_events = {
# "all": [
# "makap_backend.tasks.all"
# ],
# "daily": [
# "makap_backend.tasks.daily"
# ],
# "hourly": [
# "makap_backend.tasks.hourly"
# ],
# "weekly": [
# "makap_backend.tasks.weekly"
# ],
# "monthly": [
# "makap_backend.tasks.monthly"
# ],
# }
# Testing
# -------
# before_tests = "makap_backend.install.before_tests"
# Overriding Methods
# ------------------------------
#
# override_whitelisted_methods = {
# "frappe.desk.doctype.event.event.get_events": "makap_backend.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": "makap_backend.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 = ["makap_backend.utils.before_request"]
# after_request = ["makap_backend.utils.after_request"]
# Job Events
# ----------
# before_job = ["makap_backend.utils.before_job"]
# after_job = ["makap_backend.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 = [
# "makap_backend.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 = []
# MAKAP Module 3 child-table registration
after_migrate = ["makap_backend.makap_backend.setup.ensure_makap_functional_roles", "makap_backend.makap_backend.setup.ensure_makap_object_permissions", "makap_backend.makap_backend.setup_module3.ensure_module3_child_tables", "makap_backend.makap_backend.setup_extensions.ensure_custom_fields"]
after_migrate = ["makap_backend.makap_backend.setup.ensure_makap_functional_roles", "makap_backend.makap_backend.setup.ensure_makap_object_permissions", "makap_backend.makap_backend.setup_module3.ensure_module3_child_tables", "makap_backend.makap_backend.setup_extensions.ensure_custom_fields", "makap_backend.makap_backend.setup_module8_dashboard.ensure_module8_number_cards", "makap_backend.makap_backend.setup_workspace_dashboard.ensure_workspace_number_cards", "makap_backend.makap_backend.workspace_setup.ensure_makap_workspace", "makap_backend.makap_backend.setup_module8_permissions.ensure_module8_role_permissions", "makap_backend.makap_backend.portal_shortcut.ensure_portal_shortcut"]
# MAKAP cross-module server-side controls
doc_events = {
@@ -271,4 +28,23 @@ doc_events = {
"Purchase Order": {
"validate": "makap_backend.makap_backend.controls.validate_purchase_order",
},
"Delivery Note": {
"validate": "makap_backend.makap_backend.controls.validate_delivery_note",
"on_cancel": "makap_backend.makap_backend.controls.validate_delivery_note_on_cancel",
},
"Sales Invoice": {
"validate": "makap_backend.makap_backend.controls.validate_sales_invoice",
"on_submit": "makap_backend.makap_backend.controls.on_submit_or_cancel_sales_invoice",
"on_cancel": "makap_backend.makap_backend.controls.on_submit_or_cancel_sales_invoice",
},
"Payment Entry": {
"validate": "makap_backend.makap_backend.controls.validate_payment_entry",
"on_submit": "makap_backend.makap_backend.controls.on_submit_or_cancel_payment_entry",
"on_cancel": "makap_backend.makap_backend.controls.on_submit_or_cancel_payment_entry",
},
"Purchase Invoice": {
"validate": "makap_backend.makap_backend.controls.validate_purchase_invoice",
},
}
app_logo_url = "/assets/makap_backend/images/makap-icon.png"
-232
View File
@@ -1,232 +0,0 @@
import frappe
from frappe import _
PUBLIC_FIELDS = {
"organisation_name",
"trading_name",
"customer_type",
"business_tax_identifier",
"contact_name",
"email_id",
"telephone",
"submitted_address",
"location",
"product_interest",
"initial_enquiry",
"privacy_notice_acknowledged",
"privacy_notice_reference_version",
}
def _clean_public_payload(payload):
if not isinstance(payload, dict):
frappe.throw(_("Registration payload must be an object."))
return {key: payload.get(key) for key in PUBLIC_FIELDS if payload.get(key) not in (None, "")}
@frappe.whitelist(allow_guest=True)
def submit_customer_onboarding(payload=None):
"""Create intake evidence only; never create Customer, Contact, or User."""
if isinstance(payload, str):
payload = frappe.parse_json(payload)
values = _clean_public_payload(payload or {})
values.update(
{
"doctype": "Customer Onboarding Request",
"registration_source": "Website",
"onboarding_status": "Submitted",
"customer_match_result": "Not Checked",
"portal_access_status": "Not Provisioned",
}
)
request = frappe.get_doc(values)
request.insert(ignore_permissions=True)
frappe.db.commit()
return {
"success": True,
"reference": request.name,
"message": "Registration received for review. This is not Customer approval.",
}
def _find_existing_contact(email_id, phone=None):
filters = {"email_id": email_id}
contact_name = frappe.db.get_value("Contact", filters, "name")
if contact_name:
return contact_name
if phone:
return frappe.db.get_value("Contact Phone", {"phone": phone}, "parent")
return None
def approve_customer_onboarding(request_name, create_new_customer=False, customer=None):
"""Reviewer action: resolve identity, approve, and idempotently create/link Customer + Contact."""
request = frappe.get_doc("Customer Onboarding Request", request_name)
if request.onboarding_status not in {"Under Review", "Approved"}:
frappe.throw(_("Only requests Under Review or already Approved can be completed."))
if create_new_customer:
request.customer_match_result = "New Customer Confirmed"
elif customer:
request.customer_match_result = "Existing Customer Confirmed"
request.possible_existing_customer = customer
else:
frappe.throw(_("Provide an existing Customer or explicitly confirm a new Customer."))
if customer:
if not frappe.db.exists("Customer", customer):
frappe.throw(_("The selected Customer does not exist."))
customer_name = customer
else:
customer_name = request.created_linked_customer
if not customer_name:
customer_name = frappe.db.get_value(
"Customer", {"customer_name": request.organisation_name}, "name"
)
if not customer_name:
customer_doc = frappe.get_doc(
{
"doctype": "Customer",
"customer_name": request.organisation_name,
"customer_type": request.customer_type or "Company",
"customer_group": frappe.defaults.get_global_default("customer_group") or "Commercial",
"territory": frappe.defaults.get_global_default("territory") or "All Territories",
}
)
customer_doc.insert(ignore_permissions=True)
customer_name = customer_doc.name
contact_name = request.created_linked_contact or _find_existing_contact(
request.email_id, request.telephone
)
if not contact_name:
contact = frappe.get_doc(
{
"doctype": "Contact",
"first_name": request.contact_name,
"email_ids": [{"email_id": request.email_id, "is_primary": 1}],
"phone_nos": [{"phone": request.telephone, "is_primary_phone": 1}],
"links": [{"link_doctype": "Customer", "link_name": customer_name}],
}
)
contact.insert(ignore_permissions=True)
contact_name = contact.name
else:
contact = frappe.get_doc("Contact", contact_name)
linked = {(row.link_doctype, row.link_name) for row in contact.links}
if ("Customer", customer_name) not in linked:
contact.append("links", {"link_doctype": "Customer", "link_name": customer_name})
contact.save(ignore_permissions=True)
request.created_linked_customer = customer_name
request.created_linked_contact = contact_name
request.onboarding_status = "Completed"
request.save(ignore_permissions=True)
frappe.db.commit()
return {"request": request.name, "customer": customer_name, "contact": contact_name}
def _customer_names_for_logged_in_user():
if frappe.session.user in {"Guest", "Administrator"}:
return []
user_email = frappe.db.get_value("User", frappe.session.user, "email") or frappe.session.user
contact_names = set(
frappe.get_all(
"Contact",
filters={"email_id": user_email},
pluck="name",
)
)
contact_names.update(
frappe.get_all(
"Contact Email",
filters={"email_id": user_email},
pluck="parent",
)
)
if not contact_names:
return []
return frappe.get_all(
"Dynamic Link",
filters={
"parenttype": "Contact",
"parent": ["in", list(contact_names)],
"link_doctype": "Customer",
},
pluck="link_name",
)
@frappe.whitelist()
def get_my_customer_context():
"""Return only Customers linked to the authenticated portal user."""
customer_names = _customer_names_for_logged_in_user()
if not customer_names:
return []
return frappe.get_all(
"Customer",
filters={"name": ["in", customer_names]},
fields=["name", "customer_name", "customer_group", "territory"],
order_by="customer_name asc",
)
@frappe.whitelist()
def get_my_sales_orders(limit=50):
customer_names = _customer_names_for_logged_in_user()
if not customer_names:
return []
return frappe.get_all(
"Sales Order",
filters={"customer": ["in", customer_names], "docstatus": ["<", 2]},
fields=["name", "transaction_date", "delivery_date", "status", "grand_total", "currency"],
order_by="transaction_date desc",
limit_page_length=min(int(limit), 100),
)
@frappe.whitelist()
def get_my_quotations(limit=50):
customer_names = _customer_names_for_logged_in_user()
if not customer_names:
return []
return frappe.get_all(
"Quotation",
filters={"party_name": ["in", customer_names], "docstatus": ["<", 2]},
fields=["name", "transaction_date", "valid_till", "status", "grand_total", "currency"],
order_by="transaction_date desc",
limit_page_length=min(int(limit), 100),
)
@frappe.whitelist()
def create_my_opportunity(subject, opportunity_type="Sales", opportunity_from="Customer", customer=None, message=None):
"""Create a standard Opportunity only for a Customer authorised to the logged-in user."""
customer_names = _customer_names_for_logged_in_user()
if not customer or customer not in customer_names:
frappe.throw("You are not authorised to create an enquiry for this Customer.")
if not subject or not subject.strip():
frappe.throw("Enquiry subject is required.")
opportunity = frappe.get_doc(
{
"doctype": "Opportunity",
"opportunity_from": opportunity_from,
"party_name": customer,
"opportunity_type": opportunity_type,
"title": subject.strip(),
"enquiry_from": "Customer",
"notes": message or "",
}
)
opportunity.insert()
frappe.db.commit()
return {"name": opportunity.name, "customer": customer, "status": opportunity.status}
@@ -0,0 +1,46 @@
import frappe
from frappe import _
def _read(dt):
if not frappe.db.exists("DocType",dt) or not frappe.has_permission(dt,"read"): frappe.throw(_("You do not have permission to view {0}.").format(dt),frappe.PermissionError)
def _fields(dt,fs):
a={f.fieldname for f in frappe.get_meta(dt).fields}; return [f for f in fs if f in a or f in {"name","modified"}]
def _rows(dt,fs,filters=None,ors=None,limit=100): return frappe.get_list(dt,fields=_fields(dt,fs),filters=filters or {},or_filters=ors,order_by="modified desc",limit_page_length=limit)
def _ors(dt,s,fs): return [[dt,f,"like",f"%{s.strip()}%"] for f in fs if f in _fields(dt,fs)] if s else None
INV=["name","customer","posting_date","due_date","status","grand_total","outstanding_amount","paid_amount","currency","company","debit_to","makap_delivery_note_reference","makap_fiscalisation_status","makap_fiscal_document_number","makap_fiscal_verification_code","makap_fiscalisation_timestamp","modified"]
PAY=["name","posting_date","party","party_type","payment_type","paid_amount","received_amount","source_exchange_rate","paid_from_account_currency","paid_to_account_currency","company","unallocated_amount","makap_payment_purpose","modified"]
SO=["name","customer","transaction_date","status","grand_total","per_delivered","per_billed","makap_billing_status","makap_delivered_unbilled","makap_collection_status","makap_deposit_required","makap_deposit_required_amount","modified"]
@frappe.whitelist()
def get_billing_summary():
result={"unbilled":0,"exposure":0,"fiscal_issues":0,"collections_due":0}
if frappe.db.exists("DocType","Sales Order") and frappe.has_permission("Sales Order","read"):
rows=_rows("Sales Order",SO,{"docstatus":1},limit=0); result["unbilled"]=sum(1 for r in rows if r.get("makap_billing_status") in {"Unbilled","Partially Billed"}); result["exposure"]=sum(1 for r in rows if r.get("makap_delivered_unbilled")); result["collections_due"]=sum(1 for r in rows if r.get("makap_collection_status") in {"Due","Partially Paid"})
if frappe.db.exists("DocType","Sales Invoice") and frappe.has_permission("Sales Invoice","read"):
rows=_rows("Sales Invoice",INV,{"docstatus":1},limit=0); result["fiscal_issues"]=sum(1 for r in rows if r.get("makap_fiscalisation_status") in {"Pending","Failed"})
return result
@frappe.whitelist()
def list_sales_invoices(search=None,fiscalisation_status=None,limit_page_length=100):
_read("Sales Invoice"); f={"docstatus":["!=",2]};
if fiscalisation_status and fiscalisation_status!="All": f["makap_fiscalisation_status"]=fiscalisation_status
return _rows("Sales Invoice",INV,f,_ors("Sales Invoice",search,["name","customer","makap_delivery_note_reference"]),min(max(int(limit_page_length or 100),1),200))
@frappe.whitelist()
def get_sales_invoice(name):
_read("Sales Invoice"); d=frappe.get_doc("Sales Invoice",name)
if not frappe.has_permission(d,"read"): frappe.throw(_("Permission denied."),frappe.PermissionError)
x=d.as_dict(); x["items"]=[{"item_code":r.item_code,"item_name":r.item_name,"qty":r.qty,"amount":r.amount,"sales_order":r.get("sales_order"),"delivery_note":r.get("delivery_note"),"makap_delivery_note_item_reference":r.get("makap_delivery_note_item_reference")} for r in d.get("items") or []]; return x
@frappe.whitelist()
def list_payment_entries(search=None,payment_purpose=None,limit_page_length=100):
_read("Payment Entry"); f={"docstatus":["!=",2]};
if payment_purpose and payment_purpose!="All": f["makap_payment_purpose"]=payment_purpose
return _rows("Payment Entry",PAY,f,_ors("Payment Entry",search,["name","party","company"]),min(max(int(limit_page_length or 100),1),200))
@frappe.whitelist()
def get_payment_entry(name):
_read("Payment Entry"); d=frappe.get_doc("Payment Entry",name); x=d.as_dict(); x["references"]= [{"reference_doctype":r.reference_doctype,"reference_name":r.reference_name,"allocated_amount":r.allocated_amount} for r in d.get("references") or []]; return x
@frappe.whitelist()
def list_order_exposure(search=None,billing_status=None,collection_status=None,limit_page_length=100):
_read("Sales Order"); f={"docstatus":["!=",2]};
if billing_status and billing_status!="All": f["makap_billing_status"]=billing_status
if collection_status and collection_status!="All": f["makap_collection_status"]=collection_status
return _rows("Sales Order",SO,f,_ors("Sales Order",search,["name","customer"]),min(max(int(limit_page_length or 100),1),200))
@frappe.whitelist()
def get_order_exposure(name):
_read("Sales Order"); d=frappe.get_doc("Sales Order",name); return d.as_dict()
@@ -0,0 +1,19 @@
import frappe
from frappe import _
FIELDS=["name","sales_order","customer","operating_close","primary_blocker","production_close","material_close","quality_close","supply_close","fulfilment_close","billing_fiscal_close","collection_close","cost_status","cost_confidence","cost_attribution_basis","margin_basis","margin_notes","last_recomputed_by","last_recomputed_on","modified"]
def read():
if not frappe.has_permission("Order Closure And Margin View","read"): frappe.throw(_("You do not have permission to view close-out views."),frappe.PermissionError)
def rows(search=None):
ors=[["Order Closure And Margin View",f,"like",f"%{search.strip()}%"] for f in ["name","sales_order","customer","primary_blocker"]] if search else None
return frappe.get_list("Order Closure And Margin View",fields=FIELDS,filters={},or_filters=ors,order_by="modified desc",limit_page_length=200)
@frappe.whitelist()
def get_closeout_summary():
read(); rs=rows(); return {"open":sum(1 for r in rs if r.get("operating_close")=="Open"),"ready":sum(1 for r in rs if r.get("operating_close")=="Closed"),"blockers":sum(1 for r in rs if r.get("primary_blocker") not in {None,"","None"}),"cost_attention":sum(1 for r in rs if r.get("cost_status") in {"Not Assessed","Provisional","Restatement Required"})}
@frappe.whitelist()
def list_closeout_views(search=None,operating_close=None,cost_status=None,limit_page_length=100):
read(); rs=rows(search); return [r for r in rs if (not operating_close or operating_close=="All" or r.get("operating_close")==operating_close) and (not cost_status or cost_status=="All" or r.get("cost_status")==cost_status)][:min(max(int(limit_page_length or 100),1),200)]
@frappe.whitelist()
def get_closeout_view(name):
read(); d=frappe.get_doc("Order Closure And Margin View",name); return d.as_dict()
@frappe.whitelist()
def list_closeout_blockers(search=None,limit_page_length=100): return [r for r in list_closeout_views(search) if r.get("operating_close")!="Closed"]
@@ -0,0 +1,51 @@
import os
import frappe
def setup_google():
client_id = os.environ.get("MAKAP_GOOGLE_CLIENT_ID")
client_secret = os.environ.get("MAKAP_GOOGLE_CLIENT_SECRET")
if not client_id or not client_secret:
raise RuntimeError("Both Google OAuth values are required")
existing_name = frappe.db.get_value(
"Social Login Key",
{"provider_name": "Google"},
"name",
)
values = {
"enable_social_login": 1,
"social_login_provider": "Google",
"provider_name": "Google",
"client_id": client_id,
"client_secret": client_secret,
"sign_ups": "Allow",
"authorize_url": "https://accounts.google.com/o/oauth2/auth",
"access_token_url": "https://oauth2.googleapis.com/token",
"api_endpoint": "https://www.googleapis.com/oauth2/v2/userinfo",
"redirect_url": "http://makap.local/api/method/frappe.integrations.oauth2_logins.login_via_google",
"custom_base_url": 0,
}
if existing_name:
doc = frappe.get_doc("Social Login Key", existing_name )
doc.update(values)
else:
doc = frappe.get_doc({"doctype": "Social Login Key", **values})
doc.save(ignore_permissions=True)
frappe.db.commit()
return {
"name": doc.name,
"provider_name": doc.provider_name,
"social_login_provider": doc.social_login_provider,
"enabled": doc.enable_social_login,
"sign_ups": doc.sign_ups,
"authorize_url": doc.authorize_url,
"access_token_url": doc.access_token_url,
"api_endpoint": doc.api_endpoint,
"redirect_url": doc.redirect_url,
}
+103
View File
@@ -0,0 +1,103 @@
import frappe
from frappe import _
CONTACT_FIELDS = [
"name",
"first_name",
"last_name",
"full_name",
"email_id",
"phone",
"mobile_no",
"company_name",
"designation",
"status",
"image",
"modified",
]
def _ensure_read_permission():
if not frappe.has_permission("Contact", "read"):
frappe.throw(_("You do not have permission to view Contacts."), frappe.PermissionError)
def _contact_row(row):
row = dict(row)
row["display_name"] = row.get("full_name") or " ".join(
value for value in [row.get("first_name"), row.get("last_name")] if value
) or row.get("name")
return row
@frappe.whitelist()
def list_contacts(search=None, limit_start=0, limit_page_length=25):
"""Return a permission-aware, searchable Contact list for the MAKAP workspace."""
_ensure_read_permission()
search = (search or "").strip()
limit_start = max(int(limit_start or 0), 0)
limit_page_length = min(max(int(limit_page_length or 25), 1), 50)
or_filters = None
if search:
like = f"%{search}%"
or_filters = [
["Contact", "name", "like", like],
["Contact", "full_name", "like", like],
["Contact", "first_name", "like", like],
["Contact", "last_name", "like", like],
["Contact", "email_id", "like", like],
["Contact", "company_name", "like", like],
]
rows = frappe.get_list(
"Contact",
fields=CONTACT_FIELDS,
filters={},
or_filters=or_filters,
order_by="modified desc",
limit_start=limit_start,
limit_page_length=limit_page_length,
)
return [_contact_row(row) for row in rows]
@frappe.whitelist()
def get_contact(name):
"""Return a Contact and its linked Customers for the right-hand detail panel."""
_ensure_read_permission()
if not name:
frappe.throw(_("Contact name is required."))
doc = frappe.get_doc("Contact", name)
if not frappe.has_permission(doc, "read"):
frappe.throw(_("You do not have permission to view this Contact."), frappe.PermissionError)
links = []
for link in doc.get("links") or []:
links.append({
"doctype": link.link_doctype,
"name": link.link_name,
})
emails = [{"email_id": row.email_id, "is_primary": row.is_primary} for row in doc.get("email_ids") or []]
phones = [{"phone": row.phone, "is_primary": row.is_primary, "is_mobile": row.is_mobile} for row in doc.get("phone_nos") or []]
return {
"name": doc.name,
"display_name": doc.full_name or " ".join(value for value in [doc.first_name, doc.last_name] if value) or doc.name,
"first_name": doc.first_name,
"last_name": doc.last_name,
"email_id": doc.email_id,
"phone": doc.phone,
"mobile_no": doc.mobile_no,
"company_name": doc.company_name,
"designation": doc.designation,
"status": doc.status,
"image": doc.image,
"modified": doc.modified,
"links": links,
"emails": emails,
"phones": phones,
}
+342
View File
@@ -127,6 +127,348 @@ def validate_quality_inspection(doc, method=None):
frappe.throw("Held, rejected, rework, or scrap stock cannot be marked Eligible.")
def _is_controlled_item(item_code):
if not item_code or not frappe.db.exists("Item", item_code):
return False
control_class = frappe.db.get_value("Item", item_code, "makap_material_control_class")
return control_class in {"Controlled", "High Traceability"}
def _latest_quality_disposition(item_code, batch_no=None):
"""Best-effort read of the most recent relevant Quality Inspection for this
item/batch. Real batch-population matching (M8 section 8/9) is intentionally
left as a follow-up once Makap's actual QI-to-batch linkage in this bench is
confirmed — this covers the common case where batch_no is captured on the QI.
"""
filters = {"item_code": item_code, "docstatus": 1}
if batch_no:
filters["batch_no"] = batch_no
rows = frappe.get_all(
"Quality Inspection",
filters=filters,
fields=["name", "makap_quality_eligibility", "makap_quality_disposition"],
order_by="creation desc",
limit=1,
)
return rows[0] if rows else None
def _compute_dispatch_gate(doc):
"""Derived Not Assessed / Blocked / Ready condition per M8 section 7.
Runs fresh on every validate — including amendments and API submission —
so a stale queue view can never authorise dispatch (M8-BR-06 / M8-RT-04).
Returns (status, blockers list).
"""
blockers = []
if not doc.customer:
blockers.append("No Customer on Delivery Note.")
for line in doc.get("items") or []:
if not _is_controlled_item(line.item_code):
continue
prefix = f"Item {line.item_code}: "
if not line.makap_dispatch_allocation_mode:
blockers.append(prefix + "Dispatch Allocation Mode (Reserved/Pooled) not set.")
if line.makap_dispatch_allocation_mode == "Reserved" and not line.makap_stock_reservation_entry:
blockers.append(prefix + "Reserved mode requires a Stock Reservation Entry reference.")
if not line.qty or line.qty <= 0:
blockers.append(prefix + "Delivery quantity must be greater than zero.")
disposition = _latest_quality_disposition(line.item_code, getattr(line, "batch_no", None))
if not disposition or disposition.makap_quality_eligibility != "Eligible":
blockers.append(prefix + "No current Quality Eligibility = Eligible found for this item/batch.")
elif disposition.makap_quality_disposition not in {"Release", None, ""}:
blockers.append(
prefix + f"Latest Quality Inspection disposition is '{disposition.makap_quality_disposition}', not Release."
)
if doc.makap_dispatch_material_accountability_status != "Cleared":
blockers.append("Dispatch Material Accountability is not Cleared.")
if doc.makap_financial_release_status not in {"Not Required", "Cleared", "Exception Approved"}:
blockers.append(f"Financial Release Status is '{doc.makap_financial_release_status}'.")
if doc.makap_short_close and not doc.makap_short_close_approved_by:
blockers.append("Short-Close requires Short-Close Approved By (Commercial Authority).")
status = "Blocked" if blockers else "Ready"
return status, blockers
def validate_delivery_note(doc, method=None):
for line in doc.get("items") or []:
if not _is_controlled_item(line.item_code):
continue
so_item_spec = None
if line.against_sales_order and line.so_detail:
so_item_spec = frappe.db.get_value(
"Sales Order Item", line.so_detail, "makap_packaging_specification"
)
if so_item_spec and not line.makap_packaging_specification:
line.makap_packaging_specification = so_item_spec
line.makap_specification_revision = frappe.db.get_value(
"Packaging Specification", so_item_spec, "revision"
)
# Module 8D: physical returns must land in a Returns Hold warehouse, never
# directly back into a dispatchable FG Released location, and require a
# fresh Quality disposition before the stock is reusable (M8-FR-072/073/074).
if doc.is_return:
if not _returns_hold_warehouse(line.warehouse) and not _returns_hold_warehouse(line.t_warehouse):
frappe.throw(
f"Return item {line.item_code} must be received into a warehouse flagged "
"as Returns Hold, not directly into a dispatchable location."
)
if line.makap_return_quality_disposition == "Release":
frappe.throw(
f"Return item {line.item_code} cannot be marked Release directly on the return "
"Delivery Note — record a fresh Quality Inspection disposition first."
)
if not line.makap_return_reason:
frappe.throw(f"Return item {line.item_code} requires a Return Reason.")
if doc.is_return:
# Returns are incoming, not outgoing dispatch — the Dispatch Gate (built for
# outgoing controlled stock) does not apply; the Returns Hold checks above
# are the relevant control here instead.
doc.makap_dispatch_gate_status = "Not Assessed"
doc.makap_dispatch_gate_blockers = "Not applicable — this is a return."
doc.makap_dispatch_gate_checked_by = frappe.session.user
doc.makap_dispatch_gate_checked_on = frappe.utils.now_datetime()
return
status, blockers = _compute_dispatch_gate(doc)
doc.makap_dispatch_gate_status = status
doc.makap_dispatch_gate_blockers = "\n".join(blockers)
doc.makap_dispatch_gate_checked_by = frappe.session.user
doc.makap_dispatch_gate_checked_on = frappe.utils.now_datetime()
if doc.docstatus == 1 and status != "Ready":
frappe.throw(
"Dispatch Gate is not Ready — controlled stock cannot be dispatched:\n" + doc.makap_dispatch_gate_blockers
)
def validate_delivery_note_on_cancel(doc, method=None):
# Section 10.2: once physically shipped, correction is a Customer Return,
# not casual cancellation of the historical DN. This is a first pass —
# it only flags submitted, non-draft DNs; a fuller impact review across
# billing/fiscal/traceability lands with the Returns phase (8D).
if doc.docstatus == 1:
frappe.msgprint(
"This Delivery Note reflects a physical dispatch. Cancelling it does not reverse "
"billing, fiscalisation or traceability records — use Customer Return for goods "
"that have actually shipped.",
indicator="orange",
alert=True,
)
def _recompute_billing_and_collection(sales_order_name):
"""Best-effort Billing/Collection status for a Sales Order, driven off its
submitted Sales Invoices. Run after Sales Invoice or Payment Entry submit/cancel.
Delivered-Unbilled exposure compares standard per_delivered vs per_billed —
no new ledger, per the package's explicit prohibition.
"""
if not sales_order_name or not frappe.db.exists("Sales Order", sales_order_name):
return
so = frappe.db.get_value(
"Sales Order", sales_order_name, ["per_delivered", "per_billed"], as_dict=True
)
if not so.per_billed:
billing_status = "Not Billable" if not so.per_delivered else "Unbilled"
elif so.per_billed >= 100:
billing_status = "Commercially Billed"
else:
billing_status = "Partially Billed"
delivered_unbilled = bool(so.per_delivered and so.per_delivered > 0 and (so.per_billed or 0) < so.per_delivered)
invoices = frappe.get_all(
"Sales Invoice",
filters={"docstatus": 1},
fields=["name", "outstanding_amount", "grand_total", "is_return"],
or_filters=None,
)
# Sales Invoice doesn't carry sales_order directly at header level in all cases;
# use the child table linkage to scope invoices to this Sales Order.
linked_invoice_names = frappe.get_all(
"Sales Invoice Item",
filters={"sales_order": sales_order_name, "docstatus": 1},
pluck="parent",
distinct=True,
)
linked_invoices = [i for i in invoices if i.name in set(linked_invoice_names)]
if not linked_invoices:
collection_status = "Not Yet Due"
else:
total_outstanding = sum(i.outstanding_amount or 0 for i in linked_invoices)
total_grand = sum(i.grand_total or 0 for i in linked_invoices)
if total_outstanding <= 0:
collection_status = "Paid"
elif total_outstanding < total_grand:
collection_status = "Partially Paid"
else:
collection_status = "Due"
frappe.db.set_value(
"Sales Order",
sales_order_name,
{
"makap_billing_status": billing_status,
"makap_delivered_unbilled": 1 if delivered_unbilled else 0,
"makap_collection_status": collection_status,
},
)
def _sales_orders_on_invoice(doc):
return {row.sales_order for row in (doc.get("items") or []) if row.sales_order}
def validate_sales_invoice(doc, method=None):
if doc.is_return and doc.return_against:
# M8-BR-14: one physical return event = one stock consequence. Block a second
# Credit Note crediting the same original invoice for a controlled item.
for line in doc.get("items") or []:
if not _is_controlled_item(line.item_code):
continue
existing = frappe.get_all(
"Sales Invoice",
filters={
"is_return": 1,
"return_against": doc.return_against,
"docstatus": 1,
"name": ["!=", doc.name],
},
pluck="name",
)
if existing:
credited_items = frappe.get_all(
"Sales Invoice Item",
filters={"parent": ["in", existing], "item_code": line.item_code},
pluck="name",
)
if credited_items:
frappe.throw(
f"Item {line.item_code} against {doc.return_against} already has a Credit "
f"Note ({existing[0]}) — this looks like a duplicate return credit."
)
for line in doc.get("items") or []:
if not _is_controlled_item(line.item_code):
continue
# Section 11: Direct Sales Invoice Stock Bypass — controlled manufactured
# stock must not invoice-with-update-stock as a shortcut around Delivery Note.
if doc.update_stock:
frappe.throw(
f"Item {line.item_code} is Controlled Manufactured Stock — Sales Invoice with "
"Update Stock is not permitted; invoice from the Delivery Note instead."
)
if line.delivery_note:
dn_item_spec = frappe.db.get_value(
"Delivery Note Item",
{"parent": line.delivery_note, "item_code": line.item_code},
"makap_packaging_specification",
)
if dn_item_spec:
line.makap_delivery_note_item_reference = f"{line.delivery_note}::{line.item_code}"
if doc.docstatus == 1 and doc.makap_fiscalisation_status == "Failed":
frappe.throw("Sales Invoice cannot remain submitted with a Failed Fiscalisation Status — resolve or reset it first.")
def on_submit_or_cancel_sales_invoice(doc, method=None):
for so in _sales_orders_on_invoice(doc):
_recompute_billing_and_collection(so)
def validate_payment_entry(doc, method=None):
if doc.payment_type != "Receive":
return
if doc.makap_payment_purpose == "Deposit" and not doc.get("references"):
frappe.throw("A Deposit payment purpose requires at least one reference allocation (Sales Order or Sales Invoice).")
def on_submit_or_cancel_payment_entry(doc, method=None):
for ref in doc.get("references") or []:
if ref.reference_doctype == "Sales Order":
_recompute_billing_and_collection(ref.reference_name)
elif ref.reference_doctype == "Sales Invoice":
so_names = frappe.get_all(
"Sales Invoice Item", filters={"parent": ref.reference_name}, pluck="sales_order", distinct=True
)
for so in so_names:
if so:
_recompute_billing_and_collection(so)
def validate_purchase_invoice(doc, method=None):
for line in doc.get("items") or []:
if not getattr(line, "purchase_order", None):
continue
po_rate = frappe.db.get_value(
"Purchase Order Item", {"parent": line.purchase_order, "item_code": line.item_code}, "rate"
)
if po_rate:
line.makap_rate_variance = (line.rate or 0) - po_rate
if getattr(line, "purchase_receipt", None):
pr_qty = frappe.db.get_value(
"Purchase Receipt Item", {"parent": line.purchase_receipt, "item_code": line.item_code}, "qty"
)
if pr_qty is not None:
line.makap_quantity_variance = (line.qty or 0) - pr_qty
# M8-BR-15 / rejected-quantity guard: a rejected QI disposition on the
# matching Purchase Receipt item must never become billable through Finance.
rejected_qty = frappe.db.get_value(
"Quality Inspection",
{
"reference_type": "Purchase Receipt",
"reference_name": line.purchase_receipt,
"item_code": line.item_code,
"makap_quality_disposition": "Reject",
"docstatus": 1,
},
"makap_disposition_quantity",
)
if rejected_qty and line.qty and line.qty > (pr_qty or line.qty) - rejected_qty:
frappe.throw(
f"Purchase Invoice item {line.item_code} appears to include rejected quantity "
f"from {line.purchase_receipt} ({rejected_qty} rejected) — reduce the billed quantity."
)
has_variance = any(
(getattr(l, "makap_quantity_variance", 0) or 0) != 0 or (getattr(l, "makap_rate_variance", 0) or 0) != 0
for l in doc.get("items") or []
)
if doc.makap_supplier_invoice_match_status == "Not Assessed" and has_variance:
both = any((l.makap_quantity_variance or 0) != 0 for l in doc.items) and any(
(l.makap_rate_variance or 0) != 0 for l in doc.items
)
if both:
doc.makap_supplier_invoice_match_status = "Both Variance"
elif any((l.makap_quantity_variance or 0) != 0 for l in doc.items):
doc.makap_supplier_invoice_match_status = "Quantity Variance"
else:
doc.makap_supplier_invoice_match_status = "Rate Variance"
def _returns_hold_warehouse(warehouse):
if not warehouse:
return False
return bool(frappe.db.get_value("Warehouse", warehouse, "makap_is_returns_hold_warehouse"))
def validate_purchase_order(doc, method=None):
for line in doc.get("items") or []:
if line.makap_supplier_confirmation_status == "Confirmed" and not line.makap_supplier_confirmation_reference:
@@ -0,0 +1,242 @@
"""
Creates a standalone "Dashboard1" Workspace for MAKAP Backend:
- 4 Number Cards (stat row) backed by real doctype counts
- 24 doctypes as shortcuts, grouped into 8 sections, matching the
approved dashboard mockup
Idempotent: safe to re-run. Re-running clears and rebuilds this
workspace's own content/shortcuts/number_cards — it does not touch
the existing "MAKAP Backend" workspace or its workspace_setup.py.
Run with:
bench --site makap.local execute makap_backend.makap_backend.dashboard1_setup.create_dashboard1
(Adjust the dotted path if your app's inner module isn't doubled
"makap_backend.makap_backend" — check with:
bench --site makap.local console
>>> import makap_backend.makap_backend.dashboard1_setup
)
"""
import json
import frappe
WORKSPACE_NAME = "Dashboard1"
# ---------------------------------------------------------------------
# 1. Number Cards (top stat row)
# ---------------------------------------------------------------------
NUMBER_CARDS = [
{
"label": "Onboarding Requests",
"document_type": "Customer Onboarding Request",
"filters_json": "[]",
"color": "#2E6BE6",
},
{
"label": "Packaging Specifications",
"document_type": "Packaging Specification",
"filters_json": "[]",
"color": "#1F9D6B",
},
{
"label": "Approved for Manufacture",
"document_type": "Packaging Specification",
"filters_json": json.dumps(
[["Packaging Specification", "specification_status", "=", "Approved for Manufacture"]]
),
"color": "#C8791C",
},
{
# Swapped in for the child-table "Critical Requirements Tracked" card —
# see module docstring for why.
"label": "Artwork Pending",
"document_type": "Packaging Specification",
"filters_json": json.dumps(
[["Packaging Specification", "artwork_status", "=", "Pending"]]
),
"color": "#D94B6A",
},
]
# ---------------------------------------------------------------------
# 2. Sidebar groups -> (label, doctype, is_custom)
# is_custom just drives shortcut color (Blue vs Grey) so custom
# MAKAP doctypes visually stand out from stock ERPNext/Frappe ones.
# ---------------------------------------------------------------------
SHORTCUT_GROUPS = [
("Customer & Commercial — M1-2", [
("Onboarding Requests", "Customer Onboarding Request", True),
("Customer", "Customer", False),
("Contact", "Contact", False),
("Opportunity", "Opportunity", False),
("Quotation", "Quotation", False),
("Sales Order", "Sales Order", False),
]),
("Specification & Prepress — M3", [
("Packaging Specifications", "Packaging Specification", True),
("Material Structure", "Material Structure", True),
("Critical Acceptance Requirement", "Critical Acceptance Requirement", True),
("Item", "Item", False),
]),
("Production & Planning — M4", [
("Operation", "Operation", False),
("Work Order", "Work Order", False),
("Production Plan", "Production Plan", False),
]),
("Materials & Inventory — M5", [
("Material Request", "Material Request", False),
("Purchase Order", "Purchase Order", False),
("Purchase Receipt", "Purchase Receipt", False),
]),
("Quality — M6", [
("Quality Inspection", "Quality Inspection", False),
]),
("Fulfilment & Billing — M8", [
("Delivery Note", "Delivery Note", False),
("Sales Invoice", "Sales Invoice", False),
("Payment Entry", "Payment Entry", False),
("Purchase Invoice", "Purchase Invoice", False),
]),
("Operating Control", [
("Order Closure & Margin", "Order Closure And Margin View", True),
]),
("Reference & Lookups", [
("User", "User", False),
("UOM", "UOM", False),
]),
]
def _ensure_number_card(spec):
"""Get-or-create a public Number Card. Returns the card name (== label)."""
if not frappe.db.exists("DocType", spec["document_type"]):
frappe.logger().warning(
f"[dashboard1_setup] Skipping Number Card '{spec['label']}': "
f"DocType '{spec['document_type']}' not found."
)
return None
if frappe.db.exists("Number Card", spec["label"]):
card = frappe.get_doc("Number Card", spec["label"])
else:
card = frappe.new_doc("Number Card")
card.label = spec["label"]
card.document_type = spec["document_type"]
card.type = "Document Type"
card.function = "Count"
card.filters_json = spec["filters_json"]
card.is_public = 1
card.show_percentage_stats = 0
if hasattr(card, "color"):
card.color = spec["color"]
card.save(ignore_permissions=True)
return card.name
def _content_block(block_type, data, block_id):
return {"id": block_id, "type": block_type, "data": data}
def create_dashboard1():
frappe.set_user("Administrator")
# -- 1. Number Cards --------------------------------------------------
card_names = []
for spec in NUMBER_CARDS:
name = _ensure_number_card(spec)
if name:
card_names.append(name)
# -- 2. Workspace shell -------------------------------------------------
if frappe.db.exists("Workspace", WORKSPACE_NAME):
ws = frappe.get_doc("Workspace", WORKSPACE_NAME)
else:
ws = frappe.new_doc("Workspace")
ws.name = WORKSPACE_NAME
ws.label = WORKSPACE_NAME
ws.title = WORKSPACE_NAME
ws.module = "Makap Backend"
ws.public = 1
ws.is_hidden = 0
ws.icon = "dashboard"
ws.indicator_color = "blue"
# Reset child tables before rebuilding
ws.set("shortcuts", [])
ws.set("links", [])
if ws.meta.has_field("number_cards"):
ws.set("number_cards", [])
content_blocks = []
block_seq = 0
def next_id(prefix):
nonlocal block_seq
block_seq += 1
return f"{prefix}_{block_seq}"
# Header
content_blocks.append(_content_block(
"header",
{"text": "<span class=\"h4\">MAKAP Backend — Dashboard1</span>", "col": 12},
next_id("header"),
))
# Number card row
for name in card_names:
content_blocks.append(_content_block(
"number_card",
{"number_card_name": name, "col": 3},
next_id("card"),
))
if ws.meta.has_field("number_cards"):
ws.append("number_cards", {"number_card_name": name})
# Group headers + shortcuts
skipped = []
total_shortcuts = 0
for group_label, items in SHORTCUT_GROUPS:
content_blocks.append(_content_block(
"header",
{"text": f"<span class=\"h5\">{group_label}</span>", "col": 12},
next_id("group"),
))
for shortcut_label, doctype, is_custom in items:
if not frappe.db.exists("DocType", doctype):
skipped.append(doctype)
continue
ws.append("shortcuts", {
"label": shortcut_label,
"type": "DocType",
"link_to": doctype,
"doc_view": "List",
"icon": "list",
"color": "Blue" if is_custom else "Grey",
"stats_filter": "{}",
})
content_blocks.append(_content_block(
"shortcut",
{"shortcut_name": shortcut_label, "col": 3},
next_id("shortcut"),
))
total_shortcuts += 1
ws.content = json.dumps(content_blocks)
ws.save(ignore_permissions=True)
frappe.db.commit()
result = {
"workspace": ws.name,
"number_cards_created": card_names,
"shortcuts_created": total_shortcuts,
"doctypes_skipped_not_found": skipped,
}
print(json.dumps(result, indent=2))
return result
@@ -0,0 +1,85 @@
import frappe
from frappe.utils import get_first_day, nowdate
@frappe.whitelist()
def get_universal_dashboard():
def count(doctype, filters):
if not frappe.db.exists("DocType", doctype):
return 0
return frappe.db.count(doctype, filters)
def distinct_parent_count(child_doctype, parent_doctype, child_filters):
if not frappe.db.exists("DocType", child_doctype) or not frappe.db.exists("DocType", parent_doctype):
return 0
conditions = ["parent_doc.docstatus = 1"]
values = {}
for i, (field, op, value) in enumerate(child_filters):
key = f"val{i}"
if op == "in":
conditions.append(f"child_doc.{field} in %({key})s")
else:
conditions.append(f"child_doc.{field} {op} %({key})s")
values[key] = value
where_clause = " AND ".join(conditions)
result = frappe.db.sql(
f"""
SELECT COUNT(DISTINCT child_doc.parent)
FROM `tab{child_doctype}` child_doc
INNER JOIN `tab{parent_doctype}` parent_doc ON parent_doc.name = child_doc.parent
WHERE {where_clause}
""",
values,
)
return result[0][0] if result else 0
data = {
# Top KPI row — overall health snapshot
"active_work_orders": count("Work Order", {"docstatus": 1, "status": ["in", ["Not Started", "In Process"]]}),
"active_sales_orders": count(
"Sales Order", {"docstatus": 1, "status": ["not in", ["Completed", "Cancelled", "Closed"]]}
),
"open_purchase_orders": count(
"Purchase Order", {"docstatus": 1, "status": ["not in", ["Completed", "Cancelled", "Closed"]]}
),
"deliveries_this_month": count(
"Delivery Note", {"docstatus": 1, "posting_date": [">=", get_first_day(nowdate())]}
),
# Module 4 — Production
"work_orders_on_hold": count("Work Order", {"docstatus": 1, "status": "Stopped"}),
# Module 5 — Materials & Quality
"quality_holds": count(
"Quality Inspection", {"docstatus": 1, "makap_quality_disposition": ["in", ["Hold", "Reject"]]}
),
# Module 6/7 — Supply & Procurement
"supplier_at_risk": distinct_parent_count(
"Purchase Order Item",
"Purchase Order",
[("makap_supplier_confirmation_status", "in", ["At Risk", "Declined"])],
),
# Module 8A — Dispatch
"dispatch_blocked": count("Delivery Note", {"makap_dispatch_gate_status": "Blocked"}),
# Module 8B/8C — Billing & Fiscal
"fiscalisation_issue": count(
"Sales Invoice", {"makap_fiscalisation_status": ["in", ["Pending", "Failed"]], "docstatus": 1}
),
"delivered_unbilled": count("Sales Order", {"makap_delivered_unbilled": 1, "docstatus": 1}),
# Module 8B — Collections
"collection_due": count("Sales Order", {"makap_collection_status": "Due", "docstatus": 1}),
# Module 8D — Returns
"returns_total": count("Delivery Note", {"is_return": 1, "docstatus": 1}),
# Module 8E — Supplier Reconciliation
"supplier_variance": count(
"Purchase Invoice",
{
"makap_supplier_invoice_match_status": [
"in",
["Quantity Variance", "Rate Variance", "Both Variance", "Disputed"],
],
"docstatus": 1,
},
),
# Module 8F — Closure & Margin
"closure_views": count("Order Closure And Margin View", {}),
}
return data
@@ -52,7 +52,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-09-01 21:47:35.807459",
"modified": "2026-09-25 11:01:38.805658",
"modified_by": "Administrator",
"module": "makap_backend",
"name": "Critical Acceptance Requirement",
@@ -54,7 +54,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-09-01 21:47:35.458192",
"modified": "2026-09-25 11:01:38.290429",
"modified_by": "Administrator",
"module": "makap_backend",
"name": "Material Structure",
@@ -0,0 +1,8 @@
// Copyright (c) 2026, Joseph njuki and contributors
// For license information, please see license.txt
// frappe.ui.form.on("Order Closure And Margin View", {
// refresh(frm) {
// },
// });
@@ -0,0 +1,245 @@
{
"actions": [],
"autoname": "field:sales_order",
"creation": "2026-09-21 17:53:44.393574",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"sales_order",
"customer",
"column_break_closure",
"operating_close",
"primary_blocker",
"section_dimensions",
"production_close",
"material_close",
"quality_close",
"supply_close",
"column_break_dimensions",
"fulfilment_close",
"billing_fiscal_close",
"collection_close",
"section_cost",
"cost_status",
"cost_confidence",
"cost_attribution_basis",
"column_break_cost",
"margin_basis",
"margin_notes",
"section_meta",
"last_recomputed_by",
"last_recomputed_on"
],
"fields": [
{
"fieldname": "sales_order",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Sales Order",
"options": "Sales Order",
"reqd": 1,
"unique": 1
},
{
"fetch_from": "sales_order.customer",
"fieldname": "customer",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Customer",
"options": "Customer",
"read_only": 1
},
{
"fieldname": "column_break_closure",
"fieldtype": "Column Break"
},
{
"fieldname": "operating_close",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Operating Close",
"options": "Open\nClosed",
"read_only": 1
},
{
"fieldname": "primary_blocker",
"fieldtype": "Small Text",
"label": "Primary Blocker",
"read_only": 1
},
{
"fieldname": "section_dimensions",
"fieldtype": "Section Break",
"label": "Closure Dimensions"
},
{
"fieldname": "production_close",
"fieldtype": "Select",
"label": "Production Close",
"options": "Open\nClosed",
"read_only": 1
},
{
"fieldname": "material_close",
"fieldtype": "Select",
"label": "Material Close",
"options": "Open\nClosed",
"read_only": 1
},
{
"fieldname": "quality_close",
"fieldtype": "Select",
"label": "Quality Close",
"options": "Open\nClosed",
"read_only": 1
},
{
"fieldname": "supply_close",
"fieldtype": "Select",
"label": "Supply Close",
"options": "Open\nClosed",
"read_only": 1
},
{
"fieldname": "column_break_dimensions",
"fieldtype": "Column Break"
},
{
"fieldname": "fulfilment_close",
"fieldtype": "Select",
"label": "Fulfilment Close",
"options": "Open\nPartial\nClosed",
"read_only": 1
},
{
"fieldname": "billing_fiscal_close",
"fieldtype": "Select",
"label": "Billing/Fiscal Close",
"options": "Open\nClosed",
"read_only": 1
},
{
"description": "Independent by default \u2014 a legitimate credit sale can be Operating Closed while this remains open (M8-BR: Operating Reopen != Accounting Period Reopen).",
"fieldname": "collection_close",
"fieldtype": "Select",
"label": "Collection Status",
"options": "Not Yet Due\nDue\nPartially Paid\nPaid",
"read_only": 1
},
{
"fieldname": "section_cost",
"fieldtype": "Section Break",
"label": "Cost & Margin (manual pending full valuation build-out)"
},
{
"fieldname": "cost_status",
"fieldtype": "Select",
"label": "Cost Status",
"options": "Not Assessed\nProvisional\nReady to Close\nClosed\nRestatement Required"
},
{
"fieldname": "cost_confidence",
"fieldtype": "Select",
"label": "Cost Confidence",
"options": "Not Assessed\nHigh\nQualified\nLow / Provisional"
},
{
"fieldname": "cost_attribution_basis",
"fieldtype": "Select",
"label": "Cost Attribution Basis",
"options": "Not Assessed\nExact\nDirectly Derived\nPooled / Valuation-Based\nQualified"
},
{
"fieldname": "column_break_cost",
"fieldtype": "Column Break"
},
{
"fieldname": "margin_basis",
"fieldtype": "Select",
"label": "Margin Basis",
"options": "\nAccounting Gross Margin\nManufacturing Contribution\nFull Business Profit"
},
{
"fieldname": "margin_notes",
"fieldtype": "Small Text",
"label": "Margin Notes"
},
{
"fieldname": "section_meta",
"fieldtype": "Section Break"
},
{
"fieldname": "last_recomputed_by",
"fieldtype": "Link",
"label": "Last Recomputed By",
"options": "User",
"read_only": 1
},
{
"fieldname": "last_recomputed_on",
"fieldtype": "Datetime",
"label": "Last Recomputed On",
"read_only": 1
}
],
"links": [],
"modified": "2026-09-21 17:53:52.064306",
"modified_by": "Administrator",
"module": "makap_backend",
"name": "Order Closure And Margin View",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Commercial Reviewer",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Credit Authority",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Dispatch Authority",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
@@ -0,0 +1,146 @@
import frappe
from frappe.model.document import Document
# Dimensions that gate Operating Close. Collection is deliberately excluded —
# per the package, a legitimate credit sale can be Operating Closed while the
# receivable remains open (Operating Reopen != Accounting Period Reopen).
BLOCKING_DIMENSIONS = [
("production_close", "Closed", "Production Close"),
("material_close", "Closed", "Material Close"),
("quality_close", "Closed", "Quality Close"),
("supply_close", "Closed", "Supply Close"),
("fulfilment_close", "Closed", "Fulfilment Close"),
("billing_fiscal_close", "Closed", "Billing/Fiscal Close"),
]
class OrderClosureAndMarginView(Document):
def before_save(self):
self._derive_operating_close()
def _derive_operating_close(self):
blockers = [label for field, required, label in BLOCKING_DIMENSIONS if self.get(field) != required]
self.operating_close = "Closed" if not blockers else "Open"
self.primary_blocker = blockers[0] if blockers else "None"
@frappe.whitelist()
def recompute(self):
"""Best-effort recomputation from linked M4-M7 records already in the bench.
Cost/Margin fields are intentionally left as manual entry — deriving those
correctly needs a real pass over Stock Ledger/GL/Landed Cost Voucher data,
which this first build does not yet do. Treat this as a first pass, not a
finished closure engine.
"""
so_name = self.sales_order
so = frappe.db.get_value(
"Sales Order", so_name, ["per_delivered", "per_billed", "makap_collection_status"], as_dict=True
)
if not so:
frappe.throw(f"Sales Order {so_name} not found.")
# Fulfilment Close — standard ERPNext delivery percentage, no new ledger.
if not so.per_delivered:
self.fulfilment_close = "Open"
elif so.per_delivered >= 100:
self.fulfilment_close = "Closed"
else:
self.fulfilment_close = "Partial"
# Billing/Fiscal Close — fully billed AND every linked Sales Invoice is
# either Not Required or Fiscalised (never Pending/Failed).
invoice_names = frappe.get_all(
"Sales Invoice Item", filters={"sales_order": so_name, "docstatus": 1}, pluck="parent", distinct=True
)
fiscal_states = (
frappe.get_all(
"Sales Invoice", filters={"name": ["in", invoice_names]}, pluck="makap_fiscalisation_status"
)
if invoice_names
else []
)
billing_closed = (so.per_billed or 0) >= 100 and all(
s in {"Not Required", "Fiscalised"} for s in fiscal_states
)
self.billing_fiscal_close = "Closed" if billing_closed else "Open"
self.collection_close = so.makap_collection_status or "Not Yet Due"
# Production / Material Close — derived from every Work Order raised
# directly against this Sales Order (standard Work Order.sales_order link).
linked_wos = frappe.get_all(
"Work Order",
filters={"sales_order": so_name},
fields=["name", "status", "makap_material_close_status"],
)
if not linked_wos:
self.production_close = "Open"
self.material_close = "Open"
else:
self.production_close = (
"Closed" if all(wo.status in {"Completed", "Closed"} for wo in linked_wos) else "Open"
)
self.material_close = (
"Closed" if all(wo.makap_material_close_status == "Closed" for wo in linked_wos) else "Open"
)
# Quality Close — no unresolved Hold/Reject disposition on inspections tied
# to this order's Work Orders.
wo_names = [wo.name for wo in linked_wos]
open_quality = (
frappe.get_all(
"Quality Inspection",
filters={
"makap_work_order": ["in", wo_names],
"makap_quality_disposition": ["in", ["Hold", "Reject"]],
"docstatus": 1,
},
limit=1,
)
if wo_names
else []
)
self.quality_close = "Open" if open_quality else "Closed"
# Supply Close — first-pass only. Real M7 lineage (Material Request ->
# Production Plan -> Sales Order) isn't traversed here yet; this checks
# Purchase Order Items sharing this order's items as a rough proxy and
# should be replaced once the actual M7 linkage is confirmed against data.
item_codes = frappe.get_all(
"Sales Order Item", filters={"parent": so_name}, pluck="item_code", distinct=True
)
at_risk_po = (
frappe.get_all(
"Purchase Order Item",
filters={
"item_code": ["in", item_codes],
"makap_supplier_confirmation_status": ["in", ["At Risk", "Declined"]],
"docstatus": 1,
},
limit=1,
)
if item_codes
else []
)
self.supply_close = "Open" if at_risk_po else "Closed"
self._derive_operating_close()
self.last_recomputed_by = frappe.session.user
self.last_recomputed_on = frappe.utils.now_datetime()
self.save(ignore_permissions=True)
return {
"operating_close": self.operating_close,
"primary_blocker": self.primary_blocker,
}
@frappe.whitelist()
def get_or_create(sales_order):
if not frappe.db.exists("Order Closure And Margin View", sales_order):
doc = frappe.get_doc(
{"doctype": "Order Closure And Margin View", "sales_order": sales_order}
).insert(ignore_permissions=True)
else:
doc = frappe.get_doc("Order Closure And Margin View", sales_order)
doc.recompute()
return doc.name
@@ -0,0 +1,9 @@
# Copyright (c) 2026, Joseph njuki and Contributors
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
class TestOrderClosureAndMarginView(FrappeTestCase):
pass
@@ -0,0 +1,35 @@
import frappe
from frappe import _
FIELDS=["name","customer","posting_date","status","grand_total","currency","sales_order","company","modified","makap_dispatch_gate_status","makap_dispatch_gate_blockers","makap_dispatch_gate_checked_by","makap_dispatch_gate_checked_on","makap_dispatch_material_accountability_status","makap_dispatch_accountability_notes","makap_financial_release_status","makap_short_close","makap_short_close_reason","makap_short_close_approved_by","makap_over_delivery_approved_by"]
ITEM_FIELDS=["item_code","item_name","qty","warehouse","sales_order","sales_order_item","makap_packaging_specification","makap_specification_revision","makap_sales_order_item_reference","makap_dispatch_allocation_mode","makap_stock_reservation_entry","makap_work_order","makap_customer_trace_key"]
def _read():
if not frappe.db.exists("DocType","Delivery Note") or not frappe.has_permission("Delivery Note","read"): frappe.throw(_("You do not have permission to view Delivery Notes."),frappe.PermissionError)
def _fields(dt,fs):
a={f.fieldname for f in frappe.get_meta(dt).fields}; return [f for f in fs if f in a or f in {"name","modified"}]
def _ors(s,fs): return [["Delivery Note",f,"like",f"%{s.strip()}%"] for f in fs if f in _fields("Delivery Note",FIELDS)] if s else None
def _state(r):
if r.get("makap_dispatch_gate_status")=="Blocked" or r.get("makap_financial_release_status")=="Blocked" or r.get("makap_dispatch_material_accountability_status")=="Exception": return "Blocked"
if r.get("makap_short_close") or r.get("makap_over_delivery_approved_by"): return "Exception"
if r.get("makap_dispatch_gate_status")=="Ready" and r.get("makap_financial_release_status") in {"Cleared","Not Required","Exception Approved"} and r.get("makap_dispatch_material_accountability_status") in {"Cleared","Not Assessed",None,""}: return "Ready"
return "Not Assessed"
def _rows(filters=None,ors=None,limit=100): return frappe.get_list("Delivery Note",fields=_fields("Delivery Note",FIELDS),filters=filters or {},or_filters=ors,order_by="modified desc",limit_page_length=limit)
@frappe.whitelist()
def get_fulfilment_summary():
_read(); rows=_rows({"docstatus":["!=",2]},limit=0); states=[_state(r) for r in rows]; return {"awaiting":states.count("Not Assessed"),"blocked":states.count("Blocked"),"ready":states.count("Ready"),"exceptions":sum(1 for r in rows if r.get("makap_dispatch_material_accountability_status")=="Exception" or r.get("makap_short_close"))}
@frappe.whitelist()
def list_delivery_notes(search=None,gate_status=None,operational_state=None,limit_page_length=100):
_read(); filters={"docstatus":["!=",2]};
if gate_status and gate_status!="All": filters["makap_dispatch_gate_status"]=gate_status
rows=_rows(filters,_ors(search,["name","customer","sales_order","company"]),min(max(int(limit_page_length or 100),1),200));
for r in rows:r["makap_derived_state"]=_state(r)
return [r for r in rows if not operational_state or operational_state=="All" or r["makap_derived_state"]==operational_state]
@frappe.whitelist()
def get_delivery_note(name):
_read(); doc=frappe.get_doc("Delivery Note",name)
if not frappe.has_permission(doc,"read"): frappe.throw(_("Permission denied."),frappe.PermissionError)
d=doc.as_dict(); d["makap_derived_state"]=_state(d); d["items"]=[{k:r.get(k) for k in ITEM_FIELDS} for r in doc.get("items") or []]; return d
@frappe.whitelist()
def list_fulfilment_exceptions(search=None,limit_page_length=100): return [r for r in list_delivery_notes(search=search,limit_page_length=limit_page_length) if r["makap_derived_state"] in {"Blocked","Exception"}]
@frappe.whitelist()
def list_fulfilment_traceability(search=None,limit_page_length=100):
rows=list_delivery_notes(search=search,limit_page_length=limit_page_length); return [r for r in rows if r.get("sales_order") or r.get("customer")]
@@ -0,0 +1,74 @@
import frappe
from frappe import _
MASTER=[("Customer","Customers"),("Contact","Contacts"),("Item","Items"),("Warehouse","Warehouses"),("UOM","UOMs"),("Supplier","Suppliers"),("User","Users"),("Packaging Specification","Packaging Specifications")]
WORKFLOW=[("Customer Onboarding Request","Customer onboarding"),("Quotation","Quotations"),("Sales Order","Sales Orders"),("Packaging Specification","Packaging Specifications"),("Production Plan","Production Plans"),("Work Order","Work Orders"),("Quality Inspection","Quality Inspections"),("Delivery Note","Delivery Notes"),("Sales Invoice","Sales Invoices"),("Payment Entry","Payment Entries"),("Order Closure And Margin View","Order close-outs")]
def _exists(dt): return frappe.db.exists("DocType",dt)
def _read(dt):
if not _exists(dt) or not frappe.has_permission(dt,"read"): return False
return True
def _count(dt,filters=None):
return frappe.db.count(dt,filters or {}) if _read(dt) else 0
def _records(dt,fields,limit=100):
if not _read(dt): return []
available={f.fieldname for f in frappe.get_meta(dt).fields}; selected=[f for f in fields if f in available or f in {"name","modified"}]
return frappe.get_list(dt,fields=selected,order_by="modified desc",limit_page_length=limit)
def _check(label,category,ok,detail,route=None,severity="warning"):
return {"label":label,"category":category,"ok":bool(ok),"detail":detail,"route":route,"severity":severity if not ok else "ok"}
@frappe.whitelist()
def get_governance_summary():
checks=get_configuration_checks()
masters=[{"doctype":dt,"label":label,"available":_exists(dt),"count":_count(dt)} for dt,label in MASTER]
return {"checks":len(checks),"passed":sum(1 for x in checks if x["ok"]),"attention":sum(1 for x in checks if not x["ok"]),"masters":len(masters),"master_records":sum(x["count"] for x in masters if x["available"])}
@frappe.whitelist()
def get_configuration_checks():
checks=[]
checks.append(_check("Returns Hold warehouse configured","Configuration",bool(frappe.db.exists("Warehouse",{"makap_is_returns_hold_warehouse":1})),"A Warehouse must be marked as Returns Hold.","/app/warehouse", "critical"))
checks.append(_check("Packaging Specifications available","Configuration",_exists("Packaging Specification"),"Packaging Specification DocType must be installed.","/app/packaging-specification","critical"))
checks.append(_check("Work Order readiness fields available","Configuration",_exists("Work Order") and "makap_manufacturing_readiness_status" in {f.fieldname for f in frappe.get_meta("Work Order").fields},"MAKAP readiness controls must exist on Work Order.","/app/work-order","critical"))
checks.append(_check("Quality disposition fields available","Configuration",_exists("Quality Inspection") and "makap_quality_disposition" in {f.fieldname for f in frappe.get_meta("Quality Inspection").fields},"Quality disposition controls must exist on Quality Inspection.","/app/quality-inspection","critical"))
checks.append(_check("Dispatch gate fields available","Configuration",_exists("Delivery Note") and "makap_dispatch_gate_status" in {f.fieldname for f in frappe.get_meta("Delivery Note").fields},"Dispatch gate controls must exist on Delivery Note.","/app/delivery-note","critical"))
checks.append(_check("Billing status fields available","Configuration",_exists("Sales Order") and "makap_billing_status" in {f.fieldname for f in frappe.get_meta("Sales Order").fields},"Billing and collection controls must exist on Sales Order.","/app/sales-order","critical"))
checks.append(_check("Supplier match fields available","Configuration",_exists("Purchase Invoice") and "makap_supplier_invoice_match_status" in {f.fieldname for f in frappe.get_meta("Purchase Invoice").fields},"Supplier reconciliation controls must exist on Purchase Invoice.","/app/purchase-invoice","warning"))
checks.append(_check("Close-out view installed","Configuration",_exists("Order Closure And Margin View"),"Order Closure And Margin View must be installed.","/app/order-closure-and-margin-view","warning"))
return checks
@frappe.whitelist()
def list_master_data(doctype=None,search=None,limit_page_length=100):
requested=doctype if doctype and doctype!="All" else "Customer"
if requested not in {x[0] for x in MASTER}: frappe.throw(_("Unsupported master data type."))
fields={"Customer":["name","customer_name","customer_group","territory","disabled","makap_credit_exposure_status","makap_financial_hold"],"Contact":["name","first_name","last_name","email_id","phone","company_name","is_primary_contact"],"Item":["name","item_code","item_name","item_group","stock_uom","disabled","makap_material_control_class"],"Warehouse":["name","warehouse_name","company","disabled","makap_is_returns_hold_warehouse"],"UOM":["name","enabled"],"Supplier":["name","supplier_name","supplier_group","disabled"],"User":["name","full_name","enabled","user_type"],"Packaging Specification":["name","specification_series","revision","customer","item","specification_status"]}[requested]
ors=None
if search:
like=f"%{search.strip()}%"; ors=[[requested,f,"like",like] for f in fields if f in {x.fieldname for x in frappe.get_meta(requested).fields}]
if not _read(requested): return []
available={f.fieldname for f in frappe.get_meta(requested).fields}; selected=[f for f in fields if f in available or f=="name"]
return frappe.get_list(requested,fields=selected,or_filters=ors,order_by="modified desc",limit_page_length=min(max(int(limit_page_length or 100),1),200))
@frappe.whitelist()
def get_workflow_health():
result=[]
for dt,label in WORKFLOW:
result.append({"doctype":dt,"label":label,"available":_exists(dt),"count":_count(dt),"route":f"/app/{frappe.scrub(dt)}"})
issues=[]
if _read("Sales Order"):
issues += [{"type":"Sales Order","name":r.name,"issue":"Missing Customer","detail":"Sales Order has no Customer link."} for r in frappe.get_list("Sales Order",filters={"customer":["in",["",None]],"docstatus":["!=",2]},fields=["name"],limit_page_length=50)]
if _read("Work Order"):
fields={f.fieldname for f in frappe.get_meta("Work Order").fields}
if "makap_manufacturing_readiness_status" in fields:
issues += [{"type":"Work Order","name":r.name,"issue":"Readiness not checked","detail":"Work Order has not completed MAKAP readiness review."} for r in frappe.get_list("Work Order",filters={"status":["in",["Not Started","In Process"]],"makap_manufacturing_readiness_status":"Not Checked"},fields=["name"],limit_page_length=50)]
if _read("Quality Inspection"):
fields={f.fieldname for f in frappe.get_meta("Quality Inspection").fields}
if "makap_quality_disposition" in fields:
issues += [{"type":"Quality Inspection","name":r.name,"issue":"Disposition pending","detail":"Inspection needs quality disposition."} for r in frappe.get_list("Quality Inspection",filters={"makap_quality_disposition":["in",["Pending",""]],"docstatus":1},fields=["name"],limit_page_length=50)]
if _read("Delivery Note"):
fields={f.fieldname for f in frappe.get_meta("Delivery Note").fields}
if "makap_dispatch_gate_status" in fields:
issues += [{"type":"Delivery Note","name":r.name,"issue":"Dispatch gate not assessed","detail":"Delivery Note has not completed dispatch review."} for r in frappe.get_list("Delivery Note",filters={"docstatus":1,"makap_dispatch_gate_status":"Not Assessed"},fields=["name"],limit_page_length=50)]
return {"workflow":result,"issues":issues[:200],"issue_count":len(issues)}
+24
View File
@@ -0,0 +1,24 @@
import frappe
LOGO = "/assets/makap_backend/images/makap-icon.png"
TARGETS = {
"Navbar Settings": ["app_logo"],
"Website Settings": ["app_logo", "banner_image", "favicon", "splash_image"],
"System Settings": ["app_logo"],
}
def apply_logo():
done = []
for doctype, fields in TARGETS.items():
if not frappe.db.exists("DocType", doctype):
continue
meta = frappe.get_meta(doctype)
for field in fields:
if meta.has_field(field):
frappe.db.set_single_value(doctype, field, LOGO)
done.append("{0}.{1}".format(doctype, field))
frappe.db.commit()
frappe.clear_cache()
print("Logo set on: " + ", ".join(done))
return done
@@ -0,0 +1,97 @@
import frappe
from frappe import _
def _read(doctype):
if not frappe.db.exists("DocType", doctype) or not frappe.has_permission(doctype, "read"):
frappe.throw(_("You do not have permission to view {0}.").format(doctype), frappe.PermissionError)
def _fields(doctype, fields):
meta = frappe.get_meta(doctype)
available = {f.fieldname for f in meta.fields}
return [f for f in fields if f in available or f in {"name", "creation", "modified", "owner"}]
def _list(doctype, fields, filters=None, or_filters=None, order_by="modified desc", limit=100):
if not frappe.db.exists("DocType", doctype): return []
selected = _fields(doctype, fields)
try:
return frappe.get_list(doctype, fields=selected, filters=filters or {}, or_filters=or_filters, order_by=order_by, limit_page_length=limit)
except Exception:
return frappe.get_list(doctype, fields=[f for f in ["name", "modified"] if f in selected], filters=filters or {}, order_by=order_by, limit_page_length=limit)
def _search(doctype, search, fields):
if not search: return None
like = f"%{search.strip()}%"
return [[doctype, f, "like", like] for f in fields]
@frappe.whitelist()
def get_materials_summary():
result = {}
if frappe.db.exists("DocType", "Material Request") and frappe.has_permission("Material Request", "read"):
rows = _list("Material Request", ["name", "status", "material_request_type", "schedule_date"], {"docstatus": ["!=", 2]}, limit=0)
result["open_requests"] = sum(1 for r in rows if r.get("status") not in {"Stopped", "Cancelled", "Completed"})
result["due_requests"] = sum(1 for r in rows if r.get("schedule_date") and r.get("status") not in {"Stopped", "Cancelled", "Completed"})
else: result["open_requests"] = result["due_requests"] = 0
if frappe.db.exists("DocType", "Purchase Order") and frappe.has_permission("Purchase Order", "read"):
rows = _list("Purchase Order", ["name", "status", "transaction_date", "schedule_date"], {"docstatus": ["!=", 2]}, limit=0)
result["open_orders"] = sum(1 for r in rows if r.get("status") not in {"Completed", "Cancelled", "Closed"})
result["at_risk"] = sum(1 for r in rows if r.get("status") in {"To Receive", "To Receive and Bill"})
else: result["open_orders"] = result["at_risk"] = 0
if frappe.db.exists("DocType", "Purchase Receipt") and frappe.has_permission("Purchase Receipt", "read"):
rows = _list("Purchase Receipt", ["name", "status", "posting_date"], {"docstatus": ["!=", 2]}, limit=0)
result["receipts"] = len(rows)
else: result["receipts"] = 0
return result
@frappe.whitelist()
def list_material_requests(search=None, status=None, limit_page_length=100):
_read("Material Request")
filters = {"docstatus": ["!=", 2]}
if status and status != "All": filters["status"] = status
rows = _list("Material Request", ["name", "transaction_date", "schedule_date", "status", "material_request_type", "company", "requested_by", "set_warehouse", "modified"], filters, _search("Material Request", search, ["name", "company", "material_request_type", "requested_by"]), limit=min(max(int(limit_page_length or 100), 1), 200))
return rows
@frappe.whitelist()
def get_material_request(name):
_read("Material Request")
doc = frappe.get_doc("Material Request", name)
if not frappe.has_permission(doc, "read"): frappe.throw(_("Permission denied."), frappe.PermissionError)
return {"name": doc.name, "transaction_date": doc.transaction_date, "schedule_date": doc.schedule_date, "status": doc.status, "material_request_type": doc.material_request_type, "company": doc.company, "requested_by": doc.requested_by, "set_warehouse": doc.set_warehouse, "items": [{"item_code": r.item_code, "item_name": r.item_name, "qty": r.qty, "uom": r.uom, "warehouse": r.warehouse, "schedule_date": r.schedule_date, "makap_material_need_date": r.get("makap_material_need_date"), "makap_coverage_mode": r.get("makap_coverage_mode")} for r in doc.get("items") or []]}
@frappe.whitelist()
def list_purchase_orders(search=None, status=None, limit_page_length=100):
_read("Purchase Order")
filters = {"docstatus": ["!=", 2]}
if status and status != "All": filters["status"] = status
return _list("Purchase Order", ["name", "supplier", "transaction_date", "schedule_date", "status", "company", "grand_total", "currency", "modified"], filters, _search("Purchase Order", search, ["name", "supplier", "company"]), limit=min(max(int(limit_page_length or 100), 1), 200))
@frappe.whitelist()
def get_purchase_order(name):
_read("Purchase Order")
doc = frappe.get_doc("Purchase Order", name)
if not frappe.has_permission(doc, "read"): frappe.throw(_("Permission denied."), frappe.PermissionError)
return {"name": doc.name, "supplier": doc.supplier, "transaction_date": doc.transaction_date, "schedule_date": doc.schedule_date, "status": doc.status, "company": doc.company, "grand_total": doc.grand_total, "currency": doc.currency, "items": [{"item_code": r.item_code, "item_name": r.item_name, "qty": r.qty, "received_qty": r.received_qty, "uom": r.uom, "schedule_date": r.schedule_date, "makap_sourcing_basis": r.get("makap_sourcing_basis"), "makap_supplier_confirmation_status": r.get("makap_supplier_confirmation_status"), "makap_expected_eligible_date": r.get("makap_expected_eligible_date")} for r in doc.get("items") or []]}
@frappe.whitelist()
def list_purchase_receipts(search=None, status=None, limit_page_length=100):
_read("Purchase Receipt")
filters = {"docstatus": ["!=", 2]}
if status and status != "All": filters["status"] = status
return _list("Purchase Receipt", ["name", "supplier", "posting_date", "status", "company", "grand_total", "currency", "modified"], filters, _search("Purchase Receipt", search, ["name", "supplier", "company"]), limit=min(max(int(limit_page_length or 100), 1), 200))
@frappe.whitelist()
def get_purchase_receipt(name):
_read("Purchase Receipt")
doc = frappe.get_doc("Purchase Receipt", name)
if not frappe.has_permission(doc, "read"): frappe.throw(_("Permission denied."), frappe.PermissionError)
return {"name": doc.name, "supplier": doc.supplier, "posting_date": doc.posting_date, "status": doc.status, "company": doc.company, "grand_total": doc.grand_total, "currency": doc.currency, "items": [{"item_code": r.item_code, "item_name": r.item_name, "qty": r.qty, "received_qty": r.received_qty, "uom": r.uom, "purchase_order": r.purchase_order, "makap_receipt_exception_type": r.get("makap_receipt_exception_type")} for r in doc.get("items") or []]}
@@ -0,0 +1,99 @@
import frappe
from frappe import _
DOCTYPE = "Customer Onboarding Request"
FIELDS = [
"name", "organisation_name", "trading_name", "customer_type",
"business_tax_identifier", "contact_name", "email_id", "telephone",
"submitted_address", "location", "product_interest", "initial_enquiry",
"registration_source", "assisted_channel", "onboarding_status",
"customer_match_result", "possible_existing_customer",
"created_linked_customer", "created_linked_contact", "portal_access_status",
"review_notes", "privacy_notice_acknowledged", "acknowledgement_timestamp",
"privacy_notice_reference_version", "creation", "modified", "owner",
]
def _filters(args):
filters = {}
status = args.get("status")
if status and status != "All":
filters["onboarding_status"] = status
return filters
@frappe.whitelist()
def get_onboarding_summary():
return {
"all": frappe.db.count(DOCTYPE),
"submitted": frappe.db.count(DOCTYPE, {"onboarding_status": "Submitted"}),
"under_review": frappe.db.count(DOCTYPE, {"onboarding_status": "Under Review"}),
"approved": frappe.db.count(DOCTYPE, {"onboarding_status": "Approved"}),
"completed": frappe.db.count(DOCTYPE, {"onboarding_status": "Completed"}),
"rejected": frappe.db.count(DOCTYPE, {"onboarding_status": "Rejected"}),
}
@frappe.whitelist()
def get_onboarding_requests(status=None, search=None, limit_start=0, limit_page_length=25):
filters = _filters({"status": status})
fields = [
"name", "organisation_name", "trading_name", "contact_name", "email_id",
"customer_type", "location", "onboarding_status", "customer_match_result",
"portal_access_status", "creation", "modified",
]
rows = frappe.get_list(
DOCTYPE,
filters=filters,
fields=fields,
order_by="modified desc",
limit_start=int(limit_start or 0),
limit_page_length=min(int(limit_page_length or 25), 100),
)
if search:
term = str(search).lower()
rows = [
row for row in rows
if term in " ".join(str(row.get(key) or "") for key in fields).lower()
]
return rows
@frappe.whitelist()
def get_onboarding_request(name):
if not name:
frappe.throw(_("Request name is required."))
return frappe.get_doc(DOCTYPE, name).as_dict()
@frappe.whitelist()
def create_onboarding_request(data):
data = frappe.parse_json(data) if isinstance(data, str) else (data or {})
allowed = {field: data.get(field) for field in FIELDS if field in data}
allowed.pop("name", None)
allowed["doctype"] = DOCTYPE
allowed["onboarding_status"] = "Submitted"
allowed["customer_match_result"] = "Not Checked"
allowed["portal_access_status"] = "Not Provisioned"
if not allowed.get("organisation_name"):
frappe.throw(_("Organisation name is required."))
if not allowed.get("contact_name"):
frappe.throw(_("Contact name is required."))
if not allowed.get("email_id"):
frappe.throw(_("Email address is required."))
doc = frappe.get_doc(allowed)
doc.insert()
frappe.db.commit()
return doc.name
@frappe.whitelist()
def update_onboarding_request(name, data):
data = frappe.parse_json(data) if isinstance(data, str) else (data or {})
doc = frappe.get_doc(DOCTYPE, name)
for field in FIELDS:
if field in data and field not in {"name", "onboarding_status", "customer_match_result", "portal_access_status"}:
doc.set(field, data[field])
doc.save()
frappe.db.commit()
return doc.as_dict()
@@ -0,0 +1,140 @@
import frappe
from frappe import _
LIST_FIELDS = [
"name",
"opportunity_from",
"party_name",
"opportunity_type",
"status",
"sales_stage",
"probability",
"opportunity_amount",
"transaction_date",
"expected_closing_date",
"modified",
]
def _ensure_read_permission():
if not frappe.has_permission("Opportunity", "read"):
frappe.throw(_("You do not have permission to view Opportunities."), frappe.PermissionError)
def _number(value):
try:
return float(value or 0)
except (TypeError, ValueError):
return 0
@frappe.whitelist()
def get_opportunity_summary():
"""Return compact pipeline totals for the MAKAP Opportunity workspace."""
_ensure_read_permission()
active_statuses = ["Open", "Quotation", "Converted"]
rows = frappe.get_list(
"Opportunity",
fields=["status", "opportunity_amount", "probability"],
filters={"status": ["in", active_statuses]},
limit_page_length=0,
)
total_value = sum(_number(row.get("opportunity_amount")) for row in rows)
weighted_value = sum(
_number(row.get("opportunity_amount")) * _number(row.get("probability")) / 100
for row in rows
)
return {
"active_count": len(rows),
"pipeline_value": total_value,
"weighted_value": weighted_value,
"open_count": sum(1 for row in rows if row.get("status") == "Open"),
"quotation_count": sum(1 for row in rows if row.get("status") == "Quotation"),
"converted_count": sum(1 for row in rows if row.get("status") == "Converted"),
}
@frappe.whitelist()
def list_opportunities(search=None, status=None, limit_start=0, limit_page_length=30):
"""Return a permission-aware Opportunity list for the MAKAP workspace."""
_ensure_read_permission()
search = (search or "").strip()
status = (status or "").strip()
limit_start = max(int(limit_start or 0), 0)
limit_page_length = min(max(int(limit_page_length or 30), 1), 100)
filters = {"status": status} if status and status != "All" else {}
or_filters = None
if search:
like = f"%{search}%"
or_filters = [
["Opportunity", "name", "like", like],
["Opportunity", "party_name", "like", like],
["Opportunity", "sales_stage", "like", like],
["Opportunity", "opportunity_type", "like", like],
]
return frappe.get_list(
"Opportunity",
fields=LIST_FIELDS,
filters=filters,
or_filters=or_filters,
order_by="modified desc",
limit_start=limit_start,
limit_page_length=limit_page_length,
)
@frappe.whitelist()
def get_opportunity(name):
"""Return one Opportunity with contacts, items, and key commercial fields."""
_ensure_read_permission()
if not name:
frappe.throw(_("Opportunity name is required."))
doc = frappe.get_doc("Opportunity", name)
if not frappe.has_permission(doc, "read"):
frappe.throw(_("You do not have permission to view this Opportunity."), frappe.PermissionError)
contacts = []
for row in doc.get("contact_info") or []:
contacts.append({
"contact": row.contact,
"email_id": row.email_id,
"mobile_no": row.mobile_no,
"phone": row.phone,
})
items = []
for row in doc.get("items") or []:
items.append({
"item_code": row.item_code,
"item_name": row.item_name,
"qty": row.qty,
"uom": row.uom,
"amount": row.amount,
})
return {
"name": doc.name,
"opportunity_from": doc.opportunity_from,
"party_name": doc.party_name,
"opportunity_type": doc.opportunity_type,
"status": doc.status,
"sales_stage": doc.sales_stage,
"probability": doc.probability,
"opportunity_amount": doc.opportunity_amount,
"transaction_date": doc.transaction_date,
"expected_closing_date": doc.expected_closing_date,
"source": doc.source,
"customer_address": doc.customer_address,
"contact_person": doc.contact_person,
"contact_email": doc.contact_email,
"contact_mobile": doc.contact_mobile,
"territory": doc.territory,
"owner": doc.owner,
"modified": doc.modified,
"remarks": doc.remarks,
"contact_info": contacts,
"items": items,
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"doctype":"Page","name":"makap-billing","module":"makap_backend","standard":"Yes","title":"MAKAP Billing & Collections Center"}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"doctype":"Page","name":"makap-closeout","module":"makap_backend","standard":"Yes","title":"MAKAP Order Close-out & Margin Center"}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,109 @@
frappe.pages["makap-contacts"].on_page_load = function (wrapper) {
const page = frappe.ui.make_app_page({
parent: wrapper,
title: "Contacts",
single_column: true,
});
page.main.html(`
<section class="makap-contacts-shell">
<div class="makap-contacts-head">
<div>
<div class="makap-eyebrow">Customer & commercial</div>
<h1>Contacts</h1>
<p>Review customer people, resolve onboarding contacts, and keep communication data accurate.</p>
</div>
<div class="makap-contact-actions">
<button class="btn btn-default btn-sm" data-action="refresh"><i class="fa fa-refresh"></i> Refresh</button>
<button class="btn btn-primary btn-sm" data-action="new"><i class="fa fa-plus"></i> New Contact</button>
</div>
</div>
<div class="makap-contact-workspace">
<aside class="makap-contact-list-panel">
<div class="makap-contact-list-head">
<div><strong>Directory</strong><span class="makap-contact-count" data-count>0 contacts</span></div>
<div class="makap-contact-search"><i class="fa fa-search"></i><input type="search" placeholder="Search contacts..." data-search></div>
</div>
<div class="makap-contact-list" data-list><div class="makap-contact-empty">Loading contacts...</div></div>
</aside>
<article class="makap-contact-detail-panel" data-detail>
<div class="makap-contact-empty makap-contact-detail-empty"><div class="makap-empty-icon"><i class="fa fa-address-book-o"></i></div><strong>Select a contact</strong><span>Choose a contact from the directory to view profile details and linked customers.</span></div>
</article>
</div>
</section>
`);
const root = page.main[0];
const list = root.querySelector("[data-list]");
const detail = root.querySelector("[data-detail]");
const search = root.querySelector("[data-search]");
const count = root.querySelector("[data-count]");
let activeName = null;
let searchTimer = null;
const esc = (value) => frappe.utils.escape_html(String(value || ""));
const initials = (value) => (String(value || "?").trim().split(/\\s+/).slice(0, 2).map((part) => part[0]).join("") || "?").toUpperCase();
const call = (method, args = {}) => new Promise((resolve, reject) => frappe.call({method, args, callback: (r) => r.exc ? reject(r.exc) : resolve(r.message)}));
function renderList(rows) {
count.textContent = `${rows.length} contact${rows.length === 1 ? "" : "s"}`;
if (!rows.length) {
list.innerHTML = `<div class="makap-contact-empty"><div class="makap-empty-icon"><i class="fa fa-search"></i></div><strong>No contacts found</strong><span>Try a different name, email, or company.</span></div>`;
return;
}
list.innerHTML = rows.map((row) => `
<button class="makap-contact-row ${row.name === activeName ? "is-active" : ""}" data-name="${esc(row.name)}">
<span class="makap-contact-avatar">${esc(initials(row.display_name))}</span>
<span class="makap-contact-row-copy"><strong>${esc(row.display_name)}</strong><span>${esc(row.company_name || row.email_id || "No company linked")}</span></span>
<i class="fa fa-angle-right makap-contact-chevron"></i>
</button>
`).join("");
list.querySelectorAll("[data-name]").forEach((el) => el.addEventListener("click", () => selectContact(el.dataset.name)));
}
function renderDetail(contact) {
activeName = contact.name;
detail.innerHTML = `
<div class="makap-detail-hero">
<div class="makap-detail-avatar">${esc(initials(contact.display_name))}</div>
<div class="makap-detail-identity"><div class="makap-status-pill"><span></span>${esc(contact.status || "Active")}</div><h2>${esc(contact.display_name)}</h2><p>${esc(contact.designation || "Contact")}${contact.company_name ? ` · ${esc(contact.company_name)}` : ""}</p></div>
<div class="makap-detail-menu"><a class="btn btn-default btn-xs" href="/app/contact/${encodeURIComponent(contact.name)}">Open ERPNext form</a></div>
</div>
<div class="makap-detail-body">
<section class="makap-detail-section"><div class="makap-detail-section-title">Communication</div><div class="makap-detail-fields">
<div class="makap-detail-field"><span>Email</span><strong>${contact.email_id ? `<a href="mailto:${esc(contact.email_id)}">${esc(contact.email_id)}</a>` : "Not provided"}</strong></div>
<div class="makap-detail-field"><span>Phone</span><strong>${contact.phone ? `<a href="tel:${esc(contact.phone)}">${esc(contact.phone)}</a>` : "Not provided"}</strong></div>
<div class="makap-detail-field"><span>Mobile</span><strong>${contact.mobile_no ? `<a href="tel:${esc(contact.mobile_no)}">${esc(contact.mobile_no)}</a>` : "Not provided"}</strong></div>
<div class="makap-detail-field"><span>Last updated</span><strong>${esc(frappe.datetime.str_to_user(contact.modified))}</strong></div>
</div></section>
<section class="makap-detail-section"><div class="makap-detail-section-title">Linked records</div>${contact.links.length ? contact.links.map((link) => `<a class="makap-linked-record" href="/app/${encodeURIComponent(link.doctype.toLowerCase().replaceAll(" ", "-"))}/${encodeURIComponent(link.name)}"><span class="makap-linked-icon"><i class="fa fa-link"></i></span><span><strong>${esc(link.name)}</strong><small>${esc(link.doctype)}</small></span><i class="fa fa-angle-right"></i></a>`).join("") : `<div class="makap-contact-note"><i class="fa fa-info-circle"></i>No Customer or other linked records.</div>`}</section>
<section class="makap-detail-section"><div class="makap-detail-section-title">Contact ID</div><div class="makap-contact-id"><code>${esc(contact.name)}</code><button class="btn btn-default btn-xs" data-copy="${esc(contact.name)}"><i class="fa fa-copy"></i> Copy</button></div></section>
</div>
`;
root.querySelectorAll(".makap-contact-row").forEach((el) => el.classList.toggle("is-active", el.dataset.name === activeName));
detail.querySelector("[data-copy]")?.addEventListener("click", (event) => { frappe.utils.copy_to_clipboard(event.currentTarget.dataset.copy); frappe.show_alert({message: "Contact ID copied", indicator: "green"}); });
}
async function selectContact(name) {
activeName = name;
root.querySelectorAll(".makap-contact-row").forEach((el) => el.classList.toggle("is-active", el.dataset.name === name));
detail.innerHTML = `<div class="makap-contact-empty makap-contact-detail-empty"><div class="makap-loading-ring"></div><strong>Loading contact</strong><span>Fetching the latest profile details.</span></div>`;
try { renderDetail(await call("makap_backend.contact_api.get_contact", {name})); } catch (error) { detail.innerHTML = `<div class="makap-contact-empty makap-contact-detail-empty"><strong>Could not load contact</strong><span>${esc(error.message || "Please try again.")}</span></div>`; }
}
async function loadContacts() {
list.innerHTML = `<div class="makap-contact-empty"><div class="makap-loading-ring"></div><span>Loading contacts...</span></div>`;
try {
const rows = await call("makap_backend.contact_api.list_contacts", {search: search.value, limit_page_length: 50});
renderList(rows || []);
if (!activeName && rows?.[0]) selectContact(rows[0].name);
else if (activeName && rows?.some((row) => row.name === activeName)) selectContact(activeName);
} catch (error) { list.innerHTML = `<div class="makap-contact-empty"><strong>Contacts unavailable</strong><span>${esc(error.message || "Check your permissions and try again.")}</span></div>`; }
}
search.addEventListener("input", () => { clearTimeout(searchTimer); searchTimer = setTimeout(loadContacts, 250); });
root.querySelector('[data-action="refresh"]').addEventListener("click", loadContacts);
root.querySelector('[data-action="new"]').addEventListener("click", () => frappe.new_doc("Contact"));
loadContacts();
};
@@ -0,0 +1,7 @@
{
"doctype": "Page",
"name": "makap-contacts",
"module": "makap_backend",
"standard": "Yes",
"title": "MAKAP Contacts"
}
@@ -0,0 +1,231 @@
frappe.pages['makap-dashboard'].on_page_load = function (wrapper) {
var page = frappe.ui.make_app_page({
parent: wrapper,
title: 'MAKAP Dashboard',
single_column: true,
});
page.main.html('<div id="makap-dashboard-root"></div>');
render_dashboard(page);
};
function icon(name) {
return frappe.utils.icon(name, 'md');
}
function render_dashboard(page) {
var root = page.main.find('#makap-dashboard-root');
root.html('<p class="text-muted">Loading…</p>');
frappe.call({
method: 'makap_backend.makap_backend.dashboard_api.get_universal_dashboard',
callback: function (r) {
if (!r.message) {
root.html('<p class="text-muted">No data.</p>');
return;
}
var d = r.message;
var user_name = frappe.session.user_fullname || frappe.session.user;
var kpis = [
{ label: 'Active Work Orders', value: d.active_work_orders, icon: 'tool', bg: '#E8F1FC', fg: '#2E6BC7',
doctype: 'Work Order', filters: { status: ['in', ['Not Started', 'In Process']] } },
{ label: 'Active Sales Orders', value: d.active_sales_orders, icon: 'shopping-cart', bg: '#E6F8EA', fg: '#1F9D4B',
doctype: 'Sales Order', filters: { status: ['not in', ['Completed', 'Cancelled', 'Closed']] } },
{ label: 'Open Purchase Orders', value: d.open_purchase_orders, icon: 'clipboard', bg: '#EEE9FA', fg: '#6C4FBB',
doctype: 'Purchase Order', filters: { status: ['not in', ['Completed', 'Cancelled', 'Closed']] } },
{ label: 'Deliveries This Month', value: d.deliveries_this_month, icon: 'truck', bg: '#FFF3E0', fg: '#C97B0A',
doctype: 'Delivery Note', filters: {} },
];
var operations = [
{
title: 'Production & Work Orders', icon: 'tool', bg: '#E8F1FC', fg: '#2E6BC7', module: 'Module 4',
desc: 'Work Order planning, progress and manufacturing readiness.',
badges: [{ label: 'On Hold', value: d.work_orders_on_hold, doctype: 'Work Order', filters: { status: 'Stopped' } }],
doctype: 'Work Order', filters: {},
},
{
title: 'Materials & Quality', icon: 'check-circle', bg: '#E6F8EA', fg: '#1F9D4B', module: 'Module 5',
desc: 'Material accountability and Quality Inspection eligibility.',
badges: [{ label: 'Hold/Reject', value: d.quality_holds, doctype: 'Quality Inspection', filters: { makap_quality_disposition: ['in', ['Hold', 'Reject']] } }],
doctype: 'Quality Inspection', filters: {},
},
{
title: 'Supply & Procurement', icon: 'package', bg: '#EEE9FA', fg: '#6C4FBB', module: 'Module 6/7',
desc: 'Material Request, Purchase Order and Purchase Receipt supply assurance.',
badges: [{ label: 'Supplier At-Risk', value: d.supplier_at_risk, doctype: 'Purchase Order', filters: {} }],
doctype: 'Purchase Order', filters: {},
},
{
title: 'Dispatch & Fulfilment', icon: 'truck', bg: '#FFF3E0', fg: '#C97B0A', module: 'Module 8A',
desc: 'Dispatch Gate checks, allocation mode and delivery quantity control.',
badges: [{ label: 'Blocked', value: d.dispatch_blocked, doctype: 'Delivery Note', filters: { makap_dispatch_gate_status: 'Blocked' } }],
doctype: 'Delivery Note', filters: {},
},
{
title: 'Billing & Fiscal', icon: 'file-text', bg: '#FDE9E9', fg: '#D33', module: 'Module 8B/8C',
desc: 'Sales Invoice billing status and Uganda EFRIS fiscalisation.',
badges: [
{ label: 'Fiscalisation', value: d.fiscalisation_issue, doctype: 'Sales Invoice', filters: { makap_fiscalisation_status: ['in', ['Pending', 'Failed']] } },
{ label: 'Delivered-Unbilled', value: d.delivered_unbilled, doctype: 'Sales Order', filters: { makap_delivered_unbilled: 1 } },
],
doctype: 'Sales Invoice', filters: {},
},
{
title: 'Payments & Collections', icon: 'credit-card', bg: '#E8F1FC', fg: '#2E6BC7', module: 'Module 8B',
desc: 'Deposits, allocations and receivable follow-up.',
badges: [{ label: 'Collection Due', value: d.collection_due, doctype: 'Sales Order', filters: { makap_collection_status: 'Due' } }],
doctype: 'Payment Entry', filters: {},
},
{
title: 'Returns & Replacement', icon: 'rotate-ccw', bg: '#FDE9E9', fg: '#D33', module: 'Module 8D',
desc: 'Customer returns routed through a Returns Hold warehouse.',
badges: [{ label: 'Total Returns', value: d.returns_total, doctype: 'Delivery Note', filters: { is_return: 1 } }],
doctype: 'Delivery Note', filters: { is_return: 1 },
},
{
title: 'Supplier Reconciliation', icon: 'flag', bg: '#EEE9FA', fg: '#6C4FBB', module: 'Module 8E',
desc: 'Purchase Invoice matching against Purchase Order and Receipt.',
badges: [{ label: 'Variance', value: d.supplier_variance, doctype: 'Purchase Invoice', filters: { makap_supplier_invoice_match_status: ['in', ['Quantity Variance', 'Rate Variance', 'Both Variance', 'Disputed']] } }],
doctype: 'Purchase Invoice', filters: {},
},
{
title: 'Closure & Margin', icon: 'bar-chart-2', bg: '#E6F3F1', fg: '#1B8074', module: 'Module 8F',
desc: 'Cross-module Operating Close status and margin view per order.',
badges: [{ label: 'Views', value: d.closure_views, doctype: 'Order Closure And Margin View', filters: {} }],
doctype: 'Order Closure And Margin View', filters: {},
},
];
var html = build_styles();
html += '<div class="makap-banner">' +
'<div class="makap-banner-text">' +
'<h2>Welcome back, ' + frappe.utils.escape_html(user_name) + ' 👋</h2>' +
'<p>A single view across MAKAP Production, Quality, Supply and Fulfilment — Modules 4 through 8.</p>' +
'</div></div>';
html += '<div class="makap-kpi-row">';
kpis.forEach(function (k) {
html += '<div class="makap-kpi-card" data-doctype="' + k.doctype + '" data-filters=\'' + JSON.stringify(k.filters) + '\'>' +
'<div class="makap-kpi-top">' +
'<span class="makap-icon-circle" style="background:' + k.bg + ';color:' + k.fg + ';">' + icon(k.icon) + '</span>' +
'<span class="makap-kpi-chevron">' + icon('chevron-right') + '</span>' +
'</div>' +
'<div class="makap-kpi-label">' + frappe.utils.escape_html(k.label) + '</div>' +
'<div class="makap-kpi-value">' + k.value + '</div>' +
'</div>';
});
html += '</div>';
html += '<div class="makap-section-header">' +
'<div class="makap-section-title-row">' +
'<span class="makap-icon-circle makap-section-icon">' + icon('grid') + '</span>' +
'<div><h3>MAKAP Operations</h3><p>Manage production, quality, supply, fulfilment and billing from one place.</p></div>' +
'</div>' +
'<div class="makap-quick-actions-wrap">' +
'<button class="btn btn-default btn-sm makap-quick-actions-btn">' + icon('zap') + ' Quick Actions</button>' +
'<div class="makap-quick-actions-menu" style="display:none;">' +
'<a data-doctype="Work Order">New Work Order</a>' +
'<a data-doctype="Delivery Note">New Delivery Note</a>' +
'<a data-doctype="Sales Invoice">New Sales Invoice</a>' +
'<a data-doctype="Payment Entry">New Payment Entry</a>' +
'<a data-doctype="Purchase Order">New Purchase Order</a>' +
'</div></div></div>';
html += '<div class="makap-ops-grid">';
operations.forEach(function (op) {
html += '<div class="makap-ops-card" data-doctype="' + op.doctype + '" data-filters=\'' + JSON.stringify(op.filters) + '\'>' +
'<div class="makap-ops-top">' +
'<span class="makap-icon-circle" style="background:' + op.bg + ';color:' + op.fg + ';">' + icon(op.icon) + '</span>' +
'<span class="makap-module-tag">' + frappe.utils.escape_html(op.module) + '</span>' +
'</div>' +
'<h4>' + frappe.utils.escape_html(op.title) + '</h4>' +
'<p>' + frappe.utils.escape_html(op.desc) + '</p>' +
'<div class="makap-ops-badges">';
op.badges.forEach(function (b) {
html += '<span class="makap-badge" data-doctype="' + b.doctype + '" data-filters=\'' + JSON.stringify(b.filters) + '\'>' +
'<b>' + b.value + '</b> ' + frappe.utils.escape_html(b.label) + '</span>';
});
html += '</div></div>';
});
html += '</div>';
html += '<div class="makap-cta-bar">' +
icon('info') +
'<div class="makap-cta-text"><b>Two policy decisions are still open:</b> Reserved vs Pooled default allocation, and the EFRIS integration method. Dispatch and fiscalisation logic stay first-pass until Makap confirms them.</div>' +
'</div>';
root.html(html);
wire_up_clicks(root);
},
});
}
function wire_up_clicks(root) {
function go(el) {
var doctype = $(el).attr('data-doctype');
var filters = JSON.parse($(el).attr('data-filters') || '{}');
frappe.set_route('List', doctype, filters);
}
root.find('.makap-kpi-card, .makap-ops-card').on('click', function (e) {
if ($(e.target).closest('.makap-badge').length) return;
go(this);
});
root.find('.makap-badge').on('click', function (e) {
e.stopPropagation();
go(this);
});
root.find('.makap-quick-actions-btn').on('click', function (e) {
e.stopPropagation();
root.find('.makap-quick-actions-menu').toggle();
});
root.find('.makap-quick-actions-menu a').on('click', function () {
frappe.new_doc($(this).attr('data-doctype'));
});
$(document).one('click', function () {
root.find('.makap-quick-actions-menu').hide();
});
}
function build_styles() {
return '<style>' +
'.makap-banner { background: linear-gradient(120deg,#0E7C6B,#2E6BC7); border-radius: 12px; padding: 28px 26px; margin-bottom: 20px; color: #fff; }' +
'.makap-banner h2 { margin: 0 0 6px; color: #fff; font-size: 22px; }' +
'.makap-banner p { margin: 0; opacity: .9; }' +
'.makap-icon-circle { display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; border-radius: 50%; }' +
'.makap-icon-circle svg { width: 18px; height: 18px; }' +
'.makap-kpi-row { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 24px; }' +
'.makap-kpi-card, .makap-ops-card { flex: 1 1 200px; background: var(--card-bg,#fff); border: 1px solid var(--border-color,#e5e7eb); border-radius: 10px; padding: 18px; cursor: pointer; transition: box-shadow .1s ease, transform .1s ease; }' +
'.makap-kpi-card:hover, .makap-ops-card:hover { box-shadow: 0 4px 14px rgba(0,0,0,.08); transform: translateY(-2px); }' +
'.makap-kpi-top, .makap-ops-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }' +
'.makap-kpi-chevron svg { width: 16px; height: 16px; opacity: .4; }' +
'.makap-kpi-label { font-size: 12px; color: var(--text-muted,#8d99a6); margin-bottom: 4px; }' +
'.makap-kpi-value { font-size: 26px; font-weight: 700; }' +
'.makap-module-tag { font-size: 10px; font-weight: 600; letter-spacing: .03em; color: var(--text-muted,#8d99a6); background: #F1F3F6; border-radius: 12px; padding: 2px 8px; }' +
'.makap-section-header { display: flex; align-items: center; justify-content: space-between; margin: 8px 0 16px; flex-wrap: wrap; gap: 10px; }' +
'.makap-section-title-row { display: flex; align-items: center; gap: 12px; }' +
'.makap-section-title-row h3 { margin: 0; font-size: 16px; }' +
'.makap-section-title-row p { margin: 0; font-size: 12px; color: var(--text-muted,#8d99a6); }' +
'.makap-section-icon { background: #EEF2F7; color: #445; }' +
'.makap-quick-actions-wrap { position: relative; }' +
'.makap-quick-actions-btn svg { width: 14px; height: 14px; margin-right: 4px; vertical-align: -2px; }' +
'.makap-quick-actions-menu { position: absolute; right: 0; top: 34px; background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; box-shadow: 0 6px 18px rgba(0,0,0,.12); min-width: 190px; z-index: 20; overflow: hidden; }' +
'.makap-quick-actions-menu a { display: block; padding: 9px 14px; font-size: 13px; color: #333; text-decoration: none; }' +
'.makap-quick-actions-menu a:hover { background: #f5f6f8; }' +
'.makap-ops-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 20px; }' +
'@media (max-width: 900px) { .makap-ops-grid { grid-template-columns: repeat(2, 1fr); } }' +
'@media (max-width: 600px) { .makap-ops-grid { grid-template-columns: 1fr; } }' +
'.makap-ops-card h4 { margin: 0 0 4px; font-size: 14px; }' +
'.makap-ops-card p { margin: 0 0 12px; font-size: 12px; color: var(--text-muted,#8d99a6); min-height: 32px; }' +
'.makap-ops-badges { display: flex; flex-wrap: wrap; gap: 6px; }' +
'.makap-badge { font-size: 11px; background: #F1F3F6; border-radius: 20px; padding: 3px 10px; color: #445; cursor: pointer; }' +
'.makap-badge b { font-weight: 700; }' +
'.makap-cta-bar { display: flex; align-items: flex-start; gap: 10px; background: #FFF8E6; border: 1px solid #F2E4B0; border-radius: 10px; padding: 14px 16px; font-size: 13px; }' +
'.makap-cta-bar svg { width: 16px; height: 16px; flex-shrink: 0; margin-top: 2px; color: #C97B0A; }' +
'.makap-cta-text { line-height: 1.5; }' +
'</style>';
}
@@ -0,0 +1,11 @@
{
"doctype": "Page",
"name": "makap-dashboard",
"module": "makap_backend",
"title": "MAKAP Dashboard",
"standard": "Yes",
"system_page": 0,
"icon": "grid",
"restrict_to_domain": "",
"roles": []
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"doctype":"Page","name":"makap-fulfilment","module":"makap_backend","standard":"Yes","title":"MAKAP Fulfilment Control Center"}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"doctype":"Page","name":"makap-governance","module":"makap_backend","standard":"Yes","title":"MAKAP Governance & Master Data Control Center"}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
frappe.pages["makap-health"].on_page_load=function(w){const p=frappe.ui.make_app_page({parent:w,title:"Workflow Health Dashboard",single_column:true});p.main.html(`<section class="makap-gov-shell health"><header class="makap-gov-head"><div><div class="makap-gov-eyebrow">End-to-end workflow audit</div><h1>Workflow Health Dashboard</h1><p>Audit the full MAKAP chain from customer demand to operating close-out and surface records that need attention.</p></div><button class="btn btn-default btn-sm" data-r><i class="fa fa-refresh"></i> Refresh</button></header><section class="makap-gov-metrics"><div><span>Workflow stages</span><strong data-m="stages">—</strong><small>Configured DocTypes</small></div><div class="good"><span>Available stages</span><strong data-m="available">—</strong><small>Installed and visible</small></div><div class="warn"><span>Health issues</span><strong data-m="issues">—</strong><small>Records requiring attention</small></div><div><span>Audited records</span><strong data-m="records">—</strong><small>Across the workflow</small></div></section><div class="makap-health-grid"><section><div class="health-title">Workflow chain</div><div data-chain class="makap-chain"></div></section><section><div class="health-title">Issues requiring attention</div><div data-issues class="makap-issues"></div></section></div></section>`);const r=p.main[0],chain=r.querySelector("[data-chain]"),issues=r.querySelector("[data-issues]");const esc=v=>frappe.utils.escape_html(String(v==null?"":v)),num=v=>new Intl.NumberFormat().format(Number(v||0)),call=(m,a={})=>new Promise((ok,no)=>frappe.call({method:m,args:a,callback:x=>x.exc?no(x.exc):ok(x.message)}));async function load(){chain.innerHTML=`<div class="makap-gov-empty"><div class="load-ring"></div><span>Auditing workflow...</span></div>`;try{const x=await call("makap_backend.governance_api.get_workflow_health");r.querySelector('[data-m="stages"]').textContent=num(x.workflow.length);r.querySelector('[data-m="available"]').textContent=num(x.workflow.filter(a=>a.available).length);r.querySelector('[data-m="issues"]').textContent=num(x.issue_count);r.querySelector('[data-m="records"]').textContent=num(x.workflow.reduce((a,b)=>a+(b.count||0),0));chain.innerHTML=x.workflow.map((a,i)=>`<a class="chain-step ${a.available?"available":"missing"}" href="${a.route}"><span>${i+1}</span><strong>${esc(a.label)}</strong><small>${a.available?`${num(a.count)} records`:"DocType missing"}</small></a>`).join("");issues.innerHTML=x.issues.length?x.issues.map(a=>`<a class="health-issue" href="/app/${a.type.toLowerCase().replaceAll(" ","-")}/${encodeURIComponent(a.name)}"><b>${esc(a.type)}</b><strong>${esc(a.name)}</strong><span>${esc(a.issue)} · ${esc(a.detail)}</span></a>`).join(""):`<div class="makap-gov-empty"><strong>No workflow issues found</strong><span>The audited workflow is healthy for the available checks.</span></div>`}catch(e){issues.innerHTML=`<div class="makap-gov-empty"><strong>Workflow audit unavailable</strong><span>${esc(e.message||"Check permissions.")}</span></div>`}}r.querySelector("[data-r]").onclick=load;load()};
@@ -0,0 +1 @@
{"doctype":"Page","name":"makap-health","module":"makap_backend","standard":"Yes","title":"MAKAP Workflow Health Dashboard"}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"doctype":"Page","name":"makap-materials","module":"makap_backend","standard":"Yes","title":"MAKAP Materials & Inventory Control Center"}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,134 @@
frappe.pages["makap-opportunities"].on_page_load = function (wrapper) {
const page = frappe.ui.make_app_page({
parent: wrapper,
title: "Opportunities",
single_column: true,
});
page.main.html(`
<section class="makap-opps-shell">
<header class="makap-opps-head">
<div>
<div class="makap-opps-eyebrow">Customer & commercial</div>
<h1>Opportunities</h1>
<p>Move qualified customer demand from first conversation to a committed quotation.</p>
</div>
<div class="makap-opps-actions">
<button class="btn btn-default btn-sm" data-action="refresh"><i class="fa fa-refresh"></i> Refresh</button>
<button class="btn btn-primary btn-sm" data-action="new"><i class="fa fa-plus"></i> New Opportunity</button>
</div>
</header>
<section class="makap-opps-metrics" data-metrics>
<div class="makap-opps-metric"><span>Active pipeline</span><strong data-metric="active_count">—</strong><small>Open commercial opportunities</small></div>
<div class="makap-opps-metric"><span>Pipeline value</span><strong data-metric="pipeline_value">—</strong><small>Current opportunity amount</small></div>
<div class="makap-opps-metric"><span>Weighted value</span><strong data-metric="weighted_value">—</strong><small>Probability-adjusted outlook</small></div>
<div class="makap-opps-metric makap-opps-metric-accent"><span>Quotation stage</span><strong data-metric="quotation_count">—</strong><small>Ready for commercial follow-up</small></div>
</section>
<div class="makap-opps-workspace">
<aside class="makap-opps-list-panel">
<div class="makap-opps-list-head">
<div class="makap-opps-list-title"><strong>Pipeline directory</strong><span data-count>0 opportunities</span></div>
<div class="makap-opps-filters">
<div class="makap-opps-search"><i class="fa fa-search"></i><input type="search" placeholder="Search customer or stage..." data-search></div>
<select data-status aria-label="Filter by status"><option>All</option><option>Open</option><option>Quotation</option><option>Converted</option><option>Lost</option><option>Replied</option><option>Closed</option></select>
</div>
</div>
<div class="makap-opps-list" data-list><div class="makap-opps-empty">Loading opportunities...</div></div>
</aside>
<article class="makap-opps-detail" data-detail>
<div class="makap-opps-empty makap-opps-detail-empty"><div class="makap-opps-empty-icon"><i class="fa fa-line-chart"></i></div><strong>Select an opportunity</strong><span>Choose a pipeline record to inspect value, stage, customer, and next commercial action.</span></div>
</article>
</div>
</section>
`);
const root = page.main[0];
const list = root.querySelector("[data-list]");
const detail = root.querySelector("[data-detail]");
const search = root.querySelector("[data-search]");
const status = root.querySelector("[data-status]");
const count = root.querySelector("[data-count]");
let activeName = null;
let searchTimer = null;
const esc = (value) => frappe.utils.escape_html(String(value == null ? "" : value));
const number = (value) => new Intl.NumberFormat().format(Number(value || 0));
const money = (value) => Number(value || 0).toLocaleString(undefined, {maximumFractionDigits: 0});
const date = (value) => value ? frappe.datetime.str_to_user(value) : "Not set";
const initials = (value) => (String(value || "?").trim().split(/\\s+/).slice(0, 2).map((part) => part[0]).join("") || "?").toUpperCase();
const call = (method, args = {}) => new Promise((resolve, reject) => frappe.call({method, args, callback: (r) => r.exc ? reject(r.exc) : resolve(r.message)}));
const statusTone = (value) => ({Open: "open", Quotation: "quotation", Converted: "converted", Lost: "lost", Replied: "replied", Closed: "closed"}[value] || "open");
function renderMetrics(summary) {
root.querySelector('[data-metric="active_count"]').textContent = number(summary.active_count);
root.querySelector('[data-metric="pipeline_value"]').textContent = money(summary.pipeline_value);
root.querySelector('[data-metric="weighted_value"]').textContent = money(summary.weighted_value);
root.querySelector('[data-metric="quotation_count"]').textContent = number(summary.quotation_count);
}
function renderList(rows) {
count.textContent = `${rows.length} opportunit${rows.length === 1 ? "y" : "ies"}`;
if (!rows.length) {
list.innerHTML = `<div class="makap-opps-empty"><div class="makap-opps-empty-icon"><i class="fa fa-search"></i></div><strong>No opportunities found</strong><span>Try a different customer, stage, or status.</span></div>`;
return;
}
list.innerHTML = rows.map((row) => `
<button class="makap-opp-row ${row.name === activeName ? "is-active" : ""}" data-name="${esc(row.name)}">
<span class="makap-opp-avatar">${esc(initials(row.party_name || row.name))}</span>
<span class="makap-opp-row-copy"><strong>${esc(row.party_name || "Unassigned customer")}</strong><span>${esc(row.sales_stage || row.opportunity_type || "Opportunity")}</span><small>${esc(row.name)} · ${money(row.opportunity_amount)}</small></span>
<span class="makap-opp-row-side"><b class="makap-opp-status ${statusTone(row.status)}">${esc(row.status || "Open")}</b><i class="fa fa-angle-right"></i></span>
</button>
`).join("");
list.querySelectorAll("[data-name]").forEach((el) => el.addEventListener("click", () => selectOpportunity(el.dataset.name)));
}
function renderDetail(opportunity) {
activeName = opportunity.name;
const probability = Math.max(0, Math.min(100, Number(opportunity.probability || 0)));
const itemMarkup = opportunity.items?.length ? opportunity.items.map((item) => `<div class="makap-opp-item"><span><strong>${esc(item.item_name || item.item_code || "Item")}</strong><small>${esc(item.item_code || "")}</small></span><b>${esc(item.qty || 0)} ${esc(item.uom || "")}</b></div>`).join("") : `<div class="makap-opp-note"><i class="fa fa-info-circle"></i>No products or services added yet.</div>`;
const linkedContact = opportunity.contact_person ? `<a href="/app/contact/${encodeURIComponent(opportunity.contact_person)}">${esc(opportunity.contact_person)}</a>` : "Not linked";
detail.innerHTML = `
<div class="makap-opp-detail-hero">
<div class="makap-opp-detail-avatar">${esc(initials(opportunity.party_name || opportunity.name))}</div>
<div class="makap-opp-detail-identity"><span class="makap-opp-status ${statusTone(opportunity.status)}">${esc(opportunity.status || "Open")}</span><h2>${esc(opportunity.party_name || "Unassigned customer")}</h2><p>${esc(opportunity.name)}${opportunity.opportunity_type ? ` · ${esc(opportunity.opportunity_type)}` : ""}</p></div>
<a class="btn btn-default btn-xs" href="/app/opportunity/${encodeURIComponent(opportunity.name)}">Open ERPNext form</a>
</div>
<div class="makap-opp-detail-body">
<section class="makap-opp-value-card"><div><span>Opportunity value</span><strong>${money(opportunity.opportunity_amount)}</strong><small>Weighted forecast: ${money(Number(opportunity.opportunity_amount || 0) * probability / 100)}</small></div><div class="makap-probability"><div class="makap-probability-ring" style="--progress:${probability * 3.6}deg"><span>${probability}%</span></div><small>Probability</small></div></section>
<section class="makap-opp-detail-section"><div class="makap-opp-section-title">Commercial timeline</div><div class="makap-opp-timeline"><div><span>Created / opened</span><strong>${date(opportunity.transaction_date)}</strong></div><div><span>Expected close</span><strong>${date(opportunity.expected_closing_date)}</strong></div><div><span>Sales stage</span><strong>${esc(opportunity.sales_stage || "Not set")}</strong></div></div></section>
<section class="makap-opp-detail-section"><div class="makap-opp-section-title">Customer and contact</div><div class="makap-opp-fields"><div><span>Customer</span><strong>${opportunity.party_name ? `<a href="/app/customer/${encodeURIComponent(opportunity.party_name)}">${esc(opportunity.party_name)}</a>` : "Not linked"}</strong></div><div><span>Contact</span><strong>${linkedContact}</strong></div><div><span>Territory</span><strong>${esc(opportunity.territory || "Not set")}</strong></div><div><span>Owner</span><strong>${esc(opportunity.owner || "Not set")}</strong></div></div></section>
<section class="makap-opp-detail-section"><div class="makap-opp-section-title">Products or services</div>${itemMarkup}</section>
${opportunity.remarks ? `<section class="makap-opp-detail-section"><div class="makap-opp-section-title">Remarks</div><p class="makap-opp-remarks">${esc(opportunity.remarks)}</p></section>` : ""}
</div>
`;
root.querySelectorAll(".makap-opp-row").forEach((el) => el.classList.toggle("is-active", el.dataset.name === activeName));
}
async function selectOpportunity(name) {
activeName = name;
root.querySelectorAll(".makap-opp-row").forEach((el) => el.classList.toggle("is-active", el.dataset.name === name));
detail.innerHTML = `<div class="makap-opps-empty makap-opps-detail-empty"><div class="makap-loading-ring"></div><strong>Loading opportunity</strong><span>Fetching the latest pipeline details.</span></div>`;
try { renderDetail(await call("makap_backend.opportunity_api.get_opportunity", {name})); } catch (error) { detail.innerHTML = `<div class="makap-opps-empty makap-opps-detail-empty"><strong>Could not load opportunity</strong><span>${esc(error.message || "Please try again.")}</span></div>`; }
}
async function loadWorkspace() {
list.innerHTML = `<div class="makap-opps-empty"><div class="makap-loading-ring"></div><span>Loading opportunities...</span></div>`;
try {
const [summary, rows] = await Promise.all([
call("makap_backend.opportunity_api.get_opportunity_summary"),
call("makap_backend.opportunity_api.list_opportunities", {search: search.value, status: status.value, limit_page_length: 100}),
]);
renderMetrics(summary || {}); renderList(rows || []);
if (!activeName && rows?.[0]) selectOpportunity(rows[0].name);
else if (activeName && rows?.some((row) => row.name === activeName)) selectOpportunity(activeName);
} catch (error) { list.innerHTML = `<div class="makap-opps-empty"><strong>Opportunities unavailable</strong><span>${esc(error.message || "Check your permissions and try again.")}</span></div>`; }
}
search.addEventListener("input", () => { clearTimeout(searchTimer); searchTimer = setTimeout(loadWorkspace, 250); });
status.addEventListener("change", loadWorkspace);
root.querySelector('[data-action="refresh"]').addEventListener("click", loadWorkspace);
root.querySelector('[data-action="new"]').addEventListener("click", () => frappe.new_doc("Opportunity"));
loadWorkspace();
};
@@ -0,0 +1,7 @@
{
"doctype": "Page",
"name": "makap-opportunities",
"module": "makap_backend",
"standard": "Yes",
"title": "MAKAP Opportunities"
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,34 @@
frappe.pages["makap-production"].on_page_load = function (wrapper) {
const page = frappe.ui.make_app_page({ parent: wrapper, title: "Production Control Center", single_column: true });
page.main.html(`
<section class="makap-prod-shell"><header class="makap-prod-head"><div><div class="makap-prod-eyebrow">Production & planning</div><h1>Production Control Center</h1><p>Release the right work with technical, material, and process readiness visible at a glance.</p></div><div class="makap-prod-actions"><button class="btn btn-default btn-sm" data-action="refresh"><i class="fa fa-refresh"></i> Refresh</button><button class="btn btn-primary btn-sm" data-action="new"><i class="fa fa-plus"></i> <span data-new-label>New Work Order</span></button></div></header>
<section class="makap-prod-metrics"><div><span>Active Work Orders</span><strong data-metric="active_work_orders">—</strong><small>Current execution load</small></div><div><span>Ready to release</span><strong data-metric="ready_to_release">—</strong><small>All applicable checks ready</small></div><div class="makap-prod-alert"><span>Blocked Work Orders</span><strong data-metric="blocked_work_orders">—</strong><small>Needs technical or supply action</small></div><div><span>Due or overdue</span><strong data-metric="due_or_overdue">—</strong><small>Schedule pressure</small></div></section>
<nav class="makap-prod-tabs"><button data-tab="work-orders"><i class="fa fa-cogs"></i> Work Orders</button><button data-tab="plans"><i class="fa fa-calendar"></i> Production Plans</button><button data-tab="operations"><i class="fa fa-wrench"></i> Operations</button></nav>
<div class="makap-prod-workspace"><aside class="makap-prod-list-panel"><div class="makap-prod-list-head"><div><strong data-list-title>Work Order queue</strong><span data-count>0 records</span></div><div class="makap-prod-search"><i class="fa fa-search"></i><input type="search" placeholder="Search work orders..." data-search></div><select data-filter aria-label="Filter production records"><option>All</option><option>Ready</option><option>Blocked</option><option>Not Checked</option><option>Not Started</option><option>In Process</option><option>Stopped</option><option>Completed</option></select></div><div class="makap-prod-list" data-list><div class="makap-prod-empty">Loading records...</div></div></aside><article class="makap-prod-detail" data-detail><div class="makap-prod-empty makap-prod-detail-empty"><div class="makap-prod-empty-icon"><i class="fa fa-cogs"></i></div><strong>Select a production record</strong><span>Choose a Work Order, Production Plan, or Operation to inspect its readiness and execution context.</span></div></article></div></section>
`);
const root = page.main[0], list = root.querySelector("[data-list]"), detail = root.querySelector("[data-detail]"), search = root.querySelector("[data-search]"), filter = root.querySelector("[data-filter]"), count = root.querySelector("[data-count]"), title = root.querySelector("[data-list-title]"), newLabel = root.querySelector("[data-new-label]");
let activeTab = new URLSearchParams(window.location.search).get("tab") || "work-orders", activeName = null, timer = null;
const esc = (v) => frappe.utils.escape_html(String(v == null ? "" : v));
const number = (v) => new Intl.NumberFormat().format(Number(v || 0));
const money = (v) => Number(v || 0).toLocaleString(undefined, {maximumFractionDigits: 0});
const date = (v) => v ? frappe.datetime.str_to_user(v) : "Not set";
const initials = (v) => (String(v || "?").trim().split(/\\s+/).slice(0, 2).map((x) => x[0]).join("") || "?").toUpperCase();
const call = (method, args = {}) => new Promise((resolve, reject) => frappe.call({method, args, callback: (r) => r.exc ? reject(r.exc) : resolve(r.message)}));
const workTone = (v) => ({Blocked: "blocked", Ready: "ready", "Not Checked": "unchecked", "Not Started": "planned", "In Process": "progress", Stopped: "blocked", Completed: "complete", Cancelled: "cancelled"}[v] || "planned");
const tabConfig = {"work-orders": ["Work Order queue", "New Work Order", "Search work orders..."], plans: ["Production Plan register", "New Production Plan", "Search plans..."], operations: ["Operation directory", "New Operation", "Search operations..."]};
function renderMetrics(s) { ["active_work_orders", "ready_to_release", "blocked_work_orders", "due_or_overdue"].forEach((k) => root.querySelector(`[data-metric="${k}"]`).textContent = number(s[k])); }
function setTab(tab) { activeTab = tab; activeName = null; root.querySelectorAll("[data-tab]").forEach((el) => el.classList.toggle("is-active", el.dataset.tab === tab)); const cfg = tabConfig[tab]; title.textContent = cfg[0]; newLabel.textContent = cfg[1]; search.placeholder = cfg[2]; filter.innerHTML = tab === "work-orders" ? `<option>All</option><option>Ready</option><option>Blocked</option><option>Not Checked</option><option>Not Started</option><option>In Process</option><option>Stopped</option><option>Completed</option>` : `<option>All</option>`; loadRecords(); }
function rowShell(active, name, avatar, headline, sub, meta, badge, badgeClass) { return `<button class="makap-prod-row ${active ? "is-active" : ""}" data-name="${esc(name)}"><span class="makap-prod-avatar ${badgeClass}">${esc(avatar)}</span><span class="makap-prod-row-copy"><strong>${esc(headline)}</strong><span>${esc(sub)}</span><small>${esc(meta)}</small></span>${badge ? `<b class="makap-prod-badge ${badgeClass}">${esc(badge)}</b>` : `<i class="fa fa-angle-right makap-prod-chevron"></i>`}</button>`; }
function renderWorkOrders(rows) { count.textContent = `${rows.length} work order${rows.length === 1 ? "" : "s"}`; list.innerHTML = rows.length ? rows.map((r) => rowShell(r.name === activeName, r.name, initials(r.production_item || r.name), r.name, r.customer || r.production_item || "No customer", `${r.item_name || r.production_item || "No item"} · ${r.planned_end_date ? `Due ${date(r.planned_end_date)}` : "No due date"}`, r.makap_derived_readiness || r.status, workTone(r.makap_derived_readiness || r.status))).join("") : `<div class="makap-prod-empty"><strong>No Work Orders found</strong><span>Try a different search or readiness filter.</span></div>`; list.querySelectorAll("[data-name]").forEach((e) => e.addEventListener("click", () => selectRecord(e.dataset.name))); }
function renderPlans(rows) { count.textContent = `${rows.length} production plan${rows.length === 1 ? "" : "s"}`; list.innerHTML = rows.length ? rows.map((r) => rowShell(r.name === activeName, r.name, initials(r.name), r.name, r.company || "No company", `${r.posting_date ? date(r.posting_date) : "No posting date"} · ${r.status || "Draft"}`, r.status || "Draft", workTone(r.status))).join("") : `<div class="makap-prod-empty"><strong>No Production Plans found</strong><span>Try a different search or status.</span></div>`; list.querySelectorAll("[data-name]").forEach((e) => e.addEventListener("click", () => selectRecord(e.dataset.name))); }
function renderOperations(rows) { count.textContent = `${rows.length} operation${rows.length === 1 ? "" : "s"}`; list.innerHTML = rows.length ? rows.map((r) => rowShell(r.name === activeName, r.name, initials(r.operation || r.name), r.operation || r.name, r.makap_process_type || "Process type not set", `${r.workstation || "No workstation"} · ${r.time_in_mins || 0} mins`, r.disabled ? "Disabled" : "Active", r.disabled ? "blocked" : "ready")).join("") : `<div class="makap-prod-empty"><strong>No Operations found</strong><span>Try a different search term.</span></div>`; list.querySelectorAll("[data-name]").forEach((e) => e.addEventListener("click", () => selectRecord(e.dataset.name))); }
function detailHeader(avatar, kicker, headline, sub, href, badge, cls) { return `<div class="makap-prod-detail-hero"><div class="makap-prod-detail-avatar ${cls || ""}">${esc(avatar)}</div><div class="makap-prod-detail-identity"><span class="makap-prod-kicker">${esc(kicker)}</span><h2>${esc(headline)}</h2><p>${esc(sub)}</p></div><span class="makap-prod-detail-actions"><a class="btn btn-default btn-xs" href="${href}">Open ERPNext form</a></span></div>`; }
function renderWorkOrder(o) { activeName = o.name; const readiness = o.makap_derived_readiness || "Not Checked", produced = Number(o.produced_qty || 0), qty = Number(o.qty || 0), completion = qty ? Math.min(100, Math.round(produced / qty * 100)) : 0; const blockers = [o.makap_manufacturing_readiness_blockers, o.makap_material_availability_blocker_summary].filter(Boolean).join(" · "); const ops = o.operations?.length ? o.operations.map((x) => `<div class="makap-prod-mini-row"><span><strong>${esc(x.operation || "Operation")}</strong><small>${esc(x.workstation || "No workstation")}</small></span><b>${esc(x.status || "Pending")}</b></div>`).join("") : `<div class="makap-prod-note">No operation rows available.</div>`; detail.innerHTML = detailHeader(initials(o.production_item || o.name), "Work Order", o.name, `${o.customer || "No customer"} · ${o.production_item || "No production item"}`, `/app/work-order/${encodeURIComponent(o.name)}`, readiness, workTone(readiness)) + `<div class="makap-prod-detail-body"><section class="makap-prod-readiness-card ${workTone(readiness)}"><div><span>Manufacturing readiness</span><strong>${esc(readiness)}</strong><small>${esc(blockers || (readiness === "Ready" ? "All applicable checks are ready." : "Readiness review is required."))}</small></div><i class="fa fa-${readiness === "Ready" ? "check-circle" : readiness === "Blocked" ? "exclamation-triangle" : "clock-o"}"></i></section><section class="makap-prod-detail-section"><div class="makap-prod-section-title">Demand and traceability</div><div class="makap-prod-fields"><div><span>Customer</span><strong>${o.customer ? `<a href="/app/customer/${encodeURIComponent(o.customer)}">${esc(o.customer)}</a>` : "Not linked"}</strong></div><div><span>Sales Order</span><strong>${o.sales_order ? `<a href="/app/sales-order/${encodeURIComponent(o.sales_order)}">${esc(o.sales_order)}</a>` : "Not linked"}</strong></div><div><span>Packaging Specification</span><strong>${o.makap_packaging_specification ? `<a href="/app/packaging-specification/${encodeURIComponent(o.makap_packaging_specification)}">${esc(o.makap_packaging_specification)}</a>` : "Not linked"}</strong></div><div><span>Specification revision</span><strong>${esc(o.makap_specification_revision || "Not set")}</strong></div></div></section><section class="makap-prod-detail-section"><div class="makap-prod-section-title">Readiness dimensions</div><div class="makap-prod-check-grid"><div><span>Manufacturing</span><b>${esc(o.makap_manufacturing_readiness_status || "Not Checked")}</b></div><div><span>BOM compatibility</span><b>${esc(o.makap_bom_compatibility_status || "Not Checked")}</b></div><div><span>Process compatibility</span><b>${esc(o.makap_process_compatibility_status || "Not Checked")}</b></div><div><span>Tooling</span><b>${esc(o.makap_tooling_readiness_status || "Not Checked")}</b></div><div><span>Materials</span><b>${esc(o.makap_material_availability_status || "Not Checked")}</b></div><div><span>Material close</span><b>${esc(o.makap_material_close_status || "Open")}</b></div></div></section><section class="makap-prod-detail-section"><div class="makap-prod-section-title">Execution progress</div><div class="makap-prod-progress-label"><span>${esc(o.status || "Not Started")}</span><b>${completion}%</b></div><div class="makap-prod-progress"><i style="width:${completion}%"></i></div><div class="makap-prod-timeline"><div><span>Planned start</span><strong>${date(o.planned_start_date)}</strong></div><div><span>Planned end</span><strong>${date(o.planned_end_date)}</strong></div><div><span>Quantity</span><strong>${money(o.qty)} / ${money(o.produced_qty)}</strong></div></div></section><section class="makap-prod-detail-section"><div class="makap-prod-section-title">Operations</div>${ops}</section></div>`; root.querySelectorAll(".makap-prod-row").forEach((e) => e.classList.toggle("is-active", e.dataset.name === activeName)); }
function renderPlan(p) { activeName = p.name; const items = p.plan_items?.length ? p.plan_items.map((x) => `<div class="makap-prod-mini-row"><span><strong>${esc(x.item_name || x.item_code || "Item")}</strong><small>${esc(x.item_code || "")} · ${esc(x.coverage_mode || "Coverage not set")}</small></span><b>${money(x.planned_qty)}</b></div>`).join("") : `<div class="makap-prod-note">No plan items found.</div>`; detail.innerHTML = detailHeader(initials(p.name), "Production Plan", p.name, `${p.company || "No company"} · ${p.status || "Draft"}`, `/app/production-plan/${encodeURIComponent(p.name)}`, p.status || "Draft", workTone(p.status)) + `<div class="makap-prod-detail-body"><section class="makap-prod-readiness-card ready"><div><span>Planning status</span><strong>${esc(p.status || "Draft")}</strong><small>${esc(p.get_items_from || "Demand source not set")}</small></div><i class="fa fa-calendar"></i></section><section class="makap-prod-detail-section"><div class="makap-prod-section-title">Plan context</div><div class="makap-prod-fields"><div><span>Posting date</span><strong>${date(p.posting_date)}</strong></div><div><span>Company</span><strong>${esc(p.company || "Not set")}</strong></div><div><span>Demand source</span><strong>${esc(p.get_items_from || "Not set")}</strong></div><div><span>Sales Order</span><strong>${esc(p.sales_order || "Not set")}</strong></div></div></section><section class="makap-prod-detail-section"><div class="makap-prod-section-title">Planned items</div>${items}</section></div>`; root.querySelectorAll(".makap-prod-row").forEach((e) => e.classList.toggle("is-active", e.dataset.name === activeName)); }
function renderOperation(o) { activeName = o.name; detail.innerHTML = detailHeader(initials(o.operation || o.name), "Operation", o.operation || o.name, `${o.makap_process_type || "Process type not set"} · ${o.workstation || "No workstation"}`, `/app/operation/${encodeURIComponent(o.name)}`, o.disabled ? "Disabled" : "Active", o.disabled ? "blocked" : "ready") + `<div class="makap-prod-detail-body"><section class="makap-prod-readiness-card ${o.disabled ? "blocked" : "ready"}"><div><span>Process classification</span><strong>${esc(o.makap_process_type || "Not classified")}</strong><small>${esc(o.description || "No operation description")}</small></div><i class="fa fa-wrench"></i></section><section class="makap-prod-detail-section"><div class="makap-prod-section-title">Operation parameters</div><div class="makap-prod-fields"><div><span>Workstation</span><strong>${esc(o.workstation || "Not set")}</strong></div><div><span>Time in minutes</span><strong>${esc(o.time_in_mins || "Not set")}</strong></div><div><span>Batch size</span><strong>${esc(o.batch_size || "Not set")}</strong></div><div><span>Hour rate</span><strong>${money(o.hour_rate)}</strong></div></div></section></div>`; root.querySelectorAll(".makap-prod-row").forEach((e) => e.classList.toggle("is-active", e.dataset.name === activeName)); }
async function selectRecord(name) { activeName = name; detail.innerHTML = `<div class="makap-prod-empty makap-prod-detail-empty"><div class="makap-loading-ring"></div><strong>Loading record</strong><span>Fetching the latest production details.</span></div>`; try { if (activeTab === "work-orders") renderWorkOrder(await call("makap_backend.production_api.get_work_order", {name})); if (activeTab === "plans") renderPlan(await call("makap_backend.production_api.get_production_plan", {name})); if (activeTab === "operations") renderOperation(await call("makap_backend.production_api.get_operation", {name})); } catch (e) { detail.innerHTML = `<div class="makap-prod-empty makap-prod-detail-empty"><strong>Could not load record</strong><span>${esc(e.message || "Please try again.")}</span></div>`; } }
async function loadRecords() { list.innerHTML = `<div class="makap-prod-empty"><div class="makap-loading-ring"></div><span>Loading records...</span></div>`; try { if (activeTab === "work-orders") { const rows = await call("makap_backend.production_api.list_work_orders", {search: search.value, status: ["Not Started", "In Process", "Stopped", "Completed", "Cancelled"].includes(filter.value) ? filter.value : "", readiness: ["Ready", "Blocked", "Not Checked"].includes(filter.value) ? filter.value : ""}); renderWorkOrders(rows || []); if (!activeName && rows?.[0]) selectRecord(rows[0].name); } if (activeTab === "plans") { const rows = await call("makap_backend.production_api.list_production_plans", {search: search.value, status: filter.value}); renderPlans(rows || []); if (!activeName && rows?.[0]) selectRecord(rows[0].name); } if (activeTab === "operations") { const rows = await call("makap_backend.production_api.list_operations", {search: search.value}); renderOperations(rows || []); if (!activeName && rows?.[0]) selectRecord(rows[0].name); } } catch (e) { list.innerHTML = `<div class="makap-prod-empty"><strong>Production records unavailable</strong><span>${esc(e.message || "Check your permissions and try again.")}</span></div>`; } }
root.querySelectorAll("[data-tab]").forEach((el) => el.addEventListener("click", () => setTab(el.dataset.tab)));
search.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(loadRecords, 250); }); filter.addEventListener("change", loadRecords); root.querySelector('[data-action="refresh"]').addEventListener("click", async () => { renderMetrics(await call("makap_backend.production_api.get_production_summary")); loadRecords(); }); root.querySelector('[data-action="new"]').addEventListener("click", () => frappe.new_doc(activeTab === "work-orders" ? "Work Order" : activeTab === "plans" ? "Production Plan" : "Operation"));
(async () => { try { renderMetrics(await call("makap_backend.production_api.get_production_summary")); } catch (e) {} setTab(activeTab); })();
};
@@ -0,0 +1,7 @@
{
"doctype": "Page",
"name": "makap-production",
"module": "makap_backend",
"standard": "Yes",
"title": "MAKAP Production Control Center"
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"doctype":"Page","name":"makap-quality","module":"makap_backend","standard":"Yes","title":"MAKAP Quality Control Center"}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,40 @@
frappe.pages["makap-quotations"].on_page_load = function (wrapper) {
const page = frappe.ui.make_app_page({ parent: wrapper, title: "Quotations", single_column: true });
page.main.html(`
<section class="makap-quotes-shell">
<header class="makap-quotes-head"><div><div class="makap-quotes-eyebrow">Customer & commercial</div><h1>Quotations</h1><p>Prepare, track, and follow up on commercial offers before they become sales orders.</p></div><div class="makap-quotes-actions"><button class="btn btn-default btn-sm" data-action="refresh"><i class="fa fa-refresh"></i> Refresh</button><button class="btn btn-primary btn-sm" data-action="new"><i class="fa fa-plus"></i> New Quotation</button></div></header>
<section class="makap-quotes-metrics"><div class="makap-quotes-metric"><span>Active quotations</span><strong data-metric="active_count">—</strong><small>Draft, open, or partially ordered</small></div><div class="makap-quotes-metric"><span>Active value</span><strong data-metric="active_value">—</strong><small>Current commercial offer value</small></div><div class="makap-quotes-metric makap-quotes-alert"><span>Expiring soon</span><strong data-metric="expiring_count">—</strong><small>Due within the next 7 days</small></div><div class="makap-quotes-metric"><span>Accepted</span><strong data-metric="accepted_count">—</strong><small>Ready to convert or fulfil</small></div></section>
<div class="makap-quotes-workspace"><aside class="makap-quotes-list-panel"><div class="makap-quotes-list-head"><div><strong>Quotation register</strong><span data-count>0 quotations</span></div><div class="makap-quotes-filters"><div class="makap-quotes-search"><i class="fa fa-search"></i><input type="search" placeholder="Search customer or quote..." data-search></div><select data-status aria-label="Filter by quotation status"><option>All</option><option>Draft</option><option>Open</option><option>Replied</option><option>Partially Ordered</option><option>Ordered</option><option>Lost</option><option>Expired</option><option>Cancelled</option><option>Accepted</option></select></div></div><div class="makap-quotes-list" data-list><div class="makap-quotes-empty">Loading quotations...</div></div></aside><article class="makap-quotes-detail" data-detail><div class="makap-quotes-empty makap-quotes-detail-empty"><div class="makap-quotes-empty-icon"><i class="fa fa-file-text-o"></i></div><strong>Select a quotation</strong><span>Choose a quote to inspect its customer, validity, value, and item breakdown.</span></div></article></div>
</section>
`);
const root = page.main[0], list = root.querySelector("[data-list]"), detail = root.querySelector("[data-detail]"), search = root.querySelector("[data-search]"), status = root.querySelector("[data-status]"), count = root.querySelector("[data-count]");
let activeName = null, searchTimer = null;
const esc = (v) => frappe.utils.escape_html(String(v == null ? "" : v));
const number = (v) => new Intl.NumberFormat().format(Number(v || 0));
const money = (v) => Number(v || 0).toLocaleString(undefined, { maximumFractionDigits: 0 });
const date = (v) => v ? frappe.datetime.str_to_user(v) : "Not set";
const initials = (v) => (String(v || "?").trim().split(/\\s+/).slice(0, 2).map((x) => x[0]).join("") || "?").toUpperCase();
const call = (method, args = {}) => new Promise((resolve, reject) => frappe.call({ method, args, callback: (r) => r.exc ? reject(r.exc) : resolve(r.message) }));
const tone = (v) => ({Draft: "draft", Open: "open", Replied: "replied", "Partially Ordered": "partial", Ordered: "ordered", Accepted: "accepted", Lost: "lost", Expired: "expired", Cancelled: "cancelled"}[v] || "draft");
function renderMetrics(s) { ["active_count", "active_value", "expiring_count", "accepted_count"].forEach((key) => { const el = root.querySelector(`[data-metric="${key}"]`); el.textContent = key.includes("value") ? money(s[key]) : number(s[key]); }); }
function renderList(rows) {
count.textContent = `${rows.length} quotation${rows.length === 1 ? "" : "s"}`;
if (!rows.length) { list.innerHTML = `<div class="makap-quotes-empty"><div class="makap-quotes-empty-icon"><i class="fa fa-search"></i></div><strong>No quotations found</strong><span>Try a different customer, quote number, or status.</span></div>`; return; }
list.innerHTML = rows.map((r) => `<button class="makap-quote-row ${r.name === activeName ? "is-active" : ""}" data-name="${esc(r.name)}"><span class="makap-quote-avatar">${esc(initials(r.customer_name || r.party_name || r.name))}</span><span class="makap-quote-row-copy"><strong>${esc(r.customer_name || r.party_name || "Unassigned customer")}</strong><span>${esc(r.name)} · ${money(r.grand_total)} ${esc(r.currency || "")}</span><small>${esc(r.valid_till ? `Valid until ${date(r.valid_till)}` : "No validity date")}</small></span><span class="makap-quote-row-side"><b class="makap-quote-status ${tone(r.status)}">${esc(r.status || "Draft")}</b><i class="fa fa-angle-right"></i></span></button>`).join("");
list.querySelectorAll("[data-name]").forEach((el) => el.addEventListener("click", () => selectQuotation(el.dataset.name)));
}
function renderDetail(q) {
activeName = q.name;
const itemMarkup = q.items?.length ? q.items.map((i) => `<div class="makap-quote-item"><span><strong>${esc(i.item_name || i.item_code || "Item")}</strong><small>${esc(i.item_code || "")} · ${esc(i.qty || 0)} ${esc(i.uom || "")}</small></span><b>${money(i.amount)}</b></div>`).join("") : `<div class="makap-quote-note"><i class="fa fa-info-circle"></i>No items added yet.</div>`;
detail.innerHTML = `<div class="makap-quote-detail-hero"><div class="makap-quote-detail-avatar">${esc(initials(q.customer_name || q.party_name || q.name))}</div><div class="makap-quote-detail-identity"><span class="makap-quote-status ${tone(q.status)}">${esc(q.status || "Draft")}</span><h2>${esc(q.customer_name || q.party_name || "Unassigned customer")}</h2><p>${esc(q.name)}${q.order_type ? ` · ${esc(q.order_type)}` : ""}</p></div><a class="btn btn-default btn-xs" href="/app/quotation/${encodeURIComponent(q.name)}">Open ERPNext form</a></div><div class="makap-quote-detail-body"><section class="makap-quote-value-card"><div><span>Total quotation value</span><strong>${money(q.grand_total)} <small>${esc(q.currency || "")}</small></strong><p>Net total ${money(q.net_total)} · Taxes ${money(q.total_taxes_and_charges)}</p></div><div class="makap-quote-validity"><i class="fa fa-calendar"></i><span>Valid until</span><strong>${date(q.valid_till)}</strong></div></section><section class="makap-quote-detail-section"><div class="makap-quote-section-title">Quotation timeline</div><div class="makap-quote-timeline"><div><span>Quotation date</span><strong>${date(q.transaction_date)}</strong></div><div><span>Validity</span><strong>${date(q.valid_till)}</strong></div><div><span>Owner</span><strong>${esc(q.owner || "Not set")}</strong></div></div></section><section class="makap-quote-detail-section"><div class="makap-quote-section-title">Customer and contact</div><div class="makap-quote-fields"><div><span>Customer</span><strong>${q.party_name ? `<a href="/app/customer/${encodeURIComponent(q.party_name)}">${esc(q.customer_name || q.party_name)}</a>` : "Not linked"}</strong></div><div><span>Contact</span><strong>${q.contact_person ? `<a href="/app/contact/${encodeURIComponent(q.contact_person)}">${esc(q.contact_person)}</a>` : "Not linked"}</strong></div><div><span>Email</span><strong>${esc(q.contact_email || "Not provided")}</strong></div><div><span>Territory</span><strong>${esc(q.territory || "Not set")}</strong></div></div></section><section class="makap-quote-detail-section"><div class="makap-quote-section-title">Quoted items</div>${itemMarkup}</section>${q.terms ? `<section class="makap-quote-detail-section"><div class="makap-quote-section-title">Terms and conditions</div><p class="makap-quote-remarks">${esc(q.terms)}</p></section>` : ""}</div>`;
root.querySelectorAll(".makap-quote-row").forEach((el) => el.classList.toggle("is-active", el.dataset.name === activeName));
}
async function selectQuotation(name) { activeName = name; root.querySelectorAll(".makap-quote-row").forEach((el) => el.classList.toggle("is-active", el.dataset.name === name)); detail.innerHTML = `<div class="makap-quotes-empty makap-quotes-detail-empty"><div class="makap-loading-ring"></div><strong>Loading quotation</strong><span>Fetching the latest commercial details.</span></div>`; try { renderDetail(await call("makap_backend.quotation_api.get_quotation", { name })); } catch (e) { detail.innerHTML = `<div class="makap-quotes-empty makap-quotes-detail-empty"><strong>Could not load quotation</strong><span>${esc(e.message || "Please try again.")}</span></div>`; } }
async function loadWorkspace() { list.innerHTML = `<div class="makap-quotes-empty"><div class="makap-loading-ring"></div><span>Loading quotations...</span></div>`; try { const [summary, rows] = await Promise.all([call("makap_backend.quotation_api.get_quotation_summary"), call("makap_backend.quotation_api.list_quotations", { search: search.value, status: status.value, limit_page_length: 100 })]); renderMetrics(summary || {}); renderList(rows || []); if (!activeName && rows?.[0]) selectQuotation(rows[0].name); else if (activeName && rows?.some((r) => r.name === activeName)) selectQuotation(activeName); } catch (e) { list.innerHTML = `<div class="makap-quotes-empty"><strong>Quotations unavailable</strong><span>${esc(e.message || "Check your permissions and try again.")}</span></div>`; } }
search.addEventListener("input", () => { clearTimeout(searchTimer); searchTimer = setTimeout(loadWorkspace, 250); });
status.addEventListener("change", loadWorkspace);
root.querySelector('[data-action="refresh"]').addEventListener("click", loadWorkspace);
root.querySelector('[data-action="new"]').addEventListener("click", () => frappe.new_doc("Quotation"));
loadWorkspace();
};
@@ -0,0 +1,7 @@
{
"doctype": "Page",
"name": "makap-quotations",
"module": "makap_backend",
"standard": "Yes",
"title": "MAKAP Quotations"
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"doctype":"Page","name":"makap-returns","module":"makap_backend","standard":"Yes","title":"MAKAP Returns Control Center"}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,41 @@
frappe.pages["makap-sales-orders"].on_page_load = function (wrapper) {
const page = frappe.ui.make_app_page({ parent: wrapper, title: "Sales Orders", single_column: true });
page.main.html(`
<section class="makap-orders-shell">
<header class="makap-orders-head"><div><div class="makap-orders-eyebrow">Customer & commercial</div><h1>Sales Orders</h1><p>Track confirmed customer demand from order acceptance through delivery and billing.</p></div><div class="makap-orders-actions"><button class="btn btn-default btn-sm" data-action="refresh"><i class="fa fa-refresh"></i> Refresh</button><button class="btn btn-primary btn-sm" data-action="new"><i class="fa fa-plus"></i> New Sales Order</button></div></header>
<section class="makap-orders-metrics"><div class="makap-orders-metric"><span>Active orders</span><strong data-metric="active_count">—</strong><small>Orders still in execution</small></div><div class="makap-orders-metric"><span>Active order value</span><strong data-metric="active_value">—</strong><small>Confirmed commercial value</small></div><div class="makap-orders-metric makap-orders-alert"><span>Delivery due</span><strong data-metric="delivery_due_count">—</strong><small>Orders not fully delivered</small></div><div class="makap-orders-metric"><span>Billing due</span><strong data-metric="billing_due_count">—</strong><small>Orders not fully billed</small></div></section>
<div class="makap-orders-workspace"><aside class="makap-orders-list-panel"><div class="makap-orders-list-head"><div><strong>Order register</strong><span data-count>0 sales orders</span></div><div class="makap-orders-filters"><div class="makap-orders-search"><i class="fa fa-search"></i><input type="search" placeholder="Search customer or order..." data-search></div><select data-status aria-label="Filter by sales order status"><option>All</option><option>Draft</option><option>To Deliver and Bill</option><option>To Bill</option><option>To Deliver</option><option>Completed</option><option>Cancelled</option><option>Closed</option><option>On Hold</option><option>Overdue</option></select></div></div><div class="makap-orders-list" data-list><div class="makap-orders-empty">Loading sales orders...</div></div></aside><article class="makap-orders-detail" data-detail><div class="makap-orders-empty makap-orders-detail-empty"><div class="makap-orders-empty-icon"><i class="fa fa-shopping-bag"></i></div><strong>Select a sales order</strong><span>Choose an order to inspect execution status, delivery progress, billing, and items.</span></div></article></div>
</section>
`);
const root = page.main[0], list = root.querySelector("[data-list]"), detail = root.querySelector("[data-detail]"), search = root.querySelector("[data-search]"), status = root.querySelector("[data-status]"), count = root.querySelector("[data-count]");
let activeName = null, searchTimer = null;
const esc = (v) => frappe.utils.escape_html(String(v == null ? "" : v));
const number = (v) => new Intl.NumberFormat().format(Number(v || 0));
const money = (v) => Number(v || 0).toLocaleString(undefined, { maximumFractionDigits: 0 });
const date = (v) => v ? frappe.datetime.str_to_user(v) : "Not set";
const initials = (v) => (String(v || "?").trim().split(/\\s+/).slice(0, 2).map((x) => x[0]).join("") || "?").toUpperCase();
const call = (method, args = {}) => new Promise((resolve, reject) => frappe.call({ method, args, callback: (r) => r.exc ? reject(r.exc) : resolve(r.message) }));
const tone = (v) => ({"To Deliver and Bill": "active", "To Bill": "billing", "To Deliver": "delivery", Completed: "completed", Cancelled: "cancelled", Closed: "closed", "On Hold": "hold", Overdue: "overdue", Draft: "draft"}[v] || "active");
const progress = (value) => Math.max(0, Math.min(100, Number(value || 0)));
function renderMetrics(s) { ["active_count", "active_value", "delivery_due_count", "billing_due_count"].forEach((key) => { const el = root.querySelector(`[data-metric="${key}"]`); el.textContent = key.includes("value") ? money(s[key]) : number(s[key]); }); }
function renderList(rows) {
count.textContent = `${rows.length} sales order${rows.length === 1 ? "" : "s"}`;
if (!rows.length) { list.innerHTML = `<div class="makap-orders-empty"><div class="makap-orders-empty-icon"><i class="fa fa-search"></i></div><strong>No sales orders found</strong><span>Try a different customer, order number, or status.</span></div>`; return; }
list.innerHTML = rows.map((r) => `<button class="makap-order-row ${r.name === activeName ? "is-active" : ""}" data-name="${esc(r.name)}"><span class="makap-order-avatar">${esc(initials(r.customer_name || r.customer || r.name))}</span><span class="makap-order-row-copy"><strong>${esc(r.customer_name || r.customer || "Unassigned customer")}</strong><span>${esc(r.name)} · ${money(r.grand_total)} ${esc(r.currency || "")}</span><small>${esc(r.delivery_date ? `Delivery ${date(r.delivery_date)}` : "No delivery date")}</small></span><span class="makap-order-row-side"><b class="makap-order-status ${tone(r.status)}">${esc(r.status || "Draft")}</b><i class="fa fa-angle-right"></i></span></button>`).join("");
list.querySelectorAll("[data-name]").forEach((el) => el.addEventListener("click", () => selectOrder(el.dataset.name)));
}
function renderDetail(o) {
activeName = o.name; const delivered = progress(o.per_delivered), billed = progress(o.per_billed);
const itemMarkup = o.items?.length ? o.items.map((i) => `<div class="makap-order-item"><span><strong>${esc(i.item_name || i.item_code || "Item")}</strong><small>${esc(i.item_code || "")} · ${esc(i.qty || 0)} ${esc(i.uom || "")}</small></span><span class="makap-order-item-progress"><i style="width:${progress(Number(i.qty) ? Number(i.delivered_qty || 0) / Number(i.qty) * 100 : 0)}%"></i><b>${money(i.amount)}</b></span></div>`).join("") : `<div class="makap-order-note"><i class="fa fa-info-circle"></i>No items added yet.</div>`;
detail.innerHTML = `<div class="makap-order-detail-hero"><div class="makap-order-detail-avatar">${esc(initials(o.customer_name || o.customer || o.name))}</div><div class="makap-order-detail-identity"><span class="makap-order-status ${tone(o.status)}">${esc(o.status || "Draft")}</span><h2>${esc(o.customer_name || o.customer || "Unassigned customer")}</h2><p>${esc(o.name)}${o.order_type ? ` · ${esc(o.order_type)}` : ""}</p></div><a class="btn btn-default btn-xs" href="/app/sales-order/${encodeURIComponent(o.name)}">Open ERPNext form</a></div><div class="makap-order-detail-body"><section class="makap-order-value-card"><div><span>Order value</span><strong>${money(o.grand_total)} <small>${esc(o.currency || "")}</small></strong><p>Net total ${money(o.net_total)} · Taxes ${money(o.total_taxes_and_charges)}</p></div><div class="makap-order-status-summary"><span class="makap-order-status ${tone(o.status)}">${esc(o.status || "Draft")}</span><small>Execution status</small></div></section><section class="makap-order-detail-section"><div class="makap-order-section-title">Execution progress</div><div class="makap-order-progress-row"><span><b>Delivery</b><small>${delivered}% delivered</small></span><div><i style="width:${delivered}%"></i></div></div><div class="makap-order-progress-row"><span><b>Billing</b><small>${billed}% billed</small></span><div><i class="billing" style="width:${billed}%"></i></div></div></section><section class="makap-order-detail-section"><div class="makap-order-section-title">Order timeline</div><div class="makap-order-timeline"><div><span>Order date</span><strong>${date(o.transaction_date)}</strong></div><div><span>Delivery date</span><strong>${date(o.delivery_date)}</strong></div><div><span>Owner</span><strong>${esc(o.owner || "Not set")}</strong></div></div></section><section class="makap-order-detail-section"><div class="makap-order-section-title">Customer and contact</div><div class="makap-order-fields"><div><span>Customer</span><strong>${o.customer ? `<a href="/app/customer/${encodeURIComponent(o.customer)}">${esc(o.customer_name || o.customer)}</a>` : "Not linked"}</strong></div><div><span>Contact</span><strong>${o.contact_person ? `<a href="/app/contact/${encodeURIComponent(o.contact_person)}">${esc(o.contact_person)}</a>` : "Not linked"}</strong></div><div><span>Email</span><strong>${esc(o.contact_email || "Not provided")}</strong></div><div><span>Territory</span><strong>${esc(o.territory || "Not set")}</strong></div></div></section><section class="makap-order-detail-section"><div class="makap-order-section-title">Ordered items</div>${itemMarkup}</section>${o.terms ? `<section class="makap-order-detail-section"><div class="makap-order-section-title">Terms and conditions</div><p class="makap-order-remarks">${esc(o.terms)}</p></section>` : ""}</div>`;
root.querySelectorAll(".makap-order-row").forEach((el) => el.classList.toggle("is-active", el.dataset.name === activeName));
}
async function selectOrder(name) { activeName = name; root.querySelectorAll(".makap-order-row").forEach((el) => el.classList.toggle("is-active", el.dataset.name === name)); detail.innerHTML = `<div class="makap-orders-empty makap-orders-detail-empty"><div class="makap-loading-ring"></div><strong>Loading sales order</strong><span>Fetching the latest execution details.</span></div>`; try { renderDetail(await call("makap_backend.sales_order_api.get_sales_order", { name })); } catch (e) { detail.innerHTML = `<div class="makap-orders-empty makap-orders-detail-empty"><strong>Could not load sales order</strong><span>${esc(e.message || "Please try again.")}</span></div>`; } }
async function loadWorkspace() { list.innerHTML = `<div class="makap-orders-empty"><div class="makap-loading-ring"></div><span>Loading sales orders...</span></div>`; try { const [summary, rows] = await Promise.all([call("makap_backend.sales_order_api.get_sales_order_summary"), call("makap_backend.sales_order_api.list_sales_orders", { search: search.value, status: status.value, limit_page_length: 100 })]); renderMetrics(summary || {}); renderList(rows || []); if (!activeName && rows?.[0]) selectOrder(rows[0].name); else if (activeName && rows?.some((r) => r.name === activeName)) selectOrder(activeName); } catch (e) { list.innerHTML = `<div class="makap-orders-empty"><strong>Sales orders unavailable</strong><span>${esc(e.message || "Check your permissions and try again.")}</span></div>`; } }
search.addEventListener("input", () => { clearTimeout(searchTimer); searchTimer = setTimeout(loadWorkspace, 250); });
status.addEventListener("change", loadWorkspace);
root.querySelector('[data-action="refresh"]').addEventListener("click", loadWorkspace);
root.querySelector('[data-action="new"]').addEventListener("click", () => frappe.new_doc("Sales Order"));
loadWorkspace();
};
@@ -0,0 +1,7 @@
{
"doctype": "Page",
"name": "makap-sales-orders",
"module": "makap_backend",
"standard": "Yes",
"title": "MAKAP Sales Orders"
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,40 @@
frappe.pages["makap-specifications"].on_page_load = function (wrapper) {
const page = frappe.ui.make_app_page({ parent: wrapper, title: "Specification Control Center", single_column: true });
page.main.html(`
<section class="makap-spec-shell">
<header class="makap-spec-head"><div><div class="makap-spec-eyebrow">Specification & prepress</div><h1>Specification Control Center</h1><p>Keep technical definitions, material layers, acceptance controls, and item masters aligned in one workspace.</p></div><div class="makap-spec-actions"><button class="btn btn-default btn-sm" data-action="refresh"><i class="fa fa-refresh"></i> Refresh</button><button class="btn btn-primary btn-sm" data-action="new"><i class="fa fa-plus"></i> <span data-new-label>New Specification</span></button></div></header>
<section class="makap-spec-metrics"><div><span>Specifications</span><strong data-metric="specifications">—</strong><small>Technical definitions</small></div><div><span>Approved for manufacture</span><strong data-metric="approved_specs">—</strong><small>Current production-ready revisions</small></div><div><span>Quote eligible</span><strong data-metric="quote_ready">—</strong><small>Available to commercial teams</small></div><div><span>Active items</span><strong data-metric="active_items">—</strong><small>Enabled item masters</small></div></section>
<nav class="makap-spec-tabs" data-tabs><button data-tab="specifications"><i class="fa fa-cubes"></i> Packaging specs</button><button data-tab="materials"><i class="fa fa-sitemap"></i> Material structures</button><button data-tab="acceptance"><i class="fa fa-check-square-o"></i> Critical acceptance</button><button data-tab="items"><i class="fa fa-archive"></i> Items</button></nav>
<div class="makap-spec-workspace"><aside class="makap-spec-list-panel"><div class="makap-spec-list-head"><div><strong data-list-title>Packaging specifications</strong><span data-count>0 records</span></div><div class="makap-spec-search"><i class="fa fa-search"></i><input type="search" placeholder="Search specifications..." data-search></div><select class="makap-spec-status" data-status><option>All</option><option>Draft</option><option>Quote Eligible</option><option>Approved for Manufacture</option><option>Superseded</option><option>Withdrawn</option></select></div><div class="makap-spec-list" data-list><div class="makap-spec-empty">Loading records...</div></div></aside><article class="makap-spec-detail" data-detail><div class="makap-spec-empty makap-spec-detail-empty"><div class="makap-spec-empty-icon"><i class="fa fa-cubes"></i></div><strong>Select a record</strong><span>Choose a record from the active tab to inspect its technical details.</span></div></article></div>
</section>
`);
const root = page.main[0], list = root.querySelector("[data-list]"), detail = root.querySelector("[data-detail]"), search = root.querySelector("[data-search]"), status = root.querySelector("[data-status]"), count = root.querySelector("[data-count]"), listTitle = root.querySelector("[data-list-title]"), newLabel = root.querySelector("[data-new-label]");
let activeTab = new URLSearchParams(window.location.search).get("tab") || "specifications", activeName = null, timer = null;
const esc = (v) => frappe.utils.escape_html(String(v == null ? "" : v));
const number = (v) => new Intl.NumberFormat().format(Number(v || 0));
const money = (v) => Number(v || 0).toLocaleString(undefined, { maximumFractionDigits: 0 });
const date = (v) => v ? frappe.datetime.str_to_user(v) : "Not set";
const initials = (v) => (String(v || "?").trim().split(/\\s+/).slice(0, 2).map((x) => x[0]).join("") || "?").toUpperCase();
const call = (method, args = {}) => new Promise((resolve, reject) => frappe.call({ method, args, callback: (r) => r.exc ? reject(r.exc) : resolve(r.message) }));
const specStatus = (v) => ({Draft: "draft", "Quote Eligible": "quote", "Approved for Manufacture": "approved", Superseded: "superseded", Withdrawn: "withdrawn"}[v] || "draft");
const setActiveTab = (tab) => { activeTab = tab; root.querySelectorAll("[data-tab]").forEach((el) => el.classList.toggle("is-active", el.dataset.tab === tab)); status.style.display = tab === "specifications" ? "block" : "none"; const labels = {specifications: ["Packaging specifications", "New Specification"], materials: ["Material structures", "Open Specification"], acceptance: ["Critical acceptance requirements", "Open Specification"], items: ["Item master", "New Item"]}; listTitle.textContent = labels[tab][0]; newLabel.textContent = labels[tab][1]; activeName = null; loadRecords(); };
const renderMetrics = (s) => { ["specifications", "approved_specs", "quote_ready", "active_items"].forEach((k) => { root.querySelector(`[data-metric="${k}"]`).textContent = number(s[k]); }); };
function renderSpecList(rows) { count.textContent = `${rows.length} specification${rows.length === 1 ? "" : "s"}`; if (!rows.length) { list.innerHTML = `<div class="makap-spec-empty"><strong>No specifications found</strong><span>Try a different status or search term.</span></div>`; return; } list.innerHTML = rows.map((r) => `<button class="makap-spec-row ${r.name === activeName ? "is-active" : ""}" data-name="${esc(r.name)}"><span class="makap-spec-avatar">${esc(initials(r.specification_series || r.name))}</span><span class="makap-spec-row-copy"><strong>${esc(r.specification_series || r.name)}</strong><span>${esc(r.customer || "No customer")} · ${esc(r.item || r.product_family || "No item")}</span><small>${esc(r.revision || "")}</small></span><b class="makap-spec-status ${specStatus(r.specification_status)}">${esc(r.specification_status || "Draft")}</b></button>`).join(""); list.querySelectorAll("[data-name]").forEach((el) => el.addEventListener("click", () => selectRecord(el.dataset.name))); }
function renderMaterialList(rows) { count.textContent = `${rows.length} material layer${rows.length === 1 ? "" : "s"}`; list.innerHTML = rows.length ? rows.map((r, i) => `<button class="makap-spec-row ${`${r.parent}:${i}` === activeName ? "is-active" : ""}" data-name="${esc(`${r.parent}:${i}`)}"><span class="makap-spec-avatar material">${esc(String(r.sequence || i + 1))}</span><span class="makap-spec-row-copy"><strong>${esc(r.material_film_description || "Material layer")}</strong><span>${esc(r.parent)} · ${esc(r.raw_material_item || "No raw item")}</span><small>${esc(r.nominal_thickness_requirement || "No thickness requirement")} ${esc(r.uom || "")}</small></span><i class="fa fa-angle-right makap-spec-chevron"></i></button>`).join("") : `<div class="makap-spec-empty"><strong>No material layers found</strong><span>Add material rows inside a Packaging Specification.</span></div>`; list.querySelectorAll("[data-name]").forEach((el) => el.addEventListener("click", () => selectRecord(el.dataset.name))); }
function renderAcceptanceList(rows) { count.textContent = `${rows.length} acceptance requirement${rows.length === 1 ? "" : "s"}`; list.innerHTML = rows.length ? rows.map((r, i) => `<button class="makap-spec-row ${`${r.parent}:${i}` === activeName ? "is-active" : ""}" data-name="${esc(`${r.parent}:${i}`)}"><span class="makap-spec-avatar acceptance"><i class="fa fa-check"></i></span><span class="makap-spec-row-copy"><strong>${esc(r.characteristic || "Requirement")}</strong><span>${esc(r.parent)} · ${esc(r.requirement_nominal || "No nominal")}</span><small>${esc(r.unit || "No unit")} ${r.minimum_lower_tolerance != null ? `· min ${esc(r.minimum_lower_tolerance)}` : ""} ${r.maximum_upper_tolerance != null ? `· max ${esc(r.maximum_upper_tolerance)}` : ""}</small></span><i class="fa fa-angle-right makap-spec-chevron"></i></button>`).join("") : `<div class="makap-spec-empty"><strong>No acceptance requirements found</strong><span>Add acceptance rows inside a Packaging Specification.</span></div>`; list.querySelectorAll("[data-name]").forEach((el) => el.addEventListener("click", () => selectRecord(el.dataset.name))); }
function renderItemList(rows) { count.textContent = `${rows.length} item${rows.length === 1 ? "" : "s"}`; list.innerHTML = rows.length ? rows.map((r) => `<button class="makap-spec-row ${r.name === activeName ? "is-active" : ""}" data-name="${esc(r.name)}"><span class="makap-spec-avatar item">${esc(initials(r.item_name || r.name))}</span><span class="makap-spec-row-copy"><strong>${esc(r.item_name || r.name)}</strong><span>${esc(r.item_group || "No group")} · ${esc(r.stock_uom || "No UOM")}</span><small>${r.disabled ? "Disabled" : "Active"} · ${esc(r.item_code || r.name)}</small></span><i class="fa fa-angle-right makap-spec-chevron"></i></button>`).join("") : `<div class="makap-spec-empty"><strong>No items found</strong><span>Try a different item name or group.</span></div>`; list.querySelectorAll("[data-name]").forEach((el) => el.addEventListener("click", () => selectRecord(el.dataset.name))); }
function renderSpecDetail(s) { activeName = s.name; const materials = s.material_structure?.length ? s.material_structure.map((r) => `<div class="makap-spec-mini-row"><b>${esc(r.sequence)}</b><span><strong>${esc(r.material_film_description)}</strong><small>${esc(r.raw_material_item || "No raw item")} · ${esc(r.nominal_thickness_requirement || "")} ${esc(r.uom || "")}</small></span></div>`).join("") : `<div class="makap-spec-note">No material structure defined.</div>`; const acceptance = s.acceptance_requirements?.length ? s.acceptance_requirements.map((r) => `<div class="makap-spec-mini-row"><span><strong>${esc(r.characteristic)}</strong><small>${esc(r.requirement_nominal)} ${esc(r.unit || "")} · ${r.minimum_lower_tolerance != null ? `min ${esc(r.minimum_lower_tolerance)}` : ""} ${r.maximum_upper_tolerance != null ? `max ${esc(r.maximum_upper_tolerance)}` : ""}</small></span></div>`).join("") : `<div class="makap-spec-note">No critical acceptance requirements defined.</div>`; detail.innerHTML = `<div class="makap-spec-detail-hero"><div class="makap-spec-detail-avatar">${esc(initials(s.specification_series || s.name))}</div><div class="makap-spec-detail-identity"><span class="makap-spec-status ${specStatus(s.specification_status)}">${esc(s.specification_status)}</span><h2>${esc(s.specification_series || s.name)}</h2><p>Revision ${esc(s.revision || "—")} · ${esc(s.customer || "No customer")}</p></div><a class="btn btn-default btn-xs" href="/app/packaging-specification/${encodeURIComponent(s.name)}">Open ERPNext form</a></div><div class="makap-spec-detail-body"><section class="makap-spec-value-card"><div><span>Product definition</span><strong>${esc(s.product_family || s.item || "Unclassified product")}</strong><small>${esc(s.material_substrate_summary || s.customer_facing_technical_summary || "No technical summary")}</small></div><div class="makap-spec-readiness"><i class="fa fa-${s.quote_eligible ? "check" : "clock-o"}"></i><span>${s.quote_eligible ? "Quote eligible" : "Not quote eligible"}</span></div></section><section class="makap-spec-detail-section"><div class="makap-spec-section-title">Dimensions and process</div><div class="makap-spec-fields"><div><span>Item</span><strong>${esc(s.item || "Not linked")}</strong></div><div><span>Dimensions</span><strong>${esc([s.width && `${s.width} ${s.width_unit || ""}`, s.length && `${s.length} ${s.length_unit || ""}`, s.thickness_gauge && `${s.thickness_gauge} ${s.thickness_gauge_unit || ""}`].filter(Boolean).join(" × ") || "Not set")}</strong></div><div><span>Process</span><strong>${esc([s.printing_required && `Print: ${s.print_process || "required"}`, s.lamination_required && "Lamination", s.slitting_required && "Slitting", s.rewinding_required && "Rewinding", s.conversion_bag_making_requirement].filter(Boolean).join(" · ") || "No process flags")}</strong></div><div><span>Artwork</span><strong>${esc(s.artwork_required ? (s.artwork_status || "Required") : "Not required")}</strong></div></div></section><section class="makap-spec-detail-section"><div class="makap-spec-section-title">Material structure</div>${materials}</section><section class="makap-spec-detail-section"><div class="makap-spec-section-title">Critical acceptance</div>${acceptance}</section></div>`; root.querySelectorAll(".makap-spec-row").forEach((el) => el.classList.toggle("is-active", el.dataset.name === activeName)); }
function renderMaterialDetail(r) { activeName = r.parent + ":" + r.sequence; detail.innerHTML = `<div class="makap-spec-detail-hero compact"><div class="makap-spec-detail-avatar material">${esc(r.sequence)}</div><div class="makap-spec-detail-identity"><span class="makap-spec-kicker">Material structure</span><h2>${esc(r.material_film_description)}</h2><p>Parent specification ${esc(r.parent)}</p></div><a class="btn btn-default btn-xs" href="/app/packaging-specification/${encodeURIComponent(r.parent)}">Open parent spec</a></div><div class="makap-spec-detail-body"><section class="makap-spec-value-card"><div><span>Raw material item</span><strong>${esc(r.raw_material_item || "Not linked")}</strong><small>${esc(r.layer_role_notes || "No layer notes")}</small></div><div class="makap-spec-readiness"><i class="fa fa-sort-numeric-asc"></i><span>Layer ${esc(r.sequence)}</span></div></section><section class="makap-spec-detail-section"><div class="makap-spec-section-title">Layer requirement</div><div class="makap-spec-fields"><div><span>Material / film</span><strong>${esc(r.material_film_description)}</strong></div><div><span>Nominal thickness</span><strong>${esc(r.nominal_thickness_requirement || "Not set")} ${esc(r.uom || "")}</strong></div><div><span>Customer</span><strong>${esc(r.customer || "Not set")}</strong></div><div><span>Specification status</span><strong>${esc(r.specification_status || "Not set")}</strong></div></div></section></div>`; }
function renderAcceptanceDetail(r) { activeName = r.parent + ":" + r.characteristic; detail.innerHTML = `<div class="makap-spec-detail-hero compact"><div class="makap-spec-detail-avatar acceptance"><i class="fa fa-check"></i></div><div class="makap-spec-detail-identity"><span class="makap-spec-kicker">Critical acceptance</span><h2>${esc(r.characteristic)}</h2><p>Parent specification ${esc(r.parent)}</p></div><a class="btn btn-default btn-xs" href="/app/packaging-specification/${encodeURIComponent(r.parent)}">Open parent spec</a></div><div class="makap-spec-detail-body"><section class="makap-spec-value-card"><div><span>Requirement / nominal</span><strong>${esc(r.requirement_nominal || "Not set")} ${esc(r.unit || "")}</strong><small>${esc(r.notes || "No acceptance notes")}</small></div><div class="makap-spec-readiness"><i class="fa fa-check-circle"></i><span>Controlled requirement</span></div></section><section class="makap-spec-detail-section"><div class="makap-spec-section-title">Tolerance window</div><div class="makap-spec-fields"><div><span>Minimum / lower tolerance</span><strong>${esc(r.minimum_lower_tolerance ?? "Not set")}</strong></div><div><span>Maximum / upper tolerance</span><strong>${esc(r.maximum_upper_tolerance ?? "Not set")}</strong></div><div><span>Unit</span><strong>${esc(r.unit || "Not set")}</strong></div><div><span>Customer</span><strong>${esc(r.customer || "Not set")}</strong></div></div></section></div>`; }
function renderItemDetail(i) { activeName = i.name; detail.innerHTML = `<div class="makap-spec-detail-hero compact"><div class="makap-spec-detail-avatar item">${esc(initials(i.item_name || i.name))}</div><div class="makap-spec-detail-identity"><span class="makap-spec-status ${i.disabled ? "withdrawn" : "approved"}">${i.disabled ? "Disabled" : "Active"}</span><h2>${esc(i.item_name || i.name)}</h2><p>${esc(i.item_code || i.name)} · ${esc(i.item_group || "No group")}</p></div><a class="btn btn-default btn-xs" href="/app/item/${encodeURIComponent(i.name)}">Open ERPNext form</a></div><div class="makap-spec-detail-body"><section class="makap-spec-value-card"><div><span>Item master</span><strong>${esc(i.item_code || i.name)}</strong><small>${esc(i.description || "No item description")}</small></div><div class="makap-spec-readiness"><i class="fa fa-cube"></i><span>${esc(i.stock_uom || "No UOM")}</span></div></section><section class="makap-spec-detail-section"><div class="makap-spec-section-title">Item controls</div><div class="makap-spec-fields"><div><span>Item group</span><strong>${esc(i.item_group || "Not set")}</strong></div><div><span>Stock item</span><strong>${i.is_stock_item ? "Yes" : "No"}</strong></div><div><span>Standard rate</span><strong>${money(i.standard_rate)}</strong></div><div><span>Last purchase rate</span><strong>${money(i.last_purchase_rate)}</strong></div></div></section></div>`; }
async function selectRecord(name) { const nameParts = name.split(":"); activeName = name; detail.innerHTML = `<div class="makap-spec-empty makap-spec-detail-empty"><div class="makap-loading-ring"></div><strong>Loading record</strong><span>Fetching the latest details.</span></div>`; try { if (activeTab === "specifications") renderSpecDetail(await call("makap_backend.specification_api.get_specification", {name})); if (activeTab === "materials") { const rows = await call("makap_backend.specification_api.list_child_rows", {doctype: "Material Structure", search: search.value}); renderMaterialDetail(rows.find((r) => r.parent === nameParts[0] && String(r.sequence) === nameParts[1]) || rows[0]); } if (activeTab === "acceptance") { const rows = await call("makap_backend.specification_api.list_child_rows", {doctype: "Critical Acceptance Requirement", search: search.value}); renderAcceptanceDetail(rows.find((r) => r.parent === nameParts[0] && r.characteristic === nameParts.slice(1).join(":")) || rows[0]); } if (activeTab === "items") renderItemDetail(await call("makap_backend.specification_api.get_item", {name})); } catch (e) { detail.innerHTML = `<div class="makap-spec-empty makap-spec-detail-empty"><strong>Could not load record</strong><span>${esc(e.message || "Please try again.")}</span></div>`; } }
async function loadRecords() { list.innerHTML = `<div class="makap-spec-empty"><div class="makap-loading-ring"></div><span>Loading records...</span></div>`; try { if (activeTab === "specifications") { const rows = await call("makap_backend.specification_api.list_specifications", {search: search.value, status: status.value}); renderSpecList(rows || []); if (!activeName && rows?.[0]) selectRecord(rows[0].name); } if (activeTab === "materials") { const rows = await call("makap_backend.specification_api.list_child_rows", {doctype: "Material Structure", search: search.value}); renderMaterialList(rows || []); if (!activeName && rows?.[0]) selectRecord(`${rows[0].parent}:${rows[0].sequence}`); } if (activeTab === "acceptance") { const rows = await call("makap_backend.specification_api.list_child_rows", {doctype: "Critical Acceptance Requirement", search: search.value}); renderAcceptanceList(rows || []); if (!activeName && rows?.[0]) selectRecord(`${rows[0].parent}:${rows[0].characteristic}`); } if (activeTab === "items") { const rows = await call("makap_backend.specification_api.list_items", {search: search.value}); renderItemList(rows || []); if (!activeName && rows?.[0]) selectRecord(rows[0].name); } } catch (e) { list.innerHTML = `<div class="makap-spec-empty"><strong>Records unavailable</strong><span>${esc(e.message || "Check your permissions and try again.")}</span></div>`; } }
root.querySelectorAll("[data-tab]").forEach((el) => el.addEventListener("click", () => setActiveTab(el.dataset.tab)));
search.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(loadRecords, 250); });
status.addEventListener("change", loadRecords);
root.querySelector('[data-action="refresh"]').addEventListener("click", async () => { renderMetrics(await call("makap_backend.specification_api.get_specification_summary")); loadRecords(); });
root.querySelector('[data-action="new"]').addEventListener("click", () => frappe.new_doc(activeTab === "items" ? "Item" : "Packaging Specification"));
(async () => { try { renderMetrics(await call("makap_backend.specification_api.get_specification_summary")); } catch (e) {} setActiveTab(activeTab); })();
};
@@ -0,0 +1,7 @@
{
"doctype": "Page",
"name": "makap-specifications",
"module": "makap_backend",
"standard": "Yes",
"title": "MAKAP Specification Control Center"
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"doctype":"Page","name":"makap-supplier-reconciliation","module":"makap_backend","standard":"Yes","title":"MAKAP Supplier Reconciliation & Cost Control Center"}
+286
View File
@@ -0,0 +1,286 @@
import frappe
from frappe import _
from frappe.utils import get_first_day, get_fullname, now_datetime, nowdate
from makap_backend.makap_backend import portal_crud as crud
STANDARD = crud.STANDARD_FIELDS
DONE_STATES = ["Completed", "Cancelled", "Closed"]
def _readable(doctype):
return bool(frappe.db.exists("DocType", doctype)) and bool(frappe.has_permission(doctype, "read"))
def _fields_exist(doctype, names):
meta = frappe.get_meta(doctype)
return all(n in STANDARD or meta.has_field(n) for n in names)
def _count(doctype, filters=None):
"""Permission-aware count. Returns 0 if the doctype/field isn't deployed yet,
and logs real errors instead of hiding them."""
filters = filters or {}
if not _readable(doctype) or not _fields_exist(doctype, filters.keys()):
return 0
try:
rows = frappe.get_list(doctype, filters=filters, fields=["count(name) as total"])
return int(rows[0].get("total") or 0) if rows else 0
except Exception:
frappe.log_error(title="MAKAP portal count failed: {0}".format(doctype))
return 0
def _rows(doctype, fields, filters=None, limit=6, order_by="modified desc"):
if not _readable(doctype):
return []
meta = frappe.get_meta(doctype)
fields = [f for f in fields if f in STANDARD or meta.has_field(f)]
try:
return frappe.get_list(
doctype, fields=fields, filters=filters or {}, order_by=order_by, limit_page_length=limit
)
except Exception:
frappe.log_error(title="MAKAP portal rows failed: {0}".format(doctype))
return []
@frappe.whitelist()
def get_portal_dashboard():
live = {"docstatus": 1}
kpis = {
"onboarding_requests": _count("Customer Onboarding Request"),
"packaging_specifications": _count("Packaging Specification"),
"approved_for_manufacture": _count(
"Packaging Specification", {"specification_status": "Approved for Manufacture"}
),
"critical_requirements_tracked": _count("Critical Acceptance Requirement"),
"active_work_orders": _count(
"Work Order", {"docstatus": 1, "status": ["in", ["Not Started", "In Process"]]}
),
"active_sales_orders": _count(
"Sales Order", {"docstatus": 1, "status": ["not in", DONE_STATES]}
),
"open_purchase_orders": _count(
"Purchase Order", {"docstatus": 1, "status": ["not in", DONE_STATES]}
),
"deliveries_this_month": _count(
"Delivery Note", {"docstatus": 1, "posting_date": [">=", get_first_day(nowdate())]}
),
"work_orders_on_hold": _count("Work Order", {"docstatus": 1, "status": "Stopped"}),
"quality_holds": _count(
"Quality Inspection",
{"docstatus": 1, "makap_quality_disposition": ["in", ["Hold", "Reject"]]},
),
"dispatch_blocked": _count("Delivery Note", {"makap_dispatch_gate_status": "Blocked"}),
"fiscalisation_issue": _count(
"Sales Invoice",
{"docstatus": 1, "makap_fiscalisation_status": ["in", ["Pending", "Failed"]]},
),
"delivered_unbilled": _count("Sales Order", {"docstatus": 1, "makap_delivered_unbilled": 1}),
"collection_due": _count("Sales Order", {"docstatus": 1, "makap_collection_status": "Due"}),
"returns_total": _count("Delivery Note", {"docstatus": 1, "is_return": 1}),
"supplier_variance": _count(
"Purchase Invoice",
{
"docstatus": 1,
"makap_supplier_invoice_match_status": [
"in", ["Quantity Variance", "Rate Variance", "Both Variance", "Disputed"],
],
},
),
"closure_views": _count("Order Closure And Margin View"),
"onboarding_open": _count(
"Customer Onboarding Request", {"onboarding_status": ["in", ["Submitted", "Under Review"]]}
),
"specifications_pending": _count(
"Packaging Specification", {"specification_status": ["in", ["Draft", "Quote Eligible"]]}
),
}
pipeline = [{"label": "Draft", "value": _count("Sales Order", {"docstatus": 0})}]
for status in ["To Deliver and Bill", "To Bill", "To Deliver", "Completed"]:
pipeline.append({"label": status, "value": _count("Sales Order", {**live, "status": status})})
priority_defs = [
("quality_holds", "Quality holds", "Inspection requires disposition", "red", "Quality Inspection"),
("dispatch_blocked", "Dispatch blocked", "Delivery gate needs attention", "amber", "Delivery Note"),
("fiscalisation_issue", "Fiscalisation issues", "Invoices pending or failed", "amber", "Sales Invoice"),
("collection_due", "Collections due", "Orders marked due", "blue", "Sales Order"),
("work_orders_on_hold", "Work orders stopped", "Production is on hold", "red", "Work Order"),
("supplier_variance", "Supplier variances", "Invoice matching needs review", "purple", "Purchase Invoice"),
("delivered_unbilled", "Delivered, not billed", "Orders awaiting invoice", "blue", "Sales Order"),
]
priorities = [
{"key": k, "label": lb, "detail": dt, "tone": tn, "route": rt, "value": kpis[k]}
for k, lb, dt, tn, rt in priority_defs
if kpis[k] > 0
]
return {
"generated_at": str(now_datetime()),
"user": frappe.session.user,
"full_name": get_fullname(frappe.session.user),
"kpis": kpis,
"pipeline": pipeline,
"priorities": priorities,
"recent_orders": _rows(
"Sales Order",
["name", "customer", "status", "grand_total", "transaction_date", "modified"],
{"docstatus": ["!=", 2]},
),
"recent_closures": _rows(
"Order Closure And Margin View",
["name", "sales_order", "customer", "operating_close", "primary_blocker", "modified"],
{},
5,
),
}
NAV_GROUPS = [
("Customer & commercial · M1–2", [
("Onboarding requests", "Customer Onboarding Request", "count"),
("Customers", "Customer", "ERPNext"),
("Contacts", "Contact", "ERPNext"),
("Opportunities", "Opportunity", "ERPNext"),
("Quotations", "Quotation", "ERPNext"),
("Sales orders", "Sales Order", "ERPNext"),
]),
("Specification & prepress · M3", [
("Packaging specs", "Packaging Specification", "count"),
("Items", "Item", "ERPNext"),
]),
("Production & planning · M4", [
("Operations", "Operation", "ERPNext"),
("Work orders", "Work Order", "ERPNext"),
("Production plans", "Production Plan", "ERPNext"),
]),
("Materials & inventory · M5", [
("Material requests", "Material Request", "ERPNext"),
("Purchase orders", "Purchase Order", "ERPNext"),
("Purchase receipts", "Purchase Receipt", "ERPNext"),
]),
("Quality · M6", [
("Quality inspections", "Quality Inspection", "ERPNext"),
]),
("Fulfilment & billing · M8", [
("Delivery notes", "Delivery Note", "ERPNext"),
("Sales invoices", "Sales Invoice", "ERPNext"),
("Payment entries", "Payment Entry", "ERPNext"),
("Purchase invoices", "Purchase Invoice", "ERPNext"),
]),
("Operating control", [
("Order closure & margin", "Order Closure And Margin View", "open"),
]),
("Reference & lookups", [
("Users", "User", "Frappe core"),
("UOM", "UOM", "Setup"),
]),
]
@frappe.whitelist()
def get_portal_navigation():
groups = []
for title, items in NAV_GROUPS:
visible = []
for label, doctype, tag in items:
if not _readable(doctype):
continue
item = {"label": _(label), "doctype": doctype, "tag": tag if tag not in ("count", "open") else ""}
if tag == "count":
item["badge"] = str(_count(doctype))
elif tag == "open":
item["badge"] = "{0} open".format(_count(doctype, {"operating_close": "Open"}))
else:
item["badge"] = ""
visible.append(item)
if title.startswith("Customer & commercial"):
visible.insert(1, {"label": _("Onboarding workspace"), "href": "/makap-onboarding", "tag": "page", "badge": ""})
groups.append({"label": _(title), "items": visible})
return [g for g in groups if g["items"]]
@frappe.whitelist()
def portal_list(doctype, search="", start=0, page_length=20, order_by="modified",
order_dir="desc", docstatus="all", state="all"):
crud.guard_read(doctype)
meta = frappe.get_meta(doctype)
cols = [
df for df in meta.fields
if df.in_list_view and not df.hidden and df.fieldtype not in crud.EXCLUDE_FIELDTYPES
]
if not cols:
cols = [df for df in meta.fields if df.reqd and not df.hidden and df.fieldtype not in crud.EXCLUDE_FIELDTYPES]
cols = cols[:6]
colnames = [c.fieldname for c in cols]
fields = ["name", "modified", "docstatus"] + [c for c in colnames if c not in ("name", "modified", "docstatus")]
if order_by not in fields:
order_by = "modified"
order_dir = "asc" if str(order_dir).lower() == "asc" else "desc"
filters = {}
if meta.is_submittable and str(docstatus) in ("0", "1", "2"):
filters["docstatus"] = int(docstatus)
has_disabled = meta.has_field("disabled")
if has_disabled and state in ("active", "disabled"):
filters["disabled"] = 0 if state == "active" else 1
or_filters = []
if search:
like = "%{0}%".format(search.strip())
or_filters.append(["name", "like", like])
for c in cols:
if c.fieldtype in ("Data", "Link", "Select", "Small Text", "Dynamic Link") and c.fieldname != "name":
or_filters.append([c.fieldname, "like", like])
start = max(int(start or 0), 0)
page_length = min(max(int(page_length or 20), 1), 100)
total_rows = frappe.get_list(
doctype, filters=filters, or_filters=or_filters or None, fields=["count(name) as total"]
)
total = int(total_rows[0].get("total") or 0) if total_rows else 0
rows = frappe.get_list(
doctype,
fields=fields,
filters=filters,
or_filters=or_filters or None,
order_by="`tab{0}`.`{1}` {2}".format(doctype, order_by, order_dir),
limit_start=start,
limit_page_length=page_length,
)
if "name" not in colnames:
colnames.insert(0, "name")
cols.insert(0, frappe._dict(fieldname="name", label=_("ID"), fieldtype="Data"))
return {
"doctype": doctype,
"columns": [{"fieldname": c.fieldname, "label": c.label or c.fieldname, "fieldtype": c.fieldtype} for c in cols],
"rows": rows,
"total": total,
"start": start,
"page_length": page_length,
"submittable": bool(meta.is_submittable),
"has_disabled": has_disabled,
"writable": doctype in crud.WRITABLE_DOCTYPES,
"can_create": doctype in crud.WRITABLE_DOCTYPES and bool(frappe.has_permission(doctype, "create")),
"can_delete": doctype in crud.WRITABLE_DOCTYPES and bool(frappe.has_permission(doctype, "delete")),
}
@frappe.whitelist()
def get_session_info():
from frappe.sessions import get_csrf_token
if frappe.db.get_value("User", frappe.session.user, "user_type") != "System User":
frappe.throw(_("The MAKAP portal is for internal users only."), frappe.PermissionError)
return {
"csrf_token": get_csrf_token(),
"user": frappe.session.user,
"full_name": get_fullname(frappe.session.user),
}
+220
View File
@@ -0,0 +1,220 @@
import frappe
from frappe import _
# Doctypes the portal may create/edit/delete. Frappe's permission engine still
# decides who can actually do it.
WRITABLE_DOCTYPES = {
"Customer Onboarding Request", "Packaging Specification", "Customer", "Contact",
"Opportunity", "Quotation", "Sales Order", "Item", "Operation", "Work Order",
"Production Plan", "Material Request", "Purchase Order", "Purchase Receipt",
"Quality Inspection", "Delivery Note", "Sales Invoice", "Payment Entry",
"Purchase Invoice", "UOM",
}
# Viewable but never editable from the portal. Move a name into WRITABLE_DOCTYPES to allow edits.
READ_ONLY_DOCTYPES = {"Order Closure And Margin View", "User"}
PORTAL_DOCTYPES = WRITABLE_DOCTYPES | READ_ONLY_DOCTYPES
STANDARD_FIELDS = {"name", "owner", "creation", "modified", "modified_by", "docstatus", "idx"}
EXCLUDE_FIELDNAMES = {"naming_series", "amended_from", "_user_tags", "_comments", "_assign", "_liked_by"}
EXCLUDE_FIELDTYPES = {
"Section Break", "Column Break", "Tab Break", "HTML", "Button", "Table", "Table MultiSelect",
"Attach", "Attach Image", "Image", "Signature", "Geolocation", "Password", "Heading", "Fold",
"Barcode", "Code", "HTML Editor", "Markdown Editor", "JSON", "Icon", "Color", "Rating",
"Duration", "Autocomplete", "Phone",
}
MAX_CHILD_FIELDS = 10
def guard_read(doctype):
if doctype not in PORTAL_DOCTYPES:
frappe.throw(_("{0} is not available in the portal.").format(doctype))
if not frappe.has_permission(doctype, "read"):
frappe.throw(_("You don't have permission to read {0}.").format(doctype), frappe.PermissionError)
def _guard(doctype, ptype, name=None):
if doctype not in WRITABLE_DOCTYPES:
frappe.throw(_("{0} can't be changed from the portal.").format(doctype))
if not frappe.has_permission(doctype, ptype, doc=name):
frappe.throw(
_("You don't have permission to {0} {1}.").format(ptype, doctype), frappe.PermissionError
)
def _descriptor(df):
return {
"fieldname": df.fieldname,
"label": df.label or df.fieldname,
"fieldtype": df.fieldtype,
"reqd": bool(df.reqd),
"options": df.options or "",
"read_only": bool(df.read_only) or df.fieldtype == "Read Only",
"default": df.default if isinstance(df.default, str) else "",
}
def _usable(df):
return not (
df.fieldtype in EXCLUDE_FIELDTYPES or df.fieldname in EXCLUDE_FIELDNAMES or df.hidden
)
def _main_fields(meta):
return [df for df in meta.fields if _usable(df)]
def _child_fields(child_doctype):
cmeta = frappe.get_meta(child_doctype)
out = [df for df in cmeta.fields if _usable(df) and (df.reqd or df.in_list_view)]
return out[:MAX_CHILD_FIELDS]
def _table_defs(meta):
out = []
for df in meta.fields:
if df.fieldtype == "Table" and df.options and not df.hidden:
out.append((df, _child_fields(df.options)))
return out
@frappe.whitelist()
def get_doctype_meta(doctype):
guard_read(doctype)
meta = frappe.get_meta(doctype)
writable = doctype in WRITABLE_DOCTYPES
return {
"doctype": doctype,
"writable": writable,
"submittable": bool(meta.is_submittable),
"fields": [_descriptor(df) for df in _main_fields(meta)],
"table_fields": [
{
"fieldname": df.fieldname,
"label": df.label or df.fieldname,
"child_doctype": df.options,
"fields": [_descriptor(c) for c in cfields],
}
for df, cfields in _table_defs(meta)
],
"can_create": writable and bool(frappe.has_permission(doctype, "create")),
"can_write": writable and bool(frappe.has_permission(doctype, "write")),
"can_delete": writable and bool(frappe.has_permission(doctype, "delete")),
"can_submit": writable and bool(meta.is_submittable) and bool(frappe.has_permission(doctype, "submit")),
"can_cancel": writable and bool(meta.is_submittable) and bool(frappe.has_permission(doctype, "cancel")),
}
@frappe.whitelist()
def get_doc(doctype, name):
guard_read(doctype)
doc = frappe.get_doc(doctype, name)
doc.check_permission("read")
doc.apply_fieldlevel_read_permissions()
return doc.as_dict()
@frappe.whitelist()
def link_options(doctype, txt=""):
"""Typeahead for Link fields. Permission-aware."""
if not frappe.db.exists("DocType", doctype) or not frappe.has_permission(doctype, "read"):
return []
meta = frappe.get_meta(doctype)
like = "%{0}%".format(txt or "")
or_filters = [["name", "like", like]]
for f in meta.get_search_fields():
if f != "name" and meta.has_field(f):
or_filters.append([f, "like", like])
fields = ["name"]
if meta.title_field and meta.title_field != "name" and meta.has_field(meta.title_field):
fields.append(meta.title_field)
rows = frappe.get_list(doctype, fields=fields, or_filters=or_filters, limit_page_length=10)
return [{"value": r.name, "label": r.get(meta.title_field) or r.name} for r in rows]
def _coerce(df, value):
ft = df.fieldtype
if ft == "Check":
return 1 if value in (1, "1", True, "true", "on") else 0
if value is None or (isinstance(value, str) and value.strip() == ""):
return None
try:
if ft == "Int":
return int(float(value))
if ft in ("Float", "Currency", "Percent"):
return float(value)
except (TypeError, ValueError):
frappe.throw(_("Invalid number for {0}.").format(df.label or df.fieldname))
if ft == "Datetime":
return str(value).replace("T", " ")
return value
def _clean(doctype, values):
"""Keep only fields the portal exposes. Nothing else (docstatus, owner, system
or hidden control fields) can be written through the portal."""
if isinstance(values, str):
values = frappe.parse_json(values)
values = values or {}
meta = frappe.get_meta(doctype)
cleaned = {}
for df in _main_fields(meta):
if df.read_only or df.fieldtype == "Read Only" or df.fieldname not in values:
continue
cleaned[df.fieldname] = _coerce(df, values[df.fieldname])
for df, cfields in _table_defs(meta):
if df.fieldname not in values:
continue
allowed = [c for c in cfields if not c.read_only and c.fieldtype != "Read Only"]
rows = []
for raw in values[df.fieldname] or []:
row = {c.fieldname: _coerce(c, raw.get(c.fieldname)) for c in allowed if c.fieldname in raw}
if any(v not in (None, 0, "") for v in row.values()):
rows.append(row)
cleaned[df.fieldname] = rows
return cleaned
@frappe.whitelist()
def create_doc(doctype, values):
_guard(doctype, "create")
doc = frappe.new_doc(doctype)
doc.update(_clean(doctype, values))
doc.insert()
return {"name": doc.name, "docstatus": doc.docstatus}
@frappe.whitelist()
def update_doc(doctype, name, values):
_guard(doctype, "write", name)
doc = frappe.get_doc(doctype, name)
if doc.docstatus != 0:
frappe.throw(_("{0} is submitted or cancelled. Cancel it before editing.").format(name))
doc.update(_clean(doctype, values))
doc.save()
return {"name": doc.name, "docstatus": doc.docstatus}
@frappe.whitelist()
def delete_doc(doctype, name):
_guard(doctype, "delete", name)
try:
frappe.delete_doc(doctype, name, ignore_permissions=False)
except frappe.LinkExistsError:
frappe.throw(_("Can't delete {0}: other records still link to it.").format(name))
return {"deleted": name}
@frappe.whitelist()
def submit_doc(doctype, name):
_guard(doctype, "submit", name)
doc = frappe.get_doc(doctype, name)
doc.submit()
return {"name": doc.name, "docstatus": doc.docstatus}
@frappe.whitelist()
def cancel_doc(doctype, name):
_guard(doctype, "cancel", name)
doc = frappe.get_doc(doctype, name)
doc.cancel()
return {"name": doc.name, "docstatus": doc.docstatus}
@@ -0,0 +1,38 @@
import json
import frappe
LABEL = "MAKAP Portal"
URL = "/makap-portal"
def ensure_portal_shortcut():
names = frappe.get_all(
"Workspace",
or_filters=[["name", "like", "MAKAP%"], ["label", "like", "MAKAP%"], ["title", "like", "MAKAP%"]],
pluck="name",
)
for name in names:
ws = frappe.get_doc("Workspace", name)
if not any(s.label == LABEL for s in ws.shortcuts):
ws.append("shortcuts", {"label": LABEL, "type": "URL", "url": URL})
try:
blocks = json.loads(ws.content or "[]")
except ValueError:
blocks = []
blocks = [
b for b in blocks
if not (b.get("type") == "shortcut" and b.get("data", {}).get("shortcut_name") == LABEL)
]
block = {
"id": frappe.generate_hash(length=10),
"type": "shortcut",
"data": {"shortcut_name": LABEL, "col": 3},
}
blocks.insert(1 if blocks else 0, block)
ws.content = json.dumps(blocks)
ws.flags.ignore_permissions = True
ws.save()
print("Portal shortcut set on workspace: " + name)
frappe.db.commit()
frappe.clear_cache()
@@ -0,0 +1,182 @@
import frappe
from frappe import _
WORK_ORDER_FIELDS = [
"name", "production_item", "item_name", "customer", "sales_order",
"qty", "produced_qty", "status", "planned_start_date", "planned_end_date",
"expected_delivery_date", "creation", "modified", "operation", "bom_no",
"makap_packaging_specification", "makap_specification_revision",
"makap_sales_order_item_reference", "makap_customer",
"makap_manufacturing_readiness_status", "makap_manufacturing_readiness_blockers",
"makap_bom_compatibility_status", "makap_process_compatibility_status",
"makap_tooling_readiness_status", "makap_material_availability_status",
"makap_material_availability_blocker_summary", "makap_material_close_status",
"makap_cost_status", "makap_cost_confidence", "owner",
]
def _ensure_read(doctype):
if not frappe.has_permission(doctype, "read"):
frappe.throw(_("You do not have permission to view {0}.").format(doctype), frappe.PermissionError)
def _available_fields(doctype, fields):
meta = frappe.get_meta(doctype)
available = {field.fieldname for field in meta.fields}
return [field for field in fields if field in available or field in {"name", "creation", "modified", "owner"}]
def _rows(doctype, fields, filters=None, or_filters=None, order_by="modified desc", limit=100):
if not frappe.db.exists("DocType", doctype):
return []
selected = _available_fields(doctype, fields)
try:
return frappe.get_list(doctype, fields=selected, filters=filters or {}, or_filters=or_filters, order_by=order_by, limit_page_length=limit)
except Exception:
return frappe.get_list(doctype, fields=[field for field in ["name", "modified"] if field in selected], filters=filters or {}, order_by=order_by, limit_page_length=limit)
def _number(value):
try:
return float(value or 0)
except (TypeError, ValueError):
return 0
def _readiness(row):
values = [
row.get("makap_manufacturing_readiness_status"),
row.get("makap_bom_compatibility_status"),
row.get("makap_process_compatibility_status"),
row.get("makap_tooling_readiness_status"),
row.get("makap_material_availability_status"),
]
if any(value in {"Blocked", "Incompatible"} for value in values):
return "Blocked"
if any(value in {None, "", "Not Checked"} for value in values):
return "Not Checked"
return "Ready"
@frappe.whitelist()
def get_production_summary():
_ensure_read("Work Order")
rows = _rows("Work Order", WORK_ORDER_FIELDS, {"docstatus": ["!=", 2]}, limit=0)
today = frappe.utils.getdate(frappe.utils.nowdate())
active = [row for row in rows if row.get("status") not in {"Completed", "Cancelled", "Closed"}]
readiness = [_readiness(row) for row in active]
due = [row for row in active if row.get("planned_end_date") and frappe.utils.getdate(row["planned_end_date"]) <= today]
return {
"active_work_orders": len(active),
"ready_to_release": sum(1 for value in readiness if value == "Ready"),
"blocked_work_orders": sum(1 for value in readiness if value == "Blocked"),
"due_or_overdue": len(due),
"production_plans": frappe.db.count("Production Plan") if frappe.db.exists("DocType", "Production Plan") else 0,
"operations": frappe.db.count("Operation") if frappe.db.exists("DocType", "Operation") else 0,
}
@frappe.whitelist()
def list_work_orders(search=None, status=None, readiness=None, limit_page_length=100):
_ensure_read("Work Order")
search = (search or "").strip()
status = (status or "").strip()
filters = {"docstatus": ["!=", 2]}
if status and status != "All":
filters["status"] = status
or_filters = None
if search:
like = f"%{search}%"
or_filters = [["Work Order", field, "like", like] for field in ["name", "production_item", "item_name", "customer", "sales_order"]]
rows = _rows("Work Order", WORK_ORDER_FIELDS, filters, or_filters, limit=min(max(int(limit_page_length or 100), 1), 200))
for row in rows:
row["makap_derived_readiness"] = _readiness(row)
if readiness and readiness != "All":
rows = [row for row in rows if row.get("makap_derived_readiness") == readiness]
return rows
@frappe.whitelist()
def get_work_order(name):
_ensure_read("Work Order")
if not name:
frappe.throw(_("Work Order name is required."))
doc = frappe.get_doc("Work Order", name)
if not frappe.has_permission(doc, "read"):
frappe.throw(_("You do not have permission to view this Work Order."), frappe.PermissionError)
data = doc.as_dict()
data["makap_derived_readiness"] = _readiness(data)
data["operations"] = [
{"operation": row.operation, "workstation": row.workstation, "status": row.status, "completed_qty": row.completed_qty, "planned_qty": row.planned_qty}
for row in doc.get("operations") or []
]
data["materials"] = [
{"item_code": row.item_code, "item_name": row.item_name, "required_qty": row.required_qty, "transferred_qty": row.transferred_qty, "consumed_qty": row.consumed_qty}
for row in doc.get("required_items") or doc.get("materials") or []
]
return data
@frappe.whitelist()
def list_production_plans(search=None, status=None, limit_page_length=100):
_ensure_read("Production Plan")
search = (search or "").strip()
filters = {}
if status and status != "All":
filters["status"] = status
or_filters = None
if search:
like = f"%{search}%"
or_filters = [["Production Plan", field, "like", like] for field in ["name", "company", "status"]]
fields = ["name", "posting_date", "company", "status", "get_items_from", "sales_order", "customer", "modified", "owner"]
return _rows("Production Plan", fields, filters, or_filters, limit=min(max(int(limit_page_length or 100), 1), 200))
@frappe.whitelist()
def get_production_plan(name):
_ensure_read("Production Plan")
if not name:
frappe.throw(_("Production Plan name is required."))
doc = frappe.get_doc("Production Plan", name)
if not frappe.has_permission(doc, "read"):
frappe.throw(_("You do not have permission to view this Production Plan."), frappe.PermissionError)
items = []
for table in ["po_items", "items", "mr_items"]:
for row in doc.get(table) or []:
items.append({
"item_code": row.item_code, "item_name": row.item_name, "planned_qty": row.planned_qty,
"material_need_date": row.get("makap_material_need_date"), "coverage_mode": row.get("makap_coverage_mode"),
"sales_order": row.get("sales_order"), "warehouse": row.get("warehouse"),
})
if items:
break
result = doc.as_dict()
result["plan_items"] = items
return result
@frappe.whitelist()
def list_operations(search=None, process_type=None, limit_page_length=100):
_ensure_read("Operation")
search = (search or "").strip()
filters = {}
if process_type and process_type != "All":
filters["makap_process_type"] = process_type
or_filters = None
if search:
like = f"%{search}%"
or_filters = [["Operation", field, "like", like] for field in ["name", "operation", "workstation", "description"]]
fields = ["name", "operation", "workstation", "description", "time_in_mins", "batch_size", "hour_rate", "disabled", "makap_process_type", "modified"]
return _rows("Operation", fields, filters, or_filters, limit=min(max(int(limit_page_length or 100), 1), 200))
@frappe.whitelist()
def get_operation(name):
_ensure_read("Operation")
if not name:
frappe.throw(_("Operation name is required."))
doc = frappe.get_doc("Operation", name)
if not frappe.has_permission(doc, "read"):
frappe.throw(_("You do not have permission to view this Operation."), frappe.PermissionError)
return doc.as_dict()
@@ -0,0 +1,65 @@
import frappe
from frappe import _
FIELDS = ["name", "item_code", "item_name", "inspection_type", "reference_type", "reference_name", "status", "posting_date", "inspected_by", "customer", "work_order", "makap_packaging_specification", "makap_specification_revision", "makap_work_order", "makap_quality_gate_stage", "makap_inspection_trigger", "makap_quality_eligibility", "makap_inspection_population_quantity", "makap_population_uom", "makap_sampling_unit", "makap_quality_disposition", "makap_disposition_quantity", "makap_disposition_uom", "makap_disposition_by", "makap_disposition_on", "makap_disposition_reason", "makap_defect_failure_category", "makap_quality_exception_type", "makap_reinspection_of", "makap_reinspection_type", "makap_concession_scope", "makap_concession_quantity", "makap_concession_uom", "makap_concession_reference", "makap_customer_approval_required", "makap_customer_approval_reference", "makap_inspection_equipment_reference", "makap_released_not_before", "modified"]
def _read():
if not frappe.db.exists("DocType", "Quality Inspection") or not frappe.has_permission("Quality Inspection", "read"):
frappe.throw(_("You do not have permission to view Quality Inspections."), frappe.PermissionError)
def _available(fields):
available={f.fieldname for f in frappe.get_meta("Quality Inspection").fields}
return [f for f in fields if f in available or f in {"name", "modified"}]
def _rows(filters=None, ors=None, limit=100):
return frappe.get_list("Quality Inspection", fields=_available(FIELDS), filters=filters or {}, or_filters=ors, order_by="modified desc", limit_page_length=limit)
def _search(value):
if not value: return None
like=f"%{value.strip()}%"
return [["Quality Inspection", f, "like", like] for f in ["name", "item_code", "item_name", "inspection_type", "reference_name", "customer"] if f in _available(FIELDS)]
def _state(row):
disposition=row.get("makap_quality_disposition")
eligibility=row.get("makap_quality_eligibility")
exception=row.get("makap_quality_exception_type")
if disposition in {"Hold", "Reject", "Rework", "Scrap"} or eligibility in {"Hold", "Ineligible"}: return "Blocked"
if exception and exception != "None" or disposition == "Concession": return "Exception"
if disposition == "Release" or eligibility == "Eligible": return "Released"
if disposition == "Pending" or eligibility in {None, "", "Not Determined"}: return "Pending"
return "Not assessed"
@frappe.whitelist()
def get_quality_summary():
_read(); rows=_rows({"docstatus":["!=",2]},limit=0); states=[_state(r) for r in rows]
return {"pending":states.count("Pending"),"holds":sum(1 for r in rows if r.get("makap_quality_disposition") in {"Hold"} or r.get("makap_quality_eligibility")=="Hold"),"rejections":sum(1 for r in rows if r.get("makap_quality_disposition") in {"Reject"} or r.get("makap_quality_eligibility")=="Ineligible"),"released":states.count("Released")}
@frappe.whitelist()
def list_inspections(search=None, gate_stage=None, eligibility=None, disposition=None, limit_page_length=100):
_read(); filters={"docstatus":["!=",2]}
if gate_stage and gate_stage!="All": filters["makap_quality_gate_stage"]=gate_stage
if eligibility and eligibility!="All": filters["makap_quality_eligibility"]=eligibility
if disposition and disposition!="All": filters["makap_quality_disposition"]=disposition
rows=_rows(filters,_search(search),min(max(int(limit_page_length or 100),1),200))
for r in rows: r["makap_derived_state"]=_state(r)
return rows
@frappe.whitelist()
def get_inspection(name):
_read(); doc=frappe.get_doc("Quality Inspection",name)
if not frappe.has_permission(doc,"read"): frappe.throw(_("Permission denied."),frappe.PermissionError)
data=doc.as_dict(); data["makap_derived_state"]=_state(data); return data
@frappe.whitelist()
def list_quality_exceptions(search=None, limit_page_length=100):
_read(); rows=_rows({"docstatus":["!=",2]},_search(search),min(max(int(limit_page_length or 100),1),200))
rows=[r for r in rows if _state(r) in {"Blocked","Exception","Pending"}]
for r in rows: r["makap_derived_state"]=_state(r)
return rows
@frappe.whitelist()
def list_quality_traceability(search=None, limit_page_length=100):
_read(); rows=_rows({"docstatus":["!=",2]},_search(search),min(max(int(limit_page_length or 100),1),200))
rows=[r for r in rows if r.get("makap_work_order") or r.get("work_order") or r.get("makap_packaging_specification") or r.get("reference_name")]
for r in rows: r["makap_derived_state"]=_state(r)
return rows
@@ -0,0 +1,100 @@
import frappe
from frappe import _
LIST_FIELDS = [
"name", "quotation_to", "party_name", "customer_name", "status",
"transaction_date", "valid_till", "grand_total", "currency", "modified",
]
def _ensure_read_permission():
if not frappe.has_permission("Quotation", "read"):
frappe.throw(_("You do not have permission to view Quotations."), frappe.PermissionError)
def _number(value):
try:
return float(value or 0)
except (TypeError, ValueError):
return 0
def _status_rows():
return frappe.get_list(
"Quotation",
fields=["status", "grand_total", "transaction_date", "valid_till"],
filters={"docstatus": ["!=", 2]},
limit_page_length=0,
)
@frappe.whitelist()
def get_quotation_summary():
_ensure_read_permission()
rows = _status_rows()
today = frappe.utils.getdate(frappe.utils.nowdate())
open_statuses = {"Draft", "Open", "Partially Ordered"}
active = [row for row in rows if row.get("status") in open_statuses]
expiring = [
row for row in active
if row.get("valid_till") and frappe.utils.getdate(row["valid_till"]) <= frappe.utils.add_days(today, 7)
]
return {
"total_count": len(rows),
"active_count": len(active),
"active_value": sum(_number(row.get("grand_total")) for row in active),
"expiring_count": len(expiring),
"accepted_count": sum(1 for row in rows if row.get("status") == "Accepted"),
"ordered_count": sum(1 for row in rows if row.get("status") == "Ordered"),
}
@frappe.whitelist()
def list_quotations(search=None, status=None, limit_start=0, limit_page_length=50):
_ensure_read_permission()
search = (search or "").strip()
status = (status or "").strip()
filters = {"docstatus": ["!=", 2]}
if status and status != "All":
filters["status"] = status
or_filters = None
if search:
like = f"%{search}%"
or_filters = [
["Quotation", "name", "like", like],
["Quotation", "party_name", "like", like],
["Quotation", "customer_name", "like", like],
["Quotation", "status", "like", like],
]
return frappe.get_list(
"Quotation", fields=LIST_FIELDS, filters=filters, or_filters=or_filters,
order_by="modified desc", limit_start=max(int(limit_start or 0), 0),
limit_page_length=min(max(int(limit_page_length or 50), 1), 100),
)
@frappe.whitelist()
def get_quotation(name):
_ensure_read_permission()
if not name:
frappe.throw(_("Quotation name is required."))
doc = frappe.get_doc("Quotation", name)
if not frappe.has_permission(doc, "read"):
frappe.throw(_("You do not have permission to view this Quotation."), frappe.PermissionError)
items = [{
"item_code": row.item_code, "item_name": row.item_name, "qty": row.qty,
"uom": row.uom, "rate": row.rate, "amount": row.amount,
} for row in doc.get("items") or []]
return {
"name": doc.name, "quotation_to": doc.quotation_to, "party_name": doc.party_name,
"customer_name": doc.customer_name, "status": doc.status,
"transaction_date": doc.transaction_date, "valid_till": doc.valid_till,
"grand_total": doc.grand_total, "net_total": doc.net_total,
"total_taxes_and_charges": doc.total_taxes_and_charges, "currency": doc.currency,
"order_type": doc.order_type, "contact_person": doc.contact_person,
"contact_email": doc.contact_email, "contact_mobile": doc.contact_mobile,
"customer_address": doc.customer_address, "territory": doc.territory,
"owner": doc.owner, "modified": doc.modified, "terms": doc.terms,
"items": items,
}
@@ -0,0 +1,50 @@
import json
import os
import frappe
def configure( ):
client_id = os.environ.get("MAKAP_GOOGLE_CLIENT_ID")
client_secret = os.environ.get("MAKAP_GOOGLE_CLIENT_SECRET")
if not client_id or not client_secret:
raise RuntimeError("MAKAP Google Client ID and Client Secret are required")
values = {
"enable_social_login": 1,
"social_login_provider": "Google",
"provider_name": "Google",
"client_id": client_id,
"client_secret": client_secret,
"sign_ups": "Allow",
"authorize_url": "https://accounts.google.com/o/oauth2/v2/auth",
"access_token_url": "https://oauth2.googleapis.com/token",
"api_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
"redirect_url": "http://makap.local/api/method/frappe.integrations.oauth2_logins.login_via_google",
"auth_url_data": json.dumps({
"response_type": "code",
"scope": "openid email profile",
"access_type": "offline",
"prompt": "select_account",
} ),
"custom_base_url": 0,
}
if frappe.db.exists("Social Login Key", "google"):
doc = frappe.get_doc("Social Login Key", "google")
doc.update(values)
else:
doc = frappe.get_doc({"doctype": "Social Login Key", "name": "google", **values})
doc.save(ignore_permissions=True)
frappe.db.commit()
return {
"name": doc.name,
"provider_name": doc.provider_name,
"social_login_provider": doc.social_login_provider,
"enabled": doc.enable_social_login,
"sign_ups": doc.sign_ups,
"auth_url_data": doc.auth_url_data,
"redirect_url": doc.redirect_url,
}
@@ -0,0 +1,28 @@
import frappe
from frappe import _
def read():
if not frappe.has_permission("Delivery Note","read"): frappe.throw(_("You do not have permission to view returns."),frappe.PermissionError)
def fs():
a={f.fieldname for f in frappe.get_meta("Delivery Note").fields}; return [f for f in ["name","customer","posting_date","status","is_return","return_against","company","grand_total","currency","modified"] if f in a]
def state(r):
vals=[r.get("makap_return_quality_disposition") for r in r.get("return_items",[])]; v=next((x for x in vals if x),None)
return "Released" if v=="Release" else "Rejected" if v in {"Reject","Scrap"} else "Blocked" if v in {"Hold","Rework","Pending",None} else "Exception"
def rows(search=None):
ors=[["Delivery Note",f,"like",f"%{search.strip()}%"] for f in ["name","customer","return_against","company"]] if search else None
return frappe.get_list("Delivery Note",fields=fs(),filters={"is_return":1,"docstatus":["!=",2]},or_filters=ors,order_by="modified desc",limit_page_length=200)
def with_items(r):
d=frappe.get_doc("Delivery Note",r["name"]); x=d.as_dict(); x["return_items"]=[{k:i.get(k) for k in ["item_code","item_name","qty","warehouse","sales_order","sales_order_item","makap_return_reason","makap_return_quality_disposition","makap_packaging_specification","makap_specification_revision","makap_work_order","makap_customer_trace_key"]} for i in d.get("items") or []]; x["makap_derived_state"]=state(x); return x
@frappe.whitelist()
def get_returns_summary():
read(); rs=[with_items(r) for r in rows()]; states=[state(r) for r in rs]; return {"pending":states.count("Blocked"),"holds":sum(1 for r in rs if any(i.get("makap_return_quality_disposition") in {"Hold","Pending","Rework"} for i in r["return_items"])),"released":states.count("Released"),"rejected":states.count("Rejected")}
@frappe.whitelist()
def list_returns(search=None,display_state=None,limit_page_length=100):
read(); rs=[with_items(r) for r in rows(search)];
return [dict(r,makap_derived_state=state(r)) for r in rs if not display_state or display_state=="All" or state(r)==display_state][:min(max(int(limit_page_length or 100),1),200)]
@frappe.whitelist()
def get_return(name):
read(); return with_items({"name":name})
@frappe.whitelist()
def list_return_exceptions(search=None,limit_page_length=100): return list_returns(search,"Blocked",limit_page_length)
@frappe.whitelist()
def list_return_traceability(search=None,limit_page_length=100): return list_returns(search,None,limit_page_length)
@@ -0,0 +1,102 @@
import frappe
from frappe import _
LIST_FIELDS = [
"name", "customer", "customer_name", "status", "order_type",
"transaction_date", "delivery_date", "grand_total", "currency",
"per_delivered", "per_billed", "contact_person", "modified",
]
def _ensure_read_permission():
if not frappe.has_permission("Sales Order", "read"):
frappe.throw(_("You do not have permission to view Sales Orders."), frappe.PermissionError)
def _number(value):
try:
return float(value or 0)
except (TypeError, ValueError):
return 0
def _order_rows():
return frappe.get_list(
"Sales Order",
fields=["status", "grand_total", "per_delivered", "per_billed", "delivery_date"],
filters={"docstatus": 1},
limit_page_length=0,
)
@frappe.whitelist()
def get_sales_order_summary():
_ensure_read_permission()
rows = _order_rows()
active_statuses = {"To Deliver and Bill", "To Bill", "To Deliver", "Overdue"}
active = [row for row in rows if row.get("status") in active_statuses]
delivery_due = [row for row in active if _number(row.get("per_delivered")) < 100]
billing_due = [row for row in active if _number(row.get("per_billed")) < 100]
return {
"active_count": len(active),
"active_value": sum(_number(row.get("grand_total")) for row in active),
"delivery_due_count": len(delivery_due),
"billing_due_count": len(billing_due),
"completed_count": sum(1 for row in rows if row.get("status") == "Completed"),
"total_count": len(rows),
}
@frappe.whitelist()
def list_sales_orders(search=None, status=None, limit_start=0, limit_page_length=50):
_ensure_read_permission()
search = (search or "").strip()
status = (status or "").strip()
filters = {"docstatus": ["!=", 2]}
if status and status != "All":
filters["status"] = status
or_filters = None
if search:
like = f"%{search}%"
or_filters = [
["Sales Order", "name", "like", like],
["Sales Order", "customer", "like", like],
["Sales Order", "customer_name", "like", like],
["Sales Order", "status", "like", like],
]
return frappe.get_list(
"Sales Order", fields=LIST_FIELDS, filters=filters, or_filters=or_filters,
order_by="modified desc", limit_start=max(int(limit_start or 0), 0),
limit_page_length=min(max(int(limit_page_length or 50), 1), 100),
)
@frappe.whitelist()
def get_sales_order(name):
_ensure_read_permission()
if not name:
frappe.throw(_("Sales Order name is required."))
doc = frappe.get_doc("Sales Order", name)
if not frappe.has_permission(doc, "read"):
frappe.throw(_("You do not have permission to view this Sales Order."), frappe.PermissionError)
items = [{
"item_code": row.item_code, "item_name": row.item_name, "qty": row.qty,
"delivered_qty": row.delivered_qty, "billed_qty": row.billed_qty,
"uom": row.uom, "rate": row.rate, "amount": row.amount,
"delivery_date": row.delivery_date,
} for row in doc.get("items") or []]
return {
"name": doc.name, "customer": doc.customer, "customer_name": doc.customer_name,
"status": doc.status, "order_type": doc.order_type,
"transaction_date": doc.transaction_date, "delivery_date": doc.delivery_date,
"grand_total": doc.grand_total, "net_total": doc.net_total,
"total_taxes_and_charges": doc.total_taxes_and_charges, "currency": doc.currency,
"per_delivered": doc.per_delivered, "per_billed": doc.per_billed,
"contact_person": doc.contact_person, "contact_email": doc.contact_email,
"contact_mobile": doc.contact_mobile, "customer_address": doc.customer_address,
"shipping_address_name": doc.shipping_address_name, "territory": doc.territory,
"sales_partner": doc.sales_partner, "owner": doc.owner,
"po_no": doc.po_no, "po_date": doc.po_date, "modified": doc.modified,
"terms": doc.terms, "items": items,
}
+10 -30
View File
@@ -97,6 +97,12 @@ def ensure_makap_functional_roles():
"Materials Controller",
"Supply Assurance Manager",
"Concession Authority",
# Module 8A
"Dispatch Preparer",
"Dispatch Authority",
"Commercial Authority",
# Module 8B
"Credit Authority",
]
created = []
for role_name in role_names:
@@ -125,36 +131,10 @@ def ensure_makap_object_permissions():
"Production Authority": {"read": 1, "print": 1, "report": 1},
"Quality Manager": {"read": 1, "print": 1, "report": 1},
},
}
for doctype_name, role_map in permissions.items():
doc = frappe.get_doc("DocType", doctype_name)
existing = {perm.role for perm in doc.permissions}
changed = False
for role_name, flags in role_map.items():
if role_name in existing:
continue
row = {"role": role_name}
row.update(flags)
doc.append("permissions", row)
changed = True
if changed:
doc.save(ignore_permissions=True)
frappe.db.commit()
return permissions
def ensure_makap_object_permissions():
permissions = {
"Customer Onboarding Request": {
"Commercial Reviewer": {"read": 1, "write": 1, "create": 1, "print": 1, "report": 1, "export": 1},
},
"Packaging Specification": {
"Commercial Reviewer": {"read": 1, "write": 1, "create": 1, "print": 1, "report": 1, "export": 1},
"Technical Authority": {"read": 1, "write": 1, "create": 1, "print": 1, "report": 1, "export": 1},
"Production Authority": {"read": 1, "print": 1, "report": 1},
"Quality Manager": {"read": 1, "print": 1, "report": 1},
"Order Closure And Margin View": {
"Commercial Reviewer": {"read": 1, "print": 1, "report": 1, "export": 1},
"Credit Authority": {"read": 1, "write": 1, "print": 1, "report": 1, "export": 1},
"Dispatch Authority": {"read": 1, "print": 1, "report": 1},
},
}
@@ -75,6 +75,64 @@ FIELDS = [
("Purchase Order Item", "makap_supplier_confirmation_reference", "Supplier Confirmation Reference", "Data", None, 0),
("Purchase Order Item", "makap_expected_eligible_date", "Expected Eligible Date", "Date", None, 0),
("Purchase Receipt Item", "makap_receipt_exception_type", "Receipt Exception Type", "Select", "None\nShort Receipt\nOver Receipt\nDamaged\nQuality Hold\nRejected\nDocumentation Exception", 0),
# Module 8A — dispatch and fulfilment control (Delivery Note header)
("Delivery Note", "makap_dispatch_gate_status", "Dispatch Gate Status", "Select", "Not Assessed\nBlocked\nReady", 1),
("Delivery Note", "makap_dispatch_gate_blockers", "Dispatch Gate Blockers", "Small Text", None, 1),
("Delivery Note", "makap_dispatch_gate_checked_by", "Dispatch Gate Checked By", "Link", "User", 1),
("Delivery Note", "makap_dispatch_gate_checked_on", "Dispatch Gate Checked On", "Datetime", None, 1),
("Delivery Note", "makap_dispatch_material_accountability_status", "Dispatch Material Accountability", "Select", "Not Assessed\nCleared\nException", 0),
("Delivery Note", "makap_dispatch_accountability_notes", "Dispatch Accountability Notes", "Small Text", None, 0),
# Placeholder only — real deposit/credit logic lands in Module 8B. Defaults to Not Required
# so the Dispatch Gate can already evaluate it without blocking every delivery today.
("Delivery Note", "makap_financial_release_status", "Financial Release Status", "Select", "Not Required\nNot Assessed\nBlocked\nCleared\nException Approved", 0),
("Delivery Note", "makap_short_close", "Short-Close", "Check", None, 0),
("Delivery Note", "makap_short_close_reason", "Short-Close Reason", "Small Text", None, 0),
("Delivery Note", "makap_short_close_approved_by", "Short-Close Approved By", "Link", "User", 1),
("Delivery Note", "makap_over_delivery_approved_by", "Over-Delivery Approved By", "Link", "User", 1),
# Module 8A — dispatch and fulfilment control (Delivery Note Item)
("Delivery Note Item", "makap_packaging_specification", "Packaging Specification", "Link", "Packaging Specification", 1),
("Delivery Note Item", "makap_specification_revision", "Specification Revision", "Data", None, 1),
("Delivery Note Item", "makap_sales_order_item_reference", "Sales Order Item Reference", "Data", None, 1),
# No default on purpose — Reserved vs Pooled is a per-line decision the preparer must make
# explicitly until Makap sets a global default policy (see M8-OD-04, still open).
("Delivery Note Item", "makap_dispatch_allocation_mode", "Dispatch Allocation Mode", "Select", "\nReserved\nPooled", 0),
("Delivery Note Item", "makap_stock_reservation_entry", "Stock Reservation Entry", "Link", "Stock Reservation Entry", 0),
("Delivery Note Item", "makap_work_order", "Work Order", "Link", "Work Order", 0),
("Delivery Note Item", "makap_customer_trace_key", "Customer Trace Key", "Data", None, 0),
# Module 8B — financial release, deposit/credit and commercial billing
("Customer", "makap_credit_exposure_status", "Credit Exposure Status", "Select", "Not Assessed\nWithin Limit\nOver Limit\nFrozen", 0),
("Customer", "makap_financial_hold", "Financial Hold", "Check", None, 0),
("Customer", "makap_financial_hold_reason", "Financial Hold Reason", "Small Text", None, 0),
("Sales Order", "makap_deposit_required", "Deposit Required", "Check", None, 0),
("Sales Order", "makap_deposit_required_amount", "Deposit Required Amount", "Currency", None, 0),
# Derived on Sales Invoice/Payment Entry submit-cancel — see _recompute_billing_and_collection.
("Sales Order", "makap_billing_status", "Billing Status", "Select", "Not Billable\nUnbilled\nPartially Billed\nCommercially Billed", 1),
("Sales Order", "makap_delivered_unbilled", "Delivered-Unbilled Exposure", "Check", None, 1),
("Sales Order", "makap_collection_status", "Collection Status", "Select", "Not Yet Due\nDue\nPartially Paid\nPaid", 1),
("Payment Entry", "makap_payment_purpose", "Payment Purpose", "Select", "Deposit\nProgress Payment\nFinal Settlement\nOther", 0),
# Module 8B/8C — commercial billing and Uganda EFRIS fiscalisation (Sales Invoice)
("Sales Invoice", "makap_delivery_note_reference", "Delivery Note Reference", "Data", None, 1),
# Not Required is the default so non-EFRIS transactions aren't blocked before Makap confirms
# the integration method (M8-OD-01/02, still open).
("Sales Invoice", "makap_fiscalisation_status", "Fiscalisation Status", "Select", "Not Required\nPending\nFiscalised\nFailed", 0),
("Sales Invoice", "makap_fiscal_document_number", "Fiscal Document Number", "Data", None, 0),
("Sales Invoice", "makap_fiscal_verification_code", "Fiscal Verification/Reference Code", "Data", None, 0),
("Sales Invoice", "makap_fiscalisation_timestamp", "Fiscalisation Timestamp", "Datetime", None, 0),
("Sales Invoice", "makap_original_fiscal_document_reference", "Original Fiscal Document Reference", "Data", None, 0),
("Sales Invoice Item", "makap_delivery_note_item_reference", "Delivery Note Item Reference", "Data", None, 1),
# Module 8D — customer returns and replacement
("Warehouse", "makap_is_returns_hold_warehouse", "Is Returns Hold Warehouse", "Check", None, 0),
("Delivery Note Item", "makap_return_reason", "Return Reason", "Select", "\nQuality Defect\nWrong Item\nDamaged In Transit\nCustomer Changed Mind\nOver-Delivery Correction\nOther", 0),
("Delivery Note Item", "makap_return_quality_disposition", "Return Quality Disposition", "Select", "\nPending\nRelease\nReject\nRework\nScrap\nConcession", 0),
# Module 8E — supplier financial reconciliation
("Purchase Invoice", "makap_supplier_invoice_match_status", "Supplier Invoice Match Status", "Select", "Not Assessed\nMatched\nQuantity Variance\nRate Variance\nBoth Variance\nDisputed\nResolved", 0),
("Purchase Invoice", "makap_match_notes", "Match Notes", "Small Text", None, 0),
("Purchase Invoice Item", "makap_quantity_variance", "Quantity Variance", "Float", None, 1),
("Purchase Invoice Item", "makap_rate_variance", "Rate Variance", "Float", None, 1),
# Module 8F — manufacturing execution cost interpretation
("Work Order", "makap_cost_status", "Cost Status", "Select", "Not Assessed\nProvisional\nReady to Close\nClosed\nRestatement Required", 0),
("Work Order", "makap_cost_confidence", "Cost Confidence", "Select", "Not Assessed\nHigh\nQualified\nLow / Provisional", 0),
("Work Order", "makap_cost_attribution_basis", "Cost Attribution Basis", "Select", "Not Assessed\nExact\nDirectly Derived\nPooled / Valuation-Based\nQualified", 0),
]
@@ -94,7 +152,7 @@ def ensure_custom_fields():
"fieldtype": fieldtype,
"options": options,
"read_only": read_only,
"insert_after": "amended_from" if dt in {"Sales Order", "Work Order", "Quality Inspection"} else None,
"insert_after": "amended_from" if dt in {"Sales Order", "Work Order", "Quality Inspection", "Delivery Note", "Sales Invoice", "Purchase Invoice"} else None,
}
)
doc.insert(ignore_permissions=True)
@@ -0,0 +1,74 @@
import json
import frappe
# (label, document_type, filters, color)
# filters use standard Frappe list-view format: [doctype, fieldname, operator, value]
MODULE8_NUMBER_CARDS = [
(
"Dispatch Ready",
"Delivery Note",
[["Delivery Note", "makap_dispatch_gate_status", "=", "Ready"], ["Delivery Note", "docstatus", "=", 1]],
"#29CD42",
),
(
"Dispatch Blocked",
"Delivery Note",
[["Delivery Note", "makap_dispatch_gate_status", "=", "Blocked"]],
"#FF5858",
),
(
"Delivered Unbilled Orders",
"Sales Order",
[["Sales Order", "makap_delivered_unbilled", "=", 1], ["Sales Order", "docstatus", "=", 1]],
"#FFA00A",
),
(
"Fiscalisation Pending Or Failed",
"Sales Invoice",
[["Sales Invoice", "makap_fiscalisation_status", "in", ["Pending", "Failed"]], ["Sales Invoice", "docstatus", "=", 1]],
"#FF5858",
),
(
"Supplier Invoice Variance",
"Purchase Invoice",
[
["Purchase Invoice", "makap_supplier_invoice_match_status", "in", ["Quantity Variance", "Rate Variance", "Both Variance", "Disputed"]],
["Purchase Invoice", "docstatus", "=", 1],
],
"#FFA00A",
),
(
"Collection Due",
"Sales Order",
[["Sales Order", "makap_collection_status", "in", ["Due"]], ["Sales Order", "docstatus", "=", 1]],
"#FFA00A",
),
]
def ensure_module8_number_cards():
created = []
for label, doctype, filters, color in MODULE8_NUMBER_CARDS:
if not frappe.db.exists("DocType", doctype):
continue
if frappe.db.exists("Number Card", label):
continue
frappe.get_doc(
{
"doctype": "Number Card",
"label": label,
"document_type": doctype,
"type": "Document Type",
"function": "Count",
"filters_json": json.dumps(filters),
"is_public": 1,
"show_percentage_stats": 0,
"color": color,
"module": "makap_backend",
}
).insert(ignore_permissions=True)
created.append(label)
frappe.db.commit()
return created
@@ -0,0 +1,43 @@
import frappe
from frappe.permissions import add_permission, update_permission_property
def _grant(doctype, role, **flags):
if not frappe.db.exists("DocType", doctype):
return
if not frappe.db.exists("Role", role):
return
if not frappe.db.exists("Custom DocPerm", {"parent": doctype, "role": role, "permlevel": 0}):
add_permission(doctype, role, permlevel=0)
for ptype, value in flags.items():
update_permission_property(doctype, role, 0, ptype, value)
def ensure_module8_role_permissions():
grants = [
# Module 8A — dispatch
("Delivery Note", "Dispatch Preparer", dict(read=1, write=1, create=1)),
("Delivery Note", "Dispatch Authority", dict(read=1, write=1, submit=1, cancel=1)),
("Delivery Note", "Commercial Authority", dict(read=1, write=1)),
# Module 8B — financial release and billing
("Sales Invoice", "Commercial Authority", dict(read=1, write=1, create=1, submit=1, cancel=1)),
("Sales Invoice", "Dispatch Authority", dict(read=1)),
("Sales Invoice", "Credit Authority", dict(read=1)),
("Customer", "Credit Authority", dict(read=1, write=1)),
("Sales Order", "Credit Authority", dict(read=1)),
("Payment Entry", "Credit Authority", dict(read=1, write=1, create=1, submit=1, cancel=1)),
# Module 8E — supplier reconciliation (extends the existing M7 role)
("Purchase Invoice", "Supply Assurance Manager", dict(read=1, write=1, create=1, submit=1)),
# Module 8F — closure/margin view
("Order Closure And Margin View", "Credit Authority", dict(read=1, write=1)),
("Order Closure And Margin View", "Dispatch Authority", dict(read=1)),
("Order Closure And Margin View", "Commercial Authority", dict(read=1)),
]
applied = []
for doctype, role, flags in grants:
_grant(doctype, role, **flags)
applied.append(f"{role} -> {doctype}")
frappe.db.commit()
return applied
@@ -0,0 +1,53 @@
import json
import frappe
# (label, document_type, filters, color)
WORKSPACE_NUMBER_CARDS = [
("Onboarding Requests", "Customer Onboarding Request", [], "#2E6BC7"),
("Packaging Specifications", "Packaging Specification", [], "#6C4FBB"),
(
"Approved for Manufacture",
"Packaging Specification",
[["Packaging Specification", "specification_status", "=", "Approved for Manufacture"]],
"#1F9D4B",
),
# Child table of Packaging Specification — Number Card may not support this
# as document_type. Wrapped in try/except below; if it fails, it's simply
# skipped (logged) rather than breaking the rest of after_migrate.
("Critical Requirements Tracked", "Critical Acceptance Requirement", [], "#C97B0A"),
]
def ensure_workspace_number_cards():
created = []
for label, doctype, filters, color in WORKSPACE_NUMBER_CARDS:
if not frappe.db.exists("DocType", doctype):
continue
if frappe.db.exists("Number Card", label):
created.append(label)
continue
try:
frappe.get_doc(
{
"doctype": "Number Card",
"label": label,
"document_type": doctype,
"type": "Document Type",
"function": "Count",
"filters_json": json.dumps(filters),
"is_public": 1,
"show_percentage_stats": 0,
"color": color,
"module": "makap_backend",
}
).insert(ignore_permissions=True)
created.append(label)
except Exception:
frappe.log_error(
title=f"MAKAP Number Card creation failed: {label}",
message=frappe.get_traceback(),
)
frappe.db.commit()
return created
@@ -0,0 +1,134 @@
import frappe
from frappe import _
def _ensure_read_permission(doctype):
if not frappe.has_permission(doctype, "read"):
frappe.throw(_("You do not have permission to view {0}.").format(doctype), frappe.PermissionError)
def _number(value):
try:
return float(value or 0)
except (TypeError, ValueError):
return 0
@frappe.whitelist()
def get_specification_summary():
_ensure_read_permission("Packaging Specification")
specs = frappe.get_list("Packaging Specification", fields=["specification_status", "quote_eligible"], limit_page_length=0)
items = frappe.get_list("Item", fields=["disabled", "is_stock_item"], limit_page_length=0) if frappe.has_permission("Item", "read") else []
return {
"specifications": len(specs),
"approved_specs": sum(1 for row in specs if row.get("specification_status") == "Approved for Manufacture"),
"quote_ready": sum(1 for row in specs if row.get("quote_eligible")),
"items": len(items),
"active_items": sum(1 for row in items if not row.get("disabled")),
}
@frappe.whitelist()
def list_specifications(search=None, status=None, limit_page_length=50):
_ensure_read_permission("Packaging Specification")
search = (search or "").strip()
status = (status or "").strip()
filters = {}
if status and status != "All":
filters["specification_status"] = status
or_filters = None
if search:
like = f"%{search}%"
or_filters = [
["Packaging Specification", "name", "like", like],
["Packaging Specification", "specification_series", "like", like],
["Packaging Specification", "customer", "like", like],
["Packaging Specification", "item", "like", like],
["Packaging Specification", "product_family", "like", like],
]
return frappe.get_list(
"Packaging Specification",
fields=["name", "specification_series", "revision", "customer", "item", "product_family", "specification_status", "quote_eligible", "current_approved_revision", "modified"],
filters=filters, or_filters=or_filters, order_by="modified desc", limit_page_length=min(max(int(limit_page_length or 50), 1), 100),
)
@frappe.whitelist()
def get_specification(name):
_ensure_read_permission("Packaging Specification")
doc = frappe.get_doc("Packaging Specification", name)
material_rows = [{
"sequence": row.sequence, "material_film_description": row.material_film_description,
"raw_material_item": row.raw_material_item, "nominal_thickness_requirement": row.nominal_thickness_requirement,
"uom": row.uom, "layer_role_notes": row.layer_role_notes,
} for row in doc.get("material_structure") or []]
acceptance_rows = [{
"characteristic": row.characteristic, "requirement_nominal": row.requirement_nominal,
"unit": row.unit, "minimum_lower_tolerance": row.minimum_lower_tolerance,
"maximum_upper_tolerance": row.maximum_upper_tolerance, "notes": row.notes,
} for row in doc.get("critical_acceptance_requirements") or []]
return {
"name": doc.name, "specification_series": doc.specification_series, "revision": doc.revision,
"customer": doc.customer, "item": doc.item, "product_family": doc.product_family,
"specification_status": doc.specification_status, "quote_eligible": doc.quote_eligible,
"current_approved_revision": doc.current_approved_revision, "commercially_referenced": doc.commercially_referenced,
"customer_facing_technical_summary": doc.customer_facing_technical_summary,
"material_substrate_summary": doc.material_substrate_summary, "width": doc.width,
"width_unit": doc.width_unit, "length": doc.length, "length_unit": doc.length_unit,
"thickness_gauge": doc.thickness_gauge, "thickness_gauge_unit": doc.thickness_gauge_unit,
"printing_required": doc.printing_required, "print_process": doc.print_process,
"lamination_required": doc.lamination_required, "slitting_required": doc.slitting_required,
"rewinding_required": doc.rewinding_required, "conversion_bag_making_requirement": doc.conversion_bag_making_requirement,
"finishing_requirement": doc.finishing_requirement, "artwork_required": doc.artwork_required,
"artwork_status": doc.artwork_status, "colour_count": doc.colour_count,
"print_tooling_required": doc.print_tooling_required, "tooling_type": doc.tooling_type,
"modified": doc.modified, "material_structure": material_rows, "acceptance_requirements": acceptance_rows,
}
@frappe.whitelist()
def list_items(search=None, limit_page_length=50):
_ensure_read_permission("Item")
search = (search or "").strip()
or_filters = None
if search:
like = f"%{search}%"
or_filters = [["Item", field, "like", like] for field in ["name", "item_name", "item_group", "description"]]
return frappe.get_list(
"Item", fields=["name", "item_code", "item_name", "item_group", "stock_uom", "is_stock_item", "disabled", "description", "image", "modified"],
filters={}, or_filters=or_filters, order_by="modified desc", limit_page_length=min(max(int(limit_page_length or 50), 1), 100),
)
@frappe.whitelist()
def get_item(name):
_ensure_read_permission("Item")
doc = frappe.get_doc("Item", name)
return {
"name": doc.name, "item_code": doc.item_code, "item_name": doc.item_name,
"item_group": doc.item_group, "stock_uom": doc.stock_uom, "is_stock_item": doc.is_stock_item,
"disabled": doc.disabled, "description": doc.description, "image": doc.image,
"standard_rate": doc.standard_rate, "last_purchase_rate": doc.last_purchase_rate,
"modified": doc.modified,
}
@frappe.whitelist()
def list_child_rows(doctype, search=None, limit_page_length=100):
"""List child rows through their parent Packaging Specification for review."""
if doctype not in {"Material Structure", "Critical Acceptance Requirement"}:
frappe.throw(_("Unsupported specification child table."))
_ensure_read_permission("Packaging Specification")
specs = frappe.get_list("Packaging Specification", fields=["name", "specification_series", "revision", "customer", "item", "specification_status"], limit_page_length=0)
result = []
needle = (search or "").strip().lower()
for spec in specs:
doc = frappe.get_doc("Packaging Specification", spec.name)
rows = doc.get("material_structure") if doctype == "Material Structure" else doc.get("critical_acceptance_requirements")
for row in rows or []:
data = row.as_dict()
data.update({"parent": spec.name, "specification_series": spec.specification_series, "revision": spec.revision, "customer": spec.customer, "item": spec.item, "specification_status": spec.specification_status})
if needle and needle not in " ".join(str(value or "") for value in data.values()).lower():
continue
result.append(data)
return result[:min(max(int(limit_page_length or 100), 1), 200)]
@@ -0,0 +1,80 @@
import frappe
from frappe import _
INV_FIELDS=["name","supplier","posting_date","due_date","status","grand_total","outstanding_amount","currency","company","bill_no","bill_date","modified"]
ITEM_FIELDS=["item_code","item_name","qty","received_qty","rate","amount","purchase_order","purchase_receipt","uom","makap_quantity_variance","makap_rate_variance"]
def _read(dt):
if not frappe.db.exists("DocType",dt) or not frappe.has_permission(dt,"read"):
frappe.throw(_("You do not have permission to view {0}.").format(dt),frappe.PermissionError)
def _fields(dt,fs):
available={f.fieldname for f in frappe.get_meta(dt).fields}
return [f for f in fs if f in available or f in {"name","modified"}]
def _ors(dt,search,fields):
if not search:return None
like=f"%{search.strip()}%"
return [[dt,f,"like",like] for f in fields if f in _fields(dt,fields)]
def _invoices(search=None):
return frappe.get_list("Purchase Invoice",fields=_fields("Purchase Invoice",INV_FIELDS),filters={"docstatus":["!=",2]},or_filters=_ors("Purchase Invoice",search,["name","supplier","bill_no","company"]),order_by="modified desc",limit_page_length=200)
def _status(row):
status=row.get("makap_supplier_invoice_match_status") or "Not Assessed"
return status
def _detail(name):
doc=frappe.get_doc("Purchase Invoice",name)
if not frappe.has_permission(doc,"read"):frappe.throw(_("Permission denied."),frappe.PermissionError)
data=doc.as_dict()
data["items"]=[{k:r.get(k) for k in ITEM_FIELDS} for r in doc.get("items") or []]
data["makap_match_status"]=_status(data)
return data
@frappe.whitelist()
def get_supplier_summary():
_read("Purchase Invoice")
rows=_invoices()
statuses=[]
for r in rows:
statuses.append(frappe.db.get_value("Purchase Invoice",r["name"],"makap_supplier_invoice_match_status") or "Not Assessed")
return {"open_invoices":sum(1 for r in rows if r.get("status") not in {"Paid","Cancelled","Completed"}),"awaiting_match":statuses.count("Not Assessed"),"quantity_variances":sum(1 for r in statuses if r=="Quantity Variance"),"rate_variances":sum(1 for r in statuses if r=="Rate Variance"),"disputed":statuses.count("Disputed"),"resolved":statuses.count("Resolved")}
@frappe.whitelist()
def list_purchase_invoices(search=None,match_status=None,limit_page_length=100):
_read("Purchase Invoice")
rows=_invoices(search)
if match_status and match_status!="All":
rows=[r for r in rows if (frappe.db.get_value("Purchase Invoice",r["name"],"makap_supplier_invoice_match_status") or "Not Assessed")==match_status]
for r in rows:r["makap_supplier_invoice_match_status"]=frappe.db.get_value("Purchase Invoice",r["name"],"makap_supplier_invoice_match_status") or "Not Assessed"
return rows[:min(max(int(limit_page_length or 100),1),200)]
@frappe.whitelist()
def get_purchase_invoice(name):
_read("Purchase Invoice")
return _detail(name)
@frappe.whitelist()
def list_supplier_variances(search=None,match_status=None,limit_page_length=100):
_read("Purchase Invoice")
rows=_invoices(search)
target={"Quantity Variance","Rate Variance","Both Variance","Disputed"}
if match_status and match_status!="All":target={match_status}
out=[]
for r in rows:
status=frappe.db.get_value("Purchase Invoice",r["name"],"makap_supplier_invoice_match_status") or "Not Assessed"
if status in target:
r["makap_supplier_invoice_match_status"]=status;out.append(r)
return out[:min(max(int(limit_page_length or 100),1),200)]
@frappe.whitelist()
def list_supplier_cost(search=None,limit_page_length=100):
_read("Purchase Invoice")
out=[]
for inv in _invoices(search):
doc=frappe.get_doc("Purchase Invoice",inv["name"])
status=doc.get("makap_supplier_invoice_match_status") or "Not Assessed"
for row in doc.get("items") or []:
out.append({"invoice":doc.name,"supplier":doc.supplier,"posting_date":doc.posting_date,"item_code":row.item_code,"item_name":row.item_name,"qty":row.qty,"received_qty":row.received_qty,"rate":row.rate,"amount":row.amount,"purchase_order":row.purchase_order,"purchase_receipt":row.purchase_receipt,"quantity_variance":row.get("makap_quantity_variance"),"rate_variance":row.get("makap_rate_variance"),"match_status":status,"currency":doc.currency})
return out[:min(max(int(limit_page_length or 100),1),300)]
+82 -284
View File
@@ -1,47 +1,60 @@
import json
import frappe
from makap_backend.makap_backend.setup_workspace_dashboard import WORKSPACE_NUMBER_CARDS
SHORTCUTS = [
("Customer Onboarding Request", "Customer Onboarding Request"),
("Packaging Specification", "Packaging Specification"),
("Material Structure", "Material Structure"),
("Critical Acceptance Requirement", "Critical Acceptance Requirement"),
WORKSPACE_NUMBER_CARD_LABELS = [row[0] for row in WORKSPACE_NUMBER_CARDS]
# (label, doctype, color) — color: "Green" flags the 5 MAKAP custom doctypes,
# "Gray" is stock ERPNext/Frappe. Grouped and ordered per the approved mockup.
SHORTCUT_GROUPS = [
("Customer & commercial — M1–2", [
("Customer Onboarding Request", "Customer Onboarding Request", "Green"),
("Customer", "Customer", "Gray"),
("Contact", "Contact", "Gray"),
("Opportunity", "Opportunity", "Gray"),
("Quotation", "Quotation", "Gray"),
("Sales Order", "Sales Order", "Gray"),
]),
("Specification & prepress — M3", [
("Packaging Specification", "Packaging Specification", "Green"),
("Material Structure", "Material Structure", "Green"),
("Critical Acceptance Requirement", "Critical Acceptance Requirement", "Green"),
("Item", "Item", "Gray"),
]),
("Production & planning — M4", [
("Operation", "Operation", "Gray"),
("Work Order", "Work Order", "Gray"),
("Production Plan", "Production Plan", "Gray"),
]),
("Materials & inventory — M5", [
("Material Request", "Material Request", "Gray"),
("Purchase Order", "Purchase Order", "Gray"),
("Purchase Receipt", "Purchase Receipt", "Gray"),
]),
("Quality — M6", [
("Quality Inspection", "Quality Inspection", "Gray"),
]),
("Fulfilment & billing — M8", [
("Delivery Note", "Delivery Note", "Gray"),
("Sales Invoice", "Sales Invoice", "Gray"),
("Payment Entry", "Payment Entry", "Gray"),
("Purchase Invoice", "Purchase Invoice", "Gray"),
]),
("Operating control", [
("Order Closure And Margin View", "Order Closure And Margin View", "Green"),
]),
("Reference & lookups", [
("User", "User", "Gray"),
("UOM", "UOM", "Gray"),
]),
]
def ensure_makap_workspace():
content = [
{
"id": "makap_backend_header",
"type": "header",
"data": {"text": "MAKAP Backend", "col": 12},
}
]
for index, (label, doctype_name) in enumerate(SHORTCUTS, start=1):
content.append(
{
"id": f"makap_shortcut_{index}",
"type": "shortcut",
"data": {
"shortcut_name": label,
"col": 3,
"link_to": doctype_name,
"type": "DocType",
},
}
)
if frappe.db.exists("Workspace", "MAKAP Backend"):
workspace = frappe.get_doc("Workspace", "MAKAP Backend")
workspace.title = "MAKAP Backend"
workspace.label = "MAKAP Backend"
workspace.is_hidden = 0
workspace.public = 1
workspace.content = json.dumps(content)
workspace.set("links", [])
else:
workspace = frappe.get_doc(
if not frappe.db.exists("Workspace", "MAKAP Backend"):
frappe.get_doc(
{
"doctype": "Workspace",
"name": "MAKAP Backend",
@@ -49,268 +62,53 @@ def ensure_makap_workspace():
"label": "MAKAP Backend",
"is_hidden": 0,
"public": 1,
"content": json.dumps(content),
"links": [],
"content": "[]",
}
)
).insert(ignore_permissions=True)
for label, doctype_name in SHORTCUTS:
workspace.append(
"links",
{
"label": label,
"link_type": "DocType",
"link_to": doctype_name,
"type": "Card Break",
},
)
workspace.save(ignore_permissions=True)
frappe.db.commit()
return "MAKAP Backend"
def ensure_makap_workspace():
content = [
{"id": "makap_backend_header", "type": "header", "data": {"text": "MAKAP Backend", "col": 12}}
]
for index, (label, doctype_name) in enumerate(SHORTCUTS, start=1):
content.append(
{
"id": f"makap_shortcut_{index}",
"type": "shortcut",
"data": {"shortcut_name": label, "col": 3, "link_to": doctype_name, "type": "DocType"},
}
)
values = {
"title": "MAKAP Backend",
"label": "MAKAP Backend",
"is_hidden": 0,
"public": 1,
"content": json.dumps(content),
}
if frappe.db.exists("Workspace", "MAKAP Backend"):
workspace = frappe.get_doc("Workspace", "MAKAP Backend")
workspace.update(values)
workspace.set("links", [])
workspace.save(ignore_permissions=True)
else:
values.update({"doctype": "Workspace", "name": "MAKAP Backend", "links": []})
workspace = frappe.get_doc(values)
workspace.insert(ignore_permissions=True)
frappe.db.commit()
return "MAKAP Backend"
def ensure_makap_workspace():
workspace = frappe.get_doc("Workspace", "MAKAP Backend")
workspace.set("shortcuts", [])
for label, doctype_name in SHORTCUTS:
workspace.append(
"shortcuts",
{
"shortcut_name": label,
"type": "DocType",
"link_to": doctype_name,
"icon": "list",
"color": "Blue",
},
)
workspace.set("links", [])
workspace.save(ignore_permissions=True)
frappe.db.commit()
return "MAKAP Backend"
def ensure_makap_workspace():
workspace = frappe.get_doc("Workspace", "MAKAP Backend")
workspace.set("shortcuts", [])
for label, doctype_name in SHORTCUTS:
workspace.append(
"shortcuts",
{
"label": label,
"type": "DocType",
"link_to": doctype_name,
"icon": "list",
"color": "Blue",
},
)
workspace.set("links", [])
workspace.save(ignore_permissions=True)
frappe.db.commit()
return "MAKAP Backend"
SHORTCUTS = [
("Customer Onboarding Request", "Customer Onboarding Request"),
("Packaging Specification", "Packaging Specification"),
("Customer", "Customer"),
("Contact", "Contact"),
("Opportunity", "Opportunity"),
("Quotation", "Quotation"),
("Sales Order", "Sales Order"),
("Item", "Item"),
("Operation", "Operation"),
("Work Order", "Work Order"),
("Quality Inspection", "Quality Inspection"),
("Production Plan", "Production Plan"),
("Material Request", "Material Request"),
("Purchase Order", "Purchase Order"),
("Purchase Receipt", "Purchase Receipt"),
]
def ensure_makap_workspace():
workspace = frappe.get_doc("Workspace", "MAKAP Backend")
workspace.set("shortcuts", [])
for label, doctype_name in SHORTCUTS:
workspace.append(
"shortcuts",
{
"label": label,
"type": "DocType",
"link_to": doctype_name,
"icon": "list",
"color": "Blue",
},
)
workspace.set("links", [])
workspace.save(ignore_permissions=True)
frappe.db.commit()
return len(SHORTCUTS)
def get_makap_workspace_shortcuts():
workspace = frappe.get_doc("Workspace", "MAKAP Backend")
return [
{"label": row.label, "type": row.type, "link_to": row.link_to}
for row in workspace.shortcuts
]
def ensure_makap_workspace():
workspace = frappe.get_doc("Workspace", "MAKAP Backend")
workspace.content = "[]"
workspace.set("links", [])
for label, doctype_name in SHORTCUTS:
workspace.append(
"links",
{
"label": label,
"type": "Link",
"link_type": "DocType",
"link_to": doctype_name,
"onboard": 0,
},
)
workspace.save(ignore_permissions=True)
frappe.db.commit()
return len(workspace.links)
def get_makap_workspace_links():
workspace = frappe.get_doc("Workspace", "MAKAP Backend")
return [
{"idx": row.idx, "label": row.label, "type": row.type, "link_type": row.link_type, "link_to": row.link_to}
for row in workspace.links
]
def ensure_makap_workspace():
workspace = frappe.get_doc("Workspace", "MAKAP Backend")
workspace.set("links", [])
workspace.set("shortcuts", [])
workspace.set("number_cards", [])
blocks = [
{"id": "makap_backend_header", "type": "header", "data": {"text": "MAKAP Backend Operations", "col": 12}}
{
"id": "makap_welcome_header",
"type": "header",
"data": {
"text": "Welcome back — here's what's happening across MAKAP Uganda's manufacturing workflow.",
"col": 12,
},
}
]
for index, (label, doctype_name) in enumerate(SHORTCUTS, start=1):
workspace.append(
"shortcuts",
{
"label": label,
"type": "DocType",
"link_to": doctype_name,
"doc_view": "List",
"icon": "list",
"color": "Blue",
},
)
card_index = 1
existing_cards = set(frappe.get_all("Number Card", pluck="name"))
for label in WORKSPACE_NUMBER_CARD_LABELS:
if label not in existing_cards:
continue
workspace.append("number_cards", {"number_card_name": label})
blocks.append(
{
"id": f"makap_shortcut_{index}",
"type": "shortcut",
"data": {
"shortcut_name": label,
"col": 3,
"link_to": doctype_name,
"type": "DocType",
"doc_view": "List",
"icon": "list",
"color": "Blue",
},
"id": f"makap_number_card_{card_index}",
"type": "number_card",
"data": {"number_card_name": label, "col": 3},
}
)
card_index += 1
workspace.content = json.dumps(blocks)
workspace.save(ignore_permissions=True)
frappe.db.commit()
return {"shortcuts": len(workspace.shortcuts), "links": len(workspace.links)}
CUSTOMER_COMMERCIAL_SHORTCUTS = [
("Customer Onboarding Request", "Customer Onboarding Request"),
("Customer", "Customer"),
("Contact", "Contact"),
("Opportunity", "Opportunity"),
("Quotation", "Quotation"),
("Sales Order", "Sales Order"),
("Packaging Specification", "Packaging Specification"),
("Item", "Item"),
("Operation", "Operation"),
("Work Order", "Work Order"),
]
INTERNAL_OPERATIONS_SHORTCUTS = [
("Quality Inspection", "Quality Inspection"),
("Production Plan", "Production Plan"),
("Material Request", "Material Request"),
("Purchase Order", "Purchase Order"),
("Purchase Receipt", "Purchase Receipt"),
]
def ensure_makap_workspace():
workspace = frappe.get_doc("Workspace", "MAKAP Backend")
workspace.set("links", [])
workspace.set("shortcuts", [])
blocks = []
shortcut_index = 1
for section_title, section_shortcuts in [
("Customer & Commercial Operations — Modules 1–4", CUSTOMER_COMMERCIAL_SHORTCUTS),
("Internal Operations — Modules 5–7", INTERNAL_OPERATIONS_SHORTCUTS),
]:
for group_title, items in SHORTCUT_GROUPS:
blocks.append(
{
"id": f"makap_section_{shortcut_index}",
"type": "header",
"data": {"text": section_title, "col": 12},
"data": {"text": group_title, "col": 12},
}
)
for label, doctype_name in section_shortcuts:
for label, doctype_name, color in items:
if not frappe.db.exists("DocType", doctype_name):
continue
workspace.append(
"shortcuts",
{
@@ -319,7 +117,7 @@ def ensure_makap_workspace():
"link_to": doctype_name,
"doc_view": "List",
"icon": "list",
"color": "Blue",
"color": color,
},
)
blocks.append(
@@ -333,7 +131,7 @@ def ensure_makap_workspace():
"type": "DocType",
"doc_view": "List",
"icon": "list",
"color": "Blue",
"color": color,
},
}
)
@@ -342,8 +140,8 @@ def ensure_makap_workspace():
workspace.content = json.dumps(blocks)
workspace.save(ignore_permissions=True)
frappe.db.commit()
return {
"customer_commercial": len(CUSTOMER_COMMERCIAL_SHORTCUTS),
"internal_operations": len(INTERNAL_OPERATIONS_SHORTCUTS),
"total": len(workspace.shortcuts),
}
summary = {title: len(items) for title, items in SHORTCUT_GROUPS}
summary["number_cards"] = len(workspace.number_cards)
summary["total_shortcuts"] = sum(len(items) for _, items in SHORTCUT_GROUPS)
return summary
@@ -0,0 +1,524 @@
frappe.pages['makap-portal'].on_page_load = function (wrapper) {
var page = frappe.ui.make_app_page({
parent: wrapper,
title: 'MAKAP Portal',
single_column: true,
});
render_portal(page);
};
function render_portal(page) {
inject_style();
var esc = function (v) {
return String(v == null ? '—' : v).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }[c];
});
};
var clean = function (s) { return (s || '').replace(/\s+/g, ' ').trim(); };
var canCreate = function (doctype) { return frappe.model.can_create(doctype); };
var canDelete = function (doctype) { return frappe.model.can_delete(doctype); };
var deleteRecord = function (doctype, name, onDone) {
frappe.confirm(
__('Delete {0} <b>{1}</b>? This cannot be undone.', [doctype, frappe.utils.escape_html(name)]),
function () {
frappe.call({
method: 'frappe.client.delete',
args: { doctype: doctype, name: name },
freeze: true,
callback: function () {
frappe.show_alert({ message: __('Deleted {0}', [name]), indicator: 'green' });
if (onDone) onDone();
}
});
}
);
};
var ROUTES = {
'Onboarding requests': 'Customer Onboarding Request', 'Customer': 'Customer', 'Contact': 'Contact',
'Opportunity': 'Opportunity', 'Quotation': 'Quotation', 'Sales order': 'Sales Order',
'Packaging specs': 'Packaging Specification', 'Material structures': 'Material Structure',
'Critical acceptance': 'Critical Acceptance Requirement', 'Item': 'Item',
'Operation': 'Operation', 'Work order': 'Work Order', 'Production plan': 'Production Plan',
'Material request': 'Material Request', 'Purchase order': 'Purchase Order', 'Purchase receipt': 'Purchase Receipt',
'Quality inspection': 'Quality Inspection', 'Delivery note': 'Delivery Note', 'Sales invoice': 'Sales Invoice',
'Payment entry': 'Payment Entry', 'Purchase invoice': 'Purchase Invoice',
'Order closure & margin': 'Order Closure And Margin View', 'User': 'User', 'UOM': 'UOM'
};
var SIDEBAR_HTML =
'<div class="mkp-nav-group-label">Overview</div>' +
'<div class="mkp-nav-item mkp-active"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/></svg>Dashboard</div>' +
'<div class="mkp-nav-group-label">Customer &amp; commercial · M1–2</div>' +
'<div class="mkp-nav-item"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2"/><circle cx="10" cy="7" r="4"/></svg>Onboarding requests<span class="mkp-nav-tag mkp-tag-count">0</span></div>' +
'<div class="mkp-nav-item mkp-dim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M4 21v-1a8 8 0 0 1 16 0v1"/></svg>Customer<span class="mkp-nav-tag mkp-tag-erp">ERPNext</span></div>' +
'<div class="mkp-nav-item mkp-dim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></svg>Contact<span class="mkp-nav-tag mkp-tag-erp">ERPNext</span></div>' +
'<div class="mkp-nav-item mkp-dim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2 3 14h7l-1 8 10-12h-7z"/></svg>Opportunity<span class="mkp-nav-tag mkp-tag-erp">ERPNext</span></div>' +
'<div class="mkp-nav-item mkp-dim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1v22"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>Quotation<span class="mkp-nav-tag mkp-tag-erp">ERPNext</span></div>' +
'<div class="mkp-nav-item mkp-dim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 0 1-8 0"/></svg>Sales order<span class="mkp-nav-tag mkp-tag-erp">ERPNext</span></div>' +
'<div class="mkp-nav-group-label">Specification &amp; prepress · M3</div>' +
'<div class="mkp-nav-item"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2 3 7v10l9 5 9-5V7z"/><path d="M3 7l9 5 9-5"/><path d="M12 12v10"/></svg>Packaging specs<span class="mkp-nav-tag mkp-tag-count">0</span></div>' +
'<div class="mkp-nav-item mkp-dim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="4" rx="1"/><rect x="3" y="10" width="18" height="4" rx="1"/><rect x="3" y="16" width="18" height="4" rx="1"/></svg>Material structures<span class="mkp-nav-tag mkp-tag-child">Child table</span></div>' +
'<div class="mkp-nav-item mkp-dim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2 4 5v6c0 5 3.4 8.7 8 11 4.6-2.3 8-6 8-11V5z"/></svg>Critical acceptance<span class="mkp-nav-tag mkp-tag-child">Child table</span></div>' +
'<div class="mkp-nav-item mkp-dim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>Item<span class="mkp-nav-tag mkp-tag-erp">ERPNext</span></div>' +
'<div class="mkp-nav-group-label">Production &amp; planning · M4</div>' +
'<div class="mkp-nav-item mkp-dim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 5v2M12 17v2M5 12h2M17 12h2"/></svg>Operation<span class="mkp-nav-tag mkp-tag-erp">ERPNext</span></div>' +
'<div class="mkp-nav-item mkp-dim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>Work order<span class="mkp-nav-tag mkp-tag-erp">ERPNext</span></div>' +
'<div class="mkp-nav-item mkp-dim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M8 2v4M16 2v4M3 10h18"/></svg>Production plan<span class="mkp-nav-tag mkp-tag-erp">ERPNext</span></div>' +
'<div class="mkp-nav-group-label">Materials &amp; inventory · M5</div>' +
'<div class="mkp-nav-item mkp-dim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 8V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v2"/><path d="M3 8l9 5 9-5"/><path d="M3 8v10a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8"/></svg>Material request<span class="mkp-nav-tag mkp-tag-erp">ERPNext</span></div>' +
'<div class="mkp-nav-item mkp-dim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z"/><path d="M3 6h18"/></svg>Purchase order<span class="mkp-nav-tag mkp-tag-erp">ERPNext</span></div>' +
'<div class="mkp-nav-item mkp-dim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 7 9 18l-5-5"/></svg>Purchase receipt<span class="mkp-nav-tag mkp-tag-erp">ERPNext</span></div>' +
'<div class="mkp-nav-group-label">Quality · M6</div>' +
'<div class="mkp-nav-item mkp-dim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>Quality inspection<span class="mkp-nav-tag mkp-tag-erp">ERPNext</span></div>' +
'<div class="mkp-nav-group-label">Fulfilment &amp; billing · M8</div>' +
'<div class="mkp-nav-item mkp-dim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="7" width="15" height="13" rx="1"/><path d="M16 11h3l4 4v5h-7"/><circle cx="6" cy="20" r="1.6"/><circle cx="17.5" cy="20" r="1.6"/></svg>Delivery note<span class="mkp-nav-tag mkp-tag-erp">ERPNext</span></div>' +
'<div class="mkp-nav-item mkp-dim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>Sales invoice<span class="mkp-nav-tag mkp-tag-erp">ERPNext</span></div>' +
'<div class="mkp-nav-item mkp-dim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20"/></svg>Payment entry<span class="mkp-nav-tag mkp-tag-erp">ERPNext</span></div>' +
'<div class="mkp-nav-item mkp-dim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>Purchase invoice<span class="mkp-nav-tag mkp-tag-erp">ERPNext</span></div>' +
'<div class="mkp-nav-group-label">Operating control</div>' +
'<div class="mkp-nav-item"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>Order closure &amp; margin<span class="mkp-nav-tag mkp-tag-count">0 Open</span></div>' +
'<div class="mkp-nav-group-label">Reference &amp; lookups</div>' +
'<div class="mkp-nav-item mkp-dim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M4 21v-1a8 8 0 0 1 16 0v1"/></svg>User<span class="mkp-nav-tag mkp-tag-erp">Frappe core</span></div>' +
'<div class="mkp-nav-item mkp-dim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><path d="M3.27 6.96 12 12l8.73-5.04"/><path d="M12 22.08V12"/></svg>UOM<span class="mkp-nav-tag mkp-tag-erp">Setup</span></div>';
var HERO_HTML =
'<div class="mkp-hero">' +
'<h1>Welcome back, <span id="mkp-user"></span> 👋</h1>' +
'<p>Here\'s what\'s happening across MAKAP Uganda\'s manufacturing workflow.</p>' +
'</div>' +
'<div class="mkp-stats">' +
'<div class="mkp-stat-card"><div class="mkp-stat-label">Onboarding requests</div><div class="mkp-stat-value">0</div></div>' +
'<div class="mkp-stat-card"><div class="mkp-stat-label">Packaging specifications</div><div class="mkp-stat-value">0</div></div>' +
'<div class="mkp-stat-card"><div class="mkp-stat-label">Approved for manufacture</div><div class="mkp-stat-value">0</div></div>' +
'<div class="mkp-stat-card"><div class="mkp-stat-label">Critical requirements tracked</div><div class="mkp-stat-value">0</div></div>' +
'</div>';
page.main.html(
'<div class="mkp-page">' +
'<div class="mkp-sidebar" id="mkp-sidebar">' + SIDEBAR_HTML + '</div>' +
'<div class="mkp-content" id="mkp-content">' + HERO_HTML + '</div>' +
'</div>'
);
var $sidebar = page.main.find('#mkp-sidebar');
var $content = page.main.find('#mkp-content');
var ORIGINAL_CONTENT = $content.html();
/* ---- identity ---- */
var fullName = frappe.session.user_fullname || frappe.session.user || 'User';
page.main.find('#mkp-user').text(fullName);
/* ---- collapsible groups ---- */
(function wrapGroups() {
var labels = $sidebar.find('.mkp-nav-group-label').toArray();
labels.forEach(function (label, index) {
var $label = $(label);
var $group = $('<div class="mkp-nav-group' + (index ? ' mkp-collapsed' : '') + '"></div>');
$label.before($group);
$group.append($label);
var next = $group.next();
while (next.length && !next.hasClass('mkp-nav-group-label') && !next.hasClass('mkp-nav-group')) {
var after = next.next();
$group.append(next);
next = after;
}
});
$sidebar.find('.mkp-nav-group-label').each(function () {
var $label = $(this);
var $group = $label.parent();
$label.attr('tabindex', 0).on('click', function () { $group.toggleClass('mkp-collapsed'); });
$label.on('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); $label.click(); } });
});
})();
/* ---- KPI cards ---- */
frappe.call({
method: 'makap_backend.makap_backend.portal_api.get_portal_dashboard',
callback: function (r) {
var k = r.message && r.message.kpis;
if (!k) return;
var values = [k.onboarding_requests, k.packaging_specifications, k.approved_for_manufacture, k.critical_requirements_tracked];
page.main.find('.mkp-stat-card .mkp-stat-value').each(function (i) {
if (values[i] !== undefined) $(this).text(Number(values[i] || 0).toLocaleString());
});
}
});
function markActive($item) {
$sidebar.find('.mkp-nav-item').removeClass('mkp-selected');
$item.addClass('mkp-selected');
}
function showDashboard() {
$content.html(ORIGINAL_CONTENT);
$sidebar.find('.mkp-nav-item').removeClass('mkp-selected');
}
/* ---- generic in-portal panel ---- */
function genericApi(doctype) {
return '/api/resource/' + encodeURIComponent(doctype) + '?fields=' + encodeURIComponent(JSON.stringify(['*'])) + '&limit_page_length=25&order_by=modified%20desc';
}
function renderGeneric(doctype, label, rows) {
var fields = rows.length ? Object.keys(rows[0]).filter(function (k) {
return ['name', 'owner', 'creation', 'modified', 'modified_by', 'docstatus', 'idx', '_user_tags', '_comments', '_assign', '_liked_by'].indexOf(k) === -1;
}).slice(0, 6) : [];
var cols = ['name'].concat(fields);
var allowDelete = canDelete(doctype);
$content.html(
'<div class="mkp-right-view">' +
'<div class="mkp-right-head">' +
'<div><p class="mkp-eyebrow">Selected module</p><h1>' + esc(label) + '</h1><p>' + rows.length + ' live records from Frappe</p></div>' +
'<div class="mkp-actions">' +
'<button class="mkp-btn" id="mkp-generic-back">← Dashboard</button>' +
(canCreate(doctype) ? '<button class="mkp-btn mkp-btn-primary" id="mkp-generic-new">+ New ' + esc(label) + '</button>' : '') +
'</div>' +
'</div>' +
'<table class="mkp-table">' +
'<thead><tr>' + cols.map(function (x) { return '<th>' + esc(x.replaceAll('_', ' ')) + '</th>'; }).join('') + (allowDelete ? '<th></th>' : '') + '</tr></thead>' +
'<tbody>' + (rows.length ? rows.map(function (r) {
return '<tr>' + cols.map(function (x, i) {
return '<td class="' + (i === 0 ? 'mkp-link' : '') + '" data-name="' + esc(r.name) + '">' + esc(r[x]) + '</td>';
}).join('') + (allowDelete ? '<td><button class="mkp-icon-btn mkp-del" data-name="' + esc(r.name) + '" title="Delete">🗑</button></td>' : '') + '</tr>';
}).join('') : '<tr><td colspan="' + (cols.length + (allowDelete ? 1 : 0)) + '" class="mkp-loading">No records found</td></tr>') +
'</tbody>' +
'</table>' +
'</div>'
);
$content.find('#mkp-generic-back').on('click', showDashboard);
$content.find('#mkp-generic-new').on('click', function () { frappe.new_doc(doctype); });
$content.find('td.mkp-link').on('click', function () {
frappe.set_route('Form', doctype, $(this).data('name'));
});
$content.find('.mkp-del').on('click', function (e) {
e.stopPropagation();
var name = $(this).data('name');
deleteRecord(doctype, name, function () { showGeneric($sidebar.find('.mkp-nav-item.mkp-selected'), doctype, label); });
});
}
function showGeneric($item, doctype, label) {
markActive($item);
$content.html('<div class="mkp-loading">Loading ' + esc(label) + '…</div>');
fetch(genericApi(doctype), { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (j) {
if (j.exc) throw new Error();
renderGeneric(doctype, label, j.data || []);
})
.catch(function () {
$content.html('<div class="mkp-loading">Unable to load ' + esc(label) + '. Check your Frappe permissions.</div>');
});
}
/* ---- onboarding panel ---- */
function loadOnboardingRequests() {
var $loading = $content.find('#mkp-onboarding-loading');
var $table = $content.find('#mkp-onboarding-table');
if (!$loading.length || !$table.length) return;
$loading.show();
$table.html('');
var fields = ['name', 'organisation_name', 'trading_name', 'contact_name', 'email_id', 'customer_type', 'location', 'onboarding_status', 'customer_match_result', 'portal_access_status', 'creation'];
fetch('/api/resource/Customer%20Onboarding%20Request?fields=' + encodeURIComponent(JSON.stringify(fields)) + '&limit_page_length=50&order_by=modified%20desc', { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (result) {
$loading.hide();
var rows = result.data || [];
if (!rows.length) {
$table.html('<div class="mkp-loading">No onboarding requests found.</div>');
return;
}
var allowDelete = canDelete('Customer Onboarding Request');
$table.html(
'<table class="mkp-table"><thead><tr><th>Request</th><th>Organisation</th><th>Contact</th><th>Email</th><th>Location</th><th>Status</th><th>Customer match</th>' + (allowDelete ? '<th></th>' : '') + '</tr></thead><tbody>' +
rows.map(function (row) {
return '<tr>' +
'<td class="mkp-link" data-name="' + esc(row.name) + '">' + esc(row.name) + '</td>' +
'<td>' + esc(row.organisation_name) + '</td>' +
'<td>' + esc(row.contact_name) + '</td>' +
'<td>' + esc(row.email_id) + '</td>' +
'<td>' + esc(row.location) + '</td>' +
'<td>' + esc(row.onboarding_status) + '</td>' +
'<td>' + esc(row.customer_match_result) + '</td>' +
(allowDelete ? '<td><button class="mkp-icon-btn mkp-del" data-name="' + esc(row.name) + '" title="Delete">🗑</button></td>' : '') +
'</tr>';
}).join('') + '</tbody></table>'
);
$table.find('td.mkp-link').on('click', function () {
frappe.set_route('Form', 'Customer Onboarding Request', $(this).data('name'));
});
$table.find('.mkp-del').on('click', function (e) {
e.stopPropagation();
deleteRecord('Customer Onboarding Request', $(this).data('name'), loadOnboardingRequests);
});
})
.catch(function () {
$loading.hide();
$table.html('<div class="mkp-loading">Unable to load onboarding requests. Check your Frappe permissions.</div>');
});
}
function showOnboarding($item) {
markActive($item);
$content.html(
'<div class="mkp-right-view">' +
'<div class="mkp-right-head">' +
'<div><p class="mkp-eyebrow">Customer & commercial · M1–2</p><h1>Customer onboarding</h1><p>Live onboarding requests from Frappe.</p></div>' +
'<button class="mkp-btn" id="mkp-onboarding-dashboard">← Dashboard</button>' +
'</div>' +
'<div class="mkp-actions">' +
(canCreate('Customer Onboarding Request') ? '<button class="mkp-btn mkp-btn-primary" id="mkp-onboarding-new">+ New request</button>' : '') +
'<button class="mkp-btn" id="mkp-onboarding-list">Open full list</button>' +
'<button class="mkp-btn" id="mkp-onboarding-refresh">Refresh requests</button>' +
'</div>' +
'<div class="mkp-loading" id="mkp-onboarding-loading">Loading onboarding requests…</div>' +
'<div id="mkp-onboarding-table"></div>' +
'</div>'
);
$content.find('#mkp-onboarding-dashboard').on('click', showDashboard);
$content.find('#mkp-onboarding-refresh').on('click', loadOnboardingRequests);
$content.find('#mkp-onboarding-new').on('click', function () { frappe.new_doc('Customer Onboarding Request'); });
$content.find('#mkp-onboarding-list').on('click', function () { frappe.set_route('List', 'Customer Onboarding Request'); });
loadOnboardingRequests();
}
/* ---- customer panel ---- */
var customerApi = '/api/resource/Customer';
var customerType = 'All', customerStatus = 'All', customerSearch = '';
function customerCount(filters) {
var p = new URLSearchParams({ fields: JSON.stringify(['name']), filters: JSON.stringify(filters || []), limit_page_length: '1', limit_start: '0' });
return fetch(customerApi + '?' + p.toString(), { credentials: 'same-origin' }).then(function (r) { return r.json(); }).then(function (r) { return Number((r.data && r.data.length) || 0); });
}
function customerQuery() {
var filters = [];
if (customerType !== 'All') filters.push(['customer_type', '=', customerType]);
if (customerStatus === 'Active') filters.push(['disabled', '=', 0]);
if (customerStatus === 'Disabled') filters.push(['disabled', '=', 1]);
var params = new URLSearchParams({
fields: JSON.stringify(['name', 'customer_name', 'customer_type', 'customer_group', 'territory', 'tax_id', 'mobile_no', 'email_id', 'disabled', 'creation', 'modified']),
filters: JSON.stringify(filters), limit_page_length: '50', order_by: 'modified desc'
});
return fetch(customerApi + '?' + params.toString(), { credentials: 'same-origin' }).then(function (r) { return r.json(); }).then(function (r) { return r.data || []; });
}
function customerCard(label, value, filterValue, active) {
return '<div class="mkp-customer-card' + (active ? ' mkp-active' : '') + '" data-filter="' + filterValue + '"><small>' + label + '</small><strong>' + Number(value || 0).toLocaleString() + '</strong></div>';
}
function renderCustomerRows(rows) {
var term = customerSearch.toLowerCase();
var data = term ? rows.filter(function (x) { return Object.values(x).join(' ').toLowerCase().indexOf(term) !== -1; }) : rows;
var cols = ['name', 'customer_name', 'customer_type', 'customer_group', 'territory', 'tax_id', 'mobile_no', 'email_id', 'disabled'];
var allowDelete = canDelete('Customer');
$content.find('#mkp-customer-table').html(data.length ?
'<table class="mkp-table"><thead><tr>' + cols.map(function (x) { return '<th>' + x.replaceAll('_', ' ') + '</th>'; }).join('') + (allowDelete ? '<th></th>' : '') + '</tr></thead><tbody>' +
data.map(function (x) {
return '<tr>' +
'<td class="mkp-link" data-name="' + esc(x.name) + '">' + esc(x.name) + '</td>' +
'<td><strong>' + esc(x.customer_name) + '</strong></td>' +
'<td>' + esc(x.customer_type) + '</td>' +
'<td>' + esc(x.customer_group) + '</td>' +
'<td>' + esc(x.territory) + '</td>' +
'<td>' + esc(x.tax_id) + '</td>' +
'<td>' + esc(x.mobile_no) + '</td>' +
'<td>' + esc(x.email_id) + '</td>' +
'<td><span class="mkp-status' + (x.disabled ? ' mkp-status-off' : '') + '">' + (x.disabled ? 'Disabled' : 'Active') + '</span></td>' +
(allowDelete ? '<td><button class="mkp-icon-btn mkp-del" data-name="' + esc(x.name) + '" title="Delete">🗑</button></td>' : '') +
'</tr>';
}).join('') + '</tbody></table>' : '<div class="mkp-loading">No customers match your filters.</div>'
);
$content.find('td.mkp-link[data-name]').on('click', function () { customerDetail($(this).data('name')); });
$content.find('.mkp-del').on('click', function (e) {
e.stopPropagation();
deleteRecord('Customer', $(this).data('name'), renderCustomerList);
});
}
function wireCustomerList() {
$content.find('#mkp-customer-dashboard').on('click', showDashboard);
$content.find('#mkp-customer-refresh').on('click', renderCustomerList);
$content.find('#mkp-customer-new').on('click', function () { frappe.new_doc('Customer'); });
$content.find('#mkp-customer-type').on('change', function () { customerType = this.value; renderCustomerList(); });
$content.find('#mkp-customer-status').on('change', function () { customerStatus = this.value; renderCustomerList(); });
var t;
$content.find('#mkp-customer-search').on('input', function () {
customerSearch = this.value;
clearTimeout(t);
t = setTimeout(renderCustomerList, 250);
});
$content.find('.mkp-customer-card').on('click', function () {
var f = $(this).data('filter');
if (f === 'Company' || f === 'Individual') { customerType = f; customerStatus = 'All'; }
else if (f) { customerStatus = f; customerType = 'All'; }
else { customerType = 'All'; customerStatus = 'All'; }
renderCustomerList();
});
}
function renderCustomerList() {
var $customerItem = $sidebar.find('.mkp-nav-item').filter(function () { return clean($(this).text()).indexOf('Customer') === 0; }).first();
if ($customerItem.length) markActive($customerItem);
$content.html('<div class="mkp-loading">Loading customers…</div>');
Promise.all([
customerCount(), customerCount([['customer_type', '=', 'Company']]), customerCount([['customer_type', '=', 'Individual']]),
customerCount([['disabled', '=', 0]]), customerCount([['disabled', '=', 1]]), customerQuery()
]).then(function (results) {
var all = results[0], companies = results[1], individuals = results[2], active = results[3], disabled = results[4], rows = results[5];
$content.html(
'<div class="mkp-right-view">' +
'<div class="mkp-right-head"><div><p class="mkp-eyebrow">Customer & commercial · M1–2</p><h1>Customers</h1><p>Live customer records from Frappe.</p></div>' +
'<div class="mkp-actions"><button class="mkp-btn" id="mkp-customer-dashboard">← Dashboard</button>' +
(canCreate('Customer') ? '<button class="mkp-btn mkp-btn-primary" id="mkp-customer-new">+ New Customer</button>' : '') +
'<button class="mkp-btn" id="mkp-customer-refresh">Refresh</button></div></div>' +
'<div class="mkp-customer-summary">' +
customerCard('Total customers', all, '', customerStatus === 'All' && customerType === 'All') +
customerCard('Companies', companies, 'Company', customerType === 'Company') +
customerCard('Individuals', individuals, 'Individual', customerType === 'Individual') +
customerCard('Active customers', active, 'Active', customerStatus === 'Active') +
customerCard('Disabled customers', disabled, 'Disabled', customerStatus === 'Disabled') +
'</div>' +
'<div class="mkp-toolbar">' +
'<input id="mkp-customer-search" value="' + esc(customerSearch) + '" placeholder="Search customers, tax ID, email, phone, or territory…">' +
'<select id="mkp-customer-type"><option>All</option><option ' + (customerType === 'Company' ? 'selected' : '') + '>Company</option><option ' + (customerType === 'Individual' ? 'selected' : '') + '>Individual</option></select>' +
'<select id="mkp-customer-status"><option>All</option><option ' + (customerStatus === 'Active' ? 'selected' : '') + '>Active</option><option ' + (customerStatus === 'Disabled' ? 'selected' : '') + '>Disabled</option></select>' +
'</div>' +
'<div id="mkp-customer-table"></div>' +
'</div>'
);
renderCustomerRows(rows);
wireCustomerList();
}).catch(function () {
$content.html('<div class="mkp-loading">Unable to load customers. Check your Frappe permissions.</div>');
});
}
function customerDetail(name) {
$content.html('<div class="mkp-loading">Loading customer…</div>');
var p = new URLSearchParams({ fields: JSON.stringify(['*']) });
fetch(customerApi + '/' + encodeURIComponent(name) + '?' + p.toString(), { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (j) {
var x = j.data || {};
var $customerItem = $sidebar.find('.mkp-nav-item').filter(function () { return clean($(this).text()).indexOf('Customer') === 0; }).first();
if ($customerItem.length) markActive($customerItem);
var rowsHtml = [
['Customer ID', x.name], ['Customer name', x.customer_name], ['Customer type', x.customer_type], ['Customer group', x.customer_group],
['Territory', x.territory], ['Tax ID', x.tax_id], ['Email', x.email_id], ['Mobile', x.mobile_no], ['Website', x.website],
['Default currency', x.default_currency], ['Payment terms', x.payment_terms], ['Credit limit', x.credit_limit],
['Created', x.creation], ['Modified', x.modified]
].map(function (pair) { return '<div><small>' + pair[0] + '</small><span>' + esc(pair[1]) + '</span></div>'; }).join('');
$content.html(
'<div class="mkp-right-view"><button class="mkp-btn mkp-back-btn" id="mkp-customer-back">← Back to customers</button>' +
'<div class="mkp-right-head"><div><p class="mkp-eyebrow">Customer profile</p><h1>' + esc(x.customer_name || x.name) + '</h1><p>' + esc(x.name) + ' · ' + (x.disabled ? 'Disabled' : 'Active') + '</p></div>' +
'<div class="mkp-actions"><button class="mkp-btn" id="mkp-customer-detail-refresh">Refresh</button><button class="mkp-btn mkp-btn-primary" id="mkp-customer-open-form">Open in Desk</button>' +
(canDelete('Customer') ? '<button class="mkp-btn mkp-btn-danger" id="mkp-customer-detail-delete">Delete</button>' : '') +
'</div></div>' +
'<div class="mkp-customer-profile">' + rowsHtml + '</div></div>'
);
$content.find('#mkp-customer-back').on('click', renderCustomerList);
$content.find('#mkp-customer-detail-refresh').on('click', function () { customerDetail(name); });
$content.find('#mkp-customer-open-form').on('click', function () { frappe.set_route('Form', 'Customer', name); });
$content.find('#mkp-customer-detail-delete').on('click', function () { deleteRecord('Customer', name, renderCustomerList); });
})
.catch(function () {
$content.html('<div class="mkp-loading">Unable to load this customer.</div>');
});
}
/* ---- wire every nav item to exactly one handler ---- */
$sidebar.find('.mkp-nav-item').each(function () {
var $item = $(this);
var text = clean($item.text());
if (text.indexOf('Dashboard') === 0) {
$item.css('cursor', 'pointer').on('click', function () { showDashboard(); });
return;
}
if (text.indexOf('Onboarding requests') === 0) {
$item.css('cursor', 'pointer').on('click', function () { showOnboarding($item); });
return;
}
if (text.indexOf('Customer') === 0) {
$item.css('cursor', 'pointer').on('click', function () { renderCustomerList(); });
return;
}
var key = Object.keys(ROUTES).filter(function (name) { return text.indexOf(name) === 0; })[0];
if (!key) return;
var doctype = ROUTES[key];
$item.css('cursor', 'pointer').on('click', function () { showGeneric($item, doctype, key); });
});
}
function inject_style() {
if (document.getElementById('mkp-portal-style')) return;
var css =
'.mkp-page{display:flex;align-items:flex-start;gap:20px;}' +
'.mkp-sidebar{width:240px;flex:0 0 240px;background:#0F1B2D;color:#C9D3E5;border-radius:10px;padding:16px 10px;position:sticky;top:10px;align-self:flex-start;max-height:calc(100vh - 100px);overflow-y:auto;}' +
'.mkp-nav-group-label{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#6D7A93;padding:12px 8px 4px;font-weight:700;cursor:pointer;user-select:none;}' +
'.mkp-nav-group-label::after{content:"⌄";float:right;opacity:.7;transition:transform .15s ease;}' +
'.mkp-nav-group.mkp-collapsed .mkp-nav-group-label::after{transform:rotate(-90deg);}' +
'.mkp-nav-group.mkp-collapsed .mkp-nav-item{display:none !important;}' +
'.mkp-nav-item{display:flex;align-items:center;gap:9px;padding:7px 8px;border-radius:7px;font-size:12.5px;font-weight:500;color:#B7C1D6;margin-bottom:1px;}' +
'.mkp-nav-item svg{flex:0 0 15px;width:15px;height:15px;opacity:.8;}' +
'.mkp-nav-item.mkp-active,.mkp-nav-item.mkp-selected{background:#2E6BE6;color:#fff;}' +
'.mkp-nav-item.mkp-active svg,.mkp-nav-item.mkp-selected svg{opacity:1;}' +
'.mkp-nav-item.mkp-dim{color:#7C87A0;}' +
'.mkp-nav-item:hover{background:#1d3554;color:#fff;cursor:pointer;}' +
'.mkp-nav-tag{margin-left:auto;font-size:9.5px;font-weight:700;padding:1px 6px;border-radius:20px;white-space:nowrap;}' +
'.mkp-tag-count{background:#1B2C46;color:#8FB4FF;}' +
'.mkp-nav-item.mkp-selected .mkp-tag-count{background:rgba(255,255,255,.22);color:#fff;}' +
'.mkp-tag-child{background:#1B2C46;color:#7C87A0;}' +
'.mkp-tag-erp{background:transparent;color:#5A6685;font-weight:600;}' +
'.mkp-content{flex:1;min-width:0;}' +
'.mkp-hero{background:linear-gradient(115deg,#E7EEFF 0%,#DCE9FF 42%,#E4F3EA 100%);border-radius:14px;padding:24px 26px;margin-bottom:18px;border:1px solid #DCE4F5;}' +
'.mkp-hero h1{font-size:21px;font-weight:800;margin:0 0 6px;color:#12182B;}' +
'.mkp-hero p{margin:0;font-size:13px;color:#3E4A63;}' +
'.mkp-stats{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;}' +
'.mkp-stat-card{background:#fff;border:1px solid #E7EAF1;border-radius:12px;padding:16px;}' +
'.mkp-stat-label{font-size:11.5px;color:#5B6478;font-weight:500;margin-bottom:10px;}' +
'.mkp-stat-value{font-size:23px;font-weight:800;color:#12182B;}' +
'@media(max-width:1100px){.mkp-stats{grid-template-columns:repeat(2,1fr);}}' +
'@media(max-width:800px){.mkp-page{flex-direction:column;}.mkp-sidebar{width:100%;position:static;max-height:none;}}' +
'.mkp-loading{padding:60px 20px;text-align:center;color:#69748b;font-size:14px;}' +
'.mkp-right-view{max-width:1100px;}' +
'.mkp-eyebrow{margin:0 0 4px;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:#8B93A8;font-weight:600;}' +
'.mkp-right-head{display:flex;justify-content:space-between;align-items:end;margin-bottom:18px;gap:12px;flex-wrap:wrap;}' +
'.mkp-right-head h1{margin:0 0 4px;font-size:22px;color:#12182B;}' +
'.mkp-right-head p{margin:0;color:#69748b;font-size:13px;}' +
'.mkp-btn{border:1px solid #dfe4ed;background:#fff;border-radius:8px;padding:8px 13px;font-weight:600;font-size:12.5px;cursor:pointer;color:#26324b;}' +
'.mkp-btn-primary{background:#2e6be6;border-color:#2e6be6;color:#fff;}' +
'.mkp-btn-danger{background:#fff;border-color:#f3c6cf;color:#c23f60;}' +
'.mkp-btn-danger:hover{background:#fff0f2;}' +
'.mkp-icon-btn{border:none;background:transparent;cursor:pointer;font-size:13px;padding:4px 8px;border-radius:6px;opacity:.7;}' +
'.mkp-icon-btn:hover{opacity:1;background:#fff0f2;}' +
'.mkp-actions{display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap;}' +
'.mkp-table{width:100%;border-collapse:collapse;background:#fff;border:1px solid #e7eaf1;border-radius:10px;overflow:hidden;font-size:12.5px;}' +
'.mkp-table th{text-align:left;text-transform:uppercase;font-size:9.5px;letter-spacing:.07em;color:#7d879a;padding:12px;border-bottom:1px solid #e7eaf1;white-space:nowrap;}' +
'.mkp-table td{padding:12px;border-bottom:1px solid #f0f2f6;white-space:nowrap;}' +
'.mkp-table tr:hover{background:#f8faff;}' +
'.mkp-link{color:#2e6be6;font-weight:700;cursor:pointer;}' +
'.mkp-customer-summary{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;margin-bottom:14px;}' +
'@media(max-width:900px){.mkp-customer-summary{grid-template-columns:repeat(3,1fr);}}' +
'@media(max-width:600px){.mkp-customer-summary{grid-template-columns:repeat(2,1fr);}}' +
'.mkp-customer-card{background:#fff;border:1px solid #e7eaf1;border-radius:10px;padding:12px 14px;cursor:pointer;}' +
'.mkp-customer-card.mkp-active{border-color:#2e6be6;box-shadow:0 0 0 2px #dbe6ff;}' +
'.mkp-customer-card small{display:block;color:#707b91;font-size:10.5px;margin-bottom:6px;}' +
'.mkp-customer-card strong{font-size:21px;letter-spacing:-.03em;color:#12182B;}' +
'.mkp-toolbar{background:#fff;border:1px solid #e7eaf1;border-radius:10px;padding:10px;display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;}' +
'.mkp-toolbar input,.mkp-toolbar select{border:1px solid #dfe4ed;border-radius:7px;padding:8px 10px;font:inherit;font-size:12.5px;background:#fff;}' +
'.mkp-toolbar input{flex:1;min-width:180px;}' +
'.mkp-status{padding:4px 9px;border-radius:20px;font-size:10.5px;font-weight:700;background:#eaf8f0;color:#1f8a5c;}' +
'.mkp-status-off{background:#fff0f2;color:#c23f60;}' +
'.mkp-customer-profile{display:grid;grid-template-columns:repeat(2,1fr);gap:12px;margin-top:18px;}' +
'@media(max-width:600px){.mkp-customer-profile{grid-template-columns:1fr;}}' +
'.mkp-customer-profile div{background:#f7f9fc;border-radius:8px;padding:11px;}' +
'.mkp-customer-profile small{display:block;color:#7d879a;text-transform:uppercase;letter-spacing:.06em;font-size:9.5px;margin-bottom:4px;}' +
'.mkp-customer-profile span{font-size:12.5px;color:#12182B;}' +
'.mkp-back-btn{margin-bottom:14px;}';
var style = document.createElement('style');
style.id = 'mkp-portal-style';
style.textContent = css;
document.head.appendChild(style);
}
@@ -0,0 +1,11 @@
{
"doctype": "Page",
"name": "makap-portal",
"module": "makap_backend",
"title": "MAKAP Portal",
"standard": "Yes",
"system_page": 0,
"icon": "grid",
"restrict_to_domain": "",
"roles": []
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

@@ -0,0 +1,94 @@
(function () {
const routes = {
'Onboarding requests': 'Customer Onboarding Request', 'Customer': 'Customer', 'Contact': 'Contact',
'Opportunity': 'Opportunity', 'Quotation': 'Quotation', 'Sales order': 'Sales Order',
'Packaging specs': 'Packaging Specification', 'Packaging specifications': 'Packaging Specification',
'Material structures': 'Material Structure', 'Critical acceptance': 'Critical Acceptance Requirement',
'Item': 'Item', 'Operation': 'Operation', 'Work order': 'Work Order',
'Production plan': 'Production Plan', 'Material request': 'Material Request',
'Purchase order': 'Purchase Order', 'Purchase receipt': 'Purchase Receipt',
'Quality inspection': 'Quality Inspection', 'Delivery note': 'Delivery Note',
'Sales invoice': 'Sales Invoice', 'Payment entry': 'Payment Entry',
'Purchase invoice': 'Purchase Invoice', 'Order closure & margin': 'Order Closure And Margin View',
'User': 'User', 'UOM': 'UOM'
};
const clean = (text) => text.replace(/\s+/g, ' ').trim();
const slug = (doctype) => doctype.toLowerCase().replaceAll(' ', '-');
const navigate = (doctype) => {
if (window.frappe && typeof frappe.set_route === 'function') {
frappe.set_route('List', doctype);
} else {
window.location.assign(`/app/${slug(doctype)}`);
}
};
// The supplied mockup uses a flat sidebar. Wrap each label plus its items into a group.
const sidebar = document.querySelector('.sidebar');
if (sidebar && !sidebar.dataset.groupsWrapped) {
const labels = Array.from(sidebar.querySelectorAll('.nav-group-label'));
labels.forEach((label, index) => {
const group = document.createElement('div');
group.className = 'nav-group';
sidebar.insertBefore(group, label);
group.appendChild(label);
let sibling = group.nextElementSibling;
while (sibling && !sibling.classList.contains('nav-group-label') && !sibling.classList.contains('sidebar-footer')) {
const next = sibling.nextElementSibling;
group.appendChild(sibling);
sibling = next;
}
if (index > 0) group.classList.add('collapsed');
});
sidebar.dataset.groupsWrapped = '1';
}
document.querySelectorAll('.sidebar .nav-item').forEach((item) => {
const text = clean(item.textContent || '');
const key = Object.keys(routes).find((label) => text.startsWith(label));
if (!key) return;
const doctype = routes[key];
item.dataset.doctype = doctype;
item.setAttribute('role', 'link');
item.addEventListener('click', (event) => { event.preventDefault(); navigate(doctype); });
});
document.querySelectorAll('.sidebar .nav-group-label').forEach((label) => {
const group = label.parentElement;
label.setAttribute('role', 'button');
label.setAttribute('tabindex', '0');
const toggle = () => group.classList.toggle('collapsed');
label.addEventListener('click', toggle);
label.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); toggle(); }
});
});
// Remove the hardcoded explanatory footer from the supplied mockup.
document.querySelector('.sidebar-footer')?.remove();
// Replace hardcoded Grace/GR with the logged-in Frappe user.
const setIdentity = (fullName) => {
const name = fullName || (window.frappe && frappe.session && frappe.session.user) || 'User';
document.querySelectorAll('#dashboard-user').forEach((node) => { node.textContent = name; });
document.querySelectorAll('#dashboard-initials').forEach((node) => {
node.textContent = name.split(/\s+/).map((part) => part[0]).join('').slice(0, 2).toUpperCase();
});
document.querySelectorAll('.hero h1').forEach((heading) => {
if (!heading.querySelector('#dashboard-user')) heading.innerHTML = `Welcome back, <span id="dashboard-user">${name}</span> 👋`;
});
};
setIdentity();
if (window.frappe && frappe.session && frappe.session.user && frappe.call) {
frappe.call({ method: 'frappe.client.get_value', args: { doctype: 'User', filters: { name: frappe.session.user }, fieldname: 'full_name' }, callback: (r) => setIdentity(r.message && r.message.full_name) });
}
// Update the four cards from live MAKAP DocType data.
if (window.frappe && frappe.call) {
frappe.call({ method: 'makap_backend.makap_backend.portal_api.get_portal_dashboard', callback: (response) => {
const kpis = response.message && response.message.kpis;
if (!kpis) return;
const values = [kpis.onboarding_requests, kpis.packaging_specifications, kpis.approved_for_manufacture, kpis.critical_requirements_tracked];
document.querySelectorAll('.stat-card .stat-value').forEach((node, index) => { if (values[index] !== undefined) node.textContent = Number(values[index] || 0).toLocaleString(); });
}});
}
})();
+17
View File
@@ -0,0 +1,17 @@
{% extends "templates/web.html" %}
{% block title %}Customer Onboarding · MAKAP{% endblock %}
{% block page_content %}
<div id="makap-onboarding" class="onboarding-page">
<style>
#makap-onboarding{font-family:Inter,system-ui,sans-serif;background:#f3f5f9;color:#12182b;min-height:calc(100vh - 60px);padding:28px 34px 55px}#makap-onboarding *{box-sizing:border-box}.onb-head{display:flex;justify-content:space-between;align-items:flex-end;gap:20px;max-width:1280px;margin:auto}.onb-eyebrow{text-transform:uppercase;letter-spacing:.1em;font-size:11px;font-weight:700;color:#6f7b91;margin:0 0 8px}.onb-head h1{font-size:30px;margin:0 0 7px;letter-spacing:-.04em}.onb-head p{color:#667086;margin:0;font-size:14px}.onb-actions{display:flex;gap:9px}.onb-btn{border:1px solid #dfe4ed;border-radius:8px;background:#fff;color:#26324b;padding:11px 15px;font-weight:700;cursor:pointer}.onb-btn.primary{background:#2e6be6;border-color:#2e6be6;color:#fff}.onb-btn:hover{filter:brightness(.96)}.onb-summary{max-width:1280px;margin:26px auto 18px;display:grid;grid-template-columns:repeat(6,1fr);gap:12px}.onb-card{background:#fff;border:1px solid #e7eaf1;border-radius:12px;padding:16px 17px;cursor:pointer}.onb-card.active{border-color:#2e6be6;box-shadow:0 0 0 2px #dbe6ff}.onb-card label{display:block;color:#707b91;font-size:12px;margin-bottom:10px}.onb-card strong{font-size:27px;letter-spacing:-.04em}.onb-toolbar{max-width:1280px;margin:0 auto 12px;background:#fff;border:1px solid #e7eaf1;border-radius:12px;padding:13px;display:flex;gap:10px;align-items:center}.onb-search{flex:1;border:1px solid #dfe4ed;border-radius:7px;padding:11px 13px;font-size:13px;min-width:170px}.onb-select{border:1px solid #dfe4ed;border-radius:7px;background:#fff;padding:11px 13px;font-size:13px}.onb-table-wrap{max-width:1280px;margin:auto;background:#fff;border:1px solid #e7eaf1;border-radius:12px;overflow:auto}.onb-table{border-collapse:collapse;width:100%;font-size:13px}.onb-table th{text-align:left;text-transform:uppercase;letter-spacing:.08em;color:#8791a4;font-size:10px;padding:14px 15px;border-bottom:1px solid #e7eaf1;white-space:nowrap}.onb-table td{padding:15px;border-bottom:1px solid #f0f2f6;white-space:nowrap}.onb-table tr:hover{background:#f8faff}.onb-id{color:#1e4fbe;font-weight:800;cursor:pointer}.onb-muted{color:#69748b}.onb-badge{border-radius:20px;padding:5px 9px;font-size:11px;font-weight:700;background:#eef3ff;color:#2e6be6}.onb-badge.review{background:#fff5e5;color:#b46b17}.onb-badge.approved{background:#eaf8f0;color:#1f8a5c}.onb-badge.rejected{background:#fff0f2;color:#c23f60}.onb-empty{text-align:center;padding:42px;color:#8a94a7}.onb-modal-backdrop{position:fixed;inset:0;background:#0f1b2d99;z-index:99;display:none;align-items:center;justify-content:center;padding:20px}.onb-modal-backdrop.open{display:flex}.onb-modal{background:#fff;border-radius:14px;width:min(760px,100%);max-height:90vh;overflow:auto;padding:25px;box-shadow:0 25px 70px #0f1b2d44}.onb-modal-head{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:22px}.onb-modal h2{margin:0;font-size:22px}.onb-close{border:0;background:transparent;font-size:25px;cursor:pointer;color:#6b7588}.onb-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.onb-field{display:flex;flex-direction:column;gap:6px}.onb-field.full{grid-column:1/-1}.onb-field label{font-size:12px;font-weight:700;color:#47536b}.onb-field input,.onb-field select,.onb-field textarea{font:inherit;border:1px solid #dfe4ed;border-radius:7px;padding:10px 11px}.onb-field textarea{min-height:74px;resize:vertical}.onb-form-actions{display:flex;justify-content:flex-end;gap:9px;margin-top:21px}.onb-detail{display:grid;grid-template-columns:1fr 1fr;gap:14px}.onb-detail div{background:#f7f9fc;border-radius:8px;padding:11px}.onb-detail small{color:#7d879a;display:block;font-size:10px;text-transform:uppercase;letter-spacing:.07em;margin-bottom:4px}.onb-detail span{font-size:13px}.onb-loading{padding:40px;text-align:center;color:#7c879b}@media(max-width:900px){.onb-summary{grid-template-columns:repeat(3,1fr)}.onb-head{display:block}.onb-actions{margin-top:18px}}@media(max-width:600px){ #makap-onboarding{padding:20px 13px 40px}.onb-summary{grid-template-columns:repeat(2,1fr)}.onb-toolbar{display:block}.onb-search,.onb-select{width:100%;margin-bottom:8px}.onb-form-grid,.onb-detail{grid-template-columns:1fr}}
</style>
<div class="onb-head"><div><p class="onb-eyebrow">Customer & commercial · M1–2</p><h1>Customer onboarding</h1><p>Capture, review, and convert new customer requests.</p></div><div class="onb-actions"><button class="onb-btn" id="onb-refresh">Refresh</button><button class="onb-btn primary" id="onb-new">+ New onboarding request</button></div></div>
<div class="onb-summary" id="onb-summary"></div>
<div class="onb-toolbar"><input class="onb-search" id="onb-search" placeholder="Search organisation, contact, email, or request ID…"><select class="onb-select" id="onb-status"><option>All</option><option>Submitted</option><option>Under Review</option><option>Approved</option><option>Completed</option><option>Rejected</option></select></div>
<div class="onb-table-wrap"><table class="onb-table"><thead><tr><th>Request</th><th>Organisation</th><th>Contact</th><th>Location</th><th>Status</th><th>Customer match</th><th>Created</th></tr></thead><tbody id="onb-rows"><tr><td class="onb-loading" colspan="7">Loading onboarding requests…</td></tr></tbody></table></div>
<div class="onb-modal-backdrop" id="onb-modal"><div class="onb-modal"><div class="onb-modal-head"><div><p class="onb-eyebrow">MAKAP request</p><h2 id="onb-modal-title">New onboarding request</h2></div><button class="onb-close" id="onb-close">×</button></div><form id="onb-form"><div class="onb-form-grid"><div class="onb-field"><label>Organisation name *</label><input name="organisation_name" required></div><div class="onb-field"><label>Trading name</label><input name="trading_name"></div><div class="onb-field"><label>Customer type *</label><select name="customer_type" required><option value="Company">Company</option><option value="Individual">Individual</option></select></div><div class="onb-field"><label>Business tax identifier</label><input name="business_tax_identifier"></div><div class="onb-field"><label>Contact name *</label><input name="contact_name" required></div><div class="onb-field"><label>Email address *</label><input name="email_id" type="email" required></div><div class="onb-field"><label>Telephone</label><input name="telephone"></div><div class="onb-field"><label>Location</label><input name="location"></div><div class="onb-field full"><label>Submitted address</label><textarea name="submitted_address"></textarea></div><div class="onb-field full"><label>Product interest</label><textarea name="product_interest"></textarea></div><div class="onb-field full"><label>Initial enquiry</label><textarea name="initial_enquiry"></textarea></div><div class="onb-field"><label>Registration source *</label><select name="registration_source" required><option value="Website">Website</option><option value="Assisted">Assisted</option></select></div><div class="onb-field"><label>Assisted channel</label><input name="assisted_channel"></div><div class="onb-field full"><label><input type="checkbox" name="privacy_notice_acknowledged" value="1" required> Privacy notice acknowledged *</label></div></div><div class="onb-form-actions"><button type="button" class="onb-btn" id="onb-cancel">Cancel</button><button class="onb-btn primary" type="submit">Create request</button></div></form><div id="onb-detail-content"></div></div></div>
</div>
<script>
(function(){const A='/api/method/';const call=(method,args={})=>fetch(A+method,{method:'POST',headers:{'Content-Type':'application/json','X-Frappe-CSRF-Token':window.frappe?.csrf_token||''},body:JSON.stringify(args)}).then(r=>r.json()).then(r=>{if(r.exc)throw Error('Request failed');return r.message});const $=s=>document.querySelector(s);const esc=v=>String(v??'—').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[c]));let selected='All';const labels={all:'All requests',submitted:'Submitted',under_review:'Under review',approved:'Approved',completed:'Completed',rejected:'Rejected'};function badge(s){let c=s==='Approved'||s==='Completed'?'approved':s==='Under Review'?'review':s==='Rejected'?'rejected':'';return `<span class="onb-badge ${c}">${esc(s||'—')}</span>`}function date(v){return v?new Date(String(v).replace(' ','T')).toLocaleDateString(): '—'}function summary(d){$('#onb-summary').innerHTML=Object.entries(labels).map(([k,l])=>`<div class="onb-card ${selected===(k==='all'?'All':k==='under_review'?'Under Review':k[0].toUpperCase()+k.slice(1))?'active':''}" data-status="${k==='all'?'All':k==='under_review'?'Under Review':k[0].toUpperCase()+k.slice(1)}"><label>${l}</label><strong>${Number(d[k]||0).toLocaleString()}</strong></div>`).join('');document.querySelectorAll('.onb-card').forEach(x=>x.onclick=()=>{selected=x.dataset.status;$('#onb-status').value=selected;load()})}function rows(data){$('#onb-rows').innerHTML=data.length?data.map(x=>`<tr><td class="onb-id" data-id="${esc(x.name)}">${esc(x.name)}</td><td><strong>${esc(x.organisation_name)}</strong><br><span class="onb-muted">${esc(x.trading_name||'')}</span></td><td>${esc(x.contact_name)}<br><span class="onb-muted">${esc(x.email_id)}</span></td><td>${esc(x.location)}</td><td>${badge(x.onboarding_status)}</td><td>${esc(x.customer_match_result)}</td><td>${date(x.creation)}</td></tr>`).join(''):'<tr><td colspan="7" class="onb-empty">No onboarding requests match your filters.</td></tr>';document.querySelectorAll('.onb-id').forEach(x=>x.onclick=()=>detail(x.dataset.id))}async function load(){try{const [s,r]=await Promise.all([call('makap_backend.makap_backend.onboarding_api.get_onboarding_summary'),call('makap_backend.makap_backend.onboarding_api.get_onboarding_requests',{status:$('#onb-status').value,search:$('#onb-search').value})]);summary(s);rows(r)}catch(e){$('#onb-rows').innerHTML='<tr><td colspan="7" class="onb-empty">Unable to load onboarding data. Check your Frappe permissions.</td></tr>'}}function open(){ $('#onb-modal').classList.add('open');$('#onb-form').style.display='block';$('#onb-detail-content').innerHTML='';$('#onb-modal-title').textContent='New onboarding request'}function close(){ $('#onb-modal').classList.remove('open')}async function detail(name){try{const x=await call('makap_backend.makap_backend.onboarding_api.get_onboarding_request',{name});$('#onb-modal-title').textContent=x.organisation_name||x.name;$('#onb-form').style.display='none';$('#onb-detail-content').innerHTML='<div class="onb-detail">'+[['Request',x.name],['Status',x.onboarding_status],['Organisation',x.organisation_name],['Trading name',x.trading_name],['Customer type',x.customer_type],['Tax identifier',x.business_tax_identifier],['Contact',x.contact_name],['Email',x.email_id],['Telephone',x.telephone],['Location',x.location],['Customer match',x.customer_match_result],['Portal access',x.portal_access_status],['Product interest',x.product_interest],['Initial enquiry',x.initial_enquiry],['Review notes',x.review_notes]].map(([a,b])=>`<div><small>${a}</small><span>${esc(b)}</span></div>`).join('')+'</div>';$('#onb-modal').classList.add('open')}catch(e){alert('Unable to open this request')}}$('#onb-new').onclick=open;$('#onb-close').onclick=close;$('#onb-cancel').onclick=close;$('#onb-refresh').onclick=load;$('#onb-status').onchange=()=>{selected=$('#onb-status').value;load()};let timer;$('#onb-search').oninput=()=>{clearTimeout(timer);timer=setTimeout(load,250)};$('#onb-form').onsubmit=async e=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.target));data.privacy_notice_acknowledged=data.privacy_notice_acknowledged?1:0;try{const name=await call('makap_backend.makap_backend.onboarding_api.create_onboarding_request',{data:JSON.stringify(data)});close();await load();detail(name)}catch(err){alert('Unable to create request. Check required fields and permissions.')}};load()})();
</script>
{% endblock %}
+688
View File
@@ -0,0 +1,688 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>MAKAP Backend — Portal</title>
<link rel="icon" href="/assets/makap_backend/images/makap-icon.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root{--brand:#B85807;--brand-d:#9C4A06;--brand-l:#FBE2C4;--ink:#1c1f1a;--soft:#5c6459;--mute:#6b7568;--line:#E5E7DF;--bg:#F7F8F4;--card:#fff;--red:#c23f60;--amber:#B08900;--blue:#2E6BE6;--purple:#6E5BD6;--ok:#2f7d4f}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--ink);font-size:14.5px;line-height:1.5;-webkit-font-smoothing:antialiased;padding-top:env(safe-area-inset-top,0px);padding-bottom:env(safe-area-inset-bottom,0px)}
a{color:inherit;text-decoration:none}
button{font:inherit;cursor:pointer}
.shell{display:flex;min-height:100%}
.sidebar{width:275px;flex:0 0 275px;background:#fff;border-right:1px solid var(--line);display:flex;flex-direction:column;padding:20px 14px;position:sticky;top:0;height:100vh;overflow-y:auto;z-index:40}
.brand{display:flex;align-items:center;gap:10px;padding:2px 8px 14px}
.brand-logo{width:38px;height:30px;object-fit:contain;flex:0 0 38px}
.brand-mark{width:30px;height:30px;border-radius:8px;background:linear-gradient(135deg,var(--brand),#FFAD5C);display:flex;align-items:center;justify-content:center;font-weight:800;font-size:12.5px;color:#fff}
.brand-name{font-weight:800;font-size:16px;line-height:1.1}
.brand-sub{font-size:11px;color:var(--mute);letter-spacing:.04em}
.nav-group-label{font-size:11.5px;text-transform:uppercase;letter-spacing:.08em;color:var(--mute);padding:14px 10px 6px;font-weight:700;cursor:pointer;user-select:none;display:flex;justify-content:space-between}
.nav-group-label::after{content:'⌄';opacity:.7;transition:transform .15s}
.nav-group.collapsed .nav-group-label::after{transform:rotate(-90deg)}
.nav-group.collapsed .nav-item{display:none}
.nav-item{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:8px;font-size:14px;font-weight:500;color:#4a5240;margin-bottom:1px;min-height:38px}
.nav-item:hover{background:var(--brand-l);color:var(--ink)}
.nav-item.active{background:var(--brand);color:#fff;box-shadow:0 5px 14px rgba(184,88,7,.25)}
.nav-item .ico{flex:0 0 17px;width:17px;height:17px;opacity:.85}
.nav-item .dot{flex:0 0 7px;width:7px;height:7px;border-radius:50%;background:#cfd3c6;margin:0 5px}
.nav-item.active .dot{background:#fff}
.nav-tag{margin-left:auto;font-size:10px;font-weight:700;padding:1px 7px;border-radius:20px;white-space:nowrap;color:var(--mute)}
.nav-tag.count{background:var(--brand-l);color:var(--brand-d)}
.nav-item.active .nav-tag{background:rgba(255,255,255,.25);color:#fff}
.sidebar-foot{margin-top:auto;padding-top:14px;border-top:1px solid var(--line);display:flex;gap:8px}
.sidebar-foot a,.sidebar-foot button{flex:1;text-align:center;border:1px solid var(--line);background:#fff;border-radius:8px;padding:8px;font-size:12.5px;font-weight:600;color:#4a5240}
.sidebar-foot a:hover,.sidebar-foot button:hover{background:var(--brand-l)}
.backdrop{display:none;position:fixed;inset:0;background:rgba(28,31,26,.4);z-index:35}
.main{flex:1;min-width:0}
.topbar{height:64px;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:14px;padding:0 26px;position:sticky;top:env(safe-area-inset-top,0px);z-index:20}
.menu-btn{display:none;border:1px solid var(--line);background:#fff;border-radius:8px;width:38px;height:38px;font-size:18px}
.crumb{font-size:14.5px;color:var(--soft);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.crumb b{color:var(--ink);font-weight:600}
.gsearch{margin-left:auto;width:340px;max-width:100%;border:1px solid var(--line);background:var(--bg);border-radius:8px;padding:9px 13px;font:inherit;font-size:13.5px}
.gsearch:focus{outline:2px solid var(--brand-l);border-color:var(--brand)}
.avatar{width:34px;height:34px;border-radius:50%;background:var(--brand-l);color:var(--brand-d);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:13px;flex:0 0 34px}
.content{padding:28px 34px 60px;max-width:1300px}
.hero{background:linear-gradient(115deg,#FDEEDD 0%,#FBE2C4 42%,#fff 100%);border:1px solid #F3DCC0;border-radius:16px;padding:26px 30px;margin-bottom:22px;display:flex;justify-content:space-between;align-items:center;gap:16px;flex-wrap:wrap}
.hero h1{margin:0 0 4px;font-size:26px;font-weight:800;letter-spacing:-.4px}
.hero p{margin:0;color:#4a5240}
.btn{border:1px solid #E3E6DD;background:#fff;color:#23281e;border-radius:8px;padding:9px 14px;font-weight:700;font-size:13px}
.btn:hover{background:var(--brand-l)}
.btn.primary{background:var(--brand);border-color:var(--brand);color:#fff}
.btn.primary:hover{background:var(--brand-d)}
.btn.danger{color:var(--red);border-color:#f3c9c7}
.btn.danger:hover{background:#fbeaea}
.btn[disabled]{opacity:.55;cursor:not-allowed}
.grid4{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:16px}
.grid-sm{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:22px}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px}
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:20px}
.card h3{margin:0 0 14px;font-size:15px}
.kpi{cursor:pointer;transition:box-shadow .15s,transform .15s}
.kpi:hover{box-shadow:0 8px 22px rgba(28,31,26,.08);transform:translateY(-1px)}
.kpi-label{font-size:13px;color:var(--soft);font-weight:500;margin-bottom:10px}
.kpi-val{font-size:32px;font-weight:800;letter-spacing:-.4px;line-height:1.1}
.mini{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px 16px;cursor:pointer}
.mini:hover{background:#FBF3E8}
.mini small{display:block;color:var(--mute);font-size:11.5px;margin-bottom:4px}
.mini strong{font-size:22px;font-weight:800}
.section-title{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--mute);font-weight:700;margin:6px 2px 10px}
.pipe{display:flex;align-items:flex-end;gap:12px;height:170px}
.pipe-col{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;height:100%;min-width:0}
.pipe-bar{width:100%;background:var(--brand-l);border-radius:8px 8px 0 0;min-height:5px}
.pipe-bar.hot{background:var(--brand)}
.pipe-num{font-weight:800;margin-top:6px}
.pipe-label{font-size:11px;color:var(--mute);text-align:center;margin-top:2px;line-height:1.2}
.row-item{display:flex;align-items:center;gap:12px;padding:11px 8px;border-radius:9px;cursor:pointer}
.row-item:hover{background:#FBF3E8}
.row-item+.row-item{border-top:1px solid #EEF1E9}
.badge{width:30px;height:30px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:800;flex:0 0 30px;font-size:13px}
.badge.red{background:#fdecef;color:var(--red)}.badge.amber{background:#fff5d6;color:var(--amber)}.badge.blue{background:#e8f0fd;color:var(--blue)}.badge.purple{background:#efecfb;color:var(--purple)}.badge.ok{background:#e4f4ea;color:var(--ok)}
.row-copy{flex:1;min-width:0}
.row-copy strong{display:block;font-size:14px}
.row-copy span{font-size:12.5px;color:var(--mute)}
.row-count{font-weight:800}
.tag{display:inline-block;padding:3px 9px;border-radius:20px;font-size:11px;font-weight:700;background:var(--brand-l);color:var(--brand-d)}
.tag.done{background:#e4f4ea;color:var(--ok)}.tag.warn{background:#fff5d6;color:var(--amber)}.tag.bad{background:#fdecef;color:var(--red)}.tag.grey{background:#EEF1E9;color:var(--mute)}
.table-wrap{overflow-x:auto;background:#fff;border:1px solid var(--line);border-radius:12px}
table.t{width:100%;border-collapse:collapse;font-size:13px}
table.t th{text-align:left;text-transform:uppercase;font-size:10.5px;letter-spacing:.07em;color:var(--mute);padding:13px 14px;border-bottom:1px solid var(--line);white-space:nowrap;user-select:none}
table.t th[data-sort]{cursor:pointer}
table.t th[data-sort]:hover{color:var(--brand)}
table.t td{padding:12px 14px;border-bottom:1px solid #EEF1E9;white-space:nowrap;max-width:280px;overflow:hidden;text-overflow:ellipsis}
table.t tr:last-child td{border-bottom:0}
table.t tbody tr:hover{background:#FBF3E8}
.link{color:var(--brand);font-weight:700;cursor:pointer}
.muted{color:var(--mute)}
.num{text-align:right}
.page-head{display:flex;justify-content:space-between;align-items:flex-end;gap:14px;margin-bottom:18px;flex-wrap:wrap}
.page-head h1{margin:0 0 4px;font-size:26px}
.page-head p{margin:0;color:var(--soft)}
.toolbar{background:#fff;border:1px solid var(--line);border-radius:11px;padding:10px;display:flex;gap:9px;margin-bottom:12px;flex-wrap:wrap}
.toolbar input,.toolbar select{border:1px solid #E3E6DD;border-radius:7px;padding:9px 12px;font:inherit;font-size:13px;background:#fff}
.toolbar input{flex:1;min-width:200px}
.pager{display:flex;justify-content:space-between;align-items:center;margin-top:12px;color:var(--soft);font-size:13px}
.pager div{display:flex;gap:8px}
.state{padding:60px 20px;text-align:center;color:var(--mute)}
.state.err{color:var(--red)}
.icon-btn{border:1px solid #E3E6DD;background:#fff;border-radius:7px;width:30px;height:30px;font-size:13px;color:#4a5240}
.icon-btn:hover{background:var(--brand-l)}
.icon-btn.danger{color:var(--red)}
.acts{display:flex;gap:6px}
.overlay{position:fixed;inset:0;background:rgba(28,31,26,.45);display:none;align-items:center;justify-content:center;z-index:60;padding:16px}
.overlay.open{display:flex}
.modal{background:#fff;border-radius:14px;width:760px;max-width:100%;max-height:92vh;display:flex;flex-direction:column;box-shadow:0 25px 60px rgba(28,31,26,.28)}
.modal-head{display:flex;justify-content:space-between;align-items:center;padding:18px 22px;border-bottom:1px solid var(--line)}
.modal-head h3{margin:0;font-size:18px}
.modal-x{border:0;background:transparent;font-size:26px;line-height:1;color:var(--mute);padding:0 4px}
.modal-body{padding:18px 22px;overflow-y:auto}
.modal-foot{padding:14px 22px;border-top:1px solid var(--line);display:flex;gap:9px;justify-content:flex-end;flex-wrap:wrap}
.modal-foot .spacer{margin-right:auto}
.form-err{background:#FBEAEA;color:#B3393E;border-radius:8px;padding:10px 12px;font-size:13px;margin-bottom:12px;display:none;white-space:pre-wrap}
.notice{background:#EEF1E9;border-radius:8px;padding:9px 12px;font-size:13px;margin-bottom:14px;color:var(--soft)}
.fields{display:grid;grid-template-columns:1fr 1fr;gap:4px 16px}
.field{margin-bottom:12px}
.field.wide{grid-column:1/-1}
.field label{display:block;font-size:12px;font-weight:700;color:#4a5240;margin-bottom:5px}
.field input:not([type=checkbox]),.field select,.field textarea{width:100%;border:1px solid #E3E6DD;border-radius:8px;padding:9px 11px;font:inherit;font-size:13px;background:#fff}
.field textarea{min-height:70px;resize:vertical}
.field input:disabled,.field select:disabled,.field textarea:disabled{background:#F4F5F0;color:#4a5240}
.field input:focus,.field select:focus,.field textarea:focus{outline:2px solid var(--brand-l);border-color:var(--brand)}
.grid-wrap{margin:8px 0 14px}
.grid-title{font-size:12px;text-transform:uppercase;letter-spacing:.07em;font-weight:700;color:var(--mute);margin-bottom:7px}
.grid-scroll{overflow-x:auto;border:1px solid var(--line);border-radius:10px;margin-bottom:8px}
table.grid{width:100%;border-collapse:collapse;font-size:12.5px}
table.grid th{text-align:left;font-size:10.5px;text-transform:uppercase;color:var(--mute);padding:9px 8px;border-bottom:1px solid var(--line);white-space:nowrap}
table.grid td{padding:5px 6px;border-bottom:1px solid #EEF1E9}
table.grid input:not([type=checkbox]),table.grid select{width:100%;min-width:110px;border:1px solid #E3E6DD;border-radius:6px;padding:6px 8px;font:inherit;font-size:12.5px;background:#fff}
table.grid input:disabled,table.grid select:disabled{background:#F4F5F0}
.toast{position:fixed;right:22px;bottom:22px;background:#1c1f1a;color:#fff;padding:12px 16px;border-radius:8px;font-size:13px;box-shadow:0 14px 35px rgba(28,31,26,.25);transform:translateY(20px);opacity:0;pointer-events:none;transition:.2s;z-index:80;max-width:min(420px,90vw)}
.toast.show{transform:none;opacity:1}
.toast.bad{background:#B3393E}
@media(max-width:1100px){.grid4,.grid-sm{grid-template-columns:repeat(2,1fr)}.grid2{grid-template-columns:1fr}}
@media(max-width:900px){
.sidebar{position:fixed;left:0;top:0;transform:translateX(-102%);transition:transform .2s;box-shadow:0 0 40px rgba(0,0,0,.2)}
.sidebar.open{transform:none}
.backdrop.open{display:block}
.menu-btn{display:block}
.content{padding:20px 16px 50px}
.topbar{padding:0 14px}
.gsearch{width:100%;margin-left:0;flex:1}
.crumb{display:none}
.fields{grid-template-columns:1fr}
}
@media(max-width:560px){.grid4,.grid-sm{grid-template-columns:1fr 1fr}.kpi-val{font-size:26px}.hero h1{font-size:22px}}
</style>
</head>
<body>
<div class="shell">
<aside class="sidebar" id="sidebar" aria-label="Navigation">
<div class="brand"><img class="brand-logo" src="/assets/makap_backend/images/makap-icon.png" alt="MAKAP"><div><div class="brand-name">MAKAP Backend</div><div class="brand-sub">Manufacturing MVP</div></div></div>
<nav id="nav"><div class="state">Loading…</div></nav>
<div class="sidebar-foot"><a href="/app">Open Desk</a><button type="button" id="logout">Log out</button></div>
</aside>
<div class="backdrop" id="backdrop"></div>
<div class="main">
<header class="topbar">
<button class="menu-btn" id="menu" type="button" aria-label="Open menu">☰</button>
<div class="crumb" id="crumb"></div>
<input class="gsearch" id="gsearch" type="search" placeholder="Jump to a module… ( / )" autocomplete="off" aria-label="Jump to a module">
<div class="avatar" id="avatar">U</div>
</header>
<main class="content" id="view"></main>
</div>
</div>
<div class="overlay" id="overlay"><div class="modal" role="dialog" aria-modal="true" id="modal"></div></div>
<div class="toast" id="toast" role="status"></div>
<script>window.MAKAP = {csrf: '', user: '', fullName: ''};</script>
<script>
(function () {
'use strict';
var M = window.MAKAP;
var API = 'makap_backend.makap_backend.portal_api.';
var CRUD = 'makap_backend.makap_backend.portal_crud.';
var $ = function (s, r) { return (r || document).querySelector(s); };
var $$ = function (s, r) { return Array.prototype.slice.call((r || document).querySelectorAll(s)); };
var view = $('#view'), crumb = $('#crumb'), nav = $('#nav');
var navToken = 0, refreshTimer = null, metaCache = {}, listState = {}, labels = {};
function attr(v) { return String(v == null ? '' : v).replace(/[&<>"']/g, function (c) { return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[c]; }); }
function esc(v) { return (v == null || v === '') ? '<span class="muted">—</span>' : attr(v); }
function num(v) { return new Intl.NumberFormat(undefined, {maximumFractionDigits: 2}).format(Number(v || 0)); }
function ago(v) {
if (!v) return '—';
var d = new Date(String(v).replace(' ', 'T'));
if (isNaN(d.getTime())) return attr(v);
var h = Math.round((Date.now() - d.getTime()) / 36e5);
return h < 1 ? 'Just now' : h < 24 ? h + 'h ago' : Math.round(h / 24) + 'd ago';
}
function slug(dt) { return dt.toLowerCase().replace(/ /g, '-'); }
function toast(msg, bad) {
var t = $('#toast'); t.textContent = msg; t.className = 'toast show' + (bad ? ' bad' : '');
clearTimeout(toast._t); toast._t = setTimeout(function () { t.className = 'toast'; }, 3200);
}
function store(k, v) { try { if (v === undefined) return localStorage.getItem(k); localStorage.setItem(k, v); } catch (e) { return null; } }
function parseErr(j, status) {
try {
if (j && j._server_messages) {
return JSON.parse(j._server_messages).map(function (m) { try { return JSON.parse(m).message; } catch (e) { return m; } }).join('\n').replace(/<[^>]+>/g, '');
}
} catch (e) {}
if (status === 401 || status === 403) return 'Not permitted, or your session expired. Reload the page.';
if (j && j.exception) return String(j.exception).replace(/^[\w.]+:\s*/, '');
return 'Request failed (' + status + ')';
}
function api(method, args) {
return fetch('/api/method/' + method, {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json', 'Accept': 'application/json', 'X-Frappe-CSRF-Token': M.csrf},
body: JSON.stringify(args || {})
}).then(function (r) {
return r.json().catch(function () { return {}; }).then(function (j) {
if (!r.ok || j.exc) throw new Error(parseErr(j, r.status));
return j.message;
});
});
}
/* ---------- identity ---------- */
var fullName = 'User';
function setIdentity() {
fullName = M.fullName || M.user || 'User';
$('#avatar').textContent = fullName.split(/\s+/).map(function (p) { return p[0]; }).join('').slice(0, 2).toUpperCase();
}
/* ---------- sidebar ---------- */
var ICO = '<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/></svg>';
function collapsedSet() { try { return JSON.parse(store('makap_collapsed') || '[]'); } catch (e) { return []; } }
function renderNav(groups) {
var coll = collapsedSet();
var html = '<a class="nav-item" data-dash="1" href="#/">' + ICO + '<span class="nav-text">Dashboard</span></a>';
groups.forEach(function (g) {
html += '<div class="nav-group' + (coll.indexOf(g.label) > -1 ? ' collapsed' : '') + '" data-label="' + attr(g.label) + '"><div class="nav-group-label" tabindex="0" role="button">' + attr(g.label) + '</div>';
g.items.forEach(function (it) {
var href = it.href ? attr(it.href) : '#/list/' + encodeURIComponent(it.doctype);
if (it.doctype) labels[it.doctype] = it.label;
var tag = it.badge ? '<span class="nav-tag count">' + attr(it.badge) + '</span>' : (it.tag ? '<span class="nav-tag">' + attr(it.tag) + '</span>' : '');
html += '<a class="nav-item" href="' + href + '"' + (it.doctype ? ' data-doctype="' + attr(it.doctype) + '"' : '') + '><span class="dot"></span><span class="nav-text">' + attr(it.label) + '</span>' + tag + '</a>';
});
html += '</div>';
});
nav.innerHTML = html;
$$('.nav-group-label', nav).forEach(function (l) {
var toggle = function () {
var g = l.parentElement; g.classList.toggle('collapsed');
store('makap_collapsed', JSON.stringify($$('.nav-group.collapsed', nav).map(function (x) { return x.dataset.label; })));
};
l.onclick = toggle;
l.onkeydown = function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } };
});
}
function setActive(doctype) {
$$('.nav-item', nav).forEach(function (a) {
a.classList.toggle('active', doctype ? a.dataset.doctype === doctype : !!a.dataset.dash);
});
}
function closeSidebar() { $('#sidebar').classList.remove('open'); $('#backdrop').classList.remove('open'); }
$('#menu').onclick = function () { $('#sidebar').classList.add('open'); $('#backdrop').classList.add('open'); };
$('#backdrop').onclick = closeSidebar;
$('#logout').onclick = function () { api('logout').catch(function () {}).then(function () { location.href = '/login'; }); };
/* jump-to search */
var gs = $('#gsearch');
function filterNav(q) {
q = q.trim().toLowerCase();
$$('.nav-group', nav).forEach(function (g) {
var any = false;
$$('.nav-item', g).forEach(function (a) {
var hit = !q || $('.nav-text', a).textContent.toLowerCase().indexOf(q) > -1;
a.style.display = hit ? '' : 'none'; if (hit) any = true;
});
g.style.display = any ? '' : 'none';
if (q) g.classList.remove('collapsed');
});
}
gs.oninput = function () { filterNav(gs.value); };
gs.onkeydown = function (e) {
if (e.key === 'Enter') {
var first = $$('.nav-group .nav-item', nav).filter(function (a) { return a.style.display !== 'none' && a.parentElement.style.display !== 'none'; })[0];
if (first) { first.click(); gs.value = ''; filterNav(''); gs.blur(); }
} else if (e.key === 'Escape') { gs.value = ''; filterNav(''); gs.blur(); }
};
document.addEventListener('keydown', function (e) {
var tag = (e.target.tagName || '').toLowerCase();
if ((e.key === '/' && tag !== 'input' && tag !== 'textarea' && tag !== 'select') || (e.ctrlKey && e.key.toLowerCase() === 'g')) {
e.preventDefault(); $('#sidebar').classList.add('open'); gs.focus();
}
});
/* ---------- router ---------- */
function route() {
clearInterval(refreshTimer);
closeSidebar();
var parts = location.hash.replace(/^#\/?/, '').split('/');
if (parts[0] === 'list' && parts[1]) showList(decodeURIComponent(parts.slice(1).join('/')));
else showDashboard();
}
window.addEventListener('hashchange', route);
function go(h) { if (location.hash === h) route(); else location.hash = h; }
function toList(dt) { go('#/list/' + encodeURIComponent(dt)); }
/* ---------- dashboard ---------- */
function showDashboard() {
var tok = ++navToken;
setActive(null);
crumb.innerHTML = 'MAKAP Backend &nbsp;›&nbsp; <b>Dashboard</b>';
view.innerHTML = '<div class="hero"><div><h1>Welcome back, ' + attr(fullName.split(' ')[0]) + ' 👋</h1><p>Here\'s what\'s happening across MAKAP Uganda\'s manufacturing workflow.</p></div><div><button class="btn" id="dash-refresh" type="button">↻ Refresh</button> <span class="muted" id="dash-time" style="margin-left:8px;font-size:12.5px"></span></div></div><div id="dash-body"><div class="state">Loading dashboard…</div></div>';
var load = function () {
api(API + 'get_portal_dashboard').then(function (d) {
if (tok !== navToken) return;
renderDashboard(d);
$('#dash-time').textContent = 'Updated ' + new Date().toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'});
}).catch(function (e) {
if (tok !== navToken) return;
$('#dash-body').innerHTML = '<div class="state err">Could not load the dashboard.<br>' + attr(e.message) + '<br><br><button class="btn" id="dash-retry" type="button">Retry</button></div>';
$('#dash-retry').onclick = load;
});
};
$('#dash-refresh').onclick = load;
load();
refreshTimer = setInterval(load, 60000);
}
function renderDashboard(d) {
var k = d.kpis || {};
var primary = [
['Onboarding requests', k.onboarding_requests, 'Customer Onboarding Request'],
['Packaging specifications', k.packaging_specifications, 'Packaging Specification'],
['Approved for manufacture', k.approved_for_manufacture, 'Packaging Specification'],
['Critical requirements tracked', k.critical_requirements_tracked, null]
];
var secondary = [
['Active sales orders', k.active_sales_orders, 'Sales Order'], ['Active work orders', k.active_work_orders, 'Work Order'],
['Open purchase orders', k.open_purchase_orders, 'Purchase Order'], ['Deliveries this month', k.deliveries_this_month, 'Delivery Note'],
['Onboarding in review', k.onboarding_open, 'Customer Onboarding Request'], ['Specs pending approval', k.specifications_pending, 'Packaging Specification'],
['Customer returns', k.returns_total, 'Delivery Note'], ['Closure views', k.closure_views, 'Order Closure And Margin View']
];
var html = '<div class="grid4">' + primary.map(function (p) {
return '<div class="card kpi"' + (p[2] ? ' data-go="' + attr(p[2]) + '"' : '') + '><div class="kpi-label">' + attr(p[0]) + '</div><div class="kpi-val">' + num(p[1]) + '</div></div>';
}).join('') + '</div>';
html += '<div class="section-title">Operations at a glance</div><div class="grid-sm">' + secondary.map(function (p) {
return '<div class="mini" data-go="' + attr(p[2]) + '"><small>' + attr(p[0]) + '</small><strong>' + num(p[1]) + '</strong></div>';
}).join('') + '</div>';
var max = Math.max.apply(null, (d.pipeline || []).map(function (x) { return Number(x.value || 0); }).concat([1]));
html += '<div class="grid2"><div class="card"><h3>Sales order pipeline</h3><div class="pipe">' + (d.pipeline || []).map(function (x) {
var v = Number(x.value || 0);
return '<div class="pipe-col"><div class="pipe-bar' + (v > 0 && v === max ? ' hot' : '') + '" style="height:' + Math.max(3, Math.round(v / max * 100)) + '%"></div><div class="pipe-num">' + num(v) + '</div><div class="pipe-label">' + attr(x.label) + '</div></div>';
}).join('') + '</div></div>';
var icons = {red: '!', amber: '⌁', blue: '$', purple: '≈'};
html += '<div class="card"><h3>Needs attention</h3>' + ((d.priorities || []).length ? d.priorities.map(function (p) {
return '<div class="row-item" data-go="' + attr(p.route) + '"><div class="badge ' + attr(p.tone) + '">' + (icons[p.tone] || '!') + '</div><div class="row-copy"><strong>' + attr(p.label) + '</strong><span>' + attr(p.detail) + '</span></div><span class="row-count">' + num(p.value) + '</span></div>';
}).join('') : '<div class="row-item"><div class="badge ok">✓</div><div class="row-copy"><strong>No active blockers</strong><span>Nothing is waiting on attention.</span></div></div>') + '</div></div>';
html += '<div class="grid2"><div class="card"><h3>Recent sales orders</h3>';
if ((d.recent_orders || []).length) {
html += '<div class="table-wrap" style="border:0"><table class="t"><thead><tr><th>Order</th><th>Customer</th><th>Status</th><th class="num">Total</th><th>Updated</th></tr></thead><tbody>' + d.recent_orders.map(function (o) {
var s = o.status || 'Draft';
var tone = ['Completed', 'Closed'].indexOf(s) > -1 ? 'done' : ['To Bill', 'To Deliver', 'To Deliver and Bill'].indexOf(s) > -1 ? 'warn' : s === 'Cancelled' ? 'bad' : 'grey';
return '<tr><td class="link" data-open="Sales Order" data-name="' + attr(o.name) + '">' + attr(o.name) + '</td><td>' + esc(o.customer) + '</td><td><span class="tag ' + tone + '">' + attr(s) + '</span></td><td class="num">' + num(o.grand_total) + '</td><td>' + ago(o.modified) + '</td></tr>';
}).join('') + '</tbody></table></div>';
} else html += '<div class="state">No sales orders yet.</div>';
html += '</div><div class="card"><h3>Order closure &amp; margin</h3>';
html += (d.recent_closures || []).length ? d.recent_closures.map(function (c) {
var open = c.operating_close !== 'Closed';
return '<div class="row-item" data-open="Order Closure And Margin View" data-name="' + attr(c.name) + '"><div class="badge ' + (open ? 'amber' : 'ok') + '">' + (open ? '!' : '✓') + '</div><div class="row-copy"><strong>' + attr(c.sales_order || c.name) + '</strong><span>' + attr(open ? (c.primary_blocker || 'Open operating close') : 'Operating close complete') + '</span></div><span class="tag ' + (open ? 'warn' : 'done') + '">' + attr(c.operating_close || 'Open') + '</span></div>';
}).join('') : '<div class="state">No closure views yet.</div>';
html += '</div></div>';
$('#dash-body').innerHTML = html;
$$('[data-go]', $('#dash-body')).forEach(function (el) { el.onclick = function () { toList(el.dataset.go); }; });
$$('[data-open]', $('#dash-body')).forEach(function (el) { el.onclick = function () { openForm(el.dataset.open, el.dataset.name, showDashboard); }; });
}
/* ---------- generic list ---------- */
function showList(doctype) {
var tok = ++navToken;
setActive(doctype);
var label = labels[doctype] || doctype;
crumb.innerHTML = 'MAKAP Backend &nbsp;›&nbsp; <b>' + attr(label) + '</b>';
var st = listState[doctype] = listState[doctype] || {search: '', start: 0, size: 20, order: 'modified', dir: 'desc', docstatus: 'all', state: 'all'};
view.innerHTML = '<div class="state">Loading ' + attr(label) + '…</div>';
var shellBuilt = false;
function fetchRows() {
return api(API + 'portal_list', {doctype: doctype, search: st.search, start: st.start, page_length: st.size, order_by: st.order, order_dir: st.dir, docstatus: st.docstatus, state: st.state});
}
function reload() {
fetchRows().then(function (d) {
if (tok !== navToken) return;
if (!shellBuilt) buildShell(d); fill(d);
}).catch(function (e) {
if (tok !== navToken) return;
var box = $('#list-table') || view;
box.innerHTML = '<div class="state err">Unable to load ' + attr(label) + '.<br>' + attr(e.message) + '</div>';
});
}
function buildShell(d) {
shellBuilt = true;
view.innerHTML =
'<div class="page-head"><div><h1>' + attr(label) + '</h1><p id="list-sub"></p></div><div style="display:flex;gap:9px">' +
(d.can_create ? '<button class="btn primary" id="l-new" type="button">+ New</button>' : '') +
'<button class="btn" id="l-refresh" type="button">↻ Refresh</button></div></div>' +
'<div class="toolbar"><input id="l-search" type="search" placeholder="Search ' + attr(label) + '…" value="' + attr(st.search) + '">' +
(d.submittable ? '<select id="l-docstatus"><option value="all">All documents</option><option value="0">Draft</option><option value="1">Submitted</option><option value="2">Cancelled</option></select>' : '') +
(d.has_disabled ? '<select id="l-state"><option value="all">All</option><option value="active">Active</option><option value="disabled">Disabled</option></select>' : '') +
'</div><div id="list-table"></div><div class="pager" id="list-pager"></div>';
if ($('#l-docstatus')) { $('#l-docstatus').value = st.docstatus; $('#l-docstatus').onchange = function (e) { st.docstatus = e.target.value; st.start = 0; reload(); }; }
if ($('#l-state')) { $('#l-state').value = st.state; $('#l-state').onchange = function (e) { st.state = e.target.value; st.start = 0; reload(); }; }
var t; $('#l-search').oninput = function (e) { st.search = e.target.value; st.start = 0; clearTimeout(t); t = setTimeout(reload, 300); };
$('#l-refresh').onclick = reload;
if ($('#l-new')) $('#l-new').onclick = function () { openForm(doctype, null, reload); };
}
function cell(c, v) {
if (v == null || v === '') return '<span class="muted">—</span>';
if (c.fieldtype === 'Check') return Number(v) ? '✓' : '—';
if (['Currency', 'Float', 'Int', 'Percent'].indexOf(c.fieldtype) > -1) return attr(num(v));
return attr(v);
}
function fill(d) {
$('#list-sub').textContent = d.total + ' record' + (d.total === 1 ? '' : 's');
var cols = d.columns;
var head = cols.map(function (c) {
var arrow = st.order === c.fieldname ? (st.dir === 'asc' ? ' ▲' : ' ▼') : '';
return '<th data-sort="' + attr(c.fieldname) + '">' + attr(c.label) + arrow + '</th>';
}).join('') + (d.submittable ? '<th>Doc status</th>' : '') + '<th></th>';
var body = d.rows.length ? d.rows.map(function (r) {
var ds = Number(r.docstatus || 0);
var canDel = d.can_delete && ds === 0;
return '<tr>' + cols.map(function (c, i) {
return '<td' + (i === 0 ? ' class="link" data-name="' + attr(r.name) + '"' : '') + '>' + cell(c, r[c.fieldname]) + '</td>';
}).join('') + (d.submittable ? '<td><span class="tag ' + (ds === 1 ? 'done' : ds === 2 ? 'bad' : 'grey') + '">' + (ds === 1 ? 'Submitted' : ds === 2 ? 'Cancelled' : 'Draft') + '</span></td>' : '') +
'<td><div class="acts"><button class="icon-btn" data-edit="' + attr(r.name) + '" title="Open" aria-label="Open">✎</button>' +
(canDel ? '<button class="icon-btn danger" data-del="' + attr(r.name) + '" title="Delete" aria-label="Delete">🗑</button>' : '') + '</div></td></tr>';
}).join('') : '<tr><td colspan="' + (cols.length + 2) + '"><div class="state">No records found.</div></td></tr>';
$('#list-table').innerHTML = '<div class="table-wrap"><table class="t"><thead><tr>' + head + '</tr></thead><tbody>' + body + '</tbody></table></div>';
$$('#list-table th[data-sort]').forEach(function (th) {
th.onclick = function () {
var f = th.dataset.sort;
if (st.order === f) st.dir = st.dir === 'asc' ? 'desc' : 'asc'; else { st.order = f; st.dir = 'asc'; }
st.start = 0; reload();
};
});
$$('#list-table [data-name]').forEach(function (td) { td.onclick = function () { openForm(doctype, td.dataset.name, reload); }; });
$$('#list-table [data-edit]').forEach(function (b) { b.onclick = function () { openForm(doctype, b.dataset.edit, reload); }; });
$$('#list-table [data-del]').forEach(function (b) { b.onclick = function () { doDelete(doctype, b.dataset.del, reload); }; });
var from = d.total ? d.start + 1 : 0, to = Math.min(d.start + d.rows.length, d.total);
$('#list-pager').innerHTML = '<span>Showing ' + from + '–' + to + ' of ' + d.total + '</span><div><button class="btn" id="p-prev" type="button"' + (d.start <= 0 ? ' disabled' : '') + '>← Prev</button><button class="btn" id="p-next" type="button"' + (to >= d.total ? ' disabled' : '') + '>Next →</button></div>';
$('#p-prev').onclick = function () { st.start = Math.max(0, st.start - st.size); reload(); };
$('#p-next').onclick = function () { st.start += st.size; reload(); };
}
reload();
}
function doDelete(doctype, name, done) {
if (!window.confirm('Delete ' + name + '? This cannot be undone.')) return;
api(CRUD + 'delete_doc', {doctype: doctype, name: name}).then(function () { toast('Deleted ' + name); closeModal(); if (done) done(); })
.catch(function (e) { toast(e.message, true); });
}
/* ---------- modal + form ---------- */
var overlay = $('#overlay'), modal = $('#modal');
function closeModal() { overlay.classList.remove('open'); modal.innerHTML = ''; }
overlay.addEventListener('mousedown', function (e) { if (e.target === overlay) closeModal(); });
document.addEventListener('keydown', function (e) {
if (!overlay.classList.contains('open')) return;
if (e.key === 'Escape') { closeModal(); return; }
if (e.key === 'Tab') {
var f = $$('button,input,select,textarea,a[href]', modal).filter(function (x) { return !x.disabled && x.offsetParent !== null; });
if (!f.length) return;
var first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
});
function getMeta(dt) {
if (metaCache[dt]) return Promise.resolve(metaCache[dt]);
return api(CRUD + 'get_doctype_meta', {doctype: dt}).then(function (m) { return (metaCache[dt] = m); });
}
function today() { var d = new Date(); return d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2) + '-' + ('0' + d.getDate()).slice(-2); }
function defaultFor(f) {
var d = f.default; if (!d || /^__/.test(d)) return '';
if (d === 'Today') return today();
if (d === 'Now') return today() + ' ' + new Date().toTimeString().slice(0, 8);
return d;
}
function inputHtml(f, val, dis, kind, isNew) {
var d = (dis || f.read_only) ? ' disabled' : '';
var req = (f.reqd && !d) ? ' required' : '';
var a = ' data-' + kind + '="' + attr(f.fieldname) + '"';
var v = val == null ? '' : val;
if (isNew && v === '' && !f.read_only) v = defaultFor(f);
switch (f.fieldtype) {
case 'Select':
var opts = (f.options || '').split('\n');
return '<select' + a + d + req + '>' + ((f.reqd && v !== '') ? '' : '<option value=""></option>') + opts.filter(function (o) { return o !== ''; }).map(function (o) {
return '<option value="' + attr(o) + '"' + (o === v ? ' selected' : '') + '>' + attr(o) + '</option>';
}).join('') + '</select>';
case 'Check':
return '<input type="checkbox"' + a + d + (Number(v) ? ' checked' : '') + '>';
case 'Text': case 'Small Text': case 'Long Text': case 'Text Editor':
return '<textarea' + a + d + req + '>' + attr(v) + '</textarea>';
case 'Int': case 'Float': case 'Currency': case 'Percent':
return '<input type="number" step="any"' + a + d + req + ' value="' + attr(v) + '">';
case 'Date':
return '<input type="date"' + a + d + req + ' value="' + attr(String(v).slice(0, 10)) + '">';
case 'Datetime':
return '<input type="datetime-local"' + a + d + req + ' value="' + attr(String(v).replace(' ', 'T').slice(0, 16)) + '">';
case 'Time':
return '<input type="time"' + a + d + req + ' value="' + attr(String(v).slice(0, 5)) + '">';
case 'Link':
return '<input type="text" autocomplete="off" list="dl-' + attr((f.options || '').replace(/\W/g, '_')) + '" data-link="' + attr(f.options) + '"' + a + d + req + ' value="' + attr(v) + '" placeholder="' + attr(f.options) + '">';
default:
return '<input type="text"' + a + d + req + ' value="' + attr(v) + '">';
}
}
function rowHtml(t, row, dis) {
return '<tr>' + t.fields.map(function (f) { return '<td>' + inputHtml(f, row[f.fieldname], dis, 'c') + '</td>'; }).join('') +
'<td>' + (dis ? '' : '<button class="icon-btn danger" type="button" data-rm="1" aria-label="Remove row">✕</button>') + '</td></tr>';
}
function linkDatalists(meta) {
var seen = {}, out = '';
var all = meta.fields.concat.apply(meta.fields, meta.table_fields.map(function (t) { return t.fields; }));
all.forEach(function (f) {
if (f.fieldtype === 'Link' && f.options && !seen[f.options]) { seen[f.options] = 1; out += '<datalist id="dl-' + attr(f.options.replace(/\W/g, '_')) + '"></datalist>'; }
});
return out;
}
function openForm(doctype, name, done) {
modal.innerHTML = '<div class="modal-head"><h3>' + attr(name || ('New ' + doctype)) + '</h3><button class="modal-x" type="button" id="m-x" aria-label="Close">&times;</button></div><div class="modal-body"><div class="state">Loading…</div></div>';
overlay.classList.add('open');
$('#m-x').onclick = closeModal;
Promise.all([getMeta(doctype), name ? api(CRUD + 'get_doc', {doctype: doctype, name: name}) : Promise.resolve({})]).then(function (res) {
buildForm(doctype, name, res[0], res[1], done);
}).catch(function (e) {
$('.modal-body', modal).innerHTML = '<div class="state err">' + attr(e.message) + '</div>';
});
}
function buildForm(doctype, name, meta, doc, done) {
var isNew = !name, ds = Number(doc.docstatus || 0);
var editable = meta.writable && (isNew ? meta.can_create : meta.can_write) && ds === 0;
var notice = '';
if (!meta.writable) notice = 'This record type is view-only in the portal.';
else if (ds === 1) notice = 'This document is submitted. Cancel it to make changes.';
else if (ds === 2) notice = 'This document is cancelled.';
else if (!isNew && !meta.can_write) notice = 'You have read-only access to this record.';
var fieldsHtml = meta.fields.map(function (f) {
var wide = ['Text', 'Small Text', 'Long Text', 'Text Editor'].indexOf(f.fieldtype) > -1;
var chk = f.fieldtype === 'Check';
return '<div class="field' + (wide ? ' wide' : '') + '"><label>' + attr(f.label) + (f.reqd && editable ? ' *' : '') + '</label>' + inputHtml(f, doc[f.fieldname], !editable, 'f', isNew) + '</div>';
}).join('');
var gridsHtml = meta.table_fields.map(function (t) {
var rows = (doc[t.fieldname] || []);
return '<div class="grid-wrap"><div class="grid-title">' + attr(t.label) + '</div><div class="grid-scroll"><table class="grid" data-t="' + attr(t.fieldname) + '"><thead><tr>' +
t.fields.map(function (f) { return '<th>' + attr(f.label) + '</th>'; }).join('') + '<th></th></tr></thead><tbody>' +
rows.map(function (r) { return rowHtml(t, r, !editable); }).join('') + '</tbody></table></div>' +
(editable ? '<button class="btn" type="button" data-add="' + attr(t.fieldname) + '">+ Add row</button>' : '') + '</div>';
}).join('');
$('.modal-body', modal).innerHTML = '<div class="form-err" id="m-err"></div>' + (notice ? '<div class="notice">' + attr(notice) + '</div>' : '') +
'<form id="m-form" autocomplete="off"><div class="fields">' + fieldsHtml + '</div>' + gridsHtml + '</form>' + linkDatalists(meta);
var foot = '<div class="modal-foot">';
if (!isNew) foot += '<a class="btn spacer" href="/app/' + slug(doctype) + '/' + encodeURIComponent(name) + '" target="_blank" rel="noopener">Open in Desk ↗</a>';
if (!isNew && editable && meta.can_delete) foot += '<button class="btn danger" type="button" id="m-del">Delete</button>';
if (!isNew && meta.submittable && ds === 0 && meta.can_submit) foot += '<button class="btn" type="button" id="m-submit">Submit</button>';
if (!isNew && meta.submittable && ds === 1 && meta.can_cancel) foot += '<button class="btn danger" type="button" id="m-cancel-doc">Cancel document</button>';
foot += '<button class="btn" type="button" id="m-close">Close</button>';
if (editable) foot += '<button class="btn primary" type="button" id="m-save">Save</button>';
foot += '</div>';
var old = $('.modal-foot', modal); if (old) old.remove();
modal.insertAdjacentHTML('beforeend', foot);
var err = $('#m-err'), form = $('#m-form');
function showErr(m) { err.textContent = m; err.style.display = 'block'; err.scrollIntoView({block: 'nearest'}); }
function busy(on) { $$('.modal-foot .btn', modal).forEach(function (b) { b.disabled = on; }); }
$('#m-close').onclick = closeModal;
/* link typeahead (shared datalists) */
var lt;
function fetchLinks(inp) {
var dt = inp.dataset.link; if (!dt) return;
clearTimeout(lt);
lt = setTimeout(function () {
api(CRUD + 'link_options', {doctype: dt, txt: inp.value}).then(function (rows) {
var dl = document.getElementById('dl-' + dt.replace(/\W/g, '_')); if (!dl) return;
dl.innerHTML = (rows || []).map(function (r) { return '<option value="' + attr(r.value) + '">' + attr(r.label !== r.value ? r.label : '') + '</option>'; }).join('');
}).catch(function () {});
}, 200);
}
form.addEventListener('input', function (e) { if (e.target.dataset && e.target.dataset.link) fetchLinks(e.target); });
form.addEventListener('focusin', function (e) { if (e.target.dataset && e.target.dataset.link) fetchLinks(e.target); });
if (editable) {
$$('[data-add]', modal).forEach(function (b) {
b.onclick = function () {
var t = meta.table_fields.filter(function (x) { return x.fieldname === b.dataset.add; })[0];
$('table.grid[data-t="' + t.fieldname + '"] tbody', modal).insertAdjacentHTML('beforeend', rowHtml(t, {}, false));
};
});
form.addEventListener('click', function (e) {
if (e.target.dataset && e.target.dataset.rm) e.target.closest('tr').remove();
});
}
function readVal(el) {
if (el.type === 'checkbox') return el.checked ? 1 : 0;
if (el.type === 'datetime-local') return el.value.replace('T', ' ');
return el.value;
}
function collect() {
var values = {};
meta.fields.forEach(function (f) {
if (f.read_only) return;
var el = form.querySelector('[data-f="' + f.fieldname + '"]'); if (el) values[f.fieldname] = readVal(el);
});
meta.table_fields.forEach(function (t) {
values[t.fieldname] = $$('table.grid[data-t="' + t.fieldname + '"] tbody tr', form).map(function (tr) {
var row = {};
$$('[data-c]', tr).forEach(function (el) { row[el.dataset.c] = readVal(el); });
return row;
});
});
return values;
}
function after(msg) { closeModal(); toast(msg); if (done) done(); }
if ($('#m-save')) $('#m-save').onclick = function () {
err.style.display = 'none';
if (!form.reportValidity()) return;
busy(true);
var call = isNew ? api(CRUD + 'create_doc', {doctype: doctype, values: collect()}) : api(CRUD + 'update_doc', {doctype: doctype, name: name, values: collect()});
call.then(function (r) { after((isNew ? 'Created ' : 'Saved ') + r.name); }).catch(function (e) { busy(false); showErr(e.message); });
};
if ($('#m-del')) $('#m-del').onclick = function () { doDelete(doctype, name, done); };
if ($('#m-submit')) $('#m-submit').onclick = function () {
if (!window.confirm('Submit ' + name + '? Submitted documents can\'t be edited.')) return;
busy(true); api(CRUD + 'submit_doc', {doctype: doctype, name: name}).then(function () { after('Submitted ' + name); }).catch(function (e) { busy(false); showErr(e.message); });
};
if ($('#m-cancel-doc')) $('#m-cancel-doc').onclick = function () {
if (!window.confirm('Cancel ' + name + '?')) return;
busy(true); api(CRUD + 'cancel_doc', {doctype: doctype, name: name}).then(function () { after('Cancelled ' + name); }).catch(function (e) { busy(false); showErr(e.message); });
};
var first = form.querySelector('input:not([disabled]),select:not([disabled]),textarea:not([disabled])'); if (first) first.focus();
}
/* ---------- boot ---------- */
function start() {
api(API + 'get_portal_navigation').then(function (groups) { renderNav(groups); route(); })
.catch(function (e) {
nav.innerHTML = '<div class="state err">Navigation unavailable.<br>' + attr(e.message) + '</div>';
route();
});
}
fetch('/api/method/' + API + 'get_session_info', {credentials: 'same-origin', headers: {'Accept': 'application/json'}})
.then(function (r) {
if (r.status === 401 || r.status === 403) { location.href = '/login?redirect-to=/makap-portal'; throw new Error('login'); }
return r.json().then(function (j) { if (!r.ok || !j.message) throw new Error(parseErr(j, r.status)); return j.message; });
})
.then(function (s) { M.csrf = s.csrf_token; M.user = s.user; M.fullName = s.full_name; setIdentity(); start(); })
.catch(function (e) {
if (e.message === 'login') return;
view.innerHTML = '<div class="state err">Could not start the portal.<br>' + attr(e.message) + '</div>';
});
})();
</script>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
import frappe
from frappe.sessions import get_csrf_token
from frappe.utils import get_fullname
no_cache = 1
def get_context(context):
if frappe.session.user == "Guest":
frappe.local.flags.redirect_location = "/login?redirect-to=/makap-portal"
raise frappe.Redirect
if frappe.db.get_value("User", frappe.session.user, "user_type") != "System User":
raise frappe.PermissionError("The MAKAP portal is for internal users only.")
context.no_cache = 1
context.csrf_token = get_csrf_token()
context.user = frappe.session.user
context.full_name = get_fullname(frappe.session.user)