diff --git a/makap_backend/hooks.py b/makap_backend/hooks.py index 207126e..1c3c691 100644 --- a/makap_backend/hooks.py +++ b/makap_backend/hooks.py @@ -5,251 +5,8 @@ app_description = "ERP" app_email = "josephnjuki833@gmail.com" app_license = "mit" -# Apps -# ------------------ - -# required_apps = [] - -# Each item in the list will be shown as an app in the apps page -# add_to_apps_screen = [ -# { -# "name": "makap_backend", -# "logo": "/assets/makap_backend/logo.png", -# "title": "Makap Backend", -# "route": "/makap_backend", -# "has_permission": "makap_backend.api.permission.has_app_permission" -# } -# ] - -# Includes in -# ------------------ - -# include js, css files in header of desk.html -# app_include_css = "/assets/makap_backend/css/makap_backend.css" -# app_include_js = "/assets/makap_backend/js/makap_backend.js" - -# include js, css files in header of web template -# web_include_css = "/assets/makap_backend/css/makap_backend.css" -# web_include_js = "/assets/makap_backend/js/makap_backend.js" - -# include custom scss in every website theme (without file extension ".scss") -# website_theme_scss = "makap_backend/public/scss/website" - -# include js, css files in header of web form -# webform_include_js = {"doctype": "public/js/doctype.js"} -# webform_include_css = {"doctype": "public/css/doctype.css"} - -# include js in page -# page_js = {"page" : "public/js/file.js"} - -# include js in doctype views -# doctype_js = {"doctype" : "public/js/doctype.js"} -# doctype_list_js = {"doctype" : "public/js/doctype_list.js"} -# doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"} -# doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"} - -# Svg Icons -# ------------------ -# include app icons in desk -# app_include_icons = "makap_backend/public/icons.svg" - -# Home Pages -# ---------- - -# application home page (will override Website Settings) -# home_page = "login" - -# website user home page (by Role) -# role_home_page = { -# "Role": "home_page" -# } - -# Generators -# ---------- - -# automatically create page for each record of this doctype -# website_generators = ["Web Page"] - -# Jinja -# ---------- - -# add methods and filters to jinja environment -# jinja = { -# "methods": "makap_backend.utils.jinja_methods", -# "filters": "makap_backend.utils.jinja_filters" -# } - -# Installation -# ------------ - -# before_install = "makap_backend.install.before_install" -# after_install = "makap_backend.install.after_install" - -# Uninstallation -# ------------ - -# before_uninstall = "makap_backend.uninstall.before_uninstall" -# after_uninstall = "makap_backend.uninstall.after_uninstall" - -# Integration Setup -# ------------------ -# To set up dependencies/integrations with other apps -# Name of the app being installed is passed as an argument - -# before_app_install = "makap_backend.utils.before_app_install" -# after_app_install = "makap_backend.utils.after_app_install" - -# Integration Cleanup -# ------------------- -# To clean up dependencies/integrations with other apps -# Name of the app being uninstalled is passed as an argument - -# before_app_uninstall = "makap_backend.utils.before_app_uninstall" -# after_app_uninstall = "makap_backend.utils.after_app_uninstall" - -# Desk Notifications -# ------------------ -# See frappe.core.notifications.get_notification_config - -# notification_config = "makap_backend.notifications.get_notification_config" - -# Permissions -# ----------- -# Permissions evaluated in scripted ways - -# permission_query_conditions = { -# "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions", -# } -# -# has_permission = { -# "Event": "frappe.desk.doctype.event.event.has_permission", -# } - -# DocType Class -# --------------- -# Override standard doctype classes - -# override_doctype_class = { -# "ToDo": "custom_app.overrides.CustomToDo" -# } - -# Document Events -# --------------- -# Hook on document methods and events - -# doc_events = { -# "*": { -# "on_update": "method", -# "on_cancel": "method", -# "on_trash": "method" -# } -# } - -# Scheduled Tasks -# --------------- - -# scheduler_events = { -# "all": [ -# "makap_backend.tasks.all" -# ], -# "daily": [ -# "makap_backend.tasks.daily" -# ], -# "hourly": [ -# "makap_backend.tasks.hourly" -# ], -# "weekly": [ -# "makap_backend.tasks.weekly" -# ], -# "monthly": [ -# "makap_backend.tasks.monthly" -# ], -# } - -# Testing -# ------- - -# before_tests = "makap_backend.install.before_tests" - -# Overriding Methods -# ------------------------------ -# -# override_whitelisted_methods = { -# "frappe.desk.doctype.event.event.get_events": "makap_backend.event.get_events" -# } -# -# each overriding function accepts a `data` argument; -# generated from the base implementation of the doctype dashboard, -# along with any modifications made in other Frappe apps -# override_doctype_dashboards = { -# "Task": "makap_backend.task.get_dashboard_data" -# } - -# exempt linked doctypes from being automatically cancelled -# -# auto_cancel_exempted_doctypes = ["Auto Repeat"] - -# Ignore links to specified DocTypes when deleting documents -# ----------------------------------------------------------- - -# ignore_links_on_delete = ["Communication", "ToDo"] - -# Request Events -# ---------------- -# before_request = ["makap_backend.utils.before_request"] -# after_request = ["makap_backend.utils.after_request"] - -# Job Events -# ---------- -# before_job = ["makap_backend.utils.before_job"] -# after_job = ["makap_backend.utils.after_job"] - -# User Data Protection -# -------------------- - -# user_data_fields = [ -# { -# "doctype": "{doctype_1}", -# "filter_by": "{filter_by}", -# "redact_fields": ["{field_1}", "{field_2}"], -# "partial": 1, -# }, -# { -# "doctype": "{doctype_2}", -# "filter_by": "{filter_by}", -# "partial": 1, -# }, -# { -# "doctype": "{doctype_3}", -# "strict": False, -# }, -# { -# "doctype": "{doctype_4}" -# } -# ] - -# Authentication and authorization -# -------------------------------- - -# auth_hooks = [ -# "makap_backend.auth.validate" -# ] - -# Automatically update python controller files with type annotations for this app. -# export_python_type_annotations = True - -# default_log_clearing_doctypes = { -# "Logging DocType Name": 30 # days to retain logs -# } - -# Translation -# ------------ -# List of apps whose translatable strings should be excluded from this app's translations. -# ignore_translatable_strings_from = [] - - # MAKAP Module 3 child-table registration -after_migrate = ["makap_backend.makap_backend.setup.ensure_makap_functional_roles", "makap_backend.makap_backend.setup.ensure_makap_object_permissions", "makap_backend.makap_backend.setup_module3.ensure_module3_child_tables", "makap_backend.makap_backend.setup_extensions.ensure_custom_fields"] +after_migrate = ["makap_backend.makap_backend.setup.ensure_makap_functional_roles", "makap_backend.makap_backend.setup.ensure_makap_object_permissions", "makap_backend.makap_backend.setup_module3.ensure_module3_child_tables", "makap_backend.makap_backend.setup_extensions.ensure_custom_fields", "makap_backend.makap_backend.setup_module8_dashboard.ensure_module8_number_cards", "makap_backend.makap_backend.setup_workspace_dashboard.ensure_workspace_number_cards", "makap_backend.makap_backend.workspace_setup.ensure_makap_workspace", "makap_backend.makap_backend.setup_module8_permissions.ensure_module8_role_permissions", "makap_backend.makap_backend.portal_shortcut.ensure_portal_shortcut"] # MAKAP cross-module server-side controls doc_events = { @@ -271,4 +28,23 @@ doc_events = { "Purchase Order": { "validate": "makap_backend.makap_backend.controls.validate_purchase_order", }, + "Delivery Note": { + "validate": "makap_backend.makap_backend.controls.validate_delivery_note", + "on_cancel": "makap_backend.makap_backend.controls.validate_delivery_note_on_cancel", + }, + "Sales Invoice": { + "validate": "makap_backend.makap_backend.controls.validate_sales_invoice", + "on_submit": "makap_backend.makap_backend.controls.on_submit_or_cancel_sales_invoice", + "on_cancel": "makap_backend.makap_backend.controls.on_submit_or_cancel_sales_invoice", + }, + "Payment Entry": { + "validate": "makap_backend.makap_backend.controls.validate_payment_entry", + "on_submit": "makap_backend.makap_backend.controls.on_submit_or_cancel_payment_entry", + "on_cancel": "makap_backend.makap_backend.controls.on_submit_or_cancel_payment_entry", + }, + "Purchase Invoice": { + "validate": "makap_backend.makap_backend.controls.validate_purchase_invoice", + }, } + +app_logo_url = "/assets/makap_backend/images/makap-icon.png" diff --git a/makap_backend/makap_backend/api.py b/makap_backend/makap_backend/api.py index 5fecfb2..e69de29 100644 --- a/makap_backend/makap_backend/api.py +++ b/makap_backend/makap_backend/api.py @@ -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} diff --git a/makap_backend/makap_backend/billing_api.py b/makap_backend/makap_backend/billing_api.py new file mode 100644 index 0000000..898e5b2 --- /dev/null +++ b/makap_backend/makap_backend/billing_api.py @@ -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() diff --git a/makap_backend/makap_backend/closeout_api.py b/makap_backend/makap_backend/closeout_api.py new file mode 100644 index 0000000..5a978d1 --- /dev/null +++ b/makap_backend/makap_backend/closeout_api.py @@ -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"] diff --git a/makap_backend/makap_backend/configure_google.py b/makap_backend/makap_backend/configure_google.py new file mode 100644 index 0000000..dde3743 --- /dev/null +++ b/makap_backend/makap_backend/configure_google.py @@ -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, + } diff --git a/makap_backend/makap_backend/contact_api.py b/makap_backend/makap_backend/contact_api.py new file mode 100644 index 0000000..f18dd0e --- /dev/null +++ b/makap_backend/makap_backend/contact_api.py @@ -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, + } diff --git a/makap_backend/makap_backend/controls.py b/makap_backend/makap_backend/controls.py index 87cf1d1..273959c 100644 --- a/makap_backend/makap_backend/controls.py +++ b/makap_backend/makap_backend/controls.py @@ -127,6 +127,348 @@ def validate_quality_inspection(doc, method=None): frappe.throw("Held, rejected, rework, or scrap stock cannot be marked Eligible.") +def _is_controlled_item(item_code): + if not item_code or not frappe.db.exists("Item", item_code): + return False + control_class = frappe.db.get_value("Item", item_code, "makap_material_control_class") + return control_class in {"Controlled", "High Traceability"} + + +def _latest_quality_disposition(item_code, batch_no=None): + """Best-effort read of the most recent relevant Quality Inspection for this + item/batch. Real batch-population matching (M8 section 8/9) is intentionally + left as a follow-up once Makap's actual QI-to-batch linkage in this bench is + confirmed — this covers the common case where batch_no is captured on the QI. + """ + filters = {"item_code": item_code, "docstatus": 1} + if batch_no: + filters["batch_no"] = batch_no + rows = frappe.get_all( + "Quality Inspection", + filters=filters, + fields=["name", "makap_quality_eligibility", "makap_quality_disposition"], + order_by="creation desc", + limit=1, + ) + return rows[0] if rows else None + + +def _compute_dispatch_gate(doc): + """Derived Not Assessed / Blocked / Ready condition per M8 section 7. + Runs fresh on every validate — including amendments and API submission — + so a stale queue view can never authorise dispatch (M8-BR-06 / M8-RT-04). + Returns (status, blockers list). + """ + blockers = [] + + if not doc.customer: + blockers.append("No Customer on Delivery Note.") + + for line in doc.get("items") or []: + if not _is_controlled_item(line.item_code): + continue + + prefix = f"Item {line.item_code}: " + + if not line.makap_dispatch_allocation_mode: + blockers.append(prefix + "Dispatch Allocation Mode (Reserved/Pooled) not set.") + + if line.makap_dispatch_allocation_mode == "Reserved" and not line.makap_stock_reservation_entry: + blockers.append(prefix + "Reserved mode requires a Stock Reservation Entry reference.") + + if not line.qty or line.qty <= 0: + blockers.append(prefix + "Delivery quantity must be greater than zero.") + + disposition = _latest_quality_disposition(line.item_code, getattr(line, "batch_no", None)) + if not disposition or disposition.makap_quality_eligibility != "Eligible": + blockers.append(prefix + "No current Quality Eligibility = Eligible found for this item/batch.") + elif disposition.makap_quality_disposition not in {"Release", None, ""}: + blockers.append( + prefix + f"Latest Quality Inspection disposition is '{disposition.makap_quality_disposition}', not Release." + ) + + if doc.makap_dispatch_material_accountability_status != "Cleared": + blockers.append("Dispatch Material Accountability is not Cleared.") + + if doc.makap_financial_release_status not in {"Not Required", "Cleared", "Exception Approved"}: + blockers.append(f"Financial Release Status is '{doc.makap_financial_release_status}'.") + + if doc.makap_short_close and not doc.makap_short_close_approved_by: + blockers.append("Short-Close requires Short-Close Approved By (Commercial Authority).") + + status = "Blocked" if blockers else "Ready" + return status, blockers + + +def validate_delivery_note(doc, method=None): + for line in doc.get("items") or []: + if not _is_controlled_item(line.item_code): + continue + so_item_spec = None + if line.against_sales_order and line.so_detail: + so_item_spec = frappe.db.get_value( + "Sales Order Item", line.so_detail, "makap_packaging_specification" + ) + if so_item_spec and not line.makap_packaging_specification: + line.makap_packaging_specification = so_item_spec + line.makap_specification_revision = frappe.db.get_value( + "Packaging Specification", so_item_spec, "revision" + ) + + # Module 8D: physical returns must land in a Returns Hold warehouse, never + # directly back into a dispatchable FG Released location, and require a + # fresh Quality disposition before the stock is reusable (M8-FR-072/073/074). + if doc.is_return: + if not _returns_hold_warehouse(line.warehouse) and not _returns_hold_warehouse(line.t_warehouse): + frappe.throw( + f"Return item {line.item_code} must be received into a warehouse flagged " + "as Returns Hold, not directly into a dispatchable location." + ) + if line.makap_return_quality_disposition == "Release": + frappe.throw( + f"Return item {line.item_code} cannot be marked Release directly on the return " + "Delivery Note — record a fresh Quality Inspection disposition first." + ) + if not line.makap_return_reason: + frappe.throw(f"Return item {line.item_code} requires a Return Reason.") + + if doc.is_return: + # Returns are incoming, not outgoing dispatch — the Dispatch Gate (built for + # outgoing controlled stock) does not apply; the Returns Hold checks above + # are the relevant control here instead. + doc.makap_dispatch_gate_status = "Not Assessed" + doc.makap_dispatch_gate_blockers = "Not applicable — this is a return." + doc.makap_dispatch_gate_checked_by = frappe.session.user + doc.makap_dispatch_gate_checked_on = frappe.utils.now_datetime() + return + + status, blockers = _compute_dispatch_gate(doc) + doc.makap_dispatch_gate_status = status + doc.makap_dispatch_gate_blockers = "\n".join(blockers) + doc.makap_dispatch_gate_checked_by = frappe.session.user + doc.makap_dispatch_gate_checked_on = frappe.utils.now_datetime() + + if doc.docstatus == 1 and status != "Ready": + frappe.throw( + "Dispatch Gate is not Ready — controlled stock cannot be dispatched:\n" + doc.makap_dispatch_gate_blockers + ) + + +def validate_delivery_note_on_cancel(doc, method=None): + # Section 10.2: once physically shipped, correction is a Customer Return, + # not casual cancellation of the historical DN. This is a first pass — + # it only flags submitted, non-draft DNs; a fuller impact review across + # billing/fiscal/traceability lands with the Returns phase (8D). + if doc.docstatus == 1: + frappe.msgprint( + "This Delivery Note reflects a physical dispatch. Cancelling it does not reverse " + "billing, fiscalisation or traceability records — use Customer Return for goods " + "that have actually shipped.", + indicator="orange", + alert=True, + ) + + +def _recompute_billing_and_collection(sales_order_name): + """Best-effort Billing/Collection status for a Sales Order, driven off its + submitted Sales Invoices. Run after Sales Invoice or Payment Entry submit/cancel. + Delivered-Unbilled exposure compares standard per_delivered vs per_billed — + no new ledger, per the package's explicit prohibition. + """ + if not sales_order_name or not frappe.db.exists("Sales Order", sales_order_name): + return + + so = frappe.db.get_value( + "Sales Order", sales_order_name, ["per_delivered", "per_billed"], as_dict=True + ) + + if not so.per_billed: + billing_status = "Not Billable" if not so.per_delivered else "Unbilled" + elif so.per_billed >= 100: + billing_status = "Commercially Billed" + else: + billing_status = "Partially Billed" + + delivered_unbilled = bool(so.per_delivered and so.per_delivered > 0 and (so.per_billed or 0) < so.per_delivered) + + invoices = frappe.get_all( + "Sales Invoice", + filters={"docstatus": 1}, + fields=["name", "outstanding_amount", "grand_total", "is_return"], + or_filters=None, + ) + # Sales Invoice doesn't carry sales_order directly at header level in all cases; + # use the child table linkage to scope invoices to this Sales Order. + linked_invoice_names = frappe.get_all( + "Sales Invoice Item", + filters={"sales_order": sales_order_name, "docstatus": 1}, + pluck="parent", + distinct=True, + ) + linked_invoices = [i for i in invoices if i.name in set(linked_invoice_names)] + + if not linked_invoices: + collection_status = "Not Yet Due" + else: + total_outstanding = sum(i.outstanding_amount or 0 for i in linked_invoices) + total_grand = sum(i.grand_total or 0 for i in linked_invoices) + if total_outstanding <= 0: + collection_status = "Paid" + elif total_outstanding < total_grand: + collection_status = "Partially Paid" + else: + collection_status = "Due" + + frappe.db.set_value( + "Sales Order", + sales_order_name, + { + "makap_billing_status": billing_status, + "makap_delivered_unbilled": 1 if delivered_unbilled else 0, + "makap_collection_status": collection_status, + }, + ) + + +def _sales_orders_on_invoice(doc): + return {row.sales_order for row in (doc.get("items") or []) if row.sales_order} + + +def validate_sales_invoice(doc, method=None): + if doc.is_return and doc.return_against: + # M8-BR-14: one physical return event = one stock consequence. Block a second + # Credit Note crediting the same original invoice for a controlled item. + for line in doc.get("items") or []: + if not _is_controlled_item(line.item_code): + continue + existing = frappe.get_all( + "Sales Invoice", + filters={ + "is_return": 1, + "return_against": doc.return_against, + "docstatus": 1, + "name": ["!=", doc.name], + }, + pluck="name", + ) + if existing: + credited_items = frappe.get_all( + "Sales Invoice Item", + filters={"parent": ["in", existing], "item_code": line.item_code}, + pluck="name", + ) + if credited_items: + frappe.throw( + f"Item {line.item_code} against {doc.return_against} already has a Credit " + f"Note ({existing[0]}) — this looks like a duplicate return credit." + ) + + for line in doc.get("items") or []: + if not _is_controlled_item(line.item_code): + continue + # Section 11: Direct Sales Invoice Stock Bypass — controlled manufactured + # stock must not invoice-with-update-stock as a shortcut around Delivery Note. + if doc.update_stock: + frappe.throw( + f"Item {line.item_code} is Controlled Manufactured Stock — Sales Invoice with " + "Update Stock is not permitted; invoice from the Delivery Note instead." + ) + if line.delivery_note: + dn_item_spec = frappe.db.get_value( + "Delivery Note Item", + {"parent": line.delivery_note, "item_code": line.item_code}, + "makap_packaging_specification", + ) + if dn_item_spec: + line.makap_delivery_note_item_reference = f"{line.delivery_note}::{line.item_code}" + + if doc.docstatus == 1 and doc.makap_fiscalisation_status == "Failed": + frappe.throw("Sales Invoice cannot remain submitted with a Failed Fiscalisation Status — resolve or reset it first.") + + +def on_submit_or_cancel_sales_invoice(doc, method=None): + for so in _sales_orders_on_invoice(doc): + _recompute_billing_and_collection(so) + + +def validate_payment_entry(doc, method=None): + if doc.payment_type != "Receive": + return + if doc.makap_payment_purpose == "Deposit" and not doc.get("references"): + frappe.throw("A Deposit payment purpose requires at least one reference allocation (Sales Order or Sales Invoice).") + + +def on_submit_or_cancel_payment_entry(doc, method=None): + for ref in doc.get("references") or []: + if ref.reference_doctype == "Sales Order": + _recompute_billing_and_collection(ref.reference_name) + elif ref.reference_doctype == "Sales Invoice": + so_names = frappe.get_all( + "Sales Invoice Item", filters={"parent": ref.reference_name}, pluck="sales_order", distinct=True + ) + for so in so_names: + if so: + _recompute_billing_and_collection(so) + + +def validate_purchase_invoice(doc, method=None): + for line in doc.get("items") or []: + if not getattr(line, "purchase_order", None): + continue + po_rate = frappe.db.get_value( + "Purchase Order Item", {"parent": line.purchase_order, "item_code": line.item_code}, "rate" + ) + if po_rate: + line.makap_rate_variance = (line.rate or 0) - po_rate + + if getattr(line, "purchase_receipt", None): + pr_qty = frappe.db.get_value( + "Purchase Receipt Item", {"parent": line.purchase_receipt, "item_code": line.item_code}, "qty" + ) + if pr_qty is not None: + line.makap_quantity_variance = (line.qty or 0) - pr_qty + + # M8-BR-15 / rejected-quantity guard: a rejected QI disposition on the + # matching Purchase Receipt item must never become billable through Finance. + rejected_qty = frappe.db.get_value( + "Quality Inspection", + { + "reference_type": "Purchase Receipt", + "reference_name": line.purchase_receipt, + "item_code": line.item_code, + "makap_quality_disposition": "Reject", + "docstatus": 1, + }, + "makap_disposition_quantity", + ) + if rejected_qty and line.qty and line.qty > (pr_qty or line.qty) - rejected_qty: + frappe.throw( + f"Purchase Invoice item {line.item_code} appears to include rejected quantity " + f"from {line.purchase_receipt} ({rejected_qty} rejected) — reduce the billed quantity." + ) + + has_variance = any( + (getattr(l, "makap_quantity_variance", 0) or 0) != 0 or (getattr(l, "makap_rate_variance", 0) or 0) != 0 + for l in doc.get("items") or [] + ) + if doc.makap_supplier_invoice_match_status == "Not Assessed" and has_variance: + both = any((l.makap_quantity_variance or 0) != 0 for l in doc.items) and any( + (l.makap_rate_variance or 0) != 0 for l in doc.items + ) + if both: + doc.makap_supplier_invoice_match_status = "Both Variance" + elif any((l.makap_quantity_variance or 0) != 0 for l in doc.items): + doc.makap_supplier_invoice_match_status = "Quantity Variance" + else: + doc.makap_supplier_invoice_match_status = "Rate Variance" + + +def _returns_hold_warehouse(warehouse): + if not warehouse: + return False + return bool(frappe.db.get_value("Warehouse", warehouse, "makap_is_returns_hold_warehouse")) + + def validate_purchase_order(doc, method=None): for line in doc.get("items") or []: if line.makap_supplier_confirmation_status == "Confirmed" and not line.makap_supplier_confirmation_reference: diff --git a/makap_backend/makap_backend/dashboard1_setup.py b/makap_backend/makap_backend/dashboard1_setup.py new file mode 100644 index 0000000..11ea6e9 --- /dev/null +++ b/makap_backend/makap_backend/dashboard1_setup.py @@ -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": "MAKAP Backend — Dashboard1", "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"{group_label}", "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 diff --git a/makap_backend/makap_backend/dashboard_api.py b/makap_backend/makap_backend/dashboard_api.py new file mode 100644 index 0000000..e4e30b0 --- /dev/null +++ b/makap_backend/makap_backend/dashboard_api.py @@ -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 diff --git a/makap_backend/makap_backend/doctype/critical_acceptance_requirement/critical_acceptance_requirement.json b/makap_backend/makap_backend/doctype/critical_acceptance_requirement/critical_acceptance_requirement.json index 7bc257f..963d26d 100644 --- a/makap_backend/makap_backend/doctype/critical_acceptance_requirement/critical_acceptance_requirement.json +++ b/makap_backend/makap_backend/doctype/critical_acceptance_requirement/critical_acceptance_requirement.json @@ -52,7 +52,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-09-01 21:47:35.807459", + "modified": "2026-09-25 11:01:38.805658", "modified_by": "Administrator", "module": "makap_backend", "name": "Critical Acceptance Requirement", diff --git a/makap_backend/makap_backend/doctype/material_structure/material_structure.json b/makap_backend/makap_backend/doctype/material_structure/material_structure.json index c153570..a998b3b 100644 --- a/makap_backend/makap_backend/doctype/material_structure/material_structure.json +++ b/makap_backend/makap_backend/doctype/material_structure/material_structure.json @@ -54,7 +54,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-09-01 21:47:35.458192", + "modified": "2026-09-25 11:01:38.290429", "modified_by": "Administrator", "module": "makap_backend", "name": "Material Structure", diff --git a/makap_backend/makap_backend/doctype/order_closure_and_margin_view/__init__.py b/makap_backend/makap_backend/doctype/order_closure_and_margin_view/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/makap_backend/makap_backend/doctype/order_closure_and_margin_view/order_closure_and_margin_view.js b/makap_backend/makap_backend/doctype/order_closure_and_margin_view/order_closure_and_margin_view.js new file mode 100644 index 0000000..2142a92 --- /dev/null +++ b/makap_backend/makap_backend/doctype/order_closure_and_margin_view/order_closure_and_margin_view.js @@ -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) { + +// }, +// }); diff --git a/makap_backend/makap_backend/doctype/order_closure_and_margin_view/order_closure_and_margin_view.json b/makap_backend/makap_backend/doctype/order_closure_and_margin_view/order_closure_and_margin_view.json new file mode 100644 index 0000000..c9e464f --- /dev/null +++ b/makap_backend/makap_backend/doctype/order_closure_and_margin_view/order_closure_and_margin_view.json @@ -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 +} \ No newline at end of file diff --git a/makap_backend/makap_backend/doctype/order_closure_and_margin_view/order_closure_and_margin_view.py b/makap_backend/makap_backend/doctype/order_closure_and_margin_view/order_closure_and_margin_view.py new file mode 100644 index 0000000..563e079 --- /dev/null +++ b/makap_backend/makap_backend/doctype/order_closure_and_margin_view/order_closure_and_margin_view.py @@ -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 diff --git a/makap_backend/makap_backend/doctype/order_closure_and_margin_view/test_order_closure_and_margin_view.py b/makap_backend/makap_backend/doctype/order_closure_and_margin_view/test_order_closure_and_margin_view.py new file mode 100644 index 0000000..751ff98 --- /dev/null +++ b/makap_backend/makap_backend/doctype/order_closure_and_margin_view/test_order_closure_and_margin_view.py @@ -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 diff --git a/makap_backend/makap_backend/fulfilment_api.py b/makap_backend/makap_backend/fulfilment_api.py new file mode 100644 index 0000000..fd0b586 --- /dev/null +++ b/makap_backend/makap_backend/fulfilment_api.py @@ -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")] diff --git a/makap_backend/makap_backend/governance_api.py b/makap_backend/makap_backend/governance_api.py new file mode 100644 index 0000000..966594d --- /dev/null +++ b/makap_backend/makap_backend/governance_api.py @@ -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)} diff --git a/makap_backend/makap_backend/logo_setup.py b/makap_backend/makap_backend/logo_setup.py new file mode 100644 index 0000000..a570908 --- /dev/null +++ b/makap_backend/makap_backend/logo_setup.py @@ -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 diff --git a/makap_backend/makap_backend/materials_api.py b/makap_backend/makap_backend/materials_api.py new file mode 100644 index 0000000..0dc60ac --- /dev/null +++ b/makap_backend/makap_backend/materials_api.py @@ -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 []]} diff --git a/makap_backend/makap_backend/onboarding_api.py b/makap_backend/makap_backend/onboarding_api.py new file mode 100644 index 0000000..7d5c9f0 --- /dev/null +++ b/makap_backend/makap_backend/onboarding_api.py @@ -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() diff --git a/makap_backend/makap_backend/opportunity_api.py b/makap_backend/makap_backend/opportunity_api.py new file mode 100644 index 0000000..c5c96ee --- /dev/null +++ b/makap_backend/makap_backend/opportunity_api.py @@ -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, + } diff --git a/makap_backend/makap_backend/page/makap_billing/makap_billing.css b/makap_backend/makap_backend/page/makap_billing/makap_billing.css new file mode 100644 index 0000000..c5c0cd9 --- /dev/null +++ b/makap_backend/makap_backend/page/makap_billing/makap_billing.css @@ -0,0 +1,2 @@ +.makap-flow-shell{--f-ink:#19343b;--f-muted:#71868b;--f-line:#e1ecea;--f-teal:#0c766c;--f-mint:#e3f5ef;--f-shadow:0 18px 50px rgba(20,68,70,.07);max-width:1500px;margin:0 auto;padding:34px 42px 48px;color:var(--f-ink);font-family:"DM Sans",sans-serif}.makap-flow-head{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:25px}.makap-flow-eyebrow{text-transform:uppercase;letter-spacing:.14em;font-size:10px;font-weight:700;color:#87a19e;margin-bottom:8px}.makap-flow-head h1{font:600 31px "Space Grotesk",sans-serif;letter-spacing:-.045em;margin:0 0 8px}.makap-flow-head p{font-size:13px;color:var(--f-muted);margin:0}.makap-flow-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:13px;margin-bottom:17px}.makap-flow-metrics>div{background:#fff;border:1px solid var(--f-line);border-radius:10px;padding:17px 19px;box-shadow:var(--f-shadow)}.makap-flow-metrics span,.makap-flow-metrics small{display:block}.makap-flow-metrics span{font-size:10px;color:var(--f-muted);font-weight:700;text-transform:uppercase;letter-spacing:.07em}.makap-flow-metrics strong{font:600 27px "Space Grotesk",sans-serif;display:block;margin:8px 0 3px}.makap-flow-metrics small{font-size:10px;color:#91a09f}.makap-flow-metrics .warn{background:#fffaf0;border-color:#f1dfbd}.makap-flow-metrics .warn strong{color:#bd7d28}.makap-flow-metrics .bad{background:#fff8f6;border-color:#f1d6d1}.makap-flow-metrics .bad strong{color:#b96868}.makap-flow-tabs{display:flex;gap:5px;background:#edf5f2;border:1px solid #dfece8;padding:5px;border-radius:10px 10px 0 0}.makap-flow-tabs button{border:0;background:transparent;color:#66817d;border-radius:7px;padding:10px 16px;font:600 11px "DM Sans";cursor:pointer}.makap-flow-tabs button.active{background:#fff;color:var(--f-teal);box-shadow:0 2px 8px #1c645d12}.makap-flow-work{display:grid;grid-template-columns:minmax(370px,.9fr) minmax(520px,1.55fr);min-height:690px;background:#fff;border:1px solid var(--f-line);border-top:0;border-radius:0 0 14px 14px;overflow:hidden;box-shadow:var(--f-shadow)}.makap-flow-listpanel{border-right:1px solid var(--f-line);background:#fbfdfd}.makap-flow-listhead{padding:21px 22px 16px;border-bottom:1px solid var(--f-line)}.makap-flow-listhead strong{font:600 17px "Space Grotesk",sans-serif;display:block}.makap-flow-listhead span{font-size:10px;color:#91a09f;display:block;margin-top:3px}.makap-flow-search{display:flex;gap:8px;align-items:center;background:#f2f7f5;border:1px solid #e3eeea;border-radius:7px;padding:9px 11px;color:#8fa09c;margin-top:16px}.makap-flow-search input{border:0;background:transparent;outline:0;width:100%;font:11px "DM Sans"}.makap-flow-listhead select{border:1px solid #e3eeea;background:#f8fbfa;color:#66807c;border-radius:7px;padding:7px;width:100%;font-size:10px;margin-top:8px}.makap-flow-list{padding:8px}.makap-flow-row{width:100%;display:flex;align-items:center;gap:11px;border:0;border-radius:9px;background:transparent;padding:12px 10px;text-align:left;cursor:pointer;color:var(--f-ink)}.makap-flow-row:hover,.makap-flow-row.active{background:var(--f-mint);box-shadow:inset 3px 0 var(--f-teal)}.flow-avatar,.flow-detail-avatar{display:grid;place-items:center;border-radius:50%;background:#d5eee6;color:var(--f-teal);font-family:"Space Grotesk",sans-serif;font-weight:700}.flow-avatar{width:35px;height:35px;font-size:10px;flex:none}.flow-avatar.blocked,.flow-detail-avatar.blocked{background:#fff0f0;color:#b96868}.flow-avatar.pending,.flow-detail-avatar.pending{background:#fff5e4;color:#bd7d28}.flow-avatar.ready,.flow-detail-avatar.ready{background:#e8f7ed;color:#4c9b73}.flow-copy{flex:1;min-width:0}.flow-copy strong,.flow-copy span,.flow-copy small{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.flow-copy strong{font-size:12px}.flow-copy span{font-size:10px;color:var(--f-muted);margin-top:3px}.flow-copy small{font-size:9px;color:#9aa9a7;margin-top:4px}.flow-badge{border-radius:20px;padding:4px 7px;font-size:9px;white-space:nowrap}.flow-badge.blocked{background:#fff0f0;color:#b96868}.flow-badge.pending{background:#fff5e4;color:#bd7d28}.flow-badge.ready{background:#e8f7ed;color:#4c9b73}.flow-badge.draft{background:#eef4f3;color:#66817d}.makap-flow-detail{background:#fff}.makap-flow-hero{padding:30px 33px 27px;background:linear-gradient(115deg,#f0faf6,#f4f9ff);border-bottom:1px solid var(--f-line);display:flex;align-items:center;gap:16px}.flow-detail-avatar{width:62px;height:62px;font-size:18px;flex:none}.flow-ident{flex:1;min-width:0}.flow-ident>span{font-size:9px;text-transform:uppercase;letter-spacing:.12em;color:#8ca09d;font-weight:700}.flow-ident h2{font:600 24px "Space Grotesk",sans-serif;letter-spacing:-.04em;margin:7px 0 4px}.flow-ident p{font-size:11px;color:var(--f-muted);margin:0}.makap-flow-body{padding:4px 33px 30px}.flow-state{margin-top:21px;border:1px solid #dcebe7;background:#f5fcf9;border-radius:10px;padding:18px 20px;display:flex;justify-content:space-between;align-items:center}.flow-state.blocked{background:#fff8f6;border-color:#f1d6d1}.flow-state.pending{background:#fffaf0;border-color:#f1dfbd}.flow-state span,.flow-state small{display:block}.flow-state span{font-size:10px;text-transform:uppercase;letter-spacing:.09em;color:#819691;font-weight:700}.flow-state strong{font:600 22px "Space Grotesk",sans-serif;display:block;margin:6px 0 2px}.flow-state small{font-size:10px;color:#8fa19e}.flow-state i{font-size:22px;color:var(--f-teal)}.flow-state.blocked i{color:#b96868}.flow-section{padding:23px 0;border-bottom:1px solid #edf3f1}.flow-section-title{text-transform:uppercase;letter-spacing:.12em;color:#8ca09d;font-weight:700;font-size:9px;margin-bottom:15px}.flow-fields{display:grid;grid-template-columns:1fr 1fr;gap:17px 30px}.flow-fields span{display:block;color:#93a29f;font-size:10px;margin-bottom:5px}.flow-fields strong{display:block;font-size:12px}.flow-fields a{color:var(--f-teal);text-decoration:none}.flow-mini{display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid #f0f4f2;font-size:11px}.flow-mini:last-child{border:0}.flow-mini strong,.flow-mini small{display:block}.flow-mini small{font-size:9px;color:var(--f-muted);margin-top:3px}.flow-mini b{font-size:10px;color:#536f6b}.flow-note{font-size:11px;color:#92a09e}.makap-flow-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:#95a3a1;padding:50px 25px;font-size:11px;min-height:180px}.makap-flow-empty strong{color:#6f8280;font-size:12px;margin:10px 0 4px}.makap-flow-empty span{max-width:250px;line-height:1.5}.detail-empty{min-height:690px}.load-ring{width:22px;height:22px;border:2px solid #d9eae5;border-top-color:var(--f-teal);border-radius:50%;animation:flowspin .8s linear infinite}@keyframes flowspin{to{transform:rotate(360deg)}} +@media(max-width:1100px){.makap-flow-shell{padding:26px 20px 40px}.makap-flow-metrics{grid-template-columns:repeat(2,1fr)}.makap-flow-work{grid-template-columns:minmax(300px,.85fr) minmax(430px,1.3fr)}}@media(max-width:820px){.makap-flow-head{display:block}.makap-flow-head button{margin-top:18px}.makap-flow-tabs{overflow:auto}.makap-flow-tabs button{white-space:nowrap}.makap-flow-work{grid-template-columns:1fr}.makap-flow-listpanel{border-right:0;border-bottom:1px solid var(--f-line);max-height:390px}.detail-empty{min-height:320px}.makap-flow-hero>a{display:none}}@media(max-width:560px){.makap-flow-metrics{grid-template-columns:1fr 1fr;gap:8px}.makap-flow-metrics>div{padding:13px}.makap-flow-metrics strong{font-size:22px}.makap-flow-hero{padding:23px 20px}.makap-flow-body{padding-left:20px;padding-right:20px}.flow-fields{grid-template-columns:1fr}.flow-ident h2{font-size:20px}} diff --git a/makap_backend/makap_backend/page/makap_billing/makap_billing.js b/makap_backend/makap_backend/page/makap_billing/makap_billing.js new file mode 100644 index 0000000..9245ada --- /dev/null +++ b/makap_backend/makap_backend/page/makap_billing/makap_billing.js @@ -0,0 +1 @@ +frappe.pages["makap-billing"].on_page_load=function(w){const p=frappe.ui.make_app_page({parent:w,title:"Billing & Collections Center",single_column:true});p.main.html(`
Billing & collections

