From 8f9da2fbf047788403de9a4dc53672c40ddbe391 Mon Sep 17 00:00:00 2001 From: John Mizerek Date: Wed, 28 Jan 2026 14:43:41 -0800 Subject: [PATCH] Add Manage Menus toolbar button, photo upload, and various improvements - Move menu manager button to toolbar next to Save Menu for visibility - Implement server-side photo upload for menu items - Strip base64 data URLs from save payload to reduce size - Add scheduled tasks, quick tasks, ratings, and task categories APIs - Add vertical support and brand color features Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 5 + api/admin/addCategoryIsActiveColumn.cfm | 37 ++ api/admin/addServiceCategoryColumn.cfm | 37 ++ api/admin/fixOrphanedTaskCategories.cfm | 37 ++ api/admin/quickTasks/addTypeIdColumn.cfm | 37 ++ api/admin/quickTasks/cleanup.cfm | 21 + api/admin/quickTasks/create.cfm | 16 +- api/admin/quickTasks/debug.cfm | 39 ++ api/admin/quickTasks/list.cfm | 2 - api/admin/quickTasks/purge.cfm | 20 + api/admin/quickTasks/save.cfm | 29 +- api/admin/scheduledTasks/list.cfm | 6 +- api/admin/scheduledTasks/run.cfm | 16 +- api/admin/scheduledTasks/runDue.cfm | 34 +- api/admin/scheduledTasks/save.cfm | 113 +++- api/admin/scheduledTasks/setup.cfm | 23 +- api/admin/scheduledTasks/toggle.cfm | 16 +- api/businesses/get.cfm | 6 +- api/businesses/saveBrandColor.cfm | 13 +- api/config/stripe.cfm | 2 +- api/menu/getForBuilder.cfm | 12 +- api/menu/uploadItemPhoto.cfm | 68 +++ api/orders/getDetail.cfm | 126 +++-- api/ratings/createAdminRating.cfm | 122 +++++ api/ratings/listForAdmin.cfm | 92 ++++ api/ratings/setup.cfm | 59 ++ api/ratings/submit.cfm | 144 +++++ api/setup/analyzeMenuImages.cfm | 50 +- api/setup/saveWizard.cfm | 15 +- api/tasks/callServer.cfm | 52 +- api/tasks/complete.cfm | 94 +++- api/tasks/deleteCategory.cfm | 107 ++++ api/tasks/listAllTypes.cfm | 6 +- api/tasks/listCategories.cfm | 83 +++ api/tasks/saveCategory.cfm | 124 +++++ api/tasks/saveType.cfm | 19 +- api/tasks/seedCategories.cfm | 137 +++++ hud/index.html | 58 +- portal/index.html | 15 + portal/menu-builder.html | 86 ++- portal/portal.css | 16 +- portal/portal.js | 10 + portal/setup-wizard.html | 281 +++++++++- verticals/hospitals.html | 655 +++++++++++++++++++++++ 44 files changed, 2709 insertions(+), 231 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 api/admin/addCategoryIsActiveColumn.cfm create mode 100644 api/admin/addServiceCategoryColumn.cfm create mode 100644 api/admin/fixOrphanedTaskCategories.cfm create mode 100644 api/admin/quickTasks/addTypeIdColumn.cfm create mode 100644 api/admin/quickTasks/cleanup.cfm create mode 100644 api/admin/quickTasks/debug.cfm create mode 100644 api/admin/quickTasks/purge.cfm create mode 100644 api/menu/uploadItemPhoto.cfm create mode 100644 api/ratings/createAdminRating.cfm create mode 100644 api/ratings/listForAdmin.cfm create mode 100644 api/ratings/setup.cfm create mode 100644 api/ratings/submit.cfm create mode 100644 api/tasks/deleteCategory.cfm create mode 100644 api/tasks/listCategories.cfm create mode 100644 api/tasks/saveCategory.cfm create mode 100644 api/tasks/seedCategories.cfm create mode 100644 verticals/hospitals.html diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fca26fd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Payfrit Portal Updates + +## Week of 2026-01-26 + +- Fixed saved cart not being detected when entering child business diff --git a/api/admin/addCategoryIsActiveColumn.cfm b/api/admin/addCategoryIsActiveColumn.cfm new file mode 100644 index 0000000..65f53ab --- /dev/null +++ b/api/admin/addCategoryIsActiveColumn.cfm @@ -0,0 +1,37 @@ + + + + +// Add TaskCategoryIsActive column to TaskCategories table +try { + // Check if column exists + qCheck = queryExecute(" + SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = 'payfrit' + AND TABLE_NAME = 'TaskCategories' + AND COLUMN_NAME = 'TaskCategoryIsActive' + ", [], { datasource: "payfrit" }); + + if (qCheck.recordCount == 0) { + queryExecute(" + ALTER TABLE TaskCategories + ADD COLUMN TaskCategoryIsActive TINYINT(1) NOT NULL DEFAULT 1 + ", [], { datasource: "payfrit" }); + + writeOutput(serializeJSON({ + "OK": true, + "MESSAGE": "Column TaskCategoryIsActive added to TaskCategories" + })); + } else { + writeOutput(serializeJSON({ + "OK": true, + "MESSAGE": "Column already exists" + })); + } +} catch (any e) { + writeOutput(serializeJSON({ + "OK": false, + "ERROR": e.message + })); +} + diff --git a/api/admin/addServiceCategoryColumn.cfm b/api/admin/addServiceCategoryColumn.cfm new file mode 100644 index 0000000..9a7939d --- /dev/null +++ b/api/admin/addServiceCategoryColumn.cfm @@ -0,0 +1,37 @@ + + + + +// Add CategoryID column to tt_TaskTypes (Services) table +try { + // Check if column exists + qCheck = queryExecute(" + SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = 'payfrit' + AND TABLE_NAME = 'tt_TaskTypes' + AND COLUMN_NAME = 'tt_TaskTypeCategoryID' + ", [], { datasource: "payfrit" }); + + if (qCheck.recordCount == 0) { + queryExecute(" + ALTER TABLE tt_TaskTypes + ADD COLUMN tt_TaskTypeCategoryID INT NULL + ", [], { datasource: "payfrit" }); + + writeOutput(serializeJSON({ + "OK": true, + "MESSAGE": "Column tt_TaskTypeCategoryID added to tt_TaskTypes" + })); + } else { + writeOutput(serializeJSON({ + "OK": true, + "MESSAGE": "Column already exists" + })); + } +} catch (any e) { + writeOutput(serializeJSON({ + "OK": false, + "ERROR": e.message + })); +} + diff --git a/api/admin/fixOrphanedTaskCategories.cfm b/api/admin/fixOrphanedTaskCategories.cfm new file mode 100644 index 0000000..c2f8d15 --- /dev/null +++ b/api/admin/fixOrphanedTaskCategories.cfm @@ -0,0 +1,37 @@ + + + + +// Fix orphaned tasks that had old category ID 4 (deleted Service Point) +// Update them to use new category ID 25 (new Service Point) +try { + // First check how many tasks are affected + qCount = queryExecute(" + SELECT COUNT(*) as cnt FROM Tasks WHERE TaskCategoryID = 4 + ", [], { datasource: "payfrit" }); + + affectedCount = qCount.cnt; + + if (affectedCount > 0) { + // Update them to the new category + queryExecute(" + UPDATE Tasks SET TaskCategoryID = 25 WHERE TaskCategoryID = 4 + ", [], { datasource: "payfrit" }); + + writeOutput(serializeJSON({ + "OK": true, + "MESSAGE": "Updated " & affectedCount & " tasks from category 4 to category 25" + })); + } else { + writeOutput(serializeJSON({ + "OK": true, + "MESSAGE": "No tasks found with category ID 4" + })); + } +} catch (any e) { + writeOutput(serializeJSON({ + "OK": false, + "ERROR": e.message + })); +} + diff --git a/api/admin/quickTasks/addTypeIdColumn.cfm b/api/admin/quickTasks/addTypeIdColumn.cfm new file mode 100644 index 0000000..5f1a566 --- /dev/null +++ b/api/admin/quickTasks/addTypeIdColumn.cfm @@ -0,0 +1,37 @@ + + + + +// Add QuickTaskTemplateTypeID column to QuickTaskTemplates table if it doesn't exist +try { + // Check if column exists + qCheck = queryExecute(" + SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = 'payfrit' + AND TABLE_NAME = 'QuickTaskTemplates' + AND COLUMN_NAME = 'QuickTaskTemplateTypeID' + ", [], { datasource: "payfrit" }); + + if (qCheck.recordCount == 0) { + queryExecute(" + ALTER TABLE QuickTaskTemplates + ADD COLUMN QuickTaskTemplateTypeID INT NULL AFTER QuickTaskTemplateCategoryID + ", [], { datasource: "payfrit" }); + + writeOutput(serializeJSON({ + "OK": true, + "MESSAGE": "Column QuickTaskTemplateTypeID added to QuickTaskTemplates" + })); + } else { + writeOutput(serializeJSON({ + "OK": true, + "MESSAGE": "Column already exists" + })); + } +} catch (any e) { + writeOutput(serializeJSON({ + "OK": false, + "ERROR": e.message + })); +} + diff --git a/api/admin/quickTasks/cleanup.cfm b/api/admin/quickTasks/cleanup.cfm new file mode 100644 index 0000000..e4d862f --- /dev/null +++ b/api/admin/quickTasks/cleanup.cfm @@ -0,0 +1,21 @@ + + + + + +// One-time cleanup: delete test tasks and reset +try { + // Delete tasks 30, 31, 32 (test tasks with bad data) + queryExecute("DELETE FROM Tasks WHERE TaskID IN (30, 31, 32)", [], { datasource: "payfrit" }); + + writeOutput(serializeJSON({ + "OK": true, + "MESSAGE": "Cleanup complete - deleted test tasks" + })); +} catch (any e) { + writeOutput(serializeJSON({ + "OK": false, + "ERROR": e.message + })); +} + diff --git a/api/admin/quickTasks/create.cfm b/api/admin/quickTasks/create.cfm index 4df3542..e8f9a34 100644 --- a/api/admin/quickTasks/create.cfm +++ b/api/admin/quickTasks/create.cfm @@ -52,8 +52,7 @@ try { SELECT QuickTaskTemplateTitle as Title, QuickTaskTemplateDetails as Details, - QuickTaskTemplateCategoryID as CategoryID, - QuickTaskTemplateTypeID as TypeID + QuickTaskTemplateCategoryID as CategoryID FROM QuickTaskTemplates WHERE QuickTaskTemplateID = :id AND QuickTaskTemplateBusinessID = :businessID @@ -67,24 +66,21 @@ try { apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Template not found" }); } - // Create the task + // Create the task (TaskClaimedByUserID=0 means unclaimed/pending) queryExecute(" INSERT INTO Tasks ( TaskBusinessID, TaskCategoryID, TaskTypeID, - TaskTitle, TaskDetails, TaskStatusID, TaskAddedOn, - TaskSourceType, TaskSourceID + TaskTitle, TaskDetails, TaskAddedOn, TaskClaimedByUserID ) VALUES ( :businessID, :categoryID, :typeID, - :title, :details, 0, NOW(), - 'quicktask', :templateID + :title, :details, NOW(), 0 ) ", { businessID: { value: businessID, cfsqltype: "cf_sql_integer" }, categoryID: { value: qTemplate.CategoryID, cfsqltype: "cf_sql_integer", null: isNull(qTemplate.CategoryID) }, - typeID: { value: qTemplate.TypeID, cfsqltype: "cf_sql_integer", null: isNull(qTemplate.TypeID) }, + typeID: { value: 0, cfsqltype: "cf_sql_integer" }, title: { value: qTemplate.Title, cfsqltype: "cf_sql_varchar" }, - details: { value: qTemplate.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(qTemplate.Details) }, - templateID: { value: templateID, cfsqltype: "cf_sql_integer" } + details: { value: qTemplate.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(qTemplate.Details) } }, { datasource: "payfrit" }); qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" }); diff --git a/api/admin/quickTasks/debug.cfm b/api/admin/quickTasks/debug.cfm new file mode 100644 index 0000000..f61acc8 --- /dev/null +++ b/api/admin/quickTasks/debug.cfm @@ -0,0 +1,39 @@ + + + + + +try { + q = queryExecute(" + SELECT TaskID, TaskTitle, TaskDetails, TaskCategoryID, TaskClaimedByUserID, TaskCompletedOn, TaskAddedOn + FROM Tasks + WHERE TaskBusinessID = 47 + ORDER BY TaskID DESC + LIMIT 20 + ", [], { datasource: "payfrit" }); + + tasks = []; + for (row in q) { + arrayAppend(tasks, { + "TaskID": row.TaskID, + "Title": row.TaskTitle, + "Details": isNull(row.TaskDetails) ? "" : row.TaskDetails, + "CategoryID": row.TaskCategoryID, + "ClaimedByUserID": row.TaskClaimedByUserID, + "CompletedOn": isNull(row.TaskCompletedOn) ? "" : dateTimeFormat(row.TaskCompletedOn, "yyyy-mm-dd HH:nn:ss"), + "AddedOn": isNull(row.TaskAddedOn) ? "" : dateTimeFormat(row.TaskAddedOn, "yyyy-mm-dd HH:nn:ss") + }); + } + + writeOutput(serializeJSON({ + "OK": true, + "COUNT": arrayLen(tasks), + "TASKS": tasks + })); +} catch (any e) { + writeOutput(serializeJSON({ + "OK": false, + "ERROR": e.message + })); +} + diff --git a/api/admin/quickTasks/list.cfm b/api/admin/quickTasks/list.cfm index ad6c544..bda5250 100644 --- a/api/admin/quickTasks/list.cfm +++ b/api/admin/quickTasks/list.cfm @@ -48,7 +48,6 @@ try { qt.QuickTaskTemplateID, qt.QuickTaskTemplateName as Name, qt.QuickTaskTemplateCategoryID as CategoryID, - qt.QuickTaskTemplateTypeID as TypeID, qt.QuickTaskTemplateTitle as Title, qt.QuickTaskTemplateDetails as Details, qt.QuickTaskTemplateIcon as Icon, @@ -72,7 +71,6 @@ try { "QuickTaskTemplateID": row.QuickTaskTemplateID, "Name": row.Name, "CategoryID": isNull(row.CategoryID) ? "" : row.CategoryID, - "TypeID": isNull(row.TypeID) ? "" : row.TypeID, "Title": row.Title, "Details": isNull(row.Details) ? "" : row.Details, "Icon": isNull(row.Icon) ? "add_box" : row.Icon, diff --git a/api/admin/quickTasks/purge.cfm b/api/admin/quickTasks/purge.cfm new file mode 100644 index 0000000..88807bc --- /dev/null +++ b/api/admin/quickTasks/purge.cfm @@ -0,0 +1,20 @@ + + + + + +try { + // Delete all Quick Task templates for business 1 + queryExecute("DELETE FROM QuickTaskTemplates WHERE QuickTaskTemplateBusinessID = 1", [], { datasource: "payfrit" }); + + writeOutput(serializeJSON({ + "OK": true, + "MESSAGE": "All Quick Task templates purged" + })); +} catch (any e) { + writeOutput(serializeJSON({ + "OK": false, + "ERROR": e.message + })); +} + diff --git a/api/admin/quickTasks/save.cfm b/api/admin/quickTasks/save.cfm index c877224..a84efad 100644 --- a/api/admin/quickTasks/save.cfm +++ b/api/admin/quickTasks/save.cfm @@ -5,7 +5,7 @@ // Create or update a quick task template // Input: BusinessID (required), QuickTaskTemplateID (optional - for update), -// Name, Title, Details, CategoryID, TypeID, Icon, Color +// Name, Title, Details, CategoryID, Icon, Color // Output: { OK: true, TEMPLATE_ID: int } function apiAbort(required struct payload) { @@ -46,8 +46,12 @@ try { templateName = structKeyExists(data, "Name") ? trim(toString(data.Name)) : ""; templateTitle = structKeyExists(data, "Title") ? trim(toString(data.Title)) : ""; templateDetails = structKeyExists(data, "Details") ? trim(toString(data.Details)) : ""; - categoryID = structKeyExists(data, "CategoryID") && isNumeric(data.CategoryID) && data.CategoryID > 0 ? int(data.CategoryID) : javaCast("null", ""); - typeID = structKeyExists(data, "TypeID") && isNumeric(data.TypeID) && data.TypeID > 0 ? int(data.TypeID) : javaCast("null", ""); + hasCategory = false; + catID = 0; + if (structKeyExists(data, "CategoryID") && isNumeric(data.CategoryID) && data.CategoryID > 0) { + catID = int(data.CategoryID); + hasCategory = true; + } templateIcon = structKeyExists(data, "Icon") && len(trim(data.Icon)) ? trim(toString(data.Icon)) : "add_box"; templateColor = structKeyExists(data, "Color") && len(trim(data.Color)) ? trim(toString(data.Color)) : "##6366f1"; @@ -58,6 +62,9 @@ try { if (!len(templateTitle)) { apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Title is required" }); } + if (!hasCategory) { + apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Please select a category" }); + } if (templateID > 0) { // UPDATE existing template @@ -79,7 +86,6 @@ try { QuickTaskTemplateTitle = :title, QuickTaskTemplateDetails = :details, QuickTaskTemplateCategoryID = :categoryID, - QuickTaskTemplateTypeID = :typeID, QuickTaskTemplateIcon = :icon, QuickTaskTemplateColor = :color WHERE QuickTaskTemplateID = :id @@ -87,8 +93,7 @@ try { name: { value: templateName, cfsqltype: "cf_sql_varchar" }, title: { value: templateTitle, cfsqltype: "cf_sql_varchar" }, details: { value: templateDetails, cfsqltype: "cf_sql_longvarchar", null: !len(templateDetails) }, - categoryID: { value: categoryID, cfsqltype: "cf_sql_integer", null: isNull(categoryID) }, - typeID: { value: typeID, cfsqltype: "cf_sql_integer", null: isNull(typeID) }, + categoryID: { value: catID, cfsqltype: "cf_sql_integer", null: !hasCategory }, icon: { value: templateIcon, cfsqltype: "cf_sql_varchar" }, color: { value: templateColor, cfsqltype: "cf_sql_varchar" }, id: { value: templateID, cfsqltype: "cf_sql_integer" } @@ -115,18 +120,17 @@ try { queryExecute(" INSERT INTO QuickTaskTemplates ( QuickTaskTemplateBusinessID, QuickTaskTemplateName, QuickTaskTemplateTitle, - QuickTaskTemplateDetails, QuickTaskTemplateCategoryID, QuickTaskTemplateTypeID, + QuickTaskTemplateDetails, QuickTaskTemplateCategoryID, QuickTaskTemplateIcon, QuickTaskTemplateColor, QuickTaskTemplateSortOrder ) VALUES ( - :businessID, :name, :title, :details, :categoryID, :typeID, :icon, :color, :sortOrder + :businessID, :name, :title, :details, :categoryID, :icon, :color, :sortOrder ) ", { businessID: { value: businessID, cfsqltype: "cf_sql_integer" }, name: { value: templateName, cfsqltype: "cf_sql_varchar" }, title: { value: templateTitle, cfsqltype: "cf_sql_varchar" }, details: { value: templateDetails, cfsqltype: "cf_sql_longvarchar", null: !len(templateDetails) }, - categoryID: { value: categoryID, cfsqltype: "cf_sql_integer", null: isNull(categoryID) }, - typeID: { value: typeID, cfsqltype: "cf_sql_integer", null: isNull(typeID) }, + categoryID: { value: catID, cfsqltype: "cf_sql_integer", null: !hasCategory }, icon: { value: templateIcon, cfsqltype: "cf_sql_varchar" }, color: { value: templateColor, cfsqltype: "cf_sql_varchar" }, sortOrder: { value: nextSort, cfsqltype: "cf_sql_integer" } @@ -145,7 +149,10 @@ try { apiAbort({ "OK": false, "ERROR": "server_error", - "MESSAGE": e.message + "MESSAGE": e.message, + "DETAIL": structKeyExists(e, "detail") ? e.detail : "none", + "TYPE": structKeyExists(e, "type") ? e.type : "none", + "TAG": (structKeyExists(e, "tagContext") && isArray(e.tagContext) && arrayLen(e.tagContext) > 0) ? serializeJSON(e.tagContext[1]) : "none" }); } diff --git a/api/admin/scheduledTasks/list.cfm b/api/admin/scheduledTasks/list.cfm index 3ebc1e9..53dda03 100644 --- a/api/admin/scheduledTasks/list.cfm +++ b/api/admin/scheduledTasks/list.cfm @@ -48,10 +48,11 @@ try { st.ScheduledTaskID, st.ScheduledTaskName as Name, st.ScheduledTaskCategoryID as CategoryID, - st.ScheduledTaskTypeID as TypeID, st.ScheduledTaskTitle as Title, st.ScheduledTaskDetails as Details, st.ScheduledTaskCronExpression as CronExpression, + COALESCE(st.ScheduledTaskScheduleType, 'cron') as ScheduleType, + st.ScheduledTaskIntervalMinutes as IntervalMinutes, st.ScheduledTaskIsActive as IsActive, st.ScheduledTaskLastRunOn as LastRunOn, st.ScheduledTaskNextRunOn as NextRunOn, @@ -72,10 +73,11 @@ try { "ScheduledTaskID": row.ScheduledTaskID, "Name": row.Name, "CategoryID": isNull(row.CategoryID) ? "" : row.CategoryID, - "TypeID": isNull(row.TypeID) ? "" : row.TypeID, "Title": row.Title, "Details": isNull(row.Details) ? "" : row.Details, "CronExpression": row.CronExpression, + "ScheduleType": row.ScheduleType, + "IntervalMinutes": isNull(row.IntervalMinutes) ? "" : row.IntervalMinutes, "IsActive": row.IsActive ? true : false, "LastRunOn": isNull(row.LastRunOn) ? "" : dateTimeFormat(row.LastRunOn, "yyyy-mm-dd HH:nn:ss"), "NextRunOn": isNull(row.NextRunOn) ? "" : dateTimeFormat(row.NextRunOn, "yyyy-mm-dd HH:nn:ss"), diff --git a/api/admin/scheduledTasks/run.cfm b/api/admin/scheduledTasks/run.cfm index 1cb1b19..d16cdd6 100644 --- a/api/admin/scheduledTasks/run.cfm +++ b/api/admin/scheduledTasks/run.cfm @@ -53,8 +53,7 @@ try { SELECT ScheduledTaskTitle as Title, ScheduledTaskDetails as Details, - ScheduledTaskCategoryID as CategoryID, - ScheduledTaskTypeID as TypeID + ScheduledTaskCategoryID as CategoryID FROM ScheduledTaskDefinitions WHERE ScheduledTaskID = :id AND ScheduledTaskBusinessID = :businessID ", { @@ -66,24 +65,21 @@ try { apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Scheduled task not found" }); } - // Create the task + // Create the task (TaskClaimedByUserID=0 means unclaimed/pending) queryExecute(" INSERT INTO Tasks ( TaskBusinessID, TaskCategoryID, TaskTypeID, - TaskTitle, TaskDetails, TaskStatusID, TaskAddedOn, - TaskSourceType, TaskSourceID + TaskTitle, TaskDetails, TaskAddedOn, TaskClaimedByUserID ) VALUES ( :businessID, :categoryID, :typeID, - :title, :details, 0, NOW(), - 'scheduled_manual', :scheduledTaskID + :title, :details, NOW(), 0 ) ", { businessID: { value: businessID, cfsqltype: "cf_sql_integer" }, categoryID: { value: qDef.CategoryID, cfsqltype: "cf_sql_integer", null: isNull(qDef.CategoryID) }, - typeID: { value: qDef.TypeID, cfsqltype: "cf_sql_integer", null: isNull(qDef.TypeID) }, + typeID: { value: 0, cfsqltype: "cf_sql_integer" }, title: { value: qDef.Title, cfsqltype: "cf_sql_varchar" }, - details: { value: qDef.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(qDef.Details) }, - scheduledTaskID: { value: scheduledTaskID, cfsqltype: "cf_sql_integer" } + details: { value: qDef.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(qDef.Details) } }, { datasource: "payfrit" }); qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" }); diff --git a/api/admin/scheduledTasks/runDue.cfm b/api/admin/scheduledTasks/runDue.cfm index e386033..d3c3e1b 100644 --- a/api/admin/scheduledTasks/runDue.cfm +++ b/api/admin/scheduledTasks/runDue.cfm @@ -70,10 +70,11 @@ try { ScheduledTaskID, ScheduledTaskBusinessID as BusinessID, ScheduledTaskCategoryID as CategoryID, - ScheduledTaskTypeID as TypeID, ScheduledTaskTitle as Title, ScheduledTaskDetails as Details, - ScheduledTaskCronExpression as CronExpression + ScheduledTaskCronExpression as CronExpression, + COALESCE(ScheduledTaskScheduleType, 'cron') as ScheduleType, + ScheduledTaskIntervalMinutes as IntervalMinutes FROM ScheduledTaskDefinitions WHERE ScheduledTaskIsActive = 1 AND ScheduledTaskNextRunOn <= NOW() @@ -82,30 +83,37 @@ try { createdTasks = []; for (task in dueTasks) { - // Create the actual task + // Create the actual task (TaskClaimedByUserID=0 means unclaimed/pending) queryExecute(" INSERT INTO Tasks ( TaskBusinessID, TaskCategoryID, TaskTypeID, - TaskTitle, TaskDetails, TaskStatusID, TaskAddedOn, - TaskSourceType, TaskSourceID + TaskTitle, TaskDetails, TaskAddedOn, TaskClaimedByUserID ) VALUES ( :businessID, :categoryID, :typeID, - :title, :details, 0, NOW(), - 'scheduled', :scheduledTaskID + :title, :details, NOW(), 0 ) ", { businessID: { value: task.BusinessID, cfsqltype: "cf_sql_integer" }, categoryID: { value: task.CategoryID, cfsqltype: "cf_sql_integer", null: isNull(task.CategoryID) }, - typeID: { value: task.TypeID, cfsqltype: "cf_sql_integer", null: isNull(task.TypeID) }, + typeID: { value: 0, cfsqltype: "cf_sql_integer" }, title: { value: task.Title, cfsqltype: "cf_sql_varchar" }, - details: { value: task.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(task.Details) }, - scheduledTaskID: { value: task.ScheduledTaskID, cfsqltype: "cf_sql_integer" } + details: { value: task.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(task.Details) } }, { datasource: "payfrit" }); qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" }); - // Calculate next run and update the scheduled task - nextRun = calculateNextRun(task.CronExpression); + // Calculate next run based on schedule type + if (task.ScheduleType == "interval_after_completion" && !isNull(task.IntervalMinutes) && task.IntervalMinutes > 0) { + // After-completion interval: don't schedule next run until task is completed + // Set to far future (effectively paused until task completion triggers recalculation) + nextRun = javaCast("null", ""); + } else if (task.ScheduleType == "interval" && !isNull(task.IntervalMinutes) && task.IntervalMinutes > 0) { + // Fixed interval: next run = NOW + interval minutes + nextRun = dateAdd("n", task.IntervalMinutes, now()); + } else { + // Cron-based: use cron parser + nextRun = calculateNextRun(task.CronExpression); + } queryExecute(" UPDATE ScheduledTaskDefinitions SET @@ -113,7 +121,7 @@ try { ScheduledTaskNextRunOn = :nextRun WHERE ScheduledTaskID = :id ", { - nextRun: { value: nextRun, cfsqltype: "cf_sql_timestamp" }, + nextRun: { value: nextRun, cfsqltype: "cf_sql_timestamp", null: isNull(nextRun) }, id: { value: task.ScheduledTaskID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); diff --git a/api/admin/scheduledTasks/save.cfm b/api/admin/scheduledTasks/save.cfm index fcc2354..b33dbb5 100644 --- a/api/admin/scheduledTasks/save.cfm +++ b/api/admin/scheduledTasks/save.cfm @@ -5,7 +5,8 @@ // Create or update a scheduled task definition // Input: BusinessID (required), ScheduledTaskID (optional - for update), -// Name, Title, Details, CategoryID, TypeID, CronExpression, IsActive +// Name, Title, Details, CategoryID, TypeID, CronExpression, IsActive, +// ScheduleType ('cron' or 'interval'), IntervalMinutes (for interval type) // Output: { OK: true, SCHEDULED_TASK_ID: int } function apiAbort(required struct payload) { @@ -108,10 +109,13 @@ try { taskTitle = structKeyExists(data, "Title") ? trim(toString(data.Title)) : ""; taskDetails = structKeyExists(data, "Details") ? trim(toString(data.Details)) : ""; categoryID = structKeyExists(data, "CategoryID") && isNumeric(data.CategoryID) && data.CategoryID > 0 ? int(data.CategoryID) : javaCast("null", ""); - typeID = structKeyExists(data, "TypeID") && isNumeric(data.TypeID) && data.TypeID > 0 ? int(data.TypeID) : javaCast("null", ""); cronExpression = structKeyExists(data, "CronExpression") ? trim(toString(data.CronExpression)) : ""; isActive = structKeyExists(data, "IsActive") ? (data.IsActive ? 1 : 0) : 1; + // New interval scheduling fields + scheduleType = structKeyExists(data, "ScheduleType") ? trim(toString(data.ScheduleType)) : "cron"; + intervalMinutes = structKeyExists(data, "IntervalMinutes") && isNumeric(data.IntervalMinutes) ? int(data.IntervalMinutes) : javaCast("null", ""); + // Validate required fields if (!len(taskName)) { apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Name is required" }); @@ -119,19 +123,37 @@ try { if (!len(taskTitle)) { apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Title is required" }); } - if (!len(cronExpression)) { - apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "CronExpression is required" }); - } - // Validate cron format (5 parts) - cronParts = listToArray(cronExpression, " "); - if (arrayLen(cronParts) != 5) { - apiAbort({ "OK": false, "ERROR": "invalid_cron", "MESSAGE": "Cron expression must have 5 parts: minute hour day month weekday" }); + // Validate based on schedule type + if (scheduleType == "interval" || scheduleType == "interval_after_completion") { + // Interval-based scheduling + if (isNull(intervalMinutes) || intervalMinutes < 1) { + apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "IntervalMinutes is required for interval scheduling (minimum 1)" }); + } + // Set a placeholder cron expression for interval type + if (!len(cronExpression)) { + cronExpression = "* * * * *"; + } + // For NEW tasks: run immediately. For UPDATES: next run = NOW + interval + if (taskID == 0) { + nextRunOn = now(); // Run immediately on first creation + } else { + nextRunOn = dateAdd("n", intervalMinutes, now()); + } + } else { + // Cron-based scheduling + if (!len(cronExpression)) { + apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "CronExpression is required" }); + } + // Validate cron format (5 parts) + cronParts = listToArray(cronExpression, " "); + if (arrayLen(cronParts) != 5) { + apiAbort({ "OK": false, "ERROR": "invalid_cron", "MESSAGE": "Cron expression must have 5 parts: minute hour day month weekday" }); + } + // Calculate next run time from cron + nextRunOn = calculateNextRun(cronExpression); } - // Calculate next run time - nextRunOn = calculateNextRun(cronExpression); - if (taskID > 0) { // UPDATE existing qCheck = queryExecute(" @@ -152,8 +174,9 @@ try { ScheduledTaskTitle = :title, ScheduledTaskDetails = :details, ScheduledTaskCategoryID = :categoryID, - ScheduledTaskTypeID = :typeID, ScheduledTaskCronExpression = :cron, + ScheduledTaskScheduleType = :scheduleType, + ScheduledTaskIntervalMinutes = :intervalMinutes, ScheduledTaskIsActive = :isActive, ScheduledTaskNextRunOn = :nextRun WHERE ScheduledTaskID = :id @@ -162,8 +185,9 @@ try { title: { value: taskTitle, cfsqltype: "cf_sql_varchar" }, details: { value: taskDetails, cfsqltype: "cf_sql_longvarchar", null: !len(taskDetails) }, categoryID: { value: categoryID, cfsqltype: "cf_sql_integer", null: isNull(categoryID) }, - typeID: { value: typeID, cfsqltype: "cf_sql_integer", null: isNull(typeID) }, cron: { value: cronExpression, cfsqltype: "cf_sql_varchar" }, + scheduleType: { value: scheduleType, cfsqltype: "cf_sql_varchar" }, + intervalMinutes: { value: intervalMinutes, cfsqltype: "cf_sql_integer", null: isNull(intervalMinutes) }, isActive: { value: isActive, cfsqltype: "cf_sql_bit" }, nextRun: { value: nextRunOn, cfsqltype: "cf_sql_timestamp" }, id: { value: taskID, cfsqltype: "cf_sql_integer" } @@ -181,10 +205,11 @@ try { queryExecute(" INSERT INTO ScheduledTaskDefinitions ( ScheduledTaskBusinessID, ScheduledTaskName, ScheduledTaskTitle, - ScheduledTaskDetails, ScheduledTaskCategoryID, ScheduledTaskTypeID, - ScheduledTaskCronExpression, ScheduledTaskIsActive, ScheduledTaskNextRunOn + ScheduledTaskDetails, ScheduledTaskCategoryID, + ScheduledTaskCronExpression, ScheduledTaskScheduleType, ScheduledTaskIntervalMinutes, + ScheduledTaskIsActive, ScheduledTaskNextRunOn ) VALUES ( - :businessID, :name, :title, :details, :categoryID, :typeID, :cron, :isActive, :nextRun + :businessID, :name, :title, :details, :categoryID, :cron, :scheduleType, :intervalMinutes, :isActive, :nextRun ) ", { businessID: { value: businessID, cfsqltype: "cf_sql_integer" }, @@ -192,19 +217,65 @@ try { title: { value: taskTitle, cfsqltype: "cf_sql_varchar" }, details: { value: taskDetails, cfsqltype: "cf_sql_longvarchar", null: !len(taskDetails) }, categoryID: { value: categoryID, cfsqltype: "cf_sql_integer", null: isNull(categoryID) }, - typeID: { value: typeID, cfsqltype: "cf_sql_integer", null: isNull(typeID) }, cron: { value: cronExpression, cfsqltype: "cf_sql_varchar" }, + scheduleType: { value: scheduleType, cfsqltype: "cf_sql_varchar" }, + intervalMinutes: { value: intervalMinutes, cfsqltype: "cf_sql_integer", null: isNull(intervalMinutes) }, isActive: { value: isActive, cfsqltype: "cf_sql_bit" }, nextRun: { value: nextRunOn, cfsqltype: "cf_sql_timestamp" } }, { datasource: "payfrit" }); qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" }); + newScheduledTaskID = qNew.newID; + + // Create the first task immediately + queryExecute(" + INSERT INTO Tasks ( + TaskBusinessID, TaskCategoryID, TaskTypeID, + TaskTitle, TaskDetails, TaskAddedOn, TaskClaimedByUserID + ) VALUES ( + :businessID, :categoryID, :typeID, + :title, :details, NOW(), 0 + ) + ", { + businessID: { value: businessID, cfsqltype: "cf_sql_integer" }, + categoryID: { value: categoryID, cfsqltype: "cf_sql_integer", null: isNull(categoryID) }, + typeID: { value: 0, cfsqltype: "cf_sql_integer" }, + title: { value: taskTitle, cfsqltype: "cf_sql_varchar" }, + details: { value: taskDetails, cfsqltype: "cf_sql_longvarchar", null: !len(taskDetails) } + }, { datasource: "payfrit" }); + + qTask = queryExecute("SELECT LAST_INSERT_ID() as taskID", [], { datasource: "payfrit" }); + + // Now set the NEXT run time (not the immediate one we just created) + if (scheduleType == "interval" || scheduleType == "interval_after_completion") { + if (scheduleType == "interval_after_completion") { + // After-completion: don't schedule next until task is completed + actualNextRun = javaCast("null", ""); + } else { + // Fixed interval: next run = NOW + interval + actualNextRun = dateAdd("n", intervalMinutes, now()); + } + } else { + // Cron-based + actualNextRun = calculateNextRun(cronExpression); + } + + queryExecute(" + UPDATE ScheduledTaskDefinitions + SET ScheduledTaskLastRunOn = NOW(), + ScheduledTaskNextRunOn = :nextRun + WHERE ScheduledTaskID = :id + ", { + nextRun: { value: actualNextRun, cfsqltype: "cf_sql_timestamp", null: isNull(actualNextRun) }, + id: { value: newScheduledTaskID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); apiAbort({ "OK": true, - "SCHEDULED_TASK_ID": qNew.newID, - "NEXT_RUN": dateTimeFormat(nextRunOn, "yyyy-mm-dd HH:nn:ss"), - "MESSAGE": "Scheduled task created" + "SCHEDULED_TASK_ID": newScheduledTaskID, + "TASK_ID": qTask.taskID, + "NEXT_RUN": isNull(actualNextRun) ? "" : dateTimeFormat(actualNextRun, "yyyy-mm-dd HH:nn:ss"), + "MESSAGE": "Scheduled task created and first task added" }); } diff --git a/api/admin/scheduledTasks/setup.cfm b/api/admin/scheduledTasks/setup.cfm index 42d7000..a354a6b 100644 --- a/api/admin/scheduledTasks/setup.cfm +++ b/api/admin/scheduledTasks/setup.cfm @@ -23,6 +23,8 @@ try { ScheduledTaskTitle VARCHAR(255) NOT NULL, ScheduledTaskDetails TEXT NULL, ScheduledTaskCronExpression VARCHAR(100) NOT NULL, + ScheduledTaskScheduleType VARCHAR(20) DEFAULT 'cron', + ScheduledTaskIntervalMinutes INT NULL, ScheduledTaskIsActive BIT(1) DEFAULT b'1', ScheduledTaskLastRunOn DATETIME NULL, ScheduledTaskNextRunOn DATETIME NULL, @@ -33,9 +35,28 @@ try { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ", [], { datasource: "payfrit" }); + // Add new columns if they don't exist (for existing tables) + try { + queryExecute(" + ALTER TABLE ScheduledTaskDefinitions + ADD COLUMN ScheduledTaskScheduleType VARCHAR(20) DEFAULT 'cron' AFTER ScheduledTaskCronExpression + ", [], { datasource: "payfrit" }); + } catch (any e) { + // Column likely already exists + } + + try { + queryExecute(" + ALTER TABLE ScheduledTaskDefinitions + ADD COLUMN ScheduledTaskIntervalMinutes INT NULL AFTER ScheduledTaskScheduleType + ", [], { datasource: "payfrit" }); + } catch (any e) { + // Column likely already exists + } + apiAbort({ "OK": true, - "MESSAGE": "ScheduledTaskDefinitions table created/verified" + "MESSAGE": "ScheduledTaskDefinitions table created/verified with interval support" }); } catch (any e) { diff --git a/api/admin/scheduledTasks/toggle.cfm b/api/admin/scheduledTasks/toggle.cfm index a5cefb2..7154400 100644 --- a/api/admin/scheduledTasks/toggle.cfm +++ b/api/admin/scheduledTasks/toggle.cfm @@ -97,9 +97,11 @@ try { apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "ScheduledTaskID is required" }); } - // Verify exists and get cron expression + // Verify exists and get cron expression and schedule type qCheck = queryExecute(" - SELECT ScheduledTaskID, ScheduledTaskCronExpression as CronExpression + SELECT ScheduledTaskID, ScheduledTaskCronExpression as CronExpression, + COALESCE(ScheduledTaskScheduleType, 'cron') as ScheduleType, + ScheduledTaskIntervalMinutes as IntervalMinutes FROM ScheduledTaskDefinitions WHERE ScheduledTaskID = :id AND ScheduledTaskBusinessID = :businessID ", { @@ -111,10 +113,16 @@ try { apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Scheduled task not found" }); } - // If enabling, recalculate next run time + // If enabling, recalculate next run time based on schedule type nextRunUpdate = ""; if (isActive) { - nextRunOn = calculateNextRun(qCheck.CronExpression); + if ((qCheck.ScheduleType == "interval" || qCheck.ScheduleType == "interval_after_completion") && !isNull(qCheck.IntervalMinutes) && qCheck.IntervalMinutes > 0) { + // Interval-based: next run = NOW + interval minutes + nextRunOn = dateAdd("n", qCheck.IntervalMinutes, now()); + } else { + // Cron-based: use cron parser + nextRunOn = calculateNextRun(qCheck.CronExpression); + } nextRunUpdate = ", ScheduledTaskNextRunOn = :nextRun"; } diff --git a/api/businesses/get.cfm b/api/businesses/get.cfm index f2798e6..a7380ca 100644 --- a/api/businesses/get.cfm +++ b/api/businesses/get.cfm @@ -42,7 +42,8 @@ try { BusinessStripeOnboardingComplete, BusinessIsHiring, BusinessHeaderImageExtension, - BusinessTaxRate + BusinessTaxRate, + BusinessBrandColor FROM Businesses WHERE BusinessID = :businessID ", { businessID: businessID }, { datasource: "payfrit" }); @@ -139,7 +140,8 @@ try { "StripeConnected": (len(q.BusinessStripeAccountID) > 0 && q.BusinessStripeOnboardingComplete == 1), "IsHiring": q.BusinessIsHiring == 1, "TaxRate": taxRate, - "TaxRatePercent": taxRate * 100 + "TaxRatePercent": taxRate * 100, + "BrandColor": len(q.BusinessBrandColor) ? q.BusinessBrandColor : "" }; // Add header image URL if extension exists diff --git a/api/businesses/saveBrandColor.cfm b/api/businesses/saveBrandColor.cfm index fe2c257..9164c43 100644 --- a/api/businesses/saveBrandColor.cfm +++ b/api/businesses/saveBrandColor.cfm @@ -39,11 +39,16 @@ brandColor = structKeyExists(data, "BrandColor") && isSimpleValue(data.BrandColo // Allow empty to clear, or validate hex format if (len(brandColor) GT 0) { - // Must start with # and have 6 hex chars - if (left(brandColor, 1) != chr(35) || len(brandColor) != 7 || !reFind("^[0-9A-Fa-f]{6}$", right(brandColor, 6))) { - apiAbort({ "OK": false, "ERROR": "invalid_color", "MESSAGE": "Color must be in ##RRGGBB format" }); + // Strip leading # if present + if (left(brandColor, 1) == chr(35)) { + brandColor = right(brandColor, len(brandColor) - 1); } - brandColor = uCase(brandColor); + // Must be exactly 6 hex chars + if (len(brandColor) != 6 || !reFind("^[0-9A-Fa-f]{6}$", brandColor)) { + apiAbort({ "OK": false, "ERROR": "invalid_color", "MESSAGE": "Color must be a valid 6-digit hex color (e.g. 1B4D3E or ##1B4D3E)" }); + } + // Store with # prefix, uppercase + brandColor = chr(35) & uCase(brandColor); } // Update the database diff --git a/api/config/stripe.cfm b/api/config/stripe.cfm index 24d2952..87dbd09 100644 --- a/api/config/stripe.cfm +++ b/api/config/stripe.cfm @@ -11,7 +11,7 @@ */ // Mode: "test" or "live" -stripeMode = "live"; +stripeMode = "test"; // Test keys (safe to commit) stripeTestSecretKey = "sk_test_LfbmDduJxTwbVZmvcByYmirw"; diff --git a/api/menu/getForBuilder.cfm b/api/menu/getForBuilder.cfm index 721e877..2c93919 100644 --- a/api/menu/getForBuilder.cfm +++ b/api/menu/getForBuilder.cfm @@ -346,13 +346,23 @@ try { }); } + // Check for existing item photo + itemImageUrl = ""; + itemsDir = expandPath("/uploads/items"); + for (ext in ["jpg","jpeg","png","gif","webp","JPG","JPEG","PNG","GIF","WEBP"]) { + if (fileExists(itemsDir & "/" & qItems.ItemID[i] & "." & ext)) { + itemImageUrl = "/uploads/items/" & qItems.ItemID[i] & "." & ext; + break; + } + } + arrayAppend(itemsByCategory[catID], { "id": "item_" & qItems.ItemID[i], "dbId": qItems.ItemID[i], "name": qItems.ItemName[i], "description": isNull(qItems.ItemDescription[i]) ? "" : qItems.ItemDescription[i], "price": qItems.ItemPrice[i], - "imageUrl": javaCast("null", ""), + "imageUrl": len(itemImageUrl) ? itemImageUrl : javaCast("null", ""), "photoTaskId": javaCast("null", ""), "modifiers": itemModifiers, "sortOrder": qItems.ItemSortOrder[i] diff --git a/api/menu/uploadItemPhoto.cfm b/api/menu/uploadItemPhoto.cfm new file mode 100644 index 0000000..fece49b --- /dev/null +++ b/api/menu/uploadItemPhoto.cfm @@ -0,0 +1,68 @@ + + + + + + + + +function apiAbort(payload) { + writeOutput(serializeJSON(payload)); + abort; +} + +// Get ItemID from form +itemId = 0; +if (structKeyExists(form, "ItemID") && isNumeric(form.ItemID) && form.ItemID GT 0) { + itemId = int(form.ItemID); +} + +if (itemId LTE 0) { + apiAbort({ "OK": false, "ERROR": "missing_itemid", "MESSAGE": "ItemID is required" }); +} + + + + + #serializeJSON({ "OK": false, "ERROR": "no_file", "MESSAGE": "No file was uploaded" })# + + + + + + + + + + + #serializeJSON({ "OK": false, "ERROR": "invalid_type", "MESSAGE": "Only image files are accepted (jpg, jpeg, gif, png, webp)" })# + + + + + +for (ext in listToArray(allowedExtensions)) { + oldFile = "#itemsDir#/#itemId#.#ext#"; + if (fileExists(oldFile)) { + try { fileDelete(oldFile); } catch (any e) {} + } +} + + + + + + +#serializeJSON({ + "OK": true, + "ERROR": "", + "MESSAGE": "Photo uploaded successfully", + "IMAGEURL": "/uploads/items/#itemId#.#uploadResult.ClientFileExt#" +})# + + + + + #serializeJSON({ "OK": false, "ERROR": "server_error", "MESSAGE": cfcatch.message, "DETAIL": cfcatch.detail })# + + diff --git a/api/orders/getDetail.cfm b/api/orders/getDetail.cfm index 9775262..b08ebc6 100644 --- a/api/orders/getDetail.cfm +++ b/api/orders/getDetail.cfm @@ -100,19 +100,18 @@ try { lineItems = []; itemsById = {}; - // First pass: create all items + // First pass: create all items (use bracket notation to preserve key casing) for (row in qItems) { - item = { - "LineItemID": val(row.OrderLineItemID), - "ItemID": val(row.OrderLineItemItemID), - "ParentLineItemID": val(row.OrderLineItemParentOrderLineItemID), - "ItemName": row.ItemName ?: "", - "Quantity": val(row.OrderLineItemQuantity), - "UnitPrice": val(row.OrderLineItemPrice), - "Remarks": row.OrderLineItemRemark ?: "", - "IsDefault": (val(row.ItemIsCheckedByDefault) == 1), - "Modifiers": [] - }; + item = structNew("ordered"); + item["LineItemID"] = val(row.OrderLineItemID); + item["ItemID"] = val(row.OrderLineItemItemID); + item["ParentLineItemID"] = val(row.OrderLineItemParentOrderLineItemID); + item["ItemName"] = row.ItemName ?: ""; + item["Quantity"] = val(row.OrderLineItemQuantity); + item["UnitPrice"] = val(row.OrderLineItemPrice); + item["Remarks"] = row.OrderLineItemRemark ?: ""; + item["IsDefault"] = (val(row.ItemIsCheckedByDefault) == 1); + item["Modifiers"] = []; itemsById[row.OrderLineItemID] = item; } @@ -122,21 +121,21 @@ try { parentID = row.OrderLineItemParentOrderLineItemID; if (parentID > 0 && structKeyExists(itemsById, parentID)) { - // This is a modifier - add to parent - arrayAppend(itemsById[parentID].Modifiers, item); + // This is a modifier - add to parent (use bracket notation) + arrayAppend(itemsById[parentID]["Modifiers"], item); } else { // This is a top-level item arrayAppend(lineItems, item); } } - // Calculate subtotal from root line items + // Calculate subtotal from root line items (use bracket notation) subtotal = 0; for (item in lineItems) { - itemTotal = item.UnitPrice * item.Quantity; + itemTotal = item["UnitPrice"] * item["Quantity"]; // Add modifier prices - for (mod in item.Modifiers) { - itemTotal += mod.UnitPrice * mod.Quantity; + for (mod in item["Modifiers"]) { + itemTotal += mod["UnitPrice"] * mod["Quantity"]; } subtotal += itemTotal; } @@ -151,57 +150,68 @@ try { // Calculate total total = subtotal + tax + tip; - // Get staff who worked on this order (from Tasks table) + // Get staff who worked on this order (from Tasks table) with pending rating tokens qStaff = queryExecute(" - SELECT DISTINCT u.UserID, u.UserFirstName + SELECT DISTINCT u.UserID, u.UserFirstName, + (SELECT r.TaskRatingAccessToken + FROM TaskRatings r + INNER JOIN Tasks t2 ON t2.TaskID = r.TaskRatingTaskID + WHERE t2.TaskOrderID = :orderID + AND r.TaskRatingForUserID = u.UserID + AND r.TaskRatingDirection = 'customer_rates_worker' + AND r.TaskRatingCompletedOn IS NULL + AND r.TaskRatingExpiresOn > NOW() + LIMIT 1) AS RatingToken FROM Tasks t INNER JOIN Users u ON u.UserID = t.TaskClaimedByUserID WHERE t.TaskOrderID = :orderID AND t.TaskClaimedByUserID > 0 ", { orderID: orderID }); - // Build staff array with avatar URLs + // Build staff array with avatar URLs and rating tokens (use ordered structs) staff = []; for (row in qStaff) { - arrayAppend(staff, { - "UserID": row.UserID, - "FirstName": row.UserFirstName, - "AvatarUrl": "https://biz.payfrit.com/uploads/users/" & row.UserID & ".jpg" - }); + staffMember = structNew("ordered"); + staffMember["UserID"] = row.UserID; + staffMember["FirstName"] = row.UserFirstName; + staffMember["AvatarUrl"] = "https://biz.payfrit.com/uploads/users/" & row.UserID & ".jpg"; + staffMember["RatingToken"] = row.RatingToken ?: ""; + arrayAppend(staff, staffMember); } - // Build response - order = { - "OrderID": qOrder.OrderID, - "BusinessID": qOrder.OrderBusinessID, - "BusinessName": qOrder.BusinessName ?: "", - "Status": qOrder.OrderStatusID, - "StatusText": getStatusText(qOrder.OrderStatusID), - "OrderTypeID": qOrder.OrderTypeID ?: 0, - "OrderTypeName": getOrderTypeName(qOrder.OrderTypeID ?: 0), - "Subtotal": subtotal, - "Tax": tax, - "Tip": tip, - "Total": total, - "Notes": qOrder.OrderRemarks, - "CreatedOn": dateTimeFormat(qOrder.OrderAddedOn, "yyyy-mm-dd HH:nn:ss"), - "SubmittedOn": len(qOrder.OrderSubmittedOn) ? dateTimeFormat(qOrder.OrderSubmittedOn, "yyyy-mm-dd HH:nn:ss") : "", - "UpdatedOn": len(qOrder.OrderLastEditedOn) ? dateTimeFormat(qOrder.OrderLastEditedOn, "yyyy-mm-dd HH:nn:ss") : "", - "Customer": { - "UserID": qOrder.OrderUserID, - "FirstName": qOrder.UserFirstName, - "LastName": qOrder.UserLastName, - "Phone": qOrder.UserContactNumber, - "Email": qOrder.UserEmailAddress - }, - "ServicePoint": { - "ServicePointID": qOrder.OrderServicePointID, - "Name": qOrder.ServicePointName, - "TypeID": qOrder.ServicePointTypeID - }, - "LineItems": lineItems, - "Staff": staff - }; + // Build response (use ordered structs to preserve key casing) + customer = structNew("ordered"); + customer["UserID"] = qOrder.OrderUserID; + customer["FirstName"] = qOrder.UserFirstName; + customer["LastName"] = qOrder.UserLastName; + customer["Phone"] = qOrder.UserContactNumber; + customer["Email"] = qOrder.UserEmailAddress; + + servicePoint = structNew("ordered"); + servicePoint["ServicePointID"] = qOrder.OrderServicePointID; + servicePoint["Name"] = qOrder.ServicePointName; + servicePoint["TypeID"] = qOrder.ServicePointTypeID; + + order = structNew("ordered"); + order["OrderID"] = qOrder.OrderID; + order["BusinessID"] = qOrder.OrderBusinessID; + order["BusinessName"] = qOrder.BusinessName ?: ""; + order["Status"] = qOrder.OrderStatusID; + order["StatusText"] = getStatusText(qOrder.OrderStatusID); + order["OrderTypeID"] = qOrder.OrderTypeID ?: 0; + order["OrderTypeName"] = getOrderTypeName(qOrder.OrderTypeID ?: 0); + order["Subtotal"] = subtotal; + order["Tax"] = tax; + order["Tip"] = tip; + order["Total"] = total; + order["Notes"] = qOrder.OrderRemarks; + order["CreatedOn"] = dateTimeFormat(qOrder.OrderAddedOn, "yyyy-mm-dd HH:nn:ss"); + order["SubmittedOn"] = len(qOrder.OrderSubmittedOn) ? dateTimeFormat(qOrder.OrderSubmittedOn, "yyyy-mm-dd HH:nn:ss") : ""; + order["UpdatedOn"] = len(qOrder.OrderLastEditedOn) ? dateTimeFormat(qOrder.OrderLastEditedOn, "yyyy-mm-dd HH:nn:ss") : ""; + order["Customer"] = customer; + order["ServicePoint"] = servicePoint; + order["LineItems"] = lineItems; + order["Staff"] = staff; response["OK"] = true; response["ORDER"] = order; diff --git a/api/ratings/createAdminRating.cfm b/api/ratings/createAdminRating.cfm new file mode 100644 index 0000000..d52face --- /dev/null +++ b/api/ratings/createAdminRating.cfm @@ -0,0 +1,122 @@ + + + +/** + * Create an admin rating for a worker on a completed task + * + * POST: { + * TaskID: 123, + * AdminUserID: 456, + * onTime: true/false, + * completedScope: true/false, + * requiredFollowup: true/false, + * continueAllow: true/false + * } + */ + +function readJsonBody() { + var raw = getHttpRequestData().content; + if (isNull(raw) || len(trim(raw)) == 0) return {}; + try { + var data = deserializeJSON(raw); + return isStruct(data) ? data : {}; + } catch (any e) { + return {}; + } +} + +function generateToken() { + return lcase(replace(createUUID(), "-", "", "all")); +} + +try { + data = readJsonBody(); + taskID = val(structKeyExists(data, "TaskID") ? data.TaskID : 0); + adminUserID = val(structKeyExists(data, "AdminUserID") ? data.AdminUserID : 0); + + if (taskID == 0) { + writeOutput(serializeJSON({ "OK": false, "ERROR": "missing_task", "MESSAGE": "TaskID is required." })); + abort; + } + + if (adminUserID == 0) { + writeOutput(serializeJSON({ "OK": false, "ERROR": "missing_admin", "MESSAGE": "AdminUserID is required." })); + abort; + } + + // Verify task exists and is completed + qTask = queryExecute(" + SELECT t.TaskID, t.TaskClaimedByUserID, t.TaskCompletedOn, t.TaskBusinessID + FROM Tasks t + WHERE t.TaskID = :taskID + ", { taskID: taskID }); + + if (qTask.recordCount == 0) { + writeOutput(serializeJSON({ "OK": false, "ERROR": "not_found", "MESSAGE": "Task not found." })); + abort; + } + + if (len(trim(qTask.TaskCompletedOn)) == 0) { + writeOutput(serializeJSON({ "OK": false, "ERROR": "not_completed", "MESSAGE": "Task has not been completed yet." })); + abort; + } + + workerUserID = qTask.TaskClaimedByUserID; + if (workerUserID == 0) { + writeOutput(serializeJSON({ "OK": false, "ERROR": "no_worker", "MESSAGE": "No worker assigned to this task." })); + abort; + } + + // Check if admin rating already exists for this task + qExisting = queryExecute(" + SELECT TaskRatingID FROM TaskRatings + WHERE TaskRatingTaskID = :taskID + AND TaskRatingDirection = 'admin_rates_worker' + LIMIT 1 + ", { taskID: taskID }); + + if (qExisting.recordCount > 0) { + writeOutput(serializeJSON({ "OK": false, "ERROR": "already_rated", "MESSAGE": "This task has already been rated by an admin." })); + abort; + } + + // Insert the admin rating (completed immediately since admin submits directly) + token = generateToken(); + queryExecute(" + INSERT INTO TaskRatings ( + TaskRatingTaskID, TaskRatingByUserID, TaskRatingForUserID, TaskRatingDirection, + TaskRatingOnTime, TaskRatingCompletedScope, TaskRatingRequiredFollowup, TaskRatingContinueAllow, + TaskRatingAccessToken, TaskRatingExpiresOn, TaskRatingCompletedOn + ) VALUES ( + :taskID, :adminUserID, :workerUserID, 'admin_rates_worker', + :onTime, :completedScope, :requiredFollowup, :continueAllow, + :token, DATE_ADD(NOW(), INTERVAL 24 HOUR), NOW() + ) + ", { + taskID: taskID, + adminUserID: adminUserID, + workerUserID: workerUserID, + onTime: { value: structKeyExists(data,"onTime") ? (data.onTime ? 1 : 0) : javaCast("null",""), cfsqltype: "cf_sql_tinyint", null: !structKeyExists(data,"onTime") }, + completedScope: { value: structKeyExists(data,"completedScope") ? (data.completedScope ? 1 : 0) : javaCast("null",""), cfsqltype: "cf_sql_tinyint", null: !structKeyExists(data,"completedScope") }, + requiredFollowup: { value: structKeyExists(data,"requiredFollowup") ? (data.requiredFollowup ? 1 : 0) : javaCast("null",""), cfsqltype: "cf_sql_tinyint", null: !structKeyExists(data,"requiredFollowup") }, + continueAllow: { value: structKeyExists(data,"continueAllow") ? (data.continueAllow ? 1 : 0) : javaCast("null",""), cfsqltype: "cf_sql_tinyint", null: !structKeyExists(data,"continueAllow") }, + token: token + }); + + ratingID = queryExecute("SELECT LAST_INSERT_ID() AS id", {}).id; + + writeOutput(serializeJSON({ + "OK": true, + "MESSAGE": "Rating submitted successfully.", + "RatingID": ratingID + })); + +} catch (any e) { + writeOutput(serializeJSON({ + "OK": false, + "ERROR": "server_error", + "MESSAGE": "Error creating rating", + "DETAIL": e.message + })); +} + diff --git a/api/ratings/listForAdmin.cfm b/api/ratings/listForAdmin.cfm new file mode 100644 index 0000000..0270c53 --- /dev/null +++ b/api/ratings/listForAdmin.cfm @@ -0,0 +1,92 @@ + + + +/** + * List completed tasks that admin can rate + * Returns tasks completed in the last 7 days where admin hasn't yet rated the worker + * + * POST: { BusinessID: 123 } + */ + +function readJsonBody() { + var raw = getHttpRequestData().content; + if (isNull(raw) || len(trim(raw)) == 0) return {}; + try { + var data = deserializeJSON(raw); + return isStruct(data) ? data : {}; + } catch (any e) { + return {}; + } +} + +try { + data = readJsonBody(); + businessID = val(structKeyExists(data, "BusinessID") ? data.BusinessID : 0); + + if (businessID == 0) { + writeOutput(serializeJSON({ "OK": false, "ERROR": "missing_business", "MESSAGE": "BusinessID is required." })); + abort; + } + + // Get completed tasks from last 7 days where a worker was assigned + // and no admin rating exists yet + qTasks = queryExecute(" + SELECT t.TaskID, t.TaskTitle, t.TaskCompletedOn, t.TaskClaimedByUserID, t.TaskOrderID, + u.UserFirstName AS WorkerFirstName, u.UserLastName AS WorkerLastName, + o.OrderID, o.OrderUserID, + cu.UserFirstName AS CustomerFirstName, cu.UserLastName AS CustomerLastName, + sp.ServicePointName, + (SELECT COUNT(*) FROM TaskRatings r + WHERE r.TaskRatingTaskID = t.TaskID + AND r.TaskRatingDirection = 'admin_rates_worker') AS HasAdminRating + FROM Tasks t + INNER JOIN Users u ON u.UserID = t.TaskClaimedByUserID + LEFT JOIN Orders o ON o.OrderID = t.TaskOrderID + LEFT JOIN Users cu ON cu.UserID = o.OrderUserID + LEFT JOIN ServicePoints sp ON sp.ServicePointID = o.OrderServicePointID + WHERE t.TaskBusinessID = :businessID + AND t.TaskCompletedOn IS NOT NULL + AND t.TaskCompletedOn > DATE_SUB(NOW(), INTERVAL 7 DAY) + AND t.TaskClaimedByUserID > 0 + HAVING HasAdminRating = 0 + ORDER BY t.TaskCompletedOn DESC + LIMIT 50 + ", { businessID: businessID }); + + tasks = []; + for (row in qTasks) { + // Build task title + taskTitle = row.TaskTitle; + if (len(taskTitle) == 0 && row.OrderID > 0) { + taskTitle = "Order ##" & row.OrderID; + } + if (len(taskTitle) == 0) { + taskTitle = "Task ##" & row.TaskID; + } + + arrayAppend(tasks, { + "TaskID": row.TaskID, + "TaskTitle": taskTitle, + "CompletedOn": dateTimeFormat(row.TaskCompletedOn, "yyyy-mm-dd HH:nn:ss"), + "WorkerUserID": row.TaskClaimedByUserID, + "WorkerName": trim(row.WorkerFirstName & " " & row.WorkerLastName), + "CustomerName": len(row.CustomerFirstName) ? trim(row.CustomerFirstName & " " & row.CustomerLastName) : "", + "ServicePointName": row.ServicePointName ?: "", + "OrderID": row.OrderID ?: 0 + }); + } + + writeOutput(serializeJSON({ + "OK": true, + "TASKS": tasks + })); + +} catch (any e) { + writeOutput(serializeJSON({ + "OK": false, + "ERROR": "server_error", + "MESSAGE": "Error loading tasks", + "DETAIL": e.message + })); +} + diff --git a/api/ratings/setup.cfm b/api/ratings/setup.cfm new file mode 100644 index 0000000..8edfe6a --- /dev/null +++ b/api/ratings/setup.cfm @@ -0,0 +1,59 @@ + + + +try { + // Create TaskRatings table + queryExecute(" + CREATE TABLE IF NOT EXISTS TaskRatings ( + TaskRatingID INT AUTO_INCREMENT PRIMARY KEY, + TaskRatingTaskID INT NOT NULL, + + TaskRatingByUserID INT NOT NULL, + TaskRatingForUserID INT NOT NULL, + TaskRatingDirection VARCHAR(25) NOT NULL, + + -- Customer/Admin rates Worker + TaskRatingOnTime TINYINT(1) NULL, + TaskRatingCompletedScope TINYINT(1) NULL, + TaskRatingRequiredFollowup TINYINT(1) NULL, + TaskRatingContinueAllow TINYINT(1) NULL, + + -- Worker rates Customer + TaskRatingPrepared TINYINT(1) NULL, + TaskRatingRespectful TINYINT(1) NULL, + TaskRatingWouldAutoAssign TINYINT(1) NULL, + + -- For rating links in receipts + TaskRatingAccessToken VARCHAR(64) NOT NULL UNIQUE, + TaskRatingExpiresOn DATETIME NOT NULL, + + -- Timestamps + TaskRatingCreatedOn DATETIME DEFAULT CURRENT_TIMESTAMP, + TaskRatingCompletedOn DATETIME NULL, + + INDEX idx_task (TaskRatingTaskID), + INDEX idx_for_user (TaskRatingForUserID), + INDEX idx_by_user (TaskRatingByUserID), + INDEX idx_token (TaskRatingAccessToken) + ) + ", {}, { datasource: "payfrit" }); + + // Verify table was created + cols = queryExecute("DESCRIBE TaskRatings", {}, { datasource: "payfrit" }); + colNames = []; + for (c in cols) { + arrayAppend(colNames, c.Field); + } + + writeOutput(serializeJSON({ + "OK": true, + "MESSAGE": "TaskRatings table created successfully", + "COLUMNS": colNames + })); +} catch (any e) { + writeOutput(serializeJSON({ + "OK": false, + "ERROR": e.message + })); +} + diff --git a/api/ratings/submit.cfm b/api/ratings/submit.cfm new file mode 100644 index 0000000..22ace82 --- /dev/null +++ b/api/ratings/submit.cfm @@ -0,0 +1,144 @@ + + + +function readJsonBody() { + var raw = getHttpRequestData().content; + if (isNull(raw) || len(trim(raw)) == 0) return {}; + try { + var data = deserializeJSON(raw); + return isStruct(data) ? data : {}; + } catch (any e) { + return {}; + } +} + +try { + data = readJsonBody(); + + // Token can come from URL param or JSON body + token = ""; + if (structKeyExists(url, "token")) token = trim(url.token); + if (len(token) == 0 && structKeyExists(data, "token")) token = trim(data.token); + + if (len(token) == 0) { + writeOutput(serializeJSON({ "OK": false, "ERROR": "missing_token", "MESSAGE": "Rating token is required." })); + abort; + } + + // Look up the rating by token + qRating = queryExecute(" + SELECT r.*, t.TaskTitle, + u_for.UserFirstName AS ForUserFirstName, u_for.UserLastName AS ForUserLastName, + u_by.UserFirstName AS ByUserFirstName, u_by.UserLastName AS ByUserLastName + FROM TaskRatings r + JOIN Tasks t ON t.TaskID = r.TaskRatingTaskID + LEFT JOIN Users u_for ON u_for.UserID = r.TaskRatingForUserID + LEFT JOIN Users u_by ON u_by.UserID = r.TaskRatingByUserID + WHERE r.TaskRatingAccessToken = ? + LIMIT 1 + ", [{ value = token, cfsqltype = "cf_sql_varchar" }]); + + if (qRating.recordCount == 0) { + writeOutput(serializeJSON({ "OK": false, "ERROR": "invalid_token", "MESSAGE": "Rating not found or link is invalid." })); + abort; + } + + // Check if expired + if (qRating.TaskRatingExpiresOn < now()) { + writeOutput(serializeJSON({ "OK": false, "ERROR": "expired", "MESSAGE": "This rating link has expired." })); + abort; + } + + // Check if already completed + if (len(trim(qRating.TaskRatingCompletedOn)) > 0) { + writeOutput(serializeJSON({ "OK": false, "ERROR": "already_submitted", "MESSAGE": "This rating has already been submitted." })); + abort; + } + + // If GET request (or no rating data), return rating info for display + isSubmission = structKeyExists(data, "onTime") || structKeyExists(data, "completedScope") + || structKeyExists(data, "requiredFollowup") || structKeyExists(data, "continueAllow") + || structKeyExists(data, "prepared") || structKeyExists(data, "respectful") + || structKeyExists(data, "wouldAutoAssign"); + + if (!isSubmission) { + // Return rating details for UI to display + result = { + "OK": true, + "RatingID": qRating.TaskRatingID, + "Direction": qRating.TaskRatingDirection, + "TaskTitle": qRating.TaskTitle, + "ForUserName": trim(qRating.ForUserFirstName & " " & qRating.ForUserLastName), + "ExpiresOn": dateTimeFormat(qRating.TaskRatingExpiresOn, "yyyy-mm-dd HH:nn:ss") + }; + + // Include appropriate questions based on direction + if (qRating.TaskRatingDirection == "customer_rates_worker" || qRating.TaskRatingDirection == "admin_rates_worker") { + result["Questions"] = { + "onTime": "Was the worker on time?", + "completedScope": "Was the scope completed?", + "requiredFollowup": "Was follow-up required?", + "continueAllow": "Continue to allow these tasks?" + }; + } else if (qRating.TaskRatingDirection == "worker_rates_customer") { + result["Questions"] = { + "prepared": "Was the customer prepared?", + "completedScope": "Was the scope clear?", + "respectful": "Was the customer respectful?", + "wouldAutoAssign": "Would you serve this customer again?" + }; + } + + writeOutput(serializeJSON(result)); + abort; + } + + // Process submission based on direction + if (qRating.TaskRatingDirection == "customer_rates_worker" || qRating.TaskRatingDirection == "admin_rates_worker") { + queryExecute(" + UPDATE TaskRatings SET + TaskRatingOnTime = ?, + TaskRatingCompletedScope = ?, + TaskRatingRequiredFollowup = ?, + TaskRatingContinueAllow = ?, + TaskRatingCompletedOn = NOW() + WHERE TaskRatingID = ? + ", [ + { value = structKeyExists(data,"onTime") ? (data.onTime ? 1 : 0) : javaCast("null",""), cfsqltype = "cf_sql_tinyint", null = !structKeyExists(data,"onTime") }, + { value = structKeyExists(data,"completedScope") ? (data.completedScope ? 1 : 0) : javaCast("null",""), cfsqltype = "cf_sql_tinyint", null = !structKeyExists(data,"completedScope") }, + { value = structKeyExists(data,"requiredFollowup") ? (data.requiredFollowup ? 1 : 0) : javaCast("null",""), cfsqltype = "cf_sql_tinyint", null = !structKeyExists(data,"requiredFollowup") }, + { value = structKeyExists(data,"continueAllow") ? (data.continueAllow ? 1 : 0) : javaCast("null",""), cfsqltype = "cf_sql_tinyint", null = !structKeyExists(data,"continueAllow") }, + { value = qRating.TaskRatingID, cfsqltype = "cf_sql_integer" } + ]); + } else if (qRating.TaskRatingDirection == "worker_rates_customer") { + queryExecute(" + UPDATE TaskRatings SET + TaskRatingPrepared = ?, + TaskRatingCompletedScope = ?, + TaskRatingRespectful = ?, + TaskRatingWouldAutoAssign = ?, + TaskRatingCompletedOn = NOW() + WHERE TaskRatingID = ? + ", [ + { value = structKeyExists(data,"prepared") ? (data.prepared ? 1 : 0) : javaCast("null",""), cfsqltype = "cf_sql_tinyint", null = !structKeyExists(data,"prepared") }, + { value = structKeyExists(data,"completedScope") ? (data.completedScope ? 1 : 0) : javaCast("null",""), cfsqltype = "cf_sql_tinyint", null = !structKeyExists(data,"completedScope") }, + { value = structKeyExists(data,"respectful") ? (data.respectful ? 1 : 0) : javaCast("null",""), cfsqltype = "cf_sql_tinyint", null = !structKeyExists(data,"respectful") }, + { value = structKeyExists(data,"wouldAutoAssign") ? (data.wouldAutoAssign ? 1 : 0) : javaCast("null",""), cfsqltype = "cf_sql_tinyint", null = !structKeyExists(data,"wouldAutoAssign") }, + { value = qRating.TaskRatingID, cfsqltype = "cf_sql_integer" } + ]); + } + + writeOutput(serializeJSON({ + "OK": true, + "MESSAGE": "Rating submitted successfully. Thank you for your feedback!" + })); + +} catch (any e) { + writeOutput(serializeJSON({ + "OK": false, + "ERROR": "server_error", + "MESSAGE": "Error submitting rating", + "DETAIL": e.message + })); +} + diff --git a/api/setup/analyzeMenuImages.cfm b/api/setup/analyzeMenuImages.cfm index 92b7489..cf8e36f 100644 --- a/api/setup/analyzeMenuImages.cfm +++ b/api/setup/analyzeMenuImages.cfm @@ -94,7 +94,7 @@ - + @@ -190,14 +190,52 @@ - + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/api/setup/saveWizard.cfm b/api/setup/saveWizard.cfm index 33e063f..c6bee07 100644 --- a/api/setup/saveWizard.cfm +++ b/api/setup/saveWizard.cfm @@ -53,6 +53,9 @@ try { // Extract phone number bizPhone = structKeyExists(biz, "phone") && isSimpleValue(biz.phone) ? trim(biz.phone) : ""; + // Extract tax rate (stored as decimal, e.g. 8.25% -> 0.0825) + bizTaxRate = structKeyExists(biz, "taxRatePercent") && isSimpleValue(biz.taxRatePercent) ? val(biz.taxRatePercent) / 100 : 0; + // Create address record first (use extracted address fields) - safely extract as simple values addressLine1 = structKeyExists(biz, "addressLine1") && isSimpleValue(biz.addressLine1) ? trim(biz.addressLine1) : ""; city = structKeyExists(biz, "city") && isSimpleValue(biz.city) ? trim(biz.city) : ""; @@ -93,16 +96,22 @@ try { addressId = qNewAddr.id; response.steps.append("Created address record (ID: " & addressId & ")"); + // Get community meal type (1=provide meals, 2=food bank donation) + communityMealType = structKeyExists(wizardData, "communityMealType") && isSimpleValue(wizardData.communityMealType) ? val(wizardData.communityMealType) : 1; + if (communityMealType < 1 || communityMealType > 2) communityMealType = 1; + // Create new business with address link and phone queryExecute(" - INSERT INTO Businesses (BusinessName, BusinessPhone, BusinessUserID, BusinessAddressID, BusinessDeliveryZipCodes, BusinessAddedOn) - VALUES (:name, :phone, :userId, :addressId, :deliveryZips, NOW()) + INSERT INTO Businesses (BusinessName, BusinessPhone, BusinessUserID, BusinessAddressID, BusinessDeliveryZipCodes, BusinessCommunityMealType, BusinessTaxRate, BusinessAddedOn) + VALUES (:name, :phone, :userId, :addressId, :deliveryZips, :communityMealType, :taxRate, NOW()) ", { name: bizName, phone: bizPhone, userId: userId, addressId: addressId, - deliveryZips: len(zip) ? zip : "" + deliveryZips: len(zip) ? zip : "", + communityMealType: communityMealType, + taxRate: { value: bizTaxRate, cfsqltype: "cf_sql_decimal" } }, { datasource: "payfrit" }); qNewBiz = queryExecute("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" }); diff --git a/api/tasks/callServer.cfm b/api/tasks/callServer.cfm index 61c3337..966cee5 100644 --- a/api/tasks/callServer.cfm +++ b/api/tasks/callServer.cfm @@ -58,14 +58,20 @@ try { } } - // Get task type name if TaskTypeID provided + // Get task type info if TaskTypeID provided (name + category) taskTypeName = ""; + taskTypeCategoryID = 0; if (taskTypeID > 0) { typeQuery = queryExecute(" - SELECT tt_TaskTypeName FROM tt_TaskTypes WHERE tt_TaskTypeID = :typeID + SELECT tt_TaskTypeName, tt_TaskTypeCategoryID FROM tt_TaskTypes WHERE tt_TaskTypeID = :typeID ", { typeID: { value: taskTypeID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); - if (typeQuery.recordCount && len(trim(typeQuery.tt_TaskTypeName))) { - taskTypeName = typeQuery.tt_TaskTypeName; + if (typeQuery.recordCount) { + if (len(trim(typeQuery.tt_TaskTypeName))) { + taskTypeName = typeQuery.tt_TaskTypeName; + } + if (!isNull(typeQuery.tt_TaskTypeCategoryID) && isNumeric(typeQuery.tt_TaskTypeCategoryID) && typeQuery.tt_TaskTypeCategoryID > 0) { + taskTypeCategoryID = typeQuery.tt_TaskTypeCategoryID; + } } } @@ -90,24 +96,32 @@ try { taskDetails &= "Customer is requesting assistance"; } - // Look up or create a "Service" category for this business - catQuery = queryExecute(" - SELECT TaskCategoryID FROM TaskCategories - WHERE TaskCategoryBusinessID = :businessID AND TaskCategoryName = 'Service' - LIMIT 1 - ", { businessID: { value: businessID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); + // Determine category: use task type's category if set, otherwise fallback to "Service" category + categoryID = 0; - if (catQuery.recordCount == 0) { - // Create the category - queryExecute(" - INSERT INTO TaskCategories (TaskCategoryBusinessID, TaskCategoryName, TaskCategoryColor) - VALUES (:businessID, 'Service', '##FF9800') + if (taskTypeCategoryID > 0) { + // Use the task type's assigned category + categoryID = taskTypeCategoryID; + } else { + // Fallback: look up or create a "Service" category for this business + catQuery = queryExecute(" + SELECT TaskCategoryID FROM TaskCategories + WHERE TaskCategoryBusinessID = :businessID AND TaskCategoryName = 'Service' + LIMIT 1 ", { businessID: { value: businessID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); - catResult = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" }); - categoryID = catResult.newID; - } else { - categoryID = catQuery.TaskCategoryID; + if (catQuery.recordCount == 0) { + // Create the category + queryExecute(" + INSERT INTO TaskCategories (TaskCategoryBusinessID, TaskCategoryName, TaskCategoryColor) + VALUES (:businessID, 'Service', '##FF9800') + ", { businessID: { value: businessID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); + + catResult = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" }); + categoryID = catResult.newID; + } else { + categoryID = catQuery.TaskCategoryID; + } } // Insert task diff --git a/api/tasks/complete.cfm b/api/tasks/complete.cfm index 0a5403f..c30eac2 100644 --- a/api/tasks/complete.cfm +++ b/api/tasks/complete.cfm @@ -26,11 +26,18 @@ + + + + + + + @@ -38,9 +45,11 @@ @@ -63,6 +72,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/api/tasks/deleteCategory.cfm b/api/tasks/deleteCategory.cfm new file mode 100644 index 0000000..e36dea9 --- /dev/null +++ b/api/tasks/deleteCategory.cfm @@ -0,0 +1,107 @@ + + + + + +// Delete (deactivate) a task category +// Input: BusinessID, TaskCategoryID +// Output: { OK: true } + +function apiAbort(required struct payload) { + writeOutput(serializeJSON(payload)); + abort; +} + +function readJsonBody() { + var raw = getHttpRequestData().content; + if (isNull(raw)) raw = ""; + if (!len(trim(raw))) return {}; + try { + var data = deserializeJSON(raw); + if (isStruct(data)) return data; + } catch (any e) {} + return {}; +} + +try { + data = readJsonBody(); + + // Get BusinessID + businessID = 0; + httpHeaders = getHttpRequestData().headers; + + if (structKeyExists(url, "bid") && isNumeric(url.bid)) { + businessID = int(url.bid); + } else if (structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID)) { + businessID = int(data.BusinessID); + } else if (structKeyExists(httpHeaders, "X-Business-ID") && isNumeric(httpHeaders["X-Business-ID"])) { + businessID = int(httpHeaders["X-Business-ID"]); + } + + if (businessID == 0) { + apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" }); + } + + // Get CategoryID + categoryID = structKeyExists(data, "TaskCategoryID") && isNumeric(data.TaskCategoryID) ? int(data.TaskCategoryID) : 0; + + if (categoryID == 0) { + apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "TaskCategoryID is required" }); + } + + // Verify ownership + qCheck = queryExecute(" + SELECT TaskCategoryID FROM TaskCategories + WHERE TaskCategoryID = :id AND TaskCategoryBusinessID = :businessID + ", { + id: { value: categoryID, cfsqltype: "cf_sql_integer" }, + businessID: { value: businessID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + if (qCheck.recordCount == 0) { + apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Category not found" }); + } + + // Check if any tasks use this category + qTasks = queryExecute(" + SELECT COUNT(*) as cnt FROM Tasks + WHERE TaskCategoryID = :id + ", { + id: { value: categoryID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + if (qTasks.cnt > 0) { + // Soft delete - just deactivate + queryExecute(" + UPDATE TaskCategories SET TaskCategoryIsActive = 0 + WHERE TaskCategoryID = :id + ", { + id: { value: categoryID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + apiAbort({ + "OK": true, + "MESSAGE": "Category deactivated (has " & qTasks.cnt & " tasks)" + }); + } else { + // Hard delete - no tasks use it + queryExecute(" + DELETE FROM TaskCategories WHERE TaskCategoryID = :id + ", { + id: { value: categoryID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + apiAbort({ + "OK": true, + "MESSAGE": "Category deleted" + }); + } + +} catch (any e) { + apiAbort({ + "OK": false, + "ERROR": "server_error", + "MESSAGE": e.message + }); +} + diff --git a/api/tasks/listAllTypes.cfm b/api/tasks/listAllTypes.cfm index 5245afa..a1c7138 100644 --- a/api/tasks/listAllTypes.cfm +++ b/api/tasks/listAllTypes.cfm @@ -50,7 +50,8 @@ try { tt_TaskTypeDescription as TaskTypeDescription, tt_TaskTypeIcon as TaskTypeIcon, tt_TaskTypeColor as TaskTypeColor, - tt_TaskTypeSortOrder as SortOrder + tt_TaskTypeSortOrder as SortOrder, + tt_TaskTypeCategoryID as CategoryID FROM tt_TaskTypes WHERE tt_TaskTypeBusinessID = :businessID ORDER BY tt_TaskTypeSortOrder, tt_TaskTypeID @@ -65,7 +66,8 @@ try { "TaskTypeName": row.TaskTypeName, "TaskTypeDescription": isNull(row.TaskTypeDescription) ? "" : row.TaskTypeDescription, "TaskTypeIcon": isNull(row.TaskTypeIcon) ? "notifications" : row.TaskTypeIcon, - "TaskTypeColor": isNull(row.TaskTypeColor) ? "##9C27B0" : row.TaskTypeColor + "TaskTypeColor": isNull(row.TaskTypeColor) ? "##9C27B0" : row.TaskTypeColor, + "CategoryID": isNull(row.CategoryID) ? "" : row.CategoryID }); } diff --git a/api/tasks/listCategories.cfm b/api/tasks/listCategories.cfm new file mode 100644 index 0000000..04f58d9 --- /dev/null +++ b/api/tasks/listCategories.cfm @@ -0,0 +1,83 @@ + + + + + +// List task categories for a business +// Categories ARE Task Types (merged concept) +// Input: BusinessID (required) +// Output: { OK: true, CATEGORIES: [...] } + +function apiAbort(required struct payload) { + writeOutput(serializeJSON(payload)); + abort; +} + +function readJsonBody() { + var raw = getHttpRequestData().content; + if (isNull(raw)) raw = ""; + if (!len(trim(raw))) return {}; + try { + var data = deserializeJSON(raw); + if (isStruct(data)) return data; + } catch (any e) {} + return {}; +} + +try { + data = readJsonBody(); + + // Get BusinessID from URL, body, or header + businessID = 0; + httpHeaders = getHttpRequestData().headers; + + if (structKeyExists(url, "bid") && isNumeric(url.bid)) { + businessID = int(url.bid); + } else if (structKeyExists(url, "BusinessID") && isNumeric(url.BusinessID)) { + businessID = int(url.BusinessID); + } else if (structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID)) { + businessID = int(data.BusinessID); + } else if (structKeyExists(httpHeaders, "X-Business-ID") && isNumeric(httpHeaders["X-Business-ID"])) { + businessID = int(httpHeaders["X-Business-ID"]); + } + + if (businessID == 0) { + apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" }); + } + + // Get Task Categories (separate from Services/Task Types) + qCategories = queryExecute(" + SELECT + TaskCategoryID, + TaskCategoryName, + TaskCategoryColor + FROM TaskCategories + WHERE TaskCategoryBusinessID = :businessID + AND TaskCategoryIsActive = 1 + ORDER BY TaskCategoryName + ", { + businessID: { value: businessID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + categories = []; + for (row in qCategories) { + arrayAppend(categories, { + "TaskCategoryID": row.TaskCategoryID, + "TaskCategoryName": row.TaskCategoryName, + "TaskCategoryColor": row.TaskCategoryColor + }); + } + + apiAbort({ + "OK": true, + "CATEGORIES": categories + }); + +} catch (any e) { + apiAbort({ + "OK": false, + "ERROR": "server_error", + "MESSAGE": e.message + }); +} + diff --git a/api/tasks/saveCategory.cfm b/api/tasks/saveCategory.cfm new file mode 100644 index 0000000..d50c26f --- /dev/null +++ b/api/tasks/saveCategory.cfm @@ -0,0 +1,124 @@ + + + + + +// Create or update a task category +// Input: BusinessID, TaskCategoryID (optional, for update), Name, Color +// Output: { OK: true, CATEGORY_ID: int } + +function apiAbort(required struct payload) { + writeOutput(serializeJSON(payload)); + abort; +} + +function readJsonBody() { + var raw = getHttpRequestData().content; + if (isNull(raw)) raw = ""; + if (!len(trim(raw))) return {}; + try { + var data = deserializeJSON(raw); + if (isStruct(data)) return data; + } catch (any e) {} + return {}; +} + +try { + data = readJsonBody(); + + // Get BusinessID + businessID = 0; + httpHeaders = getHttpRequestData().headers; + + if (structKeyExists(url, "bid") && isNumeric(url.bid)) { + businessID = int(url.bid); + } else if (structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID)) { + businessID = int(data.BusinessID); + } else if (structKeyExists(httpHeaders, "X-Business-ID") && isNumeric(httpHeaders["X-Business-ID"])) { + businessID = int(httpHeaders["X-Business-ID"]); + } + + if (businessID == 0) { + apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" }); + } + + // Get fields + categoryID = structKeyExists(data, "TaskCategoryID") && isNumeric(data.TaskCategoryID) ? int(data.TaskCategoryID) : 0; + categoryName = structKeyExists(data, "Name") ? trim(toString(data.Name)) : ""; + categoryColor = structKeyExists(data, "Color") ? trim(toString(data.Color)) : "##6366f1"; + + if (!len(categoryName)) { + apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Name is required" }); + } + + // Validate color format + if (!reFindNoCase("^##[0-9A-F]{6}$", categoryColor)) { + // Try to fix common issues + if (reFindNoCase("^[0-9A-F]{6}$", categoryColor)) { + categoryColor = "##" & categoryColor; + } else if (reFindNoCase("^##[0-9A-F]{6}$", categoryColor)) { + categoryColor = "##" & right(categoryColor, 6); + } else { + categoryColor = "##6366f1"; + } + } + + if (categoryID > 0) { + // UPDATE existing category + qCheck = queryExecute(" + SELECT TaskCategoryID FROM TaskCategories + WHERE TaskCategoryID = :id AND TaskCategoryBusinessID = :businessID + ", { + id: { value: categoryID, cfsqltype: "cf_sql_integer" }, + businessID: { value: businessID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + if (qCheck.recordCount == 0) { + apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Category not found" }); + } + + queryExecute(" + UPDATE TaskCategories SET + TaskCategoryName = :name, + TaskCategoryColor = :color + WHERE TaskCategoryID = :id + ", { + name: { value: categoryName, cfsqltype: "cf_sql_varchar" }, + color: { value: categoryColor, cfsqltype: "cf_sql_varchar" }, + id: { value: categoryID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + apiAbort({ + "OK": true, + "CATEGORY_ID": categoryID, + "MESSAGE": "Category updated" + }); + + } else { + // INSERT new category + queryExecute(" + INSERT INTO TaskCategories (TaskCategoryBusinessID, TaskCategoryName, TaskCategoryColor) + VALUES (:businessID, :name, :color) + ", { + businessID: { value: businessID, cfsqltype: "cf_sql_integer" }, + name: { value: categoryName, cfsqltype: "cf_sql_varchar" }, + color: { value: categoryColor, cfsqltype: "cf_sql_varchar" } + }, { datasource: "payfrit" }); + + qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" }); + + apiAbort({ + "OK": true, + "CATEGORY_ID": qNew.newID, + "MESSAGE": "Category created" + }); + } + +} catch (any e) { + apiAbort({ + "OK": false, + "ERROR": "server_error", + "MESSAGE": e.message + }); +} + diff --git a/api/tasks/saveType.cfm b/api/tasks/saveType.cfm index de2922c..56c4853 100644 --- a/api/tasks/saveType.cfm +++ b/api/tasks/saveType.cfm @@ -78,6 +78,14 @@ try { apiAbort({ "OK": false, "ERROR": "invalid_params", "MESSAGE": "TaskTypeColor must be a valid hex color" }); } + // Get TaskTypeCategoryID (optional - links to TaskCategories for task creation) + taskTypeCategoryID = javaCast("null", ""); + if (structKeyExists(data, "TaskTypeCategoryID") && isNumeric(data.TaskTypeCategoryID) && data.TaskTypeCategoryID > 0) { + taskTypeCategoryID = int(data.TaskTypeCategoryID); + } else if (structKeyExists(data, "CategoryID") && isNumeric(data.CategoryID) && data.CategoryID > 0) { + taskTypeCategoryID = int(data.CategoryID); + } + // Get TaskTypeID (optional - for update) taskTypeID = 0; if (structKeyExists(data, "TaskTypeID") && isNumeric(data.TaskTypeID)) { @@ -104,13 +112,15 @@ try { SET tt_TaskTypeName = :taskTypeName, tt_TaskTypeDescription = :taskTypeDescription, tt_TaskTypeIcon = :taskTypeIcon, - tt_TaskTypeColor = :taskTypeColor + tt_TaskTypeColor = :taskTypeColor, + tt_TaskTypeCategoryID = :categoryID WHERE tt_TaskTypeID = :taskTypeID ", { taskTypeName: { value: taskTypeName, cfsqltype: "cf_sql_varchar" }, taskTypeDescription: { value: taskTypeDescription, cfsqltype: "cf_sql_varchar", null: !len(taskTypeDescription) }, taskTypeIcon: { value: taskTypeIcon, cfsqltype: "cf_sql_varchar" }, taskTypeColor: { value: taskTypeColor, cfsqltype: "cf_sql_varchar" }, + categoryID: { value: taskTypeCategoryID, cfsqltype: "cf_sql_integer", null: isNull(taskTypeCategoryID) }, taskTypeID: { value: taskTypeID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); @@ -122,14 +132,15 @@ try { } else { // INSERT new task type queryExecute(" - INSERT INTO tt_TaskTypes (tt_TaskTypeName, tt_TaskTypeDescription, tt_TaskTypeIcon, tt_TaskTypeColor, tt_TaskTypeBusinessID) - VALUES (:taskTypeName, :taskTypeDescription, :taskTypeIcon, :taskTypeColor, :businessID) + INSERT INTO tt_TaskTypes (tt_TaskTypeName, tt_TaskTypeDescription, tt_TaskTypeIcon, tt_TaskTypeColor, tt_TaskTypeBusinessID, tt_TaskTypeCategoryID) + VALUES (:taskTypeName, :taskTypeDescription, :taskTypeIcon, :taskTypeColor, :businessID, :categoryID) ", { taskTypeName: { value: taskTypeName, cfsqltype: "cf_sql_varchar" }, taskTypeDescription: { value: taskTypeDescription, cfsqltype: "cf_sql_varchar", null: !len(taskTypeDescription) }, taskTypeIcon: { value: taskTypeIcon, cfsqltype: "cf_sql_varchar" }, taskTypeColor: { value: taskTypeColor, cfsqltype: "cf_sql_varchar" }, - businessID: { value: businessID, cfsqltype: "cf_sql_integer" } + businessID: { value: businessID, cfsqltype: "cf_sql_integer" }, + categoryID: { value: taskTypeCategoryID, cfsqltype: "cf_sql_integer", null: isNull(taskTypeCategoryID) } }, { datasource: "payfrit" }); qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" }); diff --git a/api/tasks/seedCategories.cfm b/api/tasks/seedCategories.cfm new file mode 100644 index 0000000..09d9b30 --- /dev/null +++ b/api/tasks/seedCategories.cfm @@ -0,0 +1,137 @@ + + + + + +// Seed default task categories for a business +// Input: BusinessID (required) +// Output: { OK: true, CATEGORIES: [...] } + +function apiAbort(required struct payload) { + writeOutput(serializeJSON(payload)); + abort; +} + +function readJsonBody() { + var raw = getHttpRequestData().content; + if (isNull(raw)) raw = ""; + if (!len(trim(raw))) return {}; + try { + var data = deserializeJSON(raw); + if (isStruct(data)) return data; + } catch (any e) {} + return {}; +} + +try { + data = readJsonBody(); + + // Get BusinessID from URL, body, or header + businessID = 0; + httpHeaders = getHttpRequestData().headers; + + if (structKeyExists(url, "bid") && isNumeric(url.bid)) { + businessID = int(url.bid); + } else if (structKeyExists(url, "BusinessID") && isNumeric(url.BusinessID)) { + businessID = int(url.BusinessID); + } else if (structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID)) { + businessID = int(data.BusinessID); + } else if (structKeyExists(httpHeaders, "X-Business-ID") && isNumeric(httpHeaders["X-Business-ID"])) { + businessID = int(httpHeaders["X-Business-ID"]); + } + + if (businessID == 0) { + apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" }); + } + + // Check if categories already exist + qCheck = queryExecute(" + SELECT COUNT(*) AS cnt FROM TaskCategories + WHERE TaskCategoryBusinessID = :businessID + ", { + businessID: { value: businessID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + if (qCheck.cnt > 0) { + // Categories already exist, just return them + qCategories = queryExecute(" + SELECT TaskCategoryID, TaskCategoryName, TaskCategoryColor + FROM TaskCategories + WHERE TaskCategoryBusinessID = :businessID AND TaskCategoryIsActive = 1 + ORDER BY TaskCategoryName + ", { + businessID: { value: businessID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + categories = []; + for (row in qCategories) { + arrayAppend(categories, { + "TaskCategoryID": row.TaskCategoryID, + "TaskCategoryName": row.TaskCategoryName, + "TaskCategoryColor": row.TaskCategoryColor + }); + } + + apiAbort({ + "OK": true, + "MESSAGE": "Categories already exist", + "CATEGORIES": categories + }); + } + + // Default categories for restaurants + defaults = [ + { name: "Service Point", color: "##F44336" }, // Red - for service point requests + { name: "Kitchen", color: "##FF9800" }, // Orange + { name: "Bar", color: "##9C27B0" }, // Purple + { name: "Cleaning", color: "##4CAF50" }, // Green + { name: "Management", color: "##2196F3" }, // Blue + { name: "Delivery", color: "##00BCD4" }, // Cyan + { name: "General", color: "##607D8B" } // Blue Grey + ]; + + // Insert defaults + for (cat in defaults) { + queryExecute(" + INSERT INTO TaskCategories (TaskCategoryBusinessID, TaskCategoryName, TaskCategoryColor) + VALUES (:businessID, :name, :color) + ", { + businessID: { value: businessID, cfsqltype: "cf_sql_integer" }, + name: { value: cat.name, cfsqltype: "cf_sql_varchar" }, + color: { value: cat.color, cfsqltype: "cf_sql_varchar" } + }, { datasource: "payfrit" }); + } + + // Return the created categories + qCategories = queryExecute(" + SELECT TaskCategoryID, TaskCategoryName, TaskCategoryColor + FROM TaskCategories + WHERE TaskCategoryBusinessID = :businessID AND TaskCategoryIsActive = 1 + ORDER BY TaskCategoryName + ", { + businessID: { value: businessID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + categories = []; + for (row in qCategories) { + arrayAppend(categories, { + "TaskCategoryID": row.TaskCategoryID, + "TaskCategoryName": row.TaskCategoryName, + "TaskCategoryColor": row.TaskCategoryColor + }); + } + + apiAbort({ + "OK": true, + "MESSAGE": "Created " & arrayLen(defaults) & " default categories", + "CATEGORIES": categories + }); + +} catch (any e) { + apiAbort({ + "OK": false, + "ERROR": "server_error", + "MESSAGE": e.message + }); +} + diff --git a/hud/index.html b/hud/index.html index ea23a2a..97e5dfc 100644 --- a/hud/index.html +++ b/hud/index.html @@ -84,7 +84,7 @@ } .task-bar.flashing { - animation: flash 0.5s ease-in-out infinite; + animation: flash 1.5s ease-in-out infinite; } @keyframes flash { @@ -264,6 +264,56 @@ --task-color-glow: rgba(236, 72, 153, 0.3); } + /* Fullscreen/maximize button */ + .fullscreen-btn { + background: transparent; + border: 1px solid #444; + color: #666; + padding: 8px 12px; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + transition: all 0.2s; + margin-left: 16px; + } + + .fullscreen-btn:hover { + border-color: #666; + color: #888; + } + + .fullscreen-btn:active { + transform: scale(0.95); + } + + /* Maximized mode - hide header, use full screen */ + body.maximized .header { + display: none; + } + + body.maximized .task-container { + padding-top: 20px; + } + + body.maximized .restore-btn { + display: block; + } + + .restore-btn { + display: none; + position: fixed; + top: 10px; + right: 10px; + background: rgba(50, 50, 50, 0.8); + border: none; + color: #888; + padding: 12px 16px; + border-radius: 8px; + cursor: pointer; + font-size: 16px; + z-index: 200; + } + /* Connection status */ .status-indicator { position: fixed; @@ -289,7 +339,10 @@

