big package update

This commit is contained in:
Casey 2026-02-05 17:05:56 -06:00
parent 21173e34c6
commit 991038bc47
15 changed files with 3282 additions and 1248 deletions

80
custom_ui/api/db/items.py Normal file
View File

@ -0,0 +1,80 @@
import frappe
import json
from custom_ui.models import PackageCreationData
from custom_ui.services import ProjectService, ItemService
from custom_ui.db_utils import build_error_response, build_success_response
@frappe.whitelist()
def get_by_project_template(project_template: str) -> dict:
"""Retrieve items associated with a given project template."""
print(f"DEBUG: Getting items for Project Template {project_template}")
item_groups = ProjectService.get_project_item_groups(project_template)
items = ItemService.get_items_by_groups(item_groups)
print(f"DEBUG: Retrieved {len(items)} items for Project Template {project_template}")
categorized_items = ItemService.build_category_dict(items)
return build_success_response(categorized_items)
@frappe.whitelist()
def save_as_package_item(data):
"""Save a new Package Item based on the provided data."""
from custom_ui.models import BOMItem
data = json.loads(data)
print(f"DEBUG: Saving Package Item with data: {data}")
# Map 'category' to 'item_group' for the model
data['item_group'] = data.pop('category')
# Convert items dictionaries to BOMItem instances
data['items'] = [
BOMItem(
item_code=item['item_code'],
qty=item['qty'],
uom=item['uom']
) for item in data['items']
]
data = PackageCreationData(**data)
item = frappe.get_doc({
"doctype": "Item",
"item_code": ItemService.build_item_code(data.code_prefix, data.package_name),
"item_name": data.package_name,
"is_stock_item": 0,
"item_group": data.item_group,
"description": data.description,
"standard_rate": data.rate or 0.0,
"company": data.company,
"has_variants": 0,
"stock_uom": "Nos",
"is_sales_item": 1,
"is_purchase_item": 0,
"is_pro_applicable": 0,
"is_fixed_asset": 0,
"is_service_item": 0
}).insert()
bom = frappe.get_doc({
"doctype": "BOM",
"item": item.name,
"uom": "Nos",
"is_active": 1,
"is_default": 1,
"items": [{
"item_code": bom_item.item_code,
"qty": bom_item.qty,
"uom": bom_item.uom
} for bom_item in data.items]
}).insert()
bom.submit()
item.reload() # Refresh to get latest version after BOM submission
item.default_bom = bom.name
item.save()
print(f"DEBUG: Created Package Item with name: {item.name}")
item_dict = item.as_dict()
item_dict["bom"] = ItemService.get_full_bom_dict(item.item_code) # Attach BOM details to the item dict
return build_success_response(item_dict)
@frappe.whitelist()
def get_item_categories():
"""Retrieve all item groups for categorization."""
print("DEBUG: Getting item categories")
item_groups = frappe.get_all("Item Group", pluck="name")
print(f"DEBUG: Retrieved item categories: {item_groups}")
return build_success_response(item_groups)

View File

@ -0,0 +1,16 @@
import frappe
def on_submit(doc, method):
print("DEBUG: On Submit Triggered for Payment Entry")
is_advance_payment = any(ref.reference_doctype == "Sales Order" for ref in doc.references)
if is_advance_payment:
print("DEBUG: Payment Entry is for an advance payment, checking Sales Order if half down requirement is met.")
so_ref = next((ref for ref in doc.references if ref.reference_doctype == "Sales Order"), None)
if so_ref:
so_doc = frappe.get_doc("Sales Order", so_ref.reference_name)
if so_doc.requires_half_payment:
is_paid = doc.custom_halfdown_amount <= doc.advance_paid or doc.advance_paid >= so_doc.grand_total / 2
if is_paid and not so_doc.custom_halfdown_is_paid:
print("DEBUG: Sales Order requires half payment and it has not been marked as paid, marking it as paid now.")
so_doc.custom_halfdown_is_paid = 1
so_doc.save()

File diff suppressed because it is too large Load Diff

View File