Billing & Collections Center

Connect invoices, fiscalisation, payments, and order exposure in one commercial control view.

Unbilled orders—Unbilled or partially billed
Delivered-unbilled—Exposure needing attention
Fiscalisation issues—Pending or failed
Collections due—Due or partially paid
Select a billing recordReview invoice, payment, or order exposure details here.
`);const r=p.main[0],list=r.querySelector("[data-list]"),detail=r.querySelector("[data-detail]"),search=r.querySelector("[data-search]"),filter=r.querySelector("[data-filter]"),count=r.querySelector("[data-count]"),title=r.querySelector("[data-title]");let tab=new URLSearchParams(location.search).get("tab")||"invoices",active=null,timer;const esc=v=>frappe.utils.escape_html(String(v==null?"":v)),num=v=>new Intl.NumberFormat().format(Number(v||0)),money=v=>Number(v||0).toLocaleString(undefined,{maximumFractionDigits:0}),date=v=>v?frappe.datetime.str_to_user(v):"Not set",ini=v=>(String(v||"?").split(/\s+/).slice(0,2).map(x=>x[0]).join("")||"?").toUpperCase(),call=(m,a={})=>new Promise((ok,no)=>frappe.call({method:m,args:a,callback:x=>x.exc?no(x.exc):ok(x.message)})),tone=s=>({Failed:"blocked",Pending:"pending",Due:"pending","Partially Paid":"pending",Paid:"ready",Fiscalised:"ready",Unbilled:"pending","Partially Billed":"pending"}[s]||"draft");function metrics(x){["unbilled","exposure","fiscal_issues","collections_due"].forEach(k=>r.querySelector(`[data-m="${k}"]`).textContent=num(x[k]))}function set(t){tab=t;active=null;r.querySelectorAll("[data-t]").forEach(e=>e.classList.toggle("active",e.dataset.t===t));title.textContent=t==="invoices"?"Invoice queue":t==="payments"?"Collections register":"Order exposure";load()}function row(x){let stat=tab==="invoices"?(x.makap_fiscalisation_status||x.status||"Draft"):tab==="payments"?(x.makap_payment_purpose||x.payment_type||"Payment"): (x.makap_collection_status||x.makap_billing_status||"Open");return ``}function render(rows){count.textContent=`${rows.length} record${rows.length===1?"":"s"}`;list.innerHTML=rows.length?rows.map(row).join(""):`
No billing records foundTry another search or filter.
`;list.querySelectorAll("[data-n]").forEach(e=>e.onclick=()=>select(e.dataset.n))}function head(kind,x,party,href,stat){return `
${esc(ini(party||x.name))}
${esc(kind)}

${esc(x.name)}

${esc(party||"No party")} · ${esc(stat||x.status||"Draft")}

Open ERPNext form
`}function renderInvoice(x){active=x.name;let lines=(x.items||[]).map(i=>`
${esc(i.item_name||i.item_code||"Item")}${esc(i.item_code||"")} · ${esc(i.sales_order||i.delivery_note||"No source")}${money(i.amount)}
`).join("")||`
No invoice lines available.
`;detail.innerHTML=head("Sales Invoice",x,x.customer,`/app/sales-invoice/${encodeURIComponent(x.name)}`,x.makap_fiscalisation_status||x.status)+`
Fiscalisation status${esc(x.makap_fiscalisation_status||"Not Required")}${esc(x.makap_fiscal_document_number||x.makap_fiscal_verification_code||"No fiscal reference recorded")}
Invoice position
Customer${esc(x.customer||"Not set")}
Status${esc(x.status||"Draft")}
Grand total${esc(x.currency||"")} ${money(x.grand_total)}
Outstanding${esc(x.currency||"")} ${money(x.outstanding_amount)}
Posting date${date(x.posting_date)}
Due date${date(x.due_date)}
Commercial traceability
Delivery Note reference${esc(x.makap_delivery_note_reference||"Not set")}
Fiscal timestamp${date(x.makap_fiscalisation_timestamp)}
Verification code${esc(x.makap_fiscal_verification_code||"Not set")}
Fiscal document${esc(x.makap_fiscal_document_number||"Not set")}
Invoice lines
${lines}
`;paint()}function renderPayment(x){active=x.name;let refs=(x.references||[]).map(a=>`
${esc(a.reference_doctype)}${esc(a.reference_name||"No reference")}${money(a.allocated_amount)}
`).join("")||`
No reference allocations recorded.
`;detail.innerHTML=head("Payment Entry",x,x.party,`/app/payment-entry/${encodeURIComponent(x.name)}`,x.makap_payment_purpose||x.payment_type)+`
Payment purpose${esc(x.makap_payment_purpose||"Not set")}${esc(x.payment_type||"Payment")} · ${esc(x.party_type||"Party")}
Payment position
Party${esc(x.party||"Not set")}
Posting date${date(x.posting_date)}
Paid amount${esc(x.paid_amount||0)}
Received amount${esc(x.received_amount||0)}
Unallocated${esc(x.unallocated_amount||0)}
Company${esc(x.company||"Not set")}
Reference allocations
${refs}
`;paint()}function renderOrder(x){active=x.name;detail.innerHTML=head("Sales Order exposure",x,x.customer,`/app/sales-order/${encodeURIComponent(x.name)}`,x.makap_collection_status||x.makap_billing_status)+`
Collection status${esc(x.makap_collection_status||"Not set")}${esc(x.makap_billing_status||"Billing status not set")}${x.makap_delivered_unbilled?" · Delivered-unbilled exposure":""}
Order exposure
Customer${esc(x.customer||"Not set")}
Order status${esc(x.status||"Draft")}
Grand total${money(x.grand_total)}
Delivered${esc(x.per_delivered||0)}%
Billed${esc(x.per_billed||0)}%
Deposit${x.makap_deposit_required?`Required · ${money(x.makap_deposit_required_amount)}`:"Not required"}
Billing and collection controls
Billing status${esc(x.makap_billing_status||"Not set")}
Collection status${esc(x.makap_collection_status||"Not set")}
Delivered-unbilled${x.makap_delivered_unbilled?"Yes":"No"}
Transaction date${date(x.transaction_date)}
`;paint()}function paint(){r.querySelectorAll(".makap-flow-row").forEach(e=>e.classList.toggle("active",e.dataset.n===active))}async function select(n){active=n;detail.innerHTML=`
Loading record
`;try{if(tab==="invoices")renderInvoice(await call("makap_backend.billing_api.get_sales_invoice",{name:n}));if(tab==="payments")renderPayment(await call("makap_backend.billing_api.get_payment_entry",{name:n}));if(tab==="orders")renderOrder(await call("makap_backend.billing_api.get_order_exposure",{name:n}))}catch(e){detail.innerHTML=`
Could not load record${esc(e.message||"Please try again.")}
`}}async function load(){list.innerHTML=`
Loading records...
`;try{let rows;if(tab==="invoices")rows=await call("makap_backend.billing_api.list_sales_invoices",{search:search.value,fiscalisation_status:["Not Required","Pending","Fiscalised","Failed"].includes(filter.value)?filter.value:""});if(tab==="payments")rows=await call("makap_backend.billing_api.list_payment_entries",{search:search.value,payment_purpose:["Deposit","Progress Payment","Final Settlement"].includes(filter.value)?filter.value:""});if(tab==="orders")rows=await call("makap_backend.billing_api.list_order_exposure",{search:search.value,billing_status:["Unbilled","Partially Billed"].includes(filter.value)?filter.value:"",collection_status:["Due","Partially Paid","Paid"].includes(filter.value)?filter.value:""});render(rows||[]);if(!active&&rows?.[0])select(rows[0].name)}catch(e){list.innerHTML=`
Billing records unavailable${esc(e.message||"Check permissions.")}
`}}r.querySelectorAll("[data-t]").forEach(e=>e.onclick=()=>set(e.dataset.t));search.oninput=()=>{clearTimeout(timer);timer=setTimeout(load,250)};filter.onchange=load;r.querySelector("[data-r]").onclick=async()=>{metrics(await call("makap_backend.billing_api.get_billing_summary"));load()};(async()=>{try{metrics(await call("makap_backend.billing_api.get_billing_summary"))}catch(e){}set(tab)})()}; diff --git a/makap_backend/makap_backend/page/makap_billing/makap_billing.json b/makap_backend/makap_backend/page/makap_billing/makap_billing.json new file mode 100644 index 0000000..7e0bde3 --- /dev/null +++ b/makap_backend/makap_backend/page/makap_billing/makap_billing.json @@ -0,0 +1 @@ +{"doctype":"Page","name":"makap-billing","module":"makap_backend","standard":"Yes","title":"MAKAP Billing & Collections Center"} diff --git a/makap_backend/makap_backend/page/makap_closeout/makap_closeout.css b/makap_backend/makap_backend/page/makap_closeout/makap_closeout.css new file mode 100644 index 0000000..4b6361c --- /dev/null +++ b/makap_backend/makap_backend/page/makap_closeout/makap_closeout.css @@ -0,0 +1 @@ +.makap-end-shell{--e-ink:#19343b;--e-muted:#71868b;--e-line:#e1ecea;--e-teal:#0c766c;--e-mint:#e3f5ef;--e-shadow:0 18px 50px rgba(20,68,70,.07);max-width:1500px;margin:0 auto;padding:34px 42px 48px;color:var(--e-ink);font-family:"DM Sans",sans-serif}.makap-end-head{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:25px}.makap-end-eyebrow{text-transform:uppercase;letter-spacing:.14em;font-size:10px;font-weight:700;color:#87a19e;margin-bottom:8px}.makap-end-head h1{font:600 31px "Space Grotesk",sans-serif;letter-spacing:-.045em;margin:0 0 8px}.makap-end-head p{font-size:13px;color:var(--e-muted);margin:0}.makap-end-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:13px;margin-bottom:17px}.makap-end-metrics>div{background:#fff;border:1px solid var(--e-line);border-radius:10px;padding:17px 19px;box-shadow:var(--e-shadow)}.makap-end-metrics span,.makap-end-metrics small{display:block}.makap-end-metrics span{font-size:10px;color:var(--e-muted);font-weight:700;text-transform:uppercase;letter-spacing:.07em}.makap-end-metrics strong{font:600 27px "Space Grotesk",sans-serif;display:block;margin:8px 0 3px}.makap-end-metrics small{font-size:10px;color:#91a09f}.makap-end-metrics .warn{background:#fffaf0;border-color:#f1dfbd}.makap-end-metrics .warn strong{color:#bd7d28}.makap-end-metrics .bad{background:#fff8f6;border-color:#f1d6d1}.makap-end-metrics .bad strong{color:#b96868}.makap-end-metrics .good{background:#f5fcf9;border-color:#dcebe7}.makap-end-metrics .good strong{color:#4c9b73}.makap-end-tabs{display:flex;gap:5px;background:#edf5f2;border:1px solid #dfece8;padding:5px;border-radius:10px 10px 0 0}.makap-end-tabs button{border:0;background:transparent;color:#66817d;border-radius:7px;padding:10px 15px;font:600 11px "DM Sans";cursor:pointer}.makap-end-tabs button.active{background:#fff;color:var(--e-teal);box-shadow:0 2px 8px #1c645d12}.makap-end-work{display:grid;grid-template-columns:minmax(370px,.9fr) minmax(520px,1.55fr);min-height:690px;background:#fff;border:1px solid var(--e-line);border-top:0;border-radius:0 0 14px 14px;overflow:hidden;box-shadow:var(--e-shadow)}.makap-end-listpanel{border-right:1px solid var(--e-line);background:#fbfdfd}.makap-end-listhead{padding:21px 22px 16px;border-bottom:1px solid var(--e-line)}.makap-end-listhead strong{font:600 17px "Space Grotesk",sans-serif;display:block}.makap-end-listhead span{font-size:10px;color:#91a09f;display:block;margin-top:3px}.makap-end-search{display:flex;gap:8px;align-items:center;background:#f2f7f5;border:1px solid #e3eeea;border-radius:7px;padding:9px 11px;color:#8fa09c;margin-top:16px}.makap-end-search input{border:0;background:transparent;outline:0;width:100%;font:11px "DM Sans"}.makap-end-listhead select{border:1px solid #e3eeea;background:#f8fbfa;color:#66807c;border-radius:7px;padding:7px;width:100%;font-size:10px;margin-top:8px}.makap-end-list{padding:8px}.makap-end-row{width:100%;display:flex;align-items:center;gap:11px;border:0;border-radius:9px;background:transparent;padding:12px 10px;text-align:left;cursor:pointer;color:var(--e-ink)}.makap-end-row:hover,.makap-end-row.active{background:var(--e-mint);box-shadow:inset 3px 0 var(--e-teal)}.end-avatar,.end-detail-avatar{display:grid;place-items:center;border-radius:50%;background:#d5eee6;color:var(--e-teal);font-family:"Space Grotesk",sans-serif;font-weight:700}.end-avatar{width:35px;height:35px;font-size:10px;flex:none}.end-avatar.warn,.end-detail-avatar.warn{background:#fff5e4;color:#bd7d28}.end-avatar.bad,.end-detail-avatar.bad{background:#fff0f0;color:#b96868}.end-avatar.good,.end-detail-avatar.good{background:#e8f7ed;color:#4c9b73}.end-copy{flex:1;min-width:0}.end-copy strong,.end-copy span,.end-copy small{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.end-copy strong{font-size:12px}.end-copy span{font-size:10px;color:var(--e-muted);margin-top:3px}.end-copy small{font-size:9px;color:#9aa9a7;margin-top:4px}.end-badge{border-radius:20px;padding:4px 7px;font-size:9px;white-space:nowrap}.end-badge.warn{background:#fff5e4;color:#bd7d28}.end-badge.bad{background:#fff0f0;color:#b96868}.end-badge.good{background:#e8f7ed;color:#4c9b73}.makap-end-detail{background:#fff}.makap-end-hero{padding:30px 33px 27px;background:linear-gradient(115deg,#f0faf6,#f4f9ff);border-bottom:1px solid var(--e-line);display:flex;align-items:center;gap:16px}.end-detail-avatar{width:62px;height:62px;font-size:18px;flex:none}.end-ident{flex:1;min-width:0}.end-ident>span{font-size:9px;text-transform:uppercase;letter-spacing:.12em;color:#8ca09d;font-weight:700}.end-ident h2{font:600 24px "Space Grotesk",sans-serif;letter-spacing:-.04em;margin:7px 0 4px}.end-ident p{font-size:11px;color:var(--e-muted);margin:0}.makap-end-body{padding:4px 33px 30px}.end-state{margin-top:21px;border:1px solid #dcebe7;background:#f5fcf9;border-radius:10px;padding:18px 20px;display:flex;justify-content:space-between;align-items:center}.end-state.warn{background:#fffaf0;border-color:#f1dfbd}.end-state.bad{background:#fff8f6;border-color:#f1d6d1}.end-state span,.end-state small{display:block}.end-state span{font-size:10px;text-transform:uppercase;letter-spacing:.09em;color:#819691;font-weight:700}.end-state strong{font:600 22px "Space Grotesk",sans-serif;display:block;margin:6px 0 2px}.end-state small{font-size:10px;color:#8fa19e}.end-state i{font-size:22px;color:var(--e-teal)}.end-state.warn i{color:#bd7d28}.end-state.bad i{color:#b96868}.end-section{padding:23px 0;border-bottom:1px solid #edf3f1}.end-title{text-transform:uppercase;letter-spacing:.12em;color:#8ca09d;font-weight:700;font-size:9px;margin-bottom:15px}.end-fields{display:grid;grid-template-columns:1fr 1fr;gap:17px 30px}.end-fields span{display:block;color:#93a29f;font-size:10px;margin-bottom:5px}.end-fields strong{display:block;font-size:12px}.end-mini{display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid #f0f4f2;font-size:11px}.end-mini:last-child{border:0}.end-mini strong,.end-mini small{display:block}.end-mini small{font-size:9px;color:var(--e-muted);margin-top:3px}.end-mini b{font-size:10px;color:#536f6b}.end-note{font-size:11px;color:#92a09e}.close-grid{display:grid;grid-template-columns:1fr 1fr;gap:9px}.close-dim{border:1px solid #edf3f1;border-radius:7px;padding:11px}.close-dim span{display:block;text-transform:capitalize;font-size:10px;color:#93a29f;margin-bottom:5px}.close-dim b{font-size:11px}.makap-end-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:#95a3a1;padding:50px 25px;font-size:11px;min-height:180px}.makap-end-empty strong{color:#6f8280;font-size:12px;margin:10px 0 4px}.makap-end-empty span{max-width:250px;line-height:1.5}.detail-empty{min-height:690px}.load-ring{width:22px;height:22px;border:2px solid #d9eae5;border-top-color:var(--e-teal);border-radius:50%;animation:ends .8s linear infinite}@keyframes ends{to{transform:rotate(360deg)}}@media(max-width:1100px){.makap-end-shell{padding:26px 20px 40px}.makap-end-metrics{grid-template-columns:repeat(2,1fr)}.makap-end-work{grid-template-columns:minmax(300px,.85fr) minmax(430px,1.3fr)}}@media(max-width:820px){.makap-end-head{display:block}.makap-end-head button{margin-top:18px}.makap-end-tabs{overflow:auto}.makap-end-tabs button{white-space:nowrap}.makap-end-work{grid-template-columns:1fr}.makap-end-listpanel{border-right:0;border-bottom:1px solid var(--e-line);max-height:390px}.detail-empty{min-height:320px}.makap-end-hero>a{display:none}}@media(max-width:560px){.makap-end-metrics{grid-template-columns:1fr 1fr;gap:8px}.makap-end-metrics>div{padding:13px}.makap-end-metrics strong{font-size:22px}.makap-end-hero{padding:23px 20px}.makap-end-body{padding-left:20px;padding-right:20px}.end-fields,.close-grid{grid-template-columns:1fr}.end-ident h2{font-size:20px}} diff --git a/makap_backend/makap_backend/page/makap_closeout/makap_closeout.js b/makap_backend/makap_backend/page/makap_closeout/makap_closeout.js new file mode 100644 index 0000000..8967dcc --- /dev/null +++ b/makap_backend/makap_backend/page/makap_closeout/makap_closeout.js @@ -0,0 +1 @@ +frappe.pages["makap-closeout"].on_page_load=function(w){const p=frappe.ui.make_app_page({parent:w,title:"Order Close-out & Margin Center",single_column:true});p.main.html(`
Operating control

Order Close-out & Margin Center

See every closure dimension, primary blocker, cost confidence, and margin basis before an order is closed.

Open operating closes—Orders with one or more blockers
Ready to close—All operating dimensions closed
Orders with blockers—Primary blocker identified
Cost attention—Provisional or restatement needed
Select an order close-outReview closure dimensions and cost confidence here.
`);const r=p.main[0],list=r.querySelector("[data-list]"),detail=r.querySelector("[data-detail]"),search=r.querySelector("[data-search]"),filter=r.querySelector("[data-filter]"),count=r.querySelector("[data-count]"),title=r.querySelector("[data-title]");let tab=new URLSearchParams(location.search).get("tab")||"open",active=null,timer;const esc=v=>frappe.utils.escape_html(String(v==null?"":v)),num=v=>new Intl.NumberFormat().format(Number(v||0)),date=v=>v?frappe.datetime.str_to_user(v):"Not set",ini=v=>(String(v||"?").split(/\s+/).slice(0,2).map(x=>x[0]).join("")||"?").toUpperCase(),call=(m,a={})=>new Promise((ok,no)=>frappe.call({method:m,args:a,callback:x=>x.exc?no(x.exc):ok(x.message)})),tone=s=>s==="Closed"?"good":s==="Open"?"warn":"bad";function metrics(x){["open","ready","blockers","cost_attention"].forEach(k=>r.querySelector(`[data-m="${k}"]`).textContent=num(x[k]))}function set(t){tab=t;active=null;r.querySelectorAll("[data-t]").forEach(e=>e.classList.toggle("active",e.dataset.t===t));title.textContent=t==="open"?"Open close-out queue":t==="dimensions"?"Closure dimensions":"Cost & margin";load()}function row(x){let stat=tab==="cost"?(x.cost_status||"Not Assessed"):x.operating_close||"Open";return ``}function render(rs){count.textContent=`${rs.length} record${rs.length===1?"":"s"}`;list.innerHTML=rs.length?rs.map(row).join(""):`
No close-out records foundGenerate a close-out view from the Sales Order workflow first.
`;list.querySelectorAll("[data-n]").forEach(e=>e.onclick=()=>select(e.dataset.n))}function detail(x){active=x.name;let dims=["production_close","material_close","quality_close","supply_close","fulfilment_close","billing_fiscal_close"].map(k=>`
${esc(k.replaceAll("_"," "))}${esc(x[k]||"Open")}
`).join("");detail.innerHTML=`
${esc(ini(x.customer||x.sales_order))}
Order Closure And Margin View

${esc(x.sales_order||x.name)}

${esc(x.customer||"No customer")} · ${esc(x.operating_close||"Open")}

Open ERPNext form
Operating close${esc(x.operating_close||"Open")}${esc(x.primary_blocker||"No primary blocker")}
Closure dimensions
${dims}
Cost and margin interpretation
Cost status${esc(x.cost_status||"Not Assessed")}
Cost confidence${esc(x.cost_confidence||"Not Assessed")}
Attribution basis${esc(x.cost_attribution_basis||"Not Assessed")}
Margin basis${esc(x.margin_basis||"Not set")}
Collection status${esc(x.collection_close||"Not Yet Due")}
Last recomputed${date(x.last_recomputed_on)}
Margin notes
${esc(x.margin_notes||"No margin notes recorded.")}
`;r.querySelectorAll(".makap-end-row").forEach(e=>e.classList.toggle("active",e.dataset.n===active))}async function select(n){active=n;detail.innerHTML=`
Loading close-out
`;try{detail(await call("makap_backend.closeout_api.get_closeout_view",{name:n}))}catch(e){detail.innerHTML=`
Could not load close-out${esc(e.message||"Please try again.")}
`}}async function load(){list.innerHTML=`
Loading close-out views...
`;try{let rs=await call("makap_backend.closeout_api.list_closeout_views",{search:search.value,operating_close:tab==="open"?"Open":"",cost_status:tab==="cost"?["Not Assessed","Provisional","Ready to Close","Restatement Required"].includes(filter.value)?filter.value:"":""});rs=filter.value==="All"?rs:rs.filter(x=>tab==="cost"?x.cost_status===filter.value:x.operating_close===filter.value);render(rs||[]);if(!active&&rs?.[0])select(rs[0].name)}catch(e){list.innerHTML=`
Close-out unavailable${esc(e.message||"Check permissions.")}
`}}r.querySelectorAll("[data-t]").forEach(e=>e.onclick=()=>set(e.dataset.t));search.oninput=()=>{clearTimeout(timer);timer=setTimeout(load,250)};filter.onchange=load;r.querySelector("[data-r]").onclick=async()=>{metrics(await call("makap_backend.closeout_api.get_closeout_summary"));load()};(async()=>{try{metrics(await call("makap_backend.closeout_api.get_closeout_summary"))}catch(e){}set(tab)})()}; diff --git a/makap_backend/makap_backend/page/makap_closeout/makap_closeout.json b/makap_backend/makap_backend/page/makap_closeout/makap_closeout.json new file mode 100644 index 0000000..cbfa379 --- /dev/null +++ b/makap_backend/makap_backend/page/makap_closeout/makap_closeout.json @@ -0,0 +1 @@ +{"doctype":"Page","name":"makap-closeout","module":"makap_backend","standard":"Yes","title":"MAKAP Order Close-out & Margin Center"} diff --git a/makap_backend/makap_backend/page/makap_contacts/makap_contacts.css b/makap_backend/makap_backend/page/makap_contacts/makap_contacts.css new file mode 100644 index 0000000..4ca6f8e --- /dev/null +++ b/makap_backend/makap_backend/page/makap_contacts/makap_contacts.css @@ -0,0 +1,3 @@ +.makap-contacts-shell{--mk-ink:#15353a;--mk-muted:#728589;--mk-line:#e4eeeb;--mk-paper:#f7fbfa;--mk-teal:#0c766c;--mk-teal-dark:#07554f;--mk-mint:#e4f5ef;--mk-shadow:0 18px 50px rgba(21,69,64,.07);max-width:1440px;margin:0 auto;padding:34px 42px 48px;color:var(--mk-ink);font-family:"DM Sans",sans-serif}.makap-contacts-head{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:25px}.makap-eyebrow{text-transform:uppercase;letter-spacing:.14em;font-size:10px;font-weight:700;color:#88a09d;margin-bottom:8px}.makap-contacts-head h1{font-family:"Space Grotesk",sans-serif;font-size:31px;letter-spacing:-.045em;margin:0 0 8px;font-weight:600}.makap-contacts-head p{font-size:13px;color:var(--mk-muted);margin:0;max-width:620px}.makap-contact-actions{display:flex;gap:9px}.makap-contact-actions .btn-primary{background:var(--mk-teal);border-color:var(--mk-teal)}.makap-contact-workspace{display:grid;grid-template-columns:minmax(310px,.88fr) minmax(480px,1.6fr);min-height:610px;background:#fff;border:1px solid var(--mk-line);border-radius:14px;overflow:hidden;box-shadow:var(--mk-shadow)}.makap-contact-list-panel{border-right:1px solid var(--mk-line);background:#fbfdfc}.makap-contact-list-head{padding:21px 22px 17px;border-bottom:1px solid var(--mk-line)}.makap-contact-list-head strong{font-family:"Space Grotesk",sans-serif;font-size:17px;font-weight:600;display:block}.makap-contact-count{display:block;color:#91a09f;font-size:10px;margin-top:3px}.makap-contact-search{display:flex;align-items:center;gap:8px;background:#f2f7f5;border:1px solid #e3eeea;border-radius:7px;padding:9px 11px;margin-top:16px;color:#8fa09c}.makap-contact-search input{border:0;background:transparent;outline:0;width:100%;font:12px "DM Sans";color:var(--mk-ink)}.makap-contact-list{padding:8px}.makap-contact-row{width:100%;display:flex;align-items:center;gap:11px;border:0;border-radius:9px;background:transparent;padding:12px 11px;text-align:left;cursor:pointer;color:var(--mk-ink);transition:.15s}.makap-contact-row:hover{background:#f0f7f4}.makap-contact-row.is-active{background:var(--mk-mint);box-shadow:inset 3px 0 var(--mk-teal)}.makap-contact-avatar,.makap-detail-avatar{display:grid;place-items:center;border-radius:50%;background:#d5eee6;color:var(--mk-teal);font-family:"Space Grotesk",sans-serif;font-weight:700}.makap-contact-avatar{width:34px;height:34px;font-size:12px;flex:none}.makap-contact-row-copy{min-width:0;flex:1}.makap-contact-row-copy strong,.makap-contact-row-copy span{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.makap-contact-row-copy strong{font-size:12px;font-weight:700}.makap-contact-row-copy span{color:var(--mk-muted);font-size:10px;margin-top:3px}.makap-contact-chevron{color:#9db0ac;font-size:12px}.makap-contact-detail-panel{background:#fff}.makap-detail-hero{padding:30px 33px 27px;background:linear-gradient(115deg,#f0faf6,#f4f9ff);border-bottom:1px solid var(--mk-line);display:flex;align-items:center;gap:16px}.makap-detail-avatar{width:62px;height:62px;font-size:21px}.makap-detail-identity{min-width:0;flex:1}.makap-detail-identity h2{font:600 25px "Space Grotesk",sans-serif;letter-spacing:-.04em;margin:4px 0 4px}.makap-detail-identity p{color:var(--mk-muted);font-size:12px;margin:0}.makap-status-pill{color:#4c9b73;background:#e8f7ed;border-radius:20px;padding:4px 8px;display:inline-flex;align-items:center;gap:5px;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em}.makap-status-pill span{width:5px;height:5px;border-radius:50%;background:#57b37d}.makap-detail-body{padding:4px 33px 30px}.makap-detail-section{padding:23px 0;border-bottom:1px solid #edf3f1}.makap-detail-section:last-child{border-bottom:0}.makap-detail-section-title{text-transform:uppercase;letter-spacing:.12em;color:#8ca09d;font-weight:700;font-size:9px;margin-bottom:15px}.makap-detail-fields{display:grid;grid-template-columns:1fr 1fr;gap:17px 30px}.makap-detail-field span{display:block;color:#93a29f;font-size:10px;margin-bottom:5px}.makap-detail-field strong{display:block;font-size:12px;font-weight:600;overflow:hidden;text-overflow:ellipsis}.makap-detail-field a{color:var(--mk-teal);text-decoration:none}.makap-linked-record{display:flex;align-items:center;gap:10px;padding:10px 0;text-decoration:none;color:var(--mk-ink);border-bottom:1px solid #f0f4f2}.makap-linked-record:last-child{border-bottom:0}.makap-linked-icon{display:grid;place-items:center;width:28px;height:28px;border-radius:7px;background:#edf6fb;color:#5384a5;font-size:11px}.makap-linked-record span:nth-child(2){flex:1}.makap-linked-record strong,.makap-linked-record small{display:block}.makap-linked-record strong{font-size:12px}.makap-linked-record small{color:var(--mk-muted);font-size:10px;margin-top:3px}.makap-linked-record>i{color:#a7b6b3}.makap-contact-note{color:#92a09e;font-size:11px;padding:5px 0}.makap-contact-note i{margin-right:7px}.makap-contact-id{display:flex;align-items:center;justify-content:space-between;background:#f6faf8;border:1px solid #e8f0ed;border-radius:7px;padding:8px 10px}.makap-contact-id code{font-size:11px;color:#4e7771}.makap-contact-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:#95a3a1;padding:50px 25px;font-size:11px;min-height:170px}.makap-contact-empty strong{color:#6f8280;font-size:12px;margin:10px 0 4px}.makap-contact-empty span{max-width:220px;line-height:1.5}.makap-detail-empty{min-height:580px}.makap-empty-icon{display:grid;place-items:center;width:41px;height:41px;border-radius:12px;background:var(--mk-mint);color:var(--mk-teal);font-size:17px}.makap-loading-ring{width:22px;height:22px;border:2px solid #d9eae5;border-top-color:var(--mk-teal);border-radius:50%;animation:mkspin .8s linear infinite}@keyframes mkspin{to{transform:rotate(360deg)}} +@media(max-width:900px){.makap-contacts-shell{padding:25px 18px 35px}.makap-contact-workspace{grid-template-columns:1fr}.makap-contact-list-panel{border-right:0;border-bottom:1px solid var(--mk-line);max-height:390px}.makap-detail-empty{min-height:320px}.makap-detail-menu{display:none}} +@media(max-width:600px){.makap-contacts-head{display:block}.makap-contact-actions{margin-top:18px}.makap-detail-hero{padding:23px 20px}.makap-detail-body{padding-left:20px;padding-right:20px}.makap-detail-fields{grid-template-columns:1fr}.makap-contact-workspace{min-height:0}} diff --git a/makap_backend/makap_backend/page/makap_contacts/makap_contacts.js b/makap_backend/makap_backend/page/makap_contacts/makap_contacts.js new file mode 100644 index 0000000..b824ccd --- /dev/null +++ b/makap_backend/makap_backend/page/makap_contacts/makap_contacts.js @@ -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(` +
+
+
+
Customer & commercial
+

Contacts

+

Review customer people, resolve onboarding contacts, and keep communication data accurate.

+
+
+ + +
+
+ +
+ +
+
Select a contactChoose a contact from the directory to view profile details and linked customers.
+
+
+
+ `); + + 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 = `
No contacts foundTry a different name, email, or company.
`; + return; + } + list.innerHTML = rows.map((row) => ` + + `).join(""); + list.querySelectorAll("[data-name]").forEach((el) => el.addEventListener("click", () => selectContact(el.dataset.name))); + } + + function renderDetail(contact) { + activeName = contact.name; + detail.innerHTML = ` +
+
${esc(initials(contact.display_name))}
+
${esc(contact.status || "Active")}

${esc(contact.display_name)}

${esc(contact.designation || "Contact")}${contact.company_name ? ` · ${esc(contact.company_name)}` : ""}

