diff --git a/custom_ui/api/db.py b/custom_ui/api/db.py index 64cc4a0..dca46d0 100644 --- a/custom_ui/api/db.py +++ b/custom_ui/api/db.py @@ -1,4 +1,5 @@ -import frappe, json +import frappe, json, re +from datetime import datetime, date from custom_ui.db_utils import calculate_appointment_scheduled_status, calculate_estimate_sent_status, calculate_payment_recieved_status, calculate_job_status @frappe.whitelist() @@ -316,5 +317,157 @@ def get_jobs(options): } @frappe.whitelist() -def upsert_estimate(): - pass +def get_warranty_claims(options): + options = json.loads(options) + print("DEBUG: Raw warranty options received:", options) + defaultOptions = { + "fields": ["*"], + "filters": {}, + "sorting": {}, + "page": 1, + "page_size": 10, + "for_table": False + } + options = {**defaultOptions, **options} + print("DEBUG: Final warranty options:", options) + + warranties = [] + tableRows = [] + + # Map frontend field names to backend field names for Warranty Claim doctype + def map_warranty_field_name(frontend_field): + field_mapping = { + "warrantyId": "name", + "customer": "customer_name", + "serviceAddress": "service_address", + "complaint": "complaint", + "status": "status", + "complaintDate": "complaint_date", + "complaintRaisedBy": "complaint_raised_by", + "fromCompany": "from_company", + "territory": "territory", + "resolutionDate": "resolution_date", + "warrantyStatus": "warranty_amc_status" + } + return field_mapping.get(frontend_field, frontend_field) + + # Process filters from PrimeVue format to Frappe format + processed_filters = {} + if options["filters"]: + for field_name, filter_obj in options["filters"].items(): + if isinstance(filter_obj, dict) and "value" in filter_obj: + if filter_obj["value"] is not None and filter_obj["value"] != "": + # Map frontend field names to backend field names + backend_field = map_warranty_field_name(field_name) + + # Handle different match modes + match_mode = filter_obj.get("matchMode", "contains") + if isinstance(match_mode, str): + match_mode = match_mode.lower() + + if match_mode in ("contains", "contains"): + processed_filters[backend_field] = ["like", f"%{filter_obj['value']}%"] + elif match_mode in ("startswith", "startsWith"): + processed_filters[backend_field] = ["like", f"{filter_obj['value']}%"] + elif match_mode in ("endswith", "endsWith"): + processed_filters[backend_field] = ["like", f"%{filter_obj['value']}"] + elif match_mode in ("equals", "equals"): + processed_filters[backend_field] = filter_obj["value"] + else: + # Default to contains + processed_filters[backend_field] = ["like", f"%{filter_obj['value']}%"] + + # Process sorting + order_by = None + if options.get("sorting") and options["sorting"]: + sorting_str = options["sorting"] + if sorting_str and sorting_str.strip(): + # Parse "field_name asc/desc" format + parts = sorting_str.strip().split() + if len(parts) >= 2: + sort_field = parts[0] + sort_direction = parts[1].lower() + # Map frontend field to backend field + backend_sort_field = map_warranty_field_name(sort_field) + order_by = f"{backend_sort_field} {sort_direction}" + + print("DEBUG: Processed warranty filters:", processed_filters) + print("DEBUG: Warranty order by:", order_by) + + count = frappe.db.count("Warranty Claim", filters=processed_filters) + print("DEBUG: Total warranty claims count:", count) + + warranty_claims = frappe.db.get_all( + "Warranty Claim", + fields=options["fields"], + filters=processed_filters, + limit=options["page_size"], + start=(options["page"] - 1) * options["page_size"], + order_by=order_by + ) + + for warranty in warranty_claims: + warranty_obj = {} + tableRow = {} + + tableRow["id"] = warranty["name"] + tableRow["warrantyId"] = warranty["name"] + tableRow["customer"] = warranty.get("customer_name", "") + tableRow["serviceAddress"] = warranty.get("service_address", warranty.get("address_display", "")) + + # Extract a brief description from the complaint HTML + complaint_text = warranty.get("complaint", "") + if complaint_text: + # Simple HTML stripping for display - take first 100 chars + clean_text = re.sub('<.*?>', '', complaint_text) + clean_text = clean_text.strip() + if len(clean_text) > 100: + clean_text = clean_text[:100] + "..." + tableRow["issueDescription"] = clean_text + else: + tableRow["issueDescription"] = "" + + tableRow["status"] = warranty.get("status", "") + tableRow["complaintDate"] = warranty.get("complaint_date", "") + tableRow["complaintRaisedBy"] = warranty.get("complaint_raised_by", "") + tableRow["fromCompany"] = warranty.get("from_company", "") + tableRow["territory"] = warranty.get("territory", "") + tableRow["resolutionDate"] = warranty.get("resolution_date", "") + tableRow["warrantyStatus"] = warranty.get("warranty_amc_status", "") + + # Add priority based on status and date (can be customized) + if warranty.get("status") == "Open": + # Calculate priority based on complaint date + if warranty.get("complaint_date"): + complaint_date = warranty.get("complaint_date") + if isinstance(complaint_date, str): + complaint_date = datetime.strptime(complaint_date, "%Y-%m-%d").date() + elif isinstance(complaint_date, datetime): + complaint_date = complaint_date.date() + + days_old = (date.today() - complaint_date).days + if days_old > 7: + tableRow["priority"] = "High" + elif days_old > 3: + tableRow["priority"] = "Medium" + else: + tableRow["priority"] = "Low" + else: + tableRow["priority"] = "Medium" + else: + tableRow["priority"] = "Low" + + tableRows.append(tableRow) + + warranty_obj["warranty_claim"] = warranty + warranties.append(warranty_obj) + + return { + "pagination": { + "total": count, + "page": options["page"], + "page_size": options["page_size"], + "total_pages": (count + options["page_size"] - 1) // options["page_size"] + }, + "data": tableRows if options["for_table"] else warranties + } \ No newline at end of file diff --git a/frontend/documentation/components/Form.md b/frontend/documentation/components/Form.md index cf8d4f4..2c80671 100644 --- a/frontend/documentation/components/Form.md +++ b/frontend/documentation/components/Form.md @@ -8,8 +8,8 @@ A highly flexible and configurable dynamic form component built with **PrimeVue* ## ✨ New Features (PrimeVue Migration) - **AutoComplete component** - Users can select from suggestions OR enter completely custom values -- **Better date/time pickers** with calendar popup and time selection -- **Improved accessibility** with ARIA support +- **Enhanced Date/Time Pickers** - Comprehensive date handling with multiple formats, time selection, constraints, and smart defaults +- **Better accessibility** with ARIA support - **More flexible styling** with CSS custom properties - **Enhanced mobile responsiveness** with CSS Grid @@ -363,41 +363,152 @@ Radio button group for single selection from multiple options. ### Date Input (`type: 'date'`) -Date picker input field. +Enhanced date picker input field with comprehensive formatting and configuration options. ```javascript +// Basic date input { name: 'birthDate', label: 'Birth Date', type: 'date', required: true, - min: '1900-01-01', - max: '2025-12-31' +} + +// Date with custom format +{ + name: 'eventDate', + label: 'Event Date', + type: 'date', + format: 'YYYY-MM-DD', // or 'mm/dd/yyyy', 'dd/mm/yyyy', etc. + required: true, + placeholder: 'Select event date' +} + +// Date with time picker +{ + name: 'appointmentDateTime', + label: 'Appointment Date & Time', + type: 'date', + showTime: true, + hourFormat: '12', // '12' or '24' + required: true, + defaultToNow: true, // Set to current date/time by default +} + +// Time-only picker +{ + name: 'preferredTime', + label: 'Preferred Time', + type: 'date', + timeOnly: true, + hourFormat: '12', + stepMinute: 15, // 15-minute intervals + defaultValue: 'now' +} + +// Advanced date configuration +{ + name: 'projectDeadline', + label: 'Project Deadline', + type: 'date', + format: 'dd/mm/yyyy', + minDate: 'today', // Can't select past dates + maxDate: '2025-12-31', // Maximum date + defaultToToday: true, + showButtonBar: true, + yearNavigator: true, + monthNavigator: true, + yearRange: '2024:2030', + helpText: 'Select a deadline for the project completion' +} + +// Inline date picker (always visible) +{ + name: 'calendarDate', + label: 'Calendar', + type: 'date', + inline: true, + view: 'date', // 'date', 'month', 'year' + showWeek: true, + defaultValue: 'today' } ``` **Additional Properties:** -- **`min`** (String) - Minimum allowed date (YYYY-MM-DD format) -- **`max`** (String) - Maximum allowed date (YYYY-MM-DD format) +- **`format`** (String) - Date format: `'YYYY-MM-DD'`, `'mm/dd/yyyy'`, `'dd/mm/yyyy'`, `'dd-mm-yyyy'`, `'mm-dd-yyyy'` +- **`dateFormat`** (String) - PrimeVue-specific format string (overrides `format`) +- **`showTime`** (Boolean, default: `false`) - Include time picker +- **`timeOnly`** (Boolean, default: `false`) - Show only time picker (no date) +- **`hourFormat`** (String, default: `'24'`) - Hour format: `'12'` or `'24'` +- **`stepHour`** (Number, default: `1`) - Hour step increment +- **`stepMinute`** (Number, default: `1`) - Minute step increment +- **`showSeconds`** (Boolean, default: `false`) - Show seconds in time picker +- **`stepSecond`** (Number, default: `1`) - Second step increment +- **`minDate`** (String|Date) - Minimum selectable date +- **`maxDate`** (String|Date) - Maximum selectable date +- **`defaultToToday`** (Boolean, default: `false`) - Set default to today's date +- **`defaultToNow`** (Boolean, default: `false`) - Set default to current date/time +- **`showButtonBar`** (Boolean, default: `true`) - Show today/clear buttons +- **`todayButtonLabel`** (String, default: `'Today'`) - Today button text +- **`clearButtonLabel`** (String, default: `'Clear'`) - Clear button text +- **`showWeek`** (Boolean, default: `false`) - Show week numbers +- **`manualInput`** (Boolean, default: `true`) - Allow manual date entry +- **`yearNavigator`** (Boolean, default: `false`) - Show year dropdown +- **`monthNavigator`** (Boolean, default: `false`) - Show month dropdown +- **`yearRange`** (String, default: `'1900:2100'`) - Available year range +- **`inline`** (Boolean, default: `false`) - Display picker inline (always visible) +- **`view`** (String, default: `'date'`) - Default view: `'date'`, `'month'`, `'year'` +- **`touchUI`** (Boolean, default: `false`) - Optimize for touch devices +- **`onDateChange`** (Function) - Custom date change handler + - **Signature:** `(dateValue: Date) => any` -### DateTime Input (`type: 'datetime'`) - -Date and time picker input field. +**Default Value Options:** ```javascript +// String values +defaultValue: "today"; // Set to today's date +defaultValue: "now"; // Set to current date/time +defaultValue: "2024-12-25"; // Specific date string + +// Boolean flags +defaultToToday: true; // Set to today (date only) +defaultToNow: true; // Set to current date/time + +// Date object +defaultValue: new Date(); // Specific Date object +``` + +### DateTime Input (`type: 'datetime'`) - LEGACY + +**⚠️ DEPRECATED:** Use `type: 'date'` with `showTime: true` instead. + +Legacy date and time picker input field. This is maintained for backward compatibility. + +```javascript +// LEGACY - Use date with showTime instead { name: 'appointmentTime', label: 'Appointment Time', type: 'datetime', required: true } + +// RECOMMENDED - Use this instead +{ + name: 'appointmentTime', + label: 'Appointment Time', + type: 'date', + showTime: true, + required: true +} ``` **Additional Properties:** - **`min`** (String) - Minimum allowed datetime - **`max`** (String) - Maximum allowed datetime +- **`hourFormat`** (String, default: `'24'`) - Hour format: `'12'` or `'24'` ### File Input (`type: 'file'`) @@ -526,6 +637,18 @@ const contactFields = [ format: "email", required: true, cols: 12, + md: 6, + }, + { + name: "preferredContactDate", + label: "Preferred Contact Date", + type: "date", + format: "mm/dd/yyyy", + minDate: "today", + defaultToToday: true, + cols: 12, + md: 6, + helpText: "When would you like us to contact you?", }, { name: "message", @@ -675,6 +798,280 @@ const autoCompleteFields = [ ``` +### Enhanced Date Picker Examples + +```vue + + + + + + + + Selected Dates: + + {{ key }}: + + {{ value.toLocaleString() }} + + {{ value }} + + + +``` + +### Real-World Date Picker Scenarios + +```vue + + + + + Hotel Booking + + + Meeting Scheduler + + + Personal Information + + + +``` + +```vue + +``` + ### User Registration Form ```vue @@ -1028,8 +1425,8 @@ The component has been completely migrated from Vuetify to PrimeVue. Here's what ### ✨ What's New - **AutoComplete component** - The star feature! Users can select from suggestions OR enter completely custom values -- **Better date/time pickers** - Calendar popup, time selection, better formatting options -- **Enhanced file uploads** - Drag & drop, better validation, file preview +- **Enhanced Date/Time Pickers** - Multiple format support (`YYYY-MM-DD`, `mm/dd/yyyy`, etc.), smart defaults (`today`, `now`), time-only mode, inline calendars, business hour constraints, and comprehensive validation +- **Better file uploads** - Drag & drop, better validation, file preview - **Improved accessibility** - Full ARIA support, better keyboard navigation - **Flexible styling** - CSS custom properties, easier theming - **Mobile-first responsive** - Better grid system, improved mobile UX diff --git a/frontend/src/api.js b/frontend/src/api.js index cdc1010..a71f8e2 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -305,6 +305,37 @@ class Api { return result; } + /** + * Get paginated warranty claims data with filtering and sorting + * @param {Object} paginationParams - Pagination parameters from store + * @param {Object} filters - Filter parameters from store + * @param {Object} sorting - Sorting parameters from store (optional) + * @returns {Promise<{data: Array, pagination: Object}>} + */ + static async getPaginatedWarrantyData(paginationParams = {}, filters = {}, sorting = null) { + const { page = 0, pageSize = 10, sortField = null, sortOrder = null } = paginationParams; + + // Use sorting from the dedicated sorting parameter first, then fall back to pagination params + const actualSortField = sorting?.field || sortField; + const actualSortOrder = sorting?.order || sortOrder; + + const options = { + page: page + 1, // Backend expects 1-based pages + page_size: pageSize, + filters, + sorting: + actualSortField && actualSortOrder + ? `${actualSortField} ${actualSortOrder === -1 ? "desc" : "asc"}` + : null, + for_table: true, + }; + + console.log("DEBUG: API - Sending warranty options to backend:", options); + + const result = await this.request("custom_ui.api.db.get_warranty_claims", { options }); + return result; + } + /** * Fetch a list of documents from a specific doctype. * diff --git a/frontend/src/components/common/DataTable.vue b/frontend/src/components/common/DataTable.vue index c078a04..7d15c2f 100644 --- a/frontend/src/components/common/DataTable.vue +++ b/frontend/src/components/common/DataTable.vue @@ -135,6 +135,9 @@ :severity="getBadgeColor(slotProps.data[col.fieldName])" /> + + {{ formatDate(slotProps.data[col.fieldName]) }} + { }; const getBadgeColor = (status) => { - console.log("DEBUG: - getBadgeColor status", status); switch (status?.toLowerCase()) { case "completed": - return "success"; // green + case "open": + case "active": + return "success"; case "in progress": - return "warn"; + case "pending": + return "warning"; case "not started": - return "danger"; // red + case "closed": + case "cancelled": + return "danger"; default: - return "info"; // blue fallback + return "info"; + } +}; + +const formatDate = (dateValue) => { + if (!dateValue) return ""; + + try { + // Handle different date formats + let date; + if (typeof dateValue === "string") { + date = new Date(dateValue); + } else if (dateValue instanceof Date) { + date = dateValue; + } else { + return ""; + } + + // Check if date is valid + if (isNaN(date.getTime())) { + return dateValue; // Return original value if can't parse + } + + // Format as MM/DD/YYYY + return date.toLocaleDateString("en-US"); + } catch (error) { + console.error("Error formatting date:", error); + return dateValue; } }; console.log("DEBUG: - DataTable props.columns", props.columns); diff --git a/frontend/src/components/common/Form.vue b/frontend/src/components/common/Form.vue index 0a49965..e44452e 100644 --- a/frontend/src/components/common/Form.vue +++ b/frontend/src/components/common/Form.vue @@ -267,17 +267,35 @@ {{ @@ -293,7 +311,7 @@ - + {{ field.label }} @@ -302,19 +320,35 @@ {{ @@ -536,6 +570,105 @@ const initializeFormData = () => { }); }; +// Date utility functions +const getDateDefaultValue = (field) => { + if (field.defaultValue !== undefined) { + // Handle string date values + if (typeof field.defaultValue === "string") { + if (field.defaultValue === "today" || field.defaultValue === "now") { + return new Date(); + } + // Try to parse the string as a date + const parsed = new Date(field.defaultValue); + return !isNaN(parsed.getTime()) ? parsed : null; + } + // Handle Date objects or null/undefined + return field.defaultValue; + } + + // Set default based on field configuration + if (field.defaultToToday || field.defaultToNow) { + const now = new Date(); + if (field.type === "date" && !field.showTime) { + // For date-only fields, set to start of day + return new Date(now.getFullYear(), now.getMonth(), now.getDate()); + } + return now; + } + + // No default value + return null; +}; + +const parseDateValue = (value) => { + if (!value) return null; + if (value instanceof Date) return value; + if (typeof value === "string") { + const parsed = new Date(value); + return !isNaN(parsed.getTime()) ? parsed : null; + } + return null; +}; + +const getDateFormat = (field) => { + // Return custom format if provided + if (field.dateFormat) { + return field.dateFormat; + } + + // Handle predefined format strings + if (field.format) { + switch (field.format.toLowerCase()) { + case "yyyy-mm-dd": + return "yy-mm-dd"; + case "mm/dd/yyyy": + return "mm/dd/yy"; + case "dd/mm/yyyy": + return "dd/mm/yy"; + case "dd-mm-yyyy": + return "dd-mm-yy"; + case "mm-dd-yyyy": + return "mm-dd-yy"; + default: + break; + } + } + + // Default formats based on field configuration + if (field.showTime || field.type === "datetime") { + return "dd/mm/yy"; // PrimeVue will append time format automatically + } + + return "dd/mm/yy"; // Default date format +}; + +const getDatePlaceholder = (field) => { + if (field.placeholder) return field.placeholder; + + const format = field.format || (field.showTime ? "dd/mm/yyyy hh:mm" : "dd/mm/yyyy"); + + if (field.timeOnly) { + return "Select time"; + } else if (field.showTime || field.type === "datetime") { + return `Enter date and time (${format})`; + } else { + return `Enter date (${format})`; + } +}; + +const handleDateChange = (field, value) => { + // Convert Date object to appropriate format if needed + let processedValue = value; + + // Apply custom formatting if specified + if (field.onDateChange && typeof field.onDateChange === "function") { + processedValue = field.onDateChange(value); + } + + // Call the standard field change handler + handleFieldChange(field, processedValue); +}; + // Get default value for a field based on its type const getDefaultValue = (field) => { switch (field.type) { @@ -548,6 +681,9 @@ const getDefaultValue = (field) => { return field.defaultValue !== undefined ? field.defaultValue : ""; case "file": return null; + case "date": + case "datetime": + return getDateDefaultValue(field); default: return field.defaultValue !== undefined ? field.defaultValue : ""; } @@ -634,6 +770,29 @@ const validateField = (field, value) => { } } + // Date validation + if ((field.type === "date" || field.type === "datetime") && value) { + const dateValue = value instanceof Date ? value : parseDateValue(value); + + if (!dateValue || isNaN(dateValue.getTime())) { + errors.push("Please enter a valid date"); + } else { + // Min/Max date validation + if (field.minDate) { + const minDate = parseDateValue(field.minDate); + if (minDate && dateValue < minDate) { + errors.push(`Date must be on or after ${minDate.toLocaleDateString()}`); + } + } + if (field.maxDate) { + const maxDate = parseDateValue(field.maxDate); + if (maxDate && dateValue > maxDate) { + errors.push(`Date must be on or before ${maxDate.toLocaleDateString()}`); + } + } + } + } + // Custom validation (always runs last) if (field.validate && typeof field.validate === "function") { const customError = field.validate(value); diff --git a/frontend/src/components/pages/TestDateForm.vue b/frontend/src/components/pages/TestDateForm.vue new file mode 100644 index 0000000..1865e34 --- /dev/null +++ b/frontend/src/components/pages/TestDateForm.vue @@ -0,0 +1,204 @@ + + + Enhanced Date Picker Test + This page demonstrates the enhanced date picker functionality in the Form component. + + + + + Current Form Values: + + + {{ formatFieldLabel(key) }}: + + {{ formatDateValue(key, value) }} + + {{ value }} + Not set + + + + + + + + + diff --git a/frontend/src/components/pages/Warranties.vue b/frontend/src/components/pages/Warranties.vue index 416b8f5..ea686d7 100644 --- a/frontend/src/components/pages/Warranties.vue +++ b/frontend/src/components/pages/Warranties.vue @@ -59,8 +59,8 @@ const columns = [ filterable: true, }, { - label: "Address", - fieldName: "address", + label: "Service Address", + fieldName: "serviceAddress", type: "text", sortable: true, filterable: true, @@ -69,38 +69,50 @@ const columns = [ label: "Issue Description", fieldName: "issueDescription", type: "text", - sortable: true, + sortable: false, + filterable: false, }, { label: "Priority", fieldName: "priority", type: "status", sortable: true, + filterable: false, }, { label: "Status", fieldName: "status", type: "status", sortable: true, + filterable: true, }, { - label: "Assigned Technician", - fieldName: "assignedTechnician", + label: "Complaint Date", + fieldName: "complaintDate", + type: "date", + sortable: true, + filterable: false, + }, + { + label: "Raised By", + fieldName: "complaintRaisedBy", type: "text", sortable: true, filterable: true, }, { - label: "Date Reported", - fieldName: "dateReported", + label: "Territory", + fieldName: "territory", type: "text", sortable: true, + filterable: true, }, { - label: "Est. Completion", - fieldName: "estimatedCompletionDate", - type: "text", + label: "Warranty Status", + fieldName: "warrantyStatus", + type: "status", sortable: true, + filterable: true, }, ]; @@ -155,21 +167,18 @@ const handleLazyLoad = async (event) => { console.log("Making API call with:", { paginationParams, filters }); - // For now, use existing API but we should create a paginated version - // TODO: Create Api.getPaginatedWarrantyData() method - let data = await Api.getWarrantyData(); + // Get sorting from store for proper API call + const sorting = filtersStore.getTableSorting("warranties"); - // Simulate pagination on client side for now - const startIndex = paginationParams.page * paginationParams.pageSize; - const endIndex = startIndex + paginationParams.pageSize; - const paginatedData = data.slice(startIndex, endIndex); + // Use the new paginated API method + const result = await Api.getPaginatedWarrantyData(paginationParams, filters, sorting); // Update local state - tableData.value = paginatedData; - totalRecords.value = data.length; + tableData.value = result.data; + totalRecords.value = result.pagination.total; // Update pagination store with new total - paginationStore.setTotalRecords("warranties", data.length); + paginationStore.setTotalRecords("warranties", result.pagination.total); // Cache the result paginationStore.setCachedPage( @@ -180,14 +189,14 @@ const handleLazyLoad = async (event) => { paginationParams.sortOrder, filters, { - records: paginatedData, - totalRecords: data.length, + records: result.data, + totalRecords: result.pagination.total, }, ); console.log("Loaded from API:", { - records: paginatedData.length, - total: data.length, + records: result.data.length, + total: result.pagination.total, page: paginationParams.page + 1, }); } catch (error) { diff --git a/frontend/src/router.js b/frontend/src/router.js index 563711b..98ea0a3 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -8,6 +8,7 @@ import Routes from "./components/pages/Routes.vue"; import TimeSheets from "./components/pages/TimeSheets.vue"; import Warranties from "./components/pages/Warranties.vue"; import Home from "./components/pages/Home.vue"; +import TestDateForm from "./components/pages/TestDateForm.vue"; const routes = [ { @@ -21,6 +22,7 @@ const routes = [ { path: "/create", component: Create }, { path: "/timesheets", component: TimeSheets }, { path: "/warranties", component: Warranties }, + { path: "/test-dates", component: TestDateForm }, ]; const router = createRouter({
This page demonstrates the enhanced date picker functionality in the Form component.