@ -1408,8 +1408,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:12.948757",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:30.309793",
"module": "CRM",
"name": "Properties",
"naming_rule": "By fieldname",
@ -3186,8 +3186,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:13.056788",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:30.432329",
"module": "CRM",
"name": "SNW Jobs",
"naming_rule": "Autoincrement",
@ -4109,8 +4109,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:13.154567",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:30.520105",
"module": "Projects",
"name": "Work Schedule",
"naming_rule": "",
@ -9151,8 +9151,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:13.303974",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:30.684330",
"module": "CRM",
"name": "Follow Up Checklist",
"naming_rule": "By fieldname",
@ -9457,8 +9457,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:13.377143",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:30.760407",
"module": "CRM",
"name": "Follow Check List Fields",
"naming_rule": "By fieldname",
@ -10147,8 +10147,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:13.498788",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:30.859914",
"module": "Brotherton SOP",
"name": "SOP-Documentation",
"naming_rule": "Set by user",
@ -10348,8 +10348,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:13.568354",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:30.922266",
"module": "Desk",
"name": "SOP Notes",
"naming_rule": "",
@ -10694,8 +10694,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:13.644007",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:30.991664",
"module": "Desk",
"name": "Tutorials",
"naming_rule": "By fieldname",
@ -11064,8 +11064,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:13.714534",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:31.061659",
"module": "Desk",
"name": "Brotherton Meetings Scheduler",
"naming_rule": "",
@ -11242,8 +11242,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:13.776408",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:31.124415",
"module": "Desk",
"name": "Meeting Participants",
"naming_rule": "",
@ -11588,8 +11588,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:13.863897",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:31.204512",
"module": "Desk",
"name": "Add-On Job Detail",
"naming_rule": "By fieldname",
@ -11870,8 +11870,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:13.938332",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:31.273908",
"module": "Desk",
"name": "Crew Schedule Detail",
"naming_rule": "",
@ -12152,8 +12152,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:14.015669",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:31.342599",
"module": "Setup",
"name": "City",
"naming_rule": "By fieldname",
@ -14738,8 +14738,8 @@
"make_attachments_public": 1,
"max_attachments": 5,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:14.167954",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:31.484159",
"module": "Projects",
"name": "Fencing Job Queue",
"naming_rule": "Set by user",
@ -15630,8 +15630,8 @@
"make_attachments_public": 1,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:14.271176",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:31.555530",
"module": "Setup",
"name": "Irrigation District",
"naming_rule": "By fieldname",
@ -15808,8 +15808,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:14.346591",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:31.610090",
"module": "Setup",
"name": "Linked Companies",
"naming_rule": "",
@ -16154,8 +16154,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:14.429267",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:31.668108",
"module": "Contacts",
"name": "Address Contact Role",
"naming_rule": "",
@ -17056,6 +17056,70 @@
"unique": 0,
"width": null
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": null,
"depends_on": null,
"description": null,
"documentation_url": null,
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "amended_from",
"fieldtype": "Link",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"is_virtual": 0,
"label": "Amended From",
"length": 0,
"link_filters": null,
"make_attachment_public": 0,
"mandatory_depends_on": null,
"max_height": null,
"no_copy": 1,
"non_negative": 0,
"oldfieldname": null,
"oldfieldtype": null,
"options": "Backflow Test Form",
"parent": "Backflow Test Form",
"parentfield": "fields",
"parenttype": "DocType",
"permlevel": 0,
"placeholder": null,
"precision": null,
"print_hide": 1,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 1,
"read_only_depends_on": null,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 1,
"set_only_once": 0,
"show_dashboard": 0,
"show_on_timeline": 0,
"show_preview_popup": 0,
"sort_options": 0,
"translatable": 0,
"trigger": null,
"unique": 0,
"width": null
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
@ -17140,8 +17204,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:14.536984",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:31.743894",
"module": "Selling",
"name": "Backflow Test Form",
"naming_rule": "",
@ -17830,8 +17894,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:14.668256",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:31.827955",
"module": "Selling",
"name": "Pre-Built Routes",
"naming_rule": "By \"Naming Series\" field",
@ -18351,8 +18415,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:14.752785",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:31.885625",
"module": "Contacts",
"name": "Assigned Address",
"naming_rule": "By fieldname",
@ -19573,6 +19637,70 @@
"unique": 0,
"width": null
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": null,
"depends_on": null,
"description": null,
"documentation_url": null,
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "amended_from",
"fieldtype": "Link",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"is_virtual": 0,
"label": "Amended From",
"length": 0,
"link_filters": null,
"make_attachment_public": 0,
"mandatory_depends_on": null,
"max_height": null,
"no_copy": 1,
"non_negative": 0,
"oldfieldname": null,
"oldfieldtype": null,
"options": "Locate Log",
"parent": "Locate Log",
"parentfield": "fields",
"parenttype": "DocType",
"permlevel": 0,
"placeholder": null,
"precision": null,
"print_hide": 1,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 1,
"read_only_depends_on": null,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 1,
"set_only_once": 0,
"show_dashboard": 0,
"show_on_timeline": 0,
"show_preview_popup": 0,
"sort_options": 0,
"translatable": 0,
"trigger": null,
"unique": 0,
"width": null
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
@ -19671,8 +19799,8 @@
"make_attachments_public": 1,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:14.894391",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:32.021173",
"module": "Projects",
"name": "Locate Log",
"naming_rule": "",
@ -20243,6 +20371,70 @@
"unique": 0,
"width": null
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": null,
"depends_on": null,
"description": null,
"documentation_url": null,
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "amended_from",
"fieldtype": "Link",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"is_virtual": 0,
"label": "Amended From",
"length": 0,
"link_filters": null,
"make_attachment_public": 0,
"mandatory_depends_on": null,
"max_height": null,
"no_copy": 1,
"non_negative": 0,
"oldfieldname": null,
"oldfieldtype": null,
"options": "Backflow test report form",
"parent": "Backflow test report form",
"parentfield": "fields",
"parenttype": "DocType",
"permlevel": 0,
"placeholder": null,
"precision": null,
"print_hide": 1,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 1,
"read_only_depends_on": null,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 1,
"set_only_once": 0,
"show_dashboard": 0,
"show_on_timeline": 0,
"show_preview_popup": 0,
"sort_options": 0,
"translatable": 0,
"trigger": null,
"unique": 0,
"width": null
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
@ -20327,8 +20519,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:14.974731",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:32.089145",
"module": "Brotherton SOP",
"name": "Backflow test report form",
"naming_rule": "",
@ -20633,8 +20825,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:15.060171",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:32.161902",
"module": "Accounts",
"name": "QB Export Entry",
"naming_rule": "Autoincrement",
@ -21171,8 +21363,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:15.161301",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:32.228299",
"module": "Accounts",
"name": "QB Export",
"naming_rule": "Expression",
@ -21669,8 +21861,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:15.226771",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:32.288896",
"module": "Desk",
"name": "Custom Customer Address Link",
"naming_rule": "",
@ -22015,8 +22207,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:15.293419",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:32.353749",
"module": "Selling",
"name": "On-Site Meeting",
"naming_rule": "Expression",
@ -22193,8 +22385,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:15.349611",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:32.406321",
"module": "Selling",
"name": "Route Technician Assignment",
"naming_rule": "",
@ -22347,8 +22539,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:15.412654",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:32.462798",
"module": "Desk",
"name": "Test Doctype",
"naming_rule": "",
@ -22525,8 +22717,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:15.467707",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:32.512828",
"module": "Custom",
"name": "Lead Company Link",
"naming_rule": "",
@ -22743,8 +22935,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:15.520767",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:32.565309",
"module": "Custom UI",
"name": "Customer Task Link",
"naming_rule": "",
@ -22961,8 +23153,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:15.580338",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:32.619158",
"module": "Custom UI",
"name": "Address Task Link",
"naming_rule": "",
@ -23115,8 +23307,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:15.635575",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:32.669515",
"module": "Custom",
"name": "Lead Companies Link",
"naming_rule": "",
@ -23333,8 +23525,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:15.692709",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:32.723818",
"module": "Custom",
"name": "Address Project Link",
"naming_rule": "",
@ -23551,8 +23743,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:15.753245",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:32.776210",
"module": "Custom",
"name": "Address Quotation Link",
"naming_rule": "",
@ -23769,8 +23961,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:15.813569",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:32.828052",
"module": "Custom",
"name": "Address On-Site Meeting Link",
"naming_rule": "",
@ -23987,8 +24179,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:15.869381",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:32.883125",
"module": "Custom",
"name": "Address Sales Order Link",
"naming_rule": "",
@ -24141,8 +24333,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:15.926523",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:32.934024",
"module": "Custom",
"name": "Contact Address Link",
"naming_rule": "",
@ -24295,8 +24487,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:15.982944",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:32.986817",
"module": "Custom",
"name": "Lead On-Site Meeting Link",
"naming_rule": "",
@ -24897,8 +25089,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:16.051786",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:33.054497",
"module": "Selling",
"name": "Quotation Template",
"naming_rule": "",
@ -25395,8 +25587,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:16.131703",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:33.128567",
"module": "Selling",
"name": "Quotation Template Item",
"naming_rule": "",
@ -25549,8 +25741,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:16.186884",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:33.180500",
"module": "Custom UI",
"name": "Customer Company Link",
"naming_rule": "",
@ -25703,8 +25895,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:16.242217",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:33.232445",
"module": "Custom UI",
"name": "Customer Address Link",
"naming_rule": "",
@ -25857,8 +26049,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:16.295479",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:33.283358",
"module": "Custom UI",
"name": "Customer Contact Link",
"naming_rule": "",
@ -26011,8 +26203,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:16.349430",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:33.334916",
"module": "Custom",
"name": "Address Contact Link",
"naming_rule": "",
@ -26165,8 +26357,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:16.402648",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:33.388907",
"module": "Custom",
"name": "Customer On-Site Meeting Link",
"naming_rule": "",
@ -26319,8 +26511,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:16.453671",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:33.442065",
"module": "Custom",
"name": "Customer Project Link",
"naming_rule": "",
@ -26473,8 +26665,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:16.510653",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:33.493993",
"module": "Custom",
"name": "Customer Quotation Link",
"naming_rule": "",
@ -26627,8 +26819,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:16.565855",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:33.547121",
"module": "Custom",
"name": "Customer Sales Order Link",
"naming_rule": "",
@ -26781,8 +26973,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:16.623951",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:33.599872",
"module": "Custom",
"name": "Lead Address Link",
"naming_rule": "",
@ -26935,8 +27127,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:16.678981",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:33.670663",
"module": "Custom",
"name": "Lead Contact Link",
"naming_rule": "",
@ -27089,8 +27281,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:16.735725",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:33.723019",
"module": "Custom",
"name": "Lead Quotation Link",
"naming_rule": "",
@ -27243,8 +27435,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:16.790139",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:33.773957",
"module": "Custom",
"name": "Address Company Link",
"naming_rule": "",
@ -28229,8 +28421,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 10:35:03.150818",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:33.849415",
"module": "Custom UI",
"name": "Service Appointment",
"naming_rule": "Expression",
@ -29303,8 +29495,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:16.929388",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:33.917771",
"module": "Custom UI",
"name": "Bid Meeting Note Form Field",
"naming_rule": "",
@ -29713,8 +29905,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:16.999820",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:33.980734",
"module": "Custom UI",
"name": "Bid Meeting Note Form",
"naming_rule": "",
@ -30595,8 +30787,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:17.067503",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:34.047833",
"module": "Custom UI",
"name": "Bid Meeting Note Field",
"naming_rule": "",
@ -31005,8 +31197,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:17.135078",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:34.111266",
"module": "Custom UI",
"name": "Bid Meeting Note",
"naming_rule": "",
@ -31183,8 +31375,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:17.193504",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:34.162346",
"module": "Custom UI",
"name": "Project Task Link",
"naming_rule": "",
@ -31337,8 +31529,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:17.253714",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:34.224291",
"module": "Custom UI",
"name": "Condition",
"naming_rule": "",
@ -31643,8 +31835,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": "c9094ea959c7b6ff11522d064fe04b35",
"modified": "2026-01-26 01:52:17.308226",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:34.278957",
"module": "Custom UI",
"name": "Bid Meeting Note Field Quantity",
"naming_rule": "",
@ -32693,8 +32885,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": null,
"modified": "2026-01-27 04:34:52.205293",
"migration_hash": "330c425fa522cd61f3e1012dfbe56f02",
"modified": "2026-02-02 09:31:34.354639",
"module": "Custom UI",
"name": "Service Address 2",
"naming_rule": "",

View File