+
Open ERPNext form
+
+
+
Communication
+
Email${contact.email_id ? `${esc(contact.email_id)}` : "Not provided"}
+
Phone${contact.phone ? `${esc(contact.phone)}` : "Not provided"}
+
Mobile${contact.mobile_no ? `${esc(contact.mobile_no)}` : "Not provided"}
+
Last updated${esc(frappe.datetime.str_to_user(contact.modified))}
+
+
Linked records
${contact.links.length ? contact.links.map((link) => `${esc(link.name)}${esc(link.doctype)}`).join("") : `
No Customer or other linked records.
`}
+
Contact ID
${esc(contact.name)}
+
+ `; + 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 = `
Loading contactFetching the latest profile details.
`; + try { renderDetail(await call("makap_backend.contact_api.get_contact", {name})); } catch (error) { detail.innerHTML = `
Could not load contact${esc(error.message || "Please try again.")}
`; } + } + + async function loadContacts() { + list.innerHTML = `
Loading contacts...
`; + 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 = `
Contacts unavailable${esc(error.message || "Check your permissions and try again.")}
`; } + } + + 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(); +}; diff --git a/makap_backend/makap_backend/page/makap_contacts/makap_contacts.json b/makap_backend/makap_backend/page/makap_contacts/makap_contacts.json new file mode 100644 index 0000000..6c21f02 --- /dev/null +++ b/makap_backend/makap_backend/page/makap_contacts/makap_contacts.json @@ -0,0 +1,7 @@ +{ + "doctype": "Page", + "name": "makap-contacts", + "module": "makap_backend", + "standard": "Yes", + "title": "MAKAP Contacts" +} diff --git a/makap_backend/makap_backend/page/makap_dashboard/__init__.py b/makap_backend/makap_backend/page/makap_dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/makap_backend/makap_backend/page/makap_dashboard/makap_dashboard.js b/makap_backend/makap_backend/page/makap_dashboard/makap_dashboard.js new file mode 100644 index 0000000..4bf4f89 --- /dev/null +++ b/makap_backend/makap_backend/page/makap_dashboard/makap_dashboard.js @@ -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('
'); + 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('

Loading…

'); + + frappe.call({ + method: 'makap_backend.makap_backend.dashboard_api.get_universal_dashboard', + callback: function (r) { + if (!r.message) { + root.html('

No data.

'); + 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 += '
' + + '
' + + '

Welcome back, ' + frappe.utils.escape_html(user_name) + ' 👋

' + + '

A single view across MAKAP Production, Quality, Supply and Fulfilment — Modules 4 through 8.

' + + '
'; + + html += '
'; + kpis.forEach(function (k) { + html += '
' + + '
' + + '' + icon(k.icon) + '' + + '' + icon('chevron-right') + '' + + '
' + + '
' + frappe.utils.escape_html(k.label) + '
' + + '
' + k.value + '
' + + '
'; + }); + html += '
'; + + html += '
' + + '
' + + '' + icon('grid') + '' + + '

MAKAP Operations

Manage production, quality, supply, fulfilment and billing from one place.

' + + '
' + + '
' + + '' + + '
'; + + html += '
'; + operations.forEach(function (op) { + html += '
' + + '
' + + '' + icon(op.icon) + '' + + '' + frappe.utils.escape_html(op.module) + '' + + '
' + + '

' + frappe.utils.escape_html(op.title) + '

' + + '

' + frappe.utils.escape_html(op.desc) + '

' + + '
'; + op.badges.forEach(function (b) { + html += '' + + '' + b.value + ' ' + frappe.utils.escape_html(b.label) + ''; + }); + html += '
'; + }); + html += '
'; + + html += '
' + + icon('info') + + '
Two policy decisions are still open: Reserved vs Pooled default allocation, and the EFRIS integration method. Dispatch and fiscalisation logic stay first-pass until Makap confirms them.
' + + '
'; + + 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 ''; +} diff --git a/makap_backend/makap_backend/page/makap_dashboard/makap_dashboard.json b/makap_backend/makap_backend/page/makap_dashboard/makap_dashboard.json new file mode 100644 index 0000000..040474e --- /dev/null +++ b/makap_backend/makap_backend/page/makap_dashboard/makap_dashboard.json @@ -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": [] +} diff --git a/makap_backend/makap_backend/page/makap_fulfilment/makap_fulfilment.css b/makap_backend/makap_backend/page/makap_fulfilment/makap_fulfilment.css new file mode 100644 index 0000000..c5c0cd9 --- /dev/null +++ b/makap_backend/makap_backend/page/makap_fulfilment/makap_fulfilment.css @@ -0,0 +1,2 @@ +.makap-flow-shell{--f-ink:#19343b;--f-muted:#71868b;--f-line:#e1ecea;--f-teal:#0c766c;--f-mint:#e3f5ef;--f-shadow:0 18px 50px rgba(20,68,70,.07);max-width:1500px;margin:0 auto;padding:34px 42px 48px;color:var(--f-ink);font-family:"DM Sans",sans-serif}.makap-flow-head{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:25px}.makap-flow-eyebrow{text-transform:uppercase;letter-spacing:.14em;font-size:10px;font-weight:700;color:#87a19e;margin-bottom:8px}.makap-flow-head h1{font:600 31px "Space Grotesk",sans-serif;letter-spacing:-.045em;margin:0 0 8px}.makap-flow-head p{font-size:13px;color:var(--f-muted);margin:0}.makap-flow-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:13px;margin-bottom:17px}.makap-flow-metrics>div{background:#fff;border:1px solid var(--f-line);border-radius:10px;padding:17px 19px;box-shadow:var(--f-shadow)}.makap-flow-metrics span,.makap-flow-metrics small{display:block}.makap-flow-metrics span{font-size:10px;color:var(--f-muted);font-weight:700;text-transform:uppercase;letter-spacing:.07em}.makap-flow-metrics strong{font:600 27px "Space Grotesk",sans-serif;display:block;margin:8px 0 3px}.makap-flow-metrics small{font-size:10px;color:#91a09f}.makap-flow-metrics .warn{background:#fffaf0;border-color:#f1dfbd}.makap-flow-metrics .warn strong{color:#bd7d28}.makap-flow-metrics .bad{background:#fff8f6;border-color:#f1d6d1}.makap-flow-metrics .bad strong{color:#b96868}.makap-flow-tabs{display:flex;gap:5px;background:#edf5f2;border:1px solid #dfece8;padding:5px;border-radius:10px 10px 0 0}.makap-flow-tabs button{border:0;background:transparent;color:#66817d;border-radius:7px;padding:10px 16px;font:600 11px "DM Sans";cursor:pointer}.makap-flow-tabs button.active{background:#fff;color:var(--f-teal);box-shadow:0 2px 8px #1c645d12}.makap-flow-work{display:grid;grid-template-columns:minmax(370px,.9fr) minmax(520px,1.55fr);min-height:690px;background:#fff;border:1px solid var(--f-line);border-top:0;border-radius:0 0 14px 14px;overflow:hidden;box-shadow:var(--f-shadow)}.makap-flow-listpanel{border-right:1px solid var(--f-line);background:#fbfdfd}.makap-flow-listhead{padding:21px 22px 16px;border-bottom:1px solid var(--f-line)}.makap-flow-listhead strong{font:600 17px "Space Grotesk",sans-serif;display:block}.makap-flow-listhead span{font-size:10px;color:#91a09f;display:block;margin-top:3px}.makap-flow-search{display:flex;gap:8px;align-items:center;background:#f2f7f5;border:1px solid #e3eeea;border-radius:7px;padding:9px 11px;color:#8fa09c;margin-top:16px}.makap-flow-search input{border:0;background:transparent;outline:0;width:100%;font:11px "DM Sans"}.makap-flow-listhead select{border:1px solid #e3eeea;background:#f8fbfa;color:#66807c;border-radius:7px;padding:7px;width:100%;font-size:10px;margin-top:8px}.makap-flow-list{padding:8px}.makap-flow-row{width:100%;display:flex;align-items:center;gap:11px;border:0;border-radius:9px;background:transparent;padding:12px 10px;text-align:left;cursor:pointer;color:var(--f-ink)}.makap-flow-row:hover,.makap-flow-row.active{background:var(--f-mint);box-shadow:inset 3px 0 var(--f-teal)}.flow-avatar,.flow-detail-avatar{display:grid;place-items:center;border-radius:50%;background:#d5eee6;color:var(--f-teal);font-family:"Space Grotesk",sans-serif;font-weight:700}.flow-avatar{width:35px;height:35px;font-size:10px;flex:none}.flow-avatar.blocked,.flow-detail-avatar.blocked{background:#fff0f0;color:#b96868}.flow-avatar.pending,.flow-detail-avatar.pending{background:#fff5e4;color:#bd7d28}.flow-avatar.ready,.flow-detail-avatar.ready{background:#e8f7ed;color:#4c9b73}.flow-copy{flex:1;min-width:0}.flow-copy strong,.flow-copy span,.flow-copy small{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.flow-copy strong{font-size:12px}.flow-copy span{font-size:10px;color:var(--f-muted);margin-top:3px}.flow-copy small{font-size:9px;color:#9aa9a7;margin-top:4px}.flow-badge{border-radius:20px;padding:4px 7px;font-size:9px;white-space:nowrap}.flow-badge.blocked{background:#fff0f0;color:#b96868}.flow-badge.pending{background:#fff5e4;color:#bd7d28}.flow-badge.ready{background:#e8f7ed;color:#4c9b73}.flow-badge.draft{background:#eef4f3;color:#66817d}.makap-flow-detail{background:#fff}.makap-flow-hero{padding:30px 33px 27px;background:linear-gradient(115deg,#f0faf6,#f4f9ff);border-bottom:1px solid var(--f-line);display:flex;align-items:center;gap:16px}.flow-detail-avatar{width:62px;height:62px;font-size:18px;flex:none}.flow-ident{flex:1;min-width:0}.flow-ident>span{font-size:9px;text-transform:uppercase;letter-spacing:.12em;color:#8ca09d;font-weight:700}.flow-ident h2{font:600 24px "Space Grotesk",sans-serif;letter-spacing:-.04em;margin:7px 0 4px}.flow-ident p{font-size:11px;color:var(--f-muted);margin:0}.makap-flow-body{padding:4px 33px 30px}.flow-state{margin-top:21px;border:1px solid #dcebe7;background:#f5fcf9;border-radius:10px;padding:18px 20px;display:flex;justify-content:space-between;align-items:center}.flow-state.blocked{background:#fff8f6;border-color:#f1d6d1}.flow-state.pending{background:#fffaf0;border-color:#f1dfbd}.flow-state span,.flow-state small{display:block}.flow-state span{font-size:10px;text-transform:uppercase;letter-spacing:.09em;color:#819691;font-weight:700}.flow-state strong{font:600 22px "Space Grotesk",sans-serif;display:block;margin:6px 0 2px}.flow-state small{font-size:10px;color:#8fa19e}.flow-state i{font-size:22px;color:var(--f-teal)}.flow-state.blocked i{color:#b96868}.flow-section{padding:23px 0;border-bottom:1px solid #edf3f1}.flow-section-title{text-transform:uppercase;letter-spacing:.12em;color:#8ca09d;font-weight:700;font-size:9px;margin-bottom:15px}.flow-fields{display:grid;grid-template-columns:1fr 1fr;gap:17px 30px}.flow-fields span{display:block;color:#93a29f;font-size:10px;margin-bottom:5px}.flow-fields strong{display:block;font-size:12px}.flow-fields a{color:var(--f-teal);text-decoration:none}.flow-mini{display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid #f0f4f2;font-size:11px}.flow-mini:last-child{border:0}.flow-mini strong,.flow-mini small{display:block}.flow-mini small{font-size:9px;color:var(--f-muted);margin-top:3px}.flow-mini b{font-size:10px;color:#536f6b}.flow-note{font-size:11px;color:#92a09e}.makap-flow-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:#95a3a1;padding:50px 25px;font-size:11px;min-height:180px}.makap-flow-empty strong{color:#6f8280;font-size:12px;margin:10px 0 4px}.makap-flow-empty span{max-width:250px;line-height:1.5}.detail-empty{min-height:690px}.load-ring{width:22px;height:22px;border:2px solid #d9eae5;border-top-color:var(--f-teal);border-radius:50%;animation:flowspin .8s linear infinite}@keyframes flowspin{to{transform:rotate(360deg)}} +@media(max-width:1100px){.makap-flow-shell{padding:26px 20px 40px}.makap-flow-metrics{grid-template-columns:repeat(2,1fr)}.makap-flow-work{grid-template-columns:minmax(300px,.85fr) minmax(430px,1.3fr)}}@media(max-width:820px){.makap-flow-head{display:block}.makap-flow-head button{margin-top:18px}.makap-flow-tabs{overflow:auto}.makap-flow-tabs button{white-space:nowrap}.makap-flow-work{grid-template-columns:1fr}.makap-flow-listpanel{border-right:0;border-bottom:1px solid var(--f-line);max-height:390px}.detail-empty{min-height:320px}.makap-flow-hero>a{display:none}}@media(max-width:560px){.makap-flow-metrics{grid-template-columns:1fr 1fr;gap:8px}.makap-flow-metrics>div{padding:13px}.makap-flow-metrics strong{font-size:22px}.makap-flow-hero{padding:23px 20px}.makap-flow-body{padding-left:20px;padding-right:20px}.flow-fields{grid-template-columns:1fr}.flow-ident h2{font-size:20px}} diff --git a/makap_backend/makap_backend/page/makap_fulfilment/makap_fulfilment.js b/makap_backend/makap_backend/page/makap_fulfilment/makap_fulfilment.js new file mode 100644 index 0000000..0c9bdd4 --- /dev/null +++ b/makap_backend/makap_backend/page/makap_fulfilment/makap_fulfilment.js @@ -0,0 +1 @@ +frappe.pages["makap-fulfilment"].on_page_load=function(w){const p=frappe.ui.make_app_page({parent:w,title:"Fulfilment Control Center",single_column:true});p.main.html(`
Fulfilment & dispatch

Fulfilment Control Center

Assess every dispatch against gate, material, financial, and traceability controls.

Awaiting assessment—Dispatch gate not checked
Blocked deliveries—Requires intervention
Ready for dispatch—Gate conditions satisfied
Exceptions—Material or close-out review
Select a Delivery NoteReview dispatch readiness and traceability here.
`);const r=p.main[0],list=r.querySelector("[data-list]"),detail=r.querySelector("[data-detail]"),search=r.querySelector("[data-search]"),filter=r.querySelector("[data-filter]"),count=r.querySelector("[data-count]"),title=r.querySelector("[data-title]");let tab=new URLSearchParams(location.search).get("tab")||"queue",active=null,timer;const esc=v=>frappe.utils.escape_html(String(v==null?"":v)),num=v=>new Intl.NumberFormat().format(Number(v||0)),date=v=>v?frappe.datetime.str_to_user(v):"Not set",ini=v=>(String(v||"?").split(/\s+/).slice(0,2).map(x=>x[0]).join("")||"?").toUpperCase(),call=(m,a={})=>new Promise((ok,no)=>frappe.call({method:m,args:a,callback:x=>x.exc?no(x.exc):ok(x.message)})),tone=s=>({Blocked:"blocked",Ready:"ready",Exception:"exception"}[s]||"pending");function metrics(x){["awaiting","blocked","ready","exceptions"].forEach(k=>r.querySelector(`[data-m="${k}"]`).textContent=num(x[k]))}function set(t){tab=t;active=null;r.querySelectorAll("[data-t]").forEach(e=>e.classList.toggle("active",e.dataset.t===t));title.textContent=t==="queue"?"Dispatch queue":t==="exceptions"?"Blocked & exceptions":"Traceability";load()}function row(x){let s=x.makap_derived_state||"Not Assessed";return ``}function render(rows){count.textContent=`${rows.length} record${rows.length===1?"":"s"}`;list.innerHTML=rows.length?rows.map(row).join(""):`
No delivery notes foundTry another search or state filter.
`;list.querySelectorAll("[data-n]").forEach(e=>e.onclick=()=>select(e.dataset.n))}function detail(x){active=x.name;let lines=(x.items||[]).map(i=>`
${esc(i.item_name||i.item_code||"Item")}${esc(i.item_code||"")} · ${esc(i.qty||0)} · ${esc(i.warehouse||"No warehouse")}${esc(i.makap_dispatch_allocation_mode||"No allocation")}
`).join("")||`
No line items available.
`;detail.innerHTML=`
${esc(ini(x.customer||x.name))}
Delivery Note

${esc(x.name)}

${esc(x.customer||"No customer")} · ${esc(x.status||"Draft")}

Open ERPNext form
Dispatch readiness${esc(x.makap_derived_state)}${esc(x.makap_dispatch_gate_blockers||x.makap_dispatch_accountability_notes||"No blocker summary recorded")}
Dispatch controls
Gate status${esc(x.makap_dispatch_gate_status||"Not Assessed")}
Material accountability${esc(x.makap_dispatch_material_accountability_status||"Not Assessed")}
Financial release${esc(x.makap_financial_release_status||"Not Assessed")}
Checked by${esc(x.makap_dispatch_gate_checked_by||"Not set")}
Commercial traceability
Customer${esc(x.customer||"Not linked")}
Sales Order${x.sales_order?`${esc(x.sales_order)}`:"Not linked"}
Posting date${date(x.posting_date)}
Short-close${x.makap_short_close?esc(x.makap_short_close_reason||"Flagged"):"No"}
Dispatch lines
${lines}
`;r.querySelectorAll(".makap-flow-row").forEach(e=>e.classList.toggle("active",e.dataset.n===active))}async function select(n){active=n;detail.innerHTML=`
Loading delivery
`;try{detail(await call("makap_backend.fulfilment_api.get_delivery_note",{name:n}))}catch(e){detail.innerHTML=`
Could not load delivery${esc(e.message||"Please try again.")}
`}}async function load(){list.innerHTML=`
Loading deliveries...
`;try{let rows=tab==="exceptions"?await call("makap_backend.fulfilment_api.list_fulfilment_exceptions",{search:search.value}):tab==="trace"?await call("makap_backend.fulfilment_api.list_fulfilment_traceability",{search:search.value}):await call("makap_backend.fulfilment_api.list_delivery_notes",{search:search.value,operational_state:["Ready","Blocked","Not Assessed"].includes(filter.value)?filter.value:""});render(rows||[]);if(!active&&rows?.[0])select(rows[0].name)}catch(e){list.innerHTML=`
Fulfilment unavailable${esc(e.message||"Check permissions.")}
`}}r.querySelectorAll("[data-t]").forEach(e=>e.onclick=()=>set(e.dataset.t));search.oninput=()=>{clearTimeout(timer);timer=setTimeout(load,250)};filter.onchange=load;r.querySelector("[data-r]").onclick=async()=>{metrics(await call("makap_backend.fulfilment_api.get_fulfilment_summary"));load()};(async()=>{try{metrics(await call("makap_backend.fulfilment_api.get_fulfilment_summary"))}catch(e){}set(tab)})()}; diff --git a/makap_backend/makap_backend/page/makap_fulfilment/makap_fulfilment.json b/makap_backend/makap_backend/page/makap_fulfilment/makap_fulfilment.json new file mode 100644 index 0000000..1acd25c --- /dev/null +++ b/makap_backend/makap_backend/page/makap_fulfilment/makap_fulfilment.json @@ -0,0 +1 @@ +{"doctype":"Page","name":"makap-fulfilment","module":"makap_backend","standard":"Yes","title":"MAKAP Fulfilment Control Center"} diff --git a/makap_backend/makap_backend/page/makap_governance/makap_governance.css b/makap_backend/makap_backend/page/makap_governance/makap_governance.css new file mode 100644 index 0000000..ce7e77b --- /dev/null +++ b/makap_backend/makap_backend/page/makap_governance/makap_governance.css @@ -0,0 +1 @@ +.makap-gov-shell{--g-ink:#19343b;--g-muted:#71868b;--g-line:#e1ecea;--g-teal:#0c766c;--g-mint:#e3f5ef;--g-shadow:0 18px 50px rgba(20,68,70,.07);max-width:1500px;margin:0 auto;padding:34px 42px 48px;color:var(--g-ink);font-family:"DM Sans",sans-serif}.makap-gov-head{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:25px}.makap-gov-eyebrow{ text-transform:uppercase;letter-spacing:.14em;font-size:10px;font-weight:700;color:#87a19e;margin-bottom:8px}.makap-gov-head h1{font:600 31px "Space Grotesk",sans-serif;letter-spacing:-.045em;margin:0 0 8px}.makap-gov-head p{font-size:13px;color:var(--g-muted);margin:0;max-width:760px}.makap-gov-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:13px;margin-bottom:17px}.makap-gov-metrics>div{background:#fff;border:1px solid var(--g-line);border-radius:10px;padding:17px 19px;box-shadow:var(--g-shadow)}.makap-gov-metrics span,.makap-gov-metrics small{display:block}.makap-gov-metrics span{font-size:10px;color:var(--g-muted);font-weight:700;text-transform:uppercase;letter-spacing:.07em}.makap-gov-metrics strong{font:600 27px "Space Grotesk",sans-serif;display:block;margin:8px 0 3px}.makap-gov-metrics small{font-size:10px;color:#91a09f}.makap-gov-metrics .good{background:#f5fcf9;border-color:#dcebe7}.makap-gov-metrics .good strong{color:#4c9b73}.makap-gov-metrics .warn{background:#fffaf0;border-color:#f1dfbd}.makap-gov-metrics .warn strong{color:#bd7d28}.makap-gov-tabs{display:flex;gap:5px;background:#edf5f2;border:1px solid #dfece8;padding:5px;border-radius:10px 10px 0 0}.makap-gov-tabs button{border:0;background:transparent;color:#66817d;border-radius:7px;padding:10px 15px;font:600 11px "DM Sans";cursor:pointer}.makap-gov-tabs button.active{background:#fff;color:var(--g-teal)}.makap-gov-work{display:grid;grid-template-columns:minmax(370px,.9fr) minmax(520px,1.55fr);min-height:690px;background:#fff;border:1px solid var(--g-line);border-top:0;border-radius:0 0 14px 14px;overflow:hidden;box-shadow:var(--g-shadow)}.makap-gov-listpanel{border-right:1px solid var(--g-line);background:#fbfdfd}.makap-gov-listhead{padding:21px 22px 16px;border-bottom:1px solid var(--g-line)}.makap-gov-listhead strong{font:600 17px "Space Grotesk",sans-serif;display:block}.makap-gov-listhead span{font-size:10px;color:#91a09f;display:block;margin-top:3px}.makap-gov-search{display:flex;gap:8px;align-items:center;background:#f2f7f5;border:1px solid #e3eeea;border-radius:7px;padding:9px 11px;color:#8fa09c;margin-top:16px}.makap-gov-search input{border:0;background:transparent;outline:0;width:100%;font:11px "DM Sans"}.makap-gov-listhead select{border:1px solid #e3eeea;background:#f8fbfa;color:#66807c;border-radius:7px;padding:7px;width:100%;font-size:10px;margin-top:8px}.makap-gov-list{padding:8px}.makap-gov-row{width:100%;display:flex;align-items:center;gap:11px;border:0;border-radius:9px;background:transparent;padding:12px 10px;text-align:left;cursor:pointer;color:var(--g-ink)}.makap-gov-row:hover{background:var(--g-mint)}.gov-avatar,.gov-detail-avatar{display:grid;place-items:center;border-radius:50%;width:35px;height:35px;background:#edf5fb;color:#5384a5;flex:none}.gov-avatar.good,.gov-detail-avatar.good{background:#e8f7ed;color:#4c9b73}.gov-avatar.bad,.gov-detail-avatar.bad{background:#fff0f0;color:#b96868}.gov-copy{flex:1;min-width:0}.gov-copy strong,.gov-copy small{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.gov-copy strong{font-size:12px}.gov-copy small{font-size:10px;color:var(--g-muted);margin-top:3px}.makap-gov-row>b{font-size:9px;color:#bd7d28}.makap-gov-row.good>b{color:#4c9b73}.makap-gov-detail{background:#fff}.makap-gov-hero{padding:30px 33px 27px;background:linear-gradient(115deg,#f0faf6,#f4f9ff);border-bottom:1px solid var(--g-line);display:flex;align-items:center;gap:16px}.makap-gov-hero h2{font:600 24px "Space Grotesk",sans-serif;margin:7px 0 4px}.makap-gov-hero span{font-size:9px;text-transform:uppercase;letter-spacing:.12em;color:#8ca09d;font-weight:700}.makap-gov-hero p{font-size:11px;color:var(--g-muted);margin:0}.makap-gov-body{padding:25px 33px}.gov-state{padding:18px;border-radius:10px;background:#fffaf0;border:1px solid #f1dfbd;margin-bottom:20px}.gov-state.good{background:#f5fcf9;border-color:#dcebe7}.gov-state span,.gov-state small{display:block}.gov-state span{font-size:10px;text-transform:uppercase;letter-spacing:.09em;color:#819691;font-weight:700}.gov-state strong{font:600 22px "Space Grotesk",sans-serif;display:block;margin:6px 0 2px}.gov-state small{font-size:10px;color:#8fa19e}.gov-fields{display:grid;grid-template-columns:1fr 1fr;gap:17px 30px}.gov-fields span{display:block;color:#93a29f;font-size:10px;margin-bottom:5px}.gov-fields strong{font-size:12px}.makap-gov-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:#95a3a1;padding:50px 25px;font-size:11px;min-height:180px}.makap-gov-empty strong{color:#6f8280;font-size:12px;margin:10px 0 4px}.makap-gov-empty span{max-width:250px}.detail-empty{min-height:690px}.makap-health-grid{display:grid;grid-template-columns:1.3fr 1fr;gap:17px}.makap-health-grid>section{background:#fff;border:1px solid var(--g-line);border-radius:12px;padding:20px;box-shadow:var(--g-shadow)}.health-title{text-transform:uppercase;letter-spacing:.12em;color:#8ca09d;font-weight:700;font-size:10px;margin-bottom:15px}.makap-chain{display:flex;flex-wrap:wrap;gap:8px}.chain-step{display:flex;flex-direction:column;gap:3px;flex:1 1 150px;min-height:76px;padding:12px;border-radius:9px;background:#f5fcf9;border:1px solid #dcebe7;text-decoration:none;color:var(--g-ink)}.chain-step.missing{background:#fff8f6;border-color:#f1d6d1}.chain-step span{font-size:9px;color:#8ca09d}.chain-step strong{font-size:11px}.chain-step small{font-size:9px;color:var(--g-muted)}.health-issue{display:block;padding:12px 0;border-bottom:1px solid #edf3f1;text-decoration:none}.health-issue b,.health-issue strong,.health-issue span{display:block}.health-issue b{font-size:9px;color:#bd7d28;text-transform:uppercase}.health-issue strong{font-size:12px;color:var(--g-ink);margin:3px 0}.health-issue span{font-size:10px;color:var(--g-muted)}.load-ring{width:22px;height:22px;border:2px solid #d9eae5;border-top-color:var(--g-teal);border-radius:50%;animation:gspin .8s linear infinite}@keyframes gspin{to{transform:rotate(360deg)}}@media(max-width:1100px){.makap-gov-shell{padding:26px 20px 40px}.makap-gov-metrics{grid-template-columns:repeat(2,1fr)}.makap-gov-work{grid-template-columns:minmax(300px,.85fr) minmax(430px,1.3fr)}.makap-health-grid{grid-template-columns:1fr}}@media(max-width:820px){.makap-gov-head{display:block}.makap-gov-head button{margin-top:18px}.makap-gov-work{grid-template-columns:1fr}.makap-gov-listpanel{border-right:0;border-bottom:1px solid var(--g-line);max-height:390px}.detail-empty{min-height:320px}}@media(max-width:560px){.makap-gov-metrics{grid-template-columns:1fr 1fr;gap:8px}.makap-gov-metrics>div{padding:13px}.makap-gov-metrics strong{font-size:22px}.makap-gov-hero,.makap-gov-body{padding-left:20px;padding-right:20px}.gov-fields{grid-template-columns:1fr}} diff --git a/makap_backend/makap_backend/page/makap_governance/makap_governance.js b/makap_backend/makap_backend/page/makap_governance/makap_governance.js new file mode 100644 index 0000000..d8b25ce --- /dev/null +++ b/makap_backend/makap_backend/page/makap_governance/makap_governance.js @@ -0,0 +1 @@ +frappe.pages["makap-governance"].on_page_load=function(w){const p=frappe.ui.make_app_page({parent:w,title:"Governance & Master Data Control Center",single_column:true});p.main.html(`
Governance & master data

Governance & Master Data Control

Verify the setup behind every operational page and keep the master records complete enough to support safe execution.

Configuration checks—Controls evaluated
Checks passed—Ready configuration
Needs attention—Missing or incomplete setup
Master records—Available reference records
Select a governance recordReview configuration checks or master data details here.
`);const r=p.main[0],list=r.querySelector("[data-list]"),detail=r.querySelector("[data-detail]"),search=r.querySelector("[data-search]"),type=r.querySelector("[data-type]"),count=r.querySelector("[data-count]"),title=r.querySelector("[data-title]");let tab=new URLSearchParams(location.search).get("tab")||"checks",active=null,timer;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)}));function metrics(x){["checks","passed","attention","master_records"].forEach(k=>r.querySelector(`[data-m="${k}"]`).textContent=num(x[k]))}function set(t){tab=t;active=null;r.querySelectorAll("[data-t]").forEach(e=>e.classList.toggle("active",e.dataset.t===t));title.textContent=t==="checks"?"Configuration checks":"Master data register";search.placeholder=t==="checks"?"Search checks...":"Search master data...";type.style.display=t==="masters"?"block":"none";load()}function renderChecks(rs){count.textContent=`${rs.length} checks`;list.innerHTML=rs.map(x=>``).join("")||`
No checks found.
`;list.querySelectorAll("[data-n]").forEach(e=>e.onclick=()=>{active=e.dataset.n;const x=rs.find(a=>a.label===active);detail.innerHTML=`
Configuration check

${esc(x.label)}

${esc(x.category)}

Result${x.ok?"Passed":"Needs attention"}${esc(x.detail)}
${x.route?`Open related record`:""}
`})}function renderMasters(rs){count.textContent=`${rs.length} records`;list.innerHTML=rs.map(x=>``).join("")||`
No master data found.
`;list.querySelectorAll("[data-n]").forEach(e=>e.onclick=()=>{const x=rs.find(a=>a.name===e.dataset.n);active=x.name;detail.innerHTML=`
Master data record

${esc(x.name)}

${esc(x.item_name||x.customer_name||x.supplier_name||x.warehouse_name||x.full_name||x.company_name||"")}

${Object.entries(x).filter(([k])=>k!=="name").map(([k,v])=>`
${esc(k.replaceAll("_"," "))}${esc(v||"Not set")}
`).join("")}
`})}async function load(){list.innerHTML=`
Loading governance data...
`;try{if(tab==="checks"){let rs=await call("makap_backend.governance_api.get_configuration_checks");if(search.value)rs=rs.filter(x=>(x.label+x.detail).toLowerCase().includes(search.value.toLowerCase()));renderChecks(rs)}else{let rs=await call("makap_backend.governance_api.list_master_data",{doctype:type.value,search:search.value});renderMasters(rs)}}catch(e){list.innerHTML=`
Governance data unavailable${esc(e.message||"Check permissions.")}
`}}r.querySelectorAll("[data-t]").forEach(e=>e.onclick=()=>set(e.dataset.t));search.oninput=()=>{clearTimeout(timer);timer=setTimeout(load,250)};type.onchange=load;r.querySelector("[data-r]").onclick=async()=>{metrics(await call("makap_backend.governance_api.get_governance_summary"));load()};(async()=>{try{metrics(await call("makap_backend.governance_api.get_governance_summary"))}catch(e){}set(tab)})()}; diff --git a/makap_backend/makap_backend/page/makap_governance/makap_governance.json b/makap_backend/makap_backend/page/makap_governance/makap_governance.json new file mode 100644 index 0000000..14ff6ac --- /dev/null +++ b/makap_backend/makap_backend/page/makap_governance/makap_governance.json @@ -0,0 +1 @@ +{"doctype":"Page","name":"makap-governance","module":"makap_backend","standard":"Yes","title":"MAKAP Governance & Master Data Control Center"} diff --git a/makap_backend/makap_backend/page/makap_health/makap_health.css b/makap_backend/makap_backend/page/makap_health/makap_health.css new file mode 100644 index 0000000..ce7e77b --- /dev/null +++ b/makap_backend/makap_backend/page/makap_health/makap_health.css @@ -0,0 +1 @@ +.makap-gov-shell{--g-ink:#19343b;--g-muted:#71868b;--g-line:#e1ecea;--g-teal:#0c766c;--g-mint:#e3f5ef;--g-shadow:0 18px 50px rgba(20,68,70,.07);max-width:1500px;margin:0 auto;padding:34px 42px 48px;color:var(--g-ink);font-family:"DM Sans",sans-serif}.makap-gov-head{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:25px}.makap-gov-eyebrow{ text-transform:uppercase;letter-spacing:.14em;font-size:10px;font-weight:700;color:#87a19e;margin-bottom:8px}.makap-gov-head h1{font:600 31px "Space Grotesk",sans-serif;letter-spacing:-.045em;margin:0 0 8px}.makap-gov-head p{font-size:13px;color:var(--g-muted);margin:0;max-width:760px}.makap-gov-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:13px;margin-bottom:17px}.makap-gov-metrics>div{background:#fff;border:1px solid var(--g-line);border-radius:10px;padding:17px 19px;box-shadow:var(--g-shadow)}.makap-gov-metrics span,.makap-gov-metrics small{display:block}.makap-gov-metrics span{font-size:10px;color:var(--g-muted);font-weight:700;text-transform:uppercase;letter-spacing:.07em}.makap-gov-metrics strong{font:600 27px "Space Grotesk",sans-serif;display:block;margin:8px 0 3px}.makap-gov-metrics small{font-size:10px;color:#91a09f}.makap-gov-metrics .good{background:#f5fcf9;border-color:#dcebe7}.makap-gov-metrics .good strong{color:#4c9b73}.makap-gov-metrics .warn{background:#fffaf0;border-color:#f1dfbd}.makap-gov-metrics .warn strong{color:#bd7d28}.makap-gov-tabs{display:flex;gap:5px;background:#edf5f2;border:1px solid #dfece8;padding:5px;border-radius:10px 10px 0 0}.makap-gov-tabs button{border:0;background:transparent;color:#66817d;border-radius:7px;padding:10px 15px;font:600 11px "DM Sans";cursor:pointer}.makap-gov-tabs button.active{background:#fff;color:var(--g-teal)}.makap-gov-work{display:grid;grid-template-columns:minmax(370px,.9fr) minmax(520px,1.55fr);min-height:690px;background:#fff;border:1px solid var(--g-line);border-top:0;border-radius:0 0 14px 14px;overflow:hidden;box-shadow:var(--g-shadow)}.makap-gov-listpanel{border-right:1px solid var(--g-line);background:#fbfdfd}.makap-gov-listhead{padding:21px 22px 16px;border-bottom:1px solid var(--g-line)}.makap-gov-listhead strong{font:600 17px "Space Grotesk",sans-serif;display:block}.makap-gov-listhead span{font-size:10px;color:#91a09f;display:block;margin-top:3px}.makap-gov-search{display:flex;gap:8px;align-items:center;background:#f2f7f5;border:1px solid #e3eeea;border-radius:7px;padding:9px 11px;color:#8fa09c;margin-top:16px}.makap-gov-search input{border:0;background:transparent;outline:0;width:100%;font:11px "DM Sans"}.makap-gov-listhead select{border:1px solid #e3eeea;background:#f8fbfa;color:#66807c;border-radius:7px;padding:7px;width:100%;font-size:10px;margin-top:8px}.makap-gov-list{padding:8px}.makap-gov-row{width:100%;display:flex;align-items:center;gap:11px;border:0;border-radius:9px;background:transparent;padding:12px 10px;text-align:left;cursor:pointer;color:var(--g-ink)}.makap-gov-row:hover{background:var(--g-mint)}.gov-avatar,.gov-detail-avatar{display:grid;place-items:center;border-radius:50%;width:35px;height:35px;background:#edf5fb;color:#5384a5;flex:none}.gov-avatar.good,.gov-detail-avatar.good{background:#e8f7ed;color:#4c9b73}.gov-avatar.bad,.gov-detail-avatar.bad{background:#fff0f0;color:#b96868}.gov-copy{flex:1;min-width:0}.gov-copy strong,.gov-copy small{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.gov-copy strong{font-size:12px}.gov-copy small{font-size:10px;color:var(--g-muted);margin-top:3px}.makap-gov-row>b{font-size:9px;color:#bd7d28}.makap-gov-row.good>b{color:#4c9b73}.makap-gov-detail{background:#fff}.makap-gov-hero{padding:30px 33px 27px;background:linear-gradient(115deg,#f0faf6,#f4f9ff);border-bottom:1px solid var(--g-line);display:flex;align-items:center;gap:16px}.makap-gov-hero h2{font:600 24px "Space Grotesk",sans-serif;margin:7px 0 4px}.makap-gov-hero span{font-size:9px;text-transform:uppercase;letter-spacing:.12em;color:#8ca09d;font-weight:700}.makap-gov-hero p{font-size:11px;color:var(--g-muted);margin:0}.makap-gov-body{padding:25px 33px}.gov-state{padding:18px;border-radius:10px;background:#fffaf0;border:1px solid #f1dfbd;margin-bottom:20px}.gov-state.good{background:#f5fcf9;border-color:#dcebe7}.gov-state span,.gov-state small{display:block}.gov-state span{font-size:10px;text-transform:uppercase;letter-spacing:.09em;color:#819691;font-weight:700}.gov-state strong{font:600 22px "Space Grotesk",sans-serif;display:block;margin:6px 0 2px}.gov-state small{font-size:10px;color:#8fa19e}.gov-fields{display:grid;grid-template-columns:1fr 1fr;gap:17px 30px}.gov-fields span{display:block;color:#93a29f;font-size:10px;margin-bottom:5px}.gov-fields strong{font-size:12px}.makap-gov-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:#95a3a1;padding:50px 25px;font-size:11px;min-height:180px}.makap-gov-empty strong{color:#6f8280;font-size:12px;margin:10px 0 4px}.makap-gov-empty span{max-width:250px}.detail-empty{min-height:690px}.makap-health-grid{display:grid;grid-template-columns:1.3fr 1fr;gap:17px}.makap-health-grid>section{background:#fff;border:1px solid var(--g-line);border-radius:12px;padding:20px;box-shadow:var(--g-shadow)}.health-title{text-transform:uppercase;letter-spacing:.12em;color:#8ca09d;font-weight:700;font-size:10px;margin-bottom:15px}.makap-chain{display:flex;flex-wrap:wrap;gap:8px}.chain-step{display:flex;flex-direction:column;gap:3px;flex:1 1 150px;min-height:76px;padding:12px;border-radius:9px;background:#f5fcf9;border:1px solid #dcebe7;text-decoration:none;color:var(--g-ink)}.chain-step.missing{background:#fff8f6;border-color:#f1d6d1}.chain-step span{font-size:9px;color:#8ca09d}.chain-step strong{font-size:11px}.chain-step small{font-size:9px;color:var(--g-muted)}.health-issue{display:block;padding:12px 0;border-bottom:1px solid #edf3f1;text-decoration:none}.health-issue b,.health-issue strong,.health-issue span{display:block}.health-issue b{font-size:9px;color:#bd7d28;text-transform:uppercase}.health-issue strong{font-size:12px;color:var(--g-ink);margin:3px 0}.health-issue span{font-size:10px;color:var(--g-muted)}.load-ring{width:22px;height:22px;border:2px solid #d9eae5;border-top-color:var(--g-teal);border-radius:50%;animation:gspin .8s linear infinite}@keyframes gspin{to{transform:rotate(360deg)}}@media(max-width:1100px){.makap-gov-shell{padding:26px 20px 40px}.makap-gov-metrics{grid-template-columns:repeat(2,1fr)}.makap-gov-work{grid-template-columns:minmax(300px,.85fr) minmax(430px,1.3fr)}.makap-health-grid{grid-template-columns:1fr}}@media(max-width:820px){.makap-gov-head{display:block}.makap-gov-head button{margin-top:18px}.makap-gov-work{grid-template-columns:1fr}.makap-gov-listpanel{border-right:0;border-bottom:1px solid var(--g-line);max-height:390px}.detail-empty{min-height:320px}}@media(max-width:560px){.makap-gov-metrics{grid-template-columns:1fr 1fr;gap:8px}.makap-gov-metrics>div{padding:13px}.makap-gov-metrics strong{font-size:22px}.makap-gov-hero,.makap-gov-body{padding-left:20px;padding-right:20px}.gov-fields{grid-template-columns:1fr}} diff --git a/makap_backend/makap_backend/page/makap_health/makap_health.js b/makap_backend/makap_backend/page/makap_health/makap_health.js new file mode 100644 index 0000000..fbb971e --- /dev/null +++ b/makap_backend/makap_backend/page/makap_health/makap_health.js @@ -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(`
End-to-end workflow audit

