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 <noreply@anthropic.com>
This commit is contained in:
parent
0d3c381ed6
commit
8f9da2fbf0
44 changed files with 2709 additions and 231 deletions
5
CHANGELOG.md
Normal file
5
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Payfrit Portal Updates
|
||||||
|
|
||||||
|
## Week of 2026-01-26
|
||||||
|
|
||||||
|
- Fixed saved cart not being detected when entering child business
|
||||||
37
api/admin/addCategoryIsActiveColumn.cfm
Normal file
37
api/admin/addCategoryIsActiveColumn.cfm
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
<cfsetting showdebugoutput="false">
|
||||||
|
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||||
|
|
||||||
|
<cfscript>
|
||||||
|
// 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
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
</cfscript>
|
||||||
37
api/admin/addServiceCategoryColumn.cfm
Normal file
37
api/admin/addServiceCategoryColumn.cfm
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
<cfsetting showdebugoutput="false">
|
||||||
|
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||||
|
|
||||||
|
<cfscript>
|
||||||
|
// 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
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
</cfscript>
|
||||||
37
api/admin/fixOrphanedTaskCategories.cfm
Normal file
37
api/admin/fixOrphanedTaskCategories.cfm
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
<cfsetting showdebugoutput="false">
|
||||||
|
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||||
|
|
||||||
|
<cfscript>
|
||||||
|
// 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
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
</cfscript>
|
||||||
37
api/admin/quickTasks/addTypeIdColumn.cfm
Normal file
37
api/admin/quickTasks/addTypeIdColumn.cfm
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
<cfsetting showdebugoutput="false">
|
||||||
|
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||||
|
|
||||||
|
<cfscript>
|
||||||
|
// 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
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
</cfscript>
|
||||||
21
api/admin/quickTasks/cleanup.cfm
Normal file
21
api/admin/quickTasks/cleanup.cfm
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<cfsetting showdebugoutput="false">
|
||||||
|
<cfsetting enablecfoutputonly="true">
|
||||||
|
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||||
|
|
||||||
|
<cfscript>
|
||||||
|
// 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
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
</cfscript>
|
||||||
|
|
@ -52,8 +52,7 @@ try {
|
||||||
SELECT
|
SELECT
|
||||||
QuickTaskTemplateTitle as Title,
|
QuickTaskTemplateTitle as Title,
|
||||||
QuickTaskTemplateDetails as Details,
|
QuickTaskTemplateDetails as Details,
|
||||||
QuickTaskTemplateCategoryID as CategoryID,
|
QuickTaskTemplateCategoryID as CategoryID
|
||||||
QuickTaskTemplateTypeID as TypeID
|
|
||||||
FROM QuickTaskTemplates
|
FROM QuickTaskTemplates
|
||||||
WHERE QuickTaskTemplateID = :id
|
WHERE QuickTaskTemplateID = :id
|
||||||
AND QuickTaskTemplateBusinessID = :businessID
|
AND QuickTaskTemplateBusinessID = :businessID
|
||||||
|
|
@ -67,24 +66,21 @@ try {
|
||||||
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Template not found" });
|
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Template not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the task
|
// Create the task (TaskClaimedByUserID=0 means unclaimed/pending)
|
||||||
queryExecute("
|
queryExecute("
|
||||||
INSERT INTO Tasks (
|
INSERT INTO Tasks (
|
||||||
TaskBusinessID, TaskCategoryID, TaskTypeID,
|
TaskBusinessID, TaskCategoryID, TaskTypeID,
|
||||||
TaskTitle, TaskDetails, TaskStatusID, TaskAddedOn,
|
TaskTitle, TaskDetails, TaskAddedOn, TaskClaimedByUserID
|
||||||
TaskSourceType, TaskSourceID
|
|
||||||
) VALUES (
|
) VALUES (
|
||||||
:businessID, :categoryID, :typeID,
|
:businessID, :categoryID, :typeID,
|
||||||
:title, :details, 0, NOW(),
|
:title, :details, NOW(), 0
|
||||||
'quicktask', :templateID
|
|
||||||
)
|
)
|
||||||
", {
|
", {
|
||||||
businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
|
businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
|
||||||
categoryID: { value: qTemplate.CategoryID, cfsqltype: "cf_sql_integer", null: isNull(qTemplate.CategoryID) },
|
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" },
|
title: { value: qTemplate.Title, cfsqltype: "cf_sql_varchar" },
|
||||||
details: { value: qTemplate.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(qTemplate.Details) },
|
details: { value: qTemplate.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(qTemplate.Details) }
|
||||||
templateID: { value: templateID, cfsqltype: "cf_sql_integer" }
|
|
||||||
}, { datasource: "payfrit" });
|
}, { datasource: "payfrit" });
|
||||||
|
|
||||||
qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
|
qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
|
||||||
|
|
|
||||||
39
api/admin/quickTasks/debug.cfm
Normal file
39
api/admin/quickTasks/debug.cfm
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<cfsetting showdebugoutput="false">
|
||||||
|
<cfsetting enablecfoutputonly="true">
|
||||||
|
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||||
|
|
||||||
|
<cfscript>
|
||||||
|
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
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
</cfscript>
|
||||||
|
|
@ -48,7 +48,6 @@ try {
|
||||||
qt.QuickTaskTemplateID,
|
qt.QuickTaskTemplateID,
|
||||||
qt.QuickTaskTemplateName as Name,
|
qt.QuickTaskTemplateName as Name,
|
||||||
qt.QuickTaskTemplateCategoryID as CategoryID,
|
qt.QuickTaskTemplateCategoryID as CategoryID,
|
||||||
qt.QuickTaskTemplateTypeID as TypeID,
|
|
||||||
qt.QuickTaskTemplateTitle as Title,
|
qt.QuickTaskTemplateTitle as Title,
|
||||||
qt.QuickTaskTemplateDetails as Details,
|
qt.QuickTaskTemplateDetails as Details,
|
||||||
qt.QuickTaskTemplateIcon as Icon,
|
qt.QuickTaskTemplateIcon as Icon,
|
||||||
|
|
@ -72,7 +71,6 @@ try {
|
||||||
"QuickTaskTemplateID": row.QuickTaskTemplateID,
|
"QuickTaskTemplateID": row.QuickTaskTemplateID,
|
||||||
"Name": row.Name,
|
"Name": row.Name,
|
||||||
"CategoryID": isNull(row.CategoryID) ? "" : row.CategoryID,
|
"CategoryID": isNull(row.CategoryID) ? "" : row.CategoryID,
|
||||||
"TypeID": isNull(row.TypeID) ? "" : row.TypeID,
|
|
||||||
"Title": row.Title,
|
"Title": row.Title,
|
||||||
"Details": isNull(row.Details) ? "" : row.Details,
|
"Details": isNull(row.Details) ? "" : row.Details,
|
||||||
"Icon": isNull(row.Icon) ? "add_box" : row.Icon,
|
"Icon": isNull(row.Icon) ? "add_box" : row.Icon,
|
||||||
|
|
|
||||||
20
api/admin/quickTasks/purge.cfm
Normal file
20
api/admin/quickTasks/purge.cfm
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<cfsetting showdebugoutput="false">
|
||||||
|
<cfsetting enablecfoutputonly="true">
|
||||||
|
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||||
|
|
||||||
|
<cfscript>
|
||||||
|
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
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
</cfscript>
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
<cfscript>
|
<cfscript>
|
||||||
// Create or update a quick task template
|
// Create or update a quick task template
|
||||||
// Input: BusinessID (required), QuickTaskTemplateID (optional - for update),
|
// 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 }
|
// Output: { OK: true, TEMPLATE_ID: int }
|
||||||
|
|
||||||
function apiAbort(required struct payload) {
|
function apiAbort(required struct payload) {
|
||||||
|
|
@ -46,8 +46,12 @@ try {
|
||||||
templateName = structKeyExists(data, "Name") ? trim(toString(data.Name)) : "";
|
templateName = structKeyExists(data, "Name") ? trim(toString(data.Name)) : "";
|
||||||
templateTitle = structKeyExists(data, "Title") ? trim(toString(data.Title)) : "";
|
templateTitle = structKeyExists(data, "Title") ? trim(toString(data.Title)) : "";
|
||||||
templateDetails = structKeyExists(data, "Details") ? trim(toString(data.Details)) : "";
|
templateDetails = structKeyExists(data, "Details") ? trim(toString(data.Details)) : "";
|
||||||
categoryID = structKeyExists(data, "CategoryID") && isNumeric(data.CategoryID) && data.CategoryID > 0 ? int(data.CategoryID) : javaCast("null", "");
|
hasCategory = false;
|
||||||
typeID = structKeyExists(data, "TypeID") && isNumeric(data.TypeID) && data.TypeID > 0 ? int(data.TypeID) : javaCast("null", "");
|
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";
|
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";
|
templateColor = structKeyExists(data, "Color") && len(trim(data.Color)) ? trim(toString(data.Color)) : "##6366f1";
|
||||||
|
|
||||||
|
|
@ -58,6 +62,9 @@ try {
|
||||||
if (!len(templateTitle)) {
|
if (!len(templateTitle)) {
|
||||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Title is required" });
|
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) {
|
if (templateID > 0) {
|
||||||
// UPDATE existing template
|
// UPDATE existing template
|
||||||
|
|
@ -79,7 +86,6 @@ try {
|
||||||
QuickTaskTemplateTitle = :title,
|
QuickTaskTemplateTitle = :title,
|
||||||
QuickTaskTemplateDetails = :details,
|
QuickTaskTemplateDetails = :details,
|
||||||
QuickTaskTemplateCategoryID = :categoryID,
|
QuickTaskTemplateCategoryID = :categoryID,
|
||||||
QuickTaskTemplateTypeID = :typeID,
|
|
||||||
QuickTaskTemplateIcon = :icon,
|
QuickTaskTemplateIcon = :icon,
|
||||||
QuickTaskTemplateColor = :color
|
QuickTaskTemplateColor = :color
|
||||||
WHERE QuickTaskTemplateID = :id
|
WHERE QuickTaskTemplateID = :id
|
||||||
|
|
@ -87,8 +93,7 @@ try {
|
||||||
name: { value: templateName, cfsqltype: "cf_sql_varchar" },
|
name: { value: templateName, cfsqltype: "cf_sql_varchar" },
|
||||||
title: { value: templateTitle, cfsqltype: "cf_sql_varchar" },
|
title: { value: templateTitle, cfsqltype: "cf_sql_varchar" },
|
||||||
details: { value: templateDetails, cfsqltype: "cf_sql_longvarchar", null: !len(templateDetails) },
|
details: { value: templateDetails, cfsqltype: "cf_sql_longvarchar", null: !len(templateDetails) },
|
||||||
categoryID: { value: categoryID, cfsqltype: "cf_sql_integer", null: isNull(categoryID) },
|
categoryID: { value: catID, cfsqltype: "cf_sql_integer", null: !hasCategory },
|
||||||
typeID: { value: typeID, cfsqltype: "cf_sql_integer", null: isNull(typeID) },
|
|
||||||
icon: { value: templateIcon, cfsqltype: "cf_sql_varchar" },
|
icon: { value: templateIcon, cfsqltype: "cf_sql_varchar" },
|
||||||
color: { value: templateColor, cfsqltype: "cf_sql_varchar" },
|
color: { value: templateColor, cfsqltype: "cf_sql_varchar" },
|
||||||
id: { value: templateID, cfsqltype: "cf_sql_integer" }
|
id: { value: templateID, cfsqltype: "cf_sql_integer" }
|
||||||
|
|
@ -115,18 +120,17 @@ try {
|
||||||
queryExecute("
|
queryExecute("
|
||||||
INSERT INTO QuickTaskTemplates (
|
INSERT INTO QuickTaskTemplates (
|
||||||
QuickTaskTemplateBusinessID, QuickTaskTemplateName, QuickTaskTemplateTitle,
|
QuickTaskTemplateBusinessID, QuickTaskTemplateName, QuickTaskTemplateTitle,
|
||||||
QuickTaskTemplateDetails, QuickTaskTemplateCategoryID, QuickTaskTemplateTypeID,
|
QuickTaskTemplateDetails, QuickTaskTemplateCategoryID,
|
||||||
QuickTaskTemplateIcon, QuickTaskTemplateColor, QuickTaskTemplateSortOrder
|
QuickTaskTemplateIcon, QuickTaskTemplateColor, QuickTaskTemplateSortOrder
|
||||||
) VALUES (
|
) 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" },
|
businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
|
||||||
name: { value: templateName, cfsqltype: "cf_sql_varchar" },
|
name: { value: templateName, cfsqltype: "cf_sql_varchar" },
|
||||||
title: { value: templateTitle, cfsqltype: "cf_sql_varchar" },
|
title: { value: templateTitle, cfsqltype: "cf_sql_varchar" },
|
||||||
details: { value: templateDetails, cfsqltype: "cf_sql_longvarchar", null: !len(templateDetails) },
|
details: { value: templateDetails, cfsqltype: "cf_sql_longvarchar", null: !len(templateDetails) },
|
||||||
categoryID: { value: categoryID, cfsqltype: "cf_sql_integer", null: isNull(categoryID) },
|
categoryID: { value: catID, cfsqltype: "cf_sql_integer", null: !hasCategory },
|
||||||
typeID: { value: typeID, cfsqltype: "cf_sql_integer", null: isNull(typeID) },
|
|
||||||
icon: { value: templateIcon, cfsqltype: "cf_sql_varchar" },
|
icon: { value: templateIcon, cfsqltype: "cf_sql_varchar" },
|
||||||
color: { value: templateColor, cfsqltype: "cf_sql_varchar" },
|
color: { value: templateColor, cfsqltype: "cf_sql_varchar" },
|
||||||
sortOrder: { value: nextSort, cfsqltype: "cf_sql_integer" }
|
sortOrder: { value: nextSort, cfsqltype: "cf_sql_integer" }
|
||||||
|
|
@ -145,7 +149,10 @@ try {
|
||||||
apiAbort({
|
apiAbort({
|
||||||
"OK": false,
|
"OK": false,
|
||||||
"ERROR": "server_error",
|
"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"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
</cfscript>
|
</cfscript>
|
||||||
|
|
|
||||||
|
|
@ -48,10 +48,11 @@ try {
|
||||||
st.ScheduledTaskID,
|
st.ScheduledTaskID,
|
||||||
st.ScheduledTaskName as Name,
|
st.ScheduledTaskName as Name,
|
||||||
st.ScheduledTaskCategoryID as CategoryID,
|
st.ScheduledTaskCategoryID as CategoryID,
|
||||||
st.ScheduledTaskTypeID as TypeID,
|
|
||||||
st.ScheduledTaskTitle as Title,
|
st.ScheduledTaskTitle as Title,
|
||||||
st.ScheduledTaskDetails as Details,
|
st.ScheduledTaskDetails as Details,
|
||||||
st.ScheduledTaskCronExpression as CronExpression,
|
st.ScheduledTaskCronExpression as CronExpression,
|
||||||
|
COALESCE(st.ScheduledTaskScheduleType, 'cron') as ScheduleType,
|
||||||
|
st.ScheduledTaskIntervalMinutes as IntervalMinutes,
|
||||||
st.ScheduledTaskIsActive as IsActive,
|
st.ScheduledTaskIsActive as IsActive,
|
||||||
st.ScheduledTaskLastRunOn as LastRunOn,
|
st.ScheduledTaskLastRunOn as LastRunOn,
|
||||||
st.ScheduledTaskNextRunOn as NextRunOn,
|
st.ScheduledTaskNextRunOn as NextRunOn,
|
||||||
|
|
@ -72,10 +73,11 @@ try {
|
||||||
"ScheduledTaskID": row.ScheduledTaskID,
|
"ScheduledTaskID": row.ScheduledTaskID,
|
||||||
"Name": row.Name,
|
"Name": row.Name,
|
||||||
"CategoryID": isNull(row.CategoryID) ? "" : row.CategoryID,
|
"CategoryID": isNull(row.CategoryID) ? "" : row.CategoryID,
|
||||||
"TypeID": isNull(row.TypeID) ? "" : row.TypeID,
|
|
||||||
"Title": row.Title,
|
"Title": row.Title,
|
||||||
"Details": isNull(row.Details) ? "" : row.Details,
|
"Details": isNull(row.Details) ? "" : row.Details,
|
||||||
"CronExpression": row.CronExpression,
|
"CronExpression": row.CronExpression,
|
||||||
|
"ScheduleType": row.ScheduleType,
|
||||||
|
"IntervalMinutes": isNull(row.IntervalMinutes) ? "" : row.IntervalMinutes,
|
||||||
"IsActive": row.IsActive ? true : false,
|
"IsActive": row.IsActive ? true : false,
|
||||||
"LastRunOn": isNull(row.LastRunOn) ? "" : dateTimeFormat(row.LastRunOn, "yyyy-mm-dd HH:nn:ss"),
|
"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"),
|
"NextRunOn": isNull(row.NextRunOn) ? "" : dateTimeFormat(row.NextRunOn, "yyyy-mm-dd HH:nn:ss"),
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,7 @@ try {
|
||||||
SELECT
|
SELECT
|
||||||
ScheduledTaskTitle as Title,
|
ScheduledTaskTitle as Title,
|
||||||
ScheduledTaskDetails as Details,
|
ScheduledTaskDetails as Details,
|
||||||
ScheduledTaskCategoryID as CategoryID,
|
ScheduledTaskCategoryID as CategoryID
|
||||||
ScheduledTaskTypeID as TypeID
|
|
||||||
FROM ScheduledTaskDefinitions
|
FROM ScheduledTaskDefinitions
|
||||||
WHERE ScheduledTaskID = :id AND ScheduledTaskBusinessID = :businessID
|
WHERE ScheduledTaskID = :id AND ScheduledTaskBusinessID = :businessID
|
||||||
", {
|
", {
|
||||||
|
|
@ -66,24 +65,21 @@ try {
|
||||||
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Scheduled task not found" });
|
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Scheduled task not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the task
|
// Create the task (TaskClaimedByUserID=0 means unclaimed/pending)
|
||||||
queryExecute("
|
queryExecute("
|
||||||
INSERT INTO Tasks (
|
INSERT INTO Tasks (
|
||||||
TaskBusinessID, TaskCategoryID, TaskTypeID,
|
TaskBusinessID, TaskCategoryID, TaskTypeID,
|
||||||
TaskTitle, TaskDetails, TaskStatusID, TaskAddedOn,
|
TaskTitle, TaskDetails, TaskAddedOn, TaskClaimedByUserID
|
||||||
TaskSourceType, TaskSourceID
|
|
||||||
) VALUES (
|
) VALUES (
|
||||||
:businessID, :categoryID, :typeID,
|
:businessID, :categoryID, :typeID,
|
||||||
:title, :details, 0, NOW(),
|
:title, :details, NOW(), 0
|
||||||
'scheduled_manual', :scheduledTaskID
|
|
||||||
)
|
)
|
||||||
", {
|
", {
|
||||||
businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
|
businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
|
||||||
categoryID: { value: qDef.CategoryID, cfsqltype: "cf_sql_integer", null: isNull(qDef.CategoryID) },
|
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" },
|
title: { value: qDef.Title, cfsqltype: "cf_sql_varchar" },
|
||||||
details: { value: qDef.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(qDef.Details) },
|
details: { value: qDef.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(qDef.Details) }
|
||||||
scheduledTaskID: { value: scheduledTaskID, cfsqltype: "cf_sql_integer" }
|
|
||||||
}, { datasource: "payfrit" });
|
}, { datasource: "payfrit" });
|
||||||
|
|
||||||
qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
|
qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
|
||||||
|
|
|
||||||
|
|
@ -70,10 +70,11 @@ try {
|
||||||
ScheduledTaskID,
|
ScheduledTaskID,
|
||||||
ScheduledTaskBusinessID as BusinessID,
|
ScheduledTaskBusinessID as BusinessID,
|
||||||
ScheduledTaskCategoryID as CategoryID,
|
ScheduledTaskCategoryID as CategoryID,
|
||||||
ScheduledTaskTypeID as TypeID,
|
|
||||||
ScheduledTaskTitle as Title,
|
ScheduledTaskTitle as Title,
|
||||||
ScheduledTaskDetails as Details,
|
ScheduledTaskDetails as Details,
|
||||||
ScheduledTaskCronExpression as CronExpression
|
ScheduledTaskCronExpression as CronExpression,
|
||||||
|
COALESCE(ScheduledTaskScheduleType, 'cron') as ScheduleType,
|
||||||
|
ScheduledTaskIntervalMinutes as IntervalMinutes
|
||||||
FROM ScheduledTaskDefinitions
|
FROM ScheduledTaskDefinitions
|
||||||
WHERE ScheduledTaskIsActive = 1
|
WHERE ScheduledTaskIsActive = 1
|
||||||
AND ScheduledTaskNextRunOn <= NOW()
|
AND ScheduledTaskNextRunOn <= NOW()
|
||||||
|
|
@ -82,30 +83,37 @@ try {
|
||||||
createdTasks = [];
|
createdTasks = [];
|
||||||
|
|
||||||
for (task in dueTasks) {
|
for (task in dueTasks) {
|
||||||
// Create the actual task
|
// Create the actual task (TaskClaimedByUserID=0 means unclaimed/pending)
|
||||||
queryExecute("
|
queryExecute("
|
||||||
INSERT INTO Tasks (
|
INSERT INTO Tasks (
|
||||||
TaskBusinessID, TaskCategoryID, TaskTypeID,
|
TaskBusinessID, TaskCategoryID, TaskTypeID,
|
||||||
TaskTitle, TaskDetails, TaskStatusID, TaskAddedOn,
|
TaskTitle, TaskDetails, TaskAddedOn, TaskClaimedByUserID
|
||||||
TaskSourceType, TaskSourceID
|
|
||||||
) VALUES (
|
) VALUES (
|
||||||
:businessID, :categoryID, :typeID,
|
:businessID, :categoryID, :typeID,
|
||||||
:title, :details, 0, NOW(),
|
:title, :details, NOW(), 0
|
||||||
'scheduled', :scheduledTaskID
|
|
||||||
)
|
)
|
||||||
", {
|
", {
|
||||||
businessID: { value: task.BusinessID, cfsqltype: "cf_sql_integer" },
|
businessID: { value: task.BusinessID, cfsqltype: "cf_sql_integer" },
|
||||||
categoryID: { value: task.CategoryID, cfsqltype: "cf_sql_integer", null: isNull(task.CategoryID) },
|
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" },
|
title: { value: task.Title, cfsqltype: "cf_sql_varchar" },
|
||||||
details: { value: task.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(task.Details) },
|
details: { value: task.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(task.Details) }
|
||||||
scheduledTaskID: { value: task.ScheduledTaskID, cfsqltype: "cf_sql_integer" }
|
|
||||||
}, { datasource: "payfrit" });
|
}, { datasource: "payfrit" });
|
||||||
|
|
||||||
qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
|
qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
|
||||||
|
|
||||||
// Calculate next run and update the scheduled task
|
// Calculate next run based on schedule type
|
||||||
nextRun = calculateNextRun(task.CronExpression);
|
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("
|
queryExecute("
|
||||||
UPDATE ScheduledTaskDefinitions SET
|
UPDATE ScheduledTaskDefinitions SET
|
||||||
|
|
@ -113,7 +121,7 @@ try {
|
||||||
ScheduledTaskNextRunOn = :nextRun
|
ScheduledTaskNextRunOn = :nextRun
|
||||||
WHERE ScheduledTaskID = :id
|
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" }
|
id: { value: task.ScheduledTaskID, cfsqltype: "cf_sql_integer" }
|
||||||
}, { datasource: "payfrit" });
|
}, { datasource: "payfrit" });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@
|
||||||
<cfscript>
|
<cfscript>
|
||||||
// Create or update a scheduled task definition
|
// Create or update a scheduled task definition
|
||||||
// Input: BusinessID (required), ScheduledTaskID (optional - for update),
|
// 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 }
|
// Output: { OK: true, SCHEDULED_TASK_ID: int }
|
||||||
|
|
||||||
function apiAbort(required struct payload) {
|
function apiAbort(required struct payload) {
|
||||||
|
|
@ -108,10 +109,13 @@ try {
|
||||||
taskTitle = structKeyExists(data, "Title") ? trim(toString(data.Title)) : "";
|
taskTitle = structKeyExists(data, "Title") ? trim(toString(data.Title)) : "";
|
||||||
taskDetails = structKeyExists(data, "Details") ? trim(toString(data.Details)) : "";
|
taskDetails = structKeyExists(data, "Details") ? trim(toString(data.Details)) : "";
|
||||||
categoryID = structKeyExists(data, "CategoryID") && isNumeric(data.CategoryID) && data.CategoryID > 0 ? int(data.CategoryID) : javaCast("null", "");
|
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)) : "";
|
cronExpression = structKeyExists(data, "CronExpression") ? trim(toString(data.CronExpression)) : "";
|
||||||
isActive = structKeyExists(data, "IsActive") ? (data.IsActive ? 1 : 0) : 1;
|
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
|
// Validate required fields
|
||||||
if (!len(taskName)) {
|
if (!len(taskName)) {
|
||||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Name is required" });
|
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Name is required" });
|
||||||
|
|
@ -119,19 +123,37 @@ try {
|
||||||
if (!len(taskTitle)) {
|
if (!len(taskTitle)) {
|
||||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Title is required" });
|
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)
|
// Validate based on schedule type
|
||||||
cronParts = listToArray(cronExpression, " ");
|
if (scheduleType == "interval" || scheduleType == "interval_after_completion") {
|
||||||
if (arrayLen(cronParts) != 5) {
|
// Interval-based scheduling
|
||||||
apiAbort({ "OK": false, "ERROR": "invalid_cron", "MESSAGE": "Cron expression must have 5 parts: minute hour day month weekday" });
|
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) {
|
if (taskID > 0) {
|
||||||
// UPDATE existing
|
// UPDATE existing
|
||||||
qCheck = queryExecute("
|
qCheck = queryExecute("
|
||||||
|
|
@ -152,8 +174,9 @@ try {
|
||||||
ScheduledTaskTitle = :title,
|
ScheduledTaskTitle = :title,
|
||||||
ScheduledTaskDetails = :details,
|
ScheduledTaskDetails = :details,
|
||||||
ScheduledTaskCategoryID = :categoryID,
|
ScheduledTaskCategoryID = :categoryID,
|
||||||
ScheduledTaskTypeID = :typeID,
|
|
||||||
ScheduledTaskCronExpression = :cron,
|
ScheduledTaskCronExpression = :cron,
|
||||||
|
ScheduledTaskScheduleType = :scheduleType,
|
||||||
|
ScheduledTaskIntervalMinutes = :intervalMinutes,
|
||||||
ScheduledTaskIsActive = :isActive,
|
ScheduledTaskIsActive = :isActive,
|
||||||
ScheduledTaskNextRunOn = :nextRun
|
ScheduledTaskNextRunOn = :nextRun
|
||||||
WHERE ScheduledTaskID = :id
|
WHERE ScheduledTaskID = :id
|
||||||
|
|
@ -162,8 +185,9 @@ try {
|
||||||
title: { value: taskTitle, cfsqltype: "cf_sql_varchar" },
|
title: { value: taskTitle, cfsqltype: "cf_sql_varchar" },
|
||||||
details: { value: taskDetails, cfsqltype: "cf_sql_longvarchar", null: !len(taskDetails) },
|
details: { value: taskDetails, cfsqltype: "cf_sql_longvarchar", null: !len(taskDetails) },
|
||||||
categoryID: { value: categoryID, cfsqltype: "cf_sql_integer", null: isNull(categoryID) },
|
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" },
|
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" },
|
isActive: { value: isActive, cfsqltype: "cf_sql_bit" },
|
||||||
nextRun: { value: nextRunOn, cfsqltype: "cf_sql_timestamp" },
|
nextRun: { value: nextRunOn, cfsqltype: "cf_sql_timestamp" },
|
||||||
id: { value: taskID, cfsqltype: "cf_sql_integer" }
|
id: { value: taskID, cfsqltype: "cf_sql_integer" }
|
||||||
|
|
@ -181,10 +205,11 @@ try {
|
||||||
queryExecute("
|
queryExecute("
|
||||||
INSERT INTO ScheduledTaskDefinitions (
|
INSERT INTO ScheduledTaskDefinitions (
|
||||||
ScheduledTaskBusinessID, ScheduledTaskName, ScheduledTaskTitle,
|
ScheduledTaskBusinessID, ScheduledTaskName, ScheduledTaskTitle,
|
||||||
ScheduledTaskDetails, ScheduledTaskCategoryID, ScheduledTaskTypeID,
|
ScheduledTaskDetails, ScheduledTaskCategoryID,
|
||||||
ScheduledTaskCronExpression, ScheduledTaskIsActive, ScheduledTaskNextRunOn
|
ScheduledTaskCronExpression, ScheduledTaskScheduleType, ScheduledTaskIntervalMinutes,
|
||||||
|
ScheduledTaskIsActive, ScheduledTaskNextRunOn
|
||||||
) VALUES (
|
) 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" },
|
businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
|
||||||
|
|
@ -192,19 +217,65 @@ try {
|
||||||
title: { value: taskTitle, cfsqltype: "cf_sql_varchar" },
|
title: { value: taskTitle, cfsqltype: "cf_sql_varchar" },
|
||||||
details: { value: taskDetails, cfsqltype: "cf_sql_longvarchar", null: !len(taskDetails) },
|
details: { value: taskDetails, cfsqltype: "cf_sql_longvarchar", null: !len(taskDetails) },
|
||||||
categoryID: { value: categoryID, cfsqltype: "cf_sql_integer", null: isNull(categoryID) },
|
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" },
|
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" },
|
isActive: { value: isActive, cfsqltype: "cf_sql_bit" },
|
||||||
nextRun: { value: nextRunOn, cfsqltype: "cf_sql_timestamp" }
|
nextRun: { value: nextRunOn, cfsqltype: "cf_sql_timestamp" }
|
||||||
}, { datasource: "payfrit" });
|
}, { datasource: "payfrit" });
|
||||||
|
|
||||||
qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { 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({
|
apiAbort({
|
||||||
"OK": true,
|
"OK": true,
|
||||||
"SCHEDULED_TASK_ID": qNew.newID,
|
"SCHEDULED_TASK_ID": newScheduledTaskID,
|
||||||
"NEXT_RUN": dateTimeFormat(nextRunOn, "yyyy-mm-dd HH:nn:ss"),
|
"TASK_ID": qTask.taskID,
|
||||||
"MESSAGE": "Scheduled task created"
|
"NEXT_RUN": isNull(actualNextRun) ? "" : dateTimeFormat(actualNextRun, "yyyy-mm-dd HH:nn:ss"),
|
||||||
|
"MESSAGE": "Scheduled task created and first task added"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ try {
|
||||||
ScheduledTaskTitle VARCHAR(255) NOT NULL,
|
ScheduledTaskTitle VARCHAR(255) NOT NULL,
|
||||||
ScheduledTaskDetails TEXT NULL,
|
ScheduledTaskDetails TEXT NULL,
|
||||||
ScheduledTaskCronExpression VARCHAR(100) NOT NULL,
|
ScheduledTaskCronExpression VARCHAR(100) NOT NULL,
|
||||||
|
ScheduledTaskScheduleType VARCHAR(20) DEFAULT 'cron',
|
||||||
|
ScheduledTaskIntervalMinutes INT NULL,
|
||||||
ScheduledTaskIsActive BIT(1) DEFAULT b'1',
|
ScheduledTaskIsActive BIT(1) DEFAULT b'1',
|
||||||
ScheduledTaskLastRunOn DATETIME NULL,
|
ScheduledTaskLastRunOn DATETIME NULL,
|
||||||
ScheduledTaskNextRunOn DATETIME NULL,
|
ScheduledTaskNextRunOn DATETIME NULL,
|
||||||
|
|
@ -33,9 +35,28 @@ try {
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
", [], { datasource: "payfrit" });
|
", [], { 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({
|
apiAbort({
|
||||||
"OK": true,
|
"OK": true,
|
||||||
"MESSAGE": "ScheduledTaskDefinitions table created/verified"
|
"MESSAGE": "ScheduledTaskDefinitions table created/verified with interval support"
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (any e) {
|
} catch (any e) {
|
||||||
|
|
|
||||||
|
|
@ -97,9 +97,11 @@ try {
|
||||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "ScheduledTaskID is required" });
|
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("
|
qCheck = queryExecute("
|
||||||
SELECT ScheduledTaskID, ScheduledTaskCronExpression as CronExpression
|
SELECT ScheduledTaskID, ScheduledTaskCronExpression as CronExpression,
|
||||||
|
COALESCE(ScheduledTaskScheduleType, 'cron') as ScheduleType,
|
||||||
|
ScheduledTaskIntervalMinutes as IntervalMinutes
|
||||||
FROM ScheduledTaskDefinitions
|
FROM ScheduledTaskDefinitions
|
||||||
WHERE ScheduledTaskID = :id AND ScheduledTaskBusinessID = :businessID
|
WHERE ScheduledTaskID = :id AND ScheduledTaskBusinessID = :businessID
|
||||||
", {
|
", {
|
||||||
|
|
@ -111,10 +113,16 @@ try {
|
||||||
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Scheduled task not found" });
|
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 = "";
|
nextRunUpdate = "";
|
||||||
if (isActive) {
|
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";
|
nextRunUpdate = ", ScheduledTaskNextRunOn = :nextRun";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,8 @@ try {
|
||||||
BusinessStripeOnboardingComplete,
|
BusinessStripeOnboardingComplete,
|
||||||
BusinessIsHiring,
|
BusinessIsHiring,
|
||||||
BusinessHeaderImageExtension,
|
BusinessHeaderImageExtension,
|
||||||
BusinessTaxRate
|
BusinessTaxRate,
|
||||||
|
BusinessBrandColor
|
||||||
FROM Businesses
|
FROM Businesses
|
||||||
WHERE BusinessID = :businessID
|
WHERE BusinessID = :businessID
|
||||||
", { businessID: businessID }, { datasource: "payfrit" });
|
", { businessID: businessID }, { datasource: "payfrit" });
|
||||||
|
|
@ -139,7 +140,8 @@ try {
|
||||||
"StripeConnected": (len(q.BusinessStripeAccountID) > 0 && q.BusinessStripeOnboardingComplete == 1),
|
"StripeConnected": (len(q.BusinessStripeAccountID) > 0 && q.BusinessStripeOnboardingComplete == 1),
|
||||||
"IsHiring": q.BusinessIsHiring == 1,
|
"IsHiring": q.BusinessIsHiring == 1,
|
||||||
"TaxRate": taxRate,
|
"TaxRate": taxRate,
|
||||||
"TaxRatePercent": taxRate * 100
|
"TaxRatePercent": taxRate * 100,
|
||||||
|
"BrandColor": len(q.BusinessBrandColor) ? q.BusinessBrandColor : ""
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add header image URL if extension exists
|
// Add header image URL if extension exists
|
||||||
|
|
|
||||||
|
|
@ -39,11 +39,16 @@ brandColor = structKeyExists(data, "BrandColor") && isSimpleValue(data.BrandColo
|
||||||
|
|
||||||
// Allow empty to clear, or validate hex format
|
// Allow empty to clear, or validate hex format
|
||||||
if (len(brandColor) GT 0) {
|
if (len(brandColor) GT 0) {
|
||||||
// Must start with # and have 6 hex chars
|
// Strip leading # if present
|
||||||
if (left(brandColor, 1) != chr(35) || len(brandColor) != 7 || !reFind("^[0-9A-Fa-f]{6}$", right(brandColor, 6))) {
|
if (left(brandColor, 1) == chr(35)) {
|
||||||
apiAbort({ "OK": false, "ERROR": "invalid_color", "MESSAGE": "Color must be in ##RRGGBB format" });
|
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
|
// Update the database
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Mode: "test" or "live"
|
// Mode: "test" or "live"
|
||||||
stripeMode = "live";
|
stripeMode = "test";
|
||||||
|
|
||||||
// Test keys (safe to commit)
|
// Test keys (safe to commit)
|
||||||
stripeTestSecretKey = "sk_test_LfbmDduJxTwbVZmvcByYmirw";
|
stripeTestSecretKey = "sk_test_LfbmDduJxTwbVZmvcByYmirw";
|
||||||
|
|
|
||||||
|
|
@ -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], {
|
arrayAppend(itemsByCategory[catID], {
|
||||||
"id": "item_" & qItems.ItemID[i],
|
"id": "item_" & qItems.ItemID[i],
|
||||||
"dbId": qItems.ItemID[i],
|
"dbId": qItems.ItemID[i],
|
||||||
"name": qItems.ItemName[i],
|
"name": qItems.ItemName[i],
|
||||||
"description": isNull(qItems.ItemDescription[i]) ? "" : qItems.ItemDescription[i],
|
"description": isNull(qItems.ItemDescription[i]) ? "" : qItems.ItemDescription[i],
|
||||||
"price": qItems.ItemPrice[i],
|
"price": qItems.ItemPrice[i],
|
||||||
"imageUrl": javaCast("null", ""),
|
"imageUrl": len(itemImageUrl) ? itemImageUrl : javaCast("null", ""),
|
||||||
"photoTaskId": javaCast("null", ""),
|
"photoTaskId": javaCast("null", ""),
|
||||||
"modifiers": itemModifiers,
|
"modifiers": itemModifiers,
|
||||||
"sortOrder": qItems.ItemSortOrder[i]
|
"sortOrder": qItems.ItemSortOrder[i]
|
||||||
|
|
|
||||||
68
api/menu/uploadItemPhoto.cfm
Normal file
68
api/menu/uploadItemPhoto.cfm
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
<cfsetting showdebugoutput="false">
|
||||||
|
<cfsetting enablecfoutputonly="true">
|
||||||
|
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||||
|
<cfheader name="Cache-Control" value="no-store">
|
||||||
|
|
||||||
|
<cftry>
|
||||||
|
<cfset itemsDir = expandPath("/uploads/items")>
|
||||||
|
<cfscript>
|
||||||
|
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" });
|
||||||
|
}
|
||||||
|
</cfscript>
|
||||||
|
|
||||||
|
<!--- Check if file was uploaded --->
|
||||||
|
<cfif NOT structKeyExists(form, "photo") OR form.photo EQ "">
|
||||||
|
<cfoutput>#serializeJSON({ "OK": false, "ERROR": "no_file", "MESSAGE": "No file was uploaded" })#</cfoutput>
|
||||||
|
<cfabort>
|
||||||
|
</cfif>
|
||||||
|
|
||||||
|
<!--- Upload the file to temp location first --->
|
||||||
|
<cffile action="UPLOAD" filefield="photo" destination="#itemsDir#/" nameconflict="MAKEUNIQUE" mode="755" result="uploadResult">
|
||||||
|
|
||||||
|
<!--- Validate file type --->
|
||||||
|
<cfset allowedExtensions = "jpg,jpeg,gif,png,webp">
|
||||||
|
<cfif NOT listFindNoCase(allowedExtensions, uploadResult.ClientFileExt)>
|
||||||
|
<cffile action="DELETE" file="#itemsDir#/#uploadResult.ServerFile#">
|
||||||
|
<cfoutput>#serializeJSON({ "OK": false, "ERROR": "invalid_type", "MESSAGE": "Only image files are accepted (jpg, jpeg, gif, png, webp)" })#</cfoutput>
|
||||||
|
<cfabort>
|
||||||
|
</cfif>
|
||||||
|
|
||||||
|
<!--- Delete old photo if exists (any extension) --->
|
||||||
|
<cfscript>
|
||||||
|
for (ext in listToArray(allowedExtensions)) {
|
||||||
|
oldFile = "#itemsDir#/#itemId#.#ext#";
|
||||||
|
if (fileExists(oldFile)) {
|
||||||
|
try { fileDelete(oldFile); } catch (any e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</cfscript>
|
||||||
|
|
||||||
|
<!--- Rename to ItemID.ext --->
|
||||||
|
<cffile action="RENAME" source="#itemsDir#/#uploadResult.ServerFile#" destination="#itemsDir#/#itemId#.#uploadResult.ClientFileExt#" mode="755">
|
||||||
|
|
||||||
|
<!--- Return success with image URL --->
|
||||||
|
<cfoutput>#serializeJSON({
|
||||||
|
"OK": true,
|
||||||
|
"ERROR": "",
|
||||||
|
"MESSAGE": "Photo uploaded successfully",
|
||||||
|
"IMAGEURL": "/uploads/items/#itemId#.#uploadResult.ClientFileExt#"
|
||||||
|
})#</cfoutput>
|
||||||
|
|
||||||
|
<cfcatch type="any">
|
||||||
|
<cfheader statuscode="200" statustext="OK">
|
||||||
|
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||||
|
<cfoutput>#serializeJSON({ "OK": false, "ERROR": "server_error", "MESSAGE": cfcatch.message, "DETAIL": cfcatch.detail })#</cfoutput>
|
||||||
|
</cfcatch>
|
||||||
|
</cftry>
|
||||||
|
|
@ -100,19 +100,18 @@ try {
|
||||||
lineItems = [];
|
lineItems = [];
|
||||||
itemsById = {};
|
itemsById = {};
|
||||||
|
|
||||||
// First pass: create all items
|
// First pass: create all items (use bracket notation to preserve key casing)
|
||||||
for (row in qItems) {
|
for (row in qItems) {
|
||||||
item = {
|
item = structNew("ordered");
|
||||||
"LineItemID": val(row.OrderLineItemID),
|
item["LineItemID"] = val(row.OrderLineItemID);
|
||||||
"ItemID": val(row.OrderLineItemItemID),
|
item["ItemID"] = val(row.OrderLineItemItemID);
|
||||||
"ParentLineItemID": val(row.OrderLineItemParentOrderLineItemID),
|
item["ParentLineItemID"] = val(row.OrderLineItemParentOrderLineItemID);
|
||||||
"ItemName": row.ItemName ?: "",
|
item["ItemName"] = row.ItemName ?: "";
|
||||||
"Quantity": val(row.OrderLineItemQuantity),
|
item["Quantity"] = val(row.OrderLineItemQuantity);
|
||||||
"UnitPrice": val(row.OrderLineItemPrice),
|
item["UnitPrice"] = val(row.OrderLineItemPrice);
|
||||||
"Remarks": row.OrderLineItemRemark ?: "",
|
item["Remarks"] = row.OrderLineItemRemark ?: "";
|
||||||
"IsDefault": (val(row.ItemIsCheckedByDefault) == 1),
|
item["IsDefault"] = (val(row.ItemIsCheckedByDefault) == 1);
|
||||||
"Modifiers": []
|
item["Modifiers"] = [];
|
||||||
};
|
|
||||||
itemsById[row.OrderLineItemID] = item;
|
itemsById[row.OrderLineItemID] = item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,21 +121,21 @@ try {
|
||||||
parentID = row.OrderLineItemParentOrderLineItemID;
|
parentID = row.OrderLineItemParentOrderLineItemID;
|
||||||
|
|
||||||
if (parentID > 0 && structKeyExists(itemsById, parentID)) {
|
if (parentID > 0 && structKeyExists(itemsById, parentID)) {
|
||||||
// This is a modifier - add to parent
|
// This is a modifier - add to parent (use bracket notation)
|
||||||
arrayAppend(itemsById[parentID].Modifiers, item);
|
arrayAppend(itemsById[parentID]["Modifiers"], item);
|
||||||
} else {
|
} else {
|
||||||
// This is a top-level item
|
// This is a top-level item
|
||||||
arrayAppend(lineItems, item);
|
arrayAppend(lineItems, item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate subtotal from root line items
|
// Calculate subtotal from root line items (use bracket notation)
|
||||||
subtotal = 0;
|
subtotal = 0;
|
||||||
for (item in lineItems) {
|
for (item in lineItems) {
|
||||||
itemTotal = item.UnitPrice * item.Quantity;
|
itemTotal = item["UnitPrice"] * item["Quantity"];
|
||||||
// Add modifier prices
|
// Add modifier prices
|
||||||
for (mod in item.Modifiers) {
|
for (mod in item["Modifiers"]) {
|
||||||
itemTotal += mod.UnitPrice * mod.Quantity;
|
itemTotal += mod["UnitPrice"] * mod["Quantity"];
|
||||||
}
|
}
|
||||||
subtotal += itemTotal;
|
subtotal += itemTotal;
|
||||||
}
|
}
|
||||||
|
|
@ -151,57 +150,68 @@ try {
|
||||||
// Calculate total
|
// Calculate total
|
||||||
total = subtotal + tax + tip;
|
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("
|
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
|
FROM Tasks t
|
||||||
INNER JOIN Users u ON u.UserID = t.TaskClaimedByUserID
|
INNER JOIN Users u ON u.UserID = t.TaskClaimedByUserID
|
||||||
WHERE t.TaskOrderID = :orderID
|
WHERE t.TaskOrderID = :orderID
|
||||||
AND t.TaskClaimedByUserID > 0
|
AND t.TaskClaimedByUserID > 0
|
||||||
", { orderID: orderID });
|
", { orderID: orderID });
|
||||||
|
|
||||||
// Build staff array with avatar URLs
|
// Build staff array with avatar URLs and rating tokens (use ordered structs)
|
||||||
staff = [];
|
staff = [];
|
||||||
for (row in qStaff) {
|
for (row in qStaff) {
|
||||||
arrayAppend(staff, {
|
staffMember = structNew("ordered");
|
||||||
"UserID": row.UserID,
|
staffMember["UserID"] = row.UserID;
|
||||||
"FirstName": row.UserFirstName,
|
staffMember["FirstName"] = row.UserFirstName;
|
||||||
"AvatarUrl": "https://biz.payfrit.com/uploads/users/" & row.UserID & ".jpg"
|
staffMember["AvatarUrl"] = "https://biz.payfrit.com/uploads/users/" & row.UserID & ".jpg";
|
||||||
});
|
staffMember["RatingToken"] = row.RatingToken ?: "";
|
||||||
|
arrayAppend(staff, staffMember);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build response
|
// Build response (use ordered structs to preserve key casing)
|
||||||
order = {
|
customer = structNew("ordered");
|
||||||
"OrderID": qOrder.OrderID,
|
customer["UserID"] = qOrder.OrderUserID;
|
||||||
"BusinessID": qOrder.OrderBusinessID,
|
customer["FirstName"] = qOrder.UserFirstName;
|
||||||
"BusinessName": qOrder.BusinessName ?: "",
|
customer["LastName"] = qOrder.UserLastName;
|
||||||
"Status": qOrder.OrderStatusID,
|
customer["Phone"] = qOrder.UserContactNumber;
|
||||||
"StatusText": getStatusText(qOrder.OrderStatusID),
|
customer["Email"] = qOrder.UserEmailAddress;
|
||||||
"OrderTypeID": qOrder.OrderTypeID ?: 0,
|
|
||||||
"OrderTypeName": getOrderTypeName(qOrder.OrderTypeID ?: 0),
|
servicePoint = structNew("ordered");
|
||||||
"Subtotal": subtotal,
|
servicePoint["ServicePointID"] = qOrder.OrderServicePointID;
|
||||||
"Tax": tax,
|
servicePoint["Name"] = qOrder.ServicePointName;
|
||||||
"Tip": tip,
|
servicePoint["TypeID"] = qOrder.ServicePointTypeID;
|
||||||
"Total": total,
|
|
||||||
"Notes": qOrder.OrderRemarks,
|
order = structNew("ordered");
|
||||||
"CreatedOn": dateTimeFormat(qOrder.OrderAddedOn, "yyyy-mm-dd HH:nn:ss"),
|
order["OrderID"] = qOrder.OrderID;
|
||||||
"SubmittedOn": len(qOrder.OrderSubmittedOn) ? dateTimeFormat(qOrder.OrderSubmittedOn, "yyyy-mm-dd HH:nn:ss") : "",
|
order["BusinessID"] = qOrder.OrderBusinessID;
|
||||||
"UpdatedOn": len(qOrder.OrderLastEditedOn) ? dateTimeFormat(qOrder.OrderLastEditedOn, "yyyy-mm-dd HH:nn:ss") : "",
|
order["BusinessName"] = qOrder.BusinessName ?: "";
|
||||||
"Customer": {
|
order["Status"] = qOrder.OrderStatusID;
|
||||||
"UserID": qOrder.OrderUserID,
|
order["StatusText"] = getStatusText(qOrder.OrderStatusID);
|
||||||
"FirstName": qOrder.UserFirstName,
|
order["OrderTypeID"] = qOrder.OrderTypeID ?: 0;
|
||||||
"LastName": qOrder.UserLastName,
|
order["OrderTypeName"] = getOrderTypeName(qOrder.OrderTypeID ?: 0);
|
||||||
"Phone": qOrder.UserContactNumber,
|
order["Subtotal"] = subtotal;
|
||||||
"Email": qOrder.UserEmailAddress
|
order["Tax"] = tax;
|
||||||
},
|
order["Tip"] = tip;
|
||||||
"ServicePoint": {
|
order["Total"] = total;
|
||||||
"ServicePointID": qOrder.OrderServicePointID,
|
order["Notes"] = qOrder.OrderRemarks;
|
||||||
"Name": qOrder.ServicePointName,
|
order["CreatedOn"] = dateTimeFormat(qOrder.OrderAddedOn, "yyyy-mm-dd HH:nn:ss");
|
||||||
"TypeID": qOrder.ServicePointTypeID
|
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") : "";
|
||||||
"LineItems": lineItems,
|
order["Customer"] = customer;
|
||||||
"Staff": staff
|
order["ServicePoint"] = servicePoint;
|
||||||
};
|
order["LineItems"] = lineItems;
|
||||||
|
order["Staff"] = staff;
|
||||||
|
|
||||||
response["OK"] = true;
|
response["OK"] = true;
|
||||||
response["ORDER"] = order;
|
response["ORDER"] = order;
|
||||||
|
|
|
||||||
122
api/ratings/createAdminRating.cfm
Normal file
122
api/ratings/createAdminRating.cfm
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
<cfsetting showdebugoutput="false">
|
||||||
|
<cfcontent type="application/json; charset=utf-8">
|
||||||
|
<cfscript>
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
</cfscript>
|
||||||
92
api/ratings/listForAdmin.cfm
Normal file
92
api/ratings/listForAdmin.cfm
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
<cfsetting showdebugoutput="false">
|
||||||
|
<cfcontent type="application/json; charset=utf-8">
|
||||||
|
<cfscript>
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
</cfscript>
|
||||||
59
api/ratings/setup.cfm
Normal file
59
api/ratings/setup.cfm
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<cfsetting showdebugoutput="false">
|
||||||
|
<cfcontent type="application/json; charset=utf-8">
|
||||||
|
<cfscript>
|
||||||
|
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
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
</cfscript>
|
||||||
144
api/ratings/submit.cfm
Normal file
144
api/ratings/submit.cfm
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
<cfsetting showdebugoutput="false">
|
||||||
|
<cfcontent type="application/json; charset=utf-8">
|
||||||
|
<cfscript>
|
||||||
|
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
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
</cfscript>
|
||||||
|
|
@ -94,7 +94,7 @@
|
||||||
</cfif>
|
</cfif>
|
||||||
|
|
||||||
<!--- System prompt for per-image analysis --->
|
<!--- System prompt for per-image analysis --->
|
||||||
<cfset systemPrompt = "You are an expert at extracting structured menu data from restaurant menu images. Extract ALL data visible on THIS image only. Return valid JSON with these keys: business (object with name, address, phone, hours - only if visible), categories (array of category names visible), modifiers (array of modifier templates with name, required boolean, appliesTo (string: 'category', 'item', or 'uncertain'), categoryName (if appliesTo='category'), and options array where each option is an object with 'name' and 'price' keys), items (array with name, description, price, category, and modifiers array). For modifier options, ALWAYS use format: {""name"": ""option name"", ""price"": 0}. IMPORTANT: Look for modifiers in these locations: (1) text under category headers (e.g., 'All burgers include...', 'Choose your size:'), (2) item descriptions (e.g., 'served with choice of side'), (3) asterisk notes, (4) header/footer text. For modifiers found under category headers, set appliesTo='category' and categoryName to the category. For modifiers clearly in item descriptions, assign them to that item's modifiers array. For modifiers where you're uncertain how they apply, set appliesTo='uncertain' and do NOT assign to items. Return ONLY valid JSON, no markdown, no explanation.">
|
<cfset systemPrompt = "You are an expert at extracting structured menu data from restaurant menu images. Extract ALL data visible on THIS image only. Return valid JSON with these keys: business (object with name, address, phone, hours as a single string like ""Mon-Thu 11am-9pm, Fri 11am-10pm, Sat 4pm-10pm, Sun 4pm-9pm"" - only if visible), categories (array of category names visible), modifiers (array of modifier templates with name, required boolean, appliesTo (string: 'category', 'item', or 'uncertain'), categoryName (if appliesTo='category'), and options array where each option is an object with 'name' and 'price' keys), items (array with name, description, price, category, and modifiers array). For modifier options, ALWAYS use format: {""name"": ""option name"", ""price"": 0}. IMPORTANT: Look for modifiers in these locations: (1) text under category headers (e.g., 'All burgers include...', 'Choose your size:'), (2) item descriptions (e.g., 'served with choice of side'), (3) asterisk notes, (4) header/footer text. For modifiers found under category headers, set appliesTo='category' and categoryName to the category. For modifiers clearly in item descriptions, assign them to that item's modifiers array. For modifiers where you're uncertain how they apply, set appliesTo='uncertain' and do NOT assign to items. Return ONLY valid JSON, no markdown, no explanation.">
|
||||||
|
|
||||||
<!--- Process each image individually --->
|
<!--- Process each image individually --->
|
||||||
<cfset allResults = arrayNew(1)>
|
<cfset allResults = arrayNew(1)>
|
||||||
|
|
@ -190,14 +190,52 @@
|
||||||
|
|
||||||
<!--- MERGE PHASE: Combine all results --->
|
<!--- MERGE PHASE: Combine all results --->
|
||||||
|
|
||||||
<!--- 1. Extract business info (from first result that has it) --->
|
<!--- 1. Extract business info (last image wins so user controls via upload order) --->
|
||||||
<cfset mergedBusiness = structNew()>
|
<cfset mergedBusiness = structNew()>
|
||||||
|
<cfset bizFields = "name,address,addressLine1,city,state,zip,phone,hours">
|
||||||
<cfloop array="#allResults#" index="result">
|
<cfloop array="#allResults#" index="result">
|
||||||
<cfif structKeyExists(result, "business") AND isStruct(result.business)>
|
<cfif structKeyExists(result, "business") AND isStruct(result.business)>
|
||||||
<cfif structKeyExists(result.business, "name") AND len(result.business.name)>
|
<cfloop list="#bizFields#" index="fieldName">
|
||||||
<cfset mergedBusiness = result.business>
|
<cfif structKeyExists(result.business, fieldName)>
|
||||||
<cfbreak>
|
<cfset fieldVal = result.business[fieldName]>
|
||||||
</cfif>
|
<cfif isSimpleValue(fieldVal) AND len(trim(fieldVal))>
|
||||||
|
<cfset mergedBusiness[fieldName] = trim(fieldVal)>
|
||||||
|
<cfelseif isStruct(fieldVal)>
|
||||||
|
<!--- Convert struct to readable string (e.g. hours as key-value pairs) --->
|
||||||
|
<cfset parts = arrayNew(1)>
|
||||||
|
<cfloop collection="#fieldVal#" item="k">
|
||||||
|
<cfif isSimpleValue(fieldVal[k])>
|
||||||
|
<cfset arrayAppend(parts, k & ": " & fieldVal[k])>
|
||||||
|
</cfif>
|
||||||
|
</cfloop>
|
||||||
|
<cfif arrayLen(parts)>
|
||||||
|
<cfset mergedBusiness[fieldName] = arrayToList(parts, ", ")>
|
||||||
|
</cfif>
|
||||||
|
<cfelseif isArray(fieldVal) AND arrayLen(fieldVal)>
|
||||||
|
<!--- Convert array to readable string --->
|
||||||
|
<cfset parts = arrayNew(1)>
|
||||||
|
<cfloop array="#fieldVal#" index="entry">
|
||||||
|
<cfif isSimpleValue(entry) AND len(trim(entry))>
|
||||||
|
<cfset arrayAppend(parts, trim(entry))>
|
||||||
|
<cfelseif isStruct(entry)>
|
||||||
|
<!--- e.g. {day:"Mon", hours:"11am-9pm"} --->
|
||||||
|
<cfset entryParts = arrayNew(1)>
|
||||||
|
<cfloop collection="#entry#" item="k">
|
||||||
|
<cfif isSimpleValue(entry[k])>
|
||||||
|
<cfset arrayAppend(entryParts, entry[k])>
|
||||||
|
</cfif>
|
||||||
|
</cfloop>
|
||||||
|
<cfif arrayLen(entryParts)>
|
||||||
|
<cfset arrayAppend(parts, arrayToList(entryParts, " "))>
|
||||||
|
</cfif>
|
||||||
|
</cfif>
|
||||||
|
</cfloop>
|
||||||
|
<cfif arrayLen(parts)>
|
||||||
|
<cfset mergedBusiness[fieldName] = arrayToList(parts, ", ")>
|
||||||
|
</cfif>
|
||||||
|
</cfif>
|
||||||
|
</cfif>
|
||||||
|
</cfloop>
|
||||||
</cfif>
|
</cfif>
|
||||||
</cfloop>
|
</cfloop>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,9 @@ try {
|
||||||
// Extract phone number
|
// Extract phone number
|
||||||
bizPhone = structKeyExists(biz, "phone") && isSimpleValue(biz.phone) ? trim(biz.phone) : "";
|
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
|
// Create address record first (use extracted address fields) - safely extract as simple values
|
||||||
addressLine1 = structKeyExists(biz, "addressLine1") && isSimpleValue(biz.addressLine1) ? trim(biz.addressLine1) : "";
|
addressLine1 = structKeyExists(biz, "addressLine1") && isSimpleValue(biz.addressLine1) ? trim(biz.addressLine1) : "";
|
||||||
city = structKeyExists(biz, "city") && isSimpleValue(biz.city) ? trim(biz.city) : "";
|
city = structKeyExists(biz, "city") && isSimpleValue(biz.city) ? trim(biz.city) : "";
|
||||||
|
|
@ -93,16 +96,22 @@ try {
|
||||||
addressId = qNewAddr.id;
|
addressId = qNewAddr.id;
|
||||||
response.steps.append("Created address record (ID: " & addressId & ")");
|
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
|
// Create new business with address link and phone
|
||||||
queryExecute("
|
queryExecute("
|
||||||
INSERT INTO Businesses (BusinessName, BusinessPhone, BusinessUserID, BusinessAddressID, BusinessDeliveryZipCodes, BusinessAddedOn)
|
INSERT INTO Businesses (BusinessName, BusinessPhone, BusinessUserID, BusinessAddressID, BusinessDeliveryZipCodes, BusinessCommunityMealType, BusinessTaxRate, BusinessAddedOn)
|
||||||
VALUES (:name, :phone, :userId, :addressId, :deliveryZips, NOW())
|
VALUES (:name, :phone, :userId, :addressId, :deliveryZips, :communityMealType, :taxRate, NOW())
|
||||||
", {
|
", {
|
||||||
name: bizName,
|
name: bizName,
|
||||||
phone: bizPhone,
|
phone: bizPhone,
|
||||||
userId: userId,
|
userId: userId,
|
||||||
addressId: addressId,
|
addressId: addressId,
|
||||||
deliveryZips: len(zip) ? zip : ""
|
deliveryZips: len(zip) ? zip : "",
|
||||||
|
communityMealType: communityMealType,
|
||||||
|
taxRate: { value: bizTaxRate, cfsqltype: "cf_sql_decimal" }
|
||||||
}, { datasource: "payfrit" });
|
}, { datasource: "payfrit" });
|
||||||
|
|
||||||
qNewBiz = queryExecute("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" });
|
qNewBiz = queryExecute("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" });
|
||||||
|
|
|
||||||
|
|
@ -58,14 +58,20 @@ try {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get task type name if TaskTypeID provided
|
// Get task type info if TaskTypeID provided (name + category)
|
||||||
taskTypeName = "";
|
taskTypeName = "";
|
||||||
|
taskTypeCategoryID = 0;
|
||||||
if (taskTypeID > 0) {
|
if (taskTypeID > 0) {
|
||||||
typeQuery = queryExecute("
|
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" });
|
", { typeID: { value: taskTypeID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
|
||||||
if (typeQuery.recordCount && len(trim(typeQuery.tt_TaskTypeName))) {
|
if (typeQuery.recordCount) {
|
||||||
taskTypeName = typeQuery.tt_TaskTypeName;
|
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";
|
taskDetails &= "Customer is requesting assistance";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up or create a "Service" category for this business
|
// Determine category: use task type's category if set, otherwise fallback to "Service" category
|
||||||
catQuery = queryExecute("
|
categoryID = 0;
|
||||||
SELECT TaskCategoryID FROM TaskCategories
|
|
||||||
WHERE TaskCategoryBusinessID = :businessID AND TaskCategoryName = 'Service'
|
|
||||||
LIMIT 1
|
|
||||||
", { businessID: { value: businessID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
|
|
||||||
|
|
||||||
if (catQuery.recordCount == 0) {
|
if (taskTypeCategoryID > 0) {
|
||||||
// Create the category
|
// Use the task type's assigned category
|
||||||
queryExecute("
|
categoryID = taskTypeCategoryID;
|
||||||
INSERT INTO TaskCategories (TaskCategoryBusinessID, TaskCategoryName, TaskCategoryColor)
|
} else {
|
||||||
VALUES (:businessID, 'Service', '##FF9800')
|
// 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" });
|
", { businessID: { value: businessID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
|
||||||
|
|
||||||
catResult = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
|
if (catQuery.recordCount == 0) {
|
||||||
categoryID = catResult.newID;
|
// Create the category
|
||||||
} else {
|
queryExecute("
|
||||||
categoryID = catQuery.TaskCategoryID;
|
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
|
// Insert task
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,18 @@
|
||||||
</cftry>
|
</cftry>
|
||||||
</cffunction>
|
</cffunction>
|
||||||
|
|
||||||
|
<cffunction name="generateToken" access="public" returntype="string" output="false">
|
||||||
|
<cfreturn lcase(replace(createUUID(), "-", "", "all"))>
|
||||||
|
</cffunction>
|
||||||
|
|
||||||
<cfset data = readJsonBody()>
|
<cfset data = readJsonBody()>
|
||||||
<cfset TaskID = val( structKeyExists(data,"TaskID") ? data.TaskID : 0 )>
|
<cfset TaskID = val( structKeyExists(data,"TaskID") ? data.TaskID : 0 )>
|
||||||
<!--- Get UserID from request (auth header) or from JSON body as fallback --->
|
<!--- Get UserID from request (auth header) or from JSON body as fallback --->
|
||||||
<cfset UserID = val( structKeyExists(request,"UserID") ? request.UserID : (structKeyExists(data,"UserID") ? data.UserID : 0) )>
|
<cfset UserID = val( structKeyExists(request,"UserID") ? request.UserID : (structKeyExists(data,"UserID") ? data.UserID : 0) )>
|
||||||
|
|
||||||
|
<!--- Optional: Worker rating of customer (when required or voluntary) --->
|
||||||
|
<cfset workerRating = structKeyExists(data,"workerRating") ? data.workerRating : {}>
|
||||||
|
|
||||||
<cfif TaskID LTE 0>
|
<cfif TaskID LTE 0>
|
||||||
<cfset apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "TaskID is required." })>
|
<cfset apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "TaskID is required." })>
|
||||||
</cfif>
|
</cfif>
|
||||||
|
|
@ -38,9 +45,11 @@
|
||||||
<cftry>
|
<cftry>
|
||||||
<!--- Verify task exists --->
|
<!--- Verify task exists --->
|
||||||
<cfset qTask = queryExecute("
|
<cfset qTask = queryExecute("
|
||||||
SELECT TaskID, TaskClaimedByUserID, TaskCompletedOn, TaskOrderID, TaskTypeID
|
SELECT t.TaskID, t.TaskClaimedByUserID, t.TaskCompletedOn, t.TaskOrderID, t.TaskTypeID, t.TaskBusinessID,
|
||||||
FROM Tasks
|
o.OrderUserID, o.OrderServicePointID
|
||||||
WHERE TaskID = ?
|
FROM Tasks t
|
||||||
|
LEFT JOIN Orders o ON o.OrderID = t.TaskOrderID
|
||||||
|
WHERE t.TaskID = ?
|
||||||
", [ { value = TaskID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
|
", [ { value = TaskID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
|
||||||
|
|
||||||
<cfif qTask.recordCount EQ 0>
|
<cfif qTask.recordCount EQ 0>
|
||||||
|
|
@ -63,6 +72,35 @@
|
||||||
<cfset apiAbort({ "OK": false, "ERROR": "already_completed", "MESSAGE": "Task has already been completed." })>
|
<cfset apiAbort({ "OK": false, "ERROR": "already_completed", "MESSAGE": "Task has already been completed." })>
|
||||||
</cfif>
|
</cfif>
|
||||||
|
|
||||||
|
<!--- Check if this is a service point task (customer-facing) --->
|
||||||
|
<cfset hasServicePoint = (val(qTask.OrderServicePointID) GT 0)>
|
||||||
|
<cfset customerUserID = val(qTask.OrderUserID)>
|
||||||
|
<cfset workerUserID = val(qTask.TaskClaimedByUserID)>
|
||||||
|
<cfset ratingRequired = false>
|
||||||
|
<cfset ratingsCreated = []>
|
||||||
|
|
||||||
|
<!--- For service point tasks, check if worker rating of customer is required (10% based on TaskID) --->
|
||||||
|
<!--- Use TaskID modulo for deterministic selection - same task always has same requirement --->
|
||||||
|
<cfif hasServicePoint AND customerUserID GT 0 AND workerUserID GT 0>
|
||||||
|
<cfset ratingRequired = (TaskID MOD 10 EQ 0)>
|
||||||
|
|
||||||
|
<!--- If rating required but not provided, return error --->
|
||||||
|
<cfif ratingRequired AND structIsEmpty(workerRating)>
|
||||||
|
<cfset apiAbort({
|
||||||
|
"OK": false,
|
||||||
|
"ERROR": "rating_required",
|
||||||
|
"MESSAGE": "Please rate the customer before completing this task.",
|
||||||
|
"CustomerUserID": customerUserID,
|
||||||
|
"Questions": {
|
||||||
|
"prepared": "Was the customer prepared?",
|
||||||
|
"completedScope": "Was the scope clear?",
|
||||||
|
"respectful": "Was the customer respectful?",
|
||||||
|
"wouldAutoAssign": "Would you serve this customer again?"
|
||||||
|
}
|
||||||
|
})>
|
||||||
|
</cfif>
|
||||||
|
</cfif>
|
||||||
|
|
||||||
<!--- Mark task as completed --->
|
<!--- Mark task as completed --->
|
||||||
<cfset queryExecute("
|
<cfset queryExecute("
|
||||||
UPDATE Tasks
|
UPDATE Tasks
|
||||||
|
|
@ -82,12 +120,60 @@
|
||||||
<cfset orderUpdated = true>
|
<cfset orderUpdated = true>
|
||||||
</cfif>
|
</cfif>
|
||||||
|
|
||||||
|
<!--- Create rating records for service point tasks --->
|
||||||
|
<cfif hasServicePoint AND customerUserID GT 0 AND workerUserID GT 0>
|
||||||
|
<!--- 1. Customer rates Worker (always created, submitted via receipt link) --->
|
||||||
|
<cfset customerToken = generateToken()>
|
||||||
|
<cfset queryExecute("
|
||||||
|
INSERT INTO TaskRatings (
|
||||||
|
TaskRatingTaskID, TaskRatingByUserID, TaskRatingForUserID, TaskRatingDirection,
|
||||||
|
TaskRatingAccessToken, TaskRatingExpiresOn
|
||||||
|
) VALUES (
|
||||||
|
?, ?, ?, 'customer_rates_worker',
|
||||||
|
?, DATE_ADD(NOW(), INTERVAL 24 HOUR)
|
||||||
|
)
|
||||||
|
", [
|
||||||
|
{ value = TaskID, cfsqltype = "cf_sql_integer" },
|
||||||
|
{ value = customerUserID, cfsqltype = "cf_sql_integer" },
|
||||||
|
{ value = workerUserID, cfsqltype = "cf_sql_integer" },
|
||||||
|
{ value = customerToken, cfsqltype = "cf_sql_varchar" }
|
||||||
|
], { datasource = "payfrit" })>
|
||||||
|
<cfset arrayAppend(ratingsCreated, { "direction": "customer_rates_worker", "token": customerToken })>
|
||||||
|
|
||||||
|
<!--- 2. Worker rates Customer (if provided or required) --->
|
||||||
|
<cfif NOT structIsEmpty(workerRating)>
|
||||||
|
<cfset workerToken = generateToken()>
|
||||||
|
<cfset queryExecute("
|
||||||
|
INSERT INTO TaskRatings (
|
||||||
|
TaskRatingTaskID, TaskRatingByUserID, TaskRatingForUserID, TaskRatingDirection,
|
||||||
|
TaskRatingPrepared, TaskRatingCompletedScope, TaskRatingRespectful, TaskRatingWouldAutoAssign,
|
||||||
|
TaskRatingAccessToken, TaskRatingExpiresOn, TaskRatingCompletedOn
|
||||||
|
) VALUES (
|
||||||
|
?, ?, ?, 'worker_rates_customer',
|
||||||
|
?, ?, ?, ?,
|
||||||
|
?, DATE_ADD(NOW(), INTERVAL 24 HOUR), NOW()
|
||||||
|
)
|
||||||
|
", [
|
||||||
|
{ value = TaskID, cfsqltype = "cf_sql_integer" },
|
||||||
|
{ value = workerUserID, cfsqltype = "cf_sql_integer" },
|
||||||
|
{ value = customerUserID, cfsqltype = "cf_sql_integer" },
|
||||||
|
{ value = structKeyExists(workerRating,"prepared") ? (workerRating.prepared ? 1 : 0) : javaCast("null",""), cfsqltype = "cf_sql_tinyint", null = !structKeyExists(workerRating,"prepared") },
|
||||||
|
{ value = structKeyExists(workerRating,"completedScope") ? (workerRating.completedScope ? 1 : 0) : javaCast("null",""), cfsqltype = "cf_sql_tinyint", null = !structKeyExists(workerRating,"completedScope") },
|
||||||
|
{ value = structKeyExists(workerRating,"respectful") ? (workerRating.respectful ? 1 : 0) : javaCast("null",""), cfsqltype = "cf_sql_tinyint", null = !structKeyExists(workerRating,"respectful") },
|
||||||
|
{ value = structKeyExists(workerRating,"wouldAutoAssign") ? (workerRating.wouldAutoAssign ? 1 : 0) : javaCast("null",""), cfsqltype = "cf_sql_tinyint", null = !structKeyExists(workerRating,"wouldAutoAssign") },
|
||||||
|
{ value = workerToken, cfsqltype = "cf_sql_varchar" }
|
||||||
|
], { datasource = "payfrit" })>
|
||||||
|
<cfset arrayAppend(ratingsCreated, { "direction": "worker_rates_customer", "submitted": true })>
|
||||||
|
</cfif>
|
||||||
|
</cfif>
|
||||||
|
|
||||||
<cfset apiAbort({
|
<cfset apiAbort({
|
||||||
"OK": true,
|
"OK": true,
|
||||||
"ERROR": "",
|
"ERROR": "",
|
||||||
"MESSAGE": "Task completed successfully.",
|
"MESSAGE": "Task completed successfully.",
|
||||||
"TaskID": TaskID,
|
"TaskID": TaskID,
|
||||||
"OrderUpdated": orderUpdated
|
"OrderUpdated": orderUpdated,
|
||||||
|
"RatingsCreated": ratingsCreated
|
||||||
})>
|
})>
|
||||||
|
|
||||||
<cfcatch>
|
<cfcatch>
|
||||||
|
|
|
||||||
107
api/tasks/deleteCategory.cfm
Normal file
107
api/tasks/deleteCategory.cfm
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
<cfsetting showdebugoutput="false">
|
||||||
|
<cfsetting enablecfoutputonly="true">
|
||||||
|
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||||
|
|
||||||
|
<cfscript>
|
||||||
|
// 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</cfscript>
|
||||||
|
|
@ -50,7 +50,8 @@ try {
|
||||||
tt_TaskTypeDescription as TaskTypeDescription,
|
tt_TaskTypeDescription as TaskTypeDescription,
|
||||||
tt_TaskTypeIcon as TaskTypeIcon,
|
tt_TaskTypeIcon as TaskTypeIcon,
|
||||||
tt_TaskTypeColor as TaskTypeColor,
|
tt_TaskTypeColor as TaskTypeColor,
|
||||||
tt_TaskTypeSortOrder as SortOrder
|
tt_TaskTypeSortOrder as SortOrder,
|
||||||
|
tt_TaskTypeCategoryID as CategoryID
|
||||||
FROM tt_TaskTypes
|
FROM tt_TaskTypes
|
||||||
WHERE tt_TaskTypeBusinessID = :businessID
|
WHERE tt_TaskTypeBusinessID = :businessID
|
||||||
ORDER BY tt_TaskTypeSortOrder, tt_TaskTypeID
|
ORDER BY tt_TaskTypeSortOrder, tt_TaskTypeID
|
||||||
|
|
@ -65,7 +66,8 @@ try {
|
||||||
"TaskTypeName": row.TaskTypeName,
|
"TaskTypeName": row.TaskTypeName,
|
||||||
"TaskTypeDescription": isNull(row.TaskTypeDescription) ? "" : row.TaskTypeDescription,
|
"TaskTypeDescription": isNull(row.TaskTypeDescription) ? "" : row.TaskTypeDescription,
|
||||||
"TaskTypeIcon": isNull(row.TaskTypeIcon) ? "notifications" : row.TaskTypeIcon,
|
"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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
83
api/tasks/listCategories.cfm
Normal file
83
api/tasks/listCategories.cfm
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
<cfsetting showdebugoutput="false">
|
||||||
|
<cfsetting enablecfoutputonly="true">
|
||||||
|
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||||
|
|
||||||
|
<cfscript>
|
||||||
|
// 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</cfscript>
|
||||||
124
api/tasks/saveCategory.cfm
Normal file
124
api/tasks/saveCategory.cfm
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
<cfsetting showdebugoutput="false">
|
||||||
|
<cfsetting enablecfoutputonly="true">
|
||||||
|
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||||
|
|
||||||
|
<cfscript>
|
||||||
|
// 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</cfscript>
|
||||||
|
|
@ -78,6 +78,14 @@ try {
|
||||||
apiAbort({ "OK": false, "ERROR": "invalid_params", "MESSAGE": "TaskTypeColor must be a valid hex color" });
|
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)
|
// Get TaskTypeID (optional - for update)
|
||||||
taskTypeID = 0;
|
taskTypeID = 0;
|
||||||
if (structKeyExists(data, "TaskTypeID") && isNumeric(data.TaskTypeID)) {
|
if (structKeyExists(data, "TaskTypeID") && isNumeric(data.TaskTypeID)) {
|
||||||
|
|
@ -104,13 +112,15 @@ try {
|
||||||
SET tt_TaskTypeName = :taskTypeName,
|
SET tt_TaskTypeName = :taskTypeName,
|
||||||
tt_TaskTypeDescription = :taskTypeDescription,
|
tt_TaskTypeDescription = :taskTypeDescription,
|
||||||
tt_TaskTypeIcon = :taskTypeIcon,
|
tt_TaskTypeIcon = :taskTypeIcon,
|
||||||
tt_TaskTypeColor = :taskTypeColor
|
tt_TaskTypeColor = :taskTypeColor,
|
||||||
|
tt_TaskTypeCategoryID = :categoryID
|
||||||
WHERE tt_TaskTypeID = :taskTypeID
|
WHERE tt_TaskTypeID = :taskTypeID
|
||||||
", {
|
", {
|
||||||
taskTypeName: { value: taskTypeName, cfsqltype: "cf_sql_varchar" },
|
taskTypeName: { value: taskTypeName, cfsqltype: "cf_sql_varchar" },
|
||||||
taskTypeDescription: { value: taskTypeDescription, cfsqltype: "cf_sql_varchar", null: !len(taskTypeDescription) },
|
taskTypeDescription: { value: taskTypeDescription, cfsqltype: "cf_sql_varchar", null: !len(taskTypeDescription) },
|
||||||
taskTypeIcon: { value: taskTypeIcon, cfsqltype: "cf_sql_varchar" },
|
taskTypeIcon: { value: taskTypeIcon, cfsqltype: "cf_sql_varchar" },
|
||||||
taskTypeColor: { value: taskTypeColor, 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" }
|
taskTypeID: { value: taskTypeID, cfsqltype: "cf_sql_integer" }
|
||||||
}, { datasource: "payfrit" });
|
}, { datasource: "payfrit" });
|
||||||
|
|
||||||
|
|
@ -122,14 +132,15 @@ try {
|
||||||
} else {
|
} else {
|
||||||
// INSERT new task type
|
// INSERT new task type
|
||||||
queryExecute("
|
queryExecute("
|
||||||
INSERT INTO tt_TaskTypes (tt_TaskTypeName, tt_TaskTypeDescription, tt_TaskTypeIcon, tt_TaskTypeColor, tt_TaskTypeBusinessID)
|
INSERT INTO tt_TaskTypes (tt_TaskTypeName, tt_TaskTypeDescription, tt_TaskTypeIcon, tt_TaskTypeColor, tt_TaskTypeBusinessID, tt_TaskTypeCategoryID)
|
||||||
VALUES (:taskTypeName, :taskTypeDescription, :taskTypeIcon, :taskTypeColor, :businessID)
|
VALUES (:taskTypeName, :taskTypeDescription, :taskTypeIcon, :taskTypeColor, :businessID, :categoryID)
|
||||||
", {
|
", {
|
||||||
taskTypeName: { value: taskTypeName, cfsqltype: "cf_sql_varchar" },
|
taskTypeName: { value: taskTypeName, cfsqltype: "cf_sql_varchar" },
|
||||||
taskTypeDescription: { value: taskTypeDescription, cfsqltype: "cf_sql_varchar", null: !len(taskTypeDescription) },
|
taskTypeDescription: { value: taskTypeDescription, cfsqltype: "cf_sql_varchar", null: !len(taskTypeDescription) },
|
||||||
taskTypeIcon: { value: taskTypeIcon, cfsqltype: "cf_sql_varchar" },
|
taskTypeIcon: { value: taskTypeIcon, cfsqltype: "cf_sql_varchar" },
|
||||||
taskTypeColor: { value: taskTypeColor, 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" });
|
}, { datasource: "payfrit" });
|
||||||
|
|
||||||
qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
|
qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
|
||||||
|
|
|
||||||
137
api/tasks/seedCategories.cfm
Normal file
137
api/tasks/seedCategories.cfm
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
<cfsetting showdebugoutput="false">
|
||||||
|
<cfsetting enablecfoutputonly="true">
|
||||||
|
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||||
|
|
||||||
|
<cfscript>
|
||||||
|
// 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</cfscript>
|
||||||
|
|
@ -84,7 +84,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-bar.flashing {
|
.task-bar.flashing {
|
||||||
animation: flash 0.5s ease-in-out infinite;
|
animation: flash 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes flash {
|
@keyframes flash {
|
||||||
|
|
@ -264,6 +264,56 @@
|
||||||
--task-color-glow: rgba(236, 72, 153, 0.3);
|
--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 */
|
/* Connection status */
|
||||||
.status-indicator {
|
.status-indicator {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
@ -289,7 +339,10 @@
|
||||||
<body>
|
<body>
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>Payfrit Tasks<span id="businessName"></span></h1>
|
<h1>Payfrit Tasks<span id="businessName"></span></h1>
|
||||||
<div class="clock" id="clock">--:--:--</div>
|
<div style="display: flex; align-items: center;">
|
||||||
|
<div class="clock" id="clock">--:--:--</div>
|
||||||
|
<button class="fullscreen-btn" id="fullscreenBtn" onclick="toggleFullscreen()">⛶</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="task-container" id="taskContainer">
|
<div class="task-container" id="taskContainer">
|
||||||
|
|
@ -326,6 +379,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="status-indicator" id="statusIndicator"></div>
|
<div class="status-indicator" id="statusIndicator"></div>
|
||||||
|
<button class="restore-btn" id="restoreBtn" onclick="toggleFullscreen()">✕</button>
|
||||||
|
|
||||||
<script src="hud.js"></script>
|
<script src="hud.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -623,6 +623,21 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Customer Preview</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p style="color: #666; font-size: 13px; margin-bottom: 12px;">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.</p>
|
||||||
|
<a id="previewAppLink" href="#" onclick="Portal.openCustomerPreview(); return false;" class="btn btn-primary" style="display: inline-flex; align-items: center; gap: 8px; width: 100%; justify-content: center;">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4M10 17l5-5-5-5M13 12H3"/>
|
||||||
|
</svg>
|
||||||
|
Open in Payfrit App
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -911,6 +911,12 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-group" style="margin-left: auto;">
|
<div class="toolbar-group" style="margin-left: auto;">
|
||||||
|
<button class="toolbar-btn" onclick="MenuBuilder.showMenuManager()" title="Manage Menus" style="font-weight: 600;">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
Manage Menus
|
||||||
|
</button>
|
||||||
<button class="toolbar-btn primary" onclick="MenuBuilder.saveMenu()">
|
<button class="toolbar-btn primary" onclick="MenuBuilder.saveMenu()">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/>
|
<path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/>
|
||||||
|
|
@ -1014,11 +1020,6 @@
|
||||||
<select id="menuSelector" onchange="MenuBuilder.onMenuSelect(this.value)" style="padding: 8px 12px; border: 1px solid var(--gray-300); border-radius: 6px; background: #fff; font-size: 14px; cursor: pointer;">
|
<select id="menuSelector" onchange="MenuBuilder.onMenuSelect(this.value)" style="padding: 8px 12px; border: 1px solid var(--gray-300); border-radius: 6px; background: #fff; font-size: 14px; cursor: pointer;">
|
||||||
<option value="0">All Categories</option>
|
<option value="0">All Categories</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="btn btn-sm btn-secondary" onclick="MenuBuilder.showMenuManager()" title="Manage Menus">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -2829,27 +2830,58 @@
|
||||||
|
|
||||||
// Upload photo
|
// Upload photo
|
||||||
uploadPhoto(itemId) {
|
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');
|
const input = document.createElement('input');
|
||||||
input.type = 'file';
|
input.type = 'file';
|
||||||
input.accept = 'image/*';
|
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
|
||||||
input.onchange = async (e) => {
|
input.onchange = async (e) => {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (file) {
|
if (!file) return;
|
||||||
// For demo, just use a data URL
|
|
||||||
const reader = new FileReader();
|
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||||
reader.onload = () => {
|
if (!validTypes.includes(file.type)) {
|
||||||
for (const cat of this.menu.categories) {
|
this.toast('Please select a valid image (JPG, PNG, GIF, or WebP)', 'error');
|
||||||
const item = cat.items.find(i => i.id === itemId);
|
return;
|
||||||
if (item) {
|
}
|
||||||
item.imageUrl = reader.result;
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
break;
|
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.render();
|
||||||
this.toast('Photo uploaded', 'success');
|
this.toast('Photo uploaded!', 'success');
|
||||||
};
|
} else {
|
||||||
reader.readAsDataURL(file);
|
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();
|
input.click();
|
||||||
|
|
@ -3553,9 +3585,19 @@
|
||||||
}
|
}
|
||||||
console.log('[MenuBuilder] Menu data:', JSON.stringify(this.menu, null, 2));
|
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 = {
|
const payload = {
|
||||||
BusinessID: this.config.businessId,
|
BusinessID: this.config.businessId,
|
||||||
Menu: this.menu
|
Menu: menuClone
|
||||||
};
|
};
|
||||||
console.log('[MenuBuilder] Full payload:', JSON.stringify(payload, null, 2));
|
console.log('[MenuBuilder] Full payload:', JSON.stringify(payload, null, 2));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
/* Payfrit Business Portal - Modern Admin UI */
|
/* Payfrit Business Portal - Modern Admin UI */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--primary: #6366f1;
|
--primary: #00d974;
|
||||||
--primary-dark: #4f46e5;
|
--primary-dark: #00b862;
|
||||||
--primary-light: #818cf8;
|
--primary-light: #33ff9f;
|
||||||
--primary-hover: #4f46e5;
|
--primary-hover: #00b862;
|
||||||
--success: #22c55e;
|
--success: #22c55e;
|
||||||
--warning: #f59e0b;
|
--warning: #f59e0b;
|
||||||
--danger: #ef4444;
|
--danger: #ef4444;
|
||||||
|
|
@ -396,7 +396,7 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-icon.orders {
|
.stat-icon.orders {
|
||||||
background: rgba(99, 102, 241, 0.1);
|
background: rgba(0, 217, 116, 0.1);
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -572,7 +572,7 @@ body {
|
||||||
.form-textarea:focus {
|
.form-textarea:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--primary);
|
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 {
|
.form-row {
|
||||||
|
|
@ -681,7 +681,7 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-icon.pending {
|
.status-icon.pending {
|
||||||
background: rgba(99, 102, 241, 0.1);
|
background: rgba(0, 217, 116, 0.1);
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -875,7 +875,7 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge.submitted {
|
.status-badge.submitted {
|
||||||
background: rgba(99, 102, 241, 0.1);
|
background: rgba(0, 217, 116, 0.1);
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1099,6 +1099,16 @@ const Portal = {
|
||||||
},
|
},
|
||||||
|
|
||||||
// Connect Stripe - initiate onboarding
|
// 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() {
|
async connectStripe() {
|
||||||
this.toast('Starting Stripe setup...', 'info');
|
this.toast('Starting Stripe setup...', 'info');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -835,6 +835,43 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Community Meal Participation -->
|
||||||
|
<div class="summary-card" style="margin-top: 16px;">
|
||||||
|
<div class="summary-card-header">
|
||||||
|
<h3>Community Meal Participation</h3>
|
||||||
|
<p style="margin: 4px 0 0; color: var(--gray-500); font-size: 14px;">Choose how this location participates.</p>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card-body" style="flex-direction: column; gap: 12px;">
|
||||||
|
<label style="display: flex; align-items: flex-start; gap: 12px; cursor: pointer; padding: 12px; border: 2px solid var(--primary); border-radius: 8px; background: rgba(99, 102, 241, 0.05);" id="communityMealOption1">
|
||||||
|
<input type="radio" name="communityMealType" value="1" checked
|
||||||
|
onchange="updateCommunityMealSelection()"
|
||||||
|
style="margin-top: 3px; width: 18px; height: 18px; accent-color: var(--primary); flex-shrink: 0;">
|
||||||
|
<div>
|
||||||
|
<strong style="display: block; color: var(--gray-900);">Provide Community Meals</strong>
|
||||||
|
<span style="color: var(--gray-500); font-size: 13px;">Offer one Community Meal per service window and receive a reduced Payfrit fee.</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: flex-start; gap: 12px; cursor: pointer; padding: 12px; border: 2px solid var(--gray-200); border-radius: 8px;" id="communityMealOption2">
|
||||||
|
<input type="radio" name="communityMealType" value="2"
|
||||||
|
onchange="updateCommunityMealSelection()"
|
||||||
|
style="margin-top: 3px; width: 18px; height: 18px; accent-color: var(--primary); flex-shrink: 0;">
|
||||||
|
<div>
|
||||||
|
<strong style="display: block; color: var(--gray-900);">Support a local food bank instead</strong>
|
||||||
|
<span style="color: var(--gray-500); font-size: 13px;">A combined 1.0% contribution (business + guest) is donated locally.</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<a href="/uploads/docs/Payfrit_Community_Meal_Participation_One_Pager.pdf" target="_blank"
|
||||||
|
style="display: inline-flex; align-items: center; gap: 6px; color: var(--primary); font-size: 13px; text-decoration: none; margin-top: 4px;">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
</svg>
|
||||||
|
Learn more about Community Meal Participation
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<button class="btn btn-outline" onclick="startOver()">Start Over</button>
|
<button class="btn btn-outline" onclick="startOver()">Start Over</button>
|
||||||
<button class="btn btn-success" onclick="saveMenu()">
|
<button class="btn btn-success" onclick="saveMenu()">
|
||||||
|
|
@ -920,6 +957,29 @@
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// US States (from tt_States)
|
||||||
|
const US_STATES = [
|
||||||
|
{abbr:'AL',name:'Alabama'},{abbr:'AK',name:'Alaska'},{abbr:'AZ',name:'Arizona'},{abbr:'AR',name:'Arkansas'},
|
||||||
|
{abbr:'CA',name:'California'},{abbr:'CO',name:'Colorado'},{abbr:'CT',name:'Connecticut'},{abbr:'DE',name:'Delaware'},
|
||||||
|
{abbr:'DC',name:'Dist. of Columbia'},{abbr:'FL',name:'Florida'},{abbr:'GA',name:'Georgia'},{abbr:'HI',name:'Hawaii'},
|
||||||
|
{abbr:'ID',name:'Idaho'},{abbr:'IL',name:'Illinois'},{abbr:'IN',name:'Indiana'},{abbr:'IA',name:'Iowa'},
|
||||||
|
{abbr:'KS',name:'Kansas'},{abbr:'KY',name:'Kentucky'},{abbr:'LA',name:'Louisiana'},{abbr:'ME',name:'Maine'},
|
||||||
|
{abbr:'MD',name:'Maryland'},{abbr:'MA',name:'Massachusetts'},{abbr:'MI',name:'Michigan'},{abbr:'MN',name:'Minnesota'},
|
||||||
|
{abbr:'MS',name:'Mississippi'},{abbr:'MO',name:'Missouri'},{abbr:'MT',name:'Montana'},{abbr:'NE',name:'Nebraska'},
|
||||||
|
{abbr:'NV',name:'Nevada'},{abbr:'NH',name:'New Hampshire'},{abbr:'NJ',name:'New Jersey'},{abbr:'NM',name:'New Mexico'},
|
||||||
|
{abbr:'NY',name:'New York'},{abbr:'NC',name:'North Carolina'},{abbr:'ND',name:'North Dakota'},{abbr:'OH',name:'Ohio'},
|
||||||
|
{abbr:'OK',name:'Oklahoma'},{abbr:'OR',name:'Oregon'},{abbr:'PA',name:'Pennsylvania'},{abbr:'RI',name:'Rhode Island'},
|
||||||
|
{abbr:'SC',name:'South Carolina'},{abbr:'SD',name:'South Dakota'},{abbr:'TN',name:'Tennessee'},{abbr:'TX',name:'Texas'},
|
||||||
|
{abbr:'UT',name:'Utah'},{abbr:'VT',name:'Vermont'},{abbr:'VA',name:'Virginia'},{abbr:'WA',name:'Washington'},
|
||||||
|
{abbr:'WV',name:'West Virginia'},{abbr:'WI',name:'Wisconsin'},{abbr:'WY',name:'Wyoming'}
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildStateOptions(selectedAbbr) {
|
||||||
|
const upper = (selectedAbbr || '').toUpperCase();
|
||||||
|
return '<option value="">Select...</option>' +
|
||||||
|
US_STATES.map(s => `<option value="${s.abbr}"${s.abbr === upper ? ' selected' : ''}>${s.abbr} - ${s.name}</option>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
const config = {
|
const config = {
|
||||||
businessId: null,
|
businessId: null,
|
||||||
|
|
@ -1223,6 +1283,7 @@
|
||||||
'saturday': 5, 'sat': 5,
|
'saturday': 5, 'sat': 5,
|
||||||
'sunday': 6, 'sun': 6
|
'sunday': 6, 'sun': 6
|
||||||
};
|
};
|
||||||
|
const dayNames = Object.keys(dayMap);
|
||||||
|
|
||||||
// Initialize all days as open with defaults
|
// Initialize all days as open with defaults
|
||||||
for (let i = 0; i < 7; i++) {
|
for (let i = 0; i < 7; i++) {
|
||||||
|
|
@ -1233,28 +1294,85 @@
|
||||||
return schedule;
|
return schedule;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to extract a time range
|
const convertTo24Hour = (hour, minute, ampm) => {
|
||||||
const timePattern = /(\d{1,2}):?(\d{2})?\s*(am|pm|a\.m\.|p\.m\.)?\s*(?:to|[-–])\s*(\d{1,2}):?(\d{2})?\s*(am|pm|a\.m\.|p\.m\.)?/i;
|
let h = parseInt(hour);
|
||||||
const timeMatch = hoursText.match(timePattern);
|
const m = minute ? parseInt(minute) : 0;
|
||||||
|
if (ampm) {
|
||||||
|
ampm = ampm.replace(/\./g, '').toLowerCase();
|
||||||
|
if (ampm === 'pm' && h < 12) h += 12;
|
||||||
|
if (ampm === 'am' && h === 12) h = 0;
|
||||||
|
}
|
||||||
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
if (timeMatch) {
|
const timePattern = /(\d{1,2}):?(\d{2})?\s*(am|pm|a\.m\.|p\.m\.)?\s*(?:to|[-–])\s*(\d{1,2}):?(\d{2})?\s*(am|pm|a\.m\.|p\.m\.)?/i;
|
||||||
const convertTo24Hour = (hour, minute, ampm) => {
|
// Day name pattern (matches day abbreviations/names)
|
||||||
let h = parseInt(hour);
|
const dayPattern = new RegExp('(' + dayNames.join('|') + ')', 'gi');
|
||||||
const m = minute ? parseInt(minute) : 0;
|
|
||||||
if (ampm) {
|
// Split by comma, semicolon, or newline to get individual segments
|
||||||
ampm = ampm.replace(/\./g, '').toLowerCase();
|
const segments = hoursText.split(/[,;\n]+/).map(s => s.trim()).filter(s => s.length);
|
||||||
if (ampm === 'pm' && h < 12) h += 12;
|
|
||||||
if (ampm === 'am' && h === 12) h = 0;
|
let appliedAny = false;
|
||||||
}
|
|
||||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
for (const segment of segments) {
|
||||||
};
|
// Find time range in this segment
|
||||||
|
const timeMatch = segment.match(timePattern);
|
||||||
|
if (!timeMatch) continue;
|
||||||
|
|
||||||
const openTime = convertTo24Hour(timeMatch[1], timeMatch[2], timeMatch[3]);
|
const openTime = convertTo24Hour(timeMatch[1], timeMatch[2], timeMatch[3]);
|
||||||
const closeTime = convertTo24Hour(timeMatch[4], timeMatch[5], timeMatch[6]);
|
const closeTime = convertTo24Hour(timeMatch[4], timeMatch[5], timeMatch[6]);
|
||||||
|
|
||||||
// Apply extracted time to all days
|
// Find all day names in this segment
|
||||||
for (let i = 0; i < 7; i++) {
|
const foundDays = [];
|
||||||
schedule[i] = { open: openTime, close: closeTime, closed: false };
|
let dayMatch;
|
||||||
|
dayPattern.lastIndex = 0;
|
||||||
|
while ((dayMatch = dayPattern.exec(segment)) !== null) {
|
||||||
|
foundDays.push(dayMap[dayMatch[1].toLowerCase()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundDays.length === 0) {
|
||||||
|
// No days specified - apply to all days
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
schedule[i] = { open: openTime, close: closeTime, closed: false };
|
||||||
|
}
|
||||||
|
appliedAny = true;
|
||||||
|
} else if (foundDays.length >= 2) {
|
||||||
|
// Check for range (e.g. "Mon-Thu" or "Mon, Tue, Wed, Thur")
|
||||||
|
// Look for dash/en-dash between two day names indicating a range
|
||||||
|
const rangeMatch = segment.match(new RegExp('(' + dayNames.join('|') + ')\\s*[-–]\\s*(' + dayNames.join('|') + ')', 'i'));
|
||||||
|
if (rangeMatch) {
|
||||||
|
// Day range like "Mon-Thu"
|
||||||
|
const startDay = dayMap[rangeMatch[1].toLowerCase()];
|
||||||
|
const endDay = dayMap[rangeMatch[2].toLowerCase()];
|
||||||
|
let d = startDay;
|
||||||
|
while (true) {
|
||||||
|
schedule[d] = { open: openTime, close: closeTime, closed: false };
|
||||||
|
if (d === endDay) break;
|
||||||
|
d = (d + 1) % 7;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Comma-separated days like "Mon, Tue, Wed, Thur"
|
||||||
|
for (const d of foundDays) {
|
||||||
|
schedule[d] = { open: openTime, close: closeTime, closed: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appliedAny = true;
|
||||||
|
} else {
|
||||||
|
// Single day
|
||||||
|
schedule[foundDays[0]] = { open: openTime, close: closeTime, closed: false };
|
||||||
|
appliedAny = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: if no segments matched, try the whole string as one time
|
||||||
|
if (!appliedAny) {
|
||||||
|
const timeMatch = hoursText.match(timePattern);
|
||||||
|
if (timeMatch) {
|
||||||
|
const openTime = convertTo24Hour(timeMatch[1], timeMatch[2], timeMatch[3]);
|
||||||
|
const closeTime = convertTo24Hour(timeMatch[4], timeMatch[5], timeMatch[6]);
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
schedule[i] = { open: openTime, close: closeTime, closed: false };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1400,7 +1518,9 @@
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||||||
<div class="extracted-value editable">
|
<div class="extracted-value editable">
|
||||||
<label style="font-size:12px;color:var(--gray-500);display:block;margin-bottom:4px;">State</label>
|
<label style="font-size:12px;color:var(--gray-500);display:block;margin-bottom:4px;">State</label>
|
||||||
<input type="text" id="bizState" value="${state}" placeholder="CA" maxlength="2">
|
<select id="bizState" style="padding:8px 12px;border:1px solid var(--gray-300);border-radius:6px;font-size:14px;width:100%;background:white;">
|
||||||
|
${buildStateOptions(state)}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="extracted-value editable">
|
<div class="extracted-value editable">
|
||||||
<label style="font-size:12px;color:var(--gray-500);display:block;margin-bottom:4px;">ZIP Code</label>
|
<label style="font-size:12px;color:var(--gray-500);display:block;margin-bottom:4px;">ZIP Code</label>
|
||||||
|
|
@ -1411,6 +1531,11 @@
|
||||||
<label style="font-size:12px;color:var(--gray-500);display:block;margin-bottom:4px;">Phone</label>
|
<label style="font-size:12px;color:var(--gray-500);display:block;margin-bottom:4px;">Phone</label>
|
||||||
<input type="text" id="bizPhone" value="${biz.phone || ''}" placeholder="Phone number">
|
<input type="text" id="bizPhone" value="${biz.phone || ''}" placeholder="Phone number">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="extracted-value editable">
|
||||||
|
<label style="font-size:12px;color:var(--gray-500);display:block;margin-bottom:4px;">Sales Tax Rate (%)</label>
|
||||||
|
<input type="number" id="bizTaxRate" value="" placeholder="8.25" step="0.01" min="0" max="25" style="width:120px;">
|
||||||
|
<span style="font-size:11px;color:var(--gray-400);margin-left:8px;">e.g. 8.25 for 8.25%</span>
|
||||||
|
</div>
|
||||||
<div class="extracted-value editable">
|
<div class="extracted-value editable">
|
||||||
<label style="font-size:12px;color:var(--gray-500);display:block;margin-bottom:4px;">Business Hours</label>
|
<label style="font-size:12px;color:var(--gray-500);display:block;margin-bottom:4px;">Business Hours</label>
|
||||||
<table style="width:100%;border-collapse:collapse;margin-top:8px;">
|
<table style="width:100%;border-collapse:collapse;margin-top:8px;">
|
||||||
|
|
@ -1488,10 +1613,77 @@
|
||||||
state: document.getElementById('bizState').value,
|
state: document.getElementById('bizState').value,
|
||||||
zip: document.getElementById('bizZip').value,
|
zip: document.getElementById('bizZip').value,
|
||||||
phone: document.getElementById('bizPhone').value,
|
phone: document.getElementById('bizPhone').value,
|
||||||
|
taxRatePercent: parseFloat(document.getElementById('bizTaxRate').value) || 0,
|
||||||
hoursSchedule: hoursSchedule // Send the structured schedule instead of the raw hours string
|
hoursSchedule: hoursSchedule // Send the structured schedule instead of the raw hours string
|
||||||
};
|
};
|
||||||
|
|
||||||
// Move to categories
|
// Move to header image step
|
||||||
|
showHeaderImageStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header Image step - between business info and categories
|
||||||
|
function showHeaderImageStep() {
|
||||||
|
addMessage('ai', `
|
||||||
|
<p><strong>Header Image</strong></p>
|
||||||
|
<p>This image appears at the top of your menu in the Payfrit app. It's the first thing customers see!</p>
|
||||||
|
<div style="background:var(--gray-100);border-radius:8px;padding:16px;margin:12px 0;">
|
||||||
|
<p style="font-weight:500;margin-bottom:8px;">Recommended specs:</p>
|
||||||
|
<ul style="margin:0;padding-left:20px;color:var(--gray-600);font-size:13px;">
|
||||||
|
<li><strong>Size:</strong> 1220 x 400 pixels</li>
|
||||||
|
<li><strong>Format:</strong> JPG or PNG</li>
|
||||||
|
<li><strong>Content:</strong> Your restaurant, food, or branding</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div id="headerUploadPreview" style="width:100%;height:120px;background:#333;border-radius:8px;margin:12px 0;background-size:cover;background-position:center;display:none;"></div>
|
||||||
|
<div style="display:flex;gap:12px;margin-top:12px;">
|
||||||
|
<label class="btn btn-secondary" style="cursor:pointer;flex:1;text-align:center;">
|
||||||
|
<input type="file" id="wizardHeaderFile" accept="image/png,image/jpeg,image/jpg" style="display:none;" onchange="previewWizardHeader(this)">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:6px;vertical-align:middle;">
|
||||||
|
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
|
||||||
|
</svg>
|
||||||
|
Choose Image
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="action-buttons" style="margin-top:16px;">
|
||||||
|
<button class="btn btn-success" onclick="confirmHeaderImage()">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="20 6 9 17 4 12"/>
|
||||||
|
</svg>
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" onclick="skipHeaderImage()">Skip for Now</button>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewWizardHeader(input) {
|
||||||
|
if (!input.files || !input.files[0]) return;
|
||||||
|
const file = input.files[0];
|
||||||
|
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
showToast('Image must be under 5MB', 'error');
|
||||||
|
input.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = function(e) {
|
||||||
|
const preview = document.getElementById('headerUploadPreview');
|
||||||
|
preview.style.backgroundImage = `url(${e.target.result})`;
|
||||||
|
preview.style.display = 'block';
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
|
||||||
|
// Store the file for upload after save
|
||||||
|
config.headerImageFile = file;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmHeaderImage() {
|
||||||
|
showCategoriesStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipHeaderImage() {
|
||||||
|
config.headerImageFile = null;
|
||||||
showCategoriesStep();
|
showCategoriesStep();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1906,7 +2098,7 @@
|
||||||
<strong>${item.name}</strong>
|
<strong>${item.name}</strong>
|
||||||
${item.description ? `<br><small style="color:var(--gray-500);">${item.description}</small>` : ''}
|
${item.description ? `<br><small style="color:var(--gray-500);">${item.description}</small>` : ''}
|
||||||
</td>
|
</td>
|
||||||
<td>$${(item.price || 0).toFixed(2)}</td>
|
<td>$${parseFloat(item.price || 0).toFixed(2)}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="item-modifiers">
|
<div class="item-modifiers">
|
||||||
${(item.modifiers || []).map(m => `<span class="item-modifier-tag">${m}</span>`).join('')}
|
${(item.modifiers || []).map(m => `<span class="item-modifier-tag">${m}</span>`).join('')}
|
||||||
|
|
@ -2024,6 +2216,10 @@
|
||||||
config.extractedData.menuStartTime = menuStartTime;
|
config.extractedData.menuStartTime = menuStartTime;
|
||||||
config.extractedData.menuEndTime = menuEndTime;
|
config.extractedData.menuEndTime = menuEndTime;
|
||||||
|
|
||||||
|
// Community meal participation type (1=provide meals, 2=food bank)
|
||||||
|
const communityMealRadio = document.querySelector('input[name="communityMealType"]:checked');
|
||||||
|
config.extractedData.communityMealType = communityMealRadio ? parseInt(communityMealRadio.value) : 1;
|
||||||
|
|
||||||
const saveBtn = document.querySelector('#finalActions .btn-success');
|
const saveBtn = document.querySelector('#finalActions .btn-success');
|
||||||
const originalText = saveBtn.innerHTML;
|
const originalText = saveBtn.innerHTML;
|
||||||
saveBtn.innerHTML = '<div class="loading-spinner" style="width:16px;height:16px;border-width:2px;"></div> Saving...';
|
saveBtn.innerHTML = '<div class="loading-spinner" style="width:16px;height:16px;border-width:2px;"></div> Saving...';
|
||||||
|
|
@ -2075,11 +2271,37 @@
|
||||||
showToast('Menu saved successfully!', 'success');
|
showToast('Menu saved successfully!', 'success');
|
||||||
|
|
||||||
// Use the businessId from the response (in case it was newly created)
|
// Use the businessId from the response (in case it was newly created)
|
||||||
const finalBusinessId = result.summary?.businessId || config.businessId;
|
// Lucee serializes struct keys as uppercase, so check both cases
|
||||||
|
const summary = result.summary || result.SUMMARY || {};
|
||||||
|
const finalBusinessId = summary.businessId || summary.BUSINESSID || summary.businessid || config.businessId;
|
||||||
|
|
||||||
// Update localStorage with the new business ID to keep user logged in
|
// Update localStorage with the new business ID to keep user logged in
|
||||||
localStorage.setItem('payfrit_portal_business', finalBusinessId);
|
localStorage.setItem('payfrit_portal_business', finalBusinessId);
|
||||||
|
|
||||||
|
// Upload header image if one was selected
|
||||||
|
if (config.headerImageFile && finalBusinessId) {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('BusinessID', finalBusinessId);
|
||||||
|
formData.append('header', config.headerImageFile);
|
||||||
|
|
||||||
|
const headerResp = await fetch(`${config.apiBaseUrl}/menu/uploadHeader.cfm`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
const headerResult = await headerResp.json();
|
||||||
|
if (headerResult.OK) {
|
||||||
|
console.log('Header image uploaded:', headerResult.HEADERURL);
|
||||||
|
} else {
|
||||||
|
console.error('Header upload failed:', headerResult.MESSAGE);
|
||||||
|
showToast('Menu saved, but header image upload failed. You can upload it later in Settings.', 'warning');
|
||||||
|
}
|
||||||
|
} catch (headerErr) {
|
||||||
|
console.error('Header upload error:', headerErr);
|
||||||
|
showToast('Menu saved, but header image upload failed. You can upload it later in Settings.', 'warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Redirect to visual menu builder after a moment
|
// Redirect to visual menu builder after a moment
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = 'index.html#menu';
|
window.location.href = 'index.html#menu';
|
||||||
|
|
@ -2207,6 +2429,23 @@
|
||||||
message.scrollIntoView({ behavior: 'smooth' });
|
message.scrollIntoView({ behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateCommunityMealSelection() {
|
||||||
|
const selected = document.querySelector('input[name="communityMealType"]:checked').value;
|
||||||
|
const opt1 = document.getElementById('communityMealOption1');
|
||||||
|
const opt2 = document.getElementById('communityMealOption2');
|
||||||
|
if (selected === '1') {
|
||||||
|
opt1.style.borderColor = 'var(--primary)';
|
||||||
|
opt1.style.background = 'rgba(99, 102, 241, 0.05)';
|
||||||
|
opt2.style.borderColor = 'var(--gray-200)';
|
||||||
|
opt2.style.background = '';
|
||||||
|
} else {
|
||||||
|
opt2.style.borderColor = 'var(--primary)';
|
||||||
|
opt2.style.background = 'rgba(99, 102, 241, 0.05)';
|
||||||
|
opt1.style.borderColor = 'var(--gray-200)';
|
||||||
|
opt1.style.background = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateProgress(step) {
|
function updateProgress(step) {
|
||||||
config.currentStep = step;
|
config.currentStep = step;
|
||||||
document.querySelectorAll('.progress-step').forEach(el => {
|
document.querySelectorAll('.progress-step').forEach(el => {
|
||||||
|
|
|
||||||
655
verticals/hospitals.html
Normal file
655
verticals/hospitals.html
Normal file
|
|
@ -0,0 +1,655 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Payfrit for Hospitals - Mobile Ordering & Staff Services</title>
|
||||||
|
<meta name="description" content="Payfrit powers hospital food services, cafeterias, micro-markets, and patient room service. BLE-enabled ordering and task management for healthcare facilities.">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0a0a0a;
|
||||||
|
--bg-card: #141414;
|
||||||
|
--bg-secondary: #1a1a1a;
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #a0a0a0;
|
||||||
|
--text-muted: #666666;
|
||||||
|
--border-color: #2a2a2a;
|
||||||
|
--primary: #00ff88;
|
||||||
|
--primary-hover: #00cc6a;
|
||||||
|
--primary-dim: rgba(0, 255, 136, 0.1);
|
||||||
|
--accent-blue: #3b82f6;
|
||||||
|
--accent-purple: #8b5cf6;
|
||||||
|
--accent-orange: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation */
|
||||||
|
.nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
padding: 20px 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: rgba(10, 10, 10, 0.9);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logo-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: #000;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logo-text {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 15px;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-cta {
|
||||||
|
padding: 10px 24px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: #000;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-cta:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero Section */
|
||||||
|
.hero {
|
||||||
|
padding: 160px 40px 100px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--primary-dim);
|
||||||
|
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||||
|
border-radius: 100px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--primary);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 56px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
background: linear-gradient(135deg, #fff 0%, #a0a0a0 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
max-width: 700px;
|
||||||
|
margin: 0 auto 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-cta-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 16px 32px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: #000;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 16px 32px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Use Cases Section */
|
||||||
|
.section {
|
||||||
|
padding: 100px 40px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-subtitle {
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Use Case Cards */
|
||||||
|
.use-cases-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.use-case-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 32px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.use-case-card:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.use-case-icon {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.use-case-icon.cafeteria { background: rgba(59, 130, 246, 0.15); }
|
||||||
|
.use-case-icon.retail { background: rgba(139, 92, 246, 0.15); }
|
||||||
|
.use-case-icon.roomservice { background: rgba(245, 158, 11, 0.15); }
|
||||||
|
.use-case-icon.staff { background: rgba(0, 255, 136, 0.15); }
|
||||||
|
|
||||||
|
.use-case-card h3 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.use-case-card p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.use-case-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.use-case-tag {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Why Payfrit Section */
|
||||||
|
.why-section {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
background: var(--primary-dim);
|
||||||
|
border-radius: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-icon svg {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
stroke: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature h3 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Partners Section */
|
||||||
|
.partners-section {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partners-note {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partners-logos {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 60px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-logo {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CTA Section */
|
||||||
|
.cta-section {
|
||||||
|
text-align: center;
|
||||||
|
padding: 120px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-card {
|
||||||
|
background: linear-gradient(135deg, var(--bg-card) 0%, var(--bg-secondary) 100%);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 80px 40px;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-card h2 {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-card p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {
|
||||||
|
padding: 40px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-text {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 968px) {
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.use-cases-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.nav {
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
padding: 120px 20px 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding: 60px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partners-logos {
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="/" class="nav-logo">
|
||||||
|
<div class="nav-logo-icon">P</div>
|
||||||
|
<span class="nav-logo-text">Payfrit</span>
|
||||||
|
</a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="#use-cases" class="nav-link">Use Cases</a>
|
||||||
|
<a href="#features" class="nav-link">Features</a>
|
||||||
|
<a href="/portal/signup.html" class="nav-cta">Get Started</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<section class="hero">
|
||||||
|
<div class="hero-badge">
|
||||||
|
<span>Healthcare</span>
|
||||||
|
</div>
|
||||||
|
<h1>Modern Food Services for Modern Hospitals</h1>
|
||||||
|
<p class="hero-subtitle">
|
||||||
|
Payfrit powers cafeterias, micro-markets, branded restaurants, and patient room service
|
||||||
|
with BLE-enabled mobile ordering and intelligent task management.
|
||||||
|
</p>
|
||||||
|
<div class="hero-cta-group">
|
||||||
|
<a href="/portal/signup.html" class="btn-primary">Start Free Trial</a>
|
||||||
|
<a href="mailto:sales@payfrit.com" class="btn-secondary">Contact Sales</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Use Cases Section -->
|
||||||
|
<section class="section" id="use-cases">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-label">Use Cases</div>
|
||||||
|
<h2 class="section-title">One Platform, Every Touchpoint</h2>
|
||||||
|
<p class="section-subtitle">
|
||||||
|
Hospital food service is complex. Payfrit handles cafeterias, retail, room service, and staff operations in a single system.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="use-cases-grid">
|
||||||
|
<div class="use-case-card">
|
||||||
|
<div class="use-case-icon cafeteria">🍽</div>
|
||||||
|
<h3>Hospital Cafeterias</h3>
|
||||||
|
<p>
|
||||||
|
Staff and visitors order from their phones, skip the line, and get notified when food is ready.
|
||||||
|
Reduce wait times during peak lunch hours and improve throughput.
|
||||||
|
</p>
|
||||||
|
<div class="use-case-tags">
|
||||||
|
<span class="use-case-tag">Mobile ordering</span>
|
||||||
|
<span class="use-case-tag">Order-ahead</span>
|
||||||
|
<span class="use-case-tag">Peak management</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="use-case-card">
|
||||||
|
<div class="use-case-icon retail">🍺</div>
|
||||||
|
<h3>Branded Retail & Micro-Markets</h3>
|
||||||
|
<p>
|
||||||
|
Starbucks, Subway, grab-and-go markets, and gift shops all work with Payfrit.
|
||||||
|
Unified reporting across all vendors, regardless of who operates them.
|
||||||
|
</p>
|
||||||
|
<div class="use-case-tags">
|
||||||
|
<span class="use-case-tag">Multi-vendor</span>
|
||||||
|
<span class="use-case-tag">Grab-and-go</span>
|
||||||
|
<span class="use-case-tag">Gift shops</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="use-case-card">
|
||||||
|
<div class="use-case-icon roomservice">🛍</div>
|
||||||
|
<h3>Patient Room Service</h3>
|
||||||
|
<p>
|
||||||
|
Patients order meals from bedside tablets or their own devices. Dietary restrictions are enforced automatically.
|
||||||
|
Kitchen staff see orders by floor and room for efficient delivery.
|
||||||
|
</p>
|
||||||
|
<div class="use-case-tags">
|
||||||
|
<span class="use-case-tag">Dietary compliance</span>
|
||||||
|
<span class="use-case-tag">Bedside ordering</span>
|
||||||
|
<span class="use-case-tag">Floor routing</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="use-case-card">
|
||||||
|
<div class="use-case-icon staff">🛠</div>
|
||||||
|
<h3>Staff Services & Logistics</h3>
|
||||||
|
<p>
|
||||||
|
Beyond food: manage supply requests, equipment transport, housekeeping tasks, and maintenance calls.
|
||||||
|
Payfrit's task system routes requests to the right team automatically.
|
||||||
|
</p>
|
||||||
|
<div class="use-case-tags">
|
||||||
|
<span class="use-case-tag">Task routing</span>
|
||||||
|
<span class="use-case-tag">Supply requests</span>
|
||||||
|
<span class="use-case-tag">Maintenance</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Why Payfrit Section -->
|
||||||
|
<section class="section why-section" id="features">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-label">Why Payfrit</div>
|
||||||
|
<h2 class="section-title">Built for Complex Facilities</h2>
|
||||||
|
<p class="section-subtitle">
|
||||||
|
Hospitals aren't restaurants. Payfrit's architecture was designed for multi-location, multi-vendor, identity-aware operations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="features-grid">
|
||||||
|
<div class="feature">
|
||||||
|
<div class="feature-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
<path d="M12 2v4M12 18v4M2 12h4M18 12h4"/>
|
||||||
|
<path d="M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3>BLE Service Points</h3>
|
||||||
|
<p>
|
||||||
|
Bluetooth beacons identify where orders should be delivered. A patient in room 412 or a doctor in the ER lounge
|
||||||
|
- orders route automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature">
|
||||||
|
<div class="feature-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/>
|
||||||
|
<circle cx="9" cy="7" r="4"/>
|
||||||
|
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3>Identity-Linked Ordering</h3>
|
||||||
|
<p>
|
||||||
|
Connect to staff directories or patient systems. Dietary restrictions, meal allowances, and departmental billing
|
||||||
|
happen automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature">
|
||||||
|
<div class="feature-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3>Intelligent Task System</h3>
|
||||||
|
<p>
|
||||||
|
Food orders, supply requests, and service calls all flow through one task system with priority routing,
|
||||||
|
worker assignment, and completion tracking.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Partners Section -->
|
||||||
|
<section class="section partners-section">
|
||||||
|
<p class="partners-note">Designed to work with major food service management companies</p>
|
||||||
|
<div class="partners-logos">
|
||||||
|
<span class="partner-logo">Sodexo</span>
|
||||||
|
<span class="partner-logo">Compass Group</span>
|
||||||
|
<span class="partner-logo">Aramark</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CTA Section -->
|
||||||
|
<section class="cta-section">
|
||||||
|
<div class="cta-card">
|
||||||
|
<h2>Ready to modernize hospital food services?</h2>
|
||||||
|
<p>See how Payfrit can reduce wait times, improve patient satisfaction, and streamline operations.</p>
|
||||||
|
<div class="hero-cta-group">
|
||||||
|
<a href="/portal/signup.html" class="btn-primary">Start Free Trial</a>
|
||||||
|
<a href="mailto:sales@payfrit.com" class="btn-secondary">Schedule Demo</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="footer">
|
||||||
|
<p class="footer-text">© 2026 Payfrit. All rights reserved.</p>
|
||||||
|
<div class="footer-links">
|
||||||
|
<a href="/privacy.html" class="footer-link">Privacy Policy</a>
|
||||||
|
<a href="mailto:support@payfrit.com" class="footer-link">Support</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in a new issue