@ -11583,22 +11583,6 @@
"row_name": null,
"value": "1"
},
{
"default_value": null,
"doc_type": "Sales Order",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2025-04-25 03:31:21.087382",
"module": null,
"name": "Sales Order-main-field_order",
"property": "field_order",
"property_type": "Data",
"row_name": null,
"value": "[\"customer_section\", \"column_break0\", \"custom_installation_address\", \"custom_requires_halfdown\", \"title\", \"naming_series\", \"customer\", \"customer_name\", \"tax_id\", \"column_break_7\", \"transaction_date\", \"order_type\", \"delivery_date\", \"custom_department_type\", \"custom_project_complete\", \"column_break1\", \"po_no\", \"po_date\", \"company\", \"skip_delivery_note\", \"amended_from\", \"custom_section_break_htf05\", \"custom_workflow_related_custom_fields__landry\", \"custom_coordinator_notification\", \"custom_sales_order_addon\", \"accounting_dimensions_section\", \"cost_center\", \"dimension_col_break\", \"project\", \"currency_and_price_list\", \"currency\", \"conversion_rate\", \"column_break2\", \"selling_price_list\", \"price_list_currency\", \"plc_conversion_rate\", \"ignore_pricing_rule\", \"sec_warehouse\", \"scan_barcode\", \"column_break_28\", \"set_warehouse\", \"reserve_stock\", \"items_section\", \"items\", \"section_break_31\", \"total_qty\", \"total_net_weight\", \"column_break_33\", \"base_total\", \"base_net_total\", \"column_break_33a\", \"total\", \"net_total\", \"taxes_section\", \"tax_category\", \"taxes_and_charges\", \"exempt_from_sales_tax\", \"column_break_38\", \"shipping_rule\", \"column_break_49\", \"incoterm\", \"named_place\", \"section_break_40\", \"taxes\", \"section_break_43\", \"base_total_taxes_and_charges\", \"column_break_46\", \"total_taxes_and_charges\", \"totals\", \"base_grand_total\", \"base_rounding_adjustment\", \"base_rounded_total\", \"base_in_words\", \"column_break3\", \"grand_total\", \"rounding_adjustment\", \"rounded_total\", \"in_words\", \"advance_paid\", \"disable_rounded_total\", \"section_break_48\", \"apply_discount_on\", \"base_discount_amount\", \"coupon_code\", \"column_break_50\", \"additional_discount_percentage\", \"discount_amount\", \"sec_tax_breakup\", \"other_charges_calculation\", \"packing_list\", \"packed_items\", \"pricing_rule_details\", \"pricing_rules\", \"contact_info\", \"billing_address_column\", \"customer_address\", \"address_display\", \"customer_group\", \"territory\", \"column_break_84\", \"contact_person\", \"contact_display\", \"contact_phone\", \"contact_mobile\", \"contact_email\", \"shipping_address_column\", \"shipping_address_name\", \"shipping_address\", \"column_break_93\", \"dispatch_address_name\", \"dispatch_address\", \"col_break46\", \"company_address\", \"column_break_92\", \"company_address_display\", \"payment_schedule_section\", \"payment_terms_section\", \"payment_terms_template\", \"payment_schedule\", \"terms_section_break\", \"tc_name\", \"terms\", \"more_info\", \"section_break_78\", \"status\", \"delivery_status\", \"per_delivered\", \"column_break_81\", \"per_billed\", \"per_picked\", \"billing_status\", \"sales_team_section_break\", \"sales_partner\", \"column_break7\", \"amount_eligible_for_commission\", \"commission_rate\", \"total_commission\", \"section_break1\", \"sales_team\", \"loyalty_points_redemption\", \"loyalty_points\", \"column_break_116\", \"loyalty_amount\", \"subscription_section\", \"from_date\", \"to_date\", \"column_break_108\", \"auto_repeat\", \"update_auto_repeat_reference\", \"printing_details\", \"letter_head\", \"group_same_items\", \"column_break4\", \"select_print_heading\", \"language\", \"additional_info_section\", \"is_internal_customer\", \"represents_company\", \"column_break_152\", \"source\", \"inter_company_order_reference\", \"campaign\", \"party_account_currency\", \"connections_tab\"]"
},
{
"default_value": null,
"doc_type": "Address",
@ -15134,5 +15118,21 @@
"property_type": "Data",
"row_name": null,
"value": "[\"custom_column_break_k7sgq\", \"custom_installation_address\", \"naming_series\", \"project_name\", \"job_address\", \"status\", \"custom_warranty_duration_days\", \"custom_warranty_expiration_date\", \"custom_warranty_information\", \"project_type\", \"percent_complete_method\", \"percent_complete\", \"column_break_5\", \"project_template\", \"expected_start_date\", \"expected_start_time\", \"expected_end_date\", \"expected_end_time\", \"is_scheduled\", \"invoice_status\", \"custom_completion_date\", \"priority\", \"custom_foreman\", \"custom_hidden_fields\", \"department\", \"service_appointment\", \"tasks\", \"is_active\", \"custom_address\", \"custom_section_break_lgkpd\", \"custom_workflow_related_custom_fields__landry\", \"custom_permit_status\", \"custom_utlity_locate_status\", \"custom_crew_scheduling\", \"customer_details\", \"customer\", \"column_break_14\", \"sales_order\", \"users_section\", \"users\", \"copied_from\", \"section_break0\", \"notes\", \"section_break_18\", \"actual_start_date\", \"actual_start_time\", \"actual_time\", \"column_break_20\", \"actual_end_date\", \"actual_end_time\", \"project_details\", \"estimated_costing\", \"total_costing_amount\", \"total_expense_claim\", \"total_purchase_cost\", \"company\", \"column_break_28\", \"total_sales_amount\", \"total_billable_amount\", \"total_billed_amount\", \"total_consumed_material_cost\", \"cost_center\", \"margin\", \"gross_margin\", \"column_break_37\", \"per_gross_margin\", \"monitor_progress\", \"collect_progress\", \"holiday_list\", \"frequency\", \"from_time\", \"to_time\", \"first_email\", \"second_email\", \"daily_time_to_send\", \"day_to_send\", \"weekly_time_to_send\", \"column_break_45\", \"subject\", \"message\"]"
},
{
"default_value": null,
"doc_type": "Sales Order",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2026-02-05 12:10:08.553140",
"module": null,
"name": "Sales Order-main-field_order",
"property": "field_order",
"property_type": "Data",
"row_name": null,
"value": "[\"customer_section\", \"column_break0\", \"custom_installation_address\", \"custom_job_address\", \"requires_half_payment\", \"custom_project_template\", \"custom_requires_halfdown\", \"title\", \"naming_series\", \"customer\", \"customer_name\", \"tax_id\", \"custom_halfdown_is_paid\", \"custom_halfdown_amount\", \"column_break_7\", \"transaction_date\", \"order_type\", \"delivery_date\", \"custom_department_type\", \"custom_project_complete\", \"column_break1\", \"po_no\", \"po_date\", \"company\", \"skip_delivery_note\", \"has_unit_price_items\", \"amended_from\", \"custom_section_break_htf05\", \"custom_workflow_related_custom_fields__landry\", \"custom_coordinator_notification\", \"custom_sales_order_addon\", \"accounting_dimensions_section\", \"cost_center\", \"dimension_col_break\", \"project\", \"currency_and_price_list\", \"currency\", \"conversion_rate\", \"column_break2\", \"selling_price_list\", \"price_list_currency\", \"plc_conversion_rate\", \"ignore_pricing_rule\", \"sec_warehouse\", \"scan_barcode\", \"last_scanned_warehouse\", \"column_break_28\", \"set_warehouse\", \"reserve_stock\", \"items_section\", \"items\", \"section_break_31\", \"total_qty\", \"total_net_weight\", \"column_break_33\", \"base_total\", \"base_net_total\", \"column_break_33a\", \"total\", \"net_total\", \"taxes_section\", \"tax_category\", \"taxes_and_charges\", \"exempt_from_sales_tax\", \"column_break_38\", \"shipping_rule\", \"column_break_49\", \"incoterm\", \"named_place\", \"section_break_40\", \"taxes\", \"section_break_43\", \"base_total_taxes_and_charges\", \"column_break_46\", \"total_taxes_and_charges\", \"totals\", \"base_grand_total\", \"base_rounding_adjustment\", \"base_rounded_total\", \"base_in_words\", \"column_break3\", \"grand_total\", \"rounding_adjustment\", \"rounded_total\", \"in_words\", \"advance_paid\", \"disable_rounded_total\", \"section_break_48\", \"apply_discount_on\", \"base_discount_amount\", \"coupon_code\", \"column_break_50\", \"additional_discount_percentage\", \"discount_amount\", \"sec_tax_breakup\", \"other_charges_calculation\", \"packing_list\", \"packed_items\", \"pricing_rule_details\", \"pricing_rules\", \"contact_info\", \"billing_address_column\", \"customer_address\", \"address_display\", \"customer_group\", \"territory\", \"column_break_84\", \"contact_person\", \"contact_display\", \"contact_phone\", \"contact_mobile\", \"contact_email\", \"shipping_address_column\", \"shipping_address_name\", \"shipping_address\", \"column_break_93\", \"dispatch_address_name\", \"dispatch_address\", \"col_break46\", \"company_address\", \"column_break_92\", \"company_contact_person\", \"company_address_display\", \"payment_schedule_section\", \"payment_terms_section\", \"payment_terms_template\", \"payment_schedule\", \"terms_section_break\", \"tc_name\", \"terms\", \"more_info\", \"section_break_78\", \"status\", \"delivery_status\", \"per_delivered\", \"column_break_81\", \"per_billed\", \"per_picked\", \"billing_status\", \"sales_team_section_break\", \"sales_partner\", \"column_break7\", \"amount_eligible_for_commission\", \"commission_rate\", \"total_commission\", \"section_break1\", \"sales_team\", \"loyalty_points_redemption\", \"loyalty_points\", \"column_break_116\", \"loyalty_amount\", \"subscription_section\", \"from_date\", \"to_date\", \"column_break_108\", \"auto_repeat\", \"update_auto_repeat_reference\", \"printing_details\", \"letter_head\", \"group_same_items\", \"column_break4\", \"select_print_heading\", \"language\", \"additional_info_section\", \"is_internal_customer\", \"represents_company\", \"column_break_152\", \"source\", \"inter_company_order_reference\", \"campaign\", \"party_account_currency\", \"connections_tab\"]"
}
]

View File

@ -1 +1,2 @@
from .payments import PaymentData
from .payments import PaymentData
from .item_models import BOMItem, PackageCreationData

View File

@ -0,0 +1,18 @@
from dataclasses import dataclass
@dataclass
class BOMItem:
item_code: str
qty: float
uom: str
item_name: str = None
@dataclass
class PackageCreationData:
package_name: str
items: list[BOMItem]
item_group: str
code_prefix: str
rate: float = 0.0
company: str = None
description: str = None

View File

@ -8,4 +8,5 @@ from .task_service import TaskService
from .service_appointment_service import ServiceAppointmentService
from .stripe_service import StripeService
from .payment_service import PaymentService
from .item_service import ItemService
from .item_service import ItemService
from .project_service import ProjectService

View File