Workflow Health Dashboard

Audit the full MAKAP chain from customer demand to operating close-out and surface records that need attention.

Workflow stages—Configured DocTypes
Available stages—Installed and visible
Health issues—Records requiring attention
Audited records—Across the workflow
Workflow chain
Issues requiring attention
`);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=`
Auditing workflow...
`;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)=>`${i+1}${esc(a.label)}${a.available?`${num(a.count)} records`:"DocType missing"}`).join("");issues.innerHTML=x.issues.length?x.issues.map(a=>`${esc(a.type)}${esc(a.name)}${esc(a.issue)} · ${esc(a.detail)}`).join(""):`
No workflow issues foundThe audited workflow is healthy for the available checks.
`}catch(e){issues.innerHTML=`
Workflow audit unavailable${esc(e.message||"Check permissions.")}
`}}r.querySelector("[data-r]").onclick=load;load()}; diff --git a/makap_backend/makap_backend/page/makap_health/makap_health.json b/makap_backend/makap_backend/page/makap_health/makap_health.json new file mode 100644 index 0000000..c76f602 --- /dev/null +++ b/makap_backend/makap_backend/page/makap_health/makap_health.json @@ -0,0 +1 @@ +{"doctype":"Page","name":"makap-health","module":"makap_backend","standard":"Yes","title":"MAKAP Workflow Health Dashboard"} diff --git a/makap_backend/makap_backend/page/makap_materials/makap_materials.css b/makap_backend/makap_backend/page/makap_materials/makap_materials.css new file mode 100644 index 0000000..1684a29 --- /dev/null +++ b/makap_backend/makap_backend/page/makap_materials/makap_materials.css @@ -0,0 +1,2 @@ +.makap-mat-shell{--m-ink:#19343b;--m-muted:#71868b;--m-line:#e1ecea;--m-teal:#0c766c;--m-mint:#e3f5ef;--m-shadow:0 18px 50px rgba(20,68,70,.07);max-width:1500px;margin:0 auto;padding:34px 42px 48px;color:var(--m-ink);font-family:"DM Sans",sans-serif}.makap-mat-head{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:25px}.makap-mat-eyebrow{text-transform:uppercase;letter-spacing:.14em;font-size:10px;font-weight:700;color:#87a19e;margin-bottom:8px}.makap-mat-head h1{font:600 31px "Space Grotesk",sans-serif;letter-spacing:-.045em;margin:0 0 8px}.makap-mat-head p{font-size:13px;color:var(--m-muted);margin:0;max-width:720px}.makap-mat-actions{display:flex;gap:9px}.makap-mat-actions .btn-primary{background:var(--m-teal);border-color:var(--m-teal)}.makap-mat-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:13px;margin-bottom:17px}.makap-mat-metrics>div{background:#fff;border:1px solid var(--m-line);border-radius:10px;padding:17px 19px;box-shadow:var(--m-shadow)}.makap-mat-metrics span,.makap-mat-metrics small{display:block}.makap-mat-metrics span{font-size:10px;color:var(--m-muted);font-weight:700;text-transform:uppercase;letter-spacing:.07em}.makap-mat-metrics strong{font:600 27px "Space Grotesk",sans-serif;display:block;margin:8px 0 3px;letter-spacing:-.04em}.makap-mat-metrics small{font-size:10px;color:#91a09f}.makap-mat-alert{background:linear-gradient(135deg,#fffaf0,#fff)!important;border-color:#f1dfbd!important}.makap-mat-alert strong{color:#bd7d28}.makap-mat-tabs{display:flex;gap:5px;background:#edf5f2;border:1px solid #dfece8;padding:5px;border-radius:10px 10px 0 0}.makap-mat-tabs button{border:0;background:transparent;color:#66817d;border-radius:7px;padding:10px 16px;font:600 11px "DM Sans";cursor:pointer}.makap-mat-tabs button i{margin-right:6px}.makap-mat-tabs button.is-active{background:#fff;color:var(--m-teal);box-shadow:0 2px 8px #1c645d12}.makap-mat-workspace{display:grid;grid-template-columns:minmax(370px,.9fr) minmax(520px,1.55fr);min-height:680px;background:#fff;border:1px solid var(--m-line);border-top:0;border-radius:0 0 14px 14px;overflow:hidden;box-shadow:var(--m-shadow)}.makap-mat-list-panel{border-right:1px solid var(--m-line);background:#fbfdfd}.makap-mat-list-head{padding:21px 22px 16px;border-bottom:1px solid var(--m-line)}.makap-mat-list-head strong{font:600 17px "Space Grotesk",sans-serif;display:block}.makap-mat-list-head span{font-size:10px;color:#91a09f;display:block;margin-top:3px}.makap-mat-search{display:flex;align-items:center;gap:8px;background:#f2f7f5;border:1px solid #e3eeea;border-radius:7px;padding:9px 11px;color:#8fa09c;margin-top:16px}.makap-mat-search input{border:0;background:transparent;outline:0;width:100%;font:11px "DM Sans";color:var(--m-ink)}.makap-mat-list-head select{border:1px solid #e3eeea;background:#f8fbfa;color:#66807c;border-radius:7px;padding:7px;width:100%;font-size:10px;margin-top:8px}.makap-mat-list{padding:8px}.makap-mat-row{width:100%;display:flex;align-items:center;gap:11px;border:0;border-radius:9px;background:transparent;padding:12px 10px;text-align:left;cursor:pointer;color:var(--m-ink);transition:.15s}.makap-mat-row:hover{background:#f0f7f4}.makap-mat-row.is-active{background:var(--m-mint);box-shadow:inset 3px 0 var(--m-teal)}.makap-mat-avatar,.makap-mat-detail-avatar{display:grid;place-items:center;border-radius:50%;background:#d5eee6;color:var(--m-teal);font-family:"Space Grotesk",sans-serif;font-weight:700}.makap-mat-avatar{width:35px;height:35px;font-size:10px;flex:none}.makap-mat-avatar.blocked{background:#fff0f0;color:#b96868}.makap-mat-avatar.pending{background:#fff5e4;color:#bd7d28}.makap-mat-avatar.ready{background:#e8f7ed;color:#4c9b73}.makap-mat-row-copy{flex:1;min-width:0}.makap-mat-row-copy strong,.makap-mat-row-copy span,.makap-mat-row-copy small{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.makap-mat-row-copy strong{font-size:12px;font-weight:700}.makap-mat-row-copy span{font-size:10px;color:var(--m-muted);margin-top:3px}.makap-mat-row-copy small{font-size:9px;color:#9aa9a7;margin-top:4px}.makap-mat-badge{border-radius:20px;padding:4px 7px;font-size:9px;font-weight:700;white-space:nowrap}.makap-mat-badge.ready{background:#e8f7ed;color:#4c9b73}.makap-mat-badge.blocked{background:#fff0f0;color:#b96868}.makap-mat-badge.pending{background:#fff5e4;color:#bd7d28}.makap-mat-badge.complete{background:#e9f1fb;color:#4d7eb2}.makap-mat-badge.draft{background:#eef4f3;color:#66817d}.makap-mat-detail{background:#fff}.makap-mat-detail-hero{padding:30px 33px 27px;background:linear-gradient(115deg,#f0faf6,#f4f9ff);border-bottom:1px solid var(--m-line);display:flex;align-items:center;gap:16px}.makap-mat-detail-avatar{width:62px;height:62px;font-size:18px;flex:none}.makap-mat-detail-avatar.blocked{background:#fff0f0;color:#b96868}.makap-mat-detail-avatar.pending{background:#fff5e4;color:#bd7d28}.makap-mat-detail-identity{min-width:0;flex:1}.makap-mat-detail-identity h2{font:600 24px "Space Grotesk",sans-serif;letter-spacing:-.04em;margin:7px 0 4px}.makap-mat-detail-identity p{color:var(--m-muted);font-size:11px;margin:0}.makap-mat-kicker{text-transform:uppercase;letter-spacing:.1em;color:#8ca09d;font-size:9px;font-weight:700}.makap-mat-detail-body{padding:4px 33px 30px}.makap-mat-readiness-card{margin-top:21px;border:1px solid #dcebe7;background:linear-gradient(135deg,#f5fcf9,#f7fbff);border-radius:10px;padding:18px 20px;display:flex;align-items:center;justify-content:space-between}.makap-mat-readiness-card.blocked{background:linear-gradient(135deg,#fff8f6,#fff);border-color:#f1d6d1}.makap-mat-readiness-card span,.makap-mat-readiness-card small{display:block}.makap-mat-readiness-card span{color:#819691;font-size:10px;text-transform:uppercase;letter-spacing:.09em;font-weight:700}.makap-mat-readiness-card strong{font:600 22px "Space Grotesk",sans-serif;display:block;margin:6px 0 2px}.makap-mat-readiness-card small{font-size:10px;color:#8fa19e}.makap-mat-readiness-card>i{font-size:22px;color:var(--m-teal)}.makap-mat-detail-section{padding:23px 0;border-bottom:1px solid #edf3f1}.makap-mat-detail-section:last-child{border-bottom:0}.makap-mat-section-title{text-transform:uppercase;letter-spacing:.12em;color:#8ca09d;font-weight:700;font-size:9px;margin-bottom:15px}.makap-mat-fields{display:grid;grid-template-columns:1fr 1fr;gap:17px 30px}.makap-mat-fields span{display:block;color:#93a29f;font-size:10px;margin-bottom:5px}.makap-mat-fields strong{display:block;font-size:12px;font-weight:600}.mat-top{margin-top:18px}.makap-mat-progress-label{display:flex;justify-content:space-between;font-size:11px;margin-bottom:7px}.makap-mat-progress-label b{color:var(--m-teal)}.makap-mat-progress{height:8px;background:#edf4f1;border-radius:20px;overflow:hidden}.makap-mat-progress i{display:block;height:100%;background:#70c4aa;border-radius:20px}.makap-mat-mini-row{display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-bottom:1px solid #f0f4f2;font-size:11px}.makap-mat-mini-row:last-child{border-bottom:0}.makap-mat-mini-row strong,.makap-mat-mini-row small{display:block}.makap-mat-mini-row small{font-size:9px;color:var(--m-muted);margin-top:3px}.makap-mat-mini-row>b{font-size:10px;color:#536f6b}.makap-mat-note{font-size:11px;color:#92a09e;padding:5px 0}.makap-mat-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:#95a3a1;padding:50px 25px;font-size:11px;min-height:180px}.makap-mat-empty strong{color:#6f8280;font-size:12px;margin:10px 0 4px}.makap-mat-empty span{max-width:250px;line-height:1.5}.makap-mat-detail-empty{min-height:650px}.makap-mat-empty-icon{display:grid;place-items:center;width:42px;height:42px;border-radius:12px;background:var(--m-mint);color:var(--m-teal);font-size:17px}.makap-loading-ring{width:22px;height:22px;border:2px solid #d9eae5;border-top-color:var(--m-teal);border-radius:50%;animation:ms .8s linear infinite}@keyframes ms{to{transform:rotate(360deg)}} +@media(max-width:1100px){.makap-mat-shell{padding:26px 20px 40px}.makap-mat-metrics{grid-template-columns:repeat(2,1fr)}.makap-mat-workspace{grid-template-columns:minmax(300px,.85fr) minmax(430px,1.3fr)}}@media(max-width:820px){.makap-mat-head{display:block}.makap-mat-actions{margin-top:18px}.makap-mat-tabs{overflow:auto}.makap-mat-tabs button{white-space:nowrap}.makap-mat-workspace{grid-template-columns:1fr}.makap-mat-list-panel{border-right:0;border-bottom:1px solid var(--m-line);max-height:390px}.makap-mat-detail-empty{min-height:320px}.makap-mat-detail-hero>a{display:none}}@media(max-width:560px){.makap-mat-metrics{grid-template-columns:1fr 1fr;gap:8px}.makap-mat-metrics>div{padding:13px}.makap-mat-metrics strong{font-size:22px}.makap-mat-detail-hero{padding:23px 20px}.makap-mat-detail-body{padding-left:20px;padding-right:20px}.makap-mat-fields{grid-template-columns:1fr}.makap-mat-detail-identity h2{font-size:20px}} diff --git a/makap_backend/makap_backend/page/makap_materials/makap_materials.js b/makap_backend/makap_backend/page/makap_materials/makap_materials.js new file mode 100644 index 0000000..ef0bbcf --- /dev/null +++ b/makap_backend/makap_backend/page/makap_materials/makap_materials.js @@ -0,0 +1 @@ +frappe.pages["makap-materials"].on_page_load=function(wrapper){const page=frappe.ui.make_app_page({parent:wrapper,title:"Materials & Inventory Control Center",single_column:true});page.main.html(`
Materials & inventory

Materials & Inventory Control Center

Track demand, supplier commitments, receipts, and material exceptions in one supply-readiness workspace.

Open requests—Demand awaiting fulfilment
Due requests—Needs date attention
Open purchase orders—Supplier commitments in flight
Receipts recorded—Inbound material documents
Select a supply recordChoose a request, purchase order, or receipt to inspect its material context.
`);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 tab=new URLSearchParams(location.search).get("tab")||"requests",active=null,timer;const esc=v=>frappe.utils.escape_html(String(v==null?"":v)),num=v=>new Intl.NumberFormat().format(Number(v||0)),money=v=>Number(v||0).toLocaleString(undefined,{maximumFractionDigits:0}),date=v=>v?frappe.datetime.str_to_user(v):"Not set",initials=v=>(String(v||"?").split(/\s+/).slice(0,2).map(x=>x[0]).join("")||"?").toUpperCase(),call=(m,a={})=>new Promise((ok,no)=>frappe.call({method:m,args:a,callback:r=>r.exc?no(r.exc):ok(r.message)})),tone=s=>({Completed:"complete",Submitted:"ready",Pending:"pending",Draft:"draft",Stopped:"blocked",Cancelled:"blocked","To Receive":"pending","To Receive and Bill":"pending"}[s]||"draft");const cfg={requests:["Material request queue","New Material Request","Search material requests..."],orders:["Purchase order queue","New Purchase Order","Search purchase orders..."],receipts:["Purchase receipt register","New Purchase Receipt","Search receipts..."]};function metrics(s){["open_requests","due_requests","open_orders","receipts"].forEach(k=>root.querySelector(`[data-metric="${k}"]`).textContent=num(s[k]))}function setTab(t){tab=t;active=null;root.querySelectorAll("[data-tab]").forEach(e=>e.classList.toggle("is-active",e.dataset.tab===t));let c=cfg[t];title.textContent=c[0];newLabel.textContent=c[1];search.placeholder=c[2];filter.innerHTML=t==="requests"?``:t==="orders"?``:``;load()}function row(r){let id=r.name,party=r.supplier||r.company||r.requested_by||"No party",sub=tab==="requests"?(r.material_request_type||"Material demand"):(r.status||"Draft"),meta=tab==="requests"?`${r.schedule_date?`Need ${date(r.schedule_date)}`:"No need date"} · ${r.set_warehouse||"No warehouse"}`:`${r.posting_date||r.transaction_date?date(r.posting_date||r.transaction_date):"No date"} · ${party}`,bad=r.status||"Draft";return ``}function render(rows){count.textContent=`${rows.length} record${rows.length===1?"":"s"}`;list.innerHTML=rows.length?rows.map(row).join(""):`
No records foundTry another search or status filter.
`;list.querySelectorAll("[data-name]").forEach(e=>e.addEventListener("click",()=>select(e.dataset.name)))}function head(kind,name,party,href,bad){return `
${esc(initials(name))}
${esc(kind)}

${esc(name)}

${esc(party||"No supplier or company")}

Open ERPNext form
`}function items(rows,type){return rows?.length?rows.map(r=>`
${esc(r.item_name||r.item_code||"Item")}${esc(r.item_code||"")} · ${esc(r.uom||"")}${r.makap_coverage_mode?` · ${esc(r.makap_coverage_mode)}`:""}${money(r.qty||r.received_qty)}
`).join(""):`
No line items available.
`}function renderRequest(o){active=o.name;detail.innerHTML=head("Material Request",o.name,o.company,`/app/material-request/${encodeURIComponent(o.name)}`,o.status)+`
Material demand${esc(o.status||"Draft")}${esc(o.material_request_type||"Purchase") } · Need date ${date(o.schedule_date)}
Request context
Company${esc(o.company||"Not set")}
Requested by${esc(o.requested_by||"Not set")}
Warehouse${esc(o.set_warehouse||"Not set")}
Transaction date${date(o.transaction_date)}
Material lines
${items(o.items)}
`;paint()}function renderOrder(o){active=o.name;let total=(o.items||[]).reduce((a,r)=>a+Number(r.qty||0),0),received=(o.items||[]).reduce((a,r)=>a+Number(r.received_qty||0),0),pct=total?Math.min(100,Math.round(received/total*100)):0;detail.innerHTML=head("Purchase Order",o.name,o.supplier,`/app/purchase-order/${encodeURIComponent(o.name)}`,o.status)+`
Supplier commitment${esc(o.status||"Draft")}${esc(o.supplier||"No supplier")} · Schedule ${date(o.schedule_date)}
Receipt progress
${money(received)} of ${money(total)} units received${pct}%
Supplier${esc(o.supplier||"Not set")}
Order value${esc(o.currency||"")} ${money(o.grand_total)}
Order date${date(o.transaction_date)}
Schedule date${date(o.schedule_date)}
Supply lines and controls
${items(o.items)}
`;paint()}function renderReceipt(o){active=o.name;detail.innerHTML=head("Purchase Receipt",o.name,o.supplier,`/app/purchase-receipt/${encodeURIComponent(o.name)}`,o.status)+`
Inbound receipt${esc(o.status||"Draft")}${esc(o.supplier||"No supplier")} · Posted ${date(o.posting_date)}
Receipt context
Supplier${esc(o.supplier||"Not set")}
Company${esc(o.company||"Not set")}
Posting date${date(o.posting_date)}
Receipt value${esc(o.currency||"")} ${money(o.grand_total)}
Received lines and exceptions
${(o.items||[]).map(r=>`
${esc(r.item_name||r.item_code||"Item")}${esc(r.item_code||"")} · PO ${esc(r.purchase_order||"Not linked")}${esc(r.makap_receipt_exception_type||"None")}
`).join("")||`
No receipt lines available.
`}
`;paint()}function paint(){root.querySelectorAll(".makap-mat-row").forEach(e=>e.classList.toggle("is-active",e.dataset.name===active))}async function select(name){active=name;detail.innerHTML=`
Loading recordFetching the latest supply details.
`;try{if(tab==="requests")renderRequest(await call("makap_backend.materials_api.get_material_request",{name}));if(tab==="orders")renderOrder(await call("makap_backend.materials_api.get_purchase_order",{name}));if(tab==="receipts")renderReceipt(await call("makap_backend.materials_api.get_purchase_receipt",{name}))}catch(e){detail.innerHTML=`
Could not load record${esc(e.message||"Please try again.")}
`}}async function load(){list.innerHTML=`
Loading records...
`;try{let rows=[];if(tab==="requests")rows=await call("makap_backend.materials_api.list_material_requests",{search:search.value,status:filter.value});if(tab==="orders")rows=await call("makap_backend.materials_api.list_purchase_orders",{search:search.value,status:filter.value});if(tab==="receipts")rows=await call("makap_backend.materials_api.list_purchase_receipts",{search:search.value,status:filter.value});render(rows||[]);if(!active&&rows?.[0])select(rows[0].name)}catch(e){list.innerHTML=`
Supply records unavailable${esc(e.message||"Check your permissions and try again.")}
`}}root.querySelectorAll("[data-tab]").forEach(e=>e.addEventListener("click",()=>setTab(e.dataset.tab)));search.addEventListener("input",()=>{clearTimeout(timer);timer=setTimeout(load,250)});filter.addEventListener("change",load);root.querySelector('[data-action="refresh"]').addEventListener("click",async()=>{metrics(await call("makap_backend.materials_api.get_materials_summary"));load()});root.querySelector('[data-action="new"]').addEventListener("click",()=>frappe.new_doc(tab==="requests"?"Material Request":tab==="orders"?"Purchase Order":"Purchase Receipt"));(async()=>{try{metrics(await call("makap_backend.materials_api.get_materials_summary"))}catch(e){}setTab(tab)})();}; diff --git a/makap_backend/makap_backend/page/makap_materials/makap_materials.json b/makap_backend/makap_backend/page/makap_materials/makap_materials.json new file mode 100644 index 0000000..be1c54d --- /dev/null +++ b/makap_backend/makap_backend/page/makap_materials/makap_materials.json @@ -0,0 +1 @@ +{"doctype":"Page","name":"makap-materials","module":"makap_backend","standard":"Yes","title":"MAKAP Materials & Inventory Control Center"} diff --git a/makap_backend/makap_backend/page/makap_opportunities/makap_opportunities.css b/makap_backend/makap_backend/page/makap_opportunities/makap_opportunities.css new file mode 100644 index 0000000..d3ab930 --- /dev/null +++ b/makap_backend/makap_backend/page/makap_opportunities/makap_opportunities.css @@ -0,0 +1,4 @@ +.makap-opps-shell{--opp-ink:#182f3a;--opp-muted:#71858c;--opp-line:#e2eceb;--opp-paper:#f7fbfb;--opp-teal:#0c766c;--opp-mint:#e2f4ef;--opp-shadow:0 18px 50px rgba(20,68,70,.07);max-width:1460px;margin:0 auto;padding:34px 42px 48px;color:var(--opp-ink);font-family:"DM Sans",sans-serif}.makap-opps-head{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:25px}.makap-opps-eyebrow{text-transform:uppercase;letter-spacing:.14em;font-size:10px;font-weight:700;color:#87a19e;margin-bottom:8px}.makap-opps-head h1{font:600 31px "Space Grotesk",sans-serif;letter-spacing:-.045em;margin:0 0 8px}.makap-opps-head p{font-size:13px;color:var(--opp-muted);margin:0}.makap-opps-actions{display:flex;gap:9px}.makap-opps-actions .btn-primary{background:var(--opp-teal);border-color:var(--opp-teal)}.makap-opps-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:13px;margin-bottom:17px}.makap-opps-metric{background:#fff;border:1px solid var(--opp-line);border-radius:10px;padding:17px 19px;box-shadow:var(--opp-shadow)}.makap-opps-metric-accent{background:linear-gradient(135deg,#f0faf6,#f4f8ff);border-color:#dcebe7}.makap-opps-metric span,.makap-opps-metric small{display:block}.makap-opps-metric span{font-size:10px;color:var(--opp-muted);font-weight:700;text-transform:uppercase;letter-spacing:.07em}.makap-opps-metric strong{font:600 27px "Space Grotesk",sans-serif;display:block;margin:8px 0 3px;letter-spacing:-.04em}.makap-opps-metric small{font-size:10px;color:#91a09f}.makap-opps-workspace{display:grid;grid-template-columns:minmax(330px,.9fr) minmax(490px,1.55fr);min-height:650px;background:#fff;border:1px solid var(--opp-line);border-radius:14px;overflow:hidden;box-shadow:var(--opp-shadow)}.makap-opps-list-panel{border-right:1px solid var(--opp-line);background:#fbfdfd}.makap-opps-list-head{padding:21px 22px 16px;border-bottom:1px solid var(--opp-line)}.makap-opps-list-title strong{font:600 17px "Space Grotesk",sans-serif;display:block}.makap-opps-list-title span{font-size:10px;color:#91a09f;display:block;margin-top:3px}.makap-opps-filters{display:flex;gap:8px;margin-top:16px}.makap-opps-search{display:flex;align-items:center;gap:8px;background:#f2f7f5;border:1px solid #e3eeea;border-radius:7px;padding:9px 11px;color:#8fa09c;flex:1}.makap-opps-search input{border:0;background:transparent;outline:0;width:100%;font:11px "DM Sans";color:var(--opp-ink)}.makap-opps-filters select{border:1px solid #e3eeea;background:#f8fbfa;color:#66807c;border-radius:7px;padding:0 7px;font-size:10px;max-width:105px}.makap-opps-list{padding:8px}.makap-opp-row{width:100%;display:flex;align-items:center;gap:11px;border:0;border-radius:9px;background:transparent;padding:12px 10px;text-align:left;cursor:pointer;color:var(--opp-ink);transition:.15s}.makap-opp-row:hover{background:#f0f7f4}.makap-opp-row.is-active{background:var(--opp-mint);box-shadow:inset 3px 0 var(--opp-teal)}.makap-opp-avatar,.makap-opp-detail-avatar{display:grid;place-items:center;border-radius:50%;background:#d5eee6;color:var(--opp-teal);font-family:"Space Grotesk",sans-serif;font-weight:700}.makap-opp-avatar{width:35px;height:35px;font-size:11px;flex:none}.makap-opp-row-copy{flex:1;min-width:0}.makap-opp-row-copy strong,.makap-opp-row-copy span,.makap-opp-row-copy small{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.makap-opp-row-copy strong{font-size:12px;font-weight:700}.makap-opp-row-copy span{font-size:10px;color:var(--opp-muted);margin-top:3px}.makap-opp-row-copy small{font-size:9px;color:#9aa9a7;margin-top:4px}.makap-opp-row-side{display:flex;flex-direction:column;align-items:flex-end;gap:7px;color:#a7b7b4}.makap-opp-status{border-radius:20px;padding:4px 7px;font-size:9px;font-weight:700;white-space:nowrap}.makap-opp-status.open{background:#e8f7ed;color:#4c9b73}.makap-opp-status.quotation{background:#fff5e4;color:#bd7d28}.makap-opp-status.converted{background:#e9f1fb;color:#4d7eb2}.makap-opp-status.lost,.makap-opp-status.closed{background:#fff0f0;color:#b96868}.makap-opp-status.replied{background:#f5f0fb;color:#8067a9}.makap-opp-detail{background:#fff}.makap-opp-detail-hero{padding:30px 33px 27px;background:linear-gradient(115deg,#f0faf6,#f4f9ff);border-bottom:1px solid var(--opp-line);display:flex;align-items:center;gap:16px}.makap-opp-detail-avatar{width:62px;height:62px;font-size:20px}.makap-opp-detail-identity{min-width:0;flex:1}.makap-opp-detail-identity h2{font:600 24px "Space Grotesk",sans-serif;letter-spacing:-.04em;margin:7px 0 4px}.makap-opp-detail-identity p{color:var(--opp-muted);font-size:11px;margin:0}.makap-opp-detail-body{padding:4px 33px 30px}.makap-opp-value-card{margin-top:21px;border:1px solid #dcebe7;background:linear-gradient(135deg,#f5fcf9,#f7fbff);border-radius:10px;padding:18px 20px;display:flex;align-items:center;justify-content:space-between}.makap-opp-value-card>div:first-child span,.makap-opp-value-card>div:first-child small{display:block}.makap-opp-value-card span{color:#819691;font-size:10px;text-transform:uppercase;letter-spacing:.09em;font-weight:700}.makap-opp-value-card strong{font:600 29px "Space Grotesk",sans-serif;display:block;margin:6px 0 2px;letter-spacing:-.04em}.makap-opp-value-card small{font-size:10px;color:#8fa19e}.makap-probability{display:flex;flex-direction:column;align-items:center;gap:5px}.makap-probability-ring{width:55px;height:55px;border-radius:50%;background:conic-gradient(var(--opp-teal) var(--progress),#dcece8 0);display:grid;place-items:center;position:relative}.makap-probability-ring::after{content:"";position:absolute;width:43px;height:43px;border-radius:50%;background:#f7fcfa}.makap-probability-ring span{position:relative;z-index:1;font:600 12px "Space Grotesk";color:var(--opp-teal)}.makap-probability small{font-size:9px;color:#8fa19e}.makap-opp-detail-section{padding:23px 0;border-bottom:1px solid #edf3f1}.makap-opp-detail-section:last-child{border-bottom:0}.makap-opp-section-title{text-transform:uppercase;letter-spacing:.12em;color:#8ca09d;font-weight:700;font-size:9px;margin-bottom:15px}.makap-opp-timeline{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.makap-opp-timeline span,.makap-opp-fields span{display:block;color:#93a29f;font-size:10px;margin-bottom:5px}.makap-opp-timeline strong,.makap-opp-fields strong{display:block;font-size:12px;font-weight:600}.makap-opp-fields{display:grid;grid-template-columns:1fr 1fr;gap:17px 30px}.makap-opp-fields a{color:var(--opp-teal);text-decoration:none}.makap-opp-item{display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-bottom:1px solid #f0f4f2;font-size:11px}.makap-opp-item:last-child{border-bottom:0}.makap-opp-item strong,.makap-opp-item small{display:block}.makap-opp-item small{color:var(--opp-muted);font-size:9px;margin-top:3px}.makap-opp-item>b{font-size:11px;color:#536f6b}.makap-opp-note{color:#92a09e;font-size:11px;padding:5px 0}.makap-opp-note i{margin-right:7px}.makap-opp-remarks{font-size:12px;line-height:1.6;color:#637875;margin:0;white-space:pre-wrap}.makap-opps-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:#95a3a1;padding:50px 25px;font-size:11px;min-height:180px}.makap-opps-empty strong{color:#6f8280;font-size:12px;margin:10px 0 4px}.makap-opps-empty span{max-width:240px;line-height:1.5}.makap-opps-detail-empty{min-height:620px}.makap-opps-empty-icon{display:grid;place-items:center;width:42px;height:42px;border-radius:12px;background:var(--opp-mint);color:var(--opp-teal);font-size:17px}.makap-loading-ring{width:22px;height:22px;border:2px solid #d9eae5;border-top-color:var(--opp-teal);border-radius:50%;animation:opp-spin .8s linear infinite}@keyframes opp-spin{to{transform:rotate(360deg)}} +@media(max-width:1050px){.makap-opps-shell{padding:26px 20px 40px}.makap-opps-metrics{grid-template-columns:repeat(2,1fr)}.makap-opps-workspace{grid-template-columns:minmax(290px,.85fr) minmax(420px,1.3fr)}} +@media(max-width:800px){.makap-opps-head{display:block}.makap-opps-actions{margin-top:18px}.makap-opps-workspace{grid-template-columns:1fr}.makap-opps-list-panel{border-right:0;border-bottom:1px solid var(--opp-line);max-height:390px}.makap-opps-detail-empty{min-height:320px}.makap-opp-detail-hero>a{display:none}} +@media(max-width:560px){.makap-opps-metrics{grid-template-columns:1fr 1fr;gap:8px}.makap-opps-metric{padding:13px}.makap-opps-metric strong{font-size:22px}.makap-opps-filters{display:block}.makap-opps-search{margin-bottom:8px}.makap-opps-filters select{height:32px;width:100%;max-width:none}.makap-opp-detail-hero{padding:23px 20px}.makap-opp-detail-body{padding-left:20px;padding-right:20px}.makap-opp-fields,.makap-opp-timeline{grid-template-columns:1fr 1fr}.makap-opp-detail-identity h2{font-size:20px}} diff --git a/makap_backend/makap_backend/page/makap_opportunities/makap_opportunities.js b/makap_backend/makap_backend/page/makap_opportunities/makap_opportunities.js new file mode 100644 index 0000000..28b511b --- /dev/null +++ b/makap_backend/makap_backend/page/makap_opportunities/makap_opportunities.js @@ -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(` +
+
+
+
Customer & commercial
+

Opportunities

+

Move qualified customer demand from first conversation to a committed quotation.

