moving towards real data

This commit is contained in:
Casey Wittrock 2025-11-06 13:00:19 -06:00
parent ac3c05cb78
commit 40c4a5a37f
8 changed files with 303 additions and 84 deletions

View File

@ -1,5 +1,58 @@
import frappe, json
@frappe.whitelist()
def get_clients(options):
options = json.loads(options)
defaultOptions = {
"fields": ["*"],
"filters": {},
"sorting": {},
"page": 1,
"page_size": 10
}
options = {**defaultOptions, **options}
clients = []
count = frappe.db.count("Address", filters=options["filters"])
print("DEBUG: Total addresses count:", count)
addresses = frappe.db.get_all(
"Address",
fields=options["fields"],
filters=options["filters"],
limit=options["page_size"],
start=(options["page"] - 1) * options["page_size"],
order_by=(options["sorting"])
)
for address in addresses:
client = {}
print("DEBUG: Processing address:", address)
quotations = frappe.db.get_all(
"Quotation",
fields=["*"],
filters={"custom_installation_address": address["name"]}
)
jobs = frappe.db.get_all("Project",
fields=["*"],
filters={"custom_installation_address": address["name"],
"project_template": "SNW Install"})
client["address"] = address
client["on_site_meetings"] = []
client["jobs"] = jobs
client["quotations"] = quotations
clients.append(client)
return {
"count": count,
"page": options["page"],
"page_size": options["page_size"],
"clients": clients
}
@frappe.whitelist()
def upsert_client(data):
data = json.loads(data)
@ -15,17 +68,14 @@ def upsert_client(data):
"customer_name": data.get("customer_name"),
"customer_type": data.get("customer_type")
}).insert(ignore_permissions=True)
customer = customer_doc.name
else:
customer_doc = frappe.get_doc("Customer", customer)
print("Customer:", customer_doc.as_dict())
filters = {
"address_line1": data.get("address_line1"),
"city": data.get("city"),
"state": data.get("state"),
"country": "US",
"pincode": data.get("pincode")
"address_title": data.get("address_title"),
}
existing_address = frappe.db.exists("Address", filters)
print("Existing address check:", existing_address)
if existing_address:
frappe.throw(f"Address already exists for customer {data.get('customer_name')}.", frappe.ValidationError)
address_doc = frappe.get_doc({
@ -33,17 +83,19 @@ def upsert_client(data):
"address_line1": data.get("address_line1"),
"city": data.get("city"),
"state": data.get("state"),
"country": "US",
"country": "United States",
"address_title": data.get("address_title"),
"pincode": data.get("pincode"),
}).insert(ignore_permissions=True)
link = {
"link_doctype": "Customer",
"link_name": customer
"link_name": customer_doc.name
}
address_doc.append("links", link)
address_doc.save(ignore_permissions=True)
return {
"customer": customer.name,
"address": address_doc.name
"customer": customer_doc,
"address": address_doc,
"success": True
}

View File