@ -24,9 +24,11 @@ class ItemService:
print(f"DEBUG: Getting BOM for Item {item_code}")
bom_name = frappe.db.get_value("BOM", {"item": item_code, "is_active": 1}, "name")
bom_dict = frappe.get_doc("BOM", bom_name).as_dict()
for item in bom_dict.get('exploded_items', []):
item_bom_name = frappe.get_value("Item", item["item_name"], "default_bom")
item["bom"] = frappe.get_doc("BOM", item_bom_name).as_dict() if item_bom_name else None
for item in bom_dict.get('items', []):
bom_no = item.get("bom_no")
if bom_no:
bom_item_code = frappe.db.get_value("BOM", bom_no, "item")
item["bom"] = ItemService.get_full_bom_dict(bom_item_code)
return bom_dict
@staticmethod
@ -35,4 +37,168 @@ class ItemService:
print(f"DEBUG: Checking existence of Item {item_code}")
exists = frappe.db.exists("Item", item_code) is not None
print(f"DEBUG: Item {item_code} exists: {exists}")
return exists
return exists
@staticmethod
def get_child_groups(item_group: str) -> list[str]:
"""Retrieve all child item groups of a given item group."""
print(f"DEBUG: Getting child groups for Item Group {item_group}")
children = []
child_groups = frappe.get_all("Item Group", filters={"parent_item_group": item_group}, pluck="name")
if child_groups:
children.extend(child_groups)
print(f"DEBUG: Found child groups: {child_groups}. Checking for further children.")
for child_group in child_groups:
additional_child_groups = ItemService.get_child_groups(child_group)
children.extend(additional_child_groups)
print(f"DEBUG: Retrieved child groups: {child_groups}")
return children
@staticmethod
def get_item_names_by_group(item_groups: set[str]) -> list[str]:
"""Retrieve item names for items belonging to the specified item groups."""
print(f"DEBUG: Getting item names for Item Groups {item_groups}")
items = frappe.get_all("Item", filters={"item_group": ["in", list(item_groups)]}, pluck="name")
print(f"DEBUG: Retrieved item names: {items}")
return items
@staticmethod
def get_items_by_groups(item_groups: list[str]) -> list[dict]:
"""Retrieve all items belonging to the specified item groups."""
print(f"DEBUG: Getting items for Item Groups {item_groups}")
all_groups = set(item_groups)
for group in item_groups:
all_groups.update(ItemService.get_child_groups(group))
# Batch fetch all items at once with all needed fields
items = frappe.get_all(
"Item",
filters={"item_group": ["in", list(all_groups)]},
fields=[
"name", "item_code", "item_name", "item_group", "description",
"standard_rate", "stock_uom", "default_bom"
]
)
# Get all item codes that have BOMs
items_with_boms = [item for item in items if item.get("default_bom")]
item_codes_with_boms = [item["item_code"] for item in items_with_boms]
# Batch fetch all BOMs and their nested structure
bom_dict = ItemService.batch_fetch_boms(item_codes_with_boms) if item_codes_with_boms else {}
# Attach BOMs to items
for item in items:
if item.get("default_bom"):
item["bom"] = bom_dict.get(item["item_code"])
else:
item["bom"] = None
print(f"DEBUG: Retrieved {len(items)} items")
return items
@staticmethod
def batch_fetch_boms(item_codes: list[str]) -> dict:
"""Batch fetch all BOMs and build nested structure efficiently."""
if not item_codes:
return {}
print(f"DEBUG: Batch fetching BOMs for {len(item_codes)} items")
# Fetch all active BOMs for the given items
boms = frappe.get_all(
"BOM",
filters={"item": ["in", item_codes], "is_active": 1},
fields=["name", "item"]
)
if not boms:
return {}
bom_names = [bom["name"] for bom in boms]
# Fetch all BOM items (children) in one query
bom_items = frappe.get_all(
"BOM Item",
filters={"parent": ["in", bom_names]},
fields=["parent", "item_code", "item_name", "qty", "uom", "bom_no"],
order_by="idx"
)
# Group BOM items by their parent BOM
bom_items_map = {}
nested_bom_items = set()
for bom_item in bom_items:
parent = bom_item["parent"]
if parent not in bom_items_map:
bom_items_map[parent] = []
bom_items_map[parent].append(bom_item)
# Track which items have nested BOMs
if bom_item.get("bom_no"):
nested_bom_items.add(bom_item["item_code"])
# Recursively fetch nested BOMs if any
nested_bom_dict = {}
if nested_bom_items:
nested_bom_dict = ItemService.batch_fetch_boms(list(nested_bom_items))
# Build the result dictionary mapping item_code to its BOM structure
result = {}
for bom in boms:
bom_name = bom["name"]
item_code = bom["item"]
items = bom_items_map.get(bom_name, [])
# Attach nested BOMs to items
for item in items:
if item.get("bom_no"):
item["bom"] = nested_bom_dict.get(item["item_code"])
else:
item["bom"] = None
result[item_code] = {
"name": bom_name,
"items": items
}
return result
@staticmethod
def build_category_dict(items: list[dict]) -> dict:
"""Build a dictionary categorizing items by their item group."""
print(f"DEBUG: Building category dictionary for items")
category_dict = {}
category_dict["Packages"] = {}
for item in items:
if item.get("bom"):
if item.get("item_group", "Uncategorized") not in category_dict["Packages"]:
category_dict["Packages"][item.get("item_group", "Uncategorized")] = []
category_dict["Packages"][item.get("item_group", "Uncategorized")].append(item)
else:
category = item.get("item_group", "Uncategorized")
if category not in category_dict:
category_dict[category] = []
category_dict[category].append(item)
print(f"DEBUG: Built category dictionary with categories: {list(category_dict.keys())}")
return category_dict
@staticmethod
def build_item_code(prefix: str, item_name: str) -> str:
"""Build a unique item code based on the provided prefix and item name."""
print(f"DEBUG: Building item code with prefix: {prefix} and item name: {item_name}")
# Replace all " " with "-" and convert to uppercase
base_code = f"{prefix}-{item_name.replace(' ', '-').upper()}"
# Check for existing items with the same base code and append a number if necessary
existing_codes = frappe.get_all("Item", filters={"item_code": ["like", f"{base_code}-%"]}, pluck="item_code")
if base_code in existing_codes:
suffix = 1
while f"{base_code}-{suffix}" in existing_codes:
suffix += 1
final_code = f"{base_code}-{suffix}"
else:
final_code = base_code
print(f"DEBUG: Built item code: {final_code}")
return final_code

View File

@ -0,0 +1,12 @@
import frappe
class ProjectService:
@staticmethod
def get_project_item_groups(project_template: str) -> list[str]:
"""Retrieve item groups associated with a given project template."""
print(f"DEBUG: Getting item groups for Project Template {project_template}")
item_groups_str = frappe.db.get_value("Project Template", project_template, "item_groups") or ""
item_groups = [item_group.strip() for item_group in item_groups_str.split(",") if item_group.strip()]
print(f"DEBUG: Retrieved item groups: {item_groups}")
return item_groups

View File

@ -19,6 +19,9 @@ const FRAPPE_GET_ESTIMATE_TEMPLATES_METHOD = "custom_ui.api.db.estimates.get_est
const FRAPPE_CREATE_ESTIMATE_TEMPLATE_METHOD = "custom_ui.api.db.estimates.create_estimate_template";
const FRAPPE_GET_UNAPPROVED_ESTIMATES_COUNT_METHOD = "custom_ui.api.db.estimates.get_unapproved_estimates_count";
const FRAPPE_GET_ESTIMATES_HALF_DOWN_COUNT_METHOD = "custom_ui.api.db.estimates.get_estimates_half_down_count";
// Item methods
const FRAPPE_SAVE_AS_PACKAGE_ITEM_METHOD = "custom_ui.api.db.items.save_as_package_item";
const FRAPPE_GET_ITEMS_BY_PROJECT_TEMPLATE_METHOD = "custom_ui.api.db.items.get_by_project_template";
// Job methods
const FRAPPE_GET_JOB_METHOD = "custom_ui.api.db.jobs.get_job";
const FRAPPE_GET_JOBS_METHOD = "custom_ui.api.db.jobs.get_jobs_table_data";
@ -657,6 +660,22 @@ class Api {
return await this.request(FRAPPE_GET_ADDRESSES_METHOD, { fields, filters });
}
// ============================================================================
// ITEM/PACKAGE METHODS
// ============================================================================
static async getItemsByProjectTemplate(projectTemplate) {
return await this.request(FRAPPE_GET_ITEMS_BY_PROJECT_TEMPLATE_METHOD, { projectTemplate });
}
static async saveAsPackageItem(data) {
return await this.request(FRAPPE_SAVE_AS_PACKAGE_ITEM_METHOD, { data });
}
static async getItemCategories() {
return await this.request("custom_ui.api.db.items.get_item_categories");
}
// ============================================================================
// SERVICE / ROUTE / TIMESHEET METHODS
// ============================================================================

View File