+
+
+ + +
+
+ +
+
Active pipeline—Open commercial opportunities
+
Pipeline value—Current opportunity amount
+
Weighted value—Probability-adjusted outlook
+
Quotation stage—Ready for commercial follow-up
+
+ +
+ +
+
Select an opportunityChoose a pipeline record to inspect value, stage, customer, and next commercial action.
+
+
+
+ `); + + 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 = `
No opportunities foundTry a different customer, stage, or status.
`; + return; + } + list.innerHTML = rows.map((row) => ` + + `).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) => `
${esc(item.item_name || item.item_code || "Item")}${esc(item.item_code || "")}${esc(item.qty || 0)} ${esc(item.uom || "")}
`).join("") : `
No products or services added yet.
`; + const linkedContact = opportunity.contact_person ? `${esc(opportunity.contact_person)}` : "Not linked"; + detail.innerHTML = ` +
+
${esc(initials(opportunity.party_name || opportunity.name))}
+
${esc(opportunity.status || "Open")}

${esc(opportunity.party_name || "Unassigned customer")}

${esc(opportunity.name)}${opportunity.opportunity_type ? ` · ${esc(opportunity.opportunity_type)}` : ""}

+ Open ERPNext form +
+
+
Opportunity value${money(opportunity.opportunity_amount)}Weighted forecast: ${money(Number(opportunity.opportunity_amount || 0) * probability / 100)}
${probability}%
Probability
+
Commercial timeline
Created / opened${date(opportunity.transaction_date)}
Expected close${date(opportunity.expected_closing_date)}
Sales stage${esc(opportunity.sales_stage || "Not set")}
+
Customer and contact
Customer${opportunity.party_name ? `${esc(opportunity.party_name)}` : "Not linked"}
Contact${linkedContact}
Territory${esc(opportunity.territory || "Not set")}
Owner${esc(opportunity.owner || "Not set")}
+
Products or services
${itemMarkup}
+ ${opportunity.remarks ? `
Remarks

${esc(opportunity.remarks)}

` : ""} +
+ `; + 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 = `
Loading opportunityFetching the latest pipeline details.
`; + try { renderDetail(await call("makap_backend.opportunity_api.get_opportunity", {name})); } catch (error) { detail.innerHTML = `
Could not load opportunity${esc(error.message || "Please try again.")}
`; } + } + + async function loadWorkspace() { + list.innerHTML = `
Loading opportunities...
`; + 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 = `
Opportunities unavailable${esc(error.message || "Check your permissions and try again.")}
`; } + } + + 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(); +}; diff --git a/makap_backend/makap_backend/page/makap_opportunities/makap_opportunities.json b/makap_backend/makap_backend/page/makap_opportunities/makap_opportunities.json new file mode 100644 index 0000000..17cd261 --- /dev/null +++ b/makap_backend/makap_backend/page/makap_opportunities/makap_opportunities.json @@ -0,0 +1,7 @@ +{ + "doctype": "Page", + "name": "makap-opportunities", + "module": "makap_backend", + "standard": "Yes", + "title": "MAKAP Opportunities" +} diff --git a/makap_backend/makap_backend/page/makap_production/makap_production.css b/makap_backend/makap_backend/page/makap_production/makap_production.css new file mode 100644 index 0000000..94f7ff5 --- /dev/null +++ b/makap_backend/makap_backend/page/makap_production/makap_production.css @@ -0,0 +1,4 @@ +.makap-prod-shell{--p-ink:#19343b;--p-muted:#71868b;--p-line:#e1ecea;--p-teal:#0c766c;--p-mint:#e3f5ef;--p-shadow:0 18px 50px rgba(20,68,70,.07);max-width:1500px;margin:0 auto;padding:34px 42px 48px;color:var(--p-ink);font-family:"DM Sans",sans-serif}.makap-prod-head{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:25px}.makap-prod-eyebrow{text-transform:uppercase;letter-spacing:.14em;font-size:10px;font-weight:700;color:#87a19e;margin-bottom:8px}.makap-prod-head h1{font:600 31px "Space Grotesk",sans-serif;letter-spacing:-.045em;margin:0 0 8px}.makap-prod-head p{font-size:13px;color:var(--p-muted);margin:0;max-width:720px}.makap-prod-actions{display:flex;gap:9px}.makap-prod-actions .btn-primary{background:var(--p-teal);border-color:var(--p-teal)}.makap-prod-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:13px;margin-bottom:17px}.makap-prod-metrics>div{background:#fff;border:1px solid var(--p-line);border-radius:10px;padding:17px 19px;box-shadow:var(--p-shadow)}.makap-prod-metrics span,.makap-prod-metrics small{display:block}.makap-prod-metrics span{font-size:10px;color:var(--p-muted);font-weight:700;text-transform:uppercase;letter-spacing:.07em}.makap-prod-metrics strong{font:600 27px "Space Grotesk",sans-serif;display:block;margin:8px 0 3px;letter-spacing:-.04em}.makap-prod-metrics small{font-size:10px;color:#91a09f}.makap-prod-alert{background:linear-gradient(135deg,#fffaf0,#fff)!important;border-color:#f1dfbd!important}.makap-prod-alert strong{color:#bd7d28}.makap-prod-tabs{display:flex;gap:5px;background:#edf5f2;border:1px solid #dfece8;padding:5px;border-radius:10px 10px 0 0}.makap-prod-tabs button{border:0;background:transparent;color:#66817d;border-radius:7px;padding:10px 16px;font:600 11px "DM Sans";cursor:pointer}.makap-prod-tabs button i{margin-right:6px}.makap-prod-tabs button.is-active{background:#fff;color:var(--p-teal);box-shadow:0 2px 8px #1c645d12}.makap-prod-workspace{display:grid;grid-template-columns:minmax(370px,.9fr) minmax(520px,1.55fr);min-height:680px;background:#fff;border:1px solid var(--p-line);border-top:0;border-radius:0 0 14px 14px;overflow:hidden;box-shadow:var(--p-shadow)}.makap-prod-list-panel{border-right:1px solid var(--p-line);background:#fbfdfd}.makap-prod-list-head{padding:21px 22px 16px;border-bottom:1px solid var(--p-line)}.makap-prod-list-head strong{font:600 17px "Space Grotesk",sans-serif;display:block}.makap-prod-list-head span{font-size:10px;color:#91a09f;display:block;margin-top:3px}.makap-prod-search{display:flex;align-items:center;gap:8px;background:#f2f7f5;border:1px solid #e3eeea;border-radius:7px;padding:9px 11px;color:#8fa09c;margin-top:16px}.makap-prod-search input{border:0;background:transparent;outline:0;width:100%;font:11px "DM Sans";color:var(--p-ink)}.makap-prod-list-head select{border:1px solid #e3eeea;background:#f8fbfa;color:#66807c;border-radius:7px;padding:7px;width:100%;font-size:10px;margin-top:8px}.makap-prod-list{padding:8px}.makap-prod-row{width:100%;display:flex;align-items:center;gap:11px;border:0;border-radius:9px;background:transparent;padding:12px 10px;text-align:left;cursor:pointer;color:var(--p-ink);transition:.15s}.makap-prod-row:hover{background:#f0f7f4}.makap-prod-row.is-active{background:var(--p-mint);box-shadow:inset 3px 0 var(--p-teal)}.makap-prod-avatar,.makap-prod-detail-avatar{display:grid;place-items:center;border-radius:50%;background:#d5eee6;color:var(--p-teal);font-family:"Space Grotesk",sans-serif;font-weight:700}.makap-prod-avatar{width:35px;height:35px;font-size:10px;flex:none}.makap-prod-avatar.blocked{background:#fff0f0;color:#b96868}.makap-prod-avatar.planned{background:#f5f0fb;color:#8067a9}.makap-prod-avatar.progress{background:#edf5fb;color:#5384a5}.makap-prod-row-copy{flex:1;min-width:0}.makap-prod-row-copy strong,.makap-prod-row-copy span,.makap-prod-row-copy small{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.makap-prod-row-copy strong{font-size:12px;font-weight:700}.makap-prod-row-copy span{font-size:10px;color:var(--p-muted);margin-top:3px}.makap-prod-row-copy small{font-size:9px;color:#9aa9a7;margin-top:4px}.makap-prod-badge{border-radius:20px;padding:4px 7px;font-size:9px;font-weight:700;white-space:nowrap}.makap-prod-badge.ready{background:#e8f7ed;color:#4c9b73}.makap-prod-badge.blocked{background:#fff0f0;color:#b96868}.makap-prod-badge.unchecked{background:#fff5e4;color:#bd7d28}.makap-prod-badge.progress{background:#edf5fb;color:#5384a5}.makap-prod-badge.complete{background:#e9f1fb;color:#4d7eb2}.makap-prod-badge.planned{background:#f5f0fb;color:#8067a9}.makap-prod-chevron{color:#a7b7b4}.makap-prod-detail{background:#fff}.makap-prod-detail-hero{padding:30px 33px 27px;background:linear-gradient(115deg,#f0faf6,#f4f9ff);border-bottom:1px solid var(--p-line);display:flex;align-items:center;gap:16px}.makap-prod-detail-avatar{width:62px;height:62px;font-size:19px;flex:none}.makap-prod-detail-avatar.blocked{background:#fff0f0;color:#b96868}.makap-prod-detail-avatar.ready{background:#e8f7ed;color:#4c9b73}.makap-prod-detail-identity{min-width:0;flex:1}.makap-prod-detail-identity h2{font:600 24px "Space Grotesk",sans-serif;letter-spacing:-.04em;margin:7px 0 4px}.makap-prod-detail-identity p{color:var(--p-muted);font-size:11px;margin:0}.makap-prod-kicker{text-transform:uppercase;letter-spacing:.1em;color:#8ca09d;font-size:9px;font-weight:700}.makap-prod-detail-body{padding:4px 33px 30px}.makap-prod-readiness-card{margin-top:21px;border:1px solid #dcebe7;background:linear-gradient(135deg,#f5fcf9,#f7fbff);border-radius:10px;padding:18px 20px;display:flex;align-items:center;justify-content:space-between}.makap-prod-readiness-card.blocked{background:linear-gradient(135deg,#fff8f6,#fff);border-color:#f1d6d1}.makap-prod-readiness-card span,.makap-prod-readiness-card small{display:block}.makap-prod-readiness-card span{color:#819691;font-size:10px;text-transform:uppercase;letter-spacing:.09em;font-weight:700}.makap-prod-readiness-card strong{font:600 22px "Space Grotesk",sans-serif;display:block;margin:6px 0 2px}.makap-prod-readiness-card small{font-size:10px;color:#8fa19e}.makap-prod-readiness-card>i{font-size:22px;color:var(--p-teal)}.makap-prod-readiness-card.blocked>i{color:#b96868}.makap-prod-detail-section{padding:23px 0;border-bottom:1px solid #edf3f1}.makap-prod-detail-section:last-child{border-bottom:0}.makap-prod-section-title{text-transform:uppercase;letter-spacing:.12em;color:#8ca09d;font-weight:700;font-size:9px;margin-bottom:15px}.makap-prod-fields,.makap-prod-check-grid{display:grid;grid-template-columns:1fr 1fr;gap:17px 30px}.makap-prod-fields span,.makap-prod-check-grid span{display:block;color:#93a29f;font-size:10px;margin-bottom:5px}.makap-prod-fields strong{display:block;font-size:12px;font-weight:600}.makap-prod-fields a{color:var(--p-teal);text-decoration:none}.makap-prod-check-grid>div{border:1px solid #edf3f1;border-radius:7px;padding:10px}.makap-prod-check-grid b{font-size:11px}.makap-prod-progress-label{display:flex;justify-content:space-between;font-size:11px;margin-bottom:7px}.makap-prod-progress-label b{color:var(--p-teal)}.makap-prod-progress{height:8px;background:#edf4f1;border-radius:20px;overflow:hidden}.makap-prod-progress i{display:block;height:100%;background:#70c4aa;border-radius:20px}.makap-prod-timeline{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-top:17px}.makap-prod-timeline span{display:block;color:#93a29f;font-size:10px;margin-bottom:5px}.makap-prod-timeline strong{font-size:12px}.makap-prod-mini-row{display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-bottom:1px solid #f0f4f2;font-size:11px}.makap-prod-mini-row:last-child{border-bottom:0}.makap-prod-mini-row strong,.makap-prod-mini-row small{display:block}.makap-prod-mini-row small{font-size:9px;color:var(--p-muted);margin-top:3px}.makap-prod-mini-row>b{font-size:10px;color:#536f6b}.makap-prod-note{font-size:11px;color:#92a09e;padding:5px 0}.makap-prod-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:#95a3a1;padding:50px 25px;font-size:11px;min-height:180px}.makap-prod-empty strong{color:#6f8280;font-size:12px;margin:10px 0 4px}.makap-prod-empty span{max-width:250px;line-height:1.5}.makap-prod-detail-empty{min-height:650px}.makap-prod-empty-icon{display:grid;place-items:center;width:42px;height:42px;border-radius:12px;background:var(--p-mint);color:var(--p-teal);font-size:17px}.makap-loading-ring{width:22px;height:22px;border:2px solid #d9eae5;border-top-color:var(--p-teal);border-radius:50%;animation:pspin .8s linear infinite}@keyframes pspin{to{transform:rotate(360deg)}} +@media(max-width:1100px){.makap-prod-shell{padding:26px 20px 40px}.makap-prod-metrics{grid-template-columns:repeat(2,1fr)}.makap-prod-workspace{grid-template-columns:minmax(300px,.85fr) minmax(430px,1.3fr)}} +@media(max-width:820px){.makap-prod-head{display:block}.makap-prod-actions{margin-top:18px}.makap-prod-tabs{overflow:auto}.makap-prod-tabs button{white-space:nowrap}.makap-prod-workspace{grid-template-columns:1fr}.makap-prod-list-panel{border-right:0;border-bottom:1px solid var(--p-line);max-height:390px}.makap-prod-detail-empty{min-height:320px}.makap-prod-detail-hero>a{display:none}} +@media(max-width:560px){.makap-prod-metrics{grid-template-columns:1fr 1fr;gap:8px}.makap-prod-metrics>div{padding:13px}.makap-prod-metrics strong{font-size:22px}.makap-prod-detail-hero{padding:23px 20px}.makap-prod-detail-body{padding-left:20px;padding-right:20px}.makap-prod-fields,.makap-prod-check-grid{grid-template-columns:1fr}.makap-prod-timeline{grid-template-columns:1fr 1fr}.makap-prod-detail-identity h2{font-size:20px}} diff --git a/makap_backend/makap_backend/page/makap_production/makap_production.js b/makap_backend/makap_backend/page/makap_production/makap_production.js new file mode 100644 index 0000000..473daa7 --- /dev/null +++ b/makap_backend/makap_backend/page/makap_production/makap_production.js @@ -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(` +
Production & planning

Production Control Center

Release the right work with technical, material, and process readiness visible at a glance.

+
Active Work Orders—Current execution load
Ready to release—All applicable checks ready
Blocked Work Orders—Needs technical or supply action
Due or overdue—Schedule pressure
+ +
Select a production recordChoose a Work Order, Production Plan, or Operation to inspect its readiness and execution context.
+ `); + 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" ? `` : ``; loadRecords(); } + function rowShell(active, name, avatar, headline, sub, meta, badge, badgeClass) { return ``; } + 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("") : `
No Work Orders foundTry a different search or readiness filter.
`; 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("") : `
No Production Plans foundTry a different search or status.
`; 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("") : `
No Operations foundTry a different search term.
`; list.querySelectorAll("[data-name]").forEach((e) => e.addEventListener("click", () => selectRecord(e.dataset.name))); } + function detailHeader(avatar, kicker, headline, sub, href, badge, cls) { return `
${esc(avatar)}
${esc(kicker)}

${esc(headline)}

${esc(sub)}

Open ERPNext form
`; } + 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) => `
${esc(x.operation || "Operation")}${esc(x.workstation || "No workstation")}${esc(x.status || "Pending")}
`).join("") : `
No operation rows available.
`; 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)) + `
Manufacturing readiness${esc(readiness)}${esc(blockers || (readiness === "Ready" ? "All applicable checks are ready." : "Readiness review is required."))}
Demand and traceability
Customer${o.customer ? `${esc(o.customer)}` : "Not linked"}
Sales Order${o.sales_order ? `${esc(o.sales_order)}` : "Not linked"}
Packaging Specification${o.makap_packaging_specification ? `${esc(o.makap_packaging_specification)}` : "Not linked"}
Specification revision${esc(o.makap_specification_revision || "Not set")}
Readiness dimensions
Manufacturing${esc(o.makap_manufacturing_readiness_status || "Not Checked")}
BOM compatibility${esc(o.makap_bom_compatibility_status || "Not Checked")}
Process compatibility${esc(o.makap_process_compatibility_status || "Not Checked")}
Tooling${esc(o.makap_tooling_readiness_status || "Not Checked")}
Materials${esc(o.makap_material_availability_status || "Not Checked")}
Material close${esc(o.makap_material_close_status || "Open")}
Execution progress
${esc(o.status || "Not Started")}${completion}%
Planned start${date(o.planned_start_date)}
Planned end${date(o.planned_end_date)}
Quantity${money(o.qty)} / ${money(o.produced_qty)}
Operations
${ops}
`; 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) => `
${esc(x.item_name || x.item_code || "Item")}${esc(x.item_code || "")} · ${esc(x.coverage_mode || "Coverage not set")}${money(x.planned_qty)}
`).join("") : `
No plan items found.
`; 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)) + `
Planning status${esc(p.status || "Draft")}${esc(p.get_items_from || "Demand source not set")}
Plan context
Posting date${date(p.posting_date)}
Company${esc(p.company || "Not set")}
Demand source${esc(p.get_items_from || "Not set")}
Sales Order${esc(p.sales_order || "Not set")}
Planned items
${items}
`; 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") + `
Process classification${esc(o.makap_process_type || "Not classified")}${esc(o.description || "No operation description")}
Operation parameters
Workstation${esc(o.workstation || "Not set")}
Time in minutes${esc(o.time_in_mins || "Not set")}
Batch size${esc(o.batch_size || "Not set")}
Hour rate${money(o.hour_rate)}
`; root.querySelectorAll(".makap-prod-row").forEach((e) => e.classList.toggle("is-active", e.dataset.name === activeName)); } + async function selectRecord(name) { activeName = name; detail.innerHTML = `
Loading recordFetching the latest production details.
`; 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 = `
Could not load record${esc(e.message || "Please try again.")}
`; } } + async function loadRecords() { list.innerHTML = `
Loading records...
`; 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 = `
Production records unavailable${esc(e.message || "Check your permissions and try again.")}
`; } } + 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); })(); +}; diff --git a/makap_backend/makap_backend/page/makap_production/makap_production.json b/makap_backend/makap_backend/page/makap_production/makap_production.json new file mode 100644 index 0000000..91ef26c --- /dev/null +++ b/makap_backend/makap_backend/page/makap_production/makap_production.json @@ -0,0 +1,7 @@ +{ + "doctype": "Page", + "name": "makap-production", + "module": "makap_backend", + "standard": "Yes", + "title": "MAKAP Production Control Center" +} diff --git a/makap_backend/makap_backend/page/makap_quality/makap_quality.css b/makap_backend/makap_backend/page/makap_quality/makap_quality.css new file mode 100644 index 0000000..c81bde8 --- /dev/null +++ b/makap_backend/makap_backend/page/makap_quality/makap_quality.css @@ -0,0 +1,2 @@ +.makap-q-shell{--q-ink:#19343b;--q-muted:#71868b;--q-line:#e1ecea;--q-teal:#0c766c;--q-mint:#e3f5ef;--q-shadow:0 18px 50px rgba(20,68,70,.07);max-width:1500px;margin:0 auto;padding:34px 42px 48px;color:var(--q-ink);font-family:"DM Sans",sans-serif}.makap-q-head{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:25px}.makap-q-eyebrow,.makap-q-kicker{ text-transform:uppercase;letter-spacing:.14em;font-size:10px;font-weight:700;color:#87a19e}.makap-q-head h1{font:600 31px "Space Grotesk",sans-serif;letter-spacing:-.045em;margin:8px 0}.makap-q-head p{font-size:13px;color:var(--q-muted);margin:0}.makap-q-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:13px;margin-bottom:17px}.makap-q-metrics>div{background:#fff;border:1px solid var(--q-line);border-radius:10px;padding:17px 19px;box-shadow:var(--q-shadow)}.makap-q-metrics span,.makap-q-metrics small{display:block}.makap-q-metrics span{font-size:10px;color:var(--q-muted);font-weight:700;text-transform:uppercase;letter-spacing:.07em}.makap-q-metrics strong{font:600 27px "Space Grotesk",sans-serif;display:block;margin:8px 0 3px}.makap-q-metrics small{font-size:10px;color:#91a09f}.makap-q-metrics .hold{background:#fffaf0;border-color:#f1dfbd}.makap-q-metrics .hold strong{color:#bd7d28}.makap-q-metrics .reject{background:#fff8f6;border-color:#f1d6d1}.makap-q-metrics .reject strong{color:#b96868}.makap-q-tabs{display:flex;gap:5px;background:#edf5f2;border:1px solid #dfece8;padding:5px;border-radius:10px 10px 0 0}.makap-q-tabs button{border:0;background:transparent;color:#66817d;border-radius:7px;padding:10px 15px;font:600 11px "DM Sans";cursor:pointer}.makap-q-tabs button i{margin-right:6px}.makap-q-tabs button.active{background:#fff;color:var(--q-teal);box-shadow:0 2px 8px #1c645d12}.makap-q-work{display:grid;grid-template-columns:minmax(370px,.9fr) minmax(520px,1.55fr);min-height:680px;background:#fff;border:1px solid var(--q-line);border-top:0;border-radius:0 0 14px 14px;overflow:hidden;box-shadow:var(--q-shadow)}.makap-q-listpanel{border-right:1px solid var(--q-line);background:#fbfdfd}.makap-q-listhead{padding:21px 22px 16px;border-bottom:1px solid var(--q-line)}.makap-q-listhead strong{font:600 17px "Space Grotesk",sans-serif;display:block}.makap-q-listhead span{font-size:10px;color:#91a09f;display:block;margin-top:3px}.makap-q-search{display:flex;gap:8px;align-items:center;background:#f2f7f5;border:1px solid #e3eeea;border-radius:7px;padding:9px 11px;color:#8fa09c;margin-top:16px}.makap-q-search input{border:0;background:transparent;outline:0;width:100%;font:11px "DM Sans"}.makap-q-listhead select{border:1px solid #e3eeea;background:#f8fbfa;color:#66807c;border-radius:7px;padding:7px;width:100%;font-size:10px;margin-top:8px}.makap-q-list{padding:8px}.makap-q-row{width:100%;display:flex;align-items:center;gap:11px;border:0;border-radius:9px;background:transparent;padding:12px 10px;text-align:left;cursor:pointer;color:var(--q-ink)}.makap-q-row:hover,.makap-q-row.active{background:var(--q-mint);box-shadow:inset 3px 0 var(--q-teal)}.makap-q-avatar,.makap-q-detailavatar{display:grid;place-items:center;border-radius:50%;background:#d5eee6;color:var(--q-teal);font-family:"Space Grotesk",sans-serif;font-weight:700}.makap-q-avatar{width:35px;height:35px;font-size:10px;flex:none}.makap-q-avatar.blocked,.makap-q-detailavatar.blocked{background:#fff0f0;color:#b96868}.makap-q-avatar.exception,.makap-q-detailavatar.exception{background:#fff5e4;color:#bd7d28}.makap-q-copy{flex:1;min-width:0}.makap-q-copy strong,.makap-q-copy span,.makap-q-copy small{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.makap-q-copy strong{font-size:12px}.makap-q-copy span{font-size:10px;color:var(--q-muted);margin-top:3px}.makap-q-copy small{font-size:9px;color:#9aa9a7;margin-top:4px}.makap-q-badge{border-radius:20px;padding:4px 7px;font-size:9px;white-space:nowrap}.makap-q-badge.blocked{background:#fff0f0;color:#b96868}.makap-q-badge.exception{background:#fff5e4;color:#bd7d28}.makap-q-badge.released{background:#e8f7ed;color:#4c9b73}.makap-q-badge.pending{background:#eef4f3;color:#66817d}.makap-q-detail{background:#fff}.makap-q-hero{padding:30px 33px 27px;background:linear-gradient(115deg,#f0faf6,#f4f9ff);border-bottom:1px solid var(--q-line);display:flex;align-items:center;gap:16px}.makap-q-detailavatar{width:62px;height:62px;font-size:18px;flex:none}.makap-q-ident{flex:1;min-width:0}.makap-q-ident h2{font:600 24px "Space Grotesk",sans-serif;letter-spacing:-.04em;margin:7px 0 4px}.makap-q-ident p{color:var(--q-muted);font-size:11px;margin:0}.makap-q-body{padding:4px 33px 30px}.makap-q-state{margin-top:21px;border:1px solid #dcebe7;background:#f5fcf9;border-radius:10px;padding:18px 20px;display:flex;justify-content:space-between;align-items:center}.makap-q-state.blocked{background:#fff8f6;border-color:#f1d6d1}.makap-q-state.exception{background:#fffaf0;border-color:#f1dfbd}.makap-q-state span,.makap-q-state small{display:block}.makap-q-state span{font-size:10px;text-transform:uppercase;letter-spacing:.09em;color:#819691;font-weight:700}.makap-q-state strong{font:600 22px "Space Grotesk",sans-serif;display:block;margin:6px 0 2px}.makap-q-state small{font-size:10px;color:#8fa19e}.makap-q-state i{font-size:22px;color:var(--q-teal)}.makap-q-state.blocked i{color:#b96868}.makap-q-section{padding:23px 0;border-bottom:1px solid #edf3f1}.makap-q-sectiontitle{text-transform:uppercase;letter-spacing:.12em;color:#8ca09d;font-weight:700;font-size:9px;margin-bottom:15px}.makap-q-fields{display:grid;grid-template-columns:1fr 1fr;gap:17px 30px}.makap-q-fields span{display:block;color:#93a29f;font-size:10px;margin-bottom:5px}.makap-q-fields strong{display:block;font-size:12px}.makap-q-fields a{color:var(--q-teal);text-decoration:none}.makap-q-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:#95a3a1;padding:50px 25px;font-size:11px;min-height:180px}.makap-q-empty strong{color:#6f8280;font-size:12px;margin:10px 0 4px}.makap-q-empty span{max-width:250px;line-height:1.5}.makap-q-detailempty{min-height:650px}.makap-q-icon{display:grid;place-items:center;width:42px;height:42px;border-radius:12px;background:var(--q-mint);color:var(--q-teal);font-size:17px}.makap-loading-ring{width:22px;height:22px;border:2px solid #d9eae5;border-top-color:var(--q-teal);border-radius:50%;animation:qspin .8s linear infinite}@keyframes qspin{to{transform:rotate(360deg)}} +@media(max-width:1100px){.makap-q-shell{padding:26px 20px 40px}.makap-q-metrics{grid-template-columns:repeat(2,1fr)}.makap-q-work{grid-template-columns:minmax(300px,.85fr) minmax(430px,1.3fr)}}@media(max-width:820px){.makap-q-head{display:block}.makap-q-head button{margin-top:18px}.makap-q-tabs{overflow:auto}.makap-q-tabs button{white-space:nowrap}.makap-q-work{grid-template-columns:1fr}.makap-q-listpanel{border-right:0;border-bottom:1px solid var(--q-line);max-height:390px}.makap-q-detailempty{min-height:320px}.makap-q-hero>a{display:none}}@media(max-width:560px){.makap-q-metrics{grid-template-columns:1fr 1fr;gap:8px}.makap-q-metrics>div{padding:13px}.makap-q-metrics strong{font-size:22px}.makap-q-hero{padding:23px 20px}.makap-q-body{padding-left:20px;padding-right:20px}.makap-q-fields{grid-template-columns:1fr}.makap-q-ident h2{font-size:20px}} diff --git a/makap_backend/makap_backend/page/makap_quality/makap_quality.js b/makap_backend/makap_backend/page/makap_quality/makap_quality.js new file mode 100644 index 0000000..3985766 --- /dev/null +++ b/makap_backend/makap_backend/page/makap_quality/makap_quality.js @@ -0,0 +1 @@ +frappe.pages["makap-quality"].on_page_load=function(wrapper){const page=frappe.ui.make_app_page({parent:wrapper,title:"Quality Control Center",single_column:true});page.main.html(`
Quality & release control

Quality Control Center

See inspection gates, holds, dispositions, and traceability before material or product moves forward.

Pending disposition—Needs a quality decision
Quality holds—Cannot proceed without review
Rejections—Ineligible or rejected
Eligible for release—Released or eligible
Select an inspectionChoose a record to inspect its quality gate, disposition, and traceability.
`);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]"),title=root.querySelector("[data-title]"),count=root.querySelector("[data-count]");let tab=new URLSearchParams(location.search).get("tab")||"queue",active=null,timer;const esc=v=>frappe.utils.escape_html(String(v==null?"":v)),num=v=>new Intl.NumberFormat().format(Number(v||0)),date=v=>v?frappe.datetime.str_to_user(v):"Not set",initials=v=>(String(v||"?").split(/\s+/).slice(0,2).map(x=>x[0]).join("")||"?").toUpperCase(),call=(m,a={})=>new Promise((ok,no)=>frappe.call({method:m,args:a,callback:r=>r.exc?no(r.exc):ok(r.message)})),tone=s=>({Blocked:"blocked",Exception:"exception",Released:"released",Pending:"pending","Not assessed":"pending"}[s]||"pending");function metrics(x){["pending","holds","rejections","released"].forEach(k=>root.querySelector(`[data-m="${k}"]`).textContent=num(x[k]))}function setTab(t){tab=t;active=null;root.querySelectorAll("[data-tab]").forEach(e=>e.classList.toggle("active",e.dataset.tab===t));title.textContent=t==="queue"?"Inspection queue":t==="exceptions"?"Holds & exceptions":"Traceability";load()}function row(r){let state=r.makap_derived_state||"Pending",party=r.customer||r.makap_work_order||r.reference_name||"No linked source";return ``}function render(rows){count.textContent=`${rows.length} record${rows.length===1?"":"s"}`;list.innerHTML=rows.length?rows.map(row).join(""):`
No inspections foundTry another search or filter.
`;list.querySelectorAll("[data-name]").forEach(e=>e.onclick=()=>select(e.dataset.name))}function head(o){let state=o.makap_derived_state||"Pending";return `
${esc(initials(o.item_name||o.name))}
Quality Inspection

${esc(o.name)}

${esc(o.item_name||o.item_code||"No item")} · ${esc(o.makap_quality_gate_stage||o.inspection_type||"No gate")}

