Portal, logo, workspace shortcut
This commit is contained in:
+20
-244
@@ -5,251 +5,8 @@ app_description = "ERP"
|
|||||||
app_email = "josephnjuki833@gmail.com"
|
app_email = "josephnjuki833@gmail.com"
|
||||||
app_license = "mit"
|
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
|
# 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
|
# MAKAP cross-module server-side controls
|
||||||
doc_events = {
|
doc_events = {
|
||||||
@@ -271,4 +28,23 @@ doc_events = {
|
|||||||
"Purchase Order": {
|
"Purchase Order": {
|
||||||
"validate": "makap_backend.makap_backend.controls.validate_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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -127,6 +127,348 @@ def validate_quality_inspection(doc, method=None):
|
|||||||
frappe.throw("Held, rejected, rework, or scrap stock cannot be marked Eligible.")
|
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):
|
def validate_purchase_order(doc, method=None):
|
||||||
for line in doc.get("items") or []:
|
for line in doc.get("items") or []:
|
||||||
if line.makap_supplier_confirmation_status == "Confirmed" and not line.makap_supplier_confirmation_reference:
|
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
|
||||||
+1
-1
@@ -52,7 +52,7 @@
|
|||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"istable": 1,
|
"istable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-09-01 21:47:35.807459",
|
"modified": "2026-09-25 11:01:38.805658",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "makap_backend",
|
"module": "makap_backend",
|
||||||
"name": "Critical Acceptance Requirement",
|
"name": "Critical Acceptance Requirement",
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"istable": 1,
|
"istable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-09-01 21:47:35.458192",
|
"modified": "2026-09-25 11:01:38.290429",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "makap_backend",
|
"module": "makap_backend",
|
||||||
"name": "Material Structure",
|
"name": "Material Structure",
|
||||||
|
|||||||
+8
@@ -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) {
|
||||||
|
|
||||||
|
// },
|
||||||
|
// });
|
||||||
+245
@@ -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
|
||||||
|
}
|
||||||
+146
@@ -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
|
||||||
+9
@@ -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)}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
|||||||
|
{"doctype":"Page","name":"makap-supplier-reconciliation","module":"makap_backend","standard":"Yes","title":"MAKAP Supplier Reconciliation & Cost Control Center"}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -97,6 +97,12 @@ def ensure_makap_functional_roles():
|
|||||||
"Materials Controller",
|
"Materials Controller",
|
||||||
"Supply Assurance Manager",
|
"Supply Assurance Manager",
|
||||||
"Concession Authority",
|
"Concession Authority",
|
||||||
|
# Module 8A
|
||||||
|
"Dispatch Preparer",
|
||||||
|
"Dispatch Authority",
|
||||||
|
"Commercial Authority",
|
||||||
|
# Module 8B
|
||||||
|
"Credit Authority",
|
||||||
]
|
]
|
||||||
created = []
|
created = []
|
||||||
for role_name in role_names:
|
for role_name in role_names:
|
||||||
@@ -125,36 +131,10 @@ def ensure_makap_object_permissions():
|
|||||||
"Production Authority": {"read": 1, "print": 1, "report": 1},
|
"Production Authority": {"read": 1, "print": 1, "report": 1},
|
||||||
"Quality Manager": {"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},
|
||||||
for doctype_name, role_map in permissions.items():
|
"Credit Authority": {"read": 1, "write": 1, "print": 1, "report": 1, "export": 1},
|
||||||
doc = frappe.get_doc("DocType", doctype_name)
|
"Dispatch Authority": {"read": 1, "print": 1, "report": 1},
|
||||||
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},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,64 @@ FIELDS = [
|
|||||||
("Purchase Order Item", "makap_supplier_confirmation_reference", "Supplier Confirmation Reference", "Data", None, 0),
|
("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 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),
|
("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,
|
"fieldtype": fieldtype,
|
||||||
"options": options,
|
"options": options,
|
||||||
"read_only": read_only,
|
"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)
|
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)]
|
||||||
@@ -1,47 +1,60 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
|
|
||||||
|
from makap_backend.makap_backend.setup_workspace_dashboard import WORKSPACE_NUMBER_CARDS
|
||||||
|
|
||||||
SHORTCUTS = [
|
WORKSPACE_NUMBER_CARD_LABELS = [row[0] for row in WORKSPACE_NUMBER_CARDS]
|
||||||
("Customer Onboarding Request", "Customer Onboarding Request"),
|
|
||||||
("Packaging Specification", "Packaging Specification"),
|
# (label, doctype, color) — color: "Green" flags the 5 MAKAP custom doctypes,
|
||||||
("Material Structure", "Material Structure"),
|
# "Gray" is stock ERPNext/Frappe. Grouped and ordered per the approved mockup.
|
||||||
("Critical Acceptance Requirement", "Critical Acceptance Requirement"),
|
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():
|
def ensure_makap_workspace():
|
||||||
content = [
|
if not frappe.db.exists("Workspace", "MAKAP Backend"):
|
||||||
{
|
frappe.get_doc(
|
||||||
"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(
|
|
||||||
{
|
{
|
||||||
"doctype": "Workspace",
|
"doctype": "Workspace",
|
||||||
"name": "MAKAP Backend",
|
"name": "MAKAP Backend",
|
||||||
@@ -49,268 +62,53 @@ def ensure_makap_workspace():
|
|||||||
"label": "MAKAP Backend",
|
"label": "MAKAP Backend",
|
||||||
"is_hidden": 0,
|
"is_hidden": 0,
|
||||||
"public": 1,
|
"public": 1,
|
||||||
"content": json.dumps(content),
|
"content": "[]",
|
||||||
"links": [],
|
|
||||||
}
|
}
|
||||||
)
|
).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 = frappe.get_doc("Workspace", "MAKAP Backend")
|
||||||
workspace.set("links", [])
|
workspace.set("links", [])
|
||||||
workspace.set("shortcuts", [])
|
workspace.set("shortcuts", [])
|
||||||
|
workspace.set("number_cards", [])
|
||||||
|
|
||||||
blocks = [
|
blocks = [
|
||||||
{"id": "makap_backend_header", "type": "header", "data": {"text": "MAKAP Backend Operations", "col": 12}}
|
|
||||||
]
|
|
||||||
|
|
||||||
for index, (label, doctype_name) in enumerate(SHORTCUTS, start=1):
|
|
||||||
workspace.append(
|
|
||||||
"shortcuts",
|
|
||||||
{
|
{
|
||||||
"label": label,
|
"id": "makap_welcome_header",
|
||||||
"type": "DocType",
|
"type": "header",
|
||||||
"link_to": doctype_name,
|
|
||||||
"doc_view": "List",
|
|
||||||
"icon": "list",
|
|
||||||
"color": "Blue",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
blocks.append(
|
|
||||||
{
|
|
||||||
"id": f"makap_shortcut_{index}",
|
|
||||||
"type": "shortcut",
|
|
||||||
"data": {
|
"data": {
|
||||||
"shortcut_name": label,
|
"text": "Welcome back — here's what's happening across MAKAP Uganda's manufacturing workflow.",
|
||||||
"col": 3,
|
"col": 12,
|
||||||
"link_to": doctype_name,
|
|
||||||
"type": "DocType",
|
|
||||||
"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_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
|
shortcut_index = 1
|
||||||
|
for group_title, items in SHORTCUT_GROUPS:
|
||||||
for section_title, section_shortcuts in [
|
|
||||||
("Customer & Commercial Operations — Modules 1–4", CUSTOMER_COMMERCIAL_SHORTCUTS),
|
|
||||||
("Internal Operations — Modules 5–7", INTERNAL_OPERATIONS_SHORTCUTS),
|
|
||||||
]:
|
|
||||||
blocks.append(
|
blocks.append(
|
||||||
{
|
{
|
||||||
"id": f"makap_section_{shortcut_index}",
|
"id": f"makap_section_{shortcut_index}",
|
||||||
"type": "header",
|
"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(
|
workspace.append(
|
||||||
"shortcuts",
|
"shortcuts",
|
||||||
{
|
{
|
||||||
@@ -319,7 +117,7 @@ def ensure_makap_workspace():
|
|||||||
"link_to": doctype_name,
|
"link_to": doctype_name,
|
||||||
"doc_view": "List",
|
"doc_view": "List",
|
||||||
"icon": "list",
|
"icon": "list",
|
||||||
"color": "Blue",
|
"color": color,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
blocks.append(
|
blocks.append(
|
||||||
@@ -333,7 +131,7 @@ def ensure_makap_workspace():
|
|||||||
"type": "DocType",
|
"type": "DocType",
|
||||||
"doc_view": "List",
|
"doc_view": "List",
|
||||||
"icon": "list",
|
"icon": "list",
|
||||||
"color": "Blue",
|
"color": color,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -342,8 +140,8 @@ def ensure_makap_workspace():
|
|||||||
workspace.content = json.dumps(blocks)
|
workspace.content = json.dumps(blocks)
|
||||||
workspace.save(ignore_permissions=True)
|
workspace.save(ignore_permissions=True)
|
||||||
frappe.db.commit()
|
frappe.db.commit()
|
||||||
return {
|
|
||||||
"customer_commercial": len(CUSTOMER_COMMERCIAL_SHORTCUTS),
|
summary = {title: len(items) for title, items in SHORTCUT_GROUPS}
|
||||||
"internal_operations": len(INTERNAL_OPERATIONS_SHORTCUTS),
|
summary["number_cards"] = len(workspace.number_cards)
|
||||||
"total": len(workspace.shortcuts),
|
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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 & 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 & 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 & 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 & 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 & 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 & margin<span class="mkp-nav-tag mkp-tag-count">0 Open</span></div>' +
|
||||||
|
|
||||||
|
'<div class="mkp-nav-group-label">Reference & 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(); });
|
||||||
|
}});
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 %}
|
||||||
@@ -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 {'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 › <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 & 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 › <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">×</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>
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user