@ -7,13 +7,15 @@ const FRAPPE_UPSERT_CLIENT_METHOD = "custom_ui.api.db.upsert_client";
class Api {
static async request(frappeMethod, args = {}) {
args = DataUtils.toSnakeCaseObject(args);
try {
const response = await frappe.call({
let response = await frappe.call({
method: frappeMethod,
args: {
...args,
},
});
response = DataUtils.toCamelCaseObject(response);
console.log("DEBUG: API - Request Response: ", response);
return response.message;
} catch (error) {
@ -24,39 +26,26 @@ class Api {
}
static async getClientDetails(options = {}) {
const {
forTable = true,
page = 0,
pageSize = 10,
filters = {},
sortField = null,
sortOrder = null,
searchTerm = null,
} = options;
return await this.request("custom_ui.api.db.get_clients", { options });
console.log("DEBUG: API - getClientDetails called with options:", options);
// Build filters for the Address query
let addressFilters = {};
// Add search functionality across multiple fields
if (searchTerm) {
// Note: This is a simplified version. You might want to implement
// full-text search on the backend for better performance
// Handle fullName filter by searching in multiple fields
// Since fullName is constructed from customer_name + address_line1 + city + state,
// we need to search across these fields
if (filters.addressTitle && filters.addressTitle.value) {
const searchTerm = filters.addressTitle.value;
// Search in address fields - this is a simplified approach
// In a real implementation, you'd want to join with Customer table and search across all fields
addressFilters.address_line1 = ["like", `%${searchTerm}%`];
}
// Add any custom filters
// Add any other custom filters
Object.keys(filters).forEach((key) => {
if (filters[key] && filters[key].value) {
// Map frontend filter names to backend field names if needed
switch (key) {
case "fullName":
// This will need special handling since fullName is constructed
// For now, we'll search in address_line1 or city
addressFilters.address_line1 = ["like", `%${filters[key].value}%`];
break;
if (filters[key] && filters[key].value && key !== "fullName") {
// Map other frontend filter names to backend field names if needed
switch (
key
// Add other filter mappings as needed
) {
}
}
});
@ -161,20 +150,15 @@ class Api {
});
}
// Apply client-side filtering for constructed fields like fullName
// Since we're applying filters at the database level, use the fetched data as-is
let filteredData = data;
if (filters.fullName && filters.fullName.value && forTable) {
const searchValue = filters.fullName.value.toLowerCase();
filteredData = data.filter((item) =>
item.fullName.toLowerCase().includes(searchValue),
);
}
console.log("DEBUG: API - Fetched Client Details:", {
total: totalCount,
page: page,
pageSize: pageSize,
returned: filteredData.length,
filtersApplied: Object.keys(addressFilters).length > 0,
});
// Return paginated response with metadata
@ -198,7 +182,7 @@ class Api {
// ...job,
// stepProgress: DataUtils.calculateStepProgress(job.steps),
//}));
const projects = await this.getDocsList("Project")
const projects = await this.getDocsList("Project");
const data = [];
for (let prj of projects) {
let project = await this.getDetailedDoc("Project", prj.name);
@ -207,7 +191,7 @@ class Api {
customInstallationAddress: project.custom_installation_address,
customer: project.customer,
status: project.status,
percentComplete: project.percent_complete
percentComplete: project.percent_complete,
};
data.push(tableRow);
}
@ -249,8 +233,7 @@ class Api {
const { page = 0, pageSize = 10, sortField = null, sortOrder = null } = paginationParams;
const options = {
forTable: true,
page,
page: page + 1,
pageSize,
filters,
sortField,
@ -260,8 +243,7 @@ class Api {
const result = await this.getClientDetails(options);
return {
data: result.data,
totalRecords: result.pagination.total,
...result,
};
}
@ -277,10 +259,13 @@ class Api {
const docs = await frappe.db.get_list(doctype, {
fields,
filters,
start: page * pageLength,
start: start,
limit: pageLength,
});
console.log(`DEBUG: API - Fetched ${doctype} list: `, docs);
console.log(
`DEBUG: API - Fetched ${doctype} list (page ${page + 1}, start ${start}): `,
docs,
);
return docs;
}

View File

@ -34,7 +34,7 @@ const createButtons = ref([
{
label: "Client",
command: () => {
modalStore.openCreateClient();
modalStore.openModal("createClient");
},
},
{

View File

@ -1,6 +1,6 @@
<template lang="html">
<!-- Filter Controls Panel -->
<div v-if="lazy && hasFilters" class="filter-controls-panel mb-3 p-3 bg-light rounded">
<div v-if="hasFilters" class="filter-controls-panel mb-3 p-3 bg-light rounded">
<div class="row g-3 align-items-end">
<div v-for="col in filterableColumns" :key="col.fieldName" class="col-md-4 col-lg-3">
<label :for="`filter-${col.fieldName}`" class="form-label small fw-semibold">
@ -41,7 +41,7 @@
</div>
<!-- Page Jump Controls -->
<div v-if="lazy && totalPages > 1" class="page-controls-panel mb-3 p-2 bg-light rounded">
<div v-if="totalPages > 1" class="page-controls-panel mb-3 p-2 bg-light rounded">
<div class="row g-3 align-items-center">
<div class="col-auto">
<small class="text-muted">Quick navigation:</small>
@ -69,12 +69,13 @@
</div>
<DataTable
ref="dataTableRef"
:value="data"
:rowsPerPageOptions="[5, 10, 20, 50]"
:paginator="true"
:rows="currentRows"
:lazy="lazy"
:totalRecords="lazy ? totalRecords : data.length"
:totalRecords="lazy ? totalRecords : getFilteredDataLength"
@page="handlePage"
@sort="handleSort"
@filter="handleFilter"
@ -201,6 +202,11 @@ const props = defineProps({
type: Number,
default: 0,
},
// Total filtered records for non-lazy tables (when server-side filtering is used)
totalFilteredRecords: {
type: Number,
default: null,
},
// Custom pagination event handler
onLazyLoad: {
type: Function,
@ -208,7 +214,14 @@ const props = defineProps({
},
});
const emit = defineEmits(["rowClick", "lazy-load", "page-change", "sort-change", "filter-change"]);
const emit = defineEmits([
"rowClick",
"lazy-load",
"page-change",
"sort-change",
"filter-change",
"data-refresh",
]);
// Computed loading state that considers both prop and global store
const loading = computed(() => {
@ -292,6 +305,8 @@ watch(
const selectedRows = ref();
const pendingFilters = ref({});
const selectedPageJump = ref("");
const dataTableRef = ref();
const currentPageState = ref({ page: 0, first: 0 }); // Track current page for non-lazy tables
// Computed properties for filtering
const filterableColumns = computed(() => {
@ -319,8 +334,12 @@ const hasFilterChanges = computed(() => {
});
const totalPages = computed(() => {
if (!props.lazy) return 0;
return paginationStore.getTotalPages(props.tableName);
if (props.lazy) {
return paginationStore.getTotalPages(props.tableName);
}
// For non-lazy tables, calculate based on current filtered data
const filteredDataLength = getFilteredDataLength.value;
return Math.ceil(filteredDataLength / currentRows.value) || 1;
});
// Initialize pending filters from store
@ -331,6 +350,17 @@ onMounted(() => {
});
});
// Computed property to get filtered data length for non-lazy tables
const getFilteredDataLength = computed(() => {
if (props.lazy) {
return props.totalRecords;
}
// For non-lazy tables, use totalFilteredRecords if provided (server-side filtering),
// otherwise fall back to data length (client-side filtering)
return props.totalFilteredRecords !== null ? props.totalFilteredRecords : props.data.length;
});
// Filter management methods
const applyFilters = () => {
// Update store with pending filter values
@ -344,10 +374,16 @@ const applyFilters = () => {
);
});
// For lazy tables, reset to first page and trigger reload
// Reset to first page when filters change (for both lazy and non-lazy)
currentPageState.value = { page: 0, first: 0 };
// For both lazy and non-lazy tables, trigger reload with new filters
if (props.lazy) {
paginationStore.resetToFirstPage(props.tableName);
triggerLazyLoad();
} else {
// For non-lazy tables, also trigger a reload to get fresh data from server
triggerDataRefresh();
}
};
@ -360,10 +396,16 @@ const clearFilters = () => {
// Clear store filters
filtersStore.clearTableFilters(props.tableName);
// For lazy tables, reset to first page and trigger reload
// Reset to first page when filters are cleared (for both lazy and non-lazy)
currentPageState.value = { page: 0, first: 0 };
// For both lazy and non-lazy tables, trigger reload with cleared filters
if (props.lazy) {
paginationStore.resetToFirstPage(props.tableName);
triggerLazyLoad();
} else {
// For non-lazy tables, also trigger a reload to get fresh data from server
triggerDataRefresh();
}
};
@ -383,21 +425,53 @@ const getActiveFiltersText = () => {
// Page navigation methods
const jumpToPage = () => {
if (selectedPageJump.value && props.lazy) {
if (selectedPageJump.value) {
const pageNumber = parseInt(selectedPageJump.value) - 1; // Convert to 0-based
paginationStore.setPage(props.tableName, pageNumber);
triggerLazyLoad();
if (props.lazy) {
paginationStore.setPage(props.tableName, pageNumber);
triggerLazyLoad();
} else {
// For non-lazy tables, update our internal state
// The DataTable will handle the actual pagination
currentPageState.value = {
page: pageNumber,
first: pageNumber * currentRows.value,
};
}
}
selectedPageJump.value = ""; // Reset selection
};
const getPageInfo = () => {
return paginationStore.getPageInfo(props.tableName);
if (props.lazy) {
return paginationStore.getPageInfo(props.tableName);
}
// For non-lazy tables, calculate based on current page state and filtered data
const filteredTotal = getFilteredDataLength.value;
const rows = currentRows.value;
const currentFirst = currentPageState.value.first;
const start = filteredTotal > 0 ? currentFirst + 1 : 0;
const end = Math.min(filteredTotal, currentFirst + rows);
return {
start,
end,
total: filteredTotal,
};
};
// Handle pagination events
const handlePage = (event) => {
console.log("Page event:", event);
// Update current page state for both lazy and non-lazy tables
currentPageState.value = {
page: event.page,
first: event.first,
};
if (props.lazy) {
paginationStore.updateTablePagination(props.tableName, {
page: event.page,
@ -424,8 +498,11 @@ const handleSort = (event) => {
// Handle filter events
const handleFilter = (event) => {
console.log("Filter event:", event);
// Reset to first page when filters change (for both lazy and non-lazy)
currentPageState.value = { page: 0, first: 0 };
if (props.lazy) {
// Reset to first page when filters change
paginationStore.resetToFirstPage(props.tableName);
triggerLazyLoad();
}
@ -453,6 +530,37 @@ const triggerLazyLoad = () => {
}
};
// Trigger data refresh for non-lazy tables when filters change
const triggerDataRefresh = () => {
const filters = filtersStore.getTableFilters(props.tableName);
const refreshEvent = {
filters: filters,
page: currentPageState.value.page,
first: currentPageState.value.first,
rows: currentRows.value,
};
console.log("Triggering data refresh with:", refreshEvent);
emit("data-refresh", refreshEvent);
};
const handleFilterInput = (fieldName, value, filterCallback) => {
// Update the filter store
filtersStore.updateTableFilter(props.tableName, fieldName, value, FilterMatchMode.CONTAINS);
// Call the PrimeVue callback to update the filter
if (filterCallback) {
filterCallback();
}
// For non-lazy tables, also trigger a data refresh when individual filters change
if (!props.lazy) {
// Reset to first page when filters change
currentPageState.value = { page: 0, first: 0 };
triggerDataRefresh();
}
};
const getBadgeColor = (status) => {
console.log("DEBUG: - getBadgeColor status", status);
switch (status?.toLowerCase()) {
@ -498,6 +606,8 @@ defineExpose({
refresh: () => {
if (props.lazy) {
triggerLazyLoad();
} else {
triggerDataRefresh();
}
},

View File

@ -1,5 +1,5 @@
<template>
<form @submit.prevent="handleSubmit" class="dynamic-form">
<form class="dynamic-form">
<div class="form-container">
<div class="form-row">
<div
@ -372,11 +372,12 @@
<div v-if="showSubmitButton || showCancelButton" class="form-buttons">
<Button
v-if="showSubmitButton"
type="submit"
type="button"
:label="submitButtonText"
:loading="isLoading"
:disabled="isFormDisabled"
severity="primary"
@click="handleSubmit"
/>
<Button
v-if="showCancelButton"
@ -839,9 +840,15 @@ const validateForm = () => {
// Handle form submission
const handleSubmit = async () => {
// Prevent double submission
if (isSubmitting.value) {
console.warn("Form: submission already in progress, ignoring duplicate submission");
return;
}
// Always validate on submit if enabled
if (props.validateOnSubmit && !validateForm()) {
console.warn("Form validation failed on submit");
console.warn("Form: validation failed on submit");
return;
}
@ -849,17 +856,23 @@ const handleSubmit = async () => {
try {
const formData = getCurrentFormData();
console.log("Form: emitting submit event with data:", formData);
// Only emit the submit event - let parent handle the actual submission
// This prevents the dual submission pathway issue
emit("submit", formData);
// Call onSubmit prop if provided (for backward compatibility)
if (props.onSubmit && typeof props.onSubmit === "function") {
await props.onSubmit(formData);
}
emit("submit", formData);
} catch (error) {
console.error("Form submission error:", error);
} finally {
console.error("Form: submission error:", error);
// Reset isSubmitting on error so user can retry
isSubmitting.value = false;
}
// Note: Don't reset isSubmitting.value here in finally - let parent control this
// The parent should call the exposed stopLoading() method when done
};
// Handle cancel action

View File

@ -14,12 +14,15 @@
</div>
<Form
ref="formRef"
:fields="formFields"
:form-data="formData"
:show-cancel-button="true"
:validate-on-change="false"
:validate-on-blur="true"
:validate-on-submit="true"
:loading="isSubmitting"
:disable-on-loading="true"
submit-button-text="Create Client"
cancel-button-text="Cancel"
@submit="handleSubmit"
@ -41,6 +44,8 @@ const modalStore = useModalStore();
// Modal visibility computed property
const isVisible = computed(() => modalStore.isModalOpen("createClient"));
const customerNames = ref([]);
// Form reference for controlling its state
const formRef = ref(null);
// Form data
const formData = reactive({
customertype: "",
@ -78,6 +83,16 @@ const modalOptions = {
// Form field definitions
const formFields = computed(() => [
{
name: "addressTitle",
label: "Address Title",
type: "text",
required: true,
placeholder: "Enter address title",
helpText: "A short title to identify this address (e.g., Johnson Home, Johnson Office)",
cols: 12,
md: 6,
},
{
name: "customertype",
label: "Client Type",
@ -318,15 +333,38 @@ function getStatusIcon(type) {
}
}
// Submission state to prevent double submission
const isSubmitting = ref(false);
// Handle form submission
async function handleSubmit() {
async function handleSubmit(formDataFromEvent) {
// Prevent double submission with detailed logging
if (isSubmitting.value) {
console.warn(
"CreateClientModal: Form submission already in progress, ignoring duplicate submission",
);
return;
}
console.log(
"CreateClientModal: Form submission started with data:",
formDataFromEvent || formData,
);
isSubmitting.value = true;
try {
showStatusMessage("Creating client...", "info");
// Convert form data to the expected format
// Use the form data from the event if provided, otherwise use reactive formData
const dataToSubmit = formDataFromEvent || formData;
console.log("CreateClientModal: Calling API with data:", dataToSubmit);
// Call API to create client
const response = await Api.createClient(formData);
const response = await Api.createClient(dataToSubmit);
console.log("CreateClientModal: API response received:", response);
if (response && response.success) {
showStatusMessage("Client created successfully!", "success");
@ -339,8 +377,15 @@ async function handleSubmit() {
throw new Error(response?.message || "Failed to create client");
}
} catch (error) {
console.error("Error creating client:", error);
console.error("CreateClientModal: Error creating client:", error);
showStatusMessage(error.message || "Failed to create client. Please try again.", "error");
} finally {
isSubmitting.value = false;
// Also reset the Form component's internal submission state
if (formRef.value && formRef.value.stopLoading) {
formRef.value.stopLoading();
}
console.log("CreateClientModal: Form submission completed, isSubmitting reset to false");
}
}
@ -351,7 +396,7 @@ function handleCancel() {
// Handle modal close
function handleClose() {
modalStore.closeCreateClient();
modalStore.closeModal("createClient");
resetForm();
}
@ -383,13 +428,9 @@ watch(isVisible, async () => {
if (isVisible.value) {
try {
const names = await Api.getCustomerNames();
console.log("Loaded customer names:", names);
customerNames.value = names;
} catch (error) {
console.error("Error loading customer names:", error);
// Set some test data to debug if autocomplete works
customerNames.value = ["Test Customer 1", "Test Customer 2", "Another Client"];
console.log("Using test customer names:", customerNames.value);
}
}
});

View File

@ -90,6 +90,12 @@ const handleLazyLoad = async (event) => {
});
}
// Clear cache when filters are active to ensure fresh data
const hasActiveFilters = Object.keys(filters).length > 0;
if (hasActiveFilters) {
paginationStore.clearTableCache("clients");
}
// Check cache first
const cachedData = paginationStore.getCachedPage(
"clients",

View File

@ -1697,13 +1697,25 @@ class DataUtils {
static toSnakeCaseObject(obj) {
const newObj = Object.entries(obj).reduce((acc, [key, value]) => {
const snakeKey = key.replace(/[A-Z]/g, "_$1").toLowerCase();
const snakeKey = key.replace(/[A-Z]/g, (match) => "_" + match.toLowerCase());
value = Object.prototype.toString.call(value) === "[object Object]" ? this.toSnakeCaseObject(value) : value;
acc[snakeKey] = value;
return acc;
}, {});
console.log("DEBUG: toSnakeCaseObject -> newObj", newObj);
return newObj;
}
static toCamelCaseObject(obj) {
const newObj = Object.entries(obj).reduce((acc, [key, value]) => {
const camelKey = key.replace(/_([a-z])/g, (match, p1) => p1.toUpperCase());
value = Object.prototype.toString.call(value) === "[object Object]" ? this.toCamelCaseObject(value) : value;
acc[camelKey] = value;
return acc;
}, {});
console.log("DEBUG: toCamelCaseObject -> newObj", newObj);
return newObj;
}
}
export default DataUtils;