Open ERPNext form
`}function fields(o){return `
Item${esc(o.item_code||o.item_name||"Not set")}
Inspection type${esc(o.inspection_type||"Not set")}
Gate stage${esc(o.makap_quality_gate_stage||"Not set")}
Trigger${esc(o.makap_inspection_trigger||"Not set")}
Eligibility${esc(o.makap_quality_eligibility||"Not determined")}
Disposition${esc(o.makap_quality_disposition||"Pending")}
`}function renderDetail(o){active=o.name;let state=o.makap_derived_state||"Pending",blocked=o.makap_disposition_reason||o.makap_defect_failure_category||o.makap_quality_exception_type||"No exception reason recorded.";detail.innerHTML=head(o)+`
Quality decision${esc(state)}${esc(blocked)}
Inspection gate
${fields(o)}
Traceability
Work Order${o.makap_work_order||o.work_order?`${esc(o.makap_work_order||o.work_order)}`:"Not linked"}
Packaging Specification${o.makap_packaging_specification?`${esc(o.makap_packaging_specification)}`:"Not linked"}
Revision${esc(o.makap_specification_revision||"Not set")}
Reference${esc(o.reference_name||"Not set")}
Disposition evidence
Population${esc(o.makap_inspection_population_quantity||"Not set")} ${esc(o.makap_population_uom||"")}
Disposition quantity${esc(o.makap_disposition_quantity||"Not set")} ${esc(o.makap_disposition_uom||"")}
Disposition by${esc(o.makap_disposition_by||"Not set")}
Released not before${date(o.makap_released_not_before)}
Exceptions and approvals
Exception type${esc(o.makap_quality_exception_type||"None")}
Reinspection${esc(o.makap_reinspection_of||"None")}
Concession reference${esc(o.makap_concession_reference||"None")}
Customer approval${o.makap_customer_approval_required?esc(o.makap_customer_approval_reference||"Required"):"Not required"}
`;root.querySelectorAll(".makap-q-row").forEach(e=>e.classList.toggle("active",e.dataset.name===active))}async function select(name){active=name;detail.innerHTML=`
Loading inspectionFetching quality evidence.
`;try{renderDetail(await call("makap_backend.quality_api.get_inspection",{name}))}catch(e){detail.innerHTML=`
Could not load inspection${esc(e.message||"Please try again.")}
`}}async function load(){list.innerHTML=`
Loading inspections...
`;try{let rows;if(tab==="queue")rows=await call("makap_backend.quality_api.list_inspections",{search:search.value,gate_stage:["First-Off","In-Process","Final","Incoming"].includes(filter.value)?filter.value:"",eligibility:["Eligible","Hold","Not Determined"].includes(filter.value)?filter.value:"",disposition:["Pending","Release","Reject"].includes(filter.value)?filter.value:""});if(tab==="exceptions")rows=await call("makap_backend.quality_api.list_quality_exceptions",{search:search.value});if(tab==="traceability")rows=await call("makap_backend.quality_api.list_quality_traceability",{search:search.value});render(rows||[]);if(!active&&rows?.[0])select(rows[0].name)}catch(e){list.innerHTML=`
Quality records unavailable${esc(e.message||"Check your permissions and try again.")}
`}}root.querySelectorAll("[data-tab]").forEach(e=>e.onclick=()=>setTab(e.dataset.tab));search.oninput=()=>{clearTimeout(timer);timer=setTimeout(load,250)};filter.onchange=load;root.querySelector("[data-refresh]").onclick=async()=>{metrics(await call("makap_backend.quality_api.get_quality_summary"));load()};(async()=>{try{metrics(await call("makap_backend.quality_api.get_quality_summary"))}catch(e){}setTab(tab)})()}; diff --git a/makap_backend/makap_backend/page/makap_quality/makap_quality.json b/makap_backend/makap_backend/page/makap_quality/makap_quality.json new file mode 100644 index 0000000..cb15bb6 --- /dev/null +++ b/makap_backend/makap_backend/page/makap_quality/makap_quality.json @@ -0,0 +1 @@ +{"doctype":"Page","name":"makap-quality","module":"makap_backend","standard":"Yes","title":"MAKAP Quality Control Center"} diff --git a/makap_backend/makap_backend/page/makap_quotations/makap_quotations.css b/makap_backend/makap_backend/page/makap_quotations/makap_quotations.css new file mode 100644 index 0000000..76eba08 --- /dev/null +++ b/makap_backend/makap_backend/page/makap_quotations/makap_quotations.css @@ -0,0 +1,4 @@ +.makap-quotes-shell{--q-ink:#19343b;--q-muted:#71868b;--q-line:#e1ecea;--q-teal:#0c766c;--q-mint:#e3f5ef;--q-shadow:0 18px 50px rgba(20,68,70,.07);max-width:1460px;margin:0 auto;padding:34px 42px 48px;color:var(--q-ink);font-family:"DM Sans",sans-serif}.makap-quotes-head{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:25px}.makap-quotes-eyebrow{text-transform:uppercase;letter-spacing:.14em;font-size:10px;font-weight:700;color:#87a19e;margin-bottom:8px}.makap-quotes-head h1{font:600 31px "Space Grotesk",sans-serif;letter-spacing:-.045em;margin:0 0 8px}.makap-quotes-head p{font-size:13px;color:var(--q-muted);margin:0}.makap-quotes-actions{display:flex;gap:9px}.makap-quotes-actions .btn-primary{background:var(--q-teal);border-color:var(--q-teal)}.makap-quotes-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:13px;margin-bottom:17px}.makap-quotes-metric{background:#fff;border:1px solid var(--q-line);border-radius:10px;padding:17px 19px;box-shadow:var(--q-shadow)}.makap-quotes-metric span,.makap-quotes-metric small{display:block}.makap-quotes-metric span{font-size:10px;color:var(--q-muted);font-weight:700;text-transform:uppercase;letter-spacing:.07em}.makap-quotes-metric strong{font:600 27px "Space Grotesk",sans-serif;display:block;margin:8px 0 3px;letter-spacing:-.04em}.makap-quotes-metric small{font-size:10px;color:#91a09f}.makap-quotes-alert{background:linear-gradient(135deg,#fffaf0,#fff);border-color:#f1dfbd}.makap-quotes-alert strong{color:#bd7d28}.makap-quotes-workspace{display:grid;grid-template-columns:minmax(340px,.9fr) minmax(490px,1.55fr);min-height:650px;background:#fff;border:1px solid var(--q-line);border-radius:14px;overflow:hidden;box-shadow:var(--q-shadow)}.makap-quotes-list-panel{border-right:1px solid var(--q-line);background:#fbfdfd}.makap-quotes-list-head{padding:21px 22px 16px;border-bottom:1px solid var(--q-line)}.makap-quotes-list-head strong{font:600 17px "Space Grotesk",sans-serif;display:block}.makap-quotes-list-head span{font-size:10px;color:#91a09f;display:block;margin-top:3px}.makap-quotes-filters{display:flex;gap:8px;margin-top:16px}.makap-quotes-search{display:flex;align-items:center;gap:8px;background:#f2f7f5;border:1px solid #e3eeea;border-radius:7px;padding:9px 11px;color:#8fa09c;flex:1}.makap-quotes-search input{border:0;background:transparent;outline:0;width:100%;font:11px "DM Sans";color:var(--q-ink)}.makap-quotes-filters select{border:1px solid #e3eeea;background:#f8fbfa;color:#66807c;border-radius:7px;padding:0 7px;font-size:10px;max-width:115px}.makap-quotes-list{padding:8px}.makap-quote-row{width:100%;display:flex;align-items:center;gap:11px;border:0;border-radius:9px;background:transparent;padding:12px 10px;text-align:left;cursor:pointer;color:var(--q-ink);transition:.15s}.makap-quote-row:hover{background:#f0f7f4}.makap-quote-row.is-active{background:var(--q-mint);box-shadow:inset 3px 0 var(--q-teal)}.makap-quote-avatar,.makap-quote-detail-avatar{display:grid;place-items:center;border-radius:50%;background:#d5eee6;color:var(--q-teal);font-family:"Space Grotesk",sans-serif;font-weight:700}.makap-quote-avatar{width:35px;height:35px;font-size:11px;flex:none}.makap-quote-row-copy{flex:1;min-width:0}.makap-quote-row-copy strong,.makap-quote-row-copy span,.makap-quote-row-copy small{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.makap-quote-row-copy strong{font-size:12px;font-weight:700}.makap-quote-row-copy span{font-size:10px;color:var(--q-muted);margin-top:3px}.makap-quote-row-copy small{font-size:9px;color:#9aa9a7;margin-top:4px}.makap-quote-row-side{display:flex;flex-direction:column;align-items:flex-end;gap:7px;color:#a7b7b4}.makap-quote-status{border-radius:20px;padding:4px 7px;font-size:9px;font-weight:700;white-space:nowrap}.makap-quote-status.draft{background:#eef4f3;color:#66817d}.makap-quote-status.open{background:#e8f7ed;color:#4c9b73}.makap-quote-status.replied,.makap-quote-status.partial{background:#f5f0fb;color:#8067a9}.makap-quote-status.ordered,.makap-quote-status.accepted{background:#e9f1fb;color:#4d7eb2}.makap-quote-status.lost,.makap-quote-status.expired,.makap-quote-status.cancelled{background:#fff0f0;color:#b96868}.makap-quotes-detail{background:#fff}.makap-quote-detail-hero{padding:30px 33px 27px;background:linear-gradient(115deg,#f0faf6,#f4f9ff);border-bottom:1px solid var(--q-line);display:flex;align-items:center;gap:16px}.makap-quote-detail-avatar{width:62px;height:62px;font-size:20px}.makap-quote-detail-identity{min-width:0;flex:1}.makap-quote-detail-identity h2{font:600 24px "Space Grotesk",sans-serif;letter-spacing:-.04em;margin:7px 0 4px}.makap-quote-detail-identity p{color:var(--q-muted);font-size:11px;margin:0}.makap-quote-detail-body{padding:4px 33px 30px}.makap-quote-value-card{margin-top:21px;border:1px solid #dcebe7;background:linear-gradient(135deg,#f5fcf9,#f7fbff);border-radius:10px;padding:18px 20px;display:flex;align-items:center;justify-content:space-between}.makap-quote-value-card>div:first-child span,.makap-quote-value-card p{display:block}.makap-quote-value-card span{color:#819691;font-size:10px;text-transform:uppercase;letter-spacing:.09em;font-weight:700}.makap-quote-value-card strong{font:600 29px "Space Grotesk",sans-serif;display:block;margin:6px 0 2px;letter-spacing:-.04em}.makap-quote-value-card strong small{font:500 12px "DM Sans";color:#718b86}.makap-quote-value-card p{font-size:10px;color:#8fa19e;margin:0}.makap-quote-validity{display:flex;flex-direction:column;align-items:center;gap:4px;color:#bd7d28}.makap-quote-validity i{font-size:17px}.makap-quote-validity span{font-size:9px;color:#9a9c8c;text-transform:uppercase;letter-spacing:.07em}.makap-quote-validity strong{font-size:11px;color:#806c48}.makap-quote-detail-section{padding:23px 0;border-bottom:1px solid #edf3f1}.makap-quote-detail-section:last-child{border-bottom:0}.makap-quote-section-title{text-transform:uppercase;letter-spacing:.12em;color:#8ca09d;font-weight:700;font-size:9px;margin-bottom:15px}.makap-quote-timeline,.makap-quote-fields{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.makap-quote-fields{grid-template-columns:1fr 1fr;gap:17px 30px}.makap-quote-timeline span,.makap-quote-fields span{display:block;color:#93a29f;font-size:10px;margin-bottom:5px}.makap-quote-timeline strong,.makap-quote-fields strong{display:block;font-size:12px;font-weight:600}.makap-quote-fields a{color:var(--q-teal);text-decoration:none}.makap-quote-item{display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-bottom:1px solid #f0f4f2;font-size:11px}.makap-quote-item:last-child{border-bottom:0}.makap-quote-item strong,.makap-quote-item small{display:block}.makap-quote-item small{color:var(--q-muted);font-size:9px;margin-top:3px}.makap-quote-item>b{font-size:11px;color:#536f6b}.makap-quote-note{color:#92a09e;font-size:11px;padding:5px 0}.makap-quote-note i{margin-right:7px}.makap-quote-remarks{font-size:12px;line-height:1.6;color:#637875;margin:0;white-space:pre-wrap}.makap-quotes-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:#95a3a1;padding:50px 25px;font-size:11px;min-height:180px}.makap-quotes-empty strong{color:#6f8280;font-size:12px;margin:10px 0 4px}.makap-quotes-empty span{max-width:240px;line-height:1.5}.makap-quotes-detail-empty{min-height:620px}.makap-quotes-empty-icon{display:grid;place-items:center;width:42px;height:42px;border-radius:12px;background:var(--q-mint);color:var(--q-teal);font-size:17px}.makap-loading-ring{width:22px;height:22px;border:2px solid #d9eae5;border-top-color:var(--q-teal);border-radius:50%;animation:qspin .8s linear infinite}@keyframes qspin{to{transform:rotate(360deg)}} +@media(max-width:1050px){.makap-quotes-shell{padding:26px 20px 40px}.makap-quotes-metrics{grid-template-columns:repeat(2,1fr)}.makap-quotes-workspace{grid-template-columns:minmax(290px,.85fr) minmax(420px,1.3fr)}} +@media(max-width:800px){.makap-quotes-head{display:block}.makap-quotes-actions{margin-top:18px}.makap-quotes-workspace{grid-template-columns:1fr}.makap-quotes-list-panel{border-right:0;border-bottom:1px solid var(--q-line);max-height:390px}.makap-quotes-detail-empty{min-height:320px}.makap-quote-detail-hero>a{display:none}} +@media(max-width:560px){.makap-quotes-metrics{grid-template-columns:1fr 1fr;gap:8px}.makap-quotes-metric{padding:13px}.makap-quotes-metric strong{font-size:22px}.makap-quotes-filters{display:block}.makap-quotes-search{margin-bottom:8px}.makap-quotes-filters select{height:32px;width:100%;max-width:none}.makap-quote-detail-hero{padding:23px 20px}.makap-quote-detail-body{padding-left:20px;padding-right:20px}.makap-quote-timeline{grid-template-columns:1fr 1fr}.makap-quote-detail-identity h2{font-size:20px}} diff --git a/makap_backend/makap_backend/page/makap_quotations/makap_quotations.js b/makap_backend/makap_backend/page/makap_quotations/makap_quotations.js new file mode 100644 index 0000000..681518f --- /dev/null +++ b/makap_backend/makap_backend/page/makap_quotations/makap_quotations.js @@ -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(` +
+
Customer & commercial

Quotations

Prepare, track, and follow up on commercial offers before they become sales orders.

+
Active quotations—Draft, open, or partially ordered
Active value—Current commercial offer value
Expiring soon—Due within the next 7 days
Accepted—Ready to convert or fulfil
+
Select a quotationChoose a quote to inspect its customer, validity, value, and item breakdown.
+
+ `); + 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 = `
No quotations foundTry a different customer, quote number, or status.
`; return; } + list.innerHTML = rows.map((r) => ``).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) => `
${esc(i.item_name || i.item_code || "Item")}${esc(i.item_code || "")} · ${esc(i.qty || 0)} ${esc(i.uom || "")}${money(i.amount)}
`).join("") : `
No items added yet.
`; + detail.innerHTML = `
${esc(initials(q.customer_name || q.party_name || q.name))}
${esc(q.status || "Draft")}

${esc(q.customer_name || q.party_name || "Unassigned customer")}

${esc(q.name)}${q.order_type ? ` · ${esc(q.order_type)}` : ""}

Open ERPNext form
Total quotation value${money(q.grand_total)} ${esc(q.currency || "")}

Net total ${money(q.net_total)} · Taxes ${money(q.total_taxes_and_charges)}

Valid until${date(q.valid_till)}
Quotation timeline
Quotation date${date(q.transaction_date)}
Validity${date(q.valid_till)}
Owner${esc(q.owner || "Not set")}
Customer and contact
Customer${q.party_name ? `${esc(q.customer_name || q.party_name)}` : "Not linked"}
Contact${q.contact_person ? `${esc(q.contact_person)}` : "Not linked"}
Email${esc(q.contact_email || "Not provided")}
Territory${esc(q.territory || "Not set")}
Quoted items
${itemMarkup}
${q.terms ? `
Terms and conditions

${esc(q.terms)}

` : ""}
`; + 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 = `
Loading quotationFetching the latest commercial details.
`; try { renderDetail(await call("makap_backend.quotation_api.get_quotation", { name })); } catch (e) { detail.innerHTML = `
Could not load quotation${esc(e.message || "Please try again.")}
`; } } + async function loadWorkspace() { list.innerHTML = `
Loading quotations...
`; 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 = `
Quotations unavailable${esc(e.message || "Check your permissions and try again.")}
`; } } + 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(); +}; diff --git a/makap_backend/makap_backend/page/makap_quotations/makap_quotations.json b/makap_backend/makap_backend/page/makap_quotations/makap_quotations.json new file mode 100644 index 0000000..0d1af46 --- /dev/null +++ b/makap_backend/makap_backend/page/makap_quotations/makap_quotations.json @@ -0,0 +1,7 @@ +{ + "doctype": "Page", + "name": "makap-quotations", + "module": "makap_backend", + "standard": "Yes", + "title": "MAKAP Quotations" +} diff --git a/makap_backend/makap_backend/page/makap_returns/makap_returns.css b/makap_backend/makap_backend/page/makap_returns/makap_returns.css new file mode 100644 index 0000000..4b6361c --- /dev/null +++ b/makap_backend/makap_backend/page/makap_returns/makap_returns.css @@ -0,0 +1 @@ +.makap-end-shell{--e-ink:#19343b;--e-muted:#71868b;--e-line:#e1ecea;--e-teal:#0c766c;--e-mint:#e3f5ef;--e-shadow:0 18px 50px rgba(20,68,70,.07);max-width:1500px;margin:0 auto;padding:34px 42px 48px;color:var(--e-ink);font-family:"DM Sans",sans-serif}.makap-end-head{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:25px}.makap-end-eyebrow{text-transform:uppercase;letter-spacing:.14em;font-size:10px;font-weight:700;color:#87a19e;margin-bottom:8px}.makap-end-head h1{font:600 31px "Space Grotesk",sans-serif;letter-spacing:-.045em;margin:0 0 8px}.makap-end-head p{font-size:13px;color:var(--e-muted);margin:0}.makap-end-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:13px;margin-bottom:17px}.makap-end-metrics>div{background:#fff;border:1px solid var(--e-line);border-radius:10px;padding:17px 19px;box-shadow:var(--e-shadow)}.makap-end-metrics span,.makap-end-metrics small{display:block}.makap-end-metrics span{font-size:10px;color:var(--e-muted);font-weight:700;text-transform:uppercase;letter-spacing:.07em}.makap-end-metrics strong{font:600 27px "Space Grotesk",sans-serif;display:block;margin:8px 0 3px}.makap-end-metrics small{font-size:10px;color:#91a09f}.makap-end-metrics .warn{background:#fffaf0;border-color:#f1dfbd}.makap-end-metrics .warn strong{color:#bd7d28}.makap-end-metrics .bad{background:#fff8f6;border-color:#f1d6d1}.makap-end-metrics .bad strong{color:#b96868}.makap-end-metrics .good{background:#f5fcf9;border-color:#dcebe7}.makap-end-metrics .good strong{color:#4c9b73}.makap-end-tabs{display:flex;gap:5px;background:#edf5f2;border:1px solid #dfece8;padding:5px;border-radius:10px 10px 0 0}.makap-end-tabs button{border:0;background:transparent;color:#66817d;border-radius:7px;padding:10px 15px;font:600 11px "DM Sans";cursor:pointer}.makap-end-tabs button.active{background:#fff;color:var(--e-teal);box-shadow:0 2px 8px #1c645d12}.makap-end-work{display:grid;grid-template-columns:minmax(370px,.9fr) minmax(520px,1.55fr);min-height:690px;background:#fff;border:1px solid var(--e-line);border-top:0;border-radius:0 0 14px 14px;overflow:hidden;box-shadow:var(--e-shadow)}.makap-end-listpanel{border-right:1px solid var(--e-line);background:#fbfdfd}.makap-end-listhead{padding:21px 22px 16px;border-bottom:1px solid var(--e-line)}.makap-end-listhead strong{font:600 17px "Space Grotesk",sans-serif;display:block}.makap-end-listhead span{font-size:10px;color:#91a09f;display:block;margin-top:3px}.makap-end-search{display:flex;gap:8px;align-items:center;background:#f2f7f5;border:1px solid #e3eeea;border-radius:7px;padding:9px 11px;color:#8fa09c;margin-top:16px}.makap-end-search input{border:0;background:transparent;outline:0;width:100%;font:11px "DM Sans"}.makap-end-listhead select{border:1px solid #e3eeea;background:#f8fbfa;color:#66807c;border-radius:7px;padding:7px;width:100%;font-size:10px;margin-top:8px}.makap-end-list{padding:8px}.makap-end-row{width:100%;display:flex;align-items:center;gap:11px;border:0;border-radius:9px;background:transparent;padding:12px 10px;text-align:left;cursor:pointer;color:var(--e-ink)}.makap-end-row:hover,.makap-end-row.active{background:var(--e-mint);box-shadow:inset 3px 0 var(--e-teal)}.end-avatar,.end-detail-avatar{display:grid;place-items:center;border-radius:50%;background:#d5eee6;color:var(--e-teal);font-family:"Space Grotesk",sans-serif;font-weight:700}.end-avatar{width:35px;height:35px;font-size:10px;flex:none}.end-avatar.warn,.end-detail-avatar.warn{background:#fff5e4;color:#bd7d28}.end-avatar.bad,.end-detail-avatar.bad{background:#fff0f0;color:#b96868}.end-avatar.good,.end-detail-avatar.good{background:#e8f7ed;color:#4c9b73}.end-copy{flex:1;min-width:0}.end-copy strong,.end-copy span,.end-copy small{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.end-copy strong{font-size:12px}.end-copy span{font-size:10px;color:var(--e-muted);margin-top:3px}.end-copy small{font-size:9px;color:#9aa9a7;margin-top:4px}.end-badge{border-radius:20px;padding:4px 7px;font-size:9px;white-space:nowrap}.end-badge.warn{background:#fff5e4;color:#bd7d28}.end-badge.bad{background:#fff0f0;color:#b96868}.end-badge.good{background:#e8f7ed;color:#4c9b73}.makap-end-detail{background:#fff}.makap-end-hero{padding:30px 33px 27px;background:linear-gradient(115deg,#f0faf6,#f4f9ff);border-bottom:1px solid var(--e-line);display:flex;align-items:center;gap:16px}.end-detail-avatar{width:62px;height:62px;font-size:18px;flex:none}.end-ident{flex:1;min-width:0}.end-ident>span{font-size:9px;text-transform:uppercase;letter-spacing:.12em;color:#8ca09d;font-weight:700}.end-ident h2{font:600 24px "Space Grotesk",sans-serif;letter-spacing:-.04em;margin:7px 0 4px}.end-ident p{font-size:11px;color:var(--e-muted);margin:0}.makap-end-body{padding:4px 33px 30px}.end-state{margin-top:21px;border:1px solid #dcebe7;background:#f5fcf9;border-radius:10px;padding:18px 20px;display:flex;justify-content:space-between;align-items:center}.end-state.warn{background:#fffaf0;border-color:#f1dfbd}.end-state.bad{background:#fff8f6;border-color:#f1d6d1}.end-state span,.end-state small{display:block}.end-state span{font-size:10px;text-transform:uppercase;letter-spacing:.09em;color:#819691;font-weight:700}.end-state strong{font:600 22px "Space Grotesk",sans-serif;display:block;margin:6px 0 2px}.end-state small{font-size:10px;color:#8fa19e}.end-state i{font-size:22px;color:var(--e-teal)}.end-state.warn i{color:#bd7d28}.end-state.bad i{color:#b96868}.end-section{padding:23px 0;border-bottom:1px solid #edf3f1}.end-title{text-transform:uppercase;letter-spacing:.12em;color:#8ca09d;font-weight:700;font-size:9px;margin-bottom:15px}.end-fields{display:grid;grid-template-columns:1fr 1fr;gap:17px 30px}.end-fields span{display:block;color:#93a29f;font-size:10px;margin-bottom:5px}.end-fields strong{display:block;font-size:12px}.end-mini{display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid #f0f4f2;font-size:11px}.end-mini:last-child{border:0}.end-mini strong,.end-mini small{display:block}.end-mini small{font-size:9px;color:var(--e-muted);margin-top:3px}.end-mini b{font-size:10px;color:#536f6b}.end-note{font-size:11px;color:#92a09e}.close-grid{display:grid;grid-template-columns:1fr 1fr;gap:9px}.close-dim{border:1px solid #edf3f1;border-radius:7px;padding:11px}.close-dim span{display:block;text-transform:capitalize;font-size:10px;color:#93a29f;margin-bottom:5px}.close-dim b{font-size:11px}.makap-end-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:#95a3a1;padding:50px 25px;font-size:11px;min-height:180px}.makap-end-empty strong{color:#6f8280;font-size:12px;margin:10px 0 4px}.makap-end-empty span{max-width:250px;line-height:1.5}.detail-empty{min-height:690px}.load-ring{width:22px;height:22px;border:2px solid #d9eae5;border-top-color:var(--e-teal);border-radius:50%;animation:ends .8s linear infinite}@keyframes ends{to{transform:rotate(360deg)}}@media(max-width:1100px){.makap-end-shell{padding:26px 20px 40px}.makap-end-metrics{grid-template-columns:repeat(2,1fr)}.makap-end-work{grid-template-columns:minmax(300px,.85fr) minmax(430px,1.3fr)}}@media(max-width:820px){.makap-end-head{display:block}.makap-end-head button{margin-top:18px}.makap-end-tabs{overflow:auto}.makap-end-tabs button{white-space:nowrap}.makap-end-work{grid-template-columns:1fr}.makap-end-listpanel{border-right:0;border-bottom:1px solid var(--e-line);max-height:390px}.detail-empty{min-height:320px}.makap-end-hero>a{display:none}}@media(max-width:560px){.makap-end-metrics{grid-template-columns:1fr 1fr;gap:8px}.makap-end-metrics>div{padding:13px}.makap-end-metrics strong{font-size:22px}.makap-end-hero{padding:23px 20px}.makap-end-body{padding-left:20px;padding-right:20px}.end-fields,.close-grid{grid-template-columns:1fr}.end-ident h2{font-size:20px}} diff --git a/makap_backend/makap_backend/page/makap_returns/makap_returns.js b/makap_backend/makap_backend/page/makap_returns/makap_returns.js new file mode 100644 index 0000000..b3b5aed --- /dev/null +++ b/makap_backend/makap_backend/page/makap_returns/makap_returns.js @@ -0,0 +1 @@ +frappe.pages["makap-returns"].on_page_load=function(w){const p=frappe.ui.make_app_page({parent:w,title:"Returns Control Center",single_column:true});p.main.html(`
Returns & replacement

Returns Control Center

Keep returned goods in controlled review until quality disposition and traceability are complete.

Pending disposition—Requires quality action
Quality holds—Held, pending, or rework
Released returns—Eligible to proceed
Rejected or scrap—Not eligible for release
Select a returnReview the customer, warehouse, reason, and quality disposition.
`);const r=p.main[0],list=r.querySelector("[data-list]"),detail=r.querySelector("[data-detail]"),search=r.querySelector("[data-search]"),filter=r.querySelector("[data-filter]"),count=r.querySelector("[data-count]"),title=r.querySelector("[data-title]");let tab=new URLSearchParams(location.search).get("tab")||"queue",active=null,timer;const esc=v=>frappe.utils.escape_html(String(v==null?"":v)),num=v=>new Intl.NumberFormat().format(Number(v||0)),date=v=>v?frappe.datetime.str_to_user(v):"Not set",ini=v=>(String(v||"?").split(/\s+/).slice(0,2).map(x=>x[0]).join("")||"?").toUpperCase(),call=(m,a={})=>new Promise((ok,no)=>frappe.call({method:m,args:a,callback:x=>x.exc?no(x.exc):ok(x.message)})),tone=s=>({Blocked:"blocked",Rejected:"bad",Released:"good"}[s]||"warn");function metrics(x){["pending","holds","released","rejected"].forEach(k=>r.querySelector(`[data-m="${k}"]`).textContent=num(x[k]))}function set(t){tab=t;active=null;r.querySelectorAll("[data-t]").forEach(e=>e.classList.toggle("active",e.dataset.t===t));title.textContent=t==="queue"?"Return queue":t==="exceptions"?"Quality disposition":"Returns traceability";load()}function row(x){let s=x.makap_derived_state||"Blocked";return ``}function render(rs){count.textContent=`${rs.length} record${rs.length===1?"":"s"}`;list.innerHTML=rs.length?rs.map(row).join(""):`
No returns foundTry another search or state filter.
`;list.querySelectorAll("[data-n]").forEach(e=>e.onclick=()=>select(e.dataset.n))}function detail(x){active=x.name;let lines=(x.return_items||[]).map(i=>`
${esc(i.item_name||i.item_code||"Item")}${esc(i.qty||0)} · ${esc(i.warehouse||"No warehouse")} · ${esc(i.makap_return_reason||"No reason")}${esc(i.makap_return_quality_disposition||"Pending")}
`).join("")||`
No return items available.
`;detail.innerHTML=`
${esc(ini(x.customer||x.name))}
Customer Return

${esc(x.name)}

${esc(x.customer||"No customer")} · ${esc(x.status||"Draft")}

Open ERPNext form
Return decision${esc(x.makap_derived_state)}Returned stock should remain in a Returns Hold warehouse until disposition.
Return context
Customer${esc(x.customer||"Not set")}
Original Delivery Note${esc(x.return_against||"Not linked")}
Posting date${date(x.posting_date)}
Company${esc(x.company||"Not set")}
Returned lines
${lines}
`;r.querySelectorAll(".makap-end-row").forEach(e=>e.classList.toggle("active",e.dataset.n===active))}async function select(n){active=n;detail.innerHTML=`
Loading return
`;try{detail(await call("makap_backend.returns_api.get_return",{name:n}))}catch(e){detail.innerHTML=`
Could not load return${esc(e.message||"Please try again.")}
`}}async function load(){list.innerHTML=`
Loading returns...
`;try{let rs=tab==="exceptions"?await call("makap_backend.returns_api.list_return_exceptions",{search:search.value}):await call("makap_backend.returns_api.list_return_traceability",{search:search.value});rs=(filter.value==="All"?rs:rs.filter(x=>x.makap_derived_state===filter.value));render(rs||[]);if(!active&&rs?.[0])select(rs[0].name)}catch(e){list.innerHTML=`
Returns unavailable${esc(e.message||"Check permissions.")}
`}}r.querySelectorAll("[data-t]").forEach(e=>e.onclick=()=>set(e.dataset.t));search.oninput=()=>{clearTimeout(timer);timer=setTimeout(load,250)};filter.onchange=load;r.querySelector("[data-r]").onclick=async()=>{metrics(await call("makap_backend.returns_api.get_returns_summary"));load()};(async()=>{try{metrics(await call("makap_backend.returns_api.get_returns_summary"))}catch(e){}set(tab)})()}; diff --git a/makap_backend/makap_backend/page/makap_returns/makap_returns.json b/makap_backend/makap_backend/page/makap_returns/makap_returns.json new file mode 100644 index 0000000..dd58dff --- /dev/null +++ b/makap_backend/makap_backend/page/makap_returns/makap_returns.json @@ -0,0 +1 @@ +{"doctype":"Page","name":"makap-returns","module":"makap_backend","standard":"Yes","title":"MAKAP Returns Control Center"} diff --git a/makap_backend/makap_backend/page/makap_sales_orders/makap_sales_orders.css b/makap_backend/makap_backend/page/makap_sales_orders/makap_sales_orders.css new file mode 100644 index 0000000..0fee03c --- /dev/null +++ b/makap_backend/makap_backend/page/makap_sales_orders/makap_sales_orders.css @@ -0,0 +1,4 @@ +.makap-orders-shell{--o-ink:#19343b;--o-muted:#71868b;--o-line:#e1ecea;--o-teal:#0c766c;--o-mint:#e3f5ef;--o-shadow:0 18px 50px rgba(20,68,70,.07);max-width:1460px;margin:0 auto;padding:34px 42px 48px;color:var(--o-ink);font-family:"DM Sans",sans-serif}.makap-orders-head{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:25px}.makap-orders-eyebrow{text-transform:uppercase;letter-spacing:.14em;font-size:10px;font-weight:700;color:#87a19e;margin-bottom:8px}.makap-orders-head h1{font:600 31px "Space Grotesk",sans-serif;letter-spacing:-.045em;margin:0 0 8px}.makap-orders-head p{font-size:13px;color:var(--o-muted);margin:0}.makap-orders-actions{display:flex;gap:9px}.makap-orders-actions .btn-primary{background:var(--o-teal);border-color:var(--o-teal)}.makap-orders-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:13px;margin-bottom:17px}.makap-orders-metric{background:#fff;border:1px solid var(--o-line);border-radius:10px;padding:17px 19px;box-shadow:var(--o-shadow)}.makap-orders-metric span,.makap-orders-metric small{display:block}.makap-orders-metric span{font-size:10px;color:var(--o-muted);font-weight:700;text-transform:uppercase;letter-spacing:.07em}.makap-orders-metric strong{font:600 27px "Space Grotesk",sans-serif;display:block;margin:8px 0 3px;letter-spacing:-.04em}.makap-orders-metric small{font-size:10px;color:#91a09f}.makap-orders-alert{background:linear-gradient(135deg,#fffaf0,#fff);border-color:#f1dfbd}.makap-orders-alert strong{color:#bd7d28}.makap-orders-workspace{display:grid;grid-template-columns:minmax(340px,.9fr) minmax(490px,1.55fr);min-height:670px;background:#fff;border:1px solid var(--o-line);border-radius:14px;overflow:hidden;box-shadow:var(--o-shadow)}.makap-orders-list-panel{border-right:1px solid var(--o-line);background:#fbfdfd}.makap-orders-list-head{padding:21px 22px 16px;border-bottom:1px solid var(--o-line)}.makap-orders-list-head strong{font:600 17px "Space Grotesk",sans-serif;display:block}.makap-orders-list-head span{font-size:10px;color:#91a09f;display:block;margin-top:3px}.makap-orders-filters{display:flex;gap:8px;margin-top:16px}.makap-orders-search{display:flex;align-items:center;gap:8px;background:#f2f7f5;border:1px solid #e3eeea;border-radius:7px;padding:9px 11px;color:#8fa09c;flex:1}.makap-orders-search input{border:0;background:transparent;outline:0;width:100%;font:11px "DM Sans";color:var(--o-ink)}.makap-orders-filters select{border:1px solid #e3eeea;background:#f8fbfa;color:#66807c;border-radius:7px;padding:0 7px;font-size:10px;max-width:125px}.makap-orders-list{padding:8px}.makap-order-row{width:100%;display:flex;align-items:center;gap:11px;border:0;border-radius:9px;background:transparent;padding:12px 10px;text-align:left;cursor:pointer;color:var(--o-ink);transition:.15s}.makap-order-row:hover{background:#f0f7f4}.makap-order-row.is-active{background:var(--o-mint);box-shadow:inset 3px 0 var(--o-teal)}.makap-order-avatar,.makap-order-detail-avatar{display:grid;place-items:center;border-radius:50%;background:#d5eee6;color:var(--o-teal);font-family:"Space Grotesk",sans-serif;font-weight:700}.makap-order-avatar{width:35px;height:35px;font-size:11px;flex:none}.makap-order-row-copy{flex:1;min-width:0}.makap-order-row-copy strong,.makap-order-row-copy span,.makap-order-row-copy small{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.makap-order-row-copy strong{font-size:12px;font-weight:700}.makap-order-row-copy span{font-size:10px;color:var(--o-muted);margin-top:3px}.makap-order-row-copy small{font-size:9px;color:#9aa9a7;margin-top:4px}.makap-order-row-side{display:flex;flex-direction:column;align-items:flex-end;gap:7px;color:#a7b7b4}.makap-order-status{border-radius:20px;padding:4px 7px;font-size:9px;font-weight:700;white-space:nowrap}.makap-order-status.active{background:#e8f7ed;color:#4c9b73}.makap-order-status.delivery{background:#edf5fb;color:#5384a5}.makap-order-status.billing{background:#fff5e4;color:#bd7d28}.makap-order-status.completed{background:#e9f1fb;color:#4d7eb2}.makap-order-status.hold,.makap-order-status.overdue{background:#fff0f0;color:#b96868}.makap-order-status.cancelled,.makap-order-status.closed{background:#eef4f3;color:#66817d}.makap-order-status.draft{background:#f5f0fb;color:#8067a9}.makap-orders-detail{background:#fff}.makap-order-detail-hero{padding:30px 33px 27px;background:linear-gradient(115deg,#f0faf6,#f4f9ff);border-bottom:1px solid var(--o-line);display:flex;align-items:center;gap:16px}.makap-order-detail-avatar{width:62px;height:62px;font-size:20px}.makap-order-detail-identity{min-width:0;flex:1}.makap-order-detail-identity h2{font:600 24px "Space Grotesk",sans-serif;letter-spacing:-.04em;margin:7px 0 4px}.makap-order-detail-identity p{color:var(--o-muted);font-size:11px;margin:0}.makap-order-detail-body{padding:4px 33px 30px}.makap-order-value-card{margin-top:21px;border:1px solid #dcebe7;background:linear-gradient(135deg,#f5fcf9,#f7fbff);border-radius:10px;padding:18px 20px;display:flex;align-items:center;justify-content:space-between}.makap-order-value-card span,.makap-order-value-card p{display:block}.makap-order-value-card span{color:#819691;font-size:10px;text-transform:uppercase;letter-spacing:.09em;font-weight:700}.makap-order-value-card strong{font:600 29px "Space Grotesk",sans-serif;display:block;margin:6px 0 2px;letter-spacing:-.04em}.makap-order-value-card strong small{font:500 12px "DM Sans";color:#718b86}.makap-order-value-card p{font-size:10px;color:#8fa19e;margin:0}.makap-order-status-summary{display:flex;flex-direction:column;align-items:center;gap:5px}.makap-order-status-summary small{font-size:9px;color:#8fa19e}.makap-order-detail-section{padding:23px 0;border-bottom:1px solid #edf3f1}.makap-order-detail-section:last-child{border-bottom:0}.makap-order-section-title{text-transform:uppercase;letter-spacing:.12em;color:#8ca09d;font-weight:700;font-size:9px;margin-bottom:15px}.makap-order-progress-row{display:grid;grid-template-columns:120px 1fr;align-items:center;gap:15px;margin:12px 0}.makap-order-progress-row span b,.makap-order-progress-row span small{display:block}.makap-order-progress-row b{font-size:11px}.makap-order-progress-row small{font-size:9px;color:var(--o-muted);margin-top:3px}.makap-order-progress-row>div{height:8px;background:#edf4f1;border-radius:20px;overflow:hidden}.makap-order-progress-row>div i{display:block;height:100%;background:#70c4aa;border-radius:20px}.makap-order-progress-row>div i.billing{background:#83a9d0}.makap-order-timeline,.makap-order-fields{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.makap-order-fields{grid-template-columns:1fr 1fr;gap:17px 30px}.makap-order-timeline span,.makap-order-fields span{display:block;color:#93a29f;font-size:10px;margin-bottom:5px}.makap-order-timeline strong,.makap-order-fields strong{display:block;font-size:12px;font-weight:600}.makap-order-fields a{color:var(--o-teal);text-decoration:none}.makap-order-item{display:flex;align-items:center;justify-content:space-between;padding:11px 0;border-bottom:1px solid #f0f4f2;font-size:11px}.makap-order-item:last-child{border-bottom:0}.makap-order-item strong,.makap-order-item small{display:block}.makap-order-item small{color:var(--o-muted);font-size:9px;margin-top:3px}.makap-order-item-progress{display:flex;align-items:center;gap:12px}.makap-order-item-progress i{width:55px;height:5px;background:#73c5aa;border-radius:10px;display:block}.makap-order-item-progress b{font-size:11px;color:#536f6b;min-width:55px;text-align:right}.makap-order-note{color:#92a09e;font-size:11px;padding:5px 0}.makap-order-note i{margin-right:7px}.makap-order-remarks{font-size:12px;line-height:1.6;color:#637875;margin:0;white-space:pre-wrap}.makap-orders-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:#95a3a1;padding:50px 25px;font-size:11px;min-height:180px}.makap-orders-empty strong{color:#6f8280;font-size:12px;margin:10px 0 4px}.makap-orders-empty span{max-width:240px;line-height:1.5}.makap-orders-detail-empty{min-height:640px}.makap-orders-empty-icon{display:grid;place-items:center;width:42px;height:42px;border-radius:12px;background:var(--o-mint);color:var(--o-teal);font-size:17px}.makap-loading-ring{width:22px;height:22px;border:2px solid #d9eae5;border-top-color:var(--o-teal);border-radius:50%;animation:ospin .8s linear infinite}@keyframes ospin{to{transform:rotate(360deg)}} +@media(max-width:1050px){.makap-orders-shell{padding:26px 20px 40px}.makap-orders-metrics{grid-template-columns:repeat(2,1fr)}.makap-orders-workspace{grid-template-columns:minmax(290px,.85fr) minmax(420px,1.3fr)}} +@media(max-width:800px){.makap-orders-head{display:block}.makap-orders-actions{margin-top:18px}.makap-orders-workspace{grid-template-columns:1fr}.makap-orders-list-panel{border-right:0;border-bottom:1px solid var(--o-line);max-height:390px}.makap-orders-detail-empty{min-height:320px}.makap-order-detail-hero>a{display:none}} +@media(max-width:560px){.makap-orders-metrics{grid-template-columns:1fr 1fr;gap:8px}.makap-orders-metric{padding:13px}.makap-orders-metric strong{font-size:22px}.makap-orders-filters{display:block}.makap-orders-search{margin-bottom:8px}.makap-orders-filters select{height:32px;width:100%;max-width:none}.makap-order-detail-hero{padding:23px 20px}.makap-order-detail-body{padding-left:20px;padding-right:20px}.makap-order-timeline{grid-template-columns:1fr 1fr}.makap-order-detail-identity h2{font-size:20px}} diff --git a/makap_backend/makap_backend/page/makap_sales_orders/makap_sales_orders.js b/makap_backend/makap_backend/page/makap_sales_orders/makap_sales_orders.js new file mode 100644 index 0000000..f95a8af --- /dev/null +++ b/makap_backend/makap_backend/page/makap_sales_orders/makap_sales_orders.js @@ -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(` +
+
Customer & commercial

