diff --git a/Screenshot from 2026-02-04 12-41-31.png b/Screenshot from 2026-02-04 12-41-31.png
new file mode 100644
index 0000000..6df5795
Binary files /dev/null and b/Screenshot from 2026-02-04 12-41-31.png differ
diff --git a/custom_ui/api/db/bid_meetings.py b/custom_ui/api/db/bid_meetings.py
index e12636a..b158739 100644
--- a/custom_ui/api/db/bid_meetings.py
+++ b/custom_ui/api/db/bid_meetings.py
@@ -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"]:
diff --git a/custom_ui/api/db/estimates.py b/custom_ui/api/db/estimates.py
index 678200b..86c1d2e 100644
--- a/custom_ui/api/db/estimates.py
+++ b/custom_ui/api/db/estimates.py
@@ -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
+from custom_ui.services import ItemService, DbService, ClientService, AddressService, ContactService, EstimateService, ItemService
+from frappe.email.doctype.email_template.email_template import get_email_template
# ===============================================================================
# ESTIMATES & INVOICES API METHODS
@@ -86,11 +86,25 @@ def get_estimate_table_data(filters={}, sortings=[], page=1, page_size=10):
@frappe.whitelist()
-def get_quotation_items():
+def get_quotation_items(project_template:str = None):
"""Get all available quotation items."""
try:
- items = frappe.get_all("Item", fields=["*"], filters={"item_group": "SNW-S"})
- return build_success_response(items)
+ 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)
except Exception as e:
return build_error_response(str(e), 500)
@@ -131,6 +145,7 @@ def get_estimate(estimate_name):
est_dict["address_details"] = address_doc
est_dict["history"] = get_doc_history("Quotation", estimate_name)
+ est_dict["items"] = [ItemService.get_full_dict(item.item_code) for item in estimate.items]
return build_success_response(est_dict)
except Exception as e:
@@ -177,7 +192,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)
@@ -196,21 +211,71 @@ def send_estimate_email(estimate_name):
if not email:
return build_error_response("No email found for the customer or address.", 400)
- # 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)
+ # 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
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,
- content=message,
+ message=message,
doctype="Quotation",
name=quotation.name,
read_receipt=1,
@@ -218,11 +283,14 @@ 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:
@@ -255,45 +323,6 @@ 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."""
@@ -448,6 +477,7 @@ 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)
})
@@ -492,6 +522,7 @@ 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)
})
@@ -502,7 +533,9 @@ def upsert_estimate(data):
# AddressService.append_link(data.get("address_name"), "quotations", "quotation", new_estimate.name)
# ClientService.append_link(data.get("customer"), "quotations", "quotation", new_estimate.name)
print("DEBUG: New estimate created with name:", new_estimate.name)
- return build_success_response(new_estimate.as_dict())
+ dict = new_estimate.as_dict()
+ dict["items"] = [ItemService.get_full_dict(item.item_code) for item in new_estimate.items]
+ return build_success_response(dict)
except Exception as e:
print(f"DEBUG: Error in upsert_estimate: {str(e)}")
return build_error_response(str(e), 500)
diff --git a/custom_ui/api/db/items.py b/custom_ui/api/db/items.py
new file mode 100644
index 0000000..5bf397b
--- /dev/null
+++ b/custom_ui/api/db/items.py
@@ -0,0 +1,80 @@
+import frappe
+import json
+from custom_ui.models import PackageCreationData
+from custom_ui.services import ProjectService, ItemService
+from custom_ui.db_utils import build_error_response, build_success_response
+
+
+@frappe.whitelist()
+def get_by_project_template(project_template: str) -> dict:
+ """Retrieve items associated with a given project template."""
+ print(f"DEBUG: Getting items for Project Template {project_template}")
+ item_groups = ProjectService.get_project_item_groups(project_template)
+ items = ItemService.get_items_by_groups(item_groups)
+ print(f"DEBUG: Retrieved {len(items)} items for Project Template {project_template}")
+ categorized_items = ItemService.build_category_dict(items)
+ return build_success_response(categorized_items)
+
+@frappe.whitelist()
+def save_as_package_item(data):
+ """Save a new Package Item based on the provided data."""
+ from custom_ui.models import BOMItem
+ data = json.loads(data)
+ print(f"DEBUG: Saving Package Item with data: {data}")
+ # Map 'category' to 'item_group' for the model
+ data['item_group'] = data.pop('category')
+ # Convert items dictionaries to BOMItem instances
+ data['items'] = [
+ BOMItem(
+ item_code=item['item_code'],
+ qty=item['qty'],
+ uom=item['uom']
+ ) for item in data['items']
+ ]
+ data = PackageCreationData(**data)
+ item = frappe.get_doc({
+ "doctype": "Item",
+ "item_code": ItemService.build_item_code(data.code_prefix, data.package_name),
+ "item_name": data.package_name,
+ "is_stock_item": 0,
+ "item_group": data.item_group,
+ "description": data.description,
+ "standard_rate": data.rate or 0.0,
+ "company": data.company,
+ "has_variants": 0,
+ "stock_uom": "Nos",
+ "is_sales_item": 1,
+ "is_purchase_item": 0,
+ "is_pro_applicable": 0,
+ "is_fixed_asset": 0,
+ "is_service_item": 0
+ }).insert()
+ bom = frappe.get_doc({
+ "doctype": "BOM",
+ "item": item.name,
+ "uom": "Nos",
+ "is_active": 1,
+ "is_default": 1,
+ "items": [{
+ "item_code": bom_item.item_code,
+ "qty": bom_item.qty,
+ "uom": bom_item.uom
+ } for bom_item in data.items]
+ }).insert()
+ bom.submit()
+ item.reload() # Refresh to get latest version after BOM submission
+ item.default_bom = bom.name
+ item.save()
+ print(f"DEBUG: Created Package Item with name: {item.name}")
+ item_dict = item.as_dict()
+ item_dict["bom"] = ItemService.get_full_bom_dict(item.item_code) # Attach BOM details to the item dict
+ return build_success_response(item_dict)
+
+@frappe.whitelist()
+def get_item_categories():
+ """Retrieve all item groups for categorization."""
+ print("DEBUG: Getting item categories")
+ item_groups = frappe.get_all("Item Group", pluck="name")
+ print(f"DEBUG: Retrieved item categories: {item_groups}")
+ return build_success_response(item_groups)
+
diff --git a/custom_ui/api/db/jobs.py b/custom_ui/api/db/jobs.py
index affeb74..42a717d 100644
--- a/custom_ui/api/db/jobs.py
+++ b/custom_ui/api/db/jobs.py
@@ -1,6 +1,6 @@
import frappe, json
-from custom_ui.db_utils import process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, build_error_response
-from custom_ui.services import AddressService, ClientService, ServiceAppointmentService
+from custom_ui.db_utils import process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, build_error_response, process_sorting
+from custom_ui.services import AddressService, ClientService, ServiceAppointmentService, ProjectService
from frappe.utils import getdate
# ===============================================================================
@@ -119,13 +119,7 @@ def get_job(job_id=""):
"""Get particular Job from DB"""
print("DEBUG: Loading Job from database:", job_id)
try:
- project = frappe.get_doc("Project", job_id)
- address_doc = AddressService.get_or_throw(project.job_address)
- project = project.as_dict()
- project["job_address"] = address_doc
- project["client"] = ClientService.get_client_or_throw(project.customer)
- task_names = frappe.get_all("Task", filters={"project": job_id})
- project["tasks"] = [frappe.get_doc("Task", task_name).as_dict() for task_name in task_names]
+ project = ProjectService.get_full_project_details(job_id)
return build_success_response(project)
except Exception as e:
return build_error_response(str(e), 500)
@@ -187,6 +181,8 @@ def get_job_task_list(job_id=""):
def get_jobs_table_data(filters={}, sortings=[], page=1, page_size=10):
"""Get paginated job table data with filtering and sorting support."""
print("DEBUG: Raw job options received:", filters, sortings, page, page_size)
+ filters = json.loads(filters) if isinstance(filters, str) else filters
+ sortings = json.loads(sortings) if isinstance(sortings, str) else sortings
processed_filters, processed_sortings, is_or, page, page_size = process_query_conditions(filters, sortings, page, page_size)
diff --git a/custom_ui/api/db/service_appointments.py b/custom_ui/api/db/service_appointments.py
index 2ac4b63..d02e707 100644
--- a/custom_ui/api/db/service_appointments.py
+++ b/custom_ui/api/db/service_appointments.py
@@ -2,6 +2,15 @@ import frappe, json
from custom_ui.db_utils import build_success_response, build_error_response
from custom_ui.services import ServiceAppointmentService
+@frappe.whitelist()
+def get_service_appointment(service_appointment_name):
+ """Get a single Service Appointment by name."""
+ try:
+ service_appointment = ServiceAppointmentService.get_full_dict(service_appointment_name)
+ return build_success_response(service_appointment)
+ except Exception as e:
+ return build_error_response(str(e), 500)
+
@frappe.whitelist()
def get_service_appointments(companies, filters={}):
"""Get Service Appointments for given companies."""
@@ -65,5 +74,19 @@ def update_service_appointment_scheduled_dates(service_appointment_name: str, st
end_time
)
return build_success_response(updated_service_appointment.as_dict())
+ except Exception as e:
+ return build_error_response(str(e), 500)
+
+
+@frappe.whitelist()
+def update_service_appointment_status(service_appointment_name: str, new_status: str):
+ """Update status for a Service Appointment."""
+ print(f"DEBUG: Updating status for Service Appointment {service_appointment_name} to new status: {new_status}")
+ try:
+ updated_service_appointment = ServiceAppointmentService.update_status(
+ service_appointment_name,
+ new_status
+ )
+ return build_success_response(updated_service_appointment.as_dict())
except Exception as e:
return build_error_response(str(e), 500)
\ No newline at end of file
diff --git a/custom_ui/api/public/estimates.py b/custom_ui/api/public/estimates.py
new file mode 100644
index 0000000..3a3716a
--- /dev/null
+++ b/custom_ui/api/public/estimates.py
@@ -0,0 +1,43 @@
+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")
\ No newline at end of file
diff --git a/custom_ui/api/public/payments.py b/custom_ui/api/public/payments.py
index b369087..19f796e 100644
--- a/custom_ui/api/public/payments.py
+++ b/custom_ui/api/public/payments.py
@@ -1,7 +1,9 @@
import frappe
import json
+from datetime import datetime
from frappe.utils.data import flt
-from custom_ui.services import DbService, StripeService
+from custom_ui.services import DbService, StripeService, PaymentService
+from custom_ui.models import PaymentData
@frappe.whitelist(allow_guest=True)
def half_down_stripe_payment(sales_order):
@@ -13,16 +15,37 @@ 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.advanced_paid >= so.custom_halfdown_amount:
+ if so.custom_halfdown_is_paid or so.advance_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,
- sales_order=so.name,
+ order_num=so.name,
for_advance_payment=True
)
- return frappe.redirect(stripe_session.url)
+ frappe.local.response["type"] = "redirect"
+ frappe.local.response["location"] = stripe_session.url
+
+@frappe.whitelist(allow_guest=True)
+def invoice_stripe_payment(sales_invoice):
+ """Public endpoint for initiating a full payment for a sales invoice."""
+ if not DbService.exists("Sales Invoice", sales_invoice):
+ frappe.throw("Sales Invoice does not exist.")
+ si = DbService.get_or_throw("Sales Invoice", sales_invoice)
+ if si.docstatus != 1:
+ frappe.throw("Sales Invoice must be submitted to proceed with payment.")
+ if si.outstanding_amount <= 0:
+ frappe.throw("This invoice has already been paid.")
+ stripe_session = StripeService.create_checkout_session(
+ company=si.company,
+ amount=si.outstanding_amount,
+ service=si.project_template,
+ order_num=si.name,
+ for_advance_payment=False
+ )
+ frappe.local.response["type"] = "redirect"
+ frappe.local.response["location"] = stripe_session.url
@frappe.whitelist(allow_guest=True)
def stripe_webhook():
@@ -31,39 +54,60 @@ 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"))
- 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,
- })
+ # Convert Unix timestamp to date string (YYYY-MM-DD)
+ reference_date = datetime.fromtimestamp(session.created).strftime('%Y-%m-%d')
- pe.insert()
- pe.submit()
- return "Payment Entry created and submitted successfully."
+ # 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")
diff --git a/custom_ui/db_utils.py b/custom_ui/db_utils.py
index e9be1be..e0b9561 100644
--- a/custom_ui/db_utils.py
+++ b/custom_ui/db_utils.py
@@ -11,7 +11,7 @@ def map_field_name(frontend_field):
"job_status": "custom_job_status",
"installation_address": "custom_installation_address",
"warranty_id": "name",
- "customer": "customer_name",
+ "customer": "customer",
"fromCompany": "from_company",
"warranty_status": "warranty_amc_status"
}
diff --git a/custom_ui/events/client.py b/custom_ui/events/client.py
index e69de29..bf1a838 100644
--- a/custom_ui/events/client.py
+++ b/custom_ui/events/client.py
@@ -0,0 +1,6 @@
+import frappe
+
+
+def before_save(doc, method):
+ print("DEBUG: Before save hook triggered for Customer:", doc.name)
+ print("DEBUG: current state: ", doc.as_dict())
\ No newline at end of file
diff --git a/custom_ui/events/estimate.py b/custom_ui/events/estimate.py
index d6465a1..ab5e78d 100644
--- a/custom_ui/events/estimate.py
+++ b/custom_ui/events/estimate.py
@@ -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, "customer_billing_address")
+ doc.customer_address = frappe.get_value(doc.customer_type, doc.actual_customer_name, "custom_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)
diff --git a/custom_ui/events/jobs.py b/custom_ui/events/jobs.py
index de0d9ff..b083756 100644
--- a/custom_ui/events/jobs.py
+++ b/custom_ui/events/jobs.py
@@ -1,5 +1,5 @@
import frappe
-from custom_ui.services import AddressService, ClientService, ServiceAppointmentService, TaskService
+from custom_ui.services import SalesOrderService, AddressService, ClientService, ServiceAppointmentService, TaskService
from datetime import timedelta
import traceback
@@ -50,7 +50,7 @@ def after_insert(doc, method):
)
if task_names:
doc.save(ignore_permissions=True)
- TaskService.calculate_and_set_due_dates(task_names, "Created", current_triggering_dict=doc.as_dict())
+ TaskService.fire_task_triggers(task_names, "Created", current_triggering_dict=doc.as_dict())
@@ -62,6 +62,7 @@ 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
@@ -73,7 +74,7 @@ def before_save(doc, method):
doc.is_scheduled = 0
event = TaskService.determine_event(doc)
if event:
- TaskService.calculate_and_set_due_dates(
+ TaskService.fire_task_triggers(
[task.task for task in doc.tasks],
event,
current_triggering_dict=doc.as_dict()
@@ -81,6 +82,18 @@ 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 = {
diff --git a/custom_ui/events/payments.py b/custom_ui/events/payments.py
new file mode 100644
index 0000000..f4eb2a5
--- /dev/null
+++ b/custom_ui/events/payments.py
@@ -0,0 +1,16 @@
+import frappe
+
+def on_submit(doc, method):
+ print("DEBUG: On Submit Triggered for Payment Entry")
+ is_advance_payment = any(ref.reference_doctype == "Sales Order" for ref in doc.references)
+ if is_advance_payment:
+ print("DEBUG: Payment Entry is for an advance payment, checking Sales Order if half down requirement is met.")
+ so_ref = next((ref for ref in doc.references if ref.reference_doctype == "Sales Order"), None)
+ if so_ref:
+ so_doc = frappe.get_doc("Sales Order", so_ref.reference_name)
+ if so_doc.requires_half_payment:
+ is_paid = 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()
\ No newline at end of file
diff --git a/custom_ui/events/sales_invoice.py b/custom_ui/events/sales_invoice.py
new file mode 100644
index 0000000..9e70a9b
--- /dev/null
+++ b/custom_ui/events/sales_invoice.py
@@ -0,0 +1,22 @@
+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)
+ frappe.set_value("Project", doc.project, "invoice_status", "Invoice Sent")
+ 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")
+
+def after_insert(doc, method):
+ print("DEBUG: After Insert Triggered for Sales Invoice:", doc.name)
+ # Additional logic can be added here if needed after invoice creation
+ frappe.set_value("Project", doc.project, "invoice_status", "Invoice Created")
+
\ No newline at end of file
diff --git a/custom_ui/events/sales_order.py b/custom_ui/events/sales_order.py
index d8d659f..b638135 100644
--- a/custom_ui/events/sales_order.py
+++ b/custom_ui/events/sales_order.py
@@ -71,6 +71,21 @@ 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)
@@ -78,7 +93,9 @@ 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.")
- frappe.set_value("Project", doc.project, "ready_to_schedule", 1)
+ project_doc = frappe.get_doc("Project", doc.project)
+ project_doc.ready_to_schedule = 1
+ project_doc.save()
@@ -121,3 +138,4 @@ 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")
+
diff --git a/custom_ui/events/service_appointment.py b/custom_ui/events/service_appointment.py
index d0f04de..23c5789 100644
--- a/custom_ui/events/service_appointment.py
+++ b/custom_ui/events/service_appointment.py
@@ -13,7 +13,7 @@ def on_update(doc, method):
def after_insert(doc, method):
print("DEBUG: After Insert Triggered for Service Appointment")
task_names = [task.name for task in TaskService.get_tasks_by_project(doc.project)]
- TaskService.calculate_and_set_due_dates(task_names=task_names, event="Created", current_triggering_dict=doc.as_dict())
+ TaskService.fire_task_triggers(task_names=task_names, event="Created", current_triggering_dict=doc.as_dict())
def before_save(doc, method):
print("DEBUG: Before Save Triggered for Service Appointment")
@@ -28,4 +28,6 @@ def before_save(doc, method):
event = TaskService.determine_event(doc)
if event:
task_names = [task.name for task in TaskService.get_tasks_by_project(doc.project)]
- TaskService.calculate_and_set_due_dates(task_names=task_names, event=event, current_triggering_dict=doc.as_dict())
\ No newline at end of file
+ TaskService.fire_task_triggers(task_names=task_names, event=event, current_triggering_dict=doc.as_dict())
+ if doc.status == "Completed" and frappe.get_value("Service Address 2", doc.name, "status") != "Completed":
+ frappe.set_value("Project", doc.project, "invoice_status", "Ready to Invoice")
\ No newline at end of file
diff --git a/custom_ui/events/task.py b/custom_ui/events/task.py
index a5e24df..e69c64d 100644
--- a/custom_ui/events/task.py
+++ b/custom_ui/events/task.py
@@ -23,12 +23,33 @@ def after_insert(doc, method):
doc.customer, "tasks", {"task": doc.name, "project_template": doc.project_template }
)
task_names = [task.name for task in TaskService.get_tasks_by_project(doc.project)]
- TaskService.calculate_and_set_due_dates(task_names, "Created", current_triggering_dict=doc.as_dict())
+ TaskService.fire_task_triggers(task_names, "Created", current_triggering_dict=doc.as_dict())
def before_save(doc, method):
print("DEBUG: Before Save Triggered for Task:", doc.name)
+ task_type_weight = frappe.get_value("Task Type", doc.type, "weight") or 0
+ if doc.task_weight != task_type_weight:
+ print(f"DEBUG: Updating Task weight from {doc.task_weight} to {task_type_weight}")
+ doc.task_weight = task_type_weight
event = TaskService.determine_event(doc)
if event:
task_names = [task.name for task in TaskService.get_tasks_by_project(doc.project)]
- TaskService.calculate_and_set_due_dates(task_names, event, current_triggering_dict=doc.as_dict())
-
\ No newline at end of file
+ TaskService.fire_task_triggers(task_names, event, current_triggering_dict=doc.as_dict())
+
+def after_save(doc, method):
+ print("DEBUG: After Save Triggered for Task:", doc.name)
+ if doc.project and doc.status == "Completed":
+ print("DEBUG: Task is completed, checking if project has calculated 100% Progress.")
+ project_doc = frappe.get_doc("Project", doc.project)
+ if project_doc.percent_complete == 100:
+ project_update_required = False
+ if project_doc.status == "Completed" and project_doc.customCompletionDate is None:
+ print("DEBUG: Project is marked as Completed but customCompletionDate is not set, updating customCompletionDate.")
+ project_doc.customCompletionDate = frappe.utils.nowdate()
+ project_update_required = True
+ if project_doc.invoice_status == "Not Ready":
+ project_doc.invoice_status = "Ready to Invoice"
+ project_update_required = True
+ if project_update_required:
+ project_doc.save(ignore_permissions=True)
+ print("DEBUG: Updated Project document after Task completion")
\ No newline at end of file
diff --git a/custom_ui/fixtures/custom_field.json b/custom_ui/fixtures/custom_field.json
index 3a3b559..0637a08 100644
--- a/custom_ui/fixtures/custom_field.json
+++ b/custom_ui/fixtures/custom_field.json
@@ -1,116 +1 @@
-[
- {
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "collapsible_depends_on": null,
- "columns": 0,
- "default": null,
- "depends_on": null,
- "description": null,
- "docstatus": 0,
- "doctype": "Custom Field",
- "dt": "Address",
- "fetch_from": null,
- "fetch_if_empty": 0,
- "fieldname": "custom_subdivision",
- "fieldtype": "Link",
- "hidden": 0,
- "hide_border": 0,
- "hide_days": 0,
- "hide_seconds": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_preview": 0,
- "in_standard_filter": 0,
- "insert_after": "custom_linked_city",
- "is_system_generated": 0,
- "is_virtual": 0,
- "label": "Subdivision",
- "length": 0,
- "link_filters": null,
- "mandatory_depends_on": null,
- "modified": "2026-01-30 08:27:37.157366",
- "module": "Custom UI",
- "name": "Address-custom_subdivision",
- "no_copy": 0,
- "non_negative": 0,
- "options": "Territory",
- "permlevel": 0,
- "placeholder": null,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "print_width": null,
- "read_only": 0,
- "read_only_depends_on": null,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "show_dashboard": 0,
- "sort_options": 0,
- "translatable": 0,
- "unique": 0,
- "width": null
- },
- {
- "allow_in_quick_entry": 1,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "collapsible_depends_on": null,
- "columns": 0,
- "default": null,
- "depends_on": null,
- "description": null,
- "docstatus": 0,
- "doctype": "Custom Field",
- "dt": "Address",
- "fetch_from": null,
- "fetch_if_empty": 1,
- "fieldname": "custom_customer_to_bill",
- "fieldtype": "Link",
- "hidden": 0,
- "hide_border": 0,
- "hide_days": 0,
- "hide_seconds": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_preview": 1,
- "in_standard_filter": 1,
- "insert_after": "custom_column_break_rrto0",
- "is_system_generated": 0,
- "is_virtual": 0,
- "label": "Customer to Bill",
- "length": 0,
- "link_filters": null,
- "mandatory_depends_on": null,
- "modified": "2026-01-30 09:05:24.498640",
- "module": "Custom UI",
- "name": "Address-custom_customer_to_bill",
- "no_copy": 0,
- "non_negative": 0,
- "options": "Customer",
- "permlevel": 0,
- "placeholder": null,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "print_width": null,
- "read_only": 0,
- "read_only_depends_on": null,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "show_dashboard": 0,
- "sort_options": 0,
- "translatable": 0,
- "unique": 0,
- "width": null
- }
-]
\ No newline at end of file
+[]
\ No newline at end of file
diff --git a/custom_ui/fixtures/email_template.json b/custom_ui/fixtures/email_template.json
index 0637a08..3221ed3 100644
--- a/custom_ui/fixtures/email_template.json
+++ b/custom_ui/fixtures/email_template.json
@@ -1 +1,12 @@
-[]
\ No newline at end of file
+[
+ {
+ "docstatus": 0,
+ "doctype": "Email Template",
+ "modified": "2026-01-24 07:18:15.939258",
+ "name": "Customer Invoice",
+ "response": "
-- Copywriting goes here --
-- Customized Payment Link goes here --
-- In the meantime --
Invoice number: {{ name }}
Amount: {{ grand_total }}
https://sprinklersnorthwest.com/product/bill-pay/
",
+ "response_html": null,
+ "subject": "Your Invoice is Ready",
+ "use_html": 0
+ }
+]
\ No newline at end of file
diff --git a/custom_ui/fixtures/project_template.json b/custom_ui/fixtures/project_template.json
index b6b8f5b..2c67ddc 100644
--- a/custom_ui/fixtures/project_template.json
+++ b/custom_ui/fixtures/project_template.json
@@ -1,10 +1,12 @@
[
{
+ "bid_meeting_note_form": null,
"calendar_color": null,
"company": "Sprinklers Northwest",
"docstatus": 0,
"doctype": "Project Template",
- "modified": "2026-01-27 09:16:15.614554",
+ "item_groups": null,
+ "modified": "2026-01-29 09:51:46.681553",
"name": "SNW Install",
"project_type": "External",
"tasks": [
@@ -51,5 +53,25 @@
"task": "TASK-2025-00006"
}
]
+ },
+ {
+ "bid_meeting_note_form": null,
+ "calendar_color": null,
+ "company": "Sprinklers Northwest",
+ "docstatus": 0,
+ "doctype": "Project Template",
+ "item_groups": null,
+ "modified": "2026-01-08 10:36:39.245470",
+ "name": "Other",
+ "project_type": null,
+ "tasks": [
+ {
+ "parent": "Other",
+ "parentfield": "tasks",
+ "parenttype": "Project Template",
+ "subject": "Primary Job",
+ "task": "TASK-2025-00004"
+ }
+ ]
}
]
\ No newline at end of file
diff --git a/custom_ui/fixtures/task.json b/custom_ui/fixtures/task.json
index 608a779..ab0ce62 100644
--- a/custom_ui/fixtures/task.json
+++ b/custom_ui/fixtures/task.json
@@ -10,6 +10,7 @@
"completed_on": null,
"custom_foreman": "HR-EMP-00014",
"custom_property": null,
+ "customer": null,
"department": null,
"depends_on": [],
"depends_on_tasks": "",
@@ -54,6 +55,7 @@
"completed_on": null,
"custom_foreman": null,
"custom_property": null,
+ "customer": null,
"department": null,
"depends_on": [],
"depends_on_tasks": "",
@@ -98,50 +100,7 @@
"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,
+ "customer": null,
"department": null,
"depends_on": [],
"depends_on_tasks": "",
@@ -186,6 +145,7 @@
"completed_on": null,
"custom_foreman": null,
"custom_property": null,
+ "customer": null,
"department": null,
"depends_on": [],
"depends_on_tasks": "",
@@ -230,6 +190,7 @@
"completed_on": null,
"custom_foreman": null,
"custom_property": null,
+ "customer": null,
"department": null,
"depends_on": [],
"depends_on_tasks": "",
@@ -244,8 +205,8 @@
"is_milestone": 0,
"is_template": 1,
"issue": null,
- "modified": "2025-05-10 05:06:24.653035",
- "name": "TASK-2025-00004",
+ "modified": "2025-05-10 05:06:35.232465",
+ "name": "TASK-2025-00005",
"old_parent": "",
"parent_task": null,
"priority": "Low",
@@ -255,7 +216,7 @@
"review_date": null,
"start": 0,
"status": "Template",
- "subject": "Primary Job",
+ "subject": "Hydroseeding",
"task_weight": 0.0,
"template_task": null,
"total_billing_amount": 0.0,
@@ -274,6 +235,7 @@
"completed_on": null,
"custom_foreman": null,
"custom_property": null,
+ "customer": null,
"department": null,
"depends_on": [],
"depends_on_tasks": "",
@@ -318,6 +280,7 @@
"completed_on": null,
"custom_foreman": null,
"custom_property": null,
+ "customer": null,
"department": null,
"depends_on": [],
"depends_on_tasks": "",
@@ -332,8 +295,8 @@
"is_milestone": 0,
"is_template": 1,
"issue": null,
- "modified": "2025-05-10 05:06:35.232465",
- "name": "TASK-2025-00005",
+ "modified": "2025-05-08 13:04:15.934399",
+ "name": "TASK-2025-00001",
"old_parent": "",
"parent_task": null,
"priority": "Low",
@@ -343,7 +306,52 @@
"review_date": null,
"start": 0,
"status": "Template",
- "subject": "Hydroseeding",
+ "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,
+ "customer": 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,
diff --git a/custom_ui/hooks.py b/custom_ui/hooks.py
index 606b82b..43e6866 100644
--- a/custom_ui/hooks.py
+++ b/custom_ui/hooks.py
@@ -205,6 +205,12 @@ 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"
}
}
@@ -238,13 +244,13 @@ fixtures = [
# Scheduled Tasks
# ---------------
-# scheduler_events = {
+scheduler_events = {
# "all": [
# "custom_ui.tasks.all"
# ],
-# "daily": [
-# "custom_ui.tasks.daily"
-# ],
+ "daily": [
+ "custom_ui.scheduled_tasks.daily"
+ ],
# "hourly": [
# "custom_ui.tasks.hourly"
# ],
@@ -254,7 +260,7 @@ fixtures = [
# "monthly": [
# "custom_ui.tasks.monthly"
# ],
-# }
+}
# Testing
# -------
diff --git a/custom_ui/install.py b/custom_ui/install.py
index 465559a..c47b330 100644
--- a/custom_ui/install.py
+++ b/custom_ui/install.py
@@ -49,6 +49,8 @@ def after_migrate():
create_task_types()
# create_tasks()
create_bid_meeting_note_form_templates()
+ create_accounts()
+ # init_stripe_accounts()
# update_address_fields()
# build_frontend()
@@ -844,3 +846,58 @@ 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()
diff --git a/custom_ui/models/__init__.py b/custom_ui/models/__init__.py
new file mode 100644
index 0000000..cf24148
--- /dev/null
+++ b/custom_ui/models/__init__.py
@@ -0,0 +1,2 @@
+from .payments import PaymentData
+from .item_models import BOMItem, PackageCreationData
\ No newline at end of file
diff --git a/custom_ui/models/item_models.py b/custom_ui/models/item_models.py
new file mode 100644
index 0000000..b7e58cc
--- /dev/null
+++ b/custom_ui/models/item_models.py
@@ -0,0 +1,18 @@
+from dataclasses import dataclass
+
+@dataclass
+class BOMItem:
+ item_code: str
+ qty: float
+ uom: str
+ item_name: str = None
+
+@dataclass
+class PackageCreationData:
+ package_name: str
+ items: list[BOMItem]
+ item_group: str
+ code_prefix: str
+ rate: float = 0.0
+ company: str = None
+ description: str = None
\ No newline at end of file
diff --git a/custom_ui/models/payments.py b/custom_ui/models/payments.py
new file mode 100644
index 0000000..dfd9bf4
--- /dev/null
+++ b/custom_ui/models/payments.py
@@ -0,0 +1,10 @@
+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
\ No newline at end of file
diff --git a/custom_ui/scheduled_tasks.py b/custom_ui/scheduled_tasks.py
new file mode 100644
index 0000000..b19ec47
--- /dev/null
+++ b/custom_ui/scheduled_tasks.py
@@ -0,0 +1,8 @@
+import frappe
+from custom_ui.services import TaskService
+
+def daily_task():
+ """Scheduled task to run daily."""
+ print("#################### Running Daily Task ####################")
+ print("DEBUG: Checking Task due dates")
+ TaskService.find_and_update_overdue_tasks()
\ No newline at end of file
diff --git a/custom_ui/services/__init__.py b/custom_ui/services/__init__.py
index a1d4631..a1fd1a7 100644
--- a/custom_ui/services/__init__.py
+++ b/custom_ui/services/__init__.py
@@ -6,4 +6,9 @@ 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
\ No newline at end of file
+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
\ No newline at end of file
diff --git a/custom_ui/services/address_service.py b/custom_ui/services/address_service.py
index 38bc14c..21eef35 100644
--- a/custom_ui/services/address_service.py
+++ b/custom_ui/services/address_service.py
@@ -186,6 +186,7 @@ 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
diff --git a/custom_ui/services/client_service.py b/custom_ui/services/client_service.py
index 099b4d2..e633cec 100644
--- a/custom_ui/services/client_service.py
+++ b/custom_ui/services/client_service.py
@@ -55,6 +55,7 @@ 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
@@ -91,6 +92,7 @@ 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")
@@ -104,6 +106,7 @@ 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")
@@ -117,6 +120,7 @@ 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")
@@ -130,6 +134,7 @@ 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:
@@ -141,11 +146,13 @@ 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:
diff --git a/custom_ui/services/email_service.py b/custom_ui/services/email_service.py
new file mode 100644
index 0000000..39a3db3
--- /dev/null
+++ b/custom_ui/services/email_service.py
@@ -0,0 +1,240 @@
+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
\ No newline at end of file
diff --git a/custom_ui/services/estimate_service.py b/custom_ui/services/estimate_service.py
index d5e3cf2..8cc96a9 100644
--- a/custom_ui/services/estimate_service.py
+++ b/custom_ui/services/estimate_service.py
@@ -1,4 +1,5 @@
import frappe
+from .item_service import ItemService
class EstimateService:
@@ -21,6 +22,7 @@ class EstimateService:
print("DEBUG: Quotation document not found.")
return None
+
@staticmethod
def get_or_throw(estimate_name: str) -> frappe._dict:
"""Retrieve a Quotation document by name or throw an error if not found."""
@@ -93,4 +95,18 @@ 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}")
-
\ No newline at end of file
+
+ @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 }
\ No newline at end of file
diff --git a/custom_ui/services/item_service.py b/custom_ui/services/item_service.py
new file mode 100644
index 0000000..d6e10c3
--- /dev/null
+++ b/custom_ui/services/item_service.py
@@ -0,0 +1,204 @@
+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
\ No newline at end of file
diff --git a/custom_ui/services/payment_service.py b/custom_ui/services/payment_service.py
index ca447f1..9e42869 100644
--- a/custom_ui/services/payment_service.py
+++ b/custom_ui/services/payment_service.py
@@ -1,29 +1,52 @@
import frappe
-from custom_ui.services import DbService
+from custom_ui.services import DbService, StripeService
+from dataclasses import dataclass
+from custom_ui.models import PaymentData
+
+
class PaymentService:
@staticmethod
- def create_payment_entry(reference_doctype: str, reference_doc_name: str, data: dict) -> frappe._dict:
+ def create_payment_entry(data: PaymentData) -> frappe._dict:
"""Create a Payment Entry document based on the reference document."""
- 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)
+ 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
pe = frappe.get_doc({
"doctype": "Payment Entry",
+ "company": data.company,
"payment_type": "Receive",
"party_type": "Customer",
- "mode_of_payment": data.get("mode_of_payment", "Stripe"),
+ "mode_of_payment": data.mode_of_payment or "Stripe",
"party": reference_doc.customer,
"party_name": reference_doc.customer,
- "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"),
+ "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,
+ }]
})
pe.insert()
print(f"DEBUG: Created Payment Entry with name: {pe.name}")
- return pe.as_dict()
+ 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.")
\ No newline at end of file
diff --git a/custom_ui/services/project_service.py b/custom_ui/services/project_service.py
new file mode 100644
index 0000000..af97126
--- /dev/null
+++ b/custom_ui/services/project_service.py
@@ -0,0 +1,29 @@
+import frappe
+from custom_ui.services import TaskService, AddressService
+
+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
+
+ @staticmethod
+ def get_full_project_details(project_name: str) -> dict:
+ """Retrieve comprehensive details for a given project, including linked sales order and invoice information."""
+ print(f"DEBUG: Getting full project details for project: {project_name}")
+ project = frappe.get_doc("Project", project_name).as_dict()
+ project["tasks"] = [frappe.get_doc("Task", task["task"]).as_dict() for task in project["tasks"] if task.get("task")]
+ for task in project["tasks"]:
+ task["type"] = frappe.get_doc("Task Type", task["type"]).as_dict() if task.get("type") else None
+ project["job_address"] = frappe.get_doc("Address", project["job_address"]).as_dict() if project["job_address"] else None
+ project["service_appointment"] = frappe.get_doc("Service Address 2", project["service_appointment"]).as_dict() if project["service_appointment"] else None
+ project["client"] = frappe.get_doc("Customer", project["customer"]).as_dict() if project["customer"] else None
+ project["sales_order"] = frappe.get_doc("Sales Order", project["sales_order"]).as_dict() if project["sales_order"] else None
+ project["billing_address"] = frappe.get_doc("Address", project["client"]["custom_billing_address"]).as_dict() if project["client"] and project["client"].get("custom_billing_address") else None
+ project["invoice"] = frappe.get_doc("Sales Invoice", {"project": project["name"]}).as_dict() if frappe.db.exists("Sales Invoice", {"project": project["name"]}) else None
+ return project
\ No newline at end of file
diff --git a/custom_ui/services/sales_order_service.py b/custom_ui/services/sales_order_service.py
index 9730618..39c863a 100644
--- a/custom_ui/services/sales_order_service.py
+++ b/custom_ui/services/sales_order_service.py
@@ -1,7 +1,28 @@
import frappe
-
+from frappe.utils import today
+from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
+
class SalesOrderService:
@staticmethod
- def apply_advance_payment(sales_order_name: str, payment_entry_doc):
- pass
\ No newline at end of file
+ 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.set_advances()
+ sales_invoice.set_missing_values()
+ sales_invoice.calculate_taxes_and_totals()
+
+ 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
\ No newline at end of file
diff --git a/custom_ui/services/service_appointment_service.py b/custom_ui/services/service_appointment_service.py
index a4d45d0..6e911c4 100644
--- a/custom_ui/services/service_appointment_service.py
+++ b/custom_ui/services/service_appointment_service.py
@@ -51,4 +51,14 @@ class ServiceAppointmentService:
setattr(service_appointment, field, value)
service_appointment.save()
print(f"DEBUG: Updated fields for Service Appointment {service_appointment_name}")
+ return service_appointment
+
+ @staticmethod
+ def update_status(service_appointment_name: str, new_status: str):
+ """Update the status of a Service Appointment."""
+ print(f"DEBUG: Updating status for Service Appointment {service_appointment_name} to {new_status}")
+ service_appointment = DbService.get_or_throw("Service Address 2", service_appointment_name)
+ service_appointment.status = new_status
+ service_appointment.save()
+ print(f"DEBUG: Updated status for Service Appointment {service_appointment_name} to {new_status}")
return service_appointment
\ No newline at end of file
diff --git a/custom_ui/services/stripe_service.py b/custom_ui/services/stripe_service.py
index 7f02c57..666a75b 100644
--- a/custom_ui/services/stripe_service.py
+++ b/custom_ui/services/stripe_service.py
@@ -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={"company": company})
+ settings_name = frappe.get_all("Stripe Settings", pluck="name", filters={"custom_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,63 +19,138 @@ 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.secret_key
+ return settings.get_password("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.webhook_secret:
+ if not settings.custom_webhook_secret:
frappe.throw(f"Stripe Webhook Secret not configured for company: {company}")
- return settings.webhook_secret
+ return settings.custom_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) -> 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."""
+ 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
+ """
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": f"Advance payment for {company}{' - ' + service if service else ''}"
+ "name": description
},
- "unit_amount": int(amount * 100),
+ "unit_amount": int(amount * 100), # Stripe expects amount in cents
},
"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={
- "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",
+ metadata=metadata,
+ 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:
- company = company if company else json.loads(payload).get("data", {}).get("object", {}).get("metadata", {}).get("company")
+ print("DEBUG: Stripe webhook received")
+ print(f"DEBUG: Signature header present: {bool(sig_header)}")
+
+ # If company not provided, try to extract from payload metadata
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),
- api_key=StripeService.get_api_key(company)
+ secret=StripeService.get_webhook_secret(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:
- frappe.throw(f"Invalid signature: {str(e)}")
+ print(f"ERROR: Invalid signature for company {company}: {str(e)}")
+ frappe.throw(f"Invalid signature for company {company}: {str(e)}")
return event
diff --git a/custom_ui/services/task_service.py b/custom_ui/services/task_service.py
index f03028b..f1d45e9 100644
--- a/custom_ui/services/task_service.py
+++ b/custom_ui/services/task_service.py
@@ -6,10 +6,10 @@ class TaskService:
@staticmethod
- def calculate_and_set_due_dates(task_names: list[str], event: str, current_triggering_dict=None):
+ def fire_task_triggers(task_names: list[str], event: str, current_triggering_dict=None):
"""Calculate the due date for a list of tasks based on their expected end dates."""
for task_name in task_names:
- TaskService.check_and_update_task_due_date(task_name, event, current_triggering_dict)
+ TaskService.fire_task_trigger(task_name, event, current_triggering_dict)
@staticmethod
@@ -20,49 +20,60 @@ class TaskService:
return tasks
@staticmethod
- def check_and_update_task_due_date(task_name: str, event: str, current_triggering_dict=None):
+ def fire_task_trigger(task_name: str, event: str, current_triggering_dict=None):
"""Determine the triggering configuration for a given task."""
task_type_doc = TaskService.get_task_type_doc(task_name)
- if task_type_doc.no_due_date:
- print(f"DEBUG: Task {task_name} is marked as no due date, skipping calculation.")
- return
- if task_type_doc.triggering_doctype != current_triggering_dict.get("doctype") and current_triggering_dict:
+ schedule_trigger = task_type_doc.triggering_doctype == current_triggering_dict.get("doctype") if current_triggering_dict else False
+ completion_trigger = task_type_doc.custom_completion_trigger_doctype == current_triggering_dict.get("doctype") if current_triggering_dict else False
+ match_schedule_event = task_type_doc.trigger == event
+ match_completion_event = task_type_doc.custom_completion_trigger == event
+ if not schedule_trigger and not completion_trigger:
print(f"DEBUG: Task {task_name} triggering doctype {task_type_doc.triggering_doctype} does not match triggering doctype {current_triggering_dict.get('doctype')}, skipping calculation.")
return
- if task_type_doc.trigger != event:
+ if not match_schedule_event and not match_completion_event:
print(f"DEBUG: Task {task_name} trigger {task_type_doc.trigger} does not match event {event}, skipping calculation.")
return
if task_type_doc.logic_key:
print(f"DEBUG: Task {task_name} has a logic key set, skipping calculations and running logic.")
safe_eval(task_type_doc.logic_key, {"task_name": task_name, "task_type_doc": task_type_doc})
- if task_type_doc.no_due_date:
- print(f"DEBUG: Task {task_name} is marked as no due date, skipping calculation.")
return
- calculate_from = task_type_doc.calculate_from
- trigger = task_type_doc.trigger
- print(f"DEBUG: Calculating triggering data for Task {task_name} from {calculate_from} on trigger {trigger}")
-
-
- triggering_doc_dict = current_triggering_dict if current_triggering_dict else TaskService.get_triggering_doc_dict(task_name=task_name, task_type_doc=task_type_doc)
-
-
- calculated_due_date, calculated_start_date = TaskService.calculate_dates(
- task_name=task_name,
- triggering_doc_dict=triggering_doc_dict,
- task_type_doc=task_type_doc
- )
-
- update_required = TaskService.determine_update_required(
- task_name=task_name,
- calculated_due_date=calculated_due_date,
- calculated_start_date=calculated_start_date
- )
- if update_required:
- TaskService.update_task_dates(
+ if schedule_trigger:
+ triggering_doc_dict = current_triggering_dict if current_triggering_dict else TaskService.get_triggering_doc_dict(task_name=task_name, doctype=task_type_doc.triggering_doctype, task_type_calculate_from=task_type_doc.task_type_calculate_from)
+ calculate_from = task_type_doc.calculate_from
+ trigger = task_type_doc.trigger
+ print(f"DEBUG: Calculating triggering data for Task {task_name} from {calculate_from} on trigger {trigger}")
+
+ calculated_due_date, calculated_start_date = TaskService.calculate_dates(
+ task_name=task_name,
+ triggering_doc_dict=triggering_doc_dict,
+ task_type_doc=task_type_doc
+ )
+
+ update_required = TaskService.determine_due_date_update_required(
task_name=task_name,
calculated_due_date=calculated_due_date,
calculated_start_date=calculated_start_date
)
+ if update_required:
+ TaskService.update_task(
+ task_name=task_name,
+ calculated_due_date=calculated_due_date,
+ calculated_start_date=calculated_start_date
+ )
+ if completion_trigger:
+ triggering_doc_dict = current_triggering_dict if current_triggering_dict else TaskService.get_triggering_doc_dict(task_name=task_name, doctype=task_type_doc.custom_completion_trigger_doctype)
+ print(f"DEBUG: Running completion trigger logic for Task {task_name}")
+ update_required = TaskService.determine_completion_update_required(
+ task_name=task_name,
+ task_type_doc=task_type_doc,
+ )
+ if update_required:
+ TaskService.update_task(
+ task_name=task_name,
+ status="Completed"
+ )
+ print(f"DEBUG: Marked Task {task_name} as Completed due to completion trigger.")
+
@staticmethod
def get_task_type_doc(task_name: str):
@@ -94,7 +105,7 @@ class TaskService:
return calculated_due_date, calculated_start_date
@staticmethod
- def determine_update_required(task_name: str, calculated_due_date: date | None, calculated_start_date: date | None) -> bool:
+ def determine_due_date_update_required(task_name: str, calculated_due_date: date | None, calculated_start_date: date | None) -> bool:
current_due_date = frappe.get_value("Task", task_name, "exp_end_date")
current_start_date = frappe.get_value("Task", task_name, "exp_start_date")
if current_due_date != calculated_due_date or current_start_date != calculated_start_date:
@@ -104,32 +115,60 @@ class TaskService:
print(f"DEBUG: No update required for Task {task_name}. Dates are up to date.")
return False
+ @staticmethod
+ def determine_completion_update_required(task_name: str, task_type_doc) -> bool:
+ current_status = frappe.get_value("Task", task_name, "status")
+ determination = False
+ if current_status == "Completed":
+ print(f"DEBUG: Task {task_name} is already marked as Completed, no update required.")
+ return False
+ else:
+ triggering_doc_dict = TaskService.get_triggering_doc_dict(task_name=task_name, doctype=task_type_doc.custom_completion_trigger_doctype)
+ check_field = TaskService.map_completion_check_field(task_type_doc.custom_completion_trigger)
+ check_value = triggering_doc_dict.get(check_field)
+ trigger = task_type_doc.custom_completion_trigger
+ if trigger == "Completed" and check_value == "Completed" and current_status != "Completed":
+ determination = True
+ elif trigger == "Percentage Reached" and check_value >= task_type_doc.custom_target_percent and current_status != "Completed":
+ determination = True
+ elif trigger in ["Scheduled", "Started", "Created"] and check_value and current_status != "Completed":
+ determination = True
+ print(f"DEBUG: Completion trigger '{trigger}' met for Task {task_name}, check field {check_field} has value {check_value}.")
+ return determination
+
+
@staticmethod
- def get_triggering_doc_dict(task_name: str, task_type_doc) -> dict | None:
+ def get_triggering_doc_dict(task_name: str, doctype, task_type_calculate_from = None) -> dict | None:
project_name = frappe.get_value("Task", task_name, "project")
print(f"DEBUG: Project name: {project_name}")
dict = None
- if task_type_doc.calculate_from == "Project":
+ if doctype == "Project":
dict = frappe.get_doc("Project", project_name).as_dict()
- if task_type_doc.calculate_from == "Service Address 2":
+ if doctype == "Service Address 2":
service_name = frappe.get_value("Project", project_name, "service_appointment")
dict = frappe.get_doc("Service Address 2", service_name).as_dict()
- if task_type_doc.calculate_from == "Task":
+ if doctype == "Task":
project_doc = frappe.get_doc("Project", project_name)
for task in project_doc.tasks:
- if task.task_type == task_type_doc.task_type_calculate_from:
+ if task.task_type == task_type_calculate_from:
dict = frappe.get_doc("Task", task.task).as_dict()
print(f"DEBUG: Triggering doc dict for Task {task_name}: {dict}")
return dict
@staticmethod
- def update_task_dates(task_name: str, calculated_due_date: date | None, calculated_start_date: date | None):
+ def update_task(task_name: str, calculated_due_date: date | None = None, calculated_start_date: date | None = None, status: str | None = None):
task_doc = frappe.get_doc("Task", task_name)
- task_doc.exp_end_date = calculated_due_date
- task_doc.exp_start_date = calculated_start_date
+ if calculated_due_date is not None:
+ task_doc.exp_end_date = calculated_due_date
+ if calculated_start_date is not None:
+ task_doc.exp_start_date = calculated_start_date
+ if status is not None:
+ task_doc.status = status
+ if status == "Completed":
+ task_doc.actual_end_date = datetime.now()
task_doc.save(ignore_permissions=True)
- print(f"DEBUG: Updated Task {task_name} with new dates - Start: {calculated_start_date}, End: {calculated_due_date}")
+ print(f"DEBUG: Updated Task {task_name} with new dates - Start: {calculated_start_date}, End: {calculated_due_date}, Status: {status}")
@staticmethod
def map_base_date_to_field(base_date: str, triggering_doctype: str) -> str:
@@ -150,6 +189,17 @@ class TaskService:
return task_date_field_map.get(base_date, "exp_end_date")
return base_date_field_map.get(base_date, "expected_end_date")
+ @staticmethod
+ def map_completion_check_field(completion_trigger: str) -> str:
+ completion_check_field_map = {
+ "Completed": "status",
+ "Scheduled": "expected_end_date",
+ "Created": "creation",
+ "Started": "actual_start_date",
+ "Percentage Reached": "progress"
+ }
+ return completion_check_field_map.get(completion_trigger, "status")
+
@staticmethod
def determine_event(triggering_doc) -> str | None:
print("DEBUG: Current Document:", triggering_doc.as_dict())
@@ -168,4 +218,16 @@ class TaskService:
return "Completed"
else:
return None
+
+ @staticmethod
+ def find_and_update_overdue_tasks():
+ today = date.today()
+ overdue_tasks = frappe.get_all("Task", filters={"exp_end_date": ("<", today), "status": ["not in", ["Completed", "Template", "Cancelled", "Overdue"]]}, pluck="name")
+ print(f"DEBUG: Found {len(overdue_tasks)} overdue tasks.")
+ for task_name in overdue_tasks:
+ task_doc = frappe.get_doc("Task", task_name)
+ task_doc.status = "Overdue"
+ task_doc.save(ignore_permissions=True)
+ print(f"DEBUG: Updated Task {task_name} to Overdue status.")
+ frappe.db.commit()
\ No newline at end of file
diff --git a/custom_ui/templates/email/downpayment.html b/custom_ui/templates/emails/downpayment.html
similarity index 91%
rename from custom_ui/templates/email/downpayment.html
rename to custom_ui/templates/emails/downpayment.html
index 136424c..598266d 100644
--- a/custom_ui/templates/email/downpayment.html
+++ b/custom_ui/templates/emails/downpayment.html
@@ -75,10 +75,10 @@
Payment Details
Sales Order Number: {{ sales_order_number }}
-
Down Payment Amount: ${{ total_amount }}
+
Down Payment Amount: {{ total_amount }}
Please click the button below to make your secure payment through our payment processor:
- Make Payment
+ Make Payment
If you have any questions or need assistance, feel free to contact us. We're here to help!
Best regards, The Team at {{ company_name }}
diff --git a/custom_ui/templates/emails/general_estimation.html b/custom_ui/templates/emails/general_estimation.html
new file mode 100644
index 0000000..46a36da
--- /dev/null
+++ b/custom_ui/templates/emails/general_estimation.html
@@ -0,0 +1,251 @@
+
+
+
+
+
+ Estimate from {{ company }}
+
+
+
+
+
+
+ {% if letterhead_image %}
+
+ {% else %}
+
{{ company }}
+ {% endif %}
+
+
+
+
+
Hello {{ customer_name }},
+
+
+ Thank you for considering {{ company }} for your project. We are pleased to provide you with the following estimate for the services requested.
+
+
+
+
+
Service Location
+
{{ address }}
+
+
+
+
Total Estimate
+
{{ price }}
+
+
+
+
+ {% if additional %}
+
+
Additional Notes
+
{{ additional }}
+
+ {% endif %}
+
+
+
+
+
+
+ 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 %}
+
Call us at: {{ company_phone }}
+ {% endif %}
+
+ We look forward to working with you!
+
+
+
+
+
+
+
+
diff --git a/custom_ui/templates/emails/invoice.html b/custom_ui/templates/emails/invoice.html
new file mode 100644
index 0000000..b10b1b5
--- /dev/null
+++ b/custom_ui/templates/emails/invoice.html
@@ -0,0 +1,152 @@
+
+
+
+
+
+ Invoice - {{ invoice_number }}
+
+
+
+
+
+
+
Dear {{ customer_name }},
+
Thank you for your business with {{ company_name }}. Please find your invoice details below:
+
+
+
Invoice Details
+
+ Invoice Number:
+ {{ invoice_number }}
+
+
+ Invoice Date:
+ {{ invoice_date }}
+
+
+ Due Date:
+ {{ due_date }}
+
+ {% if sales_order %}
+
+ Related Sales Order:
+ {{ sales_order }}
+
+ {% endif %}
+
+ Invoice Total:
+ {{ grand_total }}
+
+ {% if paid_amount and paid_amount != "$0.00" %}
+
+ Amount Paid:
+ {{ paid_amount }}
+
+ {% endif %}
+
+ Amount Due:
+ {{ outstanding_amount }}
+
+
+
+ {% if payment_url and outstanding_amount != "$0.00" %}
+
+ Payment Required: There is an outstanding balance on this invoice. Please click the button below to make a secure payment.
+
+
Pay Now
+ {% else %}
+
+ Paid in Full: This invoice has been paid in full. Thank you!
+
+ {% endif %}
+
+
If you have any questions about this invoice, please don't hesitate to contact us.
+
Best regards, The Team at {{ company_name }}
+
+
+
+
+
\ No newline at end of file
diff --git a/custom_ui/templates/emails/payment-confirmation.html b/custom_ui/templates/emails/payment-confirmation.html
new file mode 100644
index 0000000..e69de29
diff --git a/custom_ui/templates/emails/snw_install_estimation.html b/custom_ui/templates/emails/snw_install_estimation.html
new file mode 100644
index 0000000..e69de29
diff --git a/custom_ui/www/cancelled_payment.html b/custom_ui/www/cancelled_payment.html
index f1a0534..4fbec21 100644
--- a/custom_ui/www/cancelled_payment.html
+++ b/custom_ui/www/cancelled_payment.html
@@ -1 +1,141 @@
-Payment cancelled.
+
+
+
+
+
+ Payment Cancelled
+
+
+
+
+
+
β
+
Payment Cancelled
+
Your payment has been cancelled.
+
+
+
Payment Not Processed
+
No charges have been made to your account. If you cancelled by mistake or need assistance, please try again or contact support.
+
+
+
+
What happens next?
+
+ No payment has been processed
+ You can safely close this window
+ Try your payment again if needed
+ Contact us if you need help
+
+
+
+
+
diff --git a/custom_ui/www/payment_cancelled.html b/custom_ui/www/payment_cancelled.html
new file mode 100644
index 0000000..4fbec21
--- /dev/null
+++ b/custom_ui/www/payment_cancelled.html
@@ -0,0 +1,141 @@
+
+
+
+
+
+ Payment Cancelled
+
+
+
+
+
+
β
+
Payment Cancelled
+
Your payment has been cancelled.
+
+
+
Payment Not Processed
+
No charges have been made to your account. If you cancelled by mistake or need assistance, please try again or contact support.
+
+
+
+
What happens next?
+
+ No payment has been processed
+ You can safely close this window
+ Try your payment again if needed
+ Contact us if you need help
+
+
+
+
+
diff --git a/custom_ui/www/payment_cancelled.py b/custom_ui/www/payment_cancelled.py
new file mode 100644
index 0000000..e69de29
diff --git a/custom_ui/www/payment_success.html b/custom_ui/www/payment_success.html
new file mode 100644
index 0000000..f6daddb
--- /dev/null
+++ b/custom_ui/www/payment_success.html
@@ -0,0 +1,212 @@
+
+
+
+
+
+ Payment Successful
+
+
+
+
+
+
β
+ {% if reference_doc %}
+
+ {% if company_doc and company_doc.company_name %}
+ {{ company_doc.company_name }}
+ {% else %}
+ Payment Received
+ {% endif %}
+
+ {% if reference_doc.doctype == "Sales Order" %}
+
+ {% if reference_doc.customer %}
+ Thank you {{ reference_doc.customer }} for your advance payment!
+ {% else %}
+ Thank you for your advance payment!
+ {% endif %}
+
+
+
The remaining balance will be invoiced once the project is complete.
+
+ {% else %}
+
+ {% if reference_doc.customer %}
+ Thank you {{ reference_doc.customer }} for your payment!
+ {% else %}
+ Thank you for your payment!
+ {% endif %}
+
+ {% endif %}
+
+ {% if company_doc %}
+
+ {% endif %}
+ {% else %}
+
Payment Received
+
Thank you for your payment!
+ {% endif %}
+
+
+
diff --git a/custom_ui/www/payment_success.py b/custom_ui/www/payment_success.py
new file mode 100644
index 0000000..8db1438
--- /dev/null
+++ b/custom_ui/www/payment_success.py
@@ -0,0 +1,18 @@
+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
\ No newline at end of file
diff --git a/custom_ui/www/successful_payment.html b/custom_ui/www/successful_payment.html
deleted file mode 100644
index e1006a7..0000000
--- a/custom_ui/www/successful_payment.html
+++ /dev/null
@@ -1 +0,0 @@
-Thank you for your payment!
diff --git a/docker-compose.local.yaml b/docker-compose.local.yaml
new file mode 100644
index 0000000..b99b3de
--- /dev/null
+++ b/docker-compose.local.yaml
@@ -0,0 +1,8 @@
+services:
+ mailhog:
+ image: mailhog/mailhog:latest
+ container_name: mailhog
+ ports:
+ - "8025:8025" # MailHog web UI
+ - "1025:1025" # SMTP server
+ restart: unless-stopped
\ No newline at end of file
diff --git a/doctype_diff_report.md b/doctype_diff_report.md
new file mode 100644
index 0000000..bc1caea
--- /dev/null
+++ b/doctype_diff_report.md
@@ -0,0 +1,151 @@
+# 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 |
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 01e1baf..0be2552 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -19,6 +19,9 @@ const FRAPPE_GET_ESTIMATE_TEMPLATES_METHOD = "custom_ui.api.db.estimates.get_est
const FRAPPE_CREATE_ESTIMATE_TEMPLATE_METHOD = "custom_ui.api.db.estimates.create_estimate_template";
const FRAPPE_GET_UNAPPROVED_ESTIMATES_COUNT_METHOD = "custom_ui.api.db.estimates.get_unapproved_estimates_count";
const FRAPPE_GET_ESTIMATES_HALF_DOWN_COUNT_METHOD = "custom_ui.api.db.estimates.get_estimates_half_down_count";
+// Item methods
+const FRAPPE_SAVE_AS_PACKAGE_ITEM_METHOD = "custom_ui.api.db.items.save_as_package_item";
+const FRAPPE_GET_ITEMS_BY_PROJECT_TEMPLATE_METHOD = "custom_ui.api.db.items.get_by_project_template";
// Job methods
const FRAPPE_GET_JOB_METHOD = "custom_ui.api.db.jobs.get_job";
const FRAPPE_GET_JOBS_METHOD = "custom_ui.api.db.jobs.get_jobs_table_data";
@@ -67,8 +70,10 @@ const FRAPPE_GET_EMPLOYEES_ORGANIZED_METHOD = "custom_ui.api.db.employees.get_em
const FRAPPE_GET_WEEK_HOLIDAYS_METHOD = "custom_ui.api.db.general.get_week_holidays";
const FRAPPE_GET_DOC_LIST_METHOD = "custom_ui.api.db.general.get_doc_list";
// Service Appointment methods
+const FRAPPE_GET_SERVICE_APPOINTMENT_METHOD = "custom_ui.api.db.service_appointments.get_service_appointment";
const FRAPPE_GET_SERVICE_APPOINTMENTS_METHOD = "custom_ui.api.db.service_appointments.get_service_appointments";
const FRAPPE_UPDATE_SERVICE_APPOINTMENT_SCHEDULED_DATES_METHOD = "custom_ui.api.db.service_appointments.update_service_appointment_scheduled_dates";
+const FRAPPE_UPDATE_SERVICE_APPOINTMENT_STATUS_METHOD = "custom_ui.api.db.service_appointments.update_service_appointment_status";
class Api {
// ============================================================================
// CORE REQUEST METHOPD
@@ -229,8 +234,8 @@ class Api {
// ESTIMATE / QUOTATION METHODS
// ============================================================================
- static async getQuotationItems() {
- return await this.request("custom_ui.api.db.estimates.get_quotation_items");
+ static async getQuotationItems(projectTemplate) {
+ return await this.request("custom_ui.api.db.estimates.get_quotation_items", { projectTemplate });
}
static async getEstimateFromAddress(fullAddress) {
@@ -454,6 +459,10 @@ class Api {
// SERVICE APPOINTMENT METHODS
// ============================================================================
+ static async getServiceAppointment(serviceAppointmentName) {
+ return await this.request(FRAPPE_GET_SERVICE_APPOINTMENT_METHOD, { serviceAppointmentName });
+ }
+
static async getServiceAppointments(companies = [], filters = {}) {
return await this.request(FRAPPE_GET_SERVICE_APPOINTMENTS_METHOD, { companies, filters });
}
@@ -469,6 +478,10 @@ class Api {
})
}
+ static async setServiceAppointmentStatus(serviceAppointmentName, newStatus) {
+ return await this.request(FRAPPE_UPDATE_SERVICE_APPOINTMENT_STATUS_METHOD, { serviceAppointmentName, newStatus });
+ }
+
// ============================================================================
// TASK METHODS
// ============================================================================
@@ -657,6 +670,22 @@ class Api {
return await this.request(FRAPPE_GET_ADDRESSES_METHOD, { fields, filters });
}
+ // ============================================================================
+ // ITEM/PACKAGE METHODS
+ // ============================================================================
+
+ static async getItemsByProjectTemplate(projectTemplate) {
+ return await this.request(FRAPPE_GET_ITEMS_BY_PROJECT_TEMPLATE_METHOD, { projectTemplate });
+ }
+
+ static async saveAsPackageItem(data) {
+ return await this.request(FRAPPE_SAVE_AS_PACKAGE_ITEM_METHOD, { data });
+ }
+
+ static async getItemCategories() {
+ return await this.request("custom_ui.api.db.items.get_item_categories");
+ }
+
// ============================================================================
// SERVICE / ROUTE / TIMESHEET METHODS
// ============================================================================
diff --git a/frontend/src/components/calendar/CalendarNavigation.vue b/frontend/src/components/calendar/CalendarNavigation.vue
index a415e8a..70ea977 100644
--- a/frontend/src/components/calendar/CalendarNavigation.vue
+++ b/frontend/src/components/calendar/CalendarNavigation.vue
@@ -1,6 +1,6 @@
-
+
Bids
Projects
@@ -26,7 +26,7 @@
-
+
Bids
Projects
@@ -61,20 +61,36 @@
diff --git a/frontend/src/components/modals/AddItemModal.vue b/frontend/src/components/modals/AddItemModal.vue
new file mode 100644
index 0000000..afb973c
--- /dev/null
+++ b/frontend/src/components/modals/AddItemModal.vue
@@ -0,0 +1,649 @@
+
+
+
+
+ Add Item
+ {{ selectedItemsCount }} selected
+
+
+
+
+ Search Items
+
+
+
+
+
+
+
+ Packages
+
+ {{ group }}
+
+
+
+
+
+
+ {{ packageGroup }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/modals/BidMeetingNoteForm.vue b/frontend/src/components/modals/BidMeetingNoteForm.vue
index 8783a14..113361a 100644
--- a/frontend/src/components/modals/BidMeetingNoteForm.vue
+++ b/frontend/src/components/modals/BidMeetingNoteForm.vue
@@ -20,24 +20,27 @@
@@ -573,7 +575,7 @@ const loadDoctypeOptions = async () => {
for (const field of fieldsWithDoctype) {
try {
// Use the new API method for fetching docs
- let docs = await Api.getQuotationItems();
+ let docs = await Api.getQuotationItems(props.projectTemplate);
// Deduplicate by value field
const valueField = field.doctypeValueField || 'name';
diff --git a/frontend/src/components/modals/JobDetailsModal.vue b/frontend/src/components/modals/JobDetailsModal.vue
index 47d9afa..7c3dd06 100644
--- a/frontend/src/components/modals/JobDetailsModal.vue
+++ b/frontend/src/components/modals/JobDetailsModal.vue
@@ -187,6 +187,17 @@
mdi-open-in-new
View Job
+
+ mdi-check-circle
+ Mark as Completed
+
Close
@@ -196,6 +207,10 @@
+
+
diff --git a/frontend/src/components/pages/Client.vue b/frontend/src/components/pages/Client.vue
index 3fd1132..0de7dfc 100644
--- a/frontend/src/components/pages/Client.vue
+++ b/frontend/src/components/pages/Client.vue
@@ -318,9 +318,9 @@ const handleSubmit = async () => {
const createdClient = await Api.createClient(client.value);
console.log("Created client:", createdClient);
notificationStore.addSuccess("Client created successfully!");
- stripped_name = createdClient.customerName.split("-#-")[0].trim();
+ const strippedName = createdClient.name.split("-#-")[0].trim();
// Navigate to the created client
- router.push('/client?client=' + encodeURIComponent(stripped_name));
+ router.push('/client?client=' + encodeURIComponent(strippedName));
} else {
// TODO: Implement save logic
notificationStore.addSuccess("Changes saved successfully!");
diff --git a/frontend/src/components/pages/Estimate.vue b/frontend/src/components/pages/Estimate.vue
index f9d57aa..6b90878 100644
--- a/frontend/src/components/pages/Estimate.vue
+++ b/frontend/src/components/pages/Estimate.vue
@@ -67,43 +67,7 @@
-
-
-
-
- From Template
-
-
-
-
-
-
-
-
+
@@ -118,88 +82,137 @@
optionLabel="name"
optionValue="name"
placeholder="Select a project template"
- :disabled="!isEditable || isProjectTemplateDisabled"
+ :disabled="!isEditable"
fluid
/>
+
+
+ Loading available items...
+