Compare commits
16 Commits
main
...
clean-inst
| Author | SHA1 | Date | |
|---|---|---|---|
| f97d48aaed | |||
| 541e6c5ed7 | |||
| 7d8ba70107 | |||
| dd929556e8 | |||
| 1d23d406d8 | |||
| c72f8f0456 | |||
| 147389fe2a | |||
| d488651d2a | |||
| b7208f863f | |||
| 6de4bcc053 | |||
| 21e90cffe1 | |||
| 1fb682d206 | |||
| 6d301cb460 | |||
| 84cd4f7beb | |||
| 1f416302d2 | |||
| d49b834fef |
Binary file not shown.
|
Before Width: | Height: | Size: 496 KiB |
@ -132,7 +132,7 @@ def submit_bid_meeting_note_form(bid_meeting, project_template, fields, form_tem
|
||||
})
|
||||
new_bid_meeting_note_doc.insert(ignore_permissions=True)
|
||||
for field_row, field in zip(new_bid_meeting_note_doc.fields, fields):
|
||||
print(f"DEBUG: {field_row.label} - {field.get('label')}")
|
||||
print(f"DEBUG: {field_row.label} - {field.get("label")}")
|
||||
if not isinstance(field.get("value"), list):
|
||||
continue
|
||||
for item in field["value"]:
|
||||
|
||||
@ -2,9 +2,9 @@ import frappe, json
|
||||
from frappe.utils.pdf import get_pdf
|
||||
from custom_ui.api.db.general import get_doc_history
|
||||
from custom_ui.db_utils import DbUtils, process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, build_error_response
|
||||
from werkzeug.wrappers import Response
|
||||
from custom_ui.api.db.clients import check_if_customer, convert_lead_to_customer
|
||||
from custom_ui.services import DbService, ClientService, AddressService, ContactService, EstimateService, ItemService
|
||||
from frappe.email.doctype.email_template.email_template import get_email_template
|
||||
from custom_ui.services import DbService, ClientService, AddressService, ContactService
|
||||
|
||||
# ===============================================================================
|
||||
# ESTIMATES & INVOICES API METHODS
|
||||
@ -86,25 +86,11 @@ def get_estimate_table_data(filters={}, sortings=[], page=1, page_size=10):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_quotation_items(project_template:str = None):
|
||||
def get_quotation_items():
|
||||
"""Get all available quotation items."""
|
||||
try:
|
||||
filters = EstimateService.map_project_template_to_filter(project_template)
|
||||
items = frappe.get_all("Item", fields=["item_code", "item_group"], filters=filters)
|
||||
grouped_item_dicts = {}
|
||||
for item in items:
|
||||
item_dict = ItemService.get_full_dict(item.item_code)
|
||||
if item_dict["bom"]:
|
||||
if "Packages" not in grouped_item_dicts:
|
||||
grouped_item_dicts["Packages"] = {}
|
||||
if item.item_group not in grouped_item_dicts["Packages"]:
|
||||
grouped_item_dicts["Packages"][item.item_group] = []
|
||||
grouped_item_dicts["Packages"][item.item_group].append(item_dict)
|
||||
else:
|
||||
if item.item_group not in grouped_item_dicts:
|
||||
grouped_item_dicts[item.item_group] = []
|
||||
grouped_item_dicts[item.item_group].append(item_dict)
|
||||
return build_success_response(grouped_item_dicts)
|
||||
items = frappe.get_all("Item", fields=["*"], filters={"item_group": "SNW-S"})
|
||||
return build_success_response(items)
|
||||
except Exception as e:
|
||||
return build_error_response(str(e), 500)
|
||||
|
||||
@ -191,7 +177,7 @@ def send_estimate_email(estimate_name):
|
||||
print("DEBUG: Sending estimate email for:", estimate_name)
|
||||
quotation = frappe.get_doc("Quotation", estimate_name)
|
||||
|
||||
# Get recipient email
|
||||
|
||||
if not DbService.exists("Contact", quotation.contact_person):
|
||||
return build_error_response("No email found for the customer.", 400)
|
||||
party = ContactService.get_or_throw(quotation.contact_person)
|
||||
@ -210,71 +196,21 @@ def send_estimate_email(estimate_name):
|
||||
if not email:
|
||||
return build_error_response("No email found for the customer or address.", 400)
|
||||
|
||||
# Get customer name
|
||||
customer_name = party.first_name or party.name or "Valued Customer"
|
||||
if party.last_name:
|
||||
customer_name = f"{party.first_name} {party.last_name}"
|
||||
|
||||
# Get full address
|
||||
full_address = "Address not specified"
|
||||
if quotation.custom_job_address:
|
||||
address_doc = frappe.get_doc("Address", quotation.custom_job_address)
|
||||
full_address = address_doc.full_address or address_doc.address_line1 or "Address not specified"
|
||||
|
||||
# Format price
|
||||
price = frappe.utils.fmt_money(quotation.grand_total, currency=quotation.currency)
|
||||
|
||||
# Get additional notes
|
||||
additional = quotation.terms or ""
|
||||
|
||||
# Get company phone
|
||||
company_phone = ""
|
||||
if quotation.company:
|
||||
company_doc = frappe.get_doc("Company", quotation.company)
|
||||
company_phone = getattr(company_doc, 'phone_no', '') or getattr(company_doc, 'phone', '')
|
||||
|
||||
# Get base URL
|
||||
base_url = frappe.utils.get_url()
|
||||
|
||||
# Get letterhead image
|
||||
letterhead_image = ""
|
||||
if quotation.letter_head:
|
||||
letterhead_doc = frappe.get_doc("Letter Head", quotation.letter_head)
|
||||
if letterhead_doc.image:
|
||||
letterhead_image = frappe.utils.get_url() + letterhead_doc.image
|
||||
|
||||
# Prepare template context
|
||||
template_context = {
|
||||
"company": quotation.company,
|
||||
"customer_name": customer_name,
|
||||
"price": price,
|
||||
"address": full_address,
|
||||
"additional": additional,
|
||||
"company_phone": company_phone,
|
||||
"base_url": base_url,
|
||||
"estimate_name": quotation.name,
|
||||
"letterhead_image": letterhead_image
|
||||
}
|
||||
|
||||
# Render the email template
|
||||
template_path = "custom_ui/templates/emails/general_estimation.html"
|
||||
message = frappe.render_template(template_path, template_context)
|
||||
subject = f"Estimate from {quotation.company} - {quotation.name}"
|
||||
|
||||
print("DEBUG: Subject:", subject)
|
||||
print("DEBUG: Sending email to:", email)
|
||||
|
||||
# Generate PDF attachment
|
||||
# email = "casey@shilohcode.com"
|
||||
template_name = "Quote with Actions - SNW"
|
||||
template = frappe.get_doc("Email Template", template_name)
|
||||
message = frappe.render_template(template.response, {"name": quotation.name})
|
||||
subject = frappe.render_template(template.subject, {"doc": quotation})
|
||||
print("DEBUG: Message: ", message)
|
||||
print("DEBUG: Subject: ", subject)
|
||||
html = frappe.get_print("Quotation", quotation.name, print_format="Quotation - SNW - Standard", letterhead=True)
|
||||
print("DEBUG: Generated HTML for PDF.")
|
||||
pdf = get_pdf(html)
|
||||
print("DEBUG: Generated PDF for email attachment.")
|
||||
|
||||
# Send email
|
||||
frappe.sendmail(
|
||||
recipients=email,
|
||||
subject=subject,
|
||||
message=message,
|
||||
content=message,
|
||||
doctype="Quotation",
|
||||
name=quotation.name,
|
||||
read_receipt=1,
|
||||
@ -282,14 +218,11 @@ def send_estimate_email(estimate_name):
|
||||
attachments=[{"fname": f"{quotation.name}.pdf", "fcontent": pdf}]
|
||||
)
|
||||
print(f"DEBUG: Email sent to {email} successfully.")
|
||||
|
||||
# Update quotation status
|
||||
quotation.custom_current_status = "Submitted"
|
||||
quotation.custom_sent = 1
|
||||
quotation.save()
|
||||
quotation.submit()
|
||||
frappe.db.commit()
|
||||
|
||||
updated_quotation = frappe.get_doc("Quotation", estimate_name)
|
||||
return build_success_response(updated_quotation.as_dict())
|
||||
except Exception as e:
|
||||
@ -322,6 +255,45 @@ def manual_response(name, response):
|
||||
return build_error_response(str(e), 500)
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def update_response(name, response):
|
||||
"""Update the response for a given estimate."""
|
||||
print("DEBUG: RESPONSE RECEIVED:", name, response)
|
||||
try:
|
||||
if not frappe.db.exists("Quotation", name):
|
||||
raise Exception("Estimate not found.")
|
||||
estimate = frappe.get_doc("Quotation", name)
|
||||
if estimate.docstatus != 1:
|
||||
raise Exception("Estimate must be submitted to update response.")
|
||||
accepted = True if response == "Accepted" else False
|
||||
new_status = "Estimate Accepted" if accepted else "Lost"
|
||||
|
||||
estimate.custom_response = response
|
||||
estimate.custom_current_status = new_status
|
||||
estimate.custom_followup_needed = 1 if response == "Requested call" else 0
|
||||
# estimate.status = "Ordered" if accepted else "Closed"
|
||||
estimate.flags.ignore_permissions = True
|
||||
print("DEBUG: Updating estimate with response:", response, "and status:", new_status)
|
||||
estimate.save()
|
||||
|
||||
if accepted:
|
||||
template = "custom_ui/templates/estimates/accepted.html"
|
||||
# if check_if_customer(estimate.party_name):
|
||||
# print("DEBUG: Party is already a customer:", estimate.party_name)
|
||||
# else:
|
||||
# print("DEBUG: Converting lead to customer for party:", estimate.party_name)
|
||||
# convert_lead_to_customer(estimate.party_name)
|
||||
elif response == "Requested call":
|
||||
template = "custom_ui/templates/estimates/request-call.html"
|
||||
else:
|
||||
template = "custom_ui/templates/estimates/rejected.html"
|
||||
html = frappe.render_template(template, {"doc": estimate})
|
||||
return Response(html, mimetype="text/html")
|
||||
except Exception as e:
|
||||
template = "custom_ui/templates/estimates/error.html"
|
||||
html = frappe.render_template(template, {"error": str(e)})
|
||||
return Response(html, mimetype="text/html")
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_estimate_templates(company):
|
||||
"""Get available estimate templates."""
|
||||
@ -476,7 +448,6 @@ def upsert_estimate(data):
|
||||
estimate.append("items", {
|
||||
"item_code": item.get("item_code"),
|
||||
"qty": item.get("qty"),
|
||||
"rate": item.get("rate"),
|
||||
"discount_amount": item.get("discount_amount") or item.get("discountAmount", 0),
|
||||
"discount_percentage": item.get("discount_percentage") or item.get("discountPercentage", 0)
|
||||
})
|
||||
@ -521,7 +492,6 @@ def upsert_estimate(data):
|
||||
new_estimate.append("items", {
|
||||
"item_code": item.get("item_code"),
|
||||
"qty": item.get("qty"),
|
||||
"rate": item.get("rate"),
|
||||
"discount_amount": item.get("discount_amount") or item.get("discountAmount", 0),
|
||||
"discount_percentage": item.get("discount_percentage") or item.get("discountPercentage", 0)
|
||||
})
|
||||
|
||||
@ -1,80 +0,0 @@
|
||||
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)
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
import frappe
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def update_response(name, response):
|
||||
"""Update the response for a given estimate."""
|
||||
print("DEBUG: RESPONSE RECEIVED:", name, response)
|
||||
try:
|
||||
if not frappe.db.exists("Quotation", name):
|
||||
raise Exception("Estimate not found.")
|
||||
estimate = frappe.get_doc("Quotation", name)
|
||||
if estimate.docstatus != 1:
|
||||
raise Exception("Estimate must be submitted to update response.")
|
||||
accepted = True if response == "Accepted" else False
|
||||
new_status = "Estimate Accepted" if accepted else "Lost"
|
||||
|
||||
estimate.custom_response = response
|
||||
estimate.custom_current_status = new_status
|
||||
estimate.custom_followup_needed = 1 if response == "Requested call" else 0
|
||||
# estimate.status = "Ordered" if accepted else "Closed"
|
||||
estimate.flags.ignore_permissions = True
|
||||
print("DEBUG: Updating estimate with response:", response, "and status:", new_status)
|
||||
estimate.save()
|
||||
|
||||
if accepted:
|
||||
template = "custom_ui/templates/estimates/accepted.html"
|
||||
# if check_if_customer(estimate.party_name):
|
||||
# print("DEBUG: Party is already a customer:", estimate.party_name)
|
||||
# else:
|
||||
# print("DEBUG: Converting lead to customer for party:", estimate.party_name)
|
||||
# convert_lead_to_customer(estimate.party_name)
|
||||
elif response == "Requested call":
|
||||
template = "custom_ui/templates/estimates/request-call.html"
|
||||
else:
|
||||
template = "custom_ui/templates/estimates/rejected.html"
|
||||
html = frappe.render_template(template, {"doc": estimate})
|
||||
frappe.db.commit()
|
||||
return Response(html, mimetype="text/html")
|
||||
except Exception as e:
|
||||
template = "custom_ui/templates/estimates/error.html"
|
||||
html = frappe.render_template(template, {"error": str(e)})
|
||||
return Response(html, mimetype="text/html")
|
||||
@ -1,9 +1,7 @@
|
||||
import frappe
|
||||
import json
|
||||
from datetime import datetime
|
||||
from frappe.utils.data import flt
|
||||
from custom_ui.services import DbService, StripeService, PaymentService
|
||||
from custom_ui.models import PaymentData
|
||||
from custom_ui.services import DbService, StripeService
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def half_down_stripe_payment(sales_order):
|
||||
@ -15,17 +13,16 @@ def half_down_stripe_payment(sales_order):
|
||||
frappe.throw("This sales order does not require a half-down payment.")
|
||||
if so.docstatus != 1:
|
||||
frappe.throw("Sales Order must be submitted to proceed with payment.")
|
||||
if so.custom_halfdown_is_paid or so.advance_paid >= so.custom_halfdown_amount:
|
||||
if so.custom_halfdown_is_paid or so.advanced_paid >= so.custom_halfdown_amount:
|
||||
frappe.throw("Half-down payment has already been made for this sales order.")
|
||||
stripe_session = StripeService.create_checkout_session(
|
||||
company=so.company,
|
||||
amount=so.custom_halfdown_amount,
|
||||
service=so.custom_project_template,
|
||||
order_num=so.name,
|
||||
sales_order=so.name,
|
||||
for_advance_payment=True
|
||||
)
|
||||
frappe.local.response["type"] = "redirect"
|
||||
frappe.local.response["location"] = stripe_session.url
|
||||
return frappe.redirect(stripe_session.url)
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def stripe_webhook():
|
||||
@ -34,60 +31,39 @@ def stripe_webhook():
|
||||
sig_header = frappe.request.headers.get('Stripe-Signature')
|
||||
session, metadata = StripeService.get_session_and_metadata(payload, sig_header)
|
||||
|
||||
# Validate required metadata
|
||||
if not metadata.get("company"):
|
||||
raise frappe.ValidationError("Missing required metadata key: company")
|
||||
if not metadata.get("payment_type"):
|
||||
raise frappe.ValidationError("Missing required metadata key: payment_type")
|
||||
|
||||
# Determine reference document based on payment type
|
||||
payment_type = metadata.get("payment_type")
|
||||
reference_doctype = None
|
||||
reference_doc_name = None
|
||||
|
||||
if payment_type == "advance":
|
||||
reference_doctype = "Sales Order"
|
||||
reference_doc_name = metadata.get("sales_order")
|
||||
if not reference_doc_name:
|
||||
raise frappe.ValidationError("Missing sales_order in metadata for advance payment")
|
||||
elif payment_type == "full":
|
||||
reference_doctype = "Sales Invoice"
|
||||
reference_doc_name = metadata.get("sales_invoice")
|
||||
if not reference_doc_name:
|
||||
raise frappe.ValidationError("Missing sales_invoice in metadata for full payment")
|
||||
else:
|
||||
raise frappe.ValidationError(f"Invalid payment type in metadata: {payment_type}")
|
||||
|
||||
# Check if payment already exists
|
||||
if DbService.exists("Payment Entry", {"reference_no": session.id}):
|
||||
raise frappe.ValidationError("Payment Entry already exists for this session.")
|
||||
|
||||
reference_doctype = "Sales Invoice"
|
||||
|
||||
if metadata.get("payment_type") == "advance":
|
||||
reference_doctype = "Sales Order"
|
||||
elif metadata.get("payment_type") != "full":
|
||||
raise frappe.ValidationError("Invalid payment type in metadata.")
|
||||
|
||||
amount_paid = flt(session.amount_total) / 100
|
||||
currency = session.currency.upper()
|
||||
reference_doc = frappe.get_doc(reference_doctype, metadata.get("order_num"))
|
||||
|
||||
# Convert Unix timestamp to date string (YYYY-MM-DD)
|
||||
reference_date = datetime.fromtimestamp(session.created).strftime('%Y-%m-%d')
|
||||
pe = frappe.get_doc({
|
||||
"doctype": "Payment Entry",
|
||||
"payment_type": "Receive",
|
||||
"party_type": "Customer",
|
||||
"mode_of_payment": "Stripe",
|
||||
"party": reference_doc.customer,
|
||||
"party_name": reference_doc.customer,
|
||||
"paid_to": metadata.get("company"),
|
||||
"reference_no": session.id,
|
||||
"reference_date": frappe.utils.nowdate(),
|
||||
"reference_doctype": reference_doctype,
|
||||
"reference_name": reference_doc.name,
|
||||
"paid_amount": amount_paid,
|
||||
"paid_currency": currency,
|
||||
})
|
||||
|
||||
# Set Administrator context to create Payment Entry
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
try:
|
||||
pe = PaymentService.create_payment_entry(
|
||||
data=PaymentData(
|
||||
mode_of_payment="Stripe",
|
||||
reference_no=session.id,
|
||||
reference_date=reference_date,
|
||||
received_amount=amount_paid,
|
||||
company=metadata.get("company"),
|
||||
reference_doc_name=reference_doc_name
|
||||
)
|
||||
)
|
||||
pe.flags.ignore_permissions = True
|
||||
pe.submit()
|
||||
frappe.db.commit()
|
||||
return "Payment Entry created and submitted successfully."
|
||||
finally:
|
||||
# Reset to Guest user
|
||||
frappe.set_user("Guest")
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
return "Payment Entry created and submitted successfully."
|
||||
|
||||
|
||||
|
||||
|
||||
566
custom_ui/custom_fields.py
Normal file
566
custom_ui/custom_fields.py
Normal file
@ -0,0 +1,566 @@
|
||||
custom_fields = {
|
||||
"Customer": [
|
||||
dict(
|
||||
fieldname="companies",
|
||||
label="Companies",
|
||||
fieldtype="Table",
|
||||
options="Customer Company Link",
|
||||
insert_after="customer_type"
|
||||
),
|
||||
dict(
|
||||
fieldname="quotations",
|
||||
label="Quotations",
|
||||
fieldtype="Table",
|
||||
options="Customer Quotation Link",
|
||||
insert_after="companies"
|
||||
),
|
||||
dict(
|
||||
fieldname="onsite_meetings",
|
||||
label="On-Site Meetings",
|
||||
fieldtype="Table",
|
||||
options="Customer On-Site Meeting Link",
|
||||
insert_after="quotations"
|
||||
),
|
||||
dict(
|
||||
fieldname="projects",
|
||||
label="Projects",
|
||||
fieldtype="Table",
|
||||
options="Customer Project Link",
|
||||
insert_after="onsite_meetings"
|
||||
),
|
||||
dict(
|
||||
fieldname="sales_orders",
|
||||
label="Sales Orders",
|
||||
fieldtype="Table",
|
||||
options="Customer Sales Order Link",
|
||||
insert_after="projects"
|
||||
),
|
||||
dict(
|
||||
fieldname="from_lead",
|
||||
label="From Lead",
|
||||
fieldtype="Link",
|
||||
options="Lead",
|
||||
insert_after="customer_name"
|
||||
),
|
||||
dict(
|
||||
fieldname="properties",
|
||||
label="Properties",
|
||||
fieldtype="Table",
|
||||
options="Customer Address Link",
|
||||
insert_after="customer_name"
|
||||
),
|
||||
dict(
|
||||
fieldname="contacts",
|
||||
label="Contacts",
|
||||
fieldtype="Table",
|
||||
options="Customer Contact Link",
|
||||
insert_after="properties"
|
||||
),
|
||||
dict(
|
||||
fieldname="primary_contact",
|
||||
label="Primary Contact",
|
||||
fieldtype="Link",
|
||||
options="Contact",
|
||||
insert_after="contacts"
|
||||
),
|
||||
dict(
|
||||
fieldname="tasks",
|
||||
label="Tasks",
|
||||
fieldtype="Table",
|
||||
options="Customer Task Link",
|
||||
insert_after="projects"
|
||||
)
|
||||
],
|
||||
"Lead": [
|
||||
dict(
|
||||
fieldname="onsite_meetings",
|
||||
label="On-Site Meetings",
|
||||
fieldtype="Table",
|
||||
options="Lead On-Site Meeting Link",
|
||||
insert_after="quotations"
|
||||
),
|
||||
dict(
|
||||
fieldname="custom_billing_address",
|
||||
label="Custom Address",
|
||||
fieldtype="Link",
|
||||
options="Address",
|
||||
insert_after="customer_name"
|
||||
),
|
||||
dict(
|
||||
fieldname="quotations",
|
||||
label="Quotations",
|
||||
fieldtype="Table",
|
||||
options="Lead Quotation Link",
|
||||
insert_after="companies"
|
||||
),
|
||||
dict(
|
||||
fieldname="companies",
|
||||
label="Companies",
|
||||
fieldtype="Table",
|
||||
options="Lead Company Link",
|
||||
insert_after="customer_type"
|
||||
),
|
||||
dict(
|
||||
fieldname="customer_type",
|
||||
label="Customer Type",
|
||||
fieldtype="Select",
|
||||
options="Individual\nCompany\nPartnership",
|
||||
insert_after="lead_name"
|
||||
),
|
||||
dict(
|
||||
fieldname="properties",
|
||||
label="Properties",
|
||||
fieldtype="Table",
|
||||
options="Lead Address Link",
|
||||
insert_after="customer_name"
|
||||
),
|
||||
dict(
|
||||
fieldname="contacts",
|
||||
label="Contacts",
|
||||
fieldtype="Table",
|
||||
options="Lead Contact Link",
|
||||
insert_after="properties"
|
||||
),
|
||||
dict(
|
||||
fieldname="primary_contact",
|
||||
label="Primary Contact",
|
||||
fieldtype="Link",
|
||||
options="Contact",
|
||||
insert_after="contacts"
|
||||
)
|
||||
],
|
||||
"Address": [
|
||||
dict(
|
||||
fieldname="primary_contact",
|
||||
label="Primary Contact",
|
||||
fieldtype="Link",
|
||||
options="Contact",
|
||||
insert_after="address_title"
|
||||
),
|
||||
dict(
|
||||
fieldname="projects",
|
||||
label="Projects",
|
||||
fieldtype="Table",
|
||||
options="Address Project Link",
|
||||
insert_after="onsite_meetings"
|
||||
),
|
||||
dict(
|
||||
fieldname="sales_orders",
|
||||
label="Sales Orders",
|
||||
fieldtype="Table",
|
||||
options="Address Sales Order Link",
|
||||
insert_after="projects"
|
||||
),
|
||||
dict(
|
||||
fieldname="onsite_meetings",
|
||||
label="On-Site Meetings",
|
||||
fieldtype="Table",
|
||||
options="Address On-Site Meeting Link",
|
||||
insert_after="quotations"
|
||||
),
|
||||
dict(
|
||||
fieldname="quotations",
|
||||
label="Quotations",
|
||||
fieldtype="Table",
|
||||
options="Address Quotation Link",
|
||||
insert_after="companies"
|
||||
),
|
||||
dict(
|
||||
fieldname="full_address",
|
||||
label="Full Address",
|
||||
fieldtype="Data",
|
||||
insert_after="country"
|
||||
),
|
||||
dict(
|
||||
fieldname="latitude",
|
||||
label="Latitude",
|
||||
fieldtype="Float",
|
||||
precision=8,
|
||||
insert_after="full_address"
|
||||
),
|
||||
dict(
|
||||
fieldname="longitude",
|
||||
label="Longitude",
|
||||
fieldtype="Float",
|
||||
precision=8,
|
||||
insert_after="latitude"
|
||||
),
|
||||
dict(
|
||||
fieldname="onsite_meeting_scheduled",
|
||||
label="On-Site Meeting Scheduled",
|
||||
fieldtype="Select",
|
||||
options="Not Started\nIn Progress\nCompleted",
|
||||
default="Not Started",
|
||||
insert_after="longitude"
|
||||
),
|
||||
dict(
|
||||
fieldname="estimate_sent_status",
|
||||
label="Estimate Sent Status",
|
||||
fieldtype="Select",
|
||||
options="Not Started\nIn Progress\nCompleted",
|
||||
default="Not Started",
|
||||
insert_after="onsite_meeting_scheduled"
|
||||
),
|
||||
dict(
|
||||
fieldname="job_status",
|
||||
label="Job Status",
|
||||
fieldtype="Select",
|
||||
options="Not Started\nIn Progress\nCompleted",
|
||||
default="Not Started",
|
||||
insert_after="estimate_sent_status"
|
||||
),
|
||||
dict(
|
||||
fieldname="payment_received_status",
|
||||
label="Payment Received Status",
|
||||
fieldtype="Select",
|
||||
options="Not Started\nIn Progress\nCompleted",
|
||||
default="Not Started",
|
||||
insert_after="job_status"
|
||||
),
|
||||
dict(
|
||||
fieldname="lead_name",
|
||||
label="Lead Name",
|
||||
fieldtype="Data",
|
||||
insert_after="custom_customer_to_bill"
|
||||
),
|
||||
dict(
|
||||
fieldname="customer_type",
|
||||
label="Customer Type",
|
||||
fieldtype="Select",
|
||||
options="Customer\nLead",
|
||||
insert_after="lead_name"
|
||||
),
|
||||
dict(
|
||||
fieldname="customer_name",
|
||||
label="Customer Name",
|
||||
fieldtype="Dynamic Link",
|
||||
options="customer_type",
|
||||
insert_after="customer_type"
|
||||
),
|
||||
dict(
|
||||
fieldname="contacts",
|
||||
label="Contacts",
|
||||
fieldtype="Table",
|
||||
options="Address Contact Link",
|
||||
insert_after="customer_name"
|
||||
),
|
||||
dict(
|
||||
fieldname="companies",
|
||||
label="Companies",
|
||||
fieldtype="Table",
|
||||
options="Address Company Link",
|
||||
insert_after="contacts"
|
||||
),
|
||||
dict(
|
||||
fieldname="tasks",
|
||||
label="Tasks",
|
||||
fieldtype="Table",
|
||||
options="Address Task Link",
|
||||
insert_after="projects"
|
||||
),
|
||||
dict(
|
||||
fieldname="custom_subdivision",
|
||||
label="Subdivision",
|
||||
fieldtype="Link",
|
||||
options="Territory",
|
||||
insert_after="address_line2"
|
||||
),
|
||||
dict(
|
||||
fieldname="custom_customer_to_bill",
|
||||
label="Customer To Bill",
|
||||
fieldtype="Link",
|
||||
options="Customer",
|
||||
insert_after="custom_subdivision"
|
||||
)
|
||||
],
|
||||
"Contact": [
|
||||
dict(
|
||||
fieldname="role",
|
||||
label="Role",
|
||||
fieldtype="Select",
|
||||
options="Owner\nProperty Manager\nTenant\nBuilder\nNeighbor\nFamily Member\nRealtor\nOther",
|
||||
insert_after="designation"
|
||||
),
|
||||
dict(
|
||||
fieldname="email",
|
||||
label="Email",
|
||||
fieldtype="Data",
|
||||
insert_after="last_name",
|
||||
options="Email"
|
||||
),
|
||||
dict(
|
||||
fieldname="customer_type",
|
||||
label="Customer Type",
|
||||
fieldtype="Select",
|
||||
options="Customer\nLead",
|
||||
insert_after="email"
|
||||
),
|
||||
dict(
|
||||
fieldname="customer_name",
|
||||
label="Customer Name",
|
||||
fieldtype="Dynamic Link",
|
||||
options="customer_type",
|
||||
insert_after="customer_type"
|
||||
),
|
||||
dict(
|
||||
fieldname="addresses",
|
||||
label="Addresses",
|
||||
fieldtype="Table",
|
||||
options="Contact Address Link",
|
||||
insert_after="customer_name"
|
||||
)
|
||||
],
|
||||
"On-Site Meeting": [
|
||||
dict(
|
||||
fieldname="notes",
|
||||
label="Notes",
|
||||
fieldtype="Small Text",
|
||||
insert_after="address"
|
||||
),
|
||||
dict(
|
||||
fieldname="assigned_employee",
|
||||
label="Assigned Employee",
|
||||
fieldtype="Link",
|
||||
options="Employee",
|
||||
insert_after="notes"
|
||||
),
|
||||
dict(
|
||||
fieldname="status",
|
||||
label="Status",
|
||||
fieldtype="Select",
|
||||
options="Unscheduled\nScheduled\nCompleted\nCancelled",
|
||||
default="Unscheduled",
|
||||
insert_after="start_time"
|
||||
),
|
||||
dict(
|
||||
fieldname="completed_by",
|
||||
label="Completed By",
|
||||
fieldtype="Link",
|
||||
options="Employee",
|
||||
insert_after="status"
|
||||
),
|
||||
dict(
|
||||
fieldname="company",
|
||||
label="Company",
|
||||
fieldtype="Link",
|
||||
options="Company",
|
||||
insert_after="assigned_employee"
|
||||
),
|
||||
dict(
|
||||
fieldname="party_type",
|
||||
label="Party Type",
|
||||
fieldtype="Select",
|
||||
options="Customer\nLead",
|
||||
insert_after="company"
|
||||
),
|
||||
dict(
|
||||
fieldname="party_name",
|
||||
label="Party Name",
|
||||
fieldtype="Dynamic Link",
|
||||
options="party_type",
|
||||
insert_after="party_type"
|
||||
),
|
||||
dict(
|
||||
fieldname="contact",
|
||||
label="Contact",
|
||||
fieldtype="Link",
|
||||
options="Contact",
|
||||
insert_after="party_name"
|
||||
),
|
||||
dict(
|
||||
fieldname="project_template",
|
||||
label="Project Template",
|
||||
fieldtype="Link",
|
||||
options="Project Template",
|
||||
insert_after="company"
|
||||
)
|
||||
],
|
||||
"Quotation": [
|
||||
dict(
|
||||
fieldname="requires_half_payment",
|
||||
label="Requires Half Payment",
|
||||
fieldtype="Check",
|
||||
default=0,
|
||||
insert_after="custom_installation_address"
|
||||
),
|
||||
dict(
|
||||
fieldname="custom_quotation_template",
|
||||
label="Quotation Template",
|
||||
fieldtype="Link",
|
||||
options="Quotation Template",
|
||||
insert_after="company",
|
||||
description="The template used for generating this quotation."
|
||||
),
|
||||
dict(
|
||||
fieldname="custom_project_template",
|
||||
label="Project Template",
|
||||
fieldtype="Link",
|
||||
options="Project Template",
|
||||
insert_after="custom_quotation_template",
|
||||
description="The project template to use when creating a project from this quotation.",
|
||||
allow_on_submit=1
|
||||
),
|
||||
dict(
|
||||
fieldname="custom_job_address",
|
||||
label="Job Address",
|
||||
fieldtype="Link",
|
||||
options="Address",
|
||||
insert_after="custom_installation_address",
|
||||
description="The address where the job will be performed.",
|
||||
allow_on_submit=1
|
||||
),
|
||||
dict(
|
||||
fieldname="from_onsite_meeting",
|
||||
label="From On-Site Meeting",
|
||||
fieldtype="Link",
|
||||
options="On-Site Meeting",
|
||||
insert_after="custom_job_address"
|
||||
),
|
||||
dict(
|
||||
fieldname="from_onsite_meeting",
|
||||
label="From On-Site Meeting",
|
||||
fieldtype="Link",
|
||||
options="On-Site Meeting",
|
||||
insert_after="custom_job_address"
|
||||
),
|
||||
dict(
|
||||
fieldname="actual_customer_name",
|
||||
label="Customer",
|
||||
fieldtype="Dynamic Link",
|
||||
options="customer_type",
|
||||
insert_after="from_onsite_meeting",
|
||||
allow_on_submit=1
|
||||
),
|
||||
dict(
|
||||
fieldname="customer_type",
|
||||
label="Customer Type",
|
||||
fieldtype="Select",
|
||||
options="Customer\nLead",
|
||||
insert_after="customer_name",
|
||||
allow_on_submit=1
|
||||
)
|
||||
],
|
||||
"Sales Order": [
|
||||
dict(
|
||||
fieldname="requires_half_payment",
|
||||
label="Requires Half Payment",
|
||||
fieldtype="Check",
|
||||
default=0,
|
||||
insert_after="custom_installation_address"
|
||||
),
|
||||
dict(
|
||||
fieldname="custom_project_template",
|
||||
label="Project Template",
|
||||
fieldtype="Link",
|
||||
options="Project Template",
|
||||
description="The project template to use when creating a project from this sales order.",
|
||||
insert_after="custom_installation_address",
|
||||
allow_on_submit=1
|
||||
),
|
||||
dict(
|
||||
fieldname="custom_job_address",
|
||||
label="Job Address",
|
||||
fieldtype="Link",
|
||||
options="Address",
|
||||
insert_after="custom_installation_address",
|
||||
description="The address where the job will be performed.",
|
||||
allow_on_submit=1
|
||||
)
|
||||
],
|
||||
"Project": [
|
||||
dict(
|
||||
fieldname="job_address",
|
||||
label="Job Address",
|
||||
fieldtype="Link",
|
||||
options="Address",
|
||||
insert_after="project_name",
|
||||
description="The address where the job is being performed."
|
||||
),
|
||||
dict(
|
||||
fieldname="customer",
|
||||
label="Customer",
|
||||
fieldtype="Link",
|
||||
options="Customer",
|
||||
insert_after="job_address",
|
||||
description="The customer for whom the project is being executed."
|
||||
),
|
||||
dict(
|
||||
fieldname="expected_start_time",
|
||||
label="Expected Start Time",
|
||||
fieldtype="Time",
|
||||
insert_after="expected_start_date"
|
||||
),
|
||||
dict(
|
||||
fieldname="expected_end_time",
|
||||
label="Expected End Time",
|
||||
fieldtype="Time",
|
||||
insert_after="expected_end_date"
|
||||
),
|
||||
dict(
|
||||
fieldname="actual_start_time",
|
||||
label="Actual Start Time",
|
||||
fieldtype="Time",
|
||||
insert_after="actual_start_date"
|
||||
),
|
||||
dict(
|
||||
fieldname="actual_end_time",
|
||||
label="Actual End Time",
|
||||
fieldtype="Time",
|
||||
insert_after="actual_end_date"
|
||||
),
|
||||
dict(
|
||||
fieldname="is_scheduled",
|
||||
label="Is Scheduled",
|
||||
fieldtype="Check",
|
||||
default=0,
|
||||
insert_after="is_half_down_paid"
|
||||
),
|
||||
dict(
|
||||
fieldname="invoice_status",
|
||||
label="Invoice Status",
|
||||
fieldtype="Select",
|
||||
default="Not Ready",
|
||||
options="Not Ready\nReady to Invoice\nInvoice Created\nInvoice Sent",
|
||||
insert_after="is_scheduled"
|
||||
),
|
||||
dict(
|
||||
fieldname="requires_half_payment",
|
||||
label="Requires Half Payment",
|
||||
fieldtype="Check",
|
||||
default=0,
|
||||
insert_after="expected_end_time"
|
||||
),
|
||||
dict(
|
||||
fieldname="is_half_down_paid",
|
||||
label="Is Half Down Paid",
|
||||
fieldtype="Check",
|
||||
default=0,
|
||||
insert_after="requires_half_payment"
|
||||
),
|
||||
],
|
||||
"Project Template": [
|
||||
dict(
|
||||
fieldname="company",
|
||||
label="Company",
|
||||
fieldtype="Link",
|
||||
options="Company",
|
||||
insert_after="project_type",
|
||||
description="The company associated with this project template."
|
||||
),
|
||||
dict(
|
||||
fieldname="calendar_color",
|
||||
label="Calendar Color",
|
||||
fieldtype="Color",
|
||||
insert_after="company"
|
||||
)
|
||||
],
|
||||
"Task": [
|
||||
dict(
|
||||
fieldname="project_template",
|
||||
label="Project Template",
|
||||
fieldtype="Link",
|
||||
options="Project Template",
|
||||
insert_after="project"
|
||||
)
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2026-01-30 07:21:48.230423",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"task",
|
||||
"project_template"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "task",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Task",
|
||||
"options": "Task",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "project_template",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project Template",
|
||||
"options": "Project Template"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-30 07:52:45.496993",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Custom UI",
|
||||
"name": "Address Task Link",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"rows_threshold_for_grid_search": 20,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2026, Shiloh Code LLC and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class AddressTaskLink(Document):
|
||||
pass
|
||||
@ -0,0 +1,108 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2026-01-30 07:21:50.095868",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"label",
|
||||
"type",
|
||||
"value",
|
||||
"order",
|
||||
"value_doctype",
|
||||
"available_options",
|
||||
"include_available_options",
|
||||
"row",
|
||||
"column",
|
||||
"conditional_on_field",
|
||||
"conditional_on_value",
|
||||
"doctype_label_field"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "label",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Label",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "type",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Type",
|
||||
"options": "Data\nText\nCheck\nDate\nDatetime\nTime\nSelect\nMulti-Select\nMulti-Select w/ Quantity",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "value",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Value",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "order",
|
||||
"fieldtype": "Int",
|
||||
"label": "Order"
|
||||
},
|
||||
{
|
||||
"description": "Holds the doctype if this field is a link to either a single doctype or a list of doctypes. If this field has a value, then the Value field will hold the name(s) of the Doctype(s)",
|
||||
"fieldname": "value_doctype",
|
||||
"fieldtype": "Data",
|
||||
"label": "Doctype"
|
||||
},
|
||||
{
|
||||
"fieldname": "available_options",
|
||||
"fieldtype": "Small Text",
|
||||
"label": "Available Options"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "include_available_options",
|
||||
"fieldtype": "Check",
|
||||
"label": "Include Available Options"
|
||||
},
|
||||
{
|
||||
"fieldname": "row",
|
||||
"fieldtype": "Int",
|
||||
"label": "Row"
|
||||
},
|
||||
{
|
||||
"fieldname": "column",
|
||||
"fieldtype": "Int",
|
||||
"label": "Column"
|
||||
},
|
||||
{
|
||||
"fieldname": "conditional_on_field",
|
||||
"fieldtype": "Data",
|
||||
"label": "Conditional On Field"
|
||||
},
|
||||
{
|
||||
"fieldname": "conditional_on_value",
|
||||
"fieldtype": "Data",
|
||||
"label": "Conditional On Value"
|
||||
},
|
||||
{
|
||||
"fieldname": "doctype_label_field",
|
||||
"fieldtype": "Data",
|
||||
"label": "Doctype Label Field"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-30 07:52:08.063602",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Custom UI",
|
||||
"name": "Bid Meeting Note Field",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"rows_threshold_for_grid_search": 20,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2026, Shiloh Code LLC and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class BidMeetingNoteField(Document):
|
||||
pass
|
||||
@ -0,0 +1,50 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2026-01-30 07:21:50.423957",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"quantity",
|
||||
"meeting_note_field",
|
||||
"item"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "quantity",
|
||||
"fieldtype": "Int",
|
||||
"in_list_view": 1,
|
||||
"label": "Quantity",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "meeting_note_field",
|
||||
"fieldtype": "Link",
|
||||
"label": "Meeting Note Field",
|
||||
"options": "Bid Meeting Note Field",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "item",
|
||||
"fieldtype": "Data",
|
||||
"label": "Item",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-30 07:51:49.006161",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Custom UI",
|
||||
"name": "Bid Meeting Note Field Quantity",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"rows_threshold_for_grid_search": 20,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2026, Shiloh Code LLC and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class BidMeetingNoteFieldQuantity(Document):
|
||||
pass
|
||||
@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2026, Shiloh Code LLC and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("Bid Meeting Note Form", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
@ -0,0 +1,76 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2026-01-30 07:01:57.052796",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"title",
|
||||
"project_template",
|
||||
"notes",
|
||||
"fields",
|
||||
"company"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "title",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Title",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "project_template",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Project Template",
|
||||
"options": "Project Template",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "notes",
|
||||
"fieldtype": "Small Text",
|
||||
"label": "Notes"
|
||||
},
|
||||
{
|
||||
"fieldname": "fields",
|
||||
"fieldtype": "Table",
|
||||
"label": "Fields",
|
||||
"options": "Bid Meeting Note Form Field",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "company",
|
||||
"fieldtype": "Link",
|
||||
"label": "Company",
|
||||
"options": "Company"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-30 07:17:51.934698",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Custom UI",
|
||||
"name": "Bid Meeting Note Form",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"rows_threshold_for_grid_search": 20,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2026, Shiloh Code LLC and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class BidMeetingNoteForm(Document):
|
||||
pass
|
||||
@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2026, Shiloh Code LLC and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
|
||||
|
||||
class TestBidMeetingNoteForm(FrappeTestCase):
|
||||
pass
|
||||
@ -0,0 +1,149 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2026-01-30 07:21:49.918704",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"label",
|
||||
"type",
|
||||
"options",
|
||||
"required",
|
||||
"default_value",
|
||||
"read_only",
|
||||
"order",
|
||||
"help_text",
|
||||
"doctype_for_select",
|
||||
"include_options",
|
||||
"conditional_on_field",
|
||||
"conditional_on_value",
|
||||
"doctype_label_field",
|
||||
"row",
|
||||
"column"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "label",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Label",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "type",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Type",
|
||||
"options": "Data\nText\nCheck\nDate\nDatetime\nTime\nSelect\nMulti-Select\nNumber\nMulti-Select w/ Quantity",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"allow_in_quick_entry": 1,
|
||||
"description": "Comma separated options for select fields",
|
||||
"fieldname": "options",
|
||||
"fieldtype": "Small Text",
|
||||
"in_list_view": 1,
|
||||
"label": "Options"
|
||||
},
|
||||
{
|
||||
"allow_in_quick_entry": 1,
|
||||
"default": "0",
|
||||
"fieldname": "required",
|
||||
"fieldtype": "Check",
|
||||
"in_list_view": 1,
|
||||
"label": "Required"
|
||||
},
|
||||
{
|
||||
"allow_in_quick_entry": 1,
|
||||
"fieldname": "default_value",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Default Value"
|
||||
},
|
||||
{
|
||||
"allow_in_quick_entry": 1,
|
||||
"default": "0",
|
||||
"fieldname": "read_only",
|
||||
"fieldtype": "Check",
|
||||
"in_list_view": 1,
|
||||
"label": "Read Only"
|
||||
},
|
||||
{
|
||||
"allow_in_quick_entry": 1,
|
||||
"fieldname": "order",
|
||||
"fieldtype": "Int",
|
||||
"in_list_view": 1,
|
||||
"label": "Order"
|
||||
},
|
||||
{
|
||||
"allow_in_quick_entry": 1,
|
||||
"fieldname": "help_text",
|
||||
"fieldtype": "Small Text",
|
||||
"in_list_view": 1,
|
||||
"label": "Help Text"
|
||||
},
|
||||
{
|
||||
"allow_in_quick_entry": 1,
|
||||
"description": "The doctype for a select or multi-select if it's options are doctypes.",
|
||||
"fieldname": "doctype_for_select",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Doctype For Select"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "include_options",
|
||||
"fieldtype": "Check",
|
||||
"label": "Include Options"
|
||||
},
|
||||
{
|
||||
"description": "If a value is entered in this field, then the field this describes is conditional based on the value in the provided field. ",
|
||||
"fieldname": "conditional_on_field",
|
||||
"fieldtype": "Data",
|
||||
"label": "Conditional On Field"
|
||||
},
|
||||
{
|
||||
"description": "The value in which conditional should evaluate to True. If no value is provided here and a value exists in Conditional On Field, then the condition will just simply be if \"truthy\" (meaning, not null, emtpy, false, or 0)",
|
||||
"fieldname": "conditional_on_value",
|
||||
"fieldtype": "Data",
|
||||
"label": "Conditional On Value"
|
||||
},
|
||||
{
|
||||
"allow_in_quick_entry": 1,
|
||||
"fieldname": "doctype_label_field",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Doctype Label Field"
|
||||
},
|
||||
{
|
||||
"allow_in_quick_entry": 1,
|
||||
"fieldname": "row",
|
||||
"fieldtype": "Int",
|
||||
"in_list_view": 1,
|
||||
"label": "Row"
|
||||
},
|
||||
{
|
||||
"allow_in_quick_entry": 1,
|
||||
"fieldname": "column",
|
||||
"fieldtype": "Int",
|
||||
"in_list_view": 1,
|
||||
"label": "Column"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-30 07:52:16.305665",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Custom UI",
|
||||
"name": "Bid Meeting Note Form Field",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"rows_threshold_for_grid_search": 20,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2026, Shiloh Code LLC and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class BidMeetingNoteFormField(Document):
|
||||
pass
|
||||
0
custom_ui/custom_ui/doctype/condition/__init__.py
Normal file
0
custom_ui/custom_ui/doctype/condition/__init__.py
Normal file
8
custom_ui/custom_ui/doctype/condition/condition.js
Normal file
8
custom_ui/custom_ui/doctype/condition/condition.js
Normal file
@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2026, Shiloh Code LLC and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("Condition", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
43
custom_ui/custom_ui/doctype/condition/condition.json
Normal file
43
custom_ui/custom_ui/doctype/condition/condition.json
Normal file
@ -0,0 +1,43 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2026-01-30 07:01:57.401662",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"section_break_thqn"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "section_break_thqn",
|
||||
"fieldtype": "Section Break"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-30 07:16:50.657332",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Custom UI",
|
||||
"name": "Condition",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"rows_threshold_for_grid_search": 20,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
9
custom_ui/custom_ui/doctype/condition/condition.py
Normal file
9
custom_ui/custom_ui/doctype/condition/condition.py
Normal file
@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2026, Shiloh Code LLC and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class Condition(Document):
|
||||
pass
|
||||
9
custom_ui/custom_ui/doctype/condition/test_condition.py
Normal file
9
custom_ui/custom_ui/doctype/condition/test_condition.py
Normal file
@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2026, Shiloh Code LLC and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
|
||||
|
||||
class TestCondition(FrappeTestCase):
|
||||
pass
|
||||
@ -0,0 +1,34 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2026-01-30 07:21:48.972109",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"address"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "address",
|
||||
"fieldtype": "Link",
|
||||
"label": "Address",
|
||||
"options": "Address"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-30 07:52:31.110075",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Custom UI",
|
||||
"name": "Customer Address Link",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"rows_threshold_for_grid_search": 20,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2026, Shiloh Code LLC and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class CustomerAddressLink(Document):
|
||||
pass
|
||||
@ -0,0 +1,32 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2026-01-30 07:21:48.896768",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"section_break_flvp"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "section_break_flvp",
|
||||
"fieldtype": "Section Break"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-30 07:52:38.531992",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Custom UI",
|
||||
"name": "Customer Company Link",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"rows_threshold_for_grid_search": 20,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2026, Shiloh Code LLC and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class CustomerCompanyLink(Document):
|
||||
pass
|
||||
@ -0,0 +1,34 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2026-01-30 07:21:49.052039",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"contact"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "contact",
|
||||
"fieldtype": "Link",
|
||||
"label": "Contact",
|
||||
"options": "Contact"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-30 07:52:24.170798",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Custom UI",
|
||||
"name": "Customer Contact Link",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"rows_threshold_for_grid_search": 20,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2026, Shiloh Code LLC and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class CustomerContactLink(Document):
|
||||
pass
|
||||
@ -0,0 +1,43 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2026-01-30 07:21:48.120856",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"task",
|
||||
"project_template"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "task",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Task",
|
||||
"options": "Task",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "project_template",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project Template",
|
||||
"options": "Project Template"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-30 07:52:52.271939",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Custom UI",
|
||||
"name": "Customer Task Link",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"rows_threshold_for_grid_search": 20,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2026, Shiloh Code LLC and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class CustomerTaskLink(Document):
|
||||
pass
|
||||
@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2026, Shiloh Code LLC and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("On-Site Meeting", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
@ -0,0 +1,68 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "format:{address}-{#####}",
|
||||
"creation": "2026-01-30 05:04:20.781088",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"address",
|
||||
"start_time",
|
||||
"end_time",
|
||||
"bid_notes"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "address",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Address",
|
||||
"options": "Address",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "start_time",
|
||||
"fieldtype": "Datetime",
|
||||
"in_list_view": 1,
|
||||
"label": "Start Time"
|
||||
},
|
||||
{
|
||||
"fieldname": "end_time",
|
||||
"fieldtype": "Datetime",
|
||||
"label": "End Time"
|
||||
},
|
||||
{
|
||||
"fieldname": "bid_notes",
|
||||
"fieldtype": "Link",
|
||||
"label": "Bid Notes",
|
||||
"options": "Bid Meeting Note"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-30 07:03:40.962554",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Custom UI",
|
||||
"name": "On-Site Meeting",
|
||||
"naming_rule": "Expression",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2026, Shiloh Code LLC and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class OnSiteMeeting(Document):
|
||||
pass
|
||||
@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2026, Shiloh Code LLC and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
|
||||
|
||||
class TestOnSiteMeeting(FrappeTestCase):
|
||||
pass
|
||||
@ -0,0 +1,36 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2026-01-30 07:21:50.267662",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"task"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "task",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Task",
|
||||
"options": "Task",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-30 07:51:59.777431",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Custom UI",
|
||||
"name": "Project Task Link",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"rows_threshold_for_grid_search": 20,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2026, Shiloh Code LLC and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class ProjectTaskLink(Document):
|
||||
pass
|
||||
@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2026, Shiloh Code LLC and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("Service Address 2", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
@ -0,0 +1,145 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2026-01-30 07:01:57.571003",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"expected_end_date",
|
||||
"expected_end_time",
|
||||
"actual_end_date",
|
||||
"actual_end_time",
|
||||
"project_template",
|
||||
"project",
|
||||
"status",
|
||||
"expected_start_date",
|
||||
"expected_start_time",
|
||||
"actual_start_date",
|
||||
"actual_start_time",
|
||||
"customer",
|
||||
"company",
|
||||
"service_address",
|
||||
"foreman"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "expected_end_date",
|
||||
"fieldtype": "Date",
|
||||
"label": "Expected End Date"
|
||||
},
|
||||
{
|
||||
"fieldname": "expected_end_time",
|
||||
"fieldtype": "Time",
|
||||
"label": "Expected End Time"
|
||||
},
|
||||
{
|
||||
"fieldname": "actual_end_date",
|
||||
"fieldtype": "Date",
|
||||
"label": "Actual End Date"
|
||||
},
|
||||
{
|
||||
"fieldname": "actual_end_time",
|
||||
"fieldtype": "Time",
|
||||
"label": "Actual End Time"
|
||||
},
|
||||
{
|
||||
"fieldname": "project_template",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project Template",
|
||||
"options": "Project Template"
|
||||
},
|
||||
{
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Project",
|
||||
"options": "Project",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"default": "Open",
|
||||
"fieldname": "status",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Status",
|
||||
"options": "Open\nScheduled\nStarted\nCompleted\nCanceled",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "expected_start_date",
|
||||
"fieldtype": "Date",
|
||||
"label": "Expected Start Date"
|
||||
},
|
||||
{
|
||||
"fieldname": "expected_start_time",
|
||||
"fieldtype": "Time",
|
||||
"label": "Expected Start Time"
|
||||
},
|
||||
{
|
||||
"fieldname": "actual_start_date",
|
||||
"fieldtype": "Date",
|
||||
"label": "Actual Start Date"
|
||||
},
|
||||
{
|
||||
"fieldname": "actual_start_time",
|
||||
"fieldtype": "Time",
|
||||
"label": "Actual Start Time"
|
||||
},
|
||||
{
|
||||
"fieldname": "customer",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Customer",
|
||||
"options": "Customer",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "company",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Company",
|
||||
"options": "Company",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "service_address",
|
||||
"fieldtype": "Link",
|
||||
"label": "Service Address",
|
||||
"options": "Address",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "foreman",
|
||||
"fieldtype": "Link",
|
||||
"label": "Foreman",
|
||||
"options": "Employee"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-30 07:15:39.410145",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Custom UI",
|
||||
"name": "Service Address 2",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"rows_threshold_for_grid_search": 20,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2026, Shiloh Code LLC and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class ServiceAddress2(Document):
|
||||
pass
|
||||
@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2026, Shiloh Code LLC and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
|
||||
|
||||
class TestServiceAddress2(FrappeTestCase):
|
||||
pass
|
||||
@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2026, Shiloh Code LLC and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("Service Appointment", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
@ -0,0 +1,138 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "format:SA-{MM}-{YYYY}-{####}",
|
||||
"creation": "2026-01-30 07:01:56.861733",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"expected_start_date",
|
||||
"expected_end_date",
|
||||
"project_template",
|
||||
"project",
|
||||
"actual_start_date",
|
||||
"actual_end_date",
|
||||
"expected_start_time",
|
||||
"expected_end_time",
|
||||
"actual_end_time",
|
||||
"actual_start_time",
|
||||
"status",
|
||||
"customer",
|
||||
"company",
|
||||
"service_address"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "expected_start_date",
|
||||
"fieldtype": "Date",
|
||||
"label": "Expected Start Date"
|
||||
},
|
||||
{
|
||||
"fieldname": "expected_end_date",
|
||||
"fieldtype": "Date",
|
||||
"label": "Expected End Date"
|
||||
},
|
||||
{
|
||||
"fieldname": "project_template",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project Template",
|
||||
"options": "Project Template"
|
||||
},
|
||||
{
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Project",
|
||||
"options": "Project",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "actual_start_date",
|
||||
"fieldtype": "Date",
|
||||
"label": "Actual Start Date"
|
||||
},
|
||||
{
|
||||
"fieldname": "actual_end_date",
|
||||
"fieldtype": "Date",
|
||||
"label": "Actual End Date"
|
||||
},
|
||||
{
|
||||
"fieldname": "expected_start_time",
|
||||
"fieldtype": "Time",
|
||||
"label": "Expected Start Time"
|
||||
},
|
||||
{
|
||||
"fieldname": "expected_end_time",
|
||||
"fieldtype": "Time",
|
||||
"label": "Expected End Time"
|
||||
},
|
||||
{
|
||||
"fieldname": "actual_end_time",
|
||||
"fieldtype": "Time",
|
||||
"label": "Actual End Time"
|
||||
},
|
||||
{
|
||||
"fieldname": "actual_start_time",
|
||||
"fieldtype": "Time",
|
||||
"label": "Actual Start Time"
|
||||
},
|
||||
{
|
||||
"default": "Open",
|
||||
"fieldname": "status",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Status",
|
||||
"options": "Open\nScheduled\nStarted\nCompleted\nCanceled",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "customer",
|
||||
"fieldtype": "Link",
|
||||
"label": "Customer",
|
||||
"options": "Customer",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "company",
|
||||
"fieldtype": "Link",
|
||||
"label": "Company",
|
||||
"options": "Company",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "service_address",
|
||||
"fieldtype": "Link",
|
||||
"label": "Service Address",
|
||||
"options": "Address",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-30 07:18:16.297996",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Custom UI",
|
||||
"name": "Service Appointment",
|
||||
"naming_rule": "Expression",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"rows_threshold_for_grid_search": 20,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2026, Shiloh Code LLC and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class ServiceAppointment(Document):
|
||||
pass
|
||||
@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2026, Shiloh Code LLC and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
|
||||
|
||||
class TestServiceAppointment(FrappeTestCase):
|
||||
pass
|
||||
@ -1,6 +0,0 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def before_save(doc, method):
|
||||
print("DEBUG: Before save hook triggered for Customer:", doc.name)
|
||||
print("DEBUG: current state: ", doc.as_dict())
|
||||
@ -31,13 +31,13 @@ def before_insert(doc, method):
|
||||
print("DEBUG: CHECKING CUSTOMER NAME")
|
||||
print(doc.actual_customer_name)
|
||||
print("Quotation_to:", doc.quotation_to)
|
||||
doc.customer_address = frappe.get_value(doc.customer_type, doc.actual_customer_name, "custom_billing_address")
|
||||
doc.customer_address = frappe.get_value(doc.customer_type, doc.actual_customer_name, "customer_billing_address")
|
||||
# print("Party_type:", doc.party_type)
|
||||
if doc.custom_project_template == "SNW Install":
|
||||
print("DEBUG: Quotation uses SNW Install template, making sure no duplicate linked estimates.")
|
||||
address_doc = AddressService.get_or_throw(doc.custom_job_address)
|
||||
# if "SNW Install" in [link.project_template for link in address_doc.quotations]:
|
||||
# raise frappe.ValidationError("An Estimate with project template 'SNW Install' is already linked to this address.")
|
||||
if "SNW Install" in [link.project_template for link in address_doc.quotations]:
|
||||
raise frappe.ValidationError("An Estimate with project template 'SNW Install' is already linked to this address.")
|
||||
|
||||
def before_submit(doc, method):
|
||||
print("DEBUG: Before submit hook triggered for Quotation:", doc.name)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import frappe
|
||||
from custom_ui.services import SalesOrderService, AddressService, ClientService, ServiceAppointmentService, TaskService
|
||||
from custom_ui.services import AddressService, ClientService, ServiceAppointmentService, TaskService
|
||||
from datetime import timedelta
|
||||
import traceback
|
||||
|
||||
@ -62,7 +62,6 @@ def before_insert(doc, method):
|
||||
|
||||
def before_save(doc, method):
|
||||
print("DEBUG: Before Save Triggered for Project:", doc.name)
|
||||
print("DEBUG: Checking status: ", doc.status)
|
||||
if doc.expected_start_date and doc.expected_end_date:
|
||||
print("DEBUG: Project has expected start and end dates, marking as scheduled")
|
||||
doc.is_scheduled = 1
|
||||
@ -82,18 +81,6 @@ def before_save(doc, method):
|
||||
|
||||
def after_save(doc, method):
|
||||
print("DEBUG: After Save Triggered for Project:", doc.name)
|
||||
if doc.status == "Completed":
|
||||
print("DEBUG: Project marked as Completed. Generating and sending final invoice.")
|
||||
sales_order_status = frappe.get_value("Sales Order", doc.sales_order, "billing_status")
|
||||
if sales_order_status == "Not Billed":
|
||||
SalesOrderService.create_sales_invoice_from_sales_order(doc.sales_order)
|
||||
if doc.ready_to_schedule:
|
||||
service_apt_ready_to_schedule = frappe.get_value("Service Address 2", doc.service_appointment, "ready_to_schedule")
|
||||
if not service_apt_ready_to_schedule:
|
||||
print("DEBUG: Project is ready to schedule, setting Service Appointment to ready to schedule.")
|
||||
service_apt_doc = frappe.get_doc("Service Address 2", doc.service_appointment)
|
||||
service_apt_doc.ready_to_schedule = 1
|
||||
service_apt_doc.save(ignore_permissions=True)
|
||||
if doc.project_template == "SNW Install":
|
||||
print("DEBUG: Project template is SNW Install, updating Address Job Status based on Project status")
|
||||
status_mapping = {
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
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 = so_doc.custom_halfdown_amount <= so_doc.advance_paid or so_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()
|
||||
@ -1,16 +0,0 @@
|
||||
import frappe
|
||||
from custom_ui.services.email_service import EmailService
|
||||
|
||||
def on_submit(doc, method):
|
||||
print("DEBUG: On Submit Triggered for Sales Invoice:", doc.name)
|
||||
|
||||
# Send invoice email to customer
|
||||
try:
|
||||
print("DEBUG: Preparing to send invoice email for", doc.name)
|
||||
EmailService.send_invoice_email(doc.name)
|
||||
print("DEBUG: Invoice email sent successfully for", doc.name)
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to send invoice email: {str(e)}")
|
||||
# Don't raise the exception - we don't want to block the invoice submission
|
||||
frappe.log_error(f"Failed to send invoice email for {doc.name}: {str(e)}", "Invoice Email Error")
|
||||
|
||||
@ -71,21 +71,6 @@ def after_insert(doc, method):
|
||||
ClientService.append_link_v2(
|
||||
doc.customer, "sales_orders", {"sales_order": doc.name, "project_template": doc.custom_project_template}
|
||||
)
|
||||
|
||||
# Send down payment email if required
|
||||
if doc.requires_half_payment:
|
||||
try:
|
||||
print("DEBUG: Sales Order requires half payment, preparing to send down payment email")
|
||||
from custom_ui.services.email_service import EmailService
|
||||
|
||||
# Use EmailService to send the down payment email
|
||||
EmailService.send_downpayment_email(doc.name)
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to send down payment email: {str(e)}")
|
||||
# Don't raise the exception - we don't want to block the sales order creation
|
||||
frappe.log_error(f"Failed to send down payment email for {doc.name}: {str(e)}", "Down Payment Email Error")
|
||||
|
||||
|
||||
def on_update_after_submit(doc, method):
|
||||
print("DEBUG: on_update_after_submit hook triggered for Sales Order:", doc.name)
|
||||
@ -93,9 +78,7 @@ def on_update_after_submit(doc, method):
|
||||
project_is_scheduable = frappe.get_value("Project", doc.project, "ready_to_schedule")
|
||||
if not project_is_scheduable:
|
||||
print("DEBUG: Half-down payment made, setting Project to ready to schedule.")
|
||||
project_doc = frappe.get_doc("Project", doc.project)
|
||||
project_doc.ready_to_schedule = 1
|
||||
project_doc.save()
|
||||
frappe.set_value("Project", doc.project, "ready_to_schedule", 1)
|
||||
|
||||
|
||||
|
||||
@ -138,4 +121,3 @@ def create_sales_invoice_from_sales_order(doc, method):
|
||||
# except Exception as e:
|
||||
# print("ERROR creating Sales Invoice from Sales Order:", str(e))
|
||||
# frappe.log_error(f"Error creating Sales Invoice from Sales Order {doc.name}: {str(e)}", "Sales Order after_submit Error")
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
55
custom_ui/fixtures/project_template.json
Normal file
55
custom_ui/fixtures/project_template.json
Normal file
@ -0,0 +1,55 @@
|
||||
[
|
||||
{
|
||||
"calendar_color": null,
|
||||
"company": "Sprinklers Northwest",
|
||||
"docstatus": 0,
|
||||
"doctype": "Project Template",
|
||||
"modified": "2026-01-27 09:16:15.614554",
|
||||
"name": "SNW Install",
|
||||
"project_type": "External",
|
||||
"tasks": [
|
||||
{
|
||||
"parent": "SNW Install",
|
||||
"parentfield": "tasks",
|
||||
"parenttype": "Project Template",
|
||||
"subject": "Send customer 3-5 day window for start date",
|
||||
"task": "TASK-2025-00001"
|
||||
},
|
||||
{
|
||||
"parent": "SNW Install",
|
||||
"parentfield": "tasks",
|
||||
"parenttype": "Project Template",
|
||||
"subject": "811/Locate call in",
|
||||
"task": "TASK-2025-00002"
|
||||
},
|
||||
{
|
||||
"parent": "SNW Install",
|
||||
"parentfield": "tasks",
|
||||
"parenttype": "Project Template",
|
||||
"subject": "Permit(s) call in and pay",
|
||||
"task": "TASK-2025-00003"
|
||||
},
|
||||
{
|
||||
"parent": "SNW Install",
|
||||
"parentfield": "tasks",
|
||||
"parenttype": "Project Template",
|
||||
"subject": "Primary Job",
|
||||
"task": "TASK-2025-00004"
|
||||
},
|
||||
{
|
||||
"parent": "SNW Install",
|
||||
"parentfield": "tasks",
|
||||
"parenttype": "Project Template",
|
||||
"subject": "Hydroseeding",
|
||||
"task": "TASK-2025-00005"
|
||||
},
|
||||
{
|
||||
"parent": "SNW Install",
|
||||
"parentfield": "tasks",
|
||||
"parenttype": "Project Template",
|
||||
"subject": "Curbing",
|
||||
"task": "TASK-2025-00006"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
354
custom_ui/fixtures/task.json
Normal file
354
custom_ui/fixtures/task.json
Normal file
@ -0,0 +1,354 @@
|
||||
[
|
||||
{
|
||||
"act_end_date": null,
|
||||
"act_start_date": null,
|
||||
"actual_time": 0.0,
|
||||
"closing_date": null,
|
||||
"color": null,
|
||||
"company": "Sprinklers Northwest",
|
||||
"completed_by": null,
|
||||
"completed_on": null,
|
||||
"custom_foreman": "HR-EMP-00014",
|
||||
"custom_property": null,
|
||||
"department": null,
|
||||
"depends_on": [],
|
||||
"depends_on_tasks": "",
|
||||
"description": null,
|
||||
"docstatus": 0,
|
||||
"doctype": "Task",
|
||||
"duration": 0,
|
||||
"exp_end_date": null,
|
||||
"exp_start_date": null,
|
||||
"expected_time": 0.0,
|
||||
"is_group": 0,
|
||||
"is_milestone": 0,
|
||||
"is_template": 1,
|
||||
"issue": null,
|
||||
"modified": "2025-05-13 06:34:23.580282",
|
||||
"name": "TASK-2025-00007",
|
||||
"old_parent": "",
|
||||
"parent_task": null,
|
||||
"priority": "Low",
|
||||
"progress": 0.0,
|
||||
"project": null,
|
||||
"project_template": null,
|
||||
"review_date": null,
|
||||
"start": 0,
|
||||
"status": "Template",
|
||||
"subject": "15-Day QA",
|
||||
"task_weight": 0.0,
|
||||
"template_task": null,
|
||||
"total_billing_amount": 0.0,
|
||||
"total_costing_amount": 0.0,
|
||||
"total_expense_claim": 0.0,
|
||||
"type": "QA"
|
||||
},
|
||||
{
|
||||
"act_end_date": null,
|
||||
"act_start_date": null,
|
||||
"actual_time": 0.0,
|
||||
"closing_date": null,
|
||||
"color": null,
|
||||
"company": "Sprinklers Northwest",
|
||||
"completed_by": null,
|
||||
"completed_on": null,
|
||||
"custom_foreman": null,
|
||||
"custom_property": null,
|
||||
"department": null,
|
||||
"depends_on": [],
|
||||
"depends_on_tasks": "",
|
||||
"description": null,
|
||||
"docstatus": 0,
|
||||
"doctype": "Task",
|
||||
"duration": 0,
|
||||
"exp_end_date": null,
|
||||
"exp_start_date": null,
|
||||
"expected_time": 0.0,
|
||||
"is_group": 0,
|
||||
"is_milestone": 0,
|
||||
"is_template": 1,
|
||||
"issue": null,
|
||||
"modified": "2025-05-08 12:46:33.245387",
|
||||
"name": "TASK-2025-00008",
|
||||
"old_parent": "",
|
||||
"parent_task": null,
|
||||
"priority": "Low",
|
||||
"progress": 0.0,
|
||||
"project": null,
|
||||
"project_template": null,
|
||||
"review_date": null,
|
||||
"start": 0,
|
||||
"status": "Template",
|
||||
"subject": "Permit Close-out",
|
||||
"task_weight": 0.0,
|
||||
"template_task": null,
|
||||
"total_billing_amount": 0.0,
|
||||
"total_costing_amount": 0.0,
|
||||
"total_expense_claim": 0.0,
|
||||
"type": "Admin"
|
||||
},
|
||||
{
|
||||
"act_end_date": null,
|
||||
"act_start_date": null,
|
||||
"actual_time": 0.0,
|
||||
"closing_date": null,
|
||||
"color": null,
|
||||
"company": "Sprinklers Northwest",
|
||||
"completed_by": null,
|
||||
"completed_on": null,
|
||||
"custom_foreman": null,
|
||||
"custom_property": null,
|
||||
"department": null,
|
||||
"depends_on": [],
|
||||
"depends_on_tasks": "",
|
||||
"description": null,
|
||||
"docstatus": 0,
|
||||
"doctype": "Task",
|
||||
"duration": 0,
|
||||
"exp_end_date": null,
|
||||
"exp_start_date": null,
|
||||
"expected_time": 0.0,
|
||||
"is_group": 0,
|
||||
"is_milestone": 0,
|
||||
"is_template": 1,
|
||||
"issue": null,
|
||||
"modified": "2025-05-08 13:04:15.934399",
|
||||
"name": "TASK-2025-00001",
|
||||
"old_parent": "",
|
||||
"parent_task": null,
|
||||
"priority": "Low",
|
||||
"progress": 0.0,
|
||||
"project": null,
|
||||
"project_template": null,
|
||||
"review_date": null,
|
||||
"start": 0,
|
||||
"status": "Template",
|
||||
"subject": "Send customer 3-5 day window for start date",
|
||||
"task_weight": 0.0,
|
||||
"template_task": null,
|
||||
"total_billing_amount": 0.0,
|
||||
"total_costing_amount": 0.0,
|
||||
"total_expense_claim": 0.0,
|
||||
"type": "Admin"
|
||||
},
|
||||
{
|
||||
"act_end_date": null,
|
||||
"act_start_date": null,
|
||||
"actual_time": 0.0,
|
||||
"closing_date": null,
|
||||
"color": null,
|
||||
"company": "Sprinklers Northwest",
|
||||
"completed_by": null,
|
||||
"completed_on": null,
|
||||
"custom_foreman": null,
|
||||
"custom_property": null,
|
||||
"department": null,
|
||||
"depends_on": [],
|
||||
"depends_on_tasks": "",
|
||||
"description": null,
|
||||
"docstatus": 0,
|
||||
"doctype": "Task",
|
||||
"duration": 0,
|
||||
"exp_end_date": null,
|
||||
"exp_start_date": null,
|
||||
"expected_time": 0.0,
|
||||
"is_group": 0,
|
||||
"is_milestone": 0,
|
||||
"is_template": 1,
|
||||
"issue": null,
|
||||
"modified": "2025-04-24 14:57:03.402721",
|
||||
"name": "TASK-2025-00002",
|
||||
"old_parent": "",
|
||||
"parent_task": null,
|
||||
"priority": "Low",
|
||||
"progress": 0.0,
|
||||
"project": null,
|
||||
"project_template": null,
|
||||
"review_date": null,
|
||||
"start": 0,
|
||||
"status": "Template",
|
||||
"subject": "811/Locate call in",
|
||||
"task_weight": 0.0,
|
||||
"template_task": null,
|
||||
"total_billing_amount": 0.0,
|
||||
"total_costing_amount": 0.0,
|
||||
"total_expense_claim": 0.0,
|
||||
"type": "Admin"
|
||||
},
|
||||
{
|
||||
"act_end_date": null,
|
||||
"act_start_date": null,
|
||||
"actual_time": 0.0,
|
||||
"closing_date": null,
|
||||
"color": null,
|
||||
"company": "Sprinklers Northwest",
|
||||
"completed_by": null,
|
||||
"completed_on": null,
|
||||
"custom_foreman": null,
|
||||
"custom_property": null,
|
||||
"department": null,
|
||||
"depends_on": [],
|
||||
"depends_on_tasks": "",
|
||||
"description": null,
|
||||
"docstatus": 0,
|
||||
"doctype": "Task",
|
||||
"duration": 0,
|
||||
"exp_end_date": null,
|
||||
"exp_start_date": null,
|
||||
"expected_time": 0.0,
|
||||
"is_group": 0,
|
||||
"is_milestone": 0,
|
||||
"is_template": 1,
|
||||
"issue": null,
|
||||
"modified": "2025-04-24 14:57:10.789639",
|
||||
"name": "TASK-2025-00003",
|
||||
"old_parent": "",
|
||||
"parent_task": null,
|
||||
"priority": "Low",
|
||||
"progress": 0.0,
|
||||
"project": null,
|
||||
"project_template": null,
|
||||
"review_date": null,
|
||||
"start": 0,
|
||||
"status": "Template",
|
||||
"subject": "Permit(s) call in and pay",
|
||||
"task_weight": 0.0,
|
||||
"template_task": null,
|
||||
"total_billing_amount": 0.0,
|
||||
"total_costing_amount": 0.0,
|
||||
"total_expense_claim": 0.0,
|
||||
"type": "Admin"
|
||||
},
|
||||
{
|
||||
"act_end_date": null,
|
||||
"act_start_date": null,
|
||||
"actual_time": 0.0,
|
||||
"closing_date": null,
|
||||
"color": null,
|
||||
"company": "Sprinklers Northwest",
|
||||
"completed_by": null,
|
||||
"completed_on": null,
|
||||
"custom_foreman": null,
|
||||
"custom_property": null,
|
||||
"department": null,
|
||||
"depends_on": [],
|
||||
"depends_on_tasks": "",
|
||||
"description": null,
|
||||
"docstatus": 0,
|
||||
"doctype": "Task",
|
||||
"duration": 0,
|
||||
"exp_end_date": null,
|
||||
"exp_start_date": null,
|
||||
"expected_time": 0.0,
|
||||
"is_group": 0,
|
||||
"is_milestone": 0,
|
||||
"is_template": 1,
|
||||
"issue": null,
|
||||
"modified": "2025-05-10 05:06:24.653035",
|
||||
"name": "TASK-2025-00004",
|
||||
"old_parent": "",
|
||||
"parent_task": null,
|
||||
"priority": "Low",
|
||||
"progress": 0.0,
|
||||
"project": null,
|
||||
"project_template": null,
|
||||
"review_date": null,
|
||||
"start": 0,
|
||||
"status": "Template",
|
||||
"subject": "Primary Job",
|
||||
"task_weight": 0.0,
|
||||
"template_task": null,
|
||||
"total_billing_amount": 0.0,
|
||||
"total_costing_amount": 0.0,
|
||||
"total_expense_claim": 0.0,
|
||||
"type": "Labor"
|
||||
},
|
||||
{
|
||||
"act_end_date": null,
|
||||
"act_start_date": null,
|
||||
"actual_time": 0.0,
|
||||
"closing_date": null,
|
||||
"color": null,
|
||||
"company": "Sprinklers Northwest",
|
||||
"completed_by": null,
|
||||
"completed_on": null,
|
||||
"custom_foreman": null,
|
||||
"custom_property": null,
|
||||
"department": null,
|
||||
"depends_on": [],
|
||||
"depends_on_tasks": "",
|
||||
"description": null,
|
||||
"docstatus": 0,
|
||||
"doctype": "Task",
|
||||
"duration": 0,
|
||||
"exp_end_date": null,
|
||||
"exp_start_date": null,
|
||||
"expected_time": 0.0,
|
||||
"is_group": 0,
|
||||
"is_milestone": 0,
|
||||
"is_template": 1,
|
||||
"issue": null,
|
||||
"modified": "2025-05-10 05:06:44.365741",
|
||||
"name": "TASK-2025-00006",
|
||||
"old_parent": "",
|
||||
"parent_task": null,
|
||||
"priority": "Low",
|
||||
"progress": 0.0,
|
||||
"project": null,
|
||||
"project_template": null,
|
||||
"review_date": null,
|
||||
"start": 0,
|
||||
"status": "Template",
|
||||
"subject": "Curbing",
|
||||
"task_weight": 0.0,
|
||||
"template_task": null,
|
||||
"total_billing_amount": 0.0,
|
||||
"total_costing_amount": 0.0,
|
||||
"total_expense_claim": 0.0,
|
||||
"type": "Labor"
|
||||
},
|
||||
{
|
||||
"act_end_date": null,
|
||||
"act_start_date": null,
|
||||
"actual_time": 0.0,
|
||||
"closing_date": null,
|
||||
"color": null,
|
||||
"company": "Sprinklers Northwest",
|
||||
"completed_by": null,
|
||||
"completed_on": null,
|
||||
"custom_foreman": null,
|
||||
"custom_property": null,
|
||||
"department": null,
|
||||
"depends_on": [],
|
||||
"depends_on_tasks": "",
|
||||
"description": null,
|
||||
"docstatus": 0,
|
||||
"doctype": "Task",
|
||||
"duration": 0,
|
||||
"exp_end_date": null,
|
||||
"exp_start_date": null,
|
||||
"expected_time": 0.0,
|
||||
"is_group": 0,
|
||||
"is_milestone": 0,
|
||||
"is_template": 1,
|
||||
"issue": null,
|
||||
"modified": "2025-05-10 05:06:35.232465",
|
||||
"name": "TASK-2025-00005",
|
||||
"old_parent": "",
|
||||
"parent_task": null,
|
||||
"priority": "Low",
|
||||
"progress": 0.0,
|
||||
"project": null,
|
||||
"project_template": null,
|
||||
"review_date": null,
|
||||
"start": 0,
|
||||
"status": "Template",
|
||||
"subject": "Hydroseeding",
|
||||
"task_weight": 0.0,
|
||||
"template_task": null,
|
||||
"total_billing_amount": 0.0,
|
||||
"total_costing_amount": 0.0,
|
||||
"total_expense_claim": 0.0,
|
||||
"type": "Labor"
|
||||
}
|
||||
]
|
||||
@ -9,6 +9,7 @@ app_license = "mit"
|
||||
|
||||
after_install = "custom_ui.install.after_install"
|
||||
after_migrate = "custom_ui.install.after_migrate"
|
||||
after_uninstall = "custom_ui.install.after_uninstall"
|
||||
# on_session_creation = "custom_ui.utils.on_login_redirect"
|
||||
# on_login = "custom_ui.utils.on_login_redirect"
|
||||
page_js = {
|
||||
@ -204,16 +205,14 @@ doc_events = {
|
||||
"before_save": "custom_ui.events.service_appointment.before_save",
|
||||
"after_insert": "custom_ui.events.service_appointment.after_insert",
|
||||
"on_update": "custom_ui.events.service_appointment.on_update"
|
||||
},
|
||||
"Payment Entry": {
|
||||
"on_submit": "custom_ui.events.payments.on_submit"
|
||||
},
|
||||
"Sales Invoice": {
|
||||
"on_submit": "custom_ui.events.sales_invoice.on_submit"
|
||||
}
|
||||
}
|
||||
|
||||
fixtures = [
|
||||
{
|
||||
"dt": "Custom Field",
|
||||
"filters": [["module", "in", ["Custom UI"]]]
|
||||
},
|
||||
{
|
||||
"dt": "Email Template",
|
||||
"filters": [
|
||||
@ -221,21 +220,14 @@ fixtures = [
|
||||
]
|
||||
},
|
||||
{
|
||||
"dt": "DocType",
|
||||
"filters": [
|
||||
["custom", "=", 1]
|
||||
]
|
||||
"dt": "Task",
|
||||
"filters": [["status", "=", "Template"]]
|
||||
},
|
||||
{
|
||||
"dt": "Project Template"
|
||||
},
|
||||
|
||||
# These don't have reliable flags → export all
|
||||
{"dt": "Custom Field"},
|
||||
{"dt": "Property Setter"},
|
||||
{"dt": "Client Script"},
|
||||
{"dt": "Server Script"},
|
||||
# {"dt": "Report"},
|
||||
# {"dt": "Print Format"},
|
||||
# {"dt": "Dashboard"},
|
||||
# {"dt": "Workspace"},
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -6,30 +6,35 @@ import frappe
|
||||
from .utils import create_module
|
||||
import holidays
|
||||
from datetime import date, timedelta
|
||||
from . import custom_fields
|
||||
|
||||
def after_install():
|
||||
create_module()
|
||||
add_custom_fields()
|
||||
# add_custom_fields(custom_fields.custom_fields)
|
||||
frappe.db.commit()
|
||||
|
||||
# Proper way to refresh metadata
|
||||
frappe.clear_cache(doctype="Address")
|
||||
frappe.reload_doctype("Address")
|
||||
frappe.clear_cache(doctype="On-Site Meeting")
|
||||
frappe.reload_doctype("On-Site Meeting")
|
||||
update_onsite_meeting_fields()
|
||||
update_address_fields()
|
||||
check_and_create_holiday_list()
|
||||
create_project_templates()
|
||||
create_task_types()
|
||||
# create_tasks()
|
||||
create_bid_meeting_note_form_templates()
|
||||
create_accounts()
|
||||
# init_stripe_accounts()
|
||||
build_frontend()
|
||||
# # Proper way to refresh metadata
|
||||
# frappe.clear_cache(doctype="Address")
|
||||
# frappe.reload_doctype("Address")
|
||||
# frappe.clear_cache(doctype="On-Site Meeting")
|
||||
# frappe.reload_doctype("On-Site Meeting")
|
||||
# update_onsite_meeting_fields()
|
||||
# update_address_fields()
|
||||
# check_and_create_holiday_list()
|
||||
# create_project_templates()
|
||||
# create_task_types()
|
||||
# # create_tasks()
|
||||
# create_bid_meeting_note_form_templates()
|
||||
# build_frontend()
|
||||
print("Custom UI After Install hook finished successfully!")
|
||||
|
||||
|
||||
def after_uninstall():
|
||||
remove_custom_fields(custom_fields.custom_fields)
|
||||
|
||||
|
||||
def after_migrate():
|
||||
add_custom_fields()
|
||||
add_custom_fields(custom_fields.custom_fields)
|
||||
update_onsite_meeting_fields()
|
||||
frappe.db.commit()
|
||||
|
||||
@ -44,8 +49,6 @@ def after_migrate():
|
||||
create_task_types()
|
||||
# create_tasks()
|
||||
create_bid_meeting_note_form_templates()
|
||||
create_accounts()
|
||||
# init_stripe_accounts()
|
||||
|
||||
# update_address_fields()
|
||||
# build_frontend()
|
||||
@ -83,7 +86,19 @@ def build_frontend():
|
||||
frappe.log_error(message=str(e), title="Frontend Build Failed")
|
||||
print(f"\n❌ Frontend build failed: {e}\n")
|
||||
|
||||
def add_custom_fields():
|
||||
|
||||
def remove_custom_fields(custom_fields):
|
||||
for doctype, fields_list in custom_fields.items():
|
||||
for field in fields_list:
|
||||
name = f"{doctype}-{field['fieldname']}"
|
||||
if frappe.db.exists("Custom Field", name):
|
||||
frappe.delete_doc("Custom Field", name)
|
||||
frappe.db.commit()
|
||||
print(f"Deleted {name}")
|
||||
print("Deleted custom fields from database")
|
||||
|
||||
|
||||
def add_custom_fields(custom_fields):
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
||||
|
||||
print("\n🔧 Adding custom fields to doctypes...")
|
||||
@ -99,744 +114,6 @@ def add_custom_fields():
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Failed to update Address address_type: {e}")
|
||||
|
||||
custom_fields = {
|
||||
"Customer": [
|
||||
dict(
|
||||
fieldname="companies",
|
||||
label="Companies",
|
||||
fieldtype="Table",
|
||||
options="Customer Company Link",
|
||||
insert_after="customer_type"
|
||||
),
|
||||
dict(
|
||||
fieldname="quotations",
|
||||
label="Quotations",
|
||||
fieldtype="Table",
|
||||
options="Customer Quotation Link",
|
||||
insert_after="companies"
|
||||
),
|
||||
dict(
|
||||
fieldname="onsite_meetings",
|
||||
label="On-Site Meetings",
|
||||
fieldtype="Table",
|
||||
options="Customer On-Site Meeting Link",
|
||||
insert_after="quotations"
|
||||
),
|
||||
dict(
|
||||
fieldname="projects",
|
||||
label="Projects",
|
||||
fieldtype="Table",
|
||||
options="Customer Project Link",
|
||||
insert_after="onsite_meetings"
|
||||
),
|
||||
dict(
|
||||
fieldname="sales_orders",
|
||||
label="Sales Orders",
|
||||
fieldtype="Table",
|
||||
options="Customer Sales Order Link",
|
||||
insert_after="projects"
|
||||
),
|
||||
dict(
|
||||
fieldname="from_lead",
|
||||
label="From Lead",
|
||||
fieldtype="Link",
|
||||
options="Lead",
|
||||
insert_after="customer_name"
|
||||
),
|
||||
dict(
|
||||
fieldname="properties",
|
||||
label="Properties",
|
||||
fieldtype="Table",
|
||||
options="Customer Address Link",
|
||||
insert_after="customer_name"
|
||||
),
|
||||
dict(
|
||||
fieldname="contacts",
|
||||
label="Contacts",
|
||||
fieldtype="Table",
|
||||
options="Customer Contact Link",
|
||||
insert_after="properties"
|
||||
),
|
||||
dict(
|
||||
fieldname="primary_contact",
|
||||
label="Primary Contact",
|
||||
fieldtype="Link",
|
||||
options="Contact",
|
||||
insert_after="contacts"
|
||||
),
|
||||
dict(
|
||||
fieldname="tasks",
|
||||
label="Tasks",
|
||||
fieldtype="Table",
|
||||
options="Customer Task Link",
|
||||
insert_after="projects"
|
||||
)
|
||||
],
|
||||
"Lead": [
|
||||
dict(
|
||||
fieldname="onsite_meetings",
|
||||
label="On-Site Meetings",
|
||||
fieldtype="Table",
|
||||
options="Lead On-Site Meeting Link",
|
||||
insert_after="quotations"
|
||||
),
|
||||
dict(
|
||||
fieldname="custom_billing_address",
|
||||
label="Custom Address",
|
||||
fieldtype="Link",
|
||||
options="Address",
|
||||
insert_after="customer_name"
|
||||
),
|
||||
dict(
|
||||
fieldname="quotations",
|
||||
label="Quotations",
|
||||
fieldtype="Table",
|
||||
options="Lead Quotation Link",
|
||||
insert_after="companies"
|
||||
),
|
||||
dict(
|
||||
fieldname="companies",
|
||||
label="Companies",
|
||||
fieldtype="Table",
|
||||
options="Lead Company Link",
|
||||
insert_after="customer_type"
|
||||
),
|
||||
dict(
|
||||
fieldname="customer_type",
|
||||
label="Customer Type",
|
||||
fieldtype="Select",
|
||||
options="Individual\nCompany\nPartnership",
|
||||
insert_after="lead_name"
|
||||
),
|
||||
dict(
|
||||
fieldname="properties",
|
||||
label="Properties",
|
||||
fieldtype="Table",
|
||||
options="Lead Address Link",
|
||||
insert_after="customer_name"
|
||||
),
|
||||
dict(
|
||||
fieldname="contacts",
|
||||
label="Contacts",
|
||||
fieldtype="Table",
|
||||
options="Lead Contact Link",
|
||||
insert_after="properties"
|
||||
),
|
||||
dict(
|
||||
fieldname="primary_contact",
|
||||
label="Primary Contact",
|
||||
fieldtype="Link",
|
||||
options="Contact",
|
||||
insert_after="contacts"
|
||||
)
|
||||
],
|
||||
"Address": [
|
||||
dict(
|
||||
fieldname="primary_contact",
|
||||
label="Primary Contact",
|
||||
fieldtype="Link",
|
||||
options="Contact",
|
||||
insert_after="address_title"
|
||||
),
|
||||
dict(
|
||||
fieldname="projects",
|
||||
label="Projects",
|
||||
fieldtype="Table",
|
||||
options="Address Project Link",
|
||||
insert_after="onsite_meetings"
|
||||
),
|
||||
dict(
|
||||
fieldname="sales_orders",
|
||||
label="Sales Orders",
|
||||
fieldtype="Table",
|
||||
options="Address Sales Order Link",
|
||||
insert_after="projects"
|
||||
),
|
||||
dict(
|
||||
fieldname="onsite_meetings",
|
||||
label="On-Site Meetings",
|
||||
fieldtype="Table",
|
||||
options="Address On-Site Meeting Link",
|
||||
insert_after="quotations"
|
||||
),
|
||||
dict(
|
||||
fieldname="quotations",
|
||||
label="Quotations",
|
||||
fieldtype="Table",
|
||||
options="Address Quotation Link",
|
||||
insert_after="companies"
|
||||
),
|
||||
dict(
|
||||
fieldname="full_address",
|
||||
label="Full Address",
|
||||
fieldtype="Data",
|
||||
insert_after="country"
|
||||
),
|
||||
dict(
|
||||
fieldname="latitude",
|
||||
label="Latitude",
|
||||
fieldtype="Float",
|
||||
precision=8,
|
||||
insert_after="full_address"
|
||||
),
|
||||
dict(
|
||||
fieldname="longitude",
|
||||
label="Longitude",
|
||||
fieldtype="Float",
|
||||
precision=8,
|
||||
insert_after="latitude"
|
||||
),
|
||||
dict(
|
||||
fieldname="onsite_meeting_scheduled",
|
||||
label="On-Site Meeting Scheduled",
|
||||
fieldtype="Select",
|
||||
options="Not Started\nIn Progress\nCompleted",
|
||||
default="Not Started",
|
||||
insert_after="longitude"
|
||||
),
|
||||
dict(
|
||||
fieldname="estimate_sent_status",
|
||||
label="Estimate Sent Status",
|
||||
fieldtype="Select",
|
||||
options="Not Started\nIn Progress\nCompleted",
|
||||
default="Not Started",
|
||||
insert_after="onsite_meeting_scheduled"
|
||||
),
|
||||
dict(
|
||||
fieldname="job_status",
|
||||
label="Job Status",
|
||||
fieldtype="Select",
|
||||
options="Not Started\nIn Progress\nCompleted",
|
||||
default="Not Started",
|
||||
insert_after="estimate_sent_status"
|
||||
),
|
||||
dict(
|
||||
fieldname="payment_received_status",
|
||||
label="Payment Received Status",
|
||||
fieldtype="Select",
|
||||
options="Not Started\nIn Progress\nCompleted",
|
||||
default="Not Started",
|
||||
insert_after="job_status"
|
||||
),
|
||||
dict(
|
||||
fieldname="lead_name",
|
||||
label="Lead Name",
|
||||
fieldtype="Data",
|
||||
insert_after="custom_customer_to_bill"
|
||||
),
|
||||
dict(
|
||||
fieldname="customer_type",
|
||||
label="Customer Type",
|
||||
fieldtype="Select",
|
||||
options="Customer\nLead",
|
||||
insert_after="lead_name"
|
||||
),
|
||||
dict(
|
||||
fieldname="customer_name",
|
||||
label="Customer Name",
|
||||
fieldtype="Dynamic Link",
|
||||
options="customer_type",
|
||||
insert_after="customer_type"
|
||||
),
|
||||
dict(
|
||||
fieldname="contacts",
|
||||
label="Contacts",
|
||||
fieldtype="Table",
|
||||
options="Address Contact Link",
|
||||
insert_after="customer_name"
|
||||
),
|
||||
dict(
|
||||
fieldname="companies",
|
||||
label="Companies",
|
||||
fieldtype="Table",
|
||||
options="Address Company Link",
|
||||
insert_after="contacts"
|
||||
),
|
||||
dict(
|
||||
fieldname="tasks",
|
||||
label="Tasks",
|
||||
fieldtype="Table",
|
||||
options="Address Task Link",
|
||||
insert_after="projects"
|
||||
),
|
||||
dict(
|
||||
fieldname="is_service_address",
|
||||
label="Is Service Address",
|
||||
fieldtype="Check",
|
||||
insert_after="tasks"
|
||||
)
|
||||
],
|
||||
"Contact": [
|
||||
dict(
|
||||
fieldname="role",
|
||||
label="Role",
|
||||
fieldtype="Select",
|
||||
options="Owner\nProperty Manager\nTenant\nBuilder\nNeighbor\nFamily Member\nRealtor\nOther",
|
||||
insert_after="designation"
|
||||
),
|
||||
dict(
|
||||
fieldname="email",
|
||||
label="Email",
|
||||
fieldtype="Data",
|
||||
insert_after="last_name",
|
||||
options="Email"
|
||||
),
|
||||
dict(
|
||||
fieldname="customer_type",
|
||||
label="Customer Type",
|
||||
fieldtype="Select",
|
||||
options="Customer\nLead",
|
||||
insert_after="email"
|
||||
),
|
||||
dict(
|
||||
fieldname="customer_name",
|
||||
label="Customer Name",
|
||||
fieldtype="Dynamic Link",
|
||||
options="customer_type",
|
||||
insert_after="customer_type"
|
||||
),
|
||||
dict(
|
||||
fieldname="addresses",
|
||||
label="Addresses",
|
||||
fieldtype="Table",
|
||||
options="Contact Address Link",
|
||||
insert_after="customer_name"
|
||||
)
|
||||
],
|
||||
"Event": [
|
||||
dict(
|
||||
fieldname="participants",
|
||||
label="Participants",
|
||||
fieldtype="Section Break",
|
||||
insert_after="subject"
|
||||
)
|
||||
],
|
||||
"On-Site Meeting": [
|
||||
dict(
|
||||
fieldname="notes",
|
||||
label="Notes",
|
||||
fieldtype="Small Text",
|
||||
insert_after="address"
|
||||
),
|
||||
dict(
|
||||
fieldname="assigned_employee",
|
||||
label="Assigned Employee",
|
||||
fieldtype="Link",
|
||||
options="Employee",
|
||||
insert_after="notes"
|
||||
),
|
||||
dict(
|
||||
fieldname="status",
|
||||
label="Status",
|
||||
fieldtype="Select",
|
||||
options="Unscheduled\nScheduled\nCompleted\nCancelled",
|
||||
default="Unscheduled",
|
||||
insert_after="start_time"
|
||||
),
|
||||
dict(
|
||||
fieldname="completed_by",
|
||||
label="Completed By",
|
||||
fieldtype="Link",
|
||||
options="Employee",
|
||||
insert_after="status"
|
||||
),
|
||||
dict(
|
||||
fieldname="company",
|
||||
label="Company",
|
||||
fieldtype="Link",
|
||||
options="Company",
|
||||
insert_after="assigned_employee"
|
||||
),
|
||||
dict(
|
||||
fieldname="party_type",
|
||||
label="Party Type",
|
||||
fieldtype="Select",
|
||||
options="Customer\nLead",
|
||||
insert_after="company"
|
||||
),
|
||||
dict(
|
||||
fieldname="party_name",
|
||||
label="Party Name",
|
||||
fieldtype="Dynamic Link",
|
||||
options="party_type",
|
||||
insert_after="party_type"
|
||||
),
|
||||
dict(
|
||||
fieldname="contact",
|
||||
label="Contact",
|
||||
fieldtype="Link",
|
||||
options="Contact",
|
||||
insert_after="party_name"
|
||||
),
|
||||
dict(
|
||||
fieldname="project_template",
|
||||
label="Project Template",
|
||||
fieldtype="Link",
|
||||
options="Project Template",
|
||||
insert_after="company"
|
||||
)
|
||||
],
|
||||
"Quotation": [
|
||||
dict(
|
||||
fieldname="requires_half_payment",
|
||||
label="Requires Half Payment",
|
||||
fieldtype="Check",
|
||||
default=0,
|
||||
insert_after="custom_installation_address"
|
||||
),
|
||||
dict(
|
||||
fieldname="custom_quotation_template",
|
||||
label="Quotation Template",
|
||||
fieldtype="Link",
|
||||
options="Quotation Template",
|
||||
insert_after="company",
|
||||
description="The template used for generating this quotation."
|
||||
),
|
||||
dict(
|
||||
fieldname="custom_project_template",
|
||||
label="Project Template",
|
||||
fieldtype="Link",
|
||||
options="Project Template",
|
||||
insert_after="custom_quotation_template",
|
||||
description="The project template to use when creating a project from this quotation.",
|
||||
allow_on_submit=1
|
||||
),
|
||||
dict(
|
||||
fieldname="custom_job_address",
|
||||
label="Job Address",
|
||||
fieldtype="Link",
|
||||
options="Address",
|
||||
insert_after="custom_installation_address",
|
||||
description="The address where the job will be performed.",
|
||||
allow_on_submit=1
|
||||
),
|
||||
dict(
|
||||
fieldname="from_onsite_meeting",
|
||||
label="From On-Site Meeting",
|
||||
fieldtype="Link",
|
||||
options="On-Site Meeting",
|
||||
insert_after="custom_job_address"
|
||||
),
|
||||
dict(
|
||||
fieldname="from_onsite_meeting",
|
||||
label="From On-Site Meeting",
|
||||
fieldtype="Link",
|
||||
options="On-Site Meeting",
|
||||
insert_after="custom_job_address"
|
||||
),
|
||||
dict(
|
||||
fieldname="actual_customer_name",
|
||||
label="Customer",
|
||||
fieldtype="Dynamic Link",
|
||||
options="customer_type",
|
||||
insert_after="from_onsite_meeting",
|
||||
allow_on_submit=1
|
||||
),
|
||||
dict(
|
||||
fieldname="customer_type",
|
||||
label="Customer Type",
|
||||
fieldtype="Select",
|
||||
options="Customer\nLead",
|
||||
insert_after="customer_name",
|
||||
allow_on_submit=1
|
||||
),
|
||||
dict(
|
||||
fieldname="from_template",
|
||||
label="From Template",
|
||||
fieldtype="Link",
|
||||
options="Quotation Template",
|
||||
insert_after="customer_type"
|
||||
),
|
||||
dict(
|
||||
fieldname="project_template",
|
||||
label="Project Template",
|
||||
fieldtype="Link",
|
||||
options="Project Template",
|
||||
insert_after="from_template"
|
||||
)
|
||||
],
|
||||
"Sales Order": [
|
||||
dict(
|
||||
fieldname="requires_half_payment",
|
||||
label="Requires Half Payment",
|
||||
fieldtype="Check",
|
||||
default=0,
|
||||
insert_after="custom_installation_address"
|
||||
),
|
||||
dict(
|
||||
fieldname="custom_project_template",
|
||||
label="Project Template",
|
||||
fieldtype="Link",
|
||||
options="Project Template",
|
||||
description="The project template to use when creating a project from this sales order.",
|
||||
insert_after="custom_installation_address",
|
||||
allow_on_submit=1
|
||||
),
|
||||
dict(
|
||||
fieldname="custom_job_address",
|
||||
label="Job Address",
|
||||
fieldtype="Link",
|
||||
options="Address",
|
||||
insert_after="custom_installation_address",
|
||||
description="The address where the job will be performed.",
|
||||
allow_on_submit=1
|
||||
)
|
||||
],
|
||||
"Project": [
|
||||
dict(
|
||||
fieldname="job_address",
|
||||
label="Job Address",
|
||||
fieldtype="Link",
|
||||
options="Address",
|
||||
insert_after="project_name",
|
||||
description="The address where the job is being performed."
|
||||
),
|
||||
dict(
|
||||
fieldname="customer",
|
||||
label="Customer",
|
||||
fieldtype="Link",
|
||||
options="Customer",
|
||||
insert_after="job_address",
|
||||
description="The customer for whom the project is being executed."
|
||||
),
|
||||
dict(
|
||||
fieldname="expected_start_time",
|
||||
label="Expected Start Time",
|
||||
fieldtype="Time",
|
||||
insert_after="expected_start_date"
|
||||
),
|
||||
dict(
|
||||
fieldname="expected_end_time",
|
||||
label="Expected End Time",
|
||||
fieldtype="Time",
|
||||
insert_after="expected_end_date"
|
||||
),
|
||||
dict(
|
||||
fieldname="actual_start_time",
|
||||
label="Actual Start Time",
|
||||
fieldtype="Time",
|
||||
insert_after="actual_start_date"
|
||||
),
|
||||
dict(
|
||||
fieldname="actual_end_time",
|
||||
label="Actual End Time",
|
||||
fieldtype="Time",
|
||||
insert_after="actual_end_date"
|
||||
),
|
||||
dict(
|
||||
fieldname="is_scheduled",
|
||||
label="Is Scheduled",
|
||||
fieldtype="Check",
|
||||
default=0,
|
||||
insert_after="is_half_down_paid"
|
||||
),
|
||||
dict(
|
||||
fieldname="invoice_status",
|
||||
label="Invoice Status",
|
||||
fieldtype="Select",
|
||||
default="Not Ready",
|
||||
options="Not Ready\nReady to Invoice\nInvoice Created\nInvoice Sent",
|
||||
insert_after="is_scheduled"
|
||||
),
|
||||
dict(
|
||||
fieldname="requires_half_payment",
|
||||
label="Requires Half Payment",
|
||||
fieldtype="Check",
|
||||
default=0,
|
||||
insert_after="expected_end_time"
|
||||
),
|
||||
dict(
|
||||
fieldname="is_half_down_paid",
|
||||
label="Is Half Down Paid",
|
||||
fieldtype="Check",
|
||||
default=0,
|
||||
insert_after="requires_half_payment"
|
||||
),
|
||||
dict(
|
||||
fieldname="service_appointment",
|
||||
label="Service Appointment",
|
||||
fieldtype="Link",
|
||||
options="Service Address 2",
|
||||
insert_after="is_half_down_paid"
|
||||
),
|
||||
dict(
|
||||
fieldname="tasks",
|
||||
label="Tasks",
|
||||
fieldtype="Table",
|
||||
options="Project Task Link",
|
||||
insert_after="service_appointment"
|
||||
),
|
||||
dict(
|
||||
fieldname="ready_to_schedule",
|
||||
label="Ready to Schedule",
|
||||
fieldtype="Check",
|
||||
insert_after="tasks"
|
||||
)
|
||||
],
|
||||
"Project Template": [
|
||||
dict(
|
||||
fieldname="company",
|
||||
label="Company",
|
||||
fieldtype="Link",
|
||||
options="Company",
|
||||
insert_after="project_type",
|
||||
description="The company associated with this project template."
|
||||
),
|
||||
dict(
|
||||
fieldname="calendar_color",
|
||||
label="Calendar Color",
|
||||
fieldtype="Color",
|
||||
insert_after="company"
|
||||
),
|
||||
dict(
|
||||
fieldname="bid_meeting_note_form",
|
||||
label="Bid Meeting Note Form",
|
||||
fieldtype="Link",
|
||||
options="Bid Meeting Note Form",
|
||||
insert_after="calendar_color"
|
||||
),
|
||||
dict(
|
||||
fieldname="item_groups",
|
||||
label="Item Groups",
|
||||
fieldtype="Data",
|
||||
insert_after="bid_meeting_note_form"
|
||||
)
|
||||
],
|
||||
"Task": [
|
||||
dict(
|
||||
fieldname="project_template",
|
||||
label="Project Template",
|
||||
fieldtype="Link",
|
||||
options="Project Template",
|
||||
insert_after="project"
|
||||
),
|
||||
dict(
|
||||
fieldname="customer",
|
||||
label="Customer",
|
||||
fieldtype="Link",
|
||||
options="Customer",
|
||||
insert_after="project_template"
|
||||
)
|
||||
],
|
||||
"Task Type": [
|
||||
dict(
|
||||
fieldname="base_date",
|
||||
label="Base Date",
|
||||
fieldtype="Select",
|
||||
options="Start\nEnd\nCompletion\nCreation",
|
||||
reqd=1,
|
||||
insert_after="name"
|
||||
),
|
||||
dict(
|
||||
fieldname="offset_days",
|
||||
label="Offset Days",
|
||||
fieldtype="Int",
|
||||
reqd=1,
|
||||
insert_after="base_date"
|
||||
),
|
||||
dict(
|
||||
fieldname="skip_weekends",
|
||||
label="Skip Weekends",
|
||||
fieldtype="Check",
|
||||
insert_after="offset_days"
|
||||
),
|
||||
dict(
|
||||
fieldname="skip_holidays",
|
||||
label="Skip Holidays",
|
||||
fieldtype="Check",
|
||||
insert_after="skip_weekends"
|
||||
),
|
||||
dict(
|
||||
fieldname="logic_key",
|
||||
label="Logic Key",
|
||||
fieldtype="Data",
|
||||
insert_after="skip_holidays"
|
||||
),
|
||||
dict(
|
||||
fieldname="offset_direction",
|
||||
label="Offset Direction",
|
||||
fieldtype="Select",
|
||||
options="After\nBefore",
|
||||
reqd=1,
|
||||
insert_after="logic_key"
|
||||
),
|
||||
dict(
|
||||
fieldname="title",
|
||||
label="Title",
|
||||
fieldtype="Data",
|
||||
reqd=1,
|
||||
insert_after="offset_direction"
|
||||
),
|
||||
dict(
|
||||
fieldname="days",
|
||||
label="Days",
|
||||
fieldtype="Int",
|
||||
insert_after="title"
|
||||
),
|
||||
dict(
|
||||
fieldname="calculate_from",
|
||||
label="Calculate From",
|
||||
fieldtype="Select",
|
||||
options="Service Address 2\nProject\nTask",
|
||||
reqd=1,
|
||||
insert_after="days"
|
||||
),
|
||||
dict(
|
||||
fieldname="trigger",
|
||||
label="Trigger",
|
||||
fieldtype="Select",
|
||||
options="Scheduled\nCompleted\nCreated",
|
||||
reqd=1,
|
||||
insert_after="calculate_from"
|
||||
),
|
||||
dict(
|
||||
fieldname="task_type_calculate_from",
|
||||
label="Task Type For Task Calculate From",
|
||||
fieldtype="Link",
|
||||
options="Task Type",
|
||||
insert_after="trigger"
|
||||
),
|
||||
dict(
|
||||
fieldname="work_type",
|
||||
label="Work Type",
|
||||
fieldtype="Select",
|
||||
options="Admin\nLabor\nQA",
|
||||
reqd=1,
|
||||
insert_after="task_type_calculate_from"
|
||||
),
|
||||
dict(
|
||||
fieldname="no_due_date",
|
||||
label="No Due Date",
|
||||
fieldtype="Check",
|
||||
insert_after="work_type"
|
||||
),
|
||||
dict(
|
||||
fieldname="triggering_doctype",
|
||||
label="Triggering Doctype",
|
||||
fieldtype="Select",
|
||||
options="Service Address 2\nProject\nTask",
|
||||
reqd=1,
|
||||
insert_after="no_due_date"
|
||||
)
|
||||
],
|
||||
"Sales Invoice": [
|
||||
dict(
|
||||
fieldname="project_template",
|
||||
label="Project Template",
|
||||
fieldtype="Link",
|
||||
options="Project Template",
|
||||
insert_after="project"
|
||||
),
|
||||
dict(
|
||||
fieldname="job_address",
|
||||
label="Job Address",
|
||||
fieldtype="Link",
|
||||
options="Address",
|
||||
insert_after="project_template"
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
print("🔧 Custom fields to check per doctype:")
|
||||
for key, value in custom_fields.items():
|
||||
print(f" • {key}: {len(value)} fields")
|
||||
@ -1567,58 +844,3 @@ def create_bid_meeting_note_form_templates():
|
||||
)
|
||||
|
||||
doc.insert(ignore_permissions=True)
|
||||
|
||||
def create_accounts():
|
||||
"""Create necessary accounts if they do not exist."""
|
||||
print("\n🔧 Checking for necessary accounts...")
|
||||
|
||||
accounts = [
|
||||
{
|
||||
"Sprinklers Northwest": [
|
||||
{
|
||||
"account_name": "Stripe Clearing - Sprinklers Northwest",
|
||||
"account_type": "Bank",
|
||||
"parent_account": "Bank Accounts - S",
|
||||
"company": "Sprinklers Northwest"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
for company_accounts in accounts:
|
||||
for company, account_list in company_accounts.items():
|
||||
for account in account_list:
|
||||
# Idempotency check
|
||||
if frappe.db.exists("Account", {"account_name": account["account_name"], "company": account["company"]}):
|
||||
continue
|
||||
doc = frappe.get_doc({
|
||||
"doctype": "Account",
|
||||
"account_name": account["account_name"],
|
||||
"account_type": account["account_type"],
|
||||
"company": account["company"],
|
||||
"parent_account": account["parent_account"],
|
||||
"is_group": 0
|
||||
})
|
||||
doc.insert(ignore_permissions=True, ignore_if_duplicate=True)
|
||||
|
||||
frappe.db.commit()
|
||||
|
||||
def init_stripe_accounts():
|
||||
"""Initializes the bare configurations for each Stripe Settings doctypes."""
|
||||
print("\n🔧 Initializing Stripe Settings for companies...")
|
||||
|
||||
companies = ["Sprinklers Northwest"]
|
||||
|
||||
for company in companies:
|
||||
if not frappe.db.exists("Stripe Settings", {"company": company}):
|
||||
doc = frappe.get_doc({
|
||||
"doctype": "Stripe Settings",
|
||||
"company": company,
|
||||
"api_key": "",
|
||||
"publishable_key": "",
|
||||
"webhook_secret": "",
|
||||
"account": f"Stripe Clearing - {company}"
|
||||
})
|
||||
doc.insert(ignore_permissions=True)
|
||||
|
||||
frappe.db.commit()
|
||||
|
||||
@ -1,2 +0,0 @@
|
||||
from .payments import PaymentData
|
||||
from .item_models import BOMItem, PackageCreationData
|
||||
@ -1,18 +0,0 @@
|
||||
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
|
||||
@ -1,10 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class PaymentData:
|
||||
mode_of_payment: str
|
||||
reference_no: str
|
||||
reference_date: str
|
||||
received_amount: float
|
||||
company: str = None
|
||||
reference_doc_name: str = None
|
||||
@ -6,9 +6,4 @@ from .estimate_service import EstimateService
|
||||
from .onsite_meeting_service import OnSiteMeetingService
|
||||
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 .project_service import ProjectService
|
||||
from .sales_order_service import SalesOrderService
|
||||
from .email_service import EmailService
|
||||
from .stripe_service import StripeService
|
||||
@ -186,7 +186,6 @@ class AddressService:
|
||||
address_doc.append(field, link)
|
||||
print("DEBUG: Saving address document after appending link.")
|
||||
address_doc.save(ignore_permissions=True)
|
||||
frappe.db.commit()
|
||||
print(f"DEBUG: Set link field {field} for Address {address_name} with link data {link}")
|
||||
|
||||
@staticmethod
|
||||
|
||||
@ -55,7 +55,6 @@ class ClientService:
|
||||
client_doc.append(field, link)
|
||||
print("DEBUG: Saving client document after appending link.")
|
||||
client_doc.save(ignore_permissions=True)
|
||||
frappe.db.commit()
|
||||
print(f"DEBUG: Set link field {field} for client {client_doc.get('name')} with link data {link}")
|
||||
|
||||
@staticmethod
|
||||
@ -92,7 +91,6 @@ class ClientService:
|
||||
try:
|
||||
print(f"DEBUG: Processing address: {address.get('address')}")
|
||||
ClientService.append_link_v2(customer_doc.name, "properties", {"address": address.get("address")})
|
||||
customer_doc.reload()
|
||||
address_doc = AddressService.get_or_throw(address.get("address"))
|
||||
AddressService.link_address_to_customer(address_doc, "Customer", customer_doc.name)
|
||||
print(f"DEBUG: Linked address {address.get('address')} to customer")
|
||||
@ -106,7 +104,6 @@ class ClientService:
|
||||
try:
|
||||
print(f"DEBUG: Processing contact: {contact.get('contact')}")
|
||||
ClientService.append_link_v2(customer_doc.name, "contacts", {"contact": contact.get("contact")})
|
||||
customer_doc.reload()
|
||||
contact_doc = ContactService.get_or_throw(contact.get("contact"))
|
||||
ContactService.link_contact_to_customer(contact_doc, "Customer", customer_doc.name)
|
||||
print(f"DEBUG: Linked contact {contact.get('contact')} to customer")
|
||||
@ -120,7 +117,6 @@ class ClientService:
|
||||
try:
|
||||
print(f"DEBUG: Processing quotation: {quotation.get('quotation')}")
|
||||
ClientService.append_link_v2(customer_doc.name, "quotations", {"quotation": quotation.get("quotation")})
|
||||
customer_doc.reload()
|
||||
quotation_doc = EstimateService.get_or_throw(quotation.get("quotation"))
|
||||
EstimateService.link_estimate_to_customer(quotation_doc, "Customer", customer_doc.name)
|
||||
print(f"DEBUG: Linked quotation {quotation.get('quotation')} to customer")
|
||||
@ -134,7 +130,6 @@ class ClientService:
|
||||
print(f"DEBUG: Processing onsite meeting: {meeting.get('onsite_meeting')}")
|
||||
meeting_doc = DbService.get_or_throw("On-Site Meeting",meeting.get("onsite_meeting"))
|
||||
ClientService.append_link_v2(customer_doc.name, "onsite_meetings", {"onsite_meeting": meeting.get("onsite_meeting")})
|
||||
customer_doc.reload()
|
||||
OnSiteMeetingService.link_onsite_meeting_to_customer(meeting_doc, "Customer", customer_doc.name)
|
||||
print(f"DEBUG: Linked onsite meeting {meeting.get('onsite_meeting')} to customer")
|
||||
except Exception as e:
|
||||
@ -146,13 +141,11 @@ class ClientService:
|
||||
try:
|
||||
print(f"DEBUG: Processing company: {company.get('company')}")
|
||||
ClientService.append_link_v2(customer_doc.name, "companies", {"company": company.get("company")})
|
||||
customer_doc.reload()
|
||||
print(f"DEBUG: Linked company {company.get('company')} to customer")
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to link company {company.get('company')}: {str(e)}")
|
||||
frappe.log_error(f"Company linking error: {str(e)}", "convert_lead_to_customer")
|
||||
print(f"DEBUG: Converted Lead {lead_name} to Customer {customer_doc.name}")
|
||||
frappe.db.commit()
|
||||
return customer_doc
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@ -1,240 +0,0 @@
|
||||
import frappe
|
||||
from frappe.utils import get_url
|
||||
|
||||
class EmailService:
|
||||
|
||||
@staticmethod
|
||||
def get_customer_email(customer_name: str, doctype: str = "Customer") -> str | None:
|
||||
"""
|
||||
Get the primary email for a customer or lead.
|
||||
|
||||
Args:
|
||||
customer_name: Name of the Customer or Lead
|
||||
doctype: Either "Customer" or "Lead"
|
||||
|
||||
Returns:
|
||||
Email address if found, None otherwise
|
||||
"""
|
||||
try:
|
||||
customer_doc = frappe.get_doc(doctype, customer_name)
|
||||
email = None
|
||||
|
||||
# Try primary_contact field
|
||||
if hasattr(customer_doc, 'primary_contact') and customer_doc.primary_contact:
|
||||
try:
|
||||
primary_contact = frappe.get_doc("Contact", customer_doc.primary_contact)
|
||||
email = primary_contact.email_id
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not get primary_contact: {str(e)}")
|
||||
|
||||
# Fallback to customer_primary_contact
|
||||
if not email and hasattr(customer_doc, 'customer_primary_contact') and customer_doc.customer_primary_contact:
|
||||
try:
|
||||
primary_contact = frappe.get_doc("Contact", customer_doc.customer_primary_contact)
|
||||
email = primary_contact.email_id
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not get customer_primary_contact: {str(e)}")
|
||||
|
||||
# Last resort - get any contact linked to this customer/lead
|
||||
if not email:
|
||||
contact_links = frappe.get_all("Dynamic Link",
|
||||
filters={
|
||||
"link_doctype": doctype,
|
||||
"link_name": customer_name,
|
||||
"parenttype": "Contact"
|
||||
},
|
||||
pluck="parent"
|
||||
)
|
||||
if contact_links:
|
||||
try:
|
||||
contact = frappe.get_doc("Contact", contact_links[0])
|
||||
email = contact.email_id
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not get contact from dynamic link: {str(e)}")
|
||||
|
||||
return email
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to get email for {doctype} {customer_name}: {str(e)}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def send_templated_email(
|
||||
recipients: str | list,
|
||||
subject: str,
|
||||
template_path: str,
|
||||
template_context: dict,
|
||||
doctype: str = None,
|
||||
docname: str = None,
|
||||
cc: str | list = None,
|
||||
bcc: str | list = None,
|
||||
attachments: list = None
|
||||
) -> bool:
|
||||
"""
|
||||
Send an email using a Jinja2 template.
|
||||
|
||||
Args:
|
||||
recipients: Email address(es) to send to
|
||||
subject: Email subject line
|
||||
template_path: Path to the Jinja2 template (relative to app root)
|
||||
template_context: Dictionary of variables to pass to template
|
||||
doctype: Optional doctype to link email to
|
||||
docname: Optional document name to link email to
|
||||
cc: Optional CC recipients
|
||||
bcc: Optional BCC recipients
|
||||
attachments: Optional list of attachments
|
||||
|
||||
Returns:
|
||||
True if email sent successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Render the email template
|
||||
message = frappe.render_template(template_path, template_context)
|
||||
|
||||
# Prepare sendmail arguments
|
||||
email_args = {
|
||||
"recipients": recipients,
|
||||
"subject": subject,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
if doctype:
|
||||
email_args["doctype"] = doctype
|
||||
if docname:
|
||||
email_args["name"] = docname
|
||||
if cc:
|
||||
email_args["cc"] = cc
|
||||
if bcc:
|
||||
email_args["bcc"] = bcc
|
||||
if attachments:
|
||||
email_args["attachments"] = attachments
|
||||
|
||||
# Send email
|
||||
frappe.sendmail(**email_args)
|
||||
|
||||
print(f"DEBUG: Email sent successfully to {recipients}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to send email: {str(e)}")
|
||||
frappe.log_error(f"Failed to send email to {recipients}: {str(e)}", "Email Service Error")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def send_downpayment_email(sales_order_name: str) -> bool:
|
||||
"""
|
||||
Send a down payment email for a Sales Order.
|
||||
|
||||
Args:
|
||||
sales_order_name: Name of the Sales Order
|
||||
|
||||
Returns:
|
||||
True if email sent successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
doc = frappe.get_doc("Sales Order", sales_order_name)
|
||||
|
||||
# Get customer email
|
||||
email = EmailService.get_customer_email(doc.customer, "Customer")
|
||||
|
||||
if not email:
|
||||
print(f"ERROR: No email found for customer {doc.customer}, cannot send down payment email")
|
||||
return False
|
||||
|
||||
# Prepare template context
|
||||
half_down_amount = doc.custom_halfdown_amount or (doc.grand_total / 2)
|
||||
base_url = get_url()
|
||||
|
||||
template_context = {
|
||||
"company_name": doc.company,
|
||||
"customer_name": doc.customer_name or doc.customer,
|
||||
"sales_order_number": doc.name,
|
||||
"total_amount": frappe.utils.fmt_money(half_down_amount, currency=doc.currency),
|
||||
"base_url": base_url
|
||||
}
|
||||
|
||||
# Send email
|
||||
template_path = "custom_ui/templates/emails/downpayment.html"
|
||||
subject = f"Down Payment Required - {doc.company} - {doc.name}"
|
||||
|
||||
return EmailService.send_templated_email(
|
||||
recipients=email,
|
||||
subject=subject,
|
||||
template_path=template_path,
|
||||
template_context=template_context,
|
||||
doctype="Sales Order",
|
||||
docname=doc.name
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to send down payment email for {sales_order_name}: {str(e)}")
|
||||
frappe.log_error(f"Failed to send down payment email for {sales_order_name}: {str(e)}", "Down Payment Email Error")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def send_invoice_email(sales_invoice_name: str) -> bool:
|
||||
"""
|
||||
Send an invoice email for a Sales Invoice.
|
||||
|
||||
Args:
|
||||
sales_invoice_name: Name of the Sales Invoice
|
||||
|
||||
Returns:
|
||||
True if email sent successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
doc = frappe.get_doc("Sales Invoice", sales_invoice_name)
|
||||
|
||||
# Get customer email
|
||||
email = EmailService.get_customer_email(doc.customer, "Customer")
|
||||
|
||||
if not email:
|
||||
print(f"ERROR: No email found for customer {doc.customer}, cannot send invoice email")
|
||||
return False
|
||||
|
||||
# Calculate amounts
|
||||
outstanding_amount = doc.outstanding_amount
|
||||
paid_amount = doc.grand_total - outstanding_amount
|
||||
|
||||
# Get related Sales Order if available
|
||||
sales_order = None
|
||||
if hasattr(doc, 'items') and doc.items:
|
||||
for item in doc.items:
|
||||
if item.sales_order:
|
||||
sales_order = item.sales_order
|
||||
break
|
||||
|
||||
# Prepare template context
|
||||
base_url = get_url()
|
||||
|
||||
template_context = {
|
||||
"company_name": doc.company,
|
||||
"customer_name": doc.customer_name or doc.customer,
|
||||
"invoice_number": doc.name,
|
||||
"invoice_date": doc.posting_date,
|
||||
"due_date": doc.due_date,
|
||||
"grand_total": frappe.utils.fmt_money(doc.grand_total, currency=doc.currency),
|
||||
"outstanding_amount": frappe.utils.fmt_money(outstanding_amount, currency=doc.currency),
|
||||
"paid_amount": frappe.utils.fmt_money(paid_amount, currency=doc.currency),
|
||||
"sales_order": sales_order,
|
||||
"base_url": base_url,
|
||||
"payment_url": f"{base_url}/api/method/custom_ui.api.public.payments.invoice_stripe_payment?sales_invoice={doc.name}" if outstanding_amount > 0 else None
|
||||
}
|
||||
|
||||
# Send email
|
||||
template_path = "custom_ui/templates/emails/invoice.html"
|
||||
subject = f"Invoice {doc.name} - {doc.company}"
|
||||
|
||||
return EmailService.send_templated_email(
|
||||
recipients=email,
|
||||
subject=subject,
|
||||
template_path=template_path,
|
||||
template_context=template_context,
|
||||
doctype="Sales Invoice",
|
||||
docname=doc.name
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to send invoice email for {sales_invoice_name}: {str(e)}")
|
||||
frappe.log_error(f"Failed to send invoice email for {sales_invoice_name}: {str(e)}", "Invoice Email Error")
|
||||
return False
|
||||
@ -1,5 +1,4 @@
|
||||
import frappe
|
||||
from .item_service import ItemService
|
||||
|
||||
class EstimateService:
|
||||
|
||||
@ -94,18 +93,4 @@ class EstimateService:
|
||||
estimate_doc.customer = customer_name
|
||||
estimate_doc.save(ignore_permissions=True)
|
||||
print(f"DEBUG: Linked Quotation {estimate_doc.name} to {customer_type} {customer_name}")
|
||||
|
||||
@staticmethod
|
||||
def map_project_template_to_filter(project_template: str = None) -> dict | None:
|
||||
"""Map a project template to a filter."""
|
||||
print(f"DEBUG: Mapping project template {project_template} to quotation category")
|
||||
if not project_template:
|
||||
print("DEBUG: No project template provided, defaulting to 'General'")
|
||||
return None
|
||||
mapping = {
|
||||
# SNW Install is both Irrigation and SNW-S categories
|
||||
"SNW Install": ["in", ["Irrigation", "SNW-S", "Landscaping"]],
|
||||
}
|
||||
category = mapping.get(project_template, "General")
|
||||
print(f"DEBUG: Mapped to quotation category: {category}")
|
||||
return { "item_group": category }
|
||||
|
||||
@ -1,204 +0,0 @@
|
||||
import frappe
|
||||
|
||||
class ItemService:
|
||||
|
||||
@staticmethod
|
||||
def get_item_category(item_code: str) -> str:
|
||||
"""Retrieve the category of an Item document by item code."""
|
||||
print(f"DEBUG: Getting category for Item {item_code}")
|
||||
category = frappe.db.get_value("Item", item_code, "item_group")
|
||||
print(f"DEBUG: Retrieved category: {category}")
|
||||
return category
|
||||
|
||||
@staticmethod
|
||||
def get_full_dict(item_code: str) -> frappe._dict:
|
||||
"""Retrieve the full Item document by item code."""
|
||||
print(f"DEBUG: Getting full document for Item {item_code}")
|
||||
item_doc = frappe.get_doc("Item", item_code).as_dict()
|
||||
item_doc["bom"] = ItemService.get_full_bom_dict(item_code) if item_doc.get("default_bom") else None
|
||||
return item_doc
|
||||
|
||||
@staticmethod
|
||||
def get_full_bom_dict(item_code: str):
|
||||
"""Retrieve the Bill of Materials (BOM) associated with an Item."""
|
||||
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('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
|
||||
def exists(item_code: str) -> bool:
|
||||
"""Check if an Item document exists by item code."""
|
||||
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
|
||||
|
||||
@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
|
||||
@ -1,52 +1,29 @@
|
||||
import frappe
|
||||
from custom_ui.services import DbService, StripeService
|
||||
from dataclasses import dataclass
|
||||
from custom_ui.models import PaymentData
|
||||
|
||||
|
||||
from custom_ui.services import DbService
|
||||
|
||||
class PaymentService:
|
||||
|
||||
@staticmethod
|
||||
def create_payment_entry(data: PaymentData) -> frappe._dict:
|
||||
def create_payment_entry(reference_doctype: str, reference_doc_name: str, data: dict) -> frappe._dict:
|
||||
"""Create a Payment Entry document based on the reference document."""
|
||||
print(f"DEBUG: Creating Payment Entry for {data.reference_doc_name} with data: {data}")
|
||||
reference_doctype = PaymentService.determine_reference_doctype(data.reference_doc_name)
|
||||
reference_doc = DbService.get_or_throw(reference_doctype, data.reference_doc_name)
|
||||
account = StripeService.get_stripe_settings(data.company).custom_account
|
||||
print(f"DEBUG: Creating Payment Entry for {reference_doctype} {reference_doc_name} with data: {data}")
|
||||
reference_doc = DbService.get_or_throw(reference_doctype, reference_doc_name)
|
||||
pe = frappe.get_doc({
|
||||
"doctype": "Payment Entry",
|
||||
"company": data.company,
|
||||
"payment_type": "Receive",
|
||||
"party_type": "Customer",
|
||||
"mode_of_payment": data.mode_of_payment or "Stripe",
|
||||
"mode_of_payment": data.get("mode_of_payment", "Stripe"),
|
||||
"party": reference_doc.customer,
|
||||
"party_name": reference_doc.customer,
|
||||
"paid_to": account,
|
||||
"reference_no": data.reference_no,
|
||||
"reference_date": data.reference_date or frappe.utils.nowdate(),
|
||||
"paid_amount": data.received_amount,
|
||||
"received_amount": data.received_amount,
|
||||
"paid_currency": "USD",
|
||||
"received_currency": "USD",
|
||||
"references": [{
|
||||
"reference_doctype": reference_doc.doctype,
|
||||
"reference_name": reference_doc.name,
|
||||
"allocated_amount": data.received_amount,
|
||||
}]
|
||||
"paid_to": data.get("paid_to"),
|
||||
"reference_no": data.get("reference_no"),
|
||||
"reference_date": data.get("reference_date", frappe.utils.nowdate()),
|
||||
"reference_doctype": reference_doctype,
|
||||
"reference_name": reference_doc.name,
|
||||
"paid_amount": data.get("paid_amount"),
|
||||
"paid_currency": data.get("paid_currency"),
|
||||
})
|
||||
pe.insert()
|
||||
print(f"DEBUG: Created Payment Entry with name: {pe.name}")
|
||||
return pe
|
||||
|
||||
@staticmethod
|
||||
def determine_reference_doctype(reference_doc_name: str) -> str:
|
||||
"""Determine the reference doctype based on the document name pattern."""
|
||||
print(f"DEBUG: Determining reference doctype for document name: {reference_doc_name}")
|
||||
if DbService.exists("Sales Order", reference_doc_name):
|
||||
return "Sales Order"
|
||||
elif DbService.exists("Sales Invoice", reference_doc_name):
|
||||
return "Sales Invoice"
|
||||
else:
|
||||
frappe.throw("Unable to determine reference doctype from document name.")
|
||||
return pe.as_dict()
|
||||
|
||||
@ -1,12 +0,0 @@
|
||||
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
|
||||
@ -1,23 +1,7 @@
|
||||
import frappe
|
||||
from frappe.utils import today
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
|
||||
|
||||
|
||||
class SalesOrderService:
|
||||
|
||||
@staticmethod
|
||||
def create_sales_invoice_from_sales_order(sales_order_name):
|
||||
try:
|
||||
sales_order_doc = frappe.get_doc("Sales Order", sales_order_name)
|
||||
sales_invoice = make_sales_invoice(sales_order_doc.name)
|
||||
sales_invoice.project = sales_order_doc.project
|
||||
sales_invoice.posting_date = today()
|
||||
sales_invoice.due_date = today()
|
||||
sales_invoice.remarks = f"Auto-generated from Sales Order {sales_order_doc.name}"
|
||||
sales_invoice.job_address = sales_order_doc.custom_job_address
|
||||
sales_invoice.project_template = sales_order_doc.custom_project_template
|
||||
sales_invoice.insert()
|
||||
sales_invoice.submit()
|
||||
return sales_invoice.name
|
||||
except Exception as e:
|
||||
print("ERROR creating Sales Invoice from Sales Order:", str(e))
|
||||
return None
|
||||
def apply_advance_payment(sales_order_name: str, payment_entry_doc):
|
||||
pass
|
||||
@ -9,7 +9,7 @@ class StripeService:
|
||||
@staticmethod
|
||||
def get_stripe_settings(company: str):
|
||||
"""Fetch Stripe settings for a given company."""
|
||||
settings_name = frappe.get_all("Stripe Settings", pluck="name", filters={"custom_company": company})
|
||||
settings_name = frappe.get_all("Stripe Settings", pluck="name", filters={"company": company})
|
||||
if not settings_name:
|
||||
frappe.throw(f"Stripe Settings not found for company: {company}")
|
||||
settings = frappe.get_doc("Stripe Settings", settings_name[0]) if settings_name else None
|
||||
@ -19,138 +19,63 @@ class StripeService:
|
||||
def get_api_key(company: str) -> str:
|
||||
"""Retrieve the Stripe API key for the specified company."""
|
||||
settings = StripeService.get_stripe_settings(company)
|
||||
return settings.get_password("secret_key")
|
||||
return settings.secret_key
|
||||
|
||||
@staticmethod
|
||||
def get_webhook_secret(company: str) -> str:
|
||||
"""Retrieve the Stripe webhook secret for the specified company."""
|
||||
settings = StripeService.get_stripe_settings(company)
|
||||
if not settings.custom_webhook_secret:
|
||||
if not settings.webhook_secret:
|
||||
frappe.throw(f"Stripe Webhook Secret not configured for company: {company}")
|
||||
return settings.custom_webhook_secret
|
||||
return settings.webhook_secret
|
||||
|
||||
@staticmethod
|
||||
def create_checkout_session(
|
||||
company: str,
|
||||
amount: float,
|
||||
service: str,
|
||||
order_num: str,
|
||||
currency: str = "usd",
|
||||
for_advance_payment: bool = False,
|
||||
line_items: list | None = None,
|
||||
sales_invoice: str = None
|
||||
) -> stripe.checkout.Session:
|
||||
"""
|
||||
Create a Stripe Checkout Session.
|
||||
|
||||
Args:
|
||||
company: Company name
|
||||
amount: Payment amount (should be the outstanding amount for invoices)
|
||||
service: Service description
|
||||
order_num: Sales Order name if for_advance_payment is True, otherwise Sales Invoice name
|
||||
currency: Currency code (default: "usd")
|
||||
for_advance_payment: True if this is an advance/down payment, False for full invoice payment
|
||||
line_items: Optional custom line items for the checkout session
|
||||
sales_invoice: Sales Invoice name (for full payments)
|
||||
|
||||
Returns:
|
||||
stripe.checkout.Session object
|
||||
"""
|
||||
def create_checkout_session(company: str, amount: float, service: str, order_num: str, currency: str = "usd", for_advance_payment: bool = False, line_items: list | None = None) -> stripe.checkout.Session:
|
||||
"""Create a Stripe Checkout Session. order_num is a Sales Order name if for_advance_payment is True, otherwise it is a Sales Invoice name."""
|
||||
stripe.api_key = StripeService.get_api_key(company)
|
||||
|
||||
# Determine payment description
|
||||
if for_advance_payment:
|
||||
description = f"Advance payment for {company}{' - ' + service if service else ''}"
|
||||
else:
|
||||
description = f"Invoice payment for {company}{' - ' + service if service else ''}"
|
||||
if sales_invoice:
|
||||
description = f"Invoice {sales_invoice} - {company}"
|
||||
|
||||
# Use custom line items if provided and not an advance payment, otherwise create default line item
|
||||
line_items = line_items if line_items and not for_advance_payment else [{
|
||||
"price_data": {
|
||||
"currency": currency.lower(),
|
||||
"product_data": {
|
||||
"name": description
|
||||
"name": f"Advance payment for {company}{' - ' + service if service else ''}"
|
||||
},
|
||||
"unit_amount": int(amount * 100), # Stripe expects amount in cents
|
||||
"unit_amount": int(amount * 100),
|
||||
},
|
||||
"quantity": 1,
|
||||
}]
|
||||
|
||||
# Prepare metadata
|
||||
metadata = {
|
||||
"company": company,
|
||||
"payment_type": "advance" if for_advance_payment else "full"
|
||||
}
|
||||
|
||||
# Add appropriate document reference to metadata
|
||||
if for_advance_payment:
|
||||
metadata["sales_order"] = order_num
|
||||
else:
|
||||
metadata["sales_invoice"] = sales_invoice or order_num
|
||||
if sales_invoice:
|
||||
# Check if there's a related sales order
|
||||
invoice_doc = frappe.get_doc("Sales Invoice", sales_invoice)
|
||||
if hasattr(invoice_doc, 'items') and invoice_doc.items:
|
||||
for item in invoice_doc.items:
|
||||
if item.sales_order:
|
||||
metadata["sales_order"] = item.sales_order
|
||||
break
|
||||
|
||||
session = stripe.checkout.Session.create(
|
||||
mode="payment",
|
||||
payment_method_types=["card"],
|
||||
line_items=line_items,
|
||||
metadata=metadata,
|
||||
success_url=f"{get_url()}/payment_success?session_id={{CHECKOUT_SESSION_ID}}",
|
||||
cancel_url=f"{get_url()}/payment_cancelled",
|
||||
metadata={
|
||||
"order_num": order_num,
|
||||
"company": company,
|
||||
"payment_type": "advance" if for_advance_payment else "full"
|
||||
},
|
||||
success_url=f"{get_url()}/payment-success?session_id={{CHECKOUT_SESSION_ID}}",
|
||||
cancel_url=f"{get_url()}/payment-cancelled",
|
||||
)
|
||||
|
||||
return session
|
||||
|
||||
@staticmethod
|
||||
def get_event(payload: bytes, sig_header: str, company: str = None) -> stripe.Event:
|
||||
print("DEBUG: Stripe webhook received")
|
||||
print(f"DEBUG: Signature header present: {bool(sig_header)}")
|
||||
|
||||
# If company not provided, try to extract from payload metadata
|
||||
company = company if company else json.loads(payload).get("data", {}).get("object", {}).get("metadata", {}).get("company")
|
||||
if not company:
|
||||
try:
|
||||
payload_dict = json.loads(payload)
|
||||
print(f"DEBUG: Parsed payload type: {payload_dict.get('type')}")
|
||||
|
||||
metadata = payload_dict.get("data", {}).get("object", {}).get("metadata", {})
|
||||
print(f"DEBUG: Metadata from payload: {metadata}")
|
||||
|
||||
company = metadata.get("company")
|
||||
print(f"DEBUG: Extracted company from metadata: {company}")
|
||||
except (json.JSONDecodeError, KeyError, AttributeError) as e:
|
||||
print(f"DEBUG: Failed to parse payload: {str(e)}")
|
||||
|
||||
# If we still don't have a company, reject the webhook
|
||||
if not company:
|
||||
print("ERROR: Company information missing in webhook payload")
|
||||
frappe.throw("Company information missing in webhook payload.")
|
||||
|
||||
print(f"DEBUG: Validating webhook signature for company: {company}")
|
||||
|
||||
# Validate webhook signature with the specified company's secret
|
||||
try:
|
||||
event = stripe.Webhook.construct_event(
|
||||
payload=payload,
|
||||
sig_header=sig_header,
|
||||
secret=StripeService.get_webhook_secret(company)
|
||||
secret=StripeService.get_webhook_secret(company),
|
||||
api_key=StripeService.get_api_key(company)
|
||||
)
|
||||
print(f"DEBUG: Webhook signature validated successfully for company: {company}")
|
||||
print(f"DEBUG: Event type: {event.type}")
|
||||
print(f"DEBUG: Event ID: {event.id}")
|
||||
except ValueError as e:
|
||||
print(f"ERROR: Invalid payload: {str(e)}")
|
||||
frappe.throw(f"Invalid payload: {str(e)}")
|
||||
except stripe.error.SignatureVerificationError as e:
|
||||
print(f"ERROR: Invalid signature for company {company}: {str(e)}")
|
||||
frappe.throw(f"Invalid signature for company {company}: {str(e)}")
|
||||
frappe.throw(f"Invalid signature: {str(e)}")
|
||||
|
||||
return event
|
||||
|
||||
|
||||
@ -75,10 +75,10 @@
|
||||
<div class="payment-details">
|
||||
<h2>Payment Details</h2>
|
||||
<p><strong>Sales Order Number:</strong> {{ sales_order_number }}</p>
|
||||
<p><strong>Down Payment Amount:</strong> {{ total_amount }}</p>
|
||||
<p><strong>Down Payment Amount:</strong> ${{ total_amount }}</p>
|
||||
</div>
|
||||
<p>Please click the button below to make your secure payment through our payment processor:</p>
|
||||
<a href="{{ base_url }}/api/method/custom_ui.api.public.payments.half_down_stripe_payment?sales_order={{ sales_order_number }}" class="cta-button">Make Payment</a>
|
||||
<a href="https://yourdomain.com/downpayment?so={{ sales_order_number }}&amount={{ total_amount }}" class="cta-button">Make Payment</a>
|
||||
<p>If you have any questions or need assistance, feel free to contact us. We're here to help!</p>
|
||||
<p>Best regards,<br>The Team at {{ company_name }}</p>
|
||||
</div>
|
||||
@ -1,251 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Estimate from {{ company }}</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
.letterhead {
|
||||
text-align: center;
|
||||
padding: 30px 20px;
|
||||
background-color: #ffffff;
|
||||
border-bottom: 3px solid #0066cc;
|
||||
}
|
||||
.letterhead img {
|
||||
max-width: 250px;
|
||||
height: auto;
|
||||
}
|
||||
.company-name {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.content {
|
||||
padding: 40px 30px;
|
||||
}
|
||||
.greeting {
|
||||
font-size: 18px;
|
||||
color: #333333;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.intro-text {
|
||||
font-size: 16px;
|
||||
color: #555555;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.estimate-box {
|
||||
background-color: #f8f9fa;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
margin: 30px 0;
|
||||
}
|
||||
.estimate-label {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.estimate-value {
|
||||
font-size: 16px;
|
||||
color: #333333;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.estimate-value:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.price-section {
|
||||
background-color: #0066cc;
|
||||
color: #ffffff;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.price-label {
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.price-amount {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.additional-section {
|
||||
background-color: #fff9e6;
|
||||
border-left: 4px solid #ffc107;
|
||||
padding: 20px;
|
||||
margin: 30px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.additional-label {
|
||||
font-size: 14px;
|
||||
color: #856404;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.additional-text {
|
||||
font-size: 15px;
|
||||
color: #333333;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.action-buttons {
|
||||
text-align: center;
|
||||
margin: 40px 0;
|
||||
padding: 20px;
|
||||
}
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 14px 28px;
|
||||
margin: 8px;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.btn-accept {
|
||||
background-color: #28a745;
|
||||
color: #ffffff;
|
||||
}
|
||||
.btn-decline {
|
||||
background-color: #dc3545;
|
||||
color: #ffffff;
|
||||
}
|
||||
.btn-call {
|
||||
background-color: #ffc107;
|
||||
color: #333333;
|
||||
}
|
||||
.closing-text {
|
||||
font-size: 16px;
|
||||
color: #555555;
|
||||
line-height: 1.6;
|
||||
margin-top: 30px;
|
||||
}
|
||||
.contact-info {
|
||||
font-size: 15px;
|
||||
color: #0066cc;
|
||||
font-weight: bold;
|
||||
margin-top: 15px;
|
||||
}
|
||||
.footer {
|
||||
background-color: #f8f9fa;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
.footer-text {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@media only screen and (max-width: 600px) {
|
||||
.content {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
.estimate-box {
|
||||
padding: 20px;
|
||||
}
|
||||
.price-amount {
|
||||
font-size: 28px;
|
||||
}
|
||||
.company-name {
|
||||
font-size: 24px;
|
||||
}
|
||||
.btn {
|
||||
display: block;
|
||||
margin: 10px auto;
|
||||
max-width: 250px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<!-- Letterhead Section -->
|
||||
<div class="letterhead">
|
||||
{% if letterhead_image %}
|
||||
<img src="{{ letterhead_image }}" alt="{{ company }} Logo">
|
||||
{% else %}
|
||||
<div class="company-name">{{ company }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="content">
|
||||
<div class="greeting">Hello {{ customer_name }},</div>
|
||||
|
||||
<div class="intro-text">
|
||||
Thank you for considering {{ company }} for your project. We are pleased to provide you with the following estimate for the services requested.
|
||||
</div>
|
||||
|
||||
<!-- Estimate Details Box -->
|
||||
<div class="estimate-box">
|
||||
<div class="estimate-label">Service Location</div>
|
||||
<div class="estimate-value">{{ address }}</div>
|
||||
|
||||
<!-- Price Section -->
|
||||
<div class="price-section">
|
||||
<div class="price-label">Total Estimate</div>
|
||||
<div class="price-amount">{{ price }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Notes (Conditional) -->
|
||||
{% if additional %}
|
||||
<div class="additional-section">
|
||||
<div class="additional-label">Additional Notes</div>
|
||||
<div class="additional-text">{{ additional }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="action-buttons">
|
||||
<a href="{{ base_url }}/api/method/custom_ui.api.public.estimates.update_response?name={{ estimate_name }}&response=Accepted" class="btn btn-accept">Accept</a>
|
||||
<a href="{{ base_url }}/api/method/custom_ui.api.public.estimates.update_response?name={{ estimate_name }}&response=Rejected" class="btn btn-decline">Decline</a>
|
||||
<a href="{{ base_url }}/api/method/custom_ui.api.public.estimates.update_response?name={{ estimate_name }}&response=Requested%20call" class="btn btn-call">Request a Call</a>
|
||||
</div>
|
||||
|
||||
<!-- Closing -->
|
||||
<div class="closing-text">
|
||||
This estimate is valid for 30 days from the date of this email. If you have any questions or would like to proceed with this estimate, please don't hesitate to contact us.
|
||||
{% if company_phone %}
|
||||
<div class="contact-info">Call us at: {{ company_phone }}</div>
|
||||
{% endif %}
|
||||
<br>
|
||||
We look forward to working with you!
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<div class="footer-text">
|
||||
<strong>{{ company }}</strong><br>
|
||||
This is an automated message. Please do not reply directly to this email.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,152 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Invoice - {{ invoice_number }}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background-color: #f4f4f4;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.header h1 {
|
||||
color: #2c3e50;
|
||||
margin: 0;
|
||||
}
|
||||
.content {
|
||||
padding: 20px 0;
|
||||
}
|
||||
.invoice-details {
|
||||
background-color: #ecf0f1;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.invoice-details h2 {
|
||||
margin-top: 0;
|
||||
color: #3498db;
|
||||
}
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
.detail-row:last-child {
|
||||
border-bottom: none;
|
||||
font-weight: bold;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 2px solid #3498db;
|
||||
}
|
||||
.detail-label {
|
||||
font-weight: bold;
|
||||
}
|
||||
.cta-button {
|
||||
display: inline-block;
|
||||
background-color: #27ae60;
|
||||
color: #ffffff;
|
||||
padding: 12px 24px;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #eee;
|
||||
color: #7f8c8d;
|
||||
font-size: 14px;
|
||||
}
|
||||
.note {
|
||||
background-color: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
padding: 10px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Invoice</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Dear {{ customer_name }},</p>
|
||||
<p>Thank you for your business with {{ company_name }}. Please find your invoice details below:</p>
|
||||
|
||||
<div class="invoice-details">
|
||||
<h2>Invoice Details</h2>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Invoice Number:</span>
|
||||
<span>{{ invoice_number }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Invoice Date:</span>
|
||||
<span>{{ invoice_date }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Due Date:</span>
|
||||
<span>{{ due_date }}</span>
|
||||
</div>
|
||||
{% if sales_order %}
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Related Sales Order:</span>
|
||||
<span>{{ sales_order }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Invoice Total:</span>
|
||||
<span>{{ grand_total }}</span>
|
||||
</div>
|
||||
{% if paid_amount and paid_amount != "$0.00" %}
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Amount Paid:</span>
|
||||
<span>{{ paid_amount }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Amount Due:</span>
|
||||
<span>{{ outstanding_amount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if payment_url and outstanding_amount != "$0.00" %}
|
||||
<div class="note">
|
||||
<strong>Payment Required:</strong> There is an outstanding balance on this invoice. Please click the button below to make a secure payment.
|
||||
</div>
|
||||
<a href="{{ payment_url }}" class="cta-button">Pay Now</a>
|
||||
{% else %}
|
||||
<div class="note">
|
||||
<strong>Paid in Full:</strong> This invoice has been paid in full. Thank you!
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p>If you have any questions about this invoice, please don't hesitate to contact us.</p>
|
||||
<p>Best regards,<br>The Team at {{ company_name }}</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>This is an automated email. Please do not reply directly.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,141 +1 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Payment Cancelled</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.payment-container {
|
||||
text-align: center;
|
||||
background-color: #fff;
|
||||
padding: 50px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
position: relative;
|
||||
}
|
||||
.cancelled-icon {
|
||||
font-size: 5rem;
|
||||
color: #e74c3c;
|
||||
margin-bottom: 30px;
|
||||
animation: cancelledAnimation 1.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes cancelledAnimation {
|
||||
0% {
|
||||
transform: scale(0) rotate(180deg);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
70% {
|
||||
transform: scale(0.9) rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.payment-title {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.payment-message {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 30px;
|
||||
color: #666;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.cancelled-notice {
|
||||
background-color: #ffeaea;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
margin-top: 30px;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.cancelled-notice h3 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #721c24;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cancelled-notice p {
|
||||
margin: 0;
|
||||
color: #721c24;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.next-steps {
|
||||
background-color: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
margin-top: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.next-steps h4 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #333;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.next-steps ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.next-steps li {
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="payment-container">
|
||||
<div class="cancelled-icon">✕</div>
|
||||
<h1 class="payment-title">Payment Cancelled</h1>
|
||||
<p class="payment-message">Your payment has been cancelled.</p>
|
||||
|
||||
<div class="cancelled-notice">
|
||||
<h3>Payment Not Processed</h3>
|
||||
<p>No charges have been made to your account. If you cancelled by mistake or need assistance, please try again or contact support.</p>
|
||||
</div>
|
||||
|
||||
<div class="next-steps">
|
||||
<h4>What happens next?</h4>
|
||||
<ul>
|
||||
<li>No payment has been processed</li>
|
||||
<li>You can safely close this window</li>
|
||||
<li>Try your payment again if needed</li>
|
||||
<li>Contact us if you need help</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
<p>Payment cancelled.</p>
|
||||
|
||||
@ -1,141 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Payment Cancelled</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.payment-container {
|
||||
text-align: center;
|
||||
background-color: #fff;
|
||||
padding: 50px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
position: relative;
|
||||
}
|
||||
.cancelled-icon {
|
||||
font-size: 5rem;
|
||||
color: #e74c3c;
|
||||
margin-bottom: 30px;
|
||||
animation: cancelledAnimation 1.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes cancelledAnimation {
|
||||
0% {
|
||||
transform: scale(0) rotate(180deg);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
70% {
|
||||
transform: scale(0.9) rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.payment-title {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.payment-message {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 30px;
|
||||
color: #666;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.cancelled-notice {
|
||||
background-color: #ffeaea;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
margin-top: 30px;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.cancelled-notice h3 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #721c24;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cancelled-notice p {
|
||||
margin: 0;
|
||||
color: #721c24;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.next-steps {
|
||||
background-color: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
margin-top: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.next-steps h4 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #333;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.next-steps ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.next-steps li {
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="payment-container">
|
||||
<div class="cancelled-icon">✕</div>
|
||||
<h1 class="payment-title">Payment Cancelled</h1>
|
||||
<p class="payment-message">Your payment has been cancelled.</p>
|
||||
|
||||
<div class="cancelled-notice">
|
||||
<h3>Payment Not Processed</h3>
|
||||
<p>No charges have been made to your account. If you cancelled by mistake or need assistance, please try again or contact support.</p>
|
||||
</div>
|
||||
|
||||
<div class="next-steps">
|
||||
<h4>What happens next?</h4>
|
||||
<ul>
|
||||
<li>No payment has been processed</li>
|
||||
<li>You can safely close this window</li>
|
||||
<li>Try your payment again if needed</li>
|
||||
<li>Contact us if you need help</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,212 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Payment Successful</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.payment-container {
|
||||
text-align: center;
|
||||
background-color: #fff;
|
||||
padding: 50px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
position: relative;
|
||||
}
|
||||
.success-icon {
|
||||
font-size: 5rem;
|
||||
color: #00b894;
|
||||
margin-bottom: 30px;
|
||||
animation: checkmarkAnimation 1.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes checkmarkAnimation {
|
||||
0% {
|
||||
transform: scale(0) rotate(-180deg);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
70% {
|
||||
transform: scale(0.9) rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.payment-title {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.payment-message {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 30px;
|
||||
color: #666;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.advance-notice {
|
||||
background-color: #e3f2fd;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-top: 20px;
|
||||
border-left: 4px solid #2196f3;
|
||||
}
|
||||
|
||||
.contact-section {
|
||||
background-color: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
margin-top: 30px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.contact-section h3 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #333;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.contact-section > p {
|
||||
margin: 0 0 15px 0;
|
||||
color: #666;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.contact-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.contact-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.contact-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.contact-label {
|
||||
font-weight: 500;
|
||||
color: #495057;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.contact-value {
|
||||
font-weight: 400;
|
||||
color: #6c757d;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.contact-value a {
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.contact-value a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="payment-container">
|
||||
<div class="success-icon">✓</div>
|
||||
{% if reference_doc %}
|
||||
<h1 class="payment-title">
|
||||
{% if company_doc and company_doc.company_name %}
|
||||
{{ company_doc.company_name }}
|
||||
{% else %}
|
||||
Payment Received
|
||||
{% endif %}
|
||||
</h1>
|
||||
{% if reference_doc.doctype == "Sales Order" %}
|
||||
<p class="payment-message">
|
||||
{% if reference_doc.customer %}
|
||||
Thank you {{ reference_doc.customer }} for your advance payment!
|
||||
{% else %}
|
||||
Thank you for your advance payment!
|
||||
{% endif %}
|
||||
</p>
|
||||
<div class="advance-notice">
|
||||
<p>The remaining balance will be invoiced once the project is complete.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="payment-message">
|
||||
{% if reference_doc.customer %}
|
||||
Thank you {{ reference_doc.customer }} for your payment!
|
||||
{% else %}
|
||||
Thank you for your payment!
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if company_doc %}
|
||||
<div class="contact-section">
|
||||
<h3>Have Questions?</h3>
|
||||
<p>We're here to help! Contact us if you need assistance.</p>
|
||||
<div class="contact-details">
|
||||
{% if company_doc.company_name %}
|
||||
<div class="contact-row">
|
||||
<span class="contact-label">Company:</span>
|
||||
<span class="contact-value">{{ company_doc.company_name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if company_doc.phone_no %}
|
||||
<div class="contact-row">
|
||||
<span class="contact-label">Phone:</span>
|
||||
<span class="contact-value">{{ company_doc.phone_no }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if company_doc.email %}
|
||||
<div class="contact-row">
|
||||
<span class="contact-label">Email:</span>
|
||||
<span class="contact-value"><a href="mailto:{{ company_doc.email }}">{{ company_doc.email }}</a></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if company_doc.website %}
|
||||
<div class="contact-row">
|
||||
<span class="contact-label">Website:</span>
|
||||
<span class="contact-value"><a href="{{ company_doc.website }}" target="_blank">{{ company_doc.website }}</a></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<h1 class="payment-title">Payment Received</h1>
|
||||
<p class="payment-message">Thank you for your payment!</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,18 +0,0 @@
|
||||
import frappe
|
||||
|
||||
def get_context(context):
|
||||
context.no_cache = 1
|
||||
|
||||
context.title = "Payment Received"
|
||||
context.message = "Thank you for your payment! Your transaction was successful."
|
||||
|
||||
context.session_id = frappe.form_dict.get("session_id")
|
||||
|
||||
payment_entry = frappe.get_value("Payment Entry", {"reference_no": context.session_id}, "name")
|
||||
payment_entry_doc = frappe.get_doc("Payment Entry", payment_entry) if payment_entry else None
|
||||
reference = payment_entry_doc.references[0] if payment_entry_doc and payment_entry_doc.references else None
|
||||
reference_doc = frappe.get_doc(reference.reference_doctype, reference.reference_name) if reference else None
|
||||
company_doc = frappe.get_doc("Company", reference_doc.company) if reference_doc and reference_doc.company else None
|
||||
context.reference_doc = reference_doc.as_dict() if reference_doc else None
|
||||
context.company_doc = company_doc.as_dict() if company_doc else None
|
||||
return context
|
||||
1
custom_ui/www/successful_payment.html
Normal file
1
custom_ui/www/successful_payment.html
Normal file
@ -0,0 +1 @@
|
||||
<p>Thank you for your payment!</p>
|
||||
@ -1,8 +0,0 @@
|
||||
services:
|
||||
mailhog:
|
||||
image: mailhog/mailhog:latest
|
||||
container_name: mailhog
|
||||
ports:
|
||||
- "8025:8025" # MailHog web UI
|
||||
- "1025:1025" # SMTP server
|
||||
restart: unless-stopped
|
||||
@ -1,151 +0,0 @@
|
||||
# DocType Field Differences Report
|
||||
|
||||
## Fields present in LOCAL but missing in STAGE
|
||||
|
||||
### Address
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| is_service_address | Is Service Address | Check | None | 0 | 0 | False |
|
||||
|
||||
### Event
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| participants | Participants | Section Break | None | 0 | 0 | False |
|
||||
|
||||
### Project
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| service_appointment | Service Appointment | Link | Service Address 2 | 0 | 0 | False |
|
||||
| tasks | Tasks | Table | Project Task Link | 0 | 0 | False |
|
||||
| ready_to_schedule | Ready to Schedule | Check | None | 0 | 0 | False |
|
||||
|
||||
### Project Template
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| bid_meeting_note_form | Bid Meeting Note Form | Link | Bid Meeting Note Form | 0 | 0 | False |
|
||||
| item_groups | Item Groups | Data | None | 0 | 0 | False |
|
||||
|
||||
### Quotation
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| from_template | From Template | Link | Quotation Template | 0 | 0 | False |
|
||||
| project_template | Project Template | Link | Project Template | 0 | 0 | False |
|
||||
|
||||
### Sales Invoice
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| project_template | Project Template | Link | Project Template | 0 | 0 | False |
|
||||
| job_address | Job Address | Link | Address | 0 | 0 | False |
|
||||
|
||||
### Task
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| customer | Customer | Link | Customer | 0 | 0 | False |
|
||||
|
||||
### Task Type
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| base_date | Base Date | Select | Start
|
||||
End
|
||||
Completion
|
||||
Creation | 1 | 0 | False |
|
||||
| offset_days | Offset Days | Int | None | 1 | 0 | False |
|
||||
| skip_weekends | Skip Weekends | Check | None | 0 | 0 | False |
|
||||
| skip_holidays | Skip Holidays | Check | None | 0 | 0 | False |
|
||||
| logic_key | Logic Key | Data | None | 0 | 0 | False |
|
||||
| offset_direction | Offset Direction | Select | After
|
||||
Before | 1 | 0 | False |
|
||||
| title | Title | Data | None | 1 | 0 | False |
|
||||
| days | Days | Int | None | 0 | 0 | False |
|
||||
| calculate_from | Calculate From | Select | Service Address 2
|
||||
Project
|
||||
Task | 1 | 0 | False |
|
||||
| trigger | Trigger | Select | Scheduled
|
||||
Completed
|
||||
Created | 1 | 0 | False |
|
||||
| task_type_calculate_from | Task Type For Task Calculate From | Link | Task Type | 0 | 0 | False |
|
||||
| work_type | Work Type | Select | Admin
|
||||
Labor
|
||||
QA | 1 | 0 | False |
|
||||
| no_due_date | No Due Date | Check | None | 0 | 0 | False |
|
||||
| triggering_doctype | Triggering Doctype | Select | Service Address 2
|
||||
Project
|
||||
Task | 1 | 0 | False |
|
||||
|
||||
## Fields present in STAGE but missing in LOCAL
|
||||
|
||||
### Communication Link
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| communication_date | Communication Date | Datetime | None | 0 | 0 | False |
|
||||
|
||||
### Event
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| notifications | Notifications | Table | Event Notifications | 0 | 0 | False |
|
||||
| location | Location | Data | None | 0 | 0 | False |
|
||||
| attending | Attending | Select |
|
||||
Yes
|
||||
No
|
||||
Maybe | 0 | 0 | False |
|
||||
| participants_tab | Participants | Tab Break | None | 0 | 0 | False |
|
||||
| links_tab | Links | Tab Break | None | 0 | 0 | False |
|
||||
| notifications_tab | Notifications | Tab Break | None | 0 | 0 | False |
|
||||
|
||||
### Event Notifications
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| type | Type | Select | Notification
|
||||
Email | 0 | 0 | False |
|
||||
| before | Before | Int | None | 0 | 0 | False |
|
||||
| interval | Interval | Select | None | 0 | 0 | False |
|
||||
| time | Time | Time | None | 0 | 0 | False |
|
||||
|
||||
### Event Participants
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| attending | Attending | Select |
|
||||
Yes
|
||||
No
|
||||
Maybe | 0 | 0 | False |
|
||||
|
||||
### Job Opening
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| job_opening_template | Job Opening Template | Link | Job Opening Template | 0 | 0 | False |
|
||||
|
||||
### Job Opening Template
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| template_title | Template Title | Data | None | 1 | 0 | False |
|
||||
| department | Department | Link | Department | 0 | 0 | False |
|
||||
| column_break_wkcr | None | Column Break | None | 0 | 0 | False |
|
||||
| employment_type | Employment Type | Link | Employment Type | 0 | 0 | False |
|
||||
| location | Location | Link | Branch | 0 | 0 | False |
|
||||
| section_break_dwfh | None | Section Break | None | 0 | 0 | False |
|
||||
| description | Description | Text Editor | None | 0 | 0 | False |
|
||||
|
||||
### Payment Ledger Entry
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| project | Project | Link | Project | 0 | 0 | False |
|
||||
|
||||
### Quotation Item
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| ordered_qty | Ordered Qty | Float | None | 1 | 1 | False |
|
||||
|
||||
### Salary Structure Assignment
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| leave_encashment_amount_per_day | Leave Encashment Amount Per Day | Currency | currency | 0 | 0 | False |
|
||||
|
||||
### Selling Settings
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| set_zero_rate_for_expired_batch | Set Incoming Rate as Zero for Expired Batch | Check | None | 0 | 0 | False |
|
||||
|
||||
### Service Appointment
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| custom_location_of_meeting | Service Address | Link | Address | 0 | 0 | False |
|
||||
@ -19,9 +19,6 @@ 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";
|
||||
@ -232,8 +229,8 @@ class Api {
|
||||
// ESTIMATE / QUOTATION METHODS
|
||||
// ============================================================================
|
||||
|
||||
static async getQuotationItems(projectTemplate) {
|
||||
return await this.request("custom_ui.api.db.estimates.get_quotation_items", { projectTemplate });
|
||||
static async getQuotationItems() {
|
||||
return await this.request("custom_ui.api.db.estimates.get_quotation_items");
|
||||
}
|
||||
|
||||
static async getEstimateFromAddress(fullAddress) {
|
||||
@ -660,22 +657,6 @@ 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
|
||||
// ============================================================================
|
||||
|
||||
@ -17,12 +17,6 @@
|
||||
<span class="date-text">{{ weekDisplayText }}</span>
|
||||
<v-icon right size="small">mdi-calendar</v-icon>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
@click="nextWeek"
|
||||
icon="mdi-chevron-right"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
></v-btn>
|
||||
<v-btn @click="goToThisWeek" variant="outlined" size="small" class="ml-4">
|
||||
This Week
|
||||
</v-btn>
|
||||
@ -997,14 +991,13 @@ const handleDrop = async (event, foremanId, date) => {
|
||||
await Api.updateServiceAppointmentScheduledDates(
|
||||
draggedService.value.name,
|
||||
date,
|
||||
date, // Reset to single day when moved
|
||||
draggedService.value.expectedEndDate, // Keep the same end date
|
||||
foreman.name
|
||||
);
|
||||
// Update the scheduled job
|
||||
scheduledServices.value[scheduledIndex] = {
|
||||
...scheduledServices.value[scheduledIndex],
|
||||
expectedStartDate: date,
|
||||
expectedEndDate: date, // Reset to single day
|
||||
foreman: foreman.name
|
||||
};
|
||||
notifications.addSuccess("Job moved successfully!");
|
||||
@ -1181,10 +1174,9 @@ const handleResize = (event) => {
|
||||
// Calculate proposed end date by adding days to the CURRENT end date
|
||||
let proposedEndDate = addDays(currentEndDate, daysToAdd);
|
||||
|
||||
// Don't allow shrinking before the start date
|
||||
const startDate = resizingJob.value.expectedStartDate;
|
||||
if (parseLocalDate(proposedEndDate) < parseLocalDate(startDate)) {
|
||||
proposedEndDate = startDate;
|
||||
// Don't allow shrinking before the current end date (minimum stay at current)
|
||||
if (daysToAdd < 0) {
|
||||
proposedEndDate = currentEndDate;
|
||||
}
|
||||
|
||||
let newEndDate = proposedEndDate;
|
||||
@ -1314,14 +1306,13 @@ const fetchServiceAppointments = async (currentDate) => {
|
||||
{
|
||||
"expectedStartDate": ["<=", endDate],
|
||||
"expectedEndDate": [">=", startDate],
|
||||
"status": ["not in", ["Canceled", "Open"]]
|
||||
"status": ["not in", ["Canceled"]]
|
||||
}
|
||||
);
|
||||
unscheduledServices.value = await Api.getServiceAppointments(
|
||||
[companyStore.currentCompany],
|
||||
{
|
||||
"status": "Open",
|
||||
"ready_to_schedule": 1
|
||||
"status": "Open"
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@ -1,167 +0,0 @@
|
||||
<template>
|
||||
<div class="items-container">
|
||||
<div v-if="items.length === 0" class="no-items-message">
|
||||
<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': 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="isItemSelected(item.itemCode) ? 'Selected' : 'Select'"
|
||||
:icon="isItemSelected(item.itemCode) ? 'pi pi-check' : 'pi pi-plus'"
|
||||
@click.stop="handleItemClick(item, $event)"
|
||||
size="small"
|
||||
:severity="isItemSelected(item.itemCode) ? 'success' : 'secondary'"
|
||||
class="select-item-button"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="item.description" class="item-description">
|
||||
{{ item.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, computed, shallowRef } from "vue";
|
||||
import Button from "primevue/button";
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => []
|
||||
},
|
||||
selectedItems: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
emptyMessage: {
|
||||
type: String,
|
||||
default: "No items found in this category"
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select']);
|
||||
|
||||
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 = internalSelection.value.findIndex(i => i.itemCode === item.itemCode);
|
||||
const newSet = new Set(selectionSet.value);
|
||||
|
||||
if (index >= 0) {
|
||||
internalSelection.value.splice(index, 1);
|
||||
newSet.delete(item.itemCode);
|
||||
} else {
|
||||
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', [...internalSelection.value]);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.items-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.no-items-message {
|
||||
text-align: center;
|
||||
padding: 3rem 2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.no-items-message i {
|
||||
font-size: 3em;
|
||||
color: #ccc;
|
||||
margin-bottom: 1rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.no-items-message p {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
background-color: #fafafa;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.item-card:hover {
|
||||
background-color: #f0f0f0;
|
||||
border-color: #2196f3;
|
||||
}
|
||||
|
||||
.item-selected {
|
||||
background-color: #e3f2fd;
|
||||
border-color: #2196f3;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.item-card-header {
|
||||
display: grid;
|
||||
grid-template-columns: 150px 1fr 120px 100px;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.item-code {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.item-price {
|
||||
font-weight: 600;
|
||||
color: #2196f3;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.select-item-button {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.item-description {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
@ -1,649 +0,0 @@
|
||||
<template>
|
||||
<Modal
|
||||
:visible="visible"
|
||||
@update:visible="$emit('update:visible', $event)"
|
||||
@close="handleClose"
|
||||
:options="{ showActions: false, maxWidth: '90vw', width: '1350px' }"
|
||||
class="add-item-modal"
|
||||
>
|
||||
<template #title>
|
||||
<div class="modal-title-container">
|
||||
<span>Add Item</span>
|
||||
<span v-if="selectedItemsCount > 0" class="selection-badge">{{ selectedItemsCount }} selected</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="modal-content items-modal-content">
|
||||
<div class="search-section">
|
||||
<label for="item-search" class="field-label">Search Items</label>
|
||||
<InputText
|
||||
id="item-search"
|
||||
v-model="searchTerm"
|
||||
placeholder="Search by item code or name..."
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<!-- Packages tab with nested sub-tabs -->
|
||||
<TabPanel v-if="packageGroups.length > 0" value="Packages">
|
||||
<Tabs v-model="activePackageTab" class="nested-tabs">
|
||||
<TabList>
|
||||
<Tab v-for="packageGroup in packageGroups" :key="packageGroup" :value="packageGroup">{{ packageGroup }}</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel v-for="packageGroup in packageGroups" :key="packageGroup" :value="packageGroup">
|
||||
<div class="package-items-container">
|
||||
<div v-for="item in getFilteredPackageItemsForGroup(packageGroup)" :key="item.itemCode" class="package-item" :class="{ 'package-item-selected': item._selected }">
|
||||
<div class="package-item-header">
|
||||
<Button
|
||||
:icon="isPackageExpanded(item.itemCode) ? 'pi pi-chevron-down' : 'pi pi-chevron-right'"
|
||||
@click="togglePackageExpansion(item.itemCode)"
|
||||
text
|
||||
rounded
|
||||
class="expand-button"
|
||||
/>
|
||||
<span class="package-item-code">{{ item.itemCode }}</span>
|
||||
<span class="package-item-name">{{ item.itemName }}</span>
|
||||
<span class="package-item-price">${{ item.standardRate?.toFixed(2) || '0.00' }}</span>
|
||||
<Button
|
||||
:label="item._selected ? 'Selected' : 'Select'"
|
||||
:icon="item._selected ? 'pi pi-check' : 'pi pi-plus'"
|
||||
@click="handleItemSelection(item)"
|
||||
size="small"
|
||||
:severity="item._selected ? 'success' : 'secondary'"
|
||||
class="add-package-button"
|
||||
/>
|
||||
</div>
|
||||
<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-wrapper">
|
||||
<BomItem :item="bomItem" :parentPath="item.itemCode" :level="0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</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 -->
|
||||
<ItemSelector v-else :items="[]" empty-message="No items available. Please select a Project Template first." />
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<Button
|
||||
label="Clear Selection"
|
||||
@click="clearSelection"
|
||||
severity="secondary"
|
||||
:disabled="selectedItemsCount === 0"
|
||||
/>
|
||||
<Button
|
||||
:label="`Add ${selectedItemsCount} Item${selectedItemsCount !== 1 ? 's' : ''}`"
|
||||
@click="addItems"
|
||||
icon="pi pi-plus"
|
||||
:disabled="selectedItemsCount === 0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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";
|
||||
import Button from "primevue/button";
|
||||
import Tabs from "primevue/tabs";
|
||||
import TabList from "primevue/tablist";
|
||||
import Tab from "primevue/tab";
|
||||
import TabPanels from "primevue/tabpanels";
|
||||
import TabPanel from "primevue/tabpanel";
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
quotationItems: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:visible', 'add-items']);
|
||||
|
||||
const searchTerm = ref("");
|
||||
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 [];
|
||||
// Get all keys except 'Packages'
|
||||
const groups = Object.keys(props.quotationItems).filter(key => key !== 'Packages').sort();
|
||||
return groups;
|
||||
});
|
||||
|
||||
const packageGroups = computed(() => {
|
||||
if (!props.quotationItems?.Packages || typeof props.quotationItems.Packages !== 'object') return [];
|
||||
return Object.keys(props.quotationItems.Packages).sort();
|
||||
});
|
||||
|
||||
// Active tabs with default to Packages
|
||||
const activeItemTab = computed({
|
||||
get: () => _activeItemTab.value || (packageGroups.value.length > 0 ? "Packages" : itemGroups.value[0]) || "",
|
||||
set: (val) => { _activeItemTab.value = val; }
|
||||
});
|
||||
|
||||
const activePackageTab = computed({
|
||||
get: () => _activePackageTab.value || packageGroups.value[0] || "",
|
||||
set: (val) => { _activePackageTab.value = val; }
|
||||
});
|
||||
|
||||
const _activeItemTab = ref("");
|
||||
const _activePackageTab = ref("");
|
||||
|
||||
const getFilteredItemsForGroup = (group) => {
|
||||
if (!props.quotationItems || typeof props.quotationItems !== 'object') return [];
|
||||
|
||||
let items = [];
|
||||
|
||||
// Get items from the specified group
|
||||
if (group && props.quotationItems[group]) {
|
||||
items = [...props.quotationItems[group]];
|
||||
}
|
||||
|
||||
// Filter by search term
|
||||
if (searchTerm.value.trim()) {
|
||||
const term = searchTerm.value.toLowerCase();
|
||||
items = items.filter(
|
||||
(item) =>
|
||||
item.itemCode?.toLowerCase().includes(term) ||
|
||||
item.itemName?.toLowerCase().includes(term),
|
||||
);
|
||||
}
|
||||
|
||||
// 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 [];
|
||||
|
||||
let items = [];
|
||||
|
||||
// Get items from the specified package group
|
||||
if (packageGroup && props.quotationItems.Packages[packageGroup]) {
|
||||
items = [...props.quotationItems.Packages[packageGroup]];
|
||||
}
|
||||
|
||||
// Filter by search term
|
||||
if (searchTerm.value.trim()) {
|
||||
const term = searchTerm.value.toLowerCase();
|
||||
items = items.filter(
|
||||
(item) =>
|
||||
item.itemCode?.toLowerCase().includes(term) ||
|
||||
item.itemName?.toLowerCase().includes(term),
|
||||
);
|
||||
}
|
||||
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
id: item.itemCode,
|
||||
_selected: selectedItemsInModal.value.has(item.itemCode)
|
||||
}));
|
||||
};
|
||||
|
||||
const selectedItemsCount = computed(() => selectedItemsInModal.value.size);
|
||||
|
||||
const togglePackageExpansion = (itemCode) => {
|
||||
const newExpanded = new Set(expandedPackageItems.value);
|
||||
if (newExpanded.has(itemCode)) {
|
||||
newExpanded.delete(itemCode);
|
||||
} else {
|
||||
newExpanded.add(itemCode);
|
||||
}
|
||||
expandedPackageItems.value = newExpanded;
|
||||
};
|
||||
|
||||
const isPackageExpanded = (itemCode) => {
|
||||
return expandedPackageItems.value.has(itemCode);
|
||||
};
|
||||
|
||||
const handleItemSelection = (itemOrRows) => {
|
||||
// Handle both single item (from package cards) and array (from DataTable)
|
||||
if (Array.isArray(itemOrRows)) {
|
||||
// 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
|
||||
const newSelection = new Set(selectedItemsInModal.value);
|
||||
if (newSelection.has(itemOrRows.itemCode)) {
|
||||
newSelection.delete(itemOrRows.itemCode);
|
||||
} else {
|
||||
newSelection.add(itemOrRows.itemCode);
|
||||
}
|
||||
selectedItemsInModal.value = newSelection;
|
||||
}
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
selectedItemsInModal.value = new Set();
|
||||
};
|
||||
|
||||
const addItems = () => {
|
||||
// Get all selected items from all categories
|
||||
const allItems = [];
|
||||
|
||||
// Collect from regular categories
|
||||
if (props.quotationItems && typeof props.quotationItems === 'object') {
|
||||
Object.keys(props.quotationItems).forEach(key => {
|
||||
if (key !== 'Packages' && Array.isArray(props.quotationItems[key])) {
|
||||
props.quotationItems[key].forEach(item => {
|
||||
if (selectedItemsInModal.value.has(item.itemCode)) {
|
||||
allItems.push(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Collect from Packages sub-categories
|
||||
if (props.quotationItems.Packages && typeof props.quotationItems.Packages === 'object') {
|
||||
Object.keys(props.quotationItems.Packages).forEach(subKey => {
|
||||
if (Array.isArray(props.quotationItems.Packages[subKey])) {
|
||||
props.quotationItems.Packages[subKey].forEach(item => {
|
||||
if (selectedItemsInModal.value.has(item.itemCode)) {
|
||||
allItems.push(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (allItems.length > 0) {
|
||||
emit('add-items', allItems);
|
||||
selectedItemsInModal.value = new Set();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
selectedItemsInModal.value = new Set();
|
||||
searchTerm.value = "";
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
// Watch modal visibility to reset state when closing
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (newVal) {
|
||||
// Modal is opening - reset to first tabs
|
||||
_activeItemTab.value = "";
|
||||
_activePackageTab.value = "";
|
||||
} else {
|
||||
// Modal is closing - reset state
|
||||
selectedItemsInModal.value = new Set();
|
||||
searchTerm.value = "";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.items-modal-content {
|
||||
height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tabs-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tabs-container :deep(.p-tabs) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tabs-container :deep(.p-tabpanels) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tabs-container :deep(.p-tabpanel) {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-section {
|
||||
/* margin removed - parent gap handles spacing */
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tip-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background-color: #e3f2fd;
|
||||
border: 1px solid #2196f3;
|
||||
border-radius: 4px;
|
||||
color: #1565c0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tip-section i {
|
||||
color: #2196f3;
|
||||
}
|
||||
|
||||
.tip-section kbd {
|
||||
background-color: #fff;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
font-family: monospace;
|
||||
font-size: 0.85em;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.modal-title-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.modal-title-container :deep(.p-tab) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.selection-badge {
|
||||
background-color: #2196f3;
|
||||
color: white;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nested-tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nested-tabs :deep(.p-tabs) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nested-tabs :deep(.p-tabpanels) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nested-tabs :deep(.p-tabpanel) {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.package-items-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.package-item {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
background-color: #fafafa;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.package-item:hover {
|
||||
background-color: #f0f0f0;
|
||||
border-color: #2196f3;
|
||||
}
|
||||
|
||||
.package-item-selected {
|
||||
background-color: #e3f2fd;
|
||||
border-color: #2196f3;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.package-item-header {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 120px 1fr 100px 80px;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.expand-button {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.package-item-code {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.package-item-name {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.package-item-price {
|
||||
font-weight: 600;
|
||||
color: #2196f3;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.add-package-button {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.bom-details {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background-color: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.bom-header {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.bom-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.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;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.bom-item-name {
|
||||
color: #555;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.bom-item-qty {
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.nested-bom {
|
||||
background-color: #fafafa;
|
||||
border-left: 2px solid #e0e0e0;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user