Sales Orders

Track confirmed customer demand from order acceptance through delivery and billing.

+
Active orders—Orders still in execution
Active order value—Confirmed commercial value
Delivery due—Orders not fully delivered
Billing due—Orders not fully billed
+
Select a sales orderChoose an order to inspect execution status, delivery progress, billing, and items.
+
+ `); + 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 = `
No sales orders foundTry a different customer, order number, or status.
`; return; } + list.innerHTML = rows.map((r) => ``).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) => `
${esc(i.item_name || i.item_code || "Item")}${esc(i.item_code || "")} · ${esc(i.qty || 0)} ${esc(i.uom || "")}${money(i.amount)}
`).join("") : `
No items added yet.
`; + detail.innerHTML = `
${esc(initials(o.customer_name || o.customer || o.name))}
${esc(o.status || "Draft")}

${esc(o.customer_name || o.customer || "Unassigned customer")}

${esc(o.name)}${o.order_type ? ` · ${esc(o.order_type)}` : ""}

Open ERPNext form
Order value${money(o.grand_total)} ${esc(o.currency || "")}

Net total ${money(o.net_total)} · Taxes ${money(o.total_taxes_and_charges)}

${esc(o.status || "Draft")}Execution status
Execution progress
Delivery${delivered}% delivered
Billing${billed}% billed
Order timeline
Order date${date(o.transaction_date)}
Delivery date${date(o.delivery_date)}
Owner${esc(o.owner || "Not set")}
Customer and contact
Customer${o.customer ? `${esc(o.customer_name || o.customer)}` : "Not linked"}
Contact${o.contact_person ? `${esc(o.contact_person)}` : "Not linked"}
Email${esc(o.contact_email || "Not provided")}
Territory${esc(o.territory || "Not set")}
Ordered items
${itemMarkup}
${o.terms ? `
Terms and conditions

${esc(o.terms)}

` : ""}
`; + 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 = `
Loading sales orderFetching the latest execution details.
`; try { renderDetail(await call("makap_backend.sales_order_api.get_sales_order", { name })); } catch (e) { detail.innerHTML = `
Could not load sales order${esc(e.message || "Please try again.")}
`; } } + async function loadWorkspace() { list.innerHTML = `
Loading sales orders...
`; 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 = `
Sales orders unavailable${esc(e.message || "Check your permissions and try again.")}
`; } } + 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(); +}; diff --git a/makap_backend/makap_backend/page/makap_sales_orders/makap_sales_orders.json b/makap_backend/makap_backend/page/makap_sales_orders/makap_sales_orders.json new file mode 100644 index 0000000..309b365 --- /dev/null +++ b/makap_backend/makap_backend/page/makap_sales_orders/makap_sales_orders.json @@ -0,0 +1,7 @@ +{ + "doctype": "Page", + "name": "makap-sales-orders", + "module": "makap_backend", + "standard": "Yes", + "title": "MAKAP Sales Orders" +} diff --git a/makap_backend/makap_backend/page/makap_specifications/makap_specifications.css b/makap_backend/makap_backend/page/makap_specifications/makap_specifications.css new file mode 100644 index 0000000..a9c64ea --- /dev/null +++ b/makap_backend/makap_backend/page/makap_specifications/makap_specifications.css @@ -0,0 +1,4 @@ +.makap-spec-shell{--s-ink:#19343b;--s-muted:#71868b;--s-line:#e1ecea;--s-teal:#0c766c;--s-mint:#e3f5ef;--s-shadow:0 18px 50px rgba(20,68,70,.07);max-width:1500px;margin:0 auto;padding:34px 42px 48px;color:var(--s-ink);font-family:"DM Sans",sans-serif}.makap-spec-head{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:25px}.makap-spec-eyebrow{text-transform:uppercase;letter-spacing:.14em;font-size:10px;font-weight:700;color:#87a19e;margin-bottom:8px}.makap-spec-head h1{font:600 31px "Space Grotesk",sans-serif;letter-spacing:-.045em;margin:0 0 8px}.makap-spec-head p{font-size:13px;color:var(--s-muted);margin:0;max-width:720px}.makap-spec-actions{display:flex;gap:9px}.makap-spec-actions .btn-primary{background:var(--s-teal);border-color:var(--s-teal)}.makap-spec-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:13px;margin-bottom:17px}.makap-spec-metrics>div{background:#fff;border:1px solid var(--s-line);border-radius:10px;padding:17px 19px;box-shadow:var(--s-shadow)}.makap-spec-metrics span,.makap-spec-metrics small{display:block}.makap-spec-metrics span{font-size:10px;color:var(--s-muted);font-weight:700;text-transform:uppercase;letter-spacing:.07em}.makap-spec-metrics strong{font:600 27px "Space Grotesk",sans-serif;display:block;margin:8px 0 3px;letter-spacing:-.04em}.makap-spec-metrics small{font-size:10px;color:#91a09f}.makap-spec-tabs{display:flex;gap:5px;background:#edf5f2;border:1px solid #dfece8;padding:5px;border-radius:10px 10px 0 0}.makap-spec-tabs button{border:0;background:transparent;color:#66817d;border-radius:7px;padding:10px 14px;font:600 11px "DM Sans";cursor:pointer}.makap-spec-tabs button i{margin-right:6px}.makap-spec-tabs button.is-active{background:#fff;color:var(--s-teal);box-shadow:0 2px 8px #1c645d12}.makap-spec-workspace{display:grid;grid-template-columns:minmax(360px,.9fr) minmax(520px,1.55fr);min-height:650px;background:#fff;border:1px solid var(--s-line);border-top:0;border-radius:0 0 14px 14px;overflow:hidden;box-shadow:var(--s-shadow)}.makap-spec-list-panel{border-right:1px solid var(--s-line);background:#fbfdfd}.makap-spec-list-head{padding:21px 22px 16px;border-bottom:1px solid var(--s-line)}.makap-spec-list-head strong{font:600 17px "Space Grotesk",sans-serif;display:block}.makap-spec-list-head span{font-size:10px;color:#91a09f;display:block;margin-top:3px}.makap-spec-search{display:flex;align-items:center;gap:8px;background:#f2f7f5;border:1px solid #e3eeea;border-radius:7px;padding:9px 11px;color:#8fa09c;margin-top:16px}.makap-spec-search input{border:0;background:transparent;outline:0;width:100%;font:11px "DM Sans";color:var(--s-ink)}.makap-spec-status{border:1px solid #e3eeea;background:#f8fbfa;color:#66807c;border-radius:7px;padding:7px;width:100%;font-size:10px;margin-top:8px}.makap-spec-list{padding:8px}.makap-spec-row{width:100%;display:flex;align-items:center;gap:11px;border:0;border-radius:9px;background:transparent;padding:12px 10px;text-align:left;cursor:pointer;color:var(--s-ink);transition:.15s}.makap-spec-row:hover{background:#f0f7f4}.makap-spec-row.is-active{background:var(--s-mint);box-shadow:inset 3px 0 var(--s-teal)}.makap-spec-avatar,.makap-spec-detail-avatar{display:grid;place-items:center;border-radius:50%;background:#d5eee6;color:var(--s-teal);font-family:"Space Grotesk",sans-serif;font-weight:700}.makap-spec-avatar{width:35px;height:35px;font-size:11px;flex:none}.makap-spec-avatar.material{background:#edf5fb;color:#5384a5}.makap-spec-avatar.acceptance{background:#fff5e4;color:#bd7d28}.makap-spec-avatar.item{background:#f5f0fb;color:#8067a9}.makap-spec-row-copy{flex:1;min-width:0}.makap-spec-row-copy strong,.makap-spec-row-copy span,.makap-spec-row-copy small{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.makap-spec-row-copy strong{font-size:12px;font-weight:700}.makap-spec-row-copy span{font-size:10px;color:var(--s-muted);margin-top:3px}.makap-spec-row-copy small{font-size:9px;color:#9aa9a7;margin-top:4px}.makap-spec-chevron{color:#a7b7b4}.makap-spec-status{border-radius:20px;padding:4px 7px;font-size:9px;font-weight:700;white-space:nowrap}.makap-spec-status.draft{background:#eef4f3;color:#66817d}.makap-spec-status.quote{background:#fff5e4;color:#bd7d28}.makap-spec-status.approved{background:#e8f7ed;color:#4c9b73}.makap-spec-status.superseded,.makap-spec-status.withdrawn{background:#fff0f0;color:#b96868}.makap-spec-detail{background:#fff}.makap-spec-detail-hero{padding:30px 33px 27px;background:linear-gradient(115deg,#f0faf6,#f4f9ff);border-bottom:1px solid var(--s-line);display:flex;align-items:center;gap:16px}.makap-spec-detail-hero.compact{padding:27px 33px}.makap-spec-detail-avatar{width:62px;height:62px;font-size:20px;flex:none}.makap-spec-detail-avatar.material{background:#edf5fb;color:#5384a5}.makap-spec-detail-avatar.acceptance{background:#fff5e4;color:#bd7d28}.makap-spec-detail-avatar.item{background:#f5f0fb;color:#8067a9}.makap-spec-detail-identity{min-width:0;flex:1}.makap-spec-detail-identity h2{font:600 24px "Space Grotesk",sans-serif;letter-spacing:-.04em;margin:7px 0 4px}.makap-spec-detail-identity p{color:var(--s-muted);font-size:11px;margin:0}.makap-spec-kicker{text-transform:uppercase;letter-spacing:.1em;color:#8ca09d;font-size:9px;font-weight:700}.makap-spec-detail-body{padding:4px 33px 30px}.makap-spec-value-card{margin-top:21px;border:1px solid #dcebe7;background:linear-gradient(135deg,#f5fcf9,#f7fbff);border-radius:10px;padding:18px 20px;display:flex;align-items:center;justify-content:space-between}.makap-spec-value-card span,.makap-spec-value-card small{display:block}.makap-spec-value-card span{color:#819691;font-size:10px;text-transform:uppercase;letter-spacing:.09em;font-weight:700}.makap-spec-value-card strong{font:600 22px "Space Grotesk",sans-serif;display:block;margin:6px 0 2px;letter-spacing:-.04em}.makap-spec-value-card small{font-size:10px;color:#8fa19e}.makap-spec-readiness{display:flex;flex-direction:column;align-items:center;gap:5px;color:var(--s-teal);font-size:11px}.makap-spec-readiness i{font-size:19px}.makap-spec-readiness span{font-size:9px;color:#8fa19e}.makap-spec-detail-section{padding:23px 0;border-bottom:1px solid #edf3f1}.makap-spec-detail-section:last-child{border-bottom:0}.makap-spec-section-title{text-transform:uppercase;letter-spacing:.12em;color:#8ca09d;font-weight:700;font-size:9px;margin-bottom:15px}.makap-spec-fields{display:grid;grid-template-columns:1fr 1fr;gap:17px 30px}.makap-spec-fields span{display:block;color:#93a29f;font-size:10px;margin-bottom:5px}.makap-spec-fields strong{display:block;font-size:12px;font-weight:600}.makap-spec-mini-row{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid #f0f4f2}.makap-spec-mini-row:last-child{border-bottom:0}.makap-spec-mini-row>b{display:grid;place-items:center;width:24px;height:24px;border-radius:6px;background:#edf5fb;color:#5384a5;font-size:10px}.makap-spec-mini-row span{flex:1}.makap-spec-mini-row strong,.makap-spec-mini-row small{display:block}.makap-spec-mini-row strong{font-size:11px}.makap-spec-mini-row small{font-size:9px;color:var(--s-muted);margin-top:3px}.makap-spec-note{font-size:11px;color:#92a09e;padding:5px 0}.makap-spec-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:#95a3a1;padding:50px 25px;font-size:11px;min-height:180px}.makap-spec-empty strong{color:#6f8280;font-size:12px;margin:10px 0 4px}.makap-spec-empty span{max-width:240px;line-height:1.5}.makap-spec-detail-empty{min-height:640px}.makap-spec-empty-icon{display:grid;place-items:center;width:42px;height:42px;border-radius:12px;background:var(--s-mint);color:var(--s-teal);font-size:17px}.makap-loading-ring{width:22px;height:22px;border:2px solid #d9eae5;border-top-color:var(--s-teal);border-radius:50%;animation:sspin .8s linear infinite}@keyframes sspin{to{transform:rotate(360deg)}} +@media(max-width:1100px){.makap-spec-shell{padding:26px 20px 40px}.makap-spec-metrics{grid-template-columns:repeat(2,1fr)}.makap-spec-workspace{grid-template-columns:minmax(300px,.85fr) minmax(430px,1.3fr)}} +@media(max-width:820px){.makap-spec-head{display:block}.makap-spec-actions{margin-top:18px}.makap-spec-tabs{overflow:auto}.makap-spec-tabs button{white-space:nowrap}.makap-spec-workspace{grid-template-columns:1fr}.makap-spec-list-panel{border-right:0;border-bottom:1px solid var(--s-line);max-height:390px}.makap-spec-detail-empty{min-height:320px}.makap-spec-detail-hero>a{display:none}} +@media(max-width:560px){.makap-spec-metrics{grid-template-columns:1fr 1fr;gap:8px}.makap-spec-metrics>div{padding:13px}.makap-spec-metrics strong{font-size:22px}.makap-spec-detail-hero{padding:23px 20px}.makap-spec-detail-body{padding-left:20px;padding-right:20px}.makap-spec-fields{grid-template-columns:1fr}.makap-spec-detail-identity h2{font-size:20px}} diff --git a/makap_backend/makap_backend/page/makap_specifications/makap_specifications.js b/makap_backend/makap_backend/page/makap_specifications/makap_specifications.js new file mode 100644 index 0000000..85029bf --- /dev/null +++ b/makap_backend/makap_backend/page/makap_specifications/makap_specifications.js @@ -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(` +
+
Specification & prepress

Specification Control Center

Keep technical definitions, material layers, acceptance controls, and item masters aligned in one workspace.