Payfrit Tasks

-
--:--:--
+
+
--:--:--
+ +
@@ -326,6 +379,7 @@
+ diff --git a/portal/index.html b/portal/index.html index d950ef3..76ce6f5 100644 --- a/portal/index.html +++ b/portal/index.html @@ -623,6 +623,21 @@ + +
+
+

Customer Preview

+
+
+

See how your business looks in the Payfrit app. Open this link on your phone to preview your menu as a customer would see it.

+ + + + + Open in Payfrit App + +
+
diff --git a/portal/menu-builder.html b/portal/menu-builder.html index c3b94a4..50f8d25 100644 --- a/portal/menu-builder.html +++ b/portal/menu-builder.html @@ -911,6 +911,12 @@
+
@@ -2829,27 +2830,58 @@ // Upload photo uploadPhoto(itemId) { - // Create file input + // Find the item and check it has a database ID + let targetItem = null; + for (const cat of this.menu.categories) { + const item = cat.items.find(i => i.id === itemId); + if (item) { targetItem = item; break; } + } + if (!targetItem || !targetItem.dbId) { + this.toast('Please save the menu first before uploading photos', 'error'); + return; + } + const input = document.createElement('input'); input.type = 'file'; - input.accept = 'image/*'; + input.accept = 'image/jpeg,image/png,image/gif,image/webp'; input.onchange = async (e) => { const file = e.target.files[0]; - if (file) { - // For demo, just use a data URL - const reader = new FileReader(); - reader.onload = () => { - for (const cat of this.menu.categories) { - const item = cat.items.find(i => i.id === itemId); - if (item) { - item.imageUrl = reader.result; - break; - } - } + if (!file) return; + + const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; + if (!validTypes.includes(file.type)) { + this.toast('Please select a valid image (JPG, PNG, GIF, or WebP)', 'error'); + return; + } + if (file.size > 5 * 1024 * 1024) { + this.toast('Image must be under 5MB', 'error'); + return; + } + + this.toast('Uploading photo...', 'info'); + + try { + const formData = new FormData(); + formData.append('photo', file); + formData.append('ItemID', targetItem.dbId); + + const response = await fetch(`${this.config.apiBaseUrl}/menu/uploadItemPhoto.cfm`, { + method: 'POST', + body: formData, + credentials: 'include' + }); + + const data = await response.json(); + if (data.OK) { + targetItem.imageUrl = data.IMAGEURL + '?t=' + Date.now(); this.render(); - this.toast('Photo uploaded', 'success'); - }; - reader.readAsDataURL(file); + this.toast('Photo uploaded!', 'success'); + } else { + this.toast(data.MESSAGE || 'Failed to upload photo', 'error'); + } + } catch (err) { + console.error('Photo upload error:', err); + this.toast('Failed to upload photo', 'error'); } }; input.click(); @@ -3553,9 +3585,19 @@ } console.log('[MenuBuilder] Menu data:', JSON.stringify(this.menu, null, 2)); + // Deep clone menu and strip base64 data URLs to avoid huge payloads + const menuClone = JSON.parse(JSON.stringify(this.menu)); + for (const cat of menuClone.categories) { + for (const item of (cat.items || [])) { + if (item.imageUrl && item.imageUrl.startsWith('data:')) { + item.imageUrl = null; + } + } + } + const payload = { BusinessID: this.config.businessId, - Menu: this.menu + Menu: menuClone }; console.log('[MenuBuilder] Full payload:', JSON.stringify(payload, null, 2)); diff --git a/portal/portal.css b/portal/portal.css index d45e303..f27169b 100644 --- a/portal/portal.css +++ b/portal/portal.css @@ -1,10 +1,10 @@ /* Payfrit Business Portal - Modern Admin UI */ :root { - --primary: #6366f1; - --primary-dark: #4f46e5; - --primary-light: #818cf8; - --primary-hover: #4f46e5; + --primary: #00d974; + --primary-dark: #00b862; + --primary-light: #33ff9f; + --primary-hover: #00b862; --success: #22c55e; --warning: #f59e0b; --danger: #ef4444; @@ -396,7 +396,7 @@ body { } .stat-icon.orders { - background: rgba(99, 102, 241, 0.1); + background: rgba(0, 217, 116, 0.1); color: var(--primary); } @@ -572,7 +572,7 @@ body { .form-textarea:focus { outline: none; border-color: var(--primary); - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); + box-shadow: 0 0 0 3px rgba(0, 217, 116, 0.1); } .form-row { @@ -681,7 +681,7 @@ body { } .status-icon.pending { - background: rgba(99, 102, 241, 0.1); + background: rgba(0, 217, 116, 0.1); color: var(--primary); } @@ -875,7 +875,7 @@ body { } .status-badge.submitted { - background: rgba(99, 102, 241, 0.1); + background: rgba(0, 217, 116, 0.1); color: var(--primary); } diff --git a/portal/portal.js b/portal/portal.js index e256b01..47fee6b 100644 --- a/portal/portal.js +++ b/portal/portal.js @@ -1099,6 +1099,16 @@ const Portal = { }, // Connect Stripe - initiate onboarding + // Open customer preview in Payfrit app via deep link + openCustomerPreview() { + const businessId = this.config.businessId; + const businessName = encodeURIComponent( + this.currentBusiness?.BUSINESSNAME || this.currentBusiness?.BusinessName || 'Preview' + ); + const deepLink = `payfrit://open?businessId=${businessId}&businessName=${businessName}`; + window.location.href = deepLink; + }, + async connectStripe() { this.toast('Starting Stripe setup...', 'info'); diff --git a/portal/setup-wizard.html b/portal/setup-wizard.html index 199e040..7a04f2b 100644 --- a/portal/setup-wizard.html +++ b/portal/setup-wizard.html @@ -835,6 +835,43 @@ + + +
+
+

Community Meal Participation

+

Choose how this location participates.

+
+
+ + + + + + + + Learn more about Community Meal Participation + +
+
+