@ -4,17 +4,17 @@
<i class="pi pi-inbox"></i>
<p>{{ emptyMessage }}</p>
</div>
<div v-else v-for="item in items" :key="item.itemCode" class="item-card" :class="{ 'item-selected': item._selected }" @click="handleItemClick(item, $event)">
<div v-else v-for="item in items" :key="item.itemCode" class="item-card" :class="{ 'item-selected': isItemSelected(item.itemCode) }" @click="handleItemClick(item, $event)">
<div class="item-card-header">
<span class="item-code">{{ item.itemCode }}</span>
<span class="item-name">{{ item.itemName }}</span>
<span class="item-price">${{ item.standardRate?.toFixed(2) || '0.00' }}</span>
<Button
:label="item._selected ? 'Selected' : 'Select'"
:icon="item._selected ? 'pi pi-check' : 'pi pi-plus'"
:label="isItemSelected(item.itemCode) ? 'Selected' : 'Select'"
:icon="isItemSelected(item.itemCode) ? 'pi pi-check' : 'pi pi-plus'"
@click.stop="handleItemClick(item, $event)"
size="small"
:severity="item._selected ? 'success' : 'secondary'"
:severity="isItemSelected(item.itemCode) ? 'success' : 'secondary'"
class="select-item-button"
/>
</div>
@ -26,7 +26,7 @@
</template>
<script setup>
import { ref } from "vue";
import { ref, watch, computed, shallowRef } from "vue";
import Button from "primevue/button";
const props = defineProps({
@ -35,6 +35,10 @@ const props = defineProps({
required: true,
default: () => []
},
selectedItems: {
type: Array,
default: () => []
},
emptyMessage: {
type: String,
default: "No items found in this category"
@ -43,18 +47,36 @@ const props = defineProps({
const emit = defineEmits(['select']);
const selectedItems = ref([]);
const internalSelection = ref([]);
const selectionSet = shallowRef(new Set());
// Sync internal selection with prop
watch(() => props.selectedItems, (newVal) => {
internalSelection.value = [...newVal];
selectionSet.value = new Set(newVal.map(item => item.itemCode));
}, { immediate: true });
const isItemSelected = (itemCode) => {
return selectionSet.value.has(itemCode);
};
const handleItemClick = (item, event) => {
// Always multi-select mode - toggle item in selection
const index = selectedItems.value.findIndex(i => i.itemCode === item.itemCode);
const index = internalSelection.value.findIndex(i => i.itemCode === item.itemCode);
const newSet = new Set(selectionSet.value);
if (index >= 0) {
selectedItems.value.splice(index, 1);
internalSelection.value.splice(index, 1);
newSet.delete(item.itemCode);
} else {
selectedItems.value.push(item);
internalSelection.value.push(item);
newSet.add(item.itemCode);
}
// Update Set directly instead of recreating from array
selectionSet.value = newSet;
// Emit the entire selection array
emit('select', [...selectedItems.value]);
emit('select', [...internalSelection.value]);
};
</script>

View File

@ -25,15 +25,13 @@
<div class="tabs-container">
<Tabs v-model="activeItemTab" v-if="itemGroups.length > 0 || packageGroups.length > 0">
<TabList>
<Tab v-if="packageGroups.length > 0" value="Packages">
<i class="pi pi-box"></i>
<span>Packages</span>
</Tab>
<Tab v-for="group in itemGroups" :key="group" :value="group">{{ group }}</Tab>
<Tab v-if="packageGroups.length > 0" value="Packages">Packages</Tab>
</TabList>
<TabPanels>
<!-- Regular category tabs -->
<TabPanel v-for="group in itemGroups" :key="group" :value="group">
<ItemSelector :items="getFilteredItemsForGroup(group)" @select="handleItemSelection" />
</TabPanel>
<!-- Packages tab with nested sub-tabs -->
<TabPanel v-if="packageGroups.length > 0" value="Packages">
<Tabs v-model="activePackageTab" class="nested-tabs">
@ -64,12 +62,10 @@
class="add-package-button"
/>
</div>
<div v-if="isPackageExpanded(item.itemCode) && item.bom" class="bom-details">
<div v-if="isPackageExpanded(item.itemCode) && item.bom && item.bom.items" class="bom-details">
<div class="bom-header">Bill of Materials:</div>
<div v-for="bomItem in (item.bom.items || [])" :key="bomItem.itemCode" class="bom-item">
<span class="bom-item-code">{{ bomItem.itemCode }}</span>
<span class="bom-item-name">{{ bomItem.itemName }}</span>
<span class="bom-item-qty">Qty: {{ bomItem.qty }}</span>
<div v-for="bomItem in item.bom.items" :key="bomItem.itemCode" class="bom-item-wrapper">
<BomItem :item="bomItem" :parentPath="item.itemCode" :level="0" />
</div>
</div>
</div>
@ -78,6 +74,14 @@
</TabPanels>
</Tabs>
</TabPanel>
<!-- Regular category tabs -->
<TabPanel v-for="group in itemGroups" :key="group" :value="group">
<ItemSelector
:items="getFilteredItemsForGroup(group)"
:selected-items="getSelectedItemsForGroup(group)"
@select="handleItemSelection"
/>
</TabPanel>
</TabPanels>
</Tabs>
<!-- Fallback if no categories -->
@ -102,7 +106,7 @@
</template>
<script setup>
import { ref, computed, watch } from "vue";
import { ref, computed, watch, defineComponent, h, shallowRef } from "vue";
import Modal from "../common/Modal.vue";
import ItemSelector from "../common/ItemSelector.vue";
import InputText from "primevue/inputtext";
@ -127,8 +131,73 @@ const props = defineProps({
const emit = defineEmits(['update:visible', 'add-items']);
const searchTerm = ref("");
const expandedPackageItems = ref(new Set());
const selectedItemsInModal = ref(new Set());
const expandedPackageItems = shallowRef(new Set());
const selectedItemsInModal = shallowRef(new Set());
// BomItem component for recursive rendering
const BomItem = defineComponent({
name: 'BomItem',
props: {
item: Object,
parentPath: String,
level: {
type: Number,
default: 0
}
},
setup(props) {
const itemPath = computed(() => {
return props.parentPath ? `${props.parentPath}.${props.item.itemCode}` : props.item.itemCode;
});
const isPackage = computed(() => {
return props.item.bom && props.item.bom.items && props.item.bom.items.length > 0;
});
const isExpanded = computed(() => {
return expandedPackageItems.value.has(itemPath.value);
});
const toggleExpansion = () => {
if (expandedPackageItems.value.has(itemPath.value)) {
expandedPackageItems.value.delete(itemPath.value);
} else {
expandedPackageItems.value.add(itemPath.value);
}
expandedPackageItems.value = new Set(expandedPackageItems.value);
};
return () => h('div', {
class: 'bom-item',
style: { paddingLeft: `${props.level * 1}rem` }
}, [
h('div', { class: 'bom-item-content' }, [
isPackage.value ? h(Button, {
icon: isExpanded.value ? 'pi pi-chevron-down' : 'pi pi-chevron-right',
onClick: toggleExpansion,
text: true,
rounded: true,
size: 'small',
class: 'bom-expand-button'
}) : h('i', { class: 'pi pi-circle-fill bom-item-bullet' }),
isPackage.value ? h('i', { class: 'pi pi-box package-icon' }) : null,
h('span', { class: 'bom-item-code' }, props.item.itemCode),
h('span', { class: 'bom-item-name' }, props.item.itemName),
h('span', { class: 'bom-item-qty' }, `Qty: ${props.item.qty}`)
]),
isPackage.value && isExpanded.value && props.item.bom?.items ? h('div', { class: 'nested-bom' },
props.item.bom.items.map(nestedItem =>
h(BomItem, {
key: nestedItem.itemCode,
item: nestedItem,
parentPath: itemPath.value,
level: props.level + 1
})
)
) : null
]);
}
});
const itemGroups = computed(() => {
if (!props.quotationItems || typeof props.quotationItems !== 'object') return [];
@ -142,9 +211,9 @@ const packageGroups = computed(() => {
return Object.keys(props.quotationItems.Packages).sort();
});
// Active tabs with default to first category
// Active tabs with default to Packages
const activeItemTab = computed({
get: () => _activeItemTab.value || itemGroups.value[0] || "",
get: () => _activeItemTab.value || (packageGroups.value.length > 0 ? "Packages" : itemGroups.value[0]) || "",
set: (val) => { _activeItemTab.value = val; }
});
@ -176,13 +245,19 @@ const getFilteredItemsForGroup = (group) => {
);
}
return items.map((item) => ({
...item,
id: item.itemCode,
_selected: selectedItemsInModal.value.has(item.itemCode)
// Map items and mark those that are selected
return items.map((item) => ({
...item,
id: item.itemCode
}));
};
const getSelectedItemsForGroup = (group) => {
if (selectedItemsInModal.value.size === 0) return [];
const allItems = getFilteredItemsForGroup(group);
return allItems.filter(item => selectedItemsInModal.value.has(item.itemCode));
};
const getFilteredPackageItemsForGroup = (packageGroup) => {
if (!props.quotationItems?.Packages || typeof props.quotationItems.Packages !== 'object') return [];
@ -213,13 +288,13 @@ const getFilteredPackageItemsForGroup = (packageGroup) => {
const selectedItemsCount = computed(() => selectedItemsInModal.value.size);
const togglePackageExpansion = (itemCode) => {
if (expandedPackageItems.value.has(itemCode)) {
expandedPackageItems.value.delete(itemCode);
const newExpanded = new Set(expandedPackageItems.value);
if (newExpanded.has(itemCode)) {
newExpanded.delete(itemCode);
} else {
expandedPackageItems.value.add(itemCode);
newExpanded.add(itemCode);
}
// Force reactivity
expandedPackageItems.value = new Set(expandedPackageItems.value);
expandedPackageItems.value = newExpanded;
};
const isPackageExpanded = (itemCode) => {
@ -229,17 +304,31 @@ const isPackageExpanded = (itemCode) => {
const handleItemSelection = (itemOrRows) => {
// Handle both single item (from package cards) and array (from DataTable)
if (Array.isArray(itemOrRows)) {
// From DataTable - replace selection with new array
selectedItemsInModal.value = new Set(itemOrRows.map(row => row.itemCode));
// From ItemSelector - merge with existing selection
const newSelection = new Set(selectedItemsInModal.value);
const itemCodes = itemOrRows.map(row => row.itemCode);
// Check if all items are already selected
const allSelected = itemCodes.every(code => newSelection.has(code));
if (allSelected) {
// Deselect all items
itemCodes.forEach(code => newSelection.delete(code));
} else {
// Select all items
itemCodes.forEach(code => newSelection.add(code));
}
selectedItemsInModal.value = newSelection;
} else {
// From package card - toggle single item
if (selectedItemsInModal.value.has(itemOrRows.itemCode)) {
selectedItemsInModal.value.delete(itemOrRows.itemCode);
const newSelection = new Set(selectedItemsInModal.value);
if (newSelection.has(itemOrRows.itemCode)) {
newSelection.delete(itemOrRows.itemCode);
} else {
selectedItemsInModal.value.add(itemOrRows.itemCode);
newSelection.add(itemOrRows.itemCode);
}
// Force reactivity
selectedItemsInModal.value = new Set(selectedItemsInModal.value);
selectedItemsInModal.value = newSelection;
}
};
@ -378,6 +467,12 @@ watch(() => props.visible, (newVal) => {
gap: 1rem;
}
.modal-title-container :deep(.p-tab) {
display: flex;
align-items: center;
gap: 0.5rem;
}
.selection-badge {
background-color: #2196f3;
color: white;
@ -495,18 +590,40 @@ watch(() => props.visible, (newVal) => {
}
.bom-item {
display: grid;
grid-template-columns: 120px 1fr 100px;
gap: 1rem;
padding: 0.5rem;
display: flex;
flex-direction: column;
border-bottom: 1px solid #f0f0f0;
align-items: center;
}
.bom-item:last-child {
border-bottom: none;
}
.bom-item-content {
display: grid;
grid-template-columns: 32px 24px 120px 1fr 100px;
gap: 0.5rem;
padding: 0.5rem;
align-items: center;
}
.bom-expand-button {
width: 1.5rem;
height: 1.5rem;
padding: 0;
}
.bom-item-bullet {
font-size: 0.4rem;
color: #ccc;
margin-left: 0.5rem;
}
.package-icon {
color: #2196f3;
font-size: 0.9rem;
}
.bom-item-code {
font-family: monospace;
color: #666;
@ -523,4 +640,10 @@ watch(() => props.visible, (newVal) => {
font-size: 0.85rem;
text-align: right;
}
.nested-bom {
background-color: #fafafa;
border-left: 2px solid #e0e0e0;
margin-left: 1rem;
}
</style>

View File

@ -0,0 +1,382 @@
<template>
<Modal
:visible="visible"
@update:visible="$emit('update:visible', $event)"
@close="handleClose"
:options="{ showActions: false }"
>
<template #title>Save as Package</template>
<div class="modal-content">
<div class="form-section">
<label for="packageName" class="field-label">
Package Name
<span class="required">*</span>
</label>
<InputText
id="packageName"
v-model="formData.packageName"
placeholder="Enter package name"
fluid
/>
</div>
<div class="form-section">
<label for="description" class="field-label">
Description
</label>
<InputText
id="description"
v-model="formData.description"
placeholder="Enter package description (optional)"
fluid
/>
</div>
<div class="form-section">
<label for="codePrefix" class="field-label">
Code Prefix
<span class="required">*</span>
</label>
<Select
id="codePrefix"
v-model="formData.codePrefix"
:options="codePrefixOptions"
placeholder="Select a code prefix"
fluid
/>
</div>
<div class="form-section">
<label for="category" class="field-label">
Category
<span class="required">*</span>
</label>
<Select
id="category"
v-model="formData.category"
:options="categories"
placeholder="Select a category"
fluid
/>
</div>
<div class="form-section">
<label for="rate" class="field-label">
Rate
<span class="required">*</span>
</label>
<InputNumber
id="rate"
v-model="formData.rate"
mode="currency"
currency="USD"
locale="en-US"
:min="0"
placeholder="$0.00"
fluid
/>
</div>
<div class="form-section">
<h4>Package Contents</h4>
<div v-if="items.length === 0" class="no-items">
No items selected
</div>
<div v-else class="items-list">
<div
v-for="(item, index) in items"
:key="index"
class="package-item"
>
<div class="item-header" @click="toggleItemExpansion(index)">
<div class="item-info">
<i
v-if="isPackage(item)"
:class="[
'pi',
expandedItems.has(index) ? 'pi-chevron-down' : 'pi-chevron-right',
'expand-icon'
]"
></i>
<span class="item-name">{{ item.itemName || item.itemCode }}</span>
<span v-if="isPackage(item)" class="package-badge">Package</span>
</div>
<span class="item-qty">Qty: {{ item.qty || 1 }}</span>
</div>
<div
v-if="isPackage(item) && expandedItems.has(index)"
class="package-contents"
>
<div
v-for="(bomItem, bomIndex) in item.bom"
:key="bomIndex"
class="bom-item"
>
<span class="bom-item-name">{{ bomItem.itemName || bomItem.itemCode }}</span>
<span class="bom-item-qty">Qty: {{ bomItem.qty || 1 }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="action-buttons">
<Button label="Cancel" @click="handleClose" severity="secondary" />
<Button
label="Save Package"
@click="handleSave"
:disabled="!isFormValid"
/>
</div>
</div>
</Modal>
</template>
<script setup>
import { ref, reactive, computed, watch } from "vue";
import Modal from "../common/Modal.vue";
import InputText from "primevue/inputtext";
import InputNumber from "primevue/inputnumber";
import Button from "primevue/button";
import Select from "primevue/select";
import Api from "../../api";
import { useNotificationStore } from "../../stores/notifications-primevue";
const props = defineProps({
visible: {
type: Boolean,
required: true,
},
items: {
type: Array,
default: () => [],
},
defaultRate: {
type: Number,
default: 0,
},
});
const emit = defineEmits(["update:visible", "save"]);
const notificationStore = useNotificationStore();
const formData = reactive({
packageName: "",
description: "",
codePrefix: null,
category: null,
rate: null,
});
const codePrefixOptions = ref(["BLDR", "SNW-I"]);
const categories = ref([]);
const expandedItems = ref(new Set());
const isLoading = ref(false);
const isFormValid = computed(() => {
return formData.packageName.trim() !== "" &&
formData.codePrefix !== null &&
formData.category !== null &&
formData.rate !== null &&
formData.rate > 0;
});
const isPackage = (item) => {
return item.bom && Array.isArray(item.bom) && item.bom.length > 0;
};
const toggleItemExpansion = (index) => {
const item = props.items[index];
if (!isPackage(item)) return;
if (expandedItems.value.has(index)) {
expandedItems.value.delete(index);
} else {
expandedItems.value.add(index);
}
};
const fetchCategories = async () => {
try {
isLoading.value = true;
const result = await Api.getItemCategories();
categories.value = result || [];
} catch (error) {
console.error("Error fetching item categories:", error);
notificationStore.addNotification("Failed to fetch item categories", "error");
categories.value = [];
} finally {
isLoading.value = false;
}
};
const handleClose = () => {
// Reset form
formData.packageName = "";
formData.description = "";
formData.codePrefix = null;
formData.category = null;
formData.rate = null;
expandedItems.value.clear();
emit("update:visible", false);
};
const handleSave = () => {
if (!isFormValid.value) {
notificationStore.addNotification("Please fill in all required fields", "error");
return;
}
const packageData = {
packageName: formData.packageName,
description: formData.description,
codePrefix: formData.codePrefix,
category: formData.category,
rate: formData.rate,
items: props.items.map(item => ({
itemCode: item.itemCode,
itemName: item.itemName,
qty: item.qty || 1,
uom: item.uom || item.stockUom,
})),
};
emit("save", packageData);
handleClose();
};
// Watch for modal opening to fetch categories
watch(
() => props.visible,
(newVal) => {
if (newVal) {
fetchCategories();
// Set rate to defaultRate when modal opens
if (props.defaultRate > 0) {
formData.rate = props.defaultRate;
}
}
}
);
</script>
<style scoped>
.modal-content {
padding: 1.5rem;
max-height: 70vh;
overflow-y: auto;
}
.form-section {
margin-bottom: 1.5rem;
}
.field-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #333;
}
.required {
color: red;
}
.no-items {
padding: 2rem;
text-align: center;
color: #666;
font-style: italic;
}
.items-list {
border: 1px solid #e0e0e0;
border-radius: 6px;
overflow: hidden;
}
.package-item {
border-bottom: 1px solid #e0e0e0;
}
.package-item:last-child {
border-bottom: none;
}
.item-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
cursor: pointer;
transition: background-color 0.2s;
}
.item-header:hover {
background-color: #f8f9fa;
}
.item-info {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
}
.expand-icon {
font-size: 0.8rem;
color: #666;
}
.item-name {
font-weight: 500;
color: #333;
}
.package-badge {
display: inline-block;
padding: 0.2rem 0.5rem;
background-color: #e3f2fd;
color: #1976d2;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.item-qty {
color: #666;
font-size: 0.9rem;
}
.package-contents {
background-color: #f8f9fa;
padding: 0.5rem 1rem 0.5rem 2.5rem;
border-top: 1px solid #e0e0e0;
}
.bom-item {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
color: #666;
font-size: 0.9rem;
}
.bom-item-name {
flex: 1;
}
.bom-item-qty {
color: #999;
}
.action-buttons {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #e0e0e0;
}
</style>

View File

@ -67,43 +67,7 @@
</div>
</div>
<!-- Template Section -->
<div class="template-section">
<div v-if="isNew">
<label for="template" class="field-label">
From Template
<i class="pi pi-question-circle help-icon" v-tooltip.right="'Pre-fills estimate items and sets default Project Template. Serves as a starting point for this estimate.'"></i>
</label>
<div class="template-input-group">
<Select
v-model="selectedTemplate"
:options="templateOptions"
optionLabel="templateName"
optionValue="name"
placeholder="Select a template"
fluid
@change="onTemplateChange"
>
<template #option="slotProps">
<div class="template-option">
<div class="template-name">{{ slotProps.option.templateName }}</div>
<div class="template-desc">{{ slotProps.option.description }}</div>
</div>
</template>
</Select>
<Button
v-if="selectedTemplate"
icon="pi pi-times"
@click="clearTemplate"
class="clear-button"
severity="secondary"
/>
</div>
</div>
<div v-else>
<Button label="Save As Template" icon="pi pi-save" @click="openSaveTemplateModal" />
</div>
</div>
<!-- Project Template Section -->
<div class="project-template-section">
@ -118,102 +82,137 @@
optionLabel="name"
optionValue="name"
placeholder="Select a project template"
:disabled="!isEditable || isProjectTemplateDisabled"
:disabled="!isEditable"
fluid
/>
<div v-if="isLoadingQuotationItems" class="loading-message">
<i class="pi pi-spin pi-spinner"></i>
<span>Loading available items...</span>
</div>
</div>
<!-- Items Section -->
<div class="items-section">
<h3>Items</h3>
<Button
v-if="isEditable"
label="Add Item"
icon="pi pi-plus"
@click="showAddItemModal = true"
:disabled="!quotationItems || Object.keys(quotationItems).length === 0"
/>
<div v-for="(item, index) in selectedItems" :key="item.itemCode" class="item-row">
<span>{{ item.itemName }}</span>
<div class="input-wrapper">
<span class="input-label">Quantity</span>
<InputNumber
v-model="item.qty"
:min="1"
:disabled="!isEditable"
showButtons
buttonLayout="horizontal"
@input="onQtyChange(item)"
class="qty-input"
<div class="items-header">
<h3>Items</h3>
<div v-if="isEditable" class="items-actions">
<Button
label="Add Item"
icon="pi pi-plus"
@click="showAddItemModal = true"
:disabled="!hasQuotationItems || isLoadingQuotationItems"
:loading="isLoadingQuotationItems"
/>
<Button
label="Save as Package"
icon="pi pi-save"
@click="showSavePackageModal = true"
:disabled="selectedItems.length === 0"
severity="secondary"
/>
</div>
<span>X</span>
<div class="input-wrapper">
<span class="input-label">Rate</span>
<InputNumber
v-model="item.rate"
mode="currency"
currency="USD"
locale="en-US"
:min="0"
:disabled="!isEditable"
@input="onRateChange(item)"
class="rate-input"
/>
</div>
<div class="input-wrapper">
<span class="input-label">Discount</span>
<div class="discount-container">
<div class="discount-input-wrapper">
<InputNumber
v-if="item.discountType === 'currency'"
v-model="item.discountAmount"
mode="currency"
currency="USD"
locale="en-US"
:min="0"
:disabled="!isEditable"
@input="updateDiscountFromAmount(item)"
placeholder="$0.00"
class="discount-input"
/>
<InputNumber
v-else
v-model="item.discountPercentage"
suffix="%"
:min="0"
:max="100"
:disabled="!isEditable"
@input="updateDiscountFromPercentage(item)"
placeholder="0%"
class="discount-input"
/>
</div>
<div class="discount-toggle">
<Button
icon="pi pi-dollar"
class="p-button-sm p-button-outlined"
:class="{ 'p-button-secondary': item.discountType !== 'currency' }"
@click="toggleDiscountType(item, 'currency')"
:disabled="!isEditable"
/>
<Button
icon="pi pi-percentage"
class="p-button-sm p-button-outlined"
:class="{ 'p-button-secondary': item.discountType !== 'percentage' }"
@click="toggleDiscountType(item, 'percentage')"
:disabled="!isEditable"
/>
</div>
<div v-for="(item, index) in selectedItems" :key="item.itemCode" class="item-container">
<div class="item-row">
<div class="item-name-wrapper">
<Button
v-if="isPackageItem(item)"
:icon="isItemExpanded(item.itemCode) ? 'pi pi-chevron-down' : 'pi pi-chevron-right'"
text
rounded
size="small"
@click="toggleItemExpansion(item.itemCode)"
class="expand-button"
/>
<i v-if="isPackageItem(item)" class="pi pi-box package-icon"></i>
<i v-else class="pi pi-circle-fill item-bullet"></i>
<span class="item-name">{{ item.itemName }}</span>
</div>
<div class="input-wrapper">
<span class="input-label">Quantity</span>
<InputNumber
v-model="item.qty"
:min="1"
:disabled="!isEditable"
showButtons
buttonLayout="horizontal"
@input="onQtyChange(item)"
class="qty-input"
/>
</div>
<span>X</span>
<div class="input-wrapper">
<span class="input-label">Rate</span>
<InputNumber
v-model="item.rate"
mode="currency"
currency="USD"
locale="en-US"
:min="0"
:disabled="!isEditable"
@input="onRateChange(item)"
class="rate-input"
/>
</div>
<div class="input-wrapper">
<span class="input-label">Discount</span>
<div class="discount-container">
<div class="discount-input-wrapper">
<InputNumber
v-if="item.discountType === 'currency'"
v-model="item.discountAmount"
mode="currency"
currency="USD"
locale="en-US"
:min="0"
:disabled="!isEditable"
@input="updateDiscountFromAmount(item)"
placeholder="$0.00"
class="discount-input"
/>
<InputNumber
v-else
v-model="item.discountPercentage"
suffix="%"
:min="0"
:max="100"
:disabled="!isEditable"
@input="updateDiscountFromPercentage(item)"
placeholder="0%"
class="discount-input"
/>
</div>
<div class="discount-toggle">
<Button
icon="pi pi-dollar"
class="p-button-sm p-button-outlined"
:class="{ 'p-button-secondary': item.discountType !== 'currency' }"
@click="toggleDiscountType(item, 'currency')"
:disabled="!isEditable"
/>
<Button
icon="pi pi-percentage"
class="p-button-sm p-button-outlined"
:class="{ 'p-button-secondary': item.discountType !== 'percentage' }"
@click="toggleDiscountType(item, 'percentage')"
:disabled="!isEditable"
/>
</div>
</div>
</div>
<span>Total: ${{ ((item.qty || 0) * (item.rate || 0) - (item.discountAmount || 0)).toFixed(2) }}</span>
<Button
v-if="isEditable"
icon="pi pi-trash"
@click="removeItem(index)"
severity="danger"
/>
</div>
<div v-if="isPackageItem(item) && isItemExpanded(item.itemCode)" class="nested-items">
<div v-for="bomItem in item.bom.items" :key="bomItem.itemCode">
<BomItem :item="bomItem" :parentPath="item.itemCode" :level="0" />
</div>
</div>
<span>Total: ${{ ((item.qty || 0) * (item.rate || 0) - (item.discountAmount || 0)).toFixed(2) }}</span>
<Button
v-if="isEditable"
icon="pi pi-trash"
@click="removeItem(index)"
severity="danger"
/>
</div>
<div class="total-section">
<strong>Total Cost: ${{ totalCost.toFixed(2) }}</strong>
@ -301,6 +300,14 @@
:quotation-items="quotationItems"
@add-items="addSelectedItems"
/>
<!-- Save Package Modal -->
<SavePackageModal
:visible="showSavePackageModal"
@update:visible="showSavePackageModal = $event"
:items="selectedItems"
:default-rate="totalCost"
@save="handleSavePackage"
/>
<!-- Down Payment Warning Modal -->
<Modal
:visible="showDownPaymentWarningModal"
@ -400,11 +407,12 @@
</template>
<script setup>
import { ref, reactive, computed, onMounted, watch } from "vue";
import { ref, reactive, computed, onMounted, watch, defineComponent, h } from "vue";
import { useRoute, useRouter } from "vue-router";
import Modal from "../common/Modal.vue";
import SaveTemplateModal from "../modals/SaveTemplateModal.vue";
import AddItemModal from "../modals/AddItemModal.vue";
import SavePackageModal from "../modals/SavePackageModal.vue";
import DataTable from "../common/DataTable.vue";
import DocHistory from "../common/DocHistory.vue";
import BidMeetingNotes from "../modals/BidMeetingNotes.vue";
@ -456,12 +464,10 @@ const estimateResponse = ref(null);
const estimateResponseSelection = ref(null);
const contacts = ref([]);
const contactOptions = ref([]);
const quotationItems = ref([]);
const quotationItems = ref({});
const selectedItems = ref([]);
const responses = ref(["Accepted", "Rejected"]);
const templates = ref([]);
const projectTemplates = ref([]);
const selectedTemplate = ref(null);
const showAddressModal = ref(false);
const showAddItemModal = ref(false);
@ -469,12 +475,78 @@ const showConfirmationModal = ref(false);
const showDownPaymentWarningModal = ref(false);
const showResponseModal = ref(false);
const showSaveTemplateModal = ref(false);
const showSavePackageModal = ref(false);
const addressSearchResults = ref([]);
const showDrawer = ref(false);
const isLoadingQuotationItems = ref(false);
const expandedSelectedItems = ref(new Set());
const estimate = ref(null);
const bidMeeting = ref(null);
// BomItem component for recursive rendering of nested packages
const BomItem = defineComponent({
name: 'BomItem',
props: {
item: Object,
parentPath: String,
level: {
type: Number,
default: 0
}
},
setup(props) {
const itemPath = computed(() => {
return props.parentPath ? `${props.parentPath}.${props.item.itemCode}` : props.item.itemCode;
});
const isPackage = computed(() => {
return props.item.bom && props.item.bom.items && props.item.bom.items.length > 0;
});
const isExpanded = computed(() => {
return expandedSelectedItems.value.has(itemPath.value);
});
const toggleExpansion = () => {
if (expandedSelectedItems.value.has(itemPath.value)) {
expandedSelectedItems.value.delete(itemPath.value);
} else {
expandedSelectedItems.value.add(itemPath.value);
}
expandedSelectedItems.value = new Set(expandedSelectedItems.value);
};
return () => h('div', {
class: 'nested-item',
style: { paddingLeft: `${props.level * 1}rem` }
}, [
h('div', { class: 'nested-item-content' }, [
isPackage.value ? h(Button, {
icon: isExpanded.value ? 'pi pi-chevron-down' : 'pi pi-chevron-right',
onClick: toggleExpansion,
text: true,
rounded: true,
size: 'small',
class: 'nested-expand-button'
}) : null,
isPackage.value ? h('i', { class: 'pi pi-box package-icon' }) : h('i', { class: 'pi pi-circle-fill item-bullet' }),
h('span', { class: 'nested-item-name' }, `${props.item.itemName} (Qty: ${props.item.qty})`)
]),
isPackage.value && isExpanded.value && props.item.bom?.items ? h('div', { class: 'deeply-nested-items' },
props.item.bom.items.map(nestedItem =>
h(BomItem, {
key: nestedItem.itemCode,
item: nestedItem,
parentPath: itemPath.value,
level: props.level + 1
})
)
) : null
]);
}
});
// Computed property to determine if fields are editable
const isEditable = computed(() => {
if (isNew.value) return true;
@ -483,15 +555,8 @@ const isEditable = computed(() => {
return estimate.value.customSent === 0;
});
const templateOptions = computed(() => {
return [
{ name: null, templateName: 'None', description: 'Start from scratch' },
...templates.value
];
});
const isProjectTemplateDisabled = computed(() => {
return selectedTemplate.value !== null;
const hasQuotationItems = computed(() => {
return quotationItems.value && Object.keys(quotationItems.value).length > 0;
});
const itemColumns = [
@ -511,87 +576,55 @@ const fetchProjectTemplates = async () => {
}
};
const fetchTemplates = async () => {
if (!isNew.value) return;
try {
const result = await Api.getEstimateTemplates(company.currentCompany);
templates.value = result;
// Check if template query param exists and set it after templates are loaded
const templateParam = route.query.template;
if (templateParam) {
console.log("DEBUG: Setting template from query param:", templateParam);
// Find template by name (ID) or templateName (Label)
const matchedTemplate = templates.value.find(t =>
t.name === templateParam || t.templateName === templateParam
);
if (matchedTemplate) {
console.log("DEBUG: Found matched template:", matchedTemplate);
selectedTemplate.value = matchedTemplate.name;
// Trigger template change to load items and project template
onTemplateChange();
} else {
console.log("DEBUG: No matching template found for param:", templateParam);
}
}
} catch (error) {
console.error("Error fetching templates:", error);
notificationStore.addNotification("Failed to fetch templates", "error");
}
};
const onTemplateChange = () => {
if (!selectedTemplate.value) {
// None selected - clear items and project template
selectedItems.value = [];
formData.projectTemplate = null;
return;
}
const template = templates.value.find(t => t.name === selectedTemplate.value);
console.log("DEBUG: Selected template:", template);
if (template) {
// Auto-select project template if available (check both camelCase and snake_case)
const projectTemplateValue = template.projectTemplate || template.project_template;
console.log("DEBUG: Project template value from template:", projectTemplateValue);
console.log("DEBUG: Available project templates:", projectTemplates.value);
if (projectTemplateValue) {
formData.projectTemplate = projectTemplateValue;
console.log("DEBUG: Set formData.projectTemplate to:", formData.projectTemplate);
}
if (template.items) {
selectedItems.value = template.items.map(item => ({
itemCode: item.itemCode,
itemName: item.itemName,
qty: item.quantity,
standardRate: item.rate,
discountAmount: null,
discountPercentage: item.discountPercentage,
discountType: item.discountPercentage > 0 ? 'percentage' : 'currency'
}));
// Calculate discount amounts
selectedItems.value.forEach(item => {
if (item.discountType === 'percentage') {
updateDiscountFromPercentage(item);
}
});
}
}
};
const clearTemplate = () => {
selectedTemplate.value = null;
selectedItems.value = [];
formData.projectTemplate = null;
};
const openSaveTemplateModal = () => {
showSaveTemplateModal.value = true;
};
const handleSavePackage = async (packageData) => {
try {
const newPackageItem = await Api.saveAsPackageItem(packageData);
// Initialize Packages object if it doesn't exist
if (!quotationItems.value.Packages) {
quotationItems.value = { ...quotationItems.value, Packages: {} };
}
// Initialize category array if it doesn't exist and add the new package
if (!quotationItems.value.Packages[packageData.category]) {
quotationItems.value.Packages = {
...quotationItems.value.Packages,
[packageData.category]: [newPackageItem]
};
} else {
// Add to existing category
quotationItems.value.Packages[packageData.category] = [
...quotationItems.value.Packages[packageData.category],
newPackageItem
];
}
// Replace all selected items with just the new package
selectedItems.value = [{
itemCode: newPackageItem.itemCode || newPackageItem.item_code,
itemName: newPackageItem.itemName || newPackageItem.item_name,
qty: 1,
rate: newPackageItem.standardRate || newPackageItem.standard_rate || packageData.rate,
standardRate: newPackageItem.standardRate || newPackageItem.standard_rate || packageData.rate,
bom: newPackageItem.bom || null,
uom: newPackageItem.uom || newPackageItem.stockUom || newPackageItem.stock_uom || 'Nos',
discountAmount: null,
discountPercentage: null,
discountType: 'currency'
}];
notificationStore.addSuccess("Package saved successfully", "success");
showSavePackageModal.value = false;
} catch (error) {
console.error("Error saving package:", error);
notificationStore.addNotification("Failed to save package", "error");
}
};
const confirmSaveTemplate = async (templateData) => {
try {
const data = {
@ -827,6 +860,23 @@ const toggleDiscountType = (item, type) => {
item.discountType = type;
};
const toggleItemExpansion = (itemCode) => {
if (expandedSelectedItems.value.has(itemCode)) {
expandedSelectedItems.value.delete(itemCode);
} else {
expandedSelectedItems.value.add(itemCode);
}
expandedSelectedItems.value = new Set(expandedSelectedItems.value);
};
const isItemExpanded = (itemCode) => {
return expandedSelectedItems.value.has(itemCode);
};
const isPackageItem = (item) => {
return item.bom && item.bom.items && item.bom.items.length > 0;
};
const onTabClick = () => {
console.log('Bid notes tab clicked');
console.log('Current showDrawer value:', showDrawer.value);
@ -853,12 +903,29 @@ watch(
);
watch(() => formData.projectTemplate, async (newValue) => {
quotationItems.value = await Api.getQuotationItems(newValue);
if (!newValue) {
quotationItems.value = {};
return;
}
isLoadingQuotationItems.value = true;
try {
quotationItems.value = await Api.getItemsByProjectTemplate(newValue);
console.log("DEBUG: quotationItems after API call:", quotationItems.value);
console.log("DEBUG: quotationItems type:", typeof quotationItems.value);
console.log("DEBUG: quotationItems keys length:", quotationItems.value ? Object.keys(quotationItems.value).length : 0);
console.log("DEBUG: hasQuotationItems computed value:", hasQuotationItems.value);
} catch (error) {
console.error("Error fetching items by project template:", error);
notificationStore.addNotification("Failed to load items for selected project template", "error");
quotationItems.value = {};
} finally {
isLoadingQuotationItems.value = false;
console.log("DEBUG: Loading finished, isLoadingQuotationItems:", isLoadingQuotationItems.value);
}
})
watch(() => company.currentCompany, () => {
if (isNew.value) {
fetchTemplates();
fetchProjectTemplates();
}
});
@ -928,12 +995,12 @@ watch(
// Load quotation items if project template is set (needed for item details)
if (formData.projectTemplate) {
quotationItems.value = await Api.getQuotationItems(formData.projectTemplate);
quotationItems.value = await Api.getItemsByProjectTemplate(formData.projectTemplate);
}
if (estimate.value.items && estimate.value.items.length > 0) {
selectedItems.value = estimate.value.items.map(item => {
const fullItem = quotationItems.value.find(qi => qi.itemCode === item.itemCode);
const fullItem = Object.values(quotationItems.value).flat().find(qi => qi.itemCode === item.itemCode);
const discountPercentage = item.discountPercentage || item.discount_percentage || 0;
const discountAmount = item.discountAmount || item.discount_amount || 0;
return {
@ -972,12 +1039,12 @@ watch(
try {
bidMeeting.value = await Api.getBidMeeting(newFromMeetingQuery);
if (bidMeeting.value?.bidNotes?.quantities) {
// Ensure quotationItems is an array before using find
if (!Array.isArray(quotationItems.value)) {
quotationItems.value = [];
// Ensure quotationItems is an object before using
if (typeof quotationItems.value !== 'object' || quotationItems.value === null) {
quotationItems.value = {};
}
selectedItems.value = bidMeeting.value.bidNotes.quantities.map(q => {
const item = quotationItems.value.find(i => i.itemCode === q.item);
const item = Object.values(quotationItems.value).flat().find(i => i.itemCode === q.item);
return {
itemCode: q.item,
itemName: item?.itemName || q.item,
@ -1013,8 +1080,6 @@ onMounted(async () => {
await fetchProjectTemplates();
if (isNew.value) {
await fetchTemplates();
// Handle from-meeting query parameter
if (fromMeetingQuery.value) {
formData.fromOnsiteMeeting = fromMeetingQuery.value;
@ -1023,12 +1088,12 @@ onMounted(async () => {
bidMeeting.value = await Api.getBidMeeting(fromMeetingQuery.value);
// If new estimate and bid notes have quantities, set default items
if (isNew.value && bidMeeting.value?.bidNotes?.quantities) {
// Ensure quotationItems is an array before using find
if (!Array.isArray(quotationItems.value)) {
quotationItems.value = [];
// Ensure quotationItems is an object before using
if (typeof quotationItems.value !== 'object' || quotationItems.value === null) {
quotationItems.value = {};
}
selectedItems.value = bidMeeting.value.bidNotes.quantities.map(q => {
const item = quotationItems.value.find(i => i.itemCode === q.item);
const item = Object.values(quotationItems.value).flat().find(i => i.itemCode === q.item);
return {
itemCode: q.item,
itemName: item?.itemName || q.item,
@ -1049,7 +1114,7 @@ onMounted(async () => {
// Handle project-template query parameter
if (projectTemplateQuery.value) {
formData.projectTemplate = projectTemplateQuery.value;
quotationItems.value = await Api.getQuotationItems(projectTemplateQuery.value);
quotationItems.value = await Api.getItemsByProjectTemplate(projectTemplateQuery.value);
}
if (addressQuery.value && isNew.value) {
@ -1084,13 +1149,9 @@ onMounted(async () => {
// Load quotation items if project template is set (needed for item details)
if (formData.projectTemplate) {
quotationItems.value = await Api.getQuotationItems(formData.projectTemplate);
quotationItems.value = await Api.getItemsByProjectTemplate(formData.projectTemplate);
}
// Ensure quotationItems is an array
if (!Array.isArray(quotationItems.value)) {
quotationItems.value = [];
}
// Populate items from the estimate
if (estimate.value.items && estimate.value.items.length > 0) {
selectedItems.value = estimate.value.items.map(item => {
@ -1186,21 +1247,10 @@ onMounted(async () => {
.address-section,
.contact-section,
.project-template-section,
.template-section {
.project-template-section {
margin-bottom: 1.5rem;
}
.template-input-group {
display: flex;
gap: 0.5rem;
align-items: center;
}
.clear-button {
flex-shrink: 0;
}
.field-label {
display: block;
margin-bottom: 0.5rem;
@ -1231,17 +1281,115 @@ onMounted(async () => {
margin-top: 2rem;
}
.items-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.items-header h3 {
margin: 0;
}
.items-actions {
display: flex;
gap: 0.5rem;
}
.item-container {
margin-bottom: 0.5rem;
}
.item-row {
display: grid;
grid-template-columns: 3fr 140px 30px 120px 220px 1.5fr auto;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.item-name-wrapper {
display: flex;
align-items: center;
gap: 0.5rem;
}
.expand-button {
padding: 0.25rem;
min-width: auto;
}
.package-icon {
color: #2196f3;
font-size: 1.1em;
}
.item-bullet {
color: #666;
font-size: 0.5em;
}
.item-name {
flex: 1;
}
.nested-items {
margin-left: 2rem;
margin-top: 0.5rem;
padding: 0.5rem;
background-color: #f9f9f9;
border-left: 3px solid #2196f3;
border-radius: 0 4px 4px 0;
}
.nested-item {
display: flex;
flex-direction: column;
border-bottom: 1px solid #f0f0f0;
}
.nested-item:last-child {
border-bottom: none;
}
.nested-item-content {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0;
color: #666;
font-size: 0.9em;
}
.nested-expand-button {
width: 1.5rem;
height: 1.5rem;
padding: 0;
flex-shrink: 0;
}
.nested-item-name {
flex: 1;
}
.deeply-nested-items {
background-color: #fafafa;
border-left: 2px solid #e0e0e0;
margin-left: 1rem;
margin-top: 0.25rem;
}
.nested-item .package-icon {
font-size: 1em;
}
.nested-item .item-bullet {
font-size: 0.4em;
}
.qty-input {
width: 100%;
}
@ -1436,20 +1584,6 @@ onMounted(async () => {
color: #333;
}
.template-option {
display: flex;
flex-direction: column;
}
.template-name {
font-weight: bold;
}
.template-desc {
font-size: 0.85rem;
color: #666;
}
.field-group {
margin-bottom: 1rem;
}
@ -1487,6 +1621,19 @@ onMounted(async () => {
color: #666;
}
.loading-message {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
color: #2196f3;
font-size: 0.9rem;
}
.loading-message i {
font-size: 1.1rem;
}
.bid-notes-drawer {
box-shadow: -4px 0 8px rgba(0, 0, 0, 0.1);
}