+
Specifications—Technical definitions
Approved for manufacture—Current production-ready revisions
Quote eligible—Available to commercial teams
Active items—Enabled item masters
+ +
Select a recordChoose a record from the active tab to inspect its technical details.
+
+ `); + 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 = `
No specifications foundTry a different status or search term.
`; return; } list.innerHTML = rows.map((r) => ``).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) => ``).join("") : `
No material layers foundAdd material rows inside a Packaging Specification.
`; 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) => ``).join("") : `
No acceptance requirements foundAdd acceptance rows inside a Packaging Specification.
`; 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) => ``).join("") : `
No items foundTry a different item name or group.
`; 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) => `
${esc(r.sequence)}${esc(r.material_film_description)}${esc(r.raw_material_item || "No raw item")} · ${esc(r.nominal_thickness_requirement || "")} ${esc(r.uom || "")}
`).join("") : `
No material structure defined.
`; const acceptance = s.acceptance_requirements?.length ? s.acceptance_requirements.map((r) => `
${esc(r.characteristic)}${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)}` : ""}
`).join("") : `
No critical acceptance requirements defined.
`; detail.innerHTML = `
${esc(initials(s.specification_series || s.name))}
${esc(s.specification_status)}

${esc(s.specification_series || s.name)}

Revision ${esc(s.revision || "—")} · ${esc(s.customer || "No customer")}

Open ERPNext form
Product definition${esc(s.product_family || s.item || "Unclassified product")}${esc(s.material_substrate_summary || s.customer_facing_technical_summary || "No technical summary")}
${s.quote_eligible ? "Quote eligible" : "Not quote eligible"}
Dimensions and process
Item${esc(s.item || "Not linked")}
Dimensions${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")}
Process${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")}
Artwork${esc(s.artwork_required ? (s.artwork_status || "Required") : "Not required")}
Material structure
${materials}
Critical acceptance
${acceptance}
`; 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 = `
${esc(r.sequence)}
Material structure

${esc(r.material_film_description)}

Parent specification ${esc(r.parent)}

Open parent spec
Raw material item${esc(r.raw_material_item || "Not linked")}${esc(r.layer_role_notes || "No layer notes")}
Layer ${esc(r.sequence)}
Layer requirement
Material / film${esc(r.material_film_description)}
Nominal thickness${esc(r.nominal_thickness_requirement || "Not set")} ${esc(r.uom || "")}
Customer${esc(r.customer || "Not set")}
Specification status${esc(r.specification_status || "Not set")}
`; } + function renderAcceptanceDetail(r) { activeName = r.parent + ":" + r.characteristic; detail.innerHTML = `
Critical acceptance

${esc(r.characteristic)}

Parent specification ${esc(r.parent)}

Open parent spec
Requirement / nominal${esc(r.requirement_nominal || "Not set")} ${esc(r.unit || "")}${esc(r.notes || "No acceptance notes")}
Controlled requirement
Tolerance window
Minimum / lower tolerance${esc(r.minimum_lower_tolerance ?? "Not set")}
Maximum / upper tolerance${esc(r.maximum_upper_tolerance ?? "Not set")}
Unit${esc(r.unit || "Not set")}
Customer${esc(r.customer || "Not set")}
`; } + function renderItemDetail(i) { activeName = i.name; detail.innerHTML = `
${esc(initials(i.item_name || i.name))}
${i.disabled ? "Disabled" : "Active"}

${esc(i.item_name || i.name)}

${esc(i.item_code || i.name)} · ${esc(i.item_group || "No group")}

Open ERPNext form
Item master${esc(i.item_code || i.name)}${esc(i.description || "No item description")}
${esc(i.stock_uom || "No UOM")}
Item controls
Item group${esc(i.item_group || "Not set")}
Stock item${i.is_stock_item ? "Yes" : "No"}
Standard rate${money(i.standard_rate)}
Last purchase rate${money(i.last_purchase_rate)}
`; } + async function selectRecord(name) { const nameParts = name.split(":"); activeName = name; detail.innerHTML = `
Loading recordFetching the latest details.
`; 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 = `
Could not load record${esc(e.message || "Please try again.")}
`; } } + async function loadRecords() { list.innerHTML = `
Loading records...
`; 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 = `
Records unavailable${esc(e.message || "Check your permissions and try again.")}
`; } } + 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); })(); +}; diff --git a/makap_backend/makap_backend/page/makap_specifications/makap_specifications.json b/makap_backend/makap_backend/page/makap_specifications/makap_specifications.json new file mode 100644 index 0000000..cd7c264 --- /dev/null +++ b/makap_backend/makap_backend/page/makap_specifications/makap_specifications.json @@ -0,0 +1,7 @@ +{ + "doctype": "Page", + "name": "makap-specifications", + "module": "makap_backend", + "standard": "Yes", + "title": "MAKAP Specification Control Center" +} diff --git a/makap_backend/makap_backend/page/makap_supplier_reconciliation/makap_supplier_reconciliation.css b/makap_backend/makap_backend/page/makap_supplier_reconciliation/makap_supplier_reconciliation.css new file mode 100644 index 0000000..6d2d6cb --- /dev/null +++ b/makap_backend/makap_backend/page/makap_supplier_reconciliation/makap_supplier_reconciliation.css @@ -0,0 +1 @@ +.makap-sup-shell{--s-ink:#19343b;--s-muted:#71868b;--s-line:#e1ecea;--s-teal:#0c766c;--s-mint:#e3f5ef;--s-shadow:0 18px 50px rgba(20,68,70,.07);max-width:1500px;margin:0 auto;padding:34px 42px 48px;color:var(--s-ink);font-family:"DM Sans",sans-serif}.makap-sup-head{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:25px}.makap-sup-eyebrow{text-transform:uppercase;letter-spacing:.14em;font-size:10px;font-weight:700;color:#87a19e;margin-bottom:8px}.makap-sup-head h1{font:600 31px "Space Grotesk",sans-serif;letter-spacing:-.045em;margin:0 0 8px}.makap-sup-head p{font-size:13px;color:var(--s-muted);margin:0}.makap-sup-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:13px;margin-bottom:17px}.makap-sup-metrics>div{background:#fff;border:1px solid var(--s-line);border-radius:10px;padding:17px 19px;box-shadow:var(--s-shadow)}.makap-sup-metrics span,.makap-sup-metrics small{display:block}.makap-sup-metrics span{font-size:10px;color:var(--s-muted);font-weight:700;text-transform:uppercase;letter-spacing:.07em}.makap-sup-metrics strong{font:600 27px "Space Grotesk",sans-serif;display:block;margin:8px 0 3px}.makap-sup-metrics small{font-size:10px;color:#91a09f}.makap-sup-metrics .warn{background:#fffaf0;border-color:#f1dfbd}.makap-sup-metrics .warn strong{color:#bd7d28}.makap-sup-metrics .bad{background:#fff8f6;border-color:#f1d6d1}.makap-sup-metrics .bad strong{color:#b96868}.makap-sup-tabs{display:flex;gap:5px;background:#edf5f2;border:1px solid #dfece8;padding:5px;border-radius:10px 10px 0 0}.makap-sup-tabs button{border:0;background:transparent;color:#66817d;border-radius:7px;padding:10px 15px;font:600 11px "DM Sans";cursor:pointer}.makap-sup-tabs button.active{background:#fff;color:var(--s-teal);box-shadow:0 2px 8px #1c645d12}.makap-sup-work{display:grid;grid-template-columns:minmax(370px,.9fr) minmax(520px,1.55fr);min-height:690px;background:#fff;border:1px solid var(--s-line);border-top:0;border-radius:0 0 14px 14px;overflow:hidden;box-shadow:var(--s-shadow)}.makap-sup-listpanel{border-right:1px solid var(--s-line);background:#fbfdfd}.makap-sup-listhead{padding:21px 22px 16px;border-bottom:1px solid var(--s-line)}.makap-sup-listhead strong{font:600 17px "Space Grotesk",sans-serif;display:block}.makap-sup-listhead span{font-size:10px;color:#91a09f;display:block;margin-top:3px}.makap-sup-search{display:flex;gap:8px;align-items:center;background:#f2f7f5;border:1px solid #e3eeea;border-radius:7px;padding:9px 11px;color:#8fa09c;margin-top:16px}.makap-sup-search input{border:0;background:transparent;outline:0;width:100%;font:11px "DM Sans"}.makap-sup-listhead select{border:1px solid #e3eeea;background:#f8fbfa;color:#66807c;border-radius:7px;padding:7px;width:100%;font-size:10px;margin-top:8px}.makap-sup-list{padding:8px}.makap-sup-row{width:100%;display:flex;align-items:center;gap:11px;border:0;border-radius:9px;background:transparent;padding:12px 10px;text-align:left;cursor:pointer;color:var(--s-ink)}.makap-sup-row:hover,.makap-sup-row.active{background:var(--s-mint);box-shadow:inset 3px 0 var(--s-teal)}.sup-avatar,.sup-detail-avatar{display:grid;place-items:center;border-radius:50%;background:#d5eee6;color:var(--s-teal);font-family:"Space Grotesk",sans-serif;font-weight:700}.sup-avatar{width:35px;height:35px;font-size:10px;flex:none}.sup-avatar.bad,.sup-detail-avatar.bad{background:#fff0f0;color:#b96868}.sup-avatar.warn,.sup-detail-avatar.warn{background:#fff5e4;color:#bd7d28}.sup-avatar.good,.sup-detail-avatar.good{background:#e8f7ed;color:#4c9b73}.sup-copy{flex:1;min-width:0}.sup-copy strong,.sup-copy span,.sup-copy small{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sup-copy strong{font-size:12px}.sup-copy span{font-size:10px;color:var(--s-muted);margin-top:3px}.sup-copy small{font-size:9px;color:#9aa9a7;margin-top:4px}.sup-badge{border-radius:20px;padding:4px 7px;font-size:9px;white-space:nowrap}.sup-badge.bad{background:#fff0f0;color:#b96868}.sup-badge.warn{background:#fff5e4;color:#bd7d28}.sup-badge.good{background:#e8f7ed;color:#4c9b73}.makap-sup-detail{background:#fff}.makap-sup-hero{padding:30px 33px 27px;background:linear-gradient(115deg,#f0faf6,#f4f9ff);border-bottom:1px solid var(--s-line);display:flex;align-items:center;gap:16px}.sup-detail-avatar{width:62px;height:62px;font-size:18px;flex:none}.sup-ident{flex:1;min-width:0}.sup-ident>span{font-size:9px;text-transform:uppercase;letter-spacing:.12em;color:#8ca09d;font-weight:700}.sup-ident h2{font:600 24px "Space Grotesk",sans-serif;letter-spacing:-.04em;margin:7px 0 4px}.sup-ident p{font-size:11px;color:var(--s-muted);margin:0}.makap-sup-body{padding:4px 33px 30px}.sup-state{margin-top:21px;border:1px solid #dcebe7;background:#f5fcf9;border-radius:10px;padding:18px 20px;display:flex;justify-content:space-between;align-items:center}.sup-state.warn{background:#fffaf0;border-color:#f1dfbd}.sup-state.bad{background:#fff8f6;border-color:#f1d6d1}.sup-state span,.sup-state small{display:block}.sup-state span{font-size:10px;text-transform:uppercase;letter-spacing:.09em;color:#819691;font-weight:700}.sup-state strong{font:600 22px "Space Grotesk",sans-serif;display:block;margin:6px 0 2px}.sup-state small{font-size:10px;color:#8fa19e}.sup-state i{font-size:22px;color:var(--s-teal)}.sup-state.warn i{color:#bd7d28}.sup-state.bad i{color:#b96868}.sup-section{padding:23px 0;border-bottom:1px solid #edf3f1}.sup-title{text-transform:uppercase;letter-spacing:.12em;color:#8ca09d;font-weight:700;font-size:9px;margin-bottom:15px}.sup-fields{display:grid;grid-template-columns:1fr 1fr;gap:17px 30px}.sup-fields span{display:block;color:#93a29f;font-size:10px;margin-bottom:5px}.sup-fields strong{display:block;font-size:12px}.sup-mini{display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid #f0f4f2;font-size:11px}.sup-mini:last-child{border:0}.sup-mini strong,.sup-mini small{display:block}.sup-mini small{font-size:9px;color:var(--s-muted);margin-top:3px}.sup-mini b{font-size:10px;color:#536f6b}.sup-note{font-size:11px;color:#92a09e}.makap-sup-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:#95a3a1;padding:50px 25px;font-size:11px;min-height:180px}.makap-sup-empty strong{color:#6f8280;font-size:12px;margin:10px 0 4px}.makap-sup-empty span{max-width:250px;line-height:1.5}.detail-empty{min-height:690px}.load-ring{width:22px;height:22px;border:2px solid #d9eae5;border-top-color:var(--s-teal);border-radius:50%;animation:supspin .8s linear infinite}@keyframes supspin{to{transform:rotate(360deg)}}@media(max-width:1100px){.makap-sup-shell{padding:26px 20px 40px}.makap-sup-metrics{grid-template-columns:repeat(2,1fr)}.makap-sup-work{grid-template-columns:minmax(300px,.85fr) minmax(430px,1.3fr)}}@media(max-width:820px){.makap-sup-head{display:block}.makap-sup-head button{margin-top:18px}.makap-sup-tabs{overflow:auto}.makap-sup-tabs button{white-space:nowrap}.makap-sup-work{grid-template-columns:1fr}.makap-sup-listpanel{border-right:0;border-bottom:1px solid var(--s-line);max-height:390px}.detail-empty{min-height:320px}.makap-sup-hero>a{display:none}}@media(max-width:560px){.makap-sup-metrics{grid-template-columns:1fr 1fr;gap:8px}.makap-sup-metrics>div{padding:13px}.makap-sup-metrics strong{font-size:22px}.makap-sup-hero{padding:23px 20px}.makap-sup-body{padding-left:20px;padding-right:20px}.sup-fields{grid-template-columns:1fr}.sup-ident h2{font-size:20px}} diff --git a/makap_backend/makap_backend/page/makap_supplier_reconciliation/makap_supplier_reconciliation.js b/makap_backend/makap_backend/page/makap_supplier_reconciliation/makap_supplier_reconciliation.js new file mode 100644 index 0000000..f1d72af --- /dev/null +++ b/makap_backend/makap_backend/page/makap_supplier_reconciliation/makap_supplier_reconciliation.js @@ -0,0 +1 @@ +frappe.pages["makap-supplier-reconciliation"].on_page_load=function(w){const p=frappe.ui.make_app_page({parent:w,title:"Supplier Reconciliation & Cost Control Center",single_column:true});p.main.html(`
Supplier finance & cost evidence

Supplier Reconciliation & Cost Control

Match Purchase Invoices against purchasing and receiving evidence before supplier costs flow into close-out and margin.

Open invoices—Supplier invoices in progress
Awaiting match—Not yet reconciled
Quantity variances—Receipt quantity mismatch
Rate variances—Purchase rate mismatch
Select a supplier recordReview invoice status, matching evidence, and cost variance here.
`);const r=p.main[0],list=r.querySelector("[data-list]"),detail=r.querySelector("[data-detail]"),search=r.querySelector("[data-search]"),filter=r.querySelector("[data-filter]"),count=r.querySelector("[data-count]"),title=r.querySelector("[data-title]");let tab=new URLSearchParams(location.search).get("tab")||"invoices",active=null,timer;const esc=v=>frappe.utils.escape_html(String(v==null?"":v)),num=v=>new Intl.NumberFormat().format(Number(v||0)),money=v=>Number(v||0).toLocaleString(undefined,{maximumFractionDigits:0}),date=v=>v?frappe.datetime.str_to_user(v):"Not set",ini=v=>(String(v||"?").split(/\s+/).slice(0,2).map(x=>x[0]).join("")||"?").toUpperCase(),call=(m,a={})=>new Promise((ok,no)=>frappe.call({method:m,args:a,callback:x=>x.exc?no(x.exc):ok(x.message)})),tone=s=>({"Quantity Variance":"bad","Rate Variance":"bad","Both Variance":"bad",Disputed:"bad",Resolved:"good",Matched:"good",Not\ Assessed:"warn"}[s]||"warn");function metrics(x){["open_invoices","awaiting_match","quantity_variances","rate_variances"].forEach(k=>r.querySelector(`[data-m="${k}"]`).textContent=num(x[k]))}function setTab(t){tab=t;active=null;r.querySelectorAll("[data-tab]").forEach(e=>e.classList.toggle("active",e.dataset.tab===t));title.textContent=t==="invoices"?"Purchase Invoice queue":t==="variances"?"Supplier variance queue":"Supplier cost view";load()}function row(x){let stat=tab==="cost"?(x.match_status||"Not Assessed"):x.makap_supplier_invoice_match_status||"Not Assessed",id=tab==="cost"?x.invoice:x.name;return ``}function render(rs){count.textContent=`${rs.length} record${rs.length===1?"":"s"}`;list.innerHTML=rs.length?rs.map(row).join(""):`
No supplier records foundTry another search or matching status.
`;list.querySelectorAll("[data-n]").forEach(e=>e.onclick=()=>select(e.dataset.n))}function detailInvoice(x){active=x.name;let lines=(x.items||[]).map(i=>`
${esc(i.item_name||i.item_code||"Item")}${esc(i.item_code||"")} · PO ${esc(i.purchase_order||"Not linked")} · Receipt ${esc(i.purchase_receipt||"Not linked")}${money(i.amount)}
`).join("")||`
No invoice lines available.
`;detail.innerHTML=`
${esc(ini(x.supplier||x.name))}
Purchase Invoice

${esc(x.name)}

${esc(x.supplier||"No supplier")} · ${esc(x.status||"Draft")}

Open ERPNext form
Supplier match status${esc(x.makap_supplier_invoice_match_status||"Not Assessed")}${esc(x.makap_match_notes||"No match notes recorded")}
Invoice position
Supplier${esc(x.supplier||"Not set")}
Invoice status${esc(x.status||"Draft")}
Grand total${esc(x.currency||"")} ${money(x.grand_total)}
Outstanding${esc(x.currency||"")} ${money(x.outstanding_amount)}
Posting date${date(x.posting_date)}
Due date${date(x.due_date)}
Invoice lines and references
${lines}
`;paint()}function detailCost(x){active=x.invoice;detail.innerHTML=`
${esc(ini(x.supplier||x.invoice))}
Supplier cost evidence

${esc(x.item_name||x.item_code||"Item")}

${esc(x.supplier||"No supplier")} · Invoice ${esc(x.invoice)}

Open Invoice
Matching state${esc(x.match_status||"Not Assessed")}${esc(x.item_code||"No item code")} · ${esc(x.currency||"")}
Quantity and rate evidence
Invoiced quantity${esc(x.qty||0)}
Received quantity${esc(x.received_qty||0)}
Invoice rate${money(x.rate)}
Line amount${money(x.amount)}
Quantity variance${esc(x.quantity_variance||0)}
Rate variance${esc(x.rate_variance||0)}
Source references
Purchase Order${esc(x.purchase_order||"Not linked")}
Purchase Receipt${esc(x.purchase_receipt||"Not linked")}
Posting date${date(x.posting_date)}
Supplier${esc(x.supplier||"Not set")}
`;paint()}function paint(){r.querySelectorAll(".makap-sup-row").forEach(e=>e.classList.toggle("active",e.dataset.n===active))}async function select(n){active=n;detail.innerHTML=`
Loading supplier evidence
`;try{if(tab==="cost"){let rs=await call("makap_backend.supplier_reconciliation_api.list_supplier_cost",{search:search.value});detailCost(rs.find(x=>x.invoice===n)||rs[0])}else detailInvoice(await call("makap_backend.supplier_reconciliation_api.get_purchase_invoice",{name:n}))}catch(e){detail.innerHTML=`
Could not load supplier record${esc(e.message||"Please try again.")}
`}}async function load(){list.innerHTML=`
Loading supplier evidence...
`;try{let rs;if(tab==="invoices")rs=await call("makap_backend.supplier_reconciliation_api.list_purchase_invoices",{search:search.value,match_status:filter.value});if(tab==="variances")rs=await call("makap_backend.supplier_reconciliation_api.list_supplier_variances",{search:search.value,match_status:filter.value});if(tab==="cost")rs=await call("makap_backend.supplier_reconciliation_api.list_supplier_cost",{search:search.value});render(rs||[]);if(!active&&rs?.[0])select(tab==="cost"?rs[0].invoice:rs[0].name)}catch(e){list.innerHTML=`
Supplier reconciliation unavailable${esc(e.message||"Check permissions.")}
`}}r.querySelectorAll("[data-tab]").forEach(e=>e.onclick=()=>setTab(e.dataset.tab));search.oninput=()=>{clearTimeout(timer);timer=setTimeout(load,250)};filter.onchange=load;r.querySelector("[data-refresh]").onclick=async()=>{metrics(await call("makap_backend.supplier_reconciliation_api.get_supplier_summary"));load()};(async()=>{try{metrics(await call("makap_backend.supplier_reconciliation_api.get_supplier_summary"))}catch(e){}setTab(tab)})()}; diff --git a/makap_backend/makap_backend/page/makap_supplier_reconciliation/makap_supplier_reconciliation.json b/makap_backend/makap_backend/page/makap_supplier_reconciliation/makap_supplier_reconciliation.json new file mode 100644 index 0000000..94f4b2d --- /dev/null +++ b/makap_backend/makap_backend/page/makap_supplier_reconciliation/makap_supplier_reconciliation.json @@ -0,0 +1 @@ +{"doctype":"Page","name":"makap-supplier-reconciliation","module":"makap_backend","standard":"Yes","title":"MAKAP Supplier Reconciliation & Cost Control Center"} diff --git a/makap_backend/makap_backend/portal_api.py b/makap_backend/makap_backend/portal_api.py new file mode 100644 index 0000000..268b88f --- /dev/null +++ b/makap_backend/makap_backend/portal_api.py @@ -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), + } diff --git a/makap_backend/makap_backend/portal_crud.py b/makap_backend/makap_backend/portal_crud.py new file mode 100644 index 0000000..a177620 --- /dev/null +++ b/makap_backend/makap_backend/portal_crud.py @@ -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} diff --git a/makap_backend/makap_backend/portal_shortcut.py b/makap_backend/makap_backend/portal_shortcut.py new file mode 100644 index 0000000..89b3b0c --- /dev/null +++ b/makap_backend/makap_backend/portal_shortcut.py @@ -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() diff --git a/makap_backend/makap_backend/production_api.py b/makap_backend/makap_backend/production_api.py new file mode 100644 index 0000000..4eb5d66 --- /dev/null +++ b/makap_backend/makap_backend/production_api.py @@ -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() diff --git a/makap_backend/makap_backend/quality_api.py b/makap_backend/makap_backend/quality_api.py new file mode 100644 index 0000000..e9ebaf6 --- /dev/null +++ b/makap_backend/makap_backend/quality_api.py @@ -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 diff --git a/makap_backend/makap_backend/quotation_api.py b/makap_backend/makap_backend/quotation_api.py new file mode 100644 index 0000000..d513f2b --- /dev/null +++ b/makap_backend/makap_backend/quotation_api.py @@ -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, + } diff --git a/makap_backend/makap_backend/reconfigure_makap_google.py b/makap_backend/makap_backend/reconfigure_makap_google.py new file mode 100644 index 0000000..0d4390b --- /dev/null +++ b/makap_backend/makap_backend/reconfigure_makap_google.py @@ -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, + } diff --git a/makap_backend/makap_backend/returns_api.py b/makap_backend/makap_backend/returns_api.py new file mode 100644 index 0000000..df7f215 --- /dev/null +++ b/makap_backend/makap_backend/returns_api.py @@ -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) diff --git a/makap_backend/makap_backend/sales_order_api.py b/makap_backend/makap_backend/sales_order_api.py new file mode 100644 index 0000000..efcd5bf --- /dev/null +++ b/makap_backend/makap_backend/sales_order_api.py @@ -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, + } diff --git a/makap_backend/makap_backend/setup.py b/makap_backend/makap_backend/setup.py index 1ca77e0..ed98283 100644 --- a/makap_backend/makap_backend/setup.py +++ b/makap_backend/makap_backend/setup.py @@ -97,6 +97,12 @@ def ensure_makap_functional_roles(): "Materials Controller", "Supply Assurance Manager", "Concession Authority", + # Module 8A + "Dispatch Preparer", + "Dispatch Authority", + "Commercial Authority", + # Module 8B + "Credit Authority", ] created = [] for role_name in role_names: @@ -125,36 +131,10 @@ def ensure_makap_object_permissions(): "Production Authority": {"read": 1, "print": 1, "report": 1}, "Quality Manager": {"read": 1, "print": 1, "report": 1}, }, - } - - for doctype_name, role_map in permissions.items(): - doc = frappe.get_doc("DocType", doctype_name) - existing = {perm.role for perm in doc.permissions} - changed = False - for role_name, flags in role_map.items(): - if role_name in existing: - continue - row = {"role": role_name} - row.update(flags) - doc.append("permissions", row) - changed = True - if changed: - doc.save(ignore_permissions=True) - - frappe.db.commit() - return permissions - - -def ensure_makap_object_permissions(): - permissions = { - "Customer Onboarding Request": { - "Commercial Reviewer": {"read": 1, "write": 1, "create": 1, "print": 1, "report": 1, "export": 1}, - }, - "Packaging Specification": { - "Commercial Reviewer": {"read": 1, "write": 1, "create": 1, "print": 1, "report": 1, "export": 1}, - "Technical Authority": {"read": 1, "write": 1, "create": 1, "print": 1, "report": 1, "export": 1}, - "Production Authority": {"read": 1, "print": 1, "report": 1}, - "Quality Manager": {"read": 1, "print": 1, "report": 1}, + "Order Closure And Margin View": { + "Commercial Reviewer": {"read": 1, "print": 1, "report": 1, "export": 1}, + "Credit Authority": {"read": 1, "write": 1, "print": 1, "report": 1, "export": 1}, + "Dispatch Authority": {"read": 1, "print": 1, "report": 1}, }, } diff --git a/makap_backend/makap_backend/setup_extensions.py b/makap_backend/makap_backend/setup_extensions.py index 9b6780c..1f6c705 100644 --- a/makap_backend/makap_backend/setup_extensions.py +++ b/makap_backend/makap_backend/setup_extensions.py @@ -75,6 +75,64 @@ FIELDS = [ ("Purchase Order Item", "makap_supplier_confirmation_reference", "Supplier Confirmation Reference", "Data", None, 0), ("Purchase Order Item", "makap_expected_eligible_date", "Expected Eligible Date", "Date", None, 0), ("Purchase Receipt Item", "makap_receipt_exception_type", "Receipt Exception Type", "Select", "None\nShort Receipt\nOver Receipt\nDamaged\nQuality Hold\nRejected\nDocumentation Exception", 0), + # Module 8A — dispatch and fulfilment control (Delivery Note header) + ("Delivery Note", "makap_dispatch_gate_status", "Dispatch Gate Status", "Select", "Not Assessed\nBlocked\nReady", 1), + ("Delivery Note", "makap_dispatch_gate_blockers", "Dispatch Gate Blockers", "Small Text", None, 1), + ("Delivery Note", "makap_dispatch_gate_checked_by", "Dispatch Gate Checked By", "Link", "User", 1), + ("Delivery Note", "makap_dispatch_gate_checked_on", "Dispatch Gate Checked On", "Datetime", None, 1), + ("Delivery Note", "makap_dispatch_material_accountability_status", "Dispatch Material Accountability", "Select", "Not Assessed\nCleared\nException", 0), + ("Delivery Note", "makap_dispatch_accountability_notes", "Dispatch Accountability Notes", "Small Text", None, 0), + # Placeholder only — real deposit/credit logic lands in Module 8B. Defaults to Not Required + # so the Dispatch Gate can already evaluate it without blocking every delivery today. + ("Delivery Note", "makap_financial_release_status", "Financial Release Status", "Select", "Not Required\nNot Assessed\nBlocked\nCleared\nException Approved", 0), + ("Delivery Note", "makap_short_close", "Short-Close", "Check", None, 0), + ("Delivery Note", "makap_short_close_reason", "Short-Close Reason", "Small Text", None, 0), + ("Delivery Note", "makap_short_close_approved_by", "Short-Close Approved By", "Link", "User", 1), + ("Delivery Note", "makap_over_delivery_approved_by", "Over-Delivery Approved By", "Link", "User", 1), + # Module 8A — dispatch and fulfilment control (Delivery Note Item) + ("Delivery Note Item", "makap_packaging_specification", "Packaging Specification", "Link", "Packaging Specification", 1), + ("Delivery Note Item", "makap_specification_revision", "Specification Revision", "Data", None, 1), + ("Delivery Note Item", "makap_sales_order_item_reference", "Sales Order Item Reference", "Data", None, 1), + # No default on purpose — Reserved vs Pooled is a per-line decision the preparer must make + # explicitly until Makap sets a global default policy (see M8-OD-04, still open). + ("Delivery Note Item", "makap_dispatch_allocation_mode", "Dispatch Allocation Mode", "Select", "\nReserved\nPooled", 0), + ("Delivery Note Item", "makap_stock_reservation_entry", "Stock Reservation Entry", "Link", "Stock Reservation Entry", 0), + ("Delivery Note Item", "makap_work_order", "Work Order", "Link", "Work Order", 0), + ("Delivery Note Item", "makap_customer_trace_key", "Customer Trace Key", "Data", None, 0), + # Module 8B — financial release, deposit/credit and commercial billing + ("Customer", "makap_credit_exposure_status", "Credit Exposure Status", "Select", "Not Assessed\nWithin Limit\nOver Limit\nFrozen", 0), + ("Customer", "makap_financial_hold", "Financial Hold", "Check", None, 0), + ("Customer", "makap_financial_hold_reason", "Financial Hold Reason", "Small Text", None, 0), + ("Sales Order", "makap_deposit_required", "Deposit Required", "Check", None, 0), + ("Sales Order", "makap_deposit_required_amount", "Deposit Required Amount", "Currency", None, 0), + # Derived on Sales Invoice/Payment Entry submit-cancel — see _recompute_billing_and_collection. + ("Sales Order", "makap_billing_status", "Billing Status", "Select", "Not Billable\nUnbilled\nPartially Billed\nCommercially Billed", 1), + ("Sales Order", "makap_delivered_unbilled", "Delivered-Unbilled Exposure", "Check", None, 1), + ("Sales Order", "makap_collection_status", "Collection Status", "Select", "Not Yet Due\nDue\nPartially Paid\nPaid", 1), + ("Payment Entry", "makap_payment_purpose", "Payment Purpose", "Select", "Deposit\nProgress Payment\nFinal Settlement\nOther", 0), + # Module 8B/8C — commercial billing and Uganda EFRIS fiscalisation (Sales Invoice) + ("Sales Invoice", "makap_delivery_note_reference", "Delivery Note Reference", "Data", None, 1), + # Not Required is the default so non-EFRIS transactions aren't blocked before Makap confirms + # the integration method (M8-OD-01/02, still open). + ("Sales Invoice", "makap_fiscalisation_status", "Fiscalisation Status", "Select", "Not Required\nPending\nFiscalised\nFailed", 0), + ("Sales Invoice", "makap_fiscal_document_number", "Fiscal Document Number", "Data", None, 0), + ("Sales Invoice", "makap_fiscal_verification_code", "Fiscal Verification/Reference Code", "Data", None, 0), + ("Sales Invoice", "makap_fiscalisation_timestamp", "Fiscalisation Timestamp", "Datetime", None, 0), + ("Sales Invoice", "makap_original_fiscal_document_reference", "Original Fiscal Document Reference", "Data", None, 0), + ("Sales Invoice Item", "makap_delivery_note_item_reference", "Delivery Note Item Reference", "Data", None, 1), + # Module 8D — customer returns and replacement + ("Warehouse", "makap_is_returns_hold_warehouse", "Is Returns Hold Warehouse", "Check", None, 0), + ("Delivery Note Item", "makap_return_reason", "Return Reason", "Select", "\nQuality Defect\nWrong Item\nDamaged In Transit\nCustomer Changed Mind\nOver-Delivery Correction\nOther", 0), + ("Delivery Note Item", "makap_return_quality_disposition", "Return Quality Disposition", "Select", "\nPending\nRelease\nReject\nRework\nScrap\nConcession", 0), + # Module 8E — supplier financial reconciliation + ("Purchase Invoice", "makap_supplier_invoice_match_status", "Supplier Invoice Match Status", "Select", "Not Assessed\nMatched\nQuantity Variance\nRate Variance\nBoth Variance\nDisputed\nResolved", 0), + ("Purchase Invoice", "makap_match_notes", "Match Notes", "Small Text", None, 0), + ("Purchase Invoice Item", "makap_quantity_variance", "Quantity Variance", "Float", None, 1), + ("Purchase Invoice Item", "makap_rate_variance", "Rate Variance", "Float", None, 1), + # Module 8F — manufacturing execution cost interpretation + ("Work Order", "makap_cost_status", "Cost Status", "Select", "Not Assessed\nProvisional\nReady to Close\nClosed\nRestatement Required", 0), + ("Work Order", "makap_cost_confidence", "Cost Confidence", "Select", "Not Assessed\nHigh\nQualified\nLow / Provisional", 0), + ("Work Order", "makap_cost_attribution_basis", "Cost Attribution Basis", "Select", "Not Assessed\nExact\nDirectly Derived\nPooled / Valuation-Based\nQualified", 0), ] @@ -94,7 +152,7 @@ def ensure_custom_fields(): "fieldtype": fieldtype, "options": options, "read_only": read_only, - "insert_after": "amended_from" if dt in {"Sales Order", "Work Order", "Quality Inspection"} else None, + "insert_after": "amended_from" if dt in {"Sales Order", "Work Order", "Quality Inspection", "Delivery Note", "Sales Invoice", "Purchase Invoice"} else None, } ) doc.insert(ignore_permissions=True) diff --git a/makap_backend/makap_backend/setup_module8_dashboard.py b/makap_backend/makap_backend/setup_module8_dashboard.py new file mode 100644 index 0000000..e51bd64 --- /dev/null +++ b/makap_backend/makap_backend/setup_module8_dashboard.py @@ -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 diff --git a/makap_backend/makap_backend/setup_module8_permissions.py b/makap_backend/makap_backend/setup_module8_permissions.py new file mode 100644 index 0000000..9d45eb0 --- /dev/null +++ b/makap_backend/makap_backend/setup_module8_permissions.py @@ -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 diff --git a/makap_backend/makap_backend/setup_workspace_dashboard.py b/makap_backend/makap_backend/setup_workspace_dashboard.py new file mode 100644 index 0000000..c85d938 --- /dev/null +++ b/makap_backend/makap_backend/setup_workspace_dashboard.py @@ -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 diff --git a/makap_backend/makap_backend/specification_api.py b/makap_backend/makap_backend/specification_api.py new file mode 100644 index 0000000..1fb1710 --- /dev/null +++ b/makap_backend/makap_backend/specification_api.py @@ -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)] diff --git a/makap_backend/makap_backend/supplier_reconciliation_api.py b/makap_backend/makap_backend/supplier_reconciliation_api.py new file mode 100644 index 0000000..e990d8e --- /dev/null +++ b/makap_backend/makap_backend/supplier_reconciliation_api.py @@ -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)] diff --git a/makap_backend/makap_backend/workspace_setup.py b/makap_backend/makap_backend/workspace_setup.py index 9fefa72..15e6ffe 100644 --- a/makap_backend/makap_backend/workspace_setup.py +++ b/makap_backend/makap_backend/workspace_setup.py @@ -1,47 +1,60 @@ import json + import frappe +from makap_backend.makap_backend.setup_workspace_dashboard import WORKSPACE_NUMBER_CARDS -SHORTCUTS = [ - ("Customer Onboarding Request", "Customer Onboarding Request"), - ("Packaging Specification", "Packaging Specification"), - ("Material Structure", "Material Structure"), - ("Critical Acceptance Requirement", "Critical Acceptance Requirement"), +WORKSPACE_NUMBER_CARD_LABELS = [row[0] for row in WORKSPACE_NUMBER_CARDS] + +# (label, doctype, color) — color: "Green" flags the 5 MAKAP custom doctypes, +# "Gray" is stock ERPNext/Frappe. Grouped and ordered per the approved mockup. +SHORTCUT_GROUPS = [ + ("Customer & commercial — M1–2", [ + ("Customer Onboarding Request", "Customer Onboarding Request", "Green"), + ("Customer", "Customer", "Gray"), + ("Contact", "Contact", "Gray"), + ("Opportunity", "Opportunity", "Gray"), + ("Quotation", "Quotation", "Gray"), + ("Sales Order", "Sales Order", "Gray"), + ]), + ("Specification & prepress — M3", [ + ("Packaging Specification", "Packaging Specification", "Green"), + ("Material Structure", "Material Structure", "Green"), + ("Critical Acceptance Requirement", "Critical Acceptance Requirement", "Green"), + ("Item", "Item", "Gray"), + ]), + ("Production & planning — M4", [ + ("Operation", "Operation", "Gray"), + ("Work Order", "Work Order", "Gray"), + ("Production Plan", "Production Plan", "Gray"), + ]), + ("Materials & inventory — M5", [ + ("Material Request", "Material Request", "Gray"), + ("Purchase Order", "Purchase Order", "Gray"), + ("Purchase Receipt", "Purchase Receipt", "Gray"), + ]), + ("Quality — M6", [ + ("Quality Inspection", "Quality Inspection", "Gray"), + ]), + ("Fulfilment & billing — M8", [ + ("Delivery Note", "Delivery Note", "Gray"), + ("Sales Invoice", "Sales Invoice", "Gray"), + ("Payment Entry", "Payment Entry", "Gray"), + ("Purchase Invoice", "Purchase Invoice", "Gray"), + ]), + ("Operating control", [ + ("Order Closure And Margin View", "Order Closure And Margin View", "Green"), + ]), + ("Reference & lookups", [ + ("User", "User", "Gray"), + ("UOM", "UOM", "Gray"), + ]), ] def ensure_makap_workspace(): - content = [ - { - "id": "makap_backend_header", - "type": "header", - "data": {"text": "MAKAP Backend", "col": 12}, - } - ] - for index, (label, doctype_name) in enumerate(SHORTCUTS, start=1): - content.append( - { - "id": f"makap_shortcut_{index}", - "type": "shortcut", - "data": { - "shortcut_name": label, - "col": 3, - "link_to": doctype_name, - "type": "DocType", - }, - } - ) - - if frappe.db.exists("Workspace", "MAKAP Backend"): - workspace = frappe.get_doc("Workspace", "MAKAP Backend") - workspace.title = "MAKAP Backend" - workspace.label = "MAKAP Backend" - workspace.is_hidden = 0 - workspace.public = 1 - workspace.content = json.dumps(content) - workspace.set("links", []) - else: - workspace = frappe.get_doc( + if not frappe.db.exists("Workspace", "MAKAP Backend"): + frappe.get_doc( { "doctype": "Workspace", "name": "MAKAP Backend", @@ -49,268 +62,53 @@ def ensure_makap_workspace(): "label": "MAKAP Backend", "is_hidden": 0, "public": 1, - "content": json.dumps(content), - "links": [], + "content": "[]", } - ) + ).insert(ignore_permissions=True) - for label, doctype_name in SHORTCUTS: - workspace.append( - "links", - { - "label": label, - "link_type": "DocType", - "link_to": doctype_name, - "type": "Card Break", - }, - ) - - workspace.save(ignore_permissions=True) - frappe.db.commit() - return "MAKAP Backend" - - -def ensure_makap_workspace(): - content = [ - {"id": "makap_backend_header", "type": "header", "data": {"text": "MAKAP Backend", "col": 12}} - ] - for index, (label, doctype_name) in enumerate(SHORTCUTS, start=1): - content.append( - { - "id": f"makap_shortcut_{index}", - "type": "shortcut", - "data": {"shortcut_name": label, "col": 3, "link_to": doctype_name, "type": "DocType"}, - } - ) - - values = { - "title": "MAKAP Backend", - "label": "MAKAP Backend", - "is_hidden": 0, - "public": 1, - "content": json.dumps(content), - } - - if frappe.db.exists("Workspace", "MAKAP Backend"): - workspace = frappe.get_doc("Workspace", "MAKAP Backend") - workspace.update(values) - workspace.set("links", []) - workspace.save(ignore_permissions=True) - else: - values.update({"doctype": "Workspace", "name": "MAKAP Backend", "links": []}) - workspace = frappe.get_doc(values) - workspace.insert(ignore_permissions=True) - - frappe.db.commit() - return "MAKAP Backend" - - -def ensure_makap_workspace(): - workspace = frappe.get_doc("Workspace", "MAKAP Backend") - workspace.set("shortcuts", []) - - for label, doctype_name in SHORTCUTS: - workspace.append( - "shortcuts", - { - "shortcut_name": label, - "type": "DocType", - "link_to": doctype_name, - "icon": "list", - "color": "Blue", - }, - ) - - workspace.set("links", []) - workspace.save(ignore_permissions=True) - frappe.db.commit() - return "MAKAP Backend" - - -def ensure_makap_workspace(): - workspace = frappe.get_doc("Workspace", "MAKAP Backend") - workspace.set("shortcuts", []) - - for label, doctype_name in SHORTCUTS: - workspace.append( - "shortcuts", - { - "label": label, - "type": "DocType", - "link_to": doctype_name, - "icon": "list", - "color": "Blue", - }, - ) - - workspace.set("links", []) - workspace.save(ignore_permissions=True) - frappe.db.commit() - return "MAKAP Backend" - -SHORTCUTS = [ - ("Customer Onboarding Request", "Customer Onboarding Request"), - ("Packaging Specification", "Packaging Specification"), - ("Customer", "Customer"), - ("Contact", "Contact"), - ("Opportunity", "Opportunity"), - ("Quotation", "Quotation"), - ("Sales Order", "Sales Order"), - ("Item", "Item"), - ("Operation", "Operation"), - ("Work Order", "Work Order"), - ("Quality Inspection", "Quality Inspection"), - ("Production Plan", "Production Plan"), - ("Material Request", "Material Request"), - ("Purchase Order", "Purchase Order"), - ("Purchase Receipt", "Purchase Receipt"), -] - - -def ensure_makap_workspace(): - workspace = frappe.get_doc("Workspace", "MAKAP Backend") - workspace.set("shortcuts", []) - - for label, doctype_name in SHORTCUTS: - workspace.append( - "shortcuts", - { - "label": label, - "type": "DocType", - "link_to": doctype_name, - "icon": "list", - "color": "Blue", - }, - ) - - workspace.set("links", []) - workspace.save(ignore_permissions=True) - frappe.db.commit() - return len(SHORTCUTS) - - -def get_makap_workspace_shortcuts(): - workspace = frappe.get_doc("Workspace", "MAKAP Backend") - return [ - {"label": row.label, "type": row.type, "link_to": row.link_to} - for row in workspace.shortcuts - ] - - -def ensure_makap_workspace(): - workspace = frappe.get_doc("Workspace", "MAKAP Backend") - workspace.content = "[]" - workspace.set("links", []) - - for label, doctype_name in SHORTCUTS: - workspace.append( - "links", - { - "label": label, - "type": "Link", - "link_type": "DocType", - "link_to": doctype_name, - "onboard": 0, - }, - ) - - workspace.save(ignore_permissions=True) - frappe.db.commit() - return len(workspace.links) - - -def get_makap_workspace_links(): - workspace = frappe.get_doc("Workspace", "MAKAP Backend") - return [ - {"idx": row.idx, "label": row.label, "type": row.type, "link_type": row.link_type, "link_to": row.link_to} - for row in workspace.links - ] - - -def ensure_makap_workspace(): workspace = frappe.get_doc("Workspace", "MAKAP Backend") workspace.set("links", []) workspace.set("shortcuts", []) + workspace.set("number_cards", []) blocks = [ - {"id": "makap_backend_header", "type": "header", "data": {"text": "MAKAP Backend Operations", "col": 12}} + { + "id": "makap_welcome_header", + "type": "header", + "data": { + "text": "Welcome back — here's what's happening across MAKAP Uganda's manufacturing workflow.", + "col": 12, + }, + } ] - for index, (label, doctype_name) in enumerate(SHORTCUTS, start=1): - workspace.append( - "shortcuts", - { - "label": label, - "type": "DocType", - "link_to": doctype_name, - "doc_view": "List", - "icon": "list", - "color": "Blue", - }, - ) + card_index = 1 + existing_cards = set(frappe.get_all("Number Card", pluck="name")) + for label in WORKSPACE_NUMBER_CARD_LABELS: + if label not in existing_cards: + continue + workspace.append("number_cards", {"number_card_name": label}) blocks.append( { - "id": f"makap_shortcut_{index}", - "type": "shortcut", - "data": { - "shortcut_name": label, - "col": 3, - "link_to": doctype_name, - "type": "DocType", - "doc_view": "List", - "icon": "list", - "color": "Blue", - }, + "id": f"makap_number_card_{card_index}", + "type": "number_card", + "data": {"number_card_name": label, "col": 3}, } ) + card_index += 1 - workspace.content = json.dumps(blocks) - workspace.save(ignore_permissions=True) - frappe.db.commit() - return {"shortcuts": len(workspace.shortcuts), "links": len(workspace.links)} - -CUSTOMER_COMMERCIAL_SHORTCUTS = [ - ("Customer Onboarding Request", "Customer Onboarding Request"), - ("Customer", "Customer"), - ("Contact", "Contact"), - ("Opportunity", "Opportunity"), - ("Quotation", "Quotation"), - ("Sales Order", "Sales Order"), - ("Packaging Specification", "Packaging Specification"), - ("Item", "Item"), - ("Operation", "Operation"), - ("Work Order", "Work Order"), -] - -INTERNAL_OPERATIONS_SHORTCUTS = [ - ("Quality Inspection", "Quality Inspection"), - ("Production Plan", "Production Plan"), - ("Material Request", "Material Request"), - ("Purchase Order", "Purchase Order"), - ("Purchase Receipt", "Purchase Receipt"), -] - - -def ensure_makap_workspace(): - workspace = frappe.get_doc("Workspace", "MAKAP Backend") - workspace.set("links", []) - workspace.set("shortcuts", []) - - blocks = [] shortcut_index = 1 - - for section_title, section_shortcuts in [ - ("Customer & Commercial Operations — Modules 1–4", CUSTOMER_COMMERCIAL_SHORTCUTS), - ("Internal Operations — Modules 5–7", INTERNAL_OPERATIONS_SHORTCUTS), - ]: + for group_title, items in SHORTCUT_GROUPS: blocks.append( { "id": f"makap_section_{shortcut_index}", "type": "header", - "data": {"text": section_title, "col": 12}, + "data": {"text": group_title, "col": 12}, } ) - for label, doctype_name in section_shortcuts: + for label, doctype_name, color in items: + if not frappe.db.exists("DocType", doctype_name): + continue workspace.append( "shortcuts", { @@ -319,7 +117,7 @@ def ensure_makap_workspace(): "link_to": doctype_name, "doc_view": "List", "icon": "list", - "color": "Blue", + "color": color, }, ) blocks.append( @@ -333,7 +131,7 @@ def ensure_makap_workspace(): "type": "DocType", "doc_view": "List", "icon": "list", - "color": "Blue", + "color": color, }, } ) @@ -342,8 +140,8 @@ def ensure_makap_workspace(): workspace.content = json.dumps(blocks) workspace.save(ignore_permissions=True) frappe.db.commit() - return { - "customer_commercial": len(CUSTOMER_COMMERCIAL_SHORTCUTS), - "internal_operations": len(INTERNAL_OPERATIONS_SHORTCUTS), - "total": len(workspace.shortcuts), - } + + summary = {title: len(items) for title, items in SHORTCUT_GROUPS} + summary["number_cards"] = len(workspace.number_cards) + summary["total_shortcuts"] = sum(len(items) for _, items in SHORTCUT_GROUPS) + return summary diff --git a/makap_backend/page/makap_portal/__init__.py b/makap_backend/page/makap_portal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/makap_backend/page/makap_portal/makap_portal.js b/makap_backend/page/makap_portal/makap_portal.js new file mode 100644 index 0000000..a94ff0a --- /dev/null +++ b/makap_backend/page/makap_portal/makap_portal.js @@ -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} {1}? 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 = + '
Overview
' + + '
Dashboard
' + + + '
Customer & commercial · M1–2
' + + '
Onboarding requests0
' + + '
CustomerERPNext
' + + '
ContactERPNext
' + + '
OpportunityERPNext
' + + '
QuotationERPNext
' + + '
Sales orderERPNext
' + + + '
Specification & prepress · M3
' + + '
Packaging specs0
' + + '
Material structuresChild table
' + + '
Critical acceptanceChild table
' + + '
ItemERPNext
' + + + '
Production & planning · M4
' + + '
OperationERPNext
' + + '
Work orderERPNext
' + + '
Production planERPNext
' + + + '
Materials & inventory · M5
' + + '
Material requestERPNext
' + + '
Purchase orderERPNext
' + + '
Purchase receiptERPNext
' + + + '
Quality · M6
' + + '
Quality inspectionERPNext
' + + + '
Fulfilment & billing · M8
' + + '
Delivery noteERPNext
' + + '
Sales invoiceERPNext
' + + '
Payment entryERPNext
' + + '
Purchase invoiceERPNext
' + + + '
Operating control
' + + '
Order closure & margin0 Open
' + + + '
Reference & lookups
' + + '
UserFrappe core
' + + '
UOMSetup
'; + + var HERO_HTML = + '
' + + '

Welcome back, 👋

' + + '

Here\'s what\'s happening across MAKAP Uganda\'s manufacturing workflow.

' + + '
' + + '
' + + '
Onboarding requests
0
' + + '
Packaging specifications
0
' + + '
Approved for manufacture
0
' + + '
Critical requirements tracked
0
' + + '
'; + + page.main.html( + '
' + + '
' + SIDEBAR_HTML + '
' + + '
' + HERO_HTML + '
' + + '
' + ); + + 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 = $('
'); + $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( + '
' + + '
' + + '

Selected module

' + esc(label) + '

' + rows.length + ' live records from Frappe

' + + '
' + + '' + + (canCreate(doctype) ? '' : '') + + '
' + + '
' + + '' + + '' + cols.map(function (x) { return ''; }).join('') + (allowDelete ? '' : '') + '' + + '' + (rows.length ? rows.map(function (r) { + return '' + cols.map(function (x, i) { + return ''; + }).join('') + (allowDelete ? '' : '') + ''; + }).join('') : '') + + '' + + '
' + esc(x.replaceAll('_', ' ')) + '
' + esc(r[x]) + '
No records found
' + + '
' + ); + $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('
Loading ' + esc(label) + '…
'); + 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('
Unable to load ' + esc(label) + '. Check your Frappe permissions.
'); + }); + } + + /* ---- 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('
No onboarding requests found.
'); + return; + } + var allowDelete = canDelete('Customer Onboarding Request'); + $table.html( + '' + (allowDelete ? '' : '') + '' + + rows.map(function (row) { + return '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + (allowDelete ? '' : '') + + ''; + }).join('') + '
RequestOrganisationContactEmailLocationStatusCustomer match
' + esc(row.organisation_name) + '' + esc(row.contact_name) + '' + esc(row.email_id) + '' + esc(row.location) + '' + esc(row.onboarding_status) + '' + esc(row.customer_match_result) + '
' + ); + $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('
Unable to load onboarding requests. Check your Frappe permissions.
'); + }); + } + function showOnboarding($item) { + markActive($item); + $content.html( + '
' + + '
' + + '

Customer & commercial · M1–2

Customer onboarding

Live onboarding requests from Frappe.

' + + '' + + '
' + + '
' + + (canCreate('Customer Onboarding Request') ? '' : '') + + '' + + '' + + '
' + + '
Loading onboarding requests…
' + + '
' + + '
' + ); + $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 '
' + label + '' + Number(value || 0).toLocaleString() + '
'; + } + 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 ? + '' + cols.map(function (x) { return ''; }).join('') + (allowDelete ? '' : '') + '' + + data.map(function (x) { + return '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + (allowDelete ? '' : '') + + ''; + }).join('') + '
' + x.replaceAll('_', ' ') + '
' + esc(x.customer_name) + '' + esc(x.customer_type) + '' + esc(x.customer_group) + '' + esc(x.territory) + '' + esc(x.tax_id) + '' + esc(x.mobile_no) + '' + esc(x.email_id) + '' + (x.disabled ? 'Disabled' : 'Active') + '
' : '
No customers match your filters.
' + ); + $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('
Loading customers…
'); + 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( + '
' + + '

Customer & commercial · M1–2

Customers

Live customer records from Frappe.

' + + '
' + + (canCreate('Customer') ? '' : '') + + '
' + + '
' + + 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') + + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '
' + ); + renderCustomerRows(rows); + wireCustomerList(); + }).catch(function () { + $content.html('
Unable to load customers. Check your Frappe permissions.
'); + }); + } + function customerDetail(name) { + $content.html('
Loading customer…
'); + 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 '
' + pair[0] + '' + esc(pair[1]) + '
'; }).join(''); + $content.html( + '
' + + '

Customer profile

' + esc(x.customer_name || x.name) + '

' + esc(x.name) + ' · ' + (x.disabled ? 'Disabled' : 'Active') + '

' + + '
' + + (canDelete('Customer') ? '' : '') + + '
' + + '
' + rowsHtml + '
' + ); + $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('
Unable to load this customer.
'); + }); + } + + /* ---- 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); +} diff --git a/makap_backend/page/makap_portal/makap_portal.json b/makap_backend/page/makap_portal/makap_portal.json new file mode 100644 index 0000000..f4fa026 --- /dev/null +++ b/makap_backend/page/makap_portal/makap_portal.json @@ -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": [] +} diff --git a/makap_backend/public/images/makap-icon.png b/makap_backend/public/images/makap-icon.png new file mode 100644 index 0000000..0888dd8 Binary files /dev/null and b/makap_backend/public/images/makap-icon.png differ diff --git a/makap_backend/public/js/makap-dashboard-links.js b/makap_backend/public/js/makap-dashboard-links.js new file mode 100644 index 0000000..ec2e667 --- /dev/null +++ b/makap_backend/public/js/makap-dashboard-links.js @@ -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, ${name} 👋`; + }); + }; + 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(); }); + }}); + } +})(); diff --git a/makap_backend/www/makap-onboarding.html b/makap_backend/www/makap-onboarding.html new file mode 100644 index 0000000..e7fc2e2 --- /dev/null +++ b/makap_backend/www/makap-onboarding.html @@ -0,0 +1,17 @@ +{% extends "templates/web.html" %} +{% block title %}Customer Onboarding · MAKAP{% endblock %} +{% block page_content %} +
+ +

Customer & commercial · M1–2

Customer onboarding

Capture, review, and convert new customer requests.

+
+
+
RequestOrganisationContactLocationStatusCustomer matchCreated
Loading onboarding requests…
+

MAKAP request

New onboarding request

+
+ +{% endblock %} diff --git a/makap_backend/www/makap-portal.html b/makap_backend/www/makap-portal.html new file mode 100644 index 0000000..cc30d31 --- /dev/null +++ b/makap_backend/www/makap-portal.html @@ -0,0 +1,688 @@ + + + + + +MAKAP Backend — Portal + + + + + + + +
+ +
+
+
+ +
+ +
U
+
+
+
+
+
+
+ + + + + diff --git a/makap_backend/www/makap-portal.py b/makap_backend/www/makap-portal.py new file mode 100644 index 0000000..d1e3f48 --- /dev/null +++ b/makap_backend/www/makap-portal.py @@ -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)