Add local dev support and fix menu builder API

Portal local development:
- Add BASE_PATH detection to all portal files (login, portal.js, menu-builder, station-assignment)
- Allows portal to work at /biz.payfrit.com/ path locally

Menu Builder fixes:
- Fix duplicate template options in getForBuilder.cfm query
- Filter template children by business ID with DISTINCT

New APIs:
- api/portal/myBusinesses.cfm - List businesses for logged-in user
- api/stations/list.cfm - List KDS stations
- api/menu/updateStations.cfm - Update item station assignments
- api/setup/reimportBigDeans.cfm - Full Big Dean's menu import script

Admin utilities:
- Various debug and migration scripts for menu/template management
- Beacon switching, category cleanup, modifier template setup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-01-04 22:47:12 -08:00
parent 1f4d06edba
commit 51a80b537d
37 changed files with 5893 additions and 225 deletions

View file

@ -5,7 +5,7 @@
sessiontimeout="#CreateTimeSpan(0,0,30,0)#" sessiontimeout="#CreateTimeSpan(0,0,30,0)#"
clientstorage="cookie"> clientstorage="cookie">
<CFSET application.datasource = "payfrit_local"> <CFSET application.datasource = "payfrit">
<cfset application.businessMasterObj = new library.cfc.businessMaster(odbc = application.datasource) /> <cfset application.businessMasterObj = new library.cfc.businessMaster(odbc = application.datasource) />
<cfset application.twilioObj = new library.cfc.twilio() /> <cfset application.twilioObj = new library.cfc.twilio() />

View file

@ -29,6 +29,7 @@
clientmanagement="false" clientmanagement="false"
setclientcookies="false" setclientcookies="false"
datasource="payfrit" datasource="payfrit"
showdebugoutput="false"
> >
<!--- Stripe Configuration ---> <!--- Stripe Configuration --->
@ -90,17 +91,38 @@ if (len(request._api_path)) {
// Portal endpoints // Portal endpoints
if (findNoCase("/api/portal/stats.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/portal/stats.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/portal/myBusinesses.cfm", request._api_path)) request._api_isPublic = true;
// Menu builder endpoints // Menu builder endpoints
if (findNoCase("/api/menu/getForBuilder.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/menu/getForBuilder.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/menu/saveFromBuilder.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/menu/saveFromBuilder.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/menu/updateStations.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/tasks/create.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/tasks/create.cfm", request._api_path)) request._api_isPublic = true;
// Admin endpoints // Admin endpoints (protected by localhost check in each file)
if (findNoCase("/api/admin/resetTestData.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/admin/resetTestData.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/debugTasks.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/admin/debugTasks.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/testTaskInsert.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/admin/testTaskInsert.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/debugBusinesses.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/admin/debugBusinesses.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/setupStations.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/setupModifierTemplates.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/migrateModifierTemplates.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/deleteOrphanModifiers.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/fixShakeFlavors.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/eliminateCategories.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/cleanupCategories.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/deleteOrphans.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/switchBeacons.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/debugTemplateLinks.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/fixBigDeansCategories.cfm", request._api_path)) request._api_isPublic = true;
// Setup/Import endpoints
if (findNoCase("/api/setup/importBusiness.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/setup/analyzeMenu.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/setup/downloadImages.cfm", request._api_path)) request._api_isPublic = true;
// Stations endpoints
if (findNoCase("/api/stations/list.cfm", request._api_path)) request._api_isPublic = true;
// Stripe endpoints // Stripe endpoints
if (findNoCase("/api/stripe/onboard.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/stripe/onboard.cfm", request._api_path)) request._api_isPublic = true;
@ -154,3 +176,4 @@ if (!request._api_isPublic) {
} }
} }
</cfscript> </cfscript>

35
api/admin/checkUser.cfm Normal file
View file

@ -0,0 +1,35 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
phone = structKeyExists(url, "phone") ? url.phone : "";
phone = reReplace(phone, "[^0-9]", "", "all");
if (!len(phone)) {
writeOutput(serializeJSON({ "OK": false, "ERROR": "missing phone" }));
abort;
}
q = queryExecute("
SELECT UserID, UserFirstName, UserLastName, UserEmail, UserPhone, UserIsContactVerified
FROM Users
WHERE UserPhone = :phone OR UserEmail = :phone
LIMIT 1
", { phone: phone }, { datasource: "payfrit" });
if (q.recordCount EQ 0) {
writeOutput(serializeJSON({ "OK": false, "ERROR": "user_not_found", "phone": phone }));
abort;
}
writeOutput(serializeJSON({
"OK": true,
"UserID": q.UserID,
"FirstName": q.UserFirstName,
"LastName": q.UserLastName,
"Email": q.UserEmail,
"Phone": q.UserPhone,
"Verified": q.UserIsContactVerified
}));
</cfscript>

View file

@ -0,0 +1,173 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<!--- Only allow from localhost --->
<cfif NOT (cgi.remote_addr EQ "127.0.0.1" OR cgi.remote_addr EQ "::1" OR findNoCase("localhost", cgi.server_name))>
<cfoutput>#serializeJSON({"OK": false, "ERROR": "admin_only"})#</cfoutput>
<cfabort>
</cfif>
<cfscript>
/**
* Cleanup Categories - Final step after migration verification
*
* This script:
* 1. Verifies all Items have ItemBusinessID set
* 2. Finds orphan items (ParentID=0, no children, not in links)
* 3. Drops ItemCategoryID column
* 4. Drops ItemIsModifierTemplate column (derived from ItemTemplateLinks now)
* 5. Drops Categories table
*
* Query param: ?confirm=YES to actually execute (otherwise shows verification only)
*/
response = { "OK": false, "verification": {}, "orphans": [], "steps": [] };
try {
confirm = structKeyExists(url, "confirm") && url.confirm == "YES";
// Verification Step 1: Check for items without BusinessID
qNoBusinessID = queryExecute("
SELECT COUNT(*) as cnt FROM Items
WHERE ItemBusinessID IS NULL OR ItemBusinessID = 0
", {}, { datasource: "payfrit" });
response.verification["itemsWithoutBusinessID"] = qNoBusinessID.cnt;
// Verification Step 2: Check that all categories were converted
qCategories = queryExecute("
SELECT COUNT(*) as cnt FROM Categories
", {}, { datasource: "payfrit" });
response.verification["categoriesRemaining"] = qCategories.cnt;
// Verification Step 3: Check category Items exist (ParentID=0 with children)
qCategoryItems = queryExecute("
SELECT COUNT(DISTINCT p.ItemID) as cnt
FROM Items p
INNER JOIN Items c ON c.ItemParentItemID = p.ItemID
WHERE p.ItemParentItemID = 0
AND p.ItemBusinessID > 0
AND NOT EXISTS (
SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = p.ItemID
)
", {}, { datasource: "payfrit" });
response.verification["categoryItemsCreated"] = qCategoryItems.cnt;
// Verification Step 4: Check templates exist (in ItemTemplateLinks)
qTemplates = queryExecute("
SELECT COUNT(DISTINCT tl.TemplateItemID) as cnt
FROM ItemTemplateLinks tl
INNER JOIN Items t ON t.ItemID = tl.TemplateItemID
", {}, { datasource: "payfrit" });
response.verification["templatesInLinks"] = qTemplates.cnt;
// Verification Step 5: Find orphans at ParentID=0
// Orphan = ParentID=0, no children pointing to it, not in ItemTemplateLinks
qOrphans = queryExecute("
SELECT i.ItemID, i.ItemName, i.ItemBusinessID
FROM Items i
WHERE i.ItemParentItemID = 0
AND NOT EXISTS (
SELECT 1 FROM Items child WHERE child.ItemParentItemID = i.ItemID
)
AND NOT EXISTS (
SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = i.ItemID
)
ORDER BY i.ItemBusinessID, i.ItemName
", {}, { datasource: "payfrit" });
response.verification["orphanCount"] = qOrphans.recordCount;
for (orphan in qOrphans) {
arrayAppend(response.orphans, {
"ItemID": orphan.ItemID,
"ItemName": orphan.ItemName,
"BusinessID": orphan.ItemBusinessID
});
}
// Summary
safeToCleanup = (qNoBusinessID.cnt == 0);
response.verification["safeToCleanup"] = safeToCleanup;
if (!safeToCleanup) {
arrayAppend(response.steps, "VERIFICATION FAILED - Cannot cleanup yet");
arrayAppend(response.steps, "- " & qNoBusinessID.cnt & " items still missing ItemBusinessID");
response["OK"] = false;
writeOutput(serializeJSON(response));
abort;
}
if (qOrphans.recordCount > 0) {
arrayAppend(response.steps, "WARNING: " & qOrphans.recordCount & " orphan items found (see orphans array)");
arrayAppend(response.steps, "These will NOT be deleted - review and handle manually if needed");
}
arrayAppend(response.steps, "Verification passed - safe to cleanup");
if (!confirm) {
arrayAppend(response.steps, "Add ?confirm=YES to execute cleanup");
response["OK"] = true;
writeOutput(serializeJSON(response));
abort;
}
// Execute cleanup
arrayAppend(response.steps, "Executing cleanup...");
// Step 1: Drop ItemCategoryID column
try {
queryExecute("
ALTER TABLE Items DROP COLUMN ItemCategoryID
", {}, { datasource: "payfrit" });
arrayAppend(response.steps, "Dropped ItemCategoryID column from Items");
} catch (any e) {
if (findNoCase("check that column", e.message) || findNoCase("Unknown column", e.message)) {
arrayAppend(response.steps, "ItemCategoryID column already dropped");
} else {
arrayAppend(response.steps, "Warning dropping ItemCategoryID: " & e.message);
}
}
// Step 2: Drop ItemIsModifierTemplate column (now derived from ItemTemplateLinks)
try {
queryExecute("
ALTER TABLE Items DROP COLUMN ItemIsModifierTemplate
", {}, { datasource: "payfrit" });
arrayAppend(response.steps, "Dropped ItemIsModifierTemplate column from Items");
} catch (any e) {
if (findNoCase("check that column", e.message) || findNoCase("Unknown column", e.message)) {
arrayAppend(response.steps, "ItemIsModifierTemplate column already dropped");
} else {
arrayAppend(response.steps, "Warning dropping ItemIsModifierTemplate: " & e.message);
}
}
// Step 3: Drop Categories table
try {
queryExecute("
DROP TABLE Categories
", {}, { datasource: "payfrit" });
arrayAppend(response.steps, "Dropped Categories table");
} catch (any e) {
if (findNoCase("Unknown table", e.message)) {
arrayAppend(response.steps, "Categories table already dropped");
} else {
arrayAppend(response.steps, "Warning dropping Categories: " & e.message);
}
}
response["OK"] = true;
arrayAppend(response.steps, "CLEANUP COMPLETE - Schema simplified");
} catch (any e) {
response["ERROR"] = e.message;
response["DETAIL"] = e.detail;
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -0,0 +1,38 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
bizId = 27;
// These are the items that were deactivated by fixBigDeansCategories.cfm
deactivatedIds = [11177, 11180, 11183, 11186, 11190, 11193, 11196, 11199, 11204, 11212, 11220, 11259];
qDeactivated = queryExecute("
SELECT i.ItemID, i.ItemName, i.ItemParentItemID, i.ItemIsActive, i.ItemIsCollapsible,
(SELECT COUNT(*) FROM Items c WHERE c.ItemParentItemID = i.ItemID) as ChildCount,
(SELECT GROUP_CONCAT(c.ItemName) FROM Items c WHERE c.ItemParentItemID = i.ItemID) as Children
FROM Items i
WHERE i.ItemID IN (:ids)
ORDER BY i.ItemID
", { ids: { value: arrayToList(deactivatedIds), list: true } }, { datasource: "payfrit" });
items = [];
for (row in qDeactivated) {
arrayAppend(items, {
"ItemID": row.ItemID,
"ItemName": row.ItemName,
"ParentID": row.ItemParentItemID,
"IsActive": row.ItemIsActive,
"IsCollapsible": row.ItemIsCollapsible,
"ChildCount": row.ChildCount,
"Children": row.Children
});
}
writeOutput(serializeJSON({
"OK": true,
"Message": "These items were deactivated thinking they were fake categories",
"DeactivatedItems": items
}));
</cfscript>

View file

@ -0,0 +1,70 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
bizId = 27;
// Get all template links for Big Dean's with item names
qLinks = queryExecute("
SELECT
tl.ItemID as MenuItemID,
mi.ItemName as MenuItemName,
mi.ItemParentItemID,
tl.TemplateItemID,
t.ItemName as TemplateName,
tl.SortOrder
FROM ItemTemplateLinks tl
JOIN Items mi ON mi.ItemID = tl.ItemID
JOIN Items t ON t.ItemID = tl.TemplateItemID
WHERE mi.ItemBusinessID = :bizId
ORDER BY mi.ItemParentItemID, mi.ItemName, tl.SortOrder
", { bizId: bizId }, { datasource: "payfrit" });
links = [];
for (row in qLinks) {
arrayAppend(links, {
"MenuItemID": row.MenuItemID,
"MenuItemName": row.MenuItemName,
"ParentItemID": row.ItemParentItemID,
"TemplateItemID": row.TemplateItemID,
"TemplateName": row.TemplateName
});
}
// Get burgers specifically (parent = 11271)
qBurgers = queryExecute("
SELECT ItemID, ItemName FROM Items
WHERE ItemBusinessID = :bizId AND ItemParentItemID = 11271 AND ItemIsActive = 1
ORDER BY ItemSortOrder
", { bizId: bizId }, { datasource: "payfrit" });
burgers = [];
for (row in qBurgers) {
// Get templates for this burger
qBurgerTemplates = queryExecute("
SELECT tl.TemplateItemID, t.ItemName as TemplateName
FROM ItemTemplateLinks tl
JOIN Items t ON t.ItemID = tl.TemplateItemID
WHERE tl.ItemID = :itemId
", { itemId: row.ItemID }, { datasource: "payfrit" });
templates = [];
for (t in qBurgerTemplates) {
arrayAppend(templates, t.TemplateName);
}
arrayAppend(burgers, {
"ItemID": row.ItemID,
"ItemName": row.ItemName,
"Templates": templates
});
}
writeOutput(serializeJSON({
"OK": true,
"totalLinks": qLinks.recordCount,
"links": links,
"burgers": burgers
}));
</cfscript>

View file

@ -0,0 +1,64 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
bizId = 27;
// Get all template links for Big Dean's
qLinks = queryExecute("
SELECT
tl.ItemID as MenuItemID,
mi.ItemName as MenuItemName,
mi.ItemParentItemID as MenuItemParentID,
tl.TemplateItemID,
t.ItemName as TemplateName,
t.ItemIsActive as TemplateActive,
tl.SortOrder
FROM ItemTemplateLinks tl
JOIN Items mi ON mi.ItemID = tl.ItemID
JOIN Items t ON t.ItemID = tl.TemplateItemID
WHERE mi.ItemBusinessID = :bizId
ORDER BY mi.ItemName, tl.SortOrder
", { bizId: bizId }, { datasource: "payfrit" });
links = [];
for (row in qLinks) {
arrayAppend(links, {
"MenuItemID": row.MenuItemID,
"MenuItemName": row.MenuItemName,
"MenuItemParentID": row.MenuItemParentID,
"TemplateItemID": row.TemplateItemID,
"TemplateName": row.TemplateName,
"TemplateActive": row.TemplateActive,
"SortOrder": row.SortOrder
});
}
// Get all templates that exist for this business
qTemplates = queryExecute("
SELECT ItemID, ItemName, ItemIsActive, ItemParentItemID
FROM Items
WHERE ItemBusinessID = :bizId
AND ItemIsCollapsible = 1
ORDER BY ItemName
", { bizId: bizId }, { datasource: "payfrit" });
templates = [];
for (row in qTemplates) {
arrayAppend(templates, {
"ItemID": row.ItemID,
"ItemName": row.ItemName,
"IsActive": row.ItemIsActive,
"ParentID": row.ItemParentItemID
});
}
writeOutput(serializeJSON({
"OK": true,
"TemplateLinksCount": arrayLen(links),
"TemplateLinks": links,
"TemplatesCount": arrayLen(templates),
"Templates": templates
}));
</cfscript>

View file

@ -0,0 +1,79 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
bizId = 27;
// Check the template items themselves (IDs from ItemTemplateLinks)
templateIds = "11267, 11251, 11246, 11224, 11233, 11230, 11240, 11243, 11237, 11227";
qTemplates = queryExecute("
SELECT ItemID, ItemName, ItemIsCollapsible, ItemIsActive, ItemParentItemID, ItemBusinessID
FROM Items
WHERE ItemID IN (#templateIds#)
ORDER BY ItemName
", {}, { datasource: "payfrit" });
templates = [];
for (row in qTemplates) {
arrayAppend(templates, {
"ItemID": row.ItemID,
"ItemName": row.ItemName,
"IsCollapsible": row.ItemIsCollapsible,
"IsActive": row.ItemIsActive,
"ParentID": row.ItemParentItemID,
"BusinessID": row.ItemBusinessID
});
}
// Also check what other templates might exist for burgers
// Look for items that are in ItemTemplateLinks but NOT linked to burgers
qMissingTemplates = queryExecute("
SELECT DISTINCT t.ItemID, t.ItemName, t.ItemIsCollapsible, t.ItemIsActive
FROM Items t
WHERE t.ItemBusinessID = :bizId
AND t.ItemParentItemID = 0
AND t.ItemID NOT IN (
SELECT i.ItemID FROM Items i WHERE i.ItemBusinessID = :bizId AND i.ItemCategoryID > 0
)
AND EXISTS (SELECT 1 FROM Items child WHERE child.ItemParentItemID = t.ItemID)
ORDER BY t.ItemName
", { bizId: bizId }, { datasource: "payfrit" });
potentialTemplates = [];
for (row in qMissingTemplates) {
arrayAppend(potentialTemplates, {
"ItemID": row.ItemID,
"ItemName": row.ItemName,
"IsCollapsible": row.ItemIsCollapsible,
"IsActive": row.ItemIsActive
});
}
// What templates SHOULD burgers have? Let's see all templates used by ANY item
qAllTemplateUsage = queryExecute("
SELECT t.ItemID, t.ItemName, COUNT(tl.ItemID) as UsageCount
FROM ItemTemplateLinks tl
JOIN Items t ON t.ItemID = tl.TemplateItemID
JOIN Items mi ON mi.ItemID = tl.ItemID AND mi.ItemBusinessID = :bizId
GROUP BY t.ItemID, t.ItemName
ORDER BY t.ItemName
", { bizId: bizId }, { datasource: "payfrit" });
allTemplates = [];
for (row in qAllTemplateUsage) {
arrayAppend(allTemplates, {
"TemplateID": row.ItemID,
"TemplateName": row.ItemName,
"UsageCount": row.UsageCount
});
}
writeOutput(serializeJSON({
"OK": true,
"TemplatesInLinks": templates,
"PotentialTemplates": potentialTemplates,
"AllTemplateUsage": allTemplates
}));
</cfscript>

View file

@ -0,0 +1,75 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
bizId = 27;
// Get the template items themselves
qTemplates = queryExecute("
SELECT ItemID, ItemName, ItemIsCollapsible, ItemIsActive, ItemParentItemID, ItemBusinessID
FROM Items
WHERE ItemID IN (11267, 11251, 11246, 11224, 11233, 11230, 11240, 11243, 11237, 11227)
ORDER BY ItemName
", {}, { datasource: "payfrit" });
templates = [];
for (row in qTemplates) {
arrayAppend(templates, {
"ItemID": row.ItemID,
"ItemName": row.ItemName,
"IsCollapsible": row.ItemIsCollapsible,
"IsActive": row.ItemIsActive,
"ParentID": row.ItemParentItemID,
"BusinessID": row.ItemBusinessID
});
}
// What templates are used by burgers vs all items?
qBurgerLinks = queryExecute("
SELECT mi.ItemID, mi.ItemName, GROUP_CONCAT(t.ItemName ORDER BY tl.SortOrder) as Templates
FROM Items mi
JOIN ItemTemplateLinks tl ON tl.ItemID = mi.ItemID
JOIN Items t ON t.ItemID = tl.TemplateItemID
WHERE mi.ItemBusinessID = :bizId
AND mi.ItemParentItemID = 11271
GROUP BY mi.ItemID, mi.ItemName
ORDER BY mi.ItemName
", { bizId: bizId }, { datasource: "payfrit" });
burgerLinks = [];
for (row in qBurgerLinks) {
arrayAppend(burgerLinks, {
"ItemID": row.ItemID,
"ItemName": row.ItemName,
"Templates": row.Templates
});
}
// Also check: are there templates that SHOULD be linked to burgers?
// (e.g., Add Cheese, etc.)
qCheeseTemplate = queryExecute("
SELECT ItemID, ItemName, ItemParentItemID, ItemIsActive
FROM Items
WHERE ItemBusinessID = :bizId
AND ItemName LIKE '%Cheese%'
ORDER BY ItemName
", { bizId: bizId }, { datasource: "payfrit" });
cheeseItems = [];
for (row in qCheeseTemplate) {
arrayAppend(cheeseItems, {
"ItemID": row.ItemID,
"ItemName": row.ItemName,
"ParentID": row.ItemParentItemID,
"IsActive": row.ItemIsActive
});
}
writeOutput(serializeJSON({
"OK": true,
"TemplatesUsed": templates,
"BurgerTemplateLinks": burgerLinks,
"CheeseRelatedItems": cheeseItems
}));
</cfscript>

View file

@ -0,0 +1,47 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// Check ItemTemplateLinks for Big Dean's (BusinessID 27)
bizId = 27;
// Count total links
qCount = queryExecute("SELECT COUNT(*) as cnt FROM ItemTemplateLinks", {}, { datasource: "payfrit" });
// Get template item IDs for this business
qTemplates = queryExecute("
SELECT DISTINCT tl.TemplateItemID, i.ItemName
FROM ItemTemplateLinks tl
JOIN Items i ON i.ItemID = tl.TemplateItemID
WHERE i.ItemBusinessID = :bizId
", { bizId: bizId }, { datasource: "payfrit" });
templates = [];
for (row in qTemplates) {
arrayAppend(templates, { "TemplateItemID": row.TemplateItemID, "ItemName": row.ItemName });
}
// Get items that should be categories (ParentItemID=0, not templates)
qCategories = queryExecute("
SELECT i.ItemID, i.ItemName, i.ItemParentItemID, i.ItemIsCollapsible
FROM Items i
WHERE i.ItemBusinessID = :bizId
AND i.ItemParentItemID = 0
AND i.ItemIsCollapsible = 0
AND NOT EXISTS (SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = i.ItemID)
ORDER BY i.ItemSortOrder
", { bizId: bizId }, { datasource: "payfrit" });
categories = [];
for (row in qCategories) {
arrayAppend(categories, { "ItemID": row.ItemID, "ItemName": row.ItemName });
}
writeOutput(serializeJSON({
"OK": true,
"totalTemplateLinks": qCount.cnt,
"templatesForBusiness": templates,
"categoriesForBusiness": categories
}));
</cfscript>

View file

@ -0,0 +1,85 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<!--- Only allow from localhost --->
<cfif NOT (cgi.remote_addr EQ "127.0.0.1" OR cgi.remote_addr EQ "::1" OR findNoCase("localhost", cgi.server_name))>
<cfoutput>#serializeJSON({"OK": false, "ERROR": "admin_only"})#</cfoutput>
<cfabort>
</cfif>
<cfscript>
/**
* Delete Orphan Modifiers for In and Out Burger (BusinessID=17)
*
* This script deletes duplicate modifier items that are no longer needed
* because we now use ItemTemplateLinks.
*
* The orphan items are level-1 modifiers (direct children of parent items)
* that have been replaced by template links.
*/
response = { "OK": false, "deleted": [], "errors": [] };
try {
businessID = 17; // In and Out Burger
// Get all level-1 modifiers that are NOT templates
// These are the duplicates we want to delete
qOrphans = queryExecute("
SELECT
m.ItemID,
m.ItemName,
m.ItemParentItemID,
p.ItemName as ParentName
FROM Items m
INNER JOIN Items p ON p.ItemID = m.ItemParentItemID
INNER JOIN Categories c ON c.CategoryID = p.ItemCategoryID
WHERE c.CategoryBusinessID = :businessID
AND m.ItemParentItemID > 0
AND p.ItemParentItemID = 0
AND (m.ItemIsModifierTemplate IS NULL OR m.ItemIsModifierTemplate = 0)
AND m.ItemIsActive = 1
ORDER BY m.ItemName
", { businessID: businessID }, { datasource: "payfrit" });
arrayAppend(response.deleted, "Found " & qOrphans.recordCount & " orphan modifiers to delete");
// First, delete all children of orphan items (level 3+)
for (orphan in qOrphans) {
try {
// Delete children of this orphan (options within the modifier group)
qDeleteChildren = queryExecute("
DELETE FROM Items WHERE ItemParentItemID = :orphanID
", { orphanID: orphan.ItemID }, { datasource: "payfrit" });
// Delete the orphan itself
qDeleteOrphan = queryExecute("
DELETE FROM Items WHERE ItemID = :orphanID
", { orphanID: orphan.ItemID }, { datasource: "payfrit" });
arrayAppend(response.deleted, {
"ItemID": orphan.ItemID,
"ItemName": orphan.ItemName,
"WasUnder": orphan.ParentName
});
} catch (any deleteErr) {
arrayAppend(response.errors, {
"ItemID": orphan.ItemID,
"ItemName": orphan.ItemName,
"Error": deleteErr.message
});
}
}
response["deletedCount"] = arrayLen(response.deleted) - 1; // Subtract the "Found X" message
response["errorCount"] = arrayLen(response.errors);
response["OK"] = true;
} catch (any e) {
response["ERROR"] = e.message;
response["DETAIL"] = e.detail;
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -0,0 +1,60 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<!--- Only allow from localhost --->
<cfif NOT (cgi.remote_addr EQ "127.0.0.1" OR cgi.remote_addr EQ "::1" OR findNoCase("localhost", cgi.server_name))>
<cfoutput>#serializeJSON({"OK": false, "ERROR": "admin_only"})#</cfoutput>
<cfabort>
</cfif>
<cfscript>
/**
* Delete orphan Items at ParentID=0
* Orphan = ParentID=0, no children, not in ItemTemplateLinks
*/
response = { "OK": false, "deleted": 0, "orphans": [] };
try {
// Find orphans
qOrphans = queryExecute("
SELECT i.ItemID, i.ItemName, i.ItemBusinessID
FROM Items i
WHERE i.ItemParentItemID = 0
AND NOT EXISTS (
SELECT 1 FROM Items child WHERE child.ItemParentItemID = i.ItemID
)
AND NOT EXISTS (
SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = i.ItemID
)
ORDER BY i.ItemBusinessID, i.ItemName
", {}, { datasource: "payfrit" });
orphanIDs = [];
for (orphan in qOrphans) {
arrayAppend(response.orphans, {
"ItemID": orphan.ItemID,
"ItemName": orphan.ItemName,
"BusinessID": orphan.ItemBusinessID
});
arrayAppend(orphanIDs, orphan.ItemID);
}
// Delete them by ID list
if (arrayLen(orphanIDs) > 0) {
queryExecute("
DELETE FROM Items WHERE ItemID IN (#arrayToList(orphanIDs)#)
", {}, { datasource: "payfrit" });
}
response["deleted"] = arrayLen(orphanIDs);
response["OK"] = true;
} catch (any e) {
response["ERROR"] = e.message;
response["DETAIL"] = e.detail;
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -0,0 +1,19 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
qDesc = queryExecute("SHOW COLUMNS FROM Items", {}, { datasource: "payfrit" });
cols = [];
for (row in qDesc) {
arrayAppend(cols, {
"Field": row.Field,
"Type": row.Type,
"Null": row.Null,
"Default": row.Default
});
}
writeOutput(serializeJSON({ "OK": true, "Columns": cols }));
</cfscript>

View file

@ -0,0 +1,252 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfsetting requesttimeout="300">
<cfcontent type="application/json; charset=utf-8" reset="true">
<!--- Only allow from localhost --->
<cfif NOT (cgi.remote_addr EQ "127.0.0.1" OR cgi.remote_addr EQ "::1" OR findNoCase("localhost", cgi.server_name))>
<cfoutput>#serializeJSON({"OK": false, "ERROR": "admin_only"})#</cfoutput>
<cfabort>
</cfif>
<cfscript>
/**
* Eliminate Categories Table - Schema Migration
*
* Final unified schema:
* - Everything is an Item with ItemBusinessID
* - ParentID=0 items are either categories or templates (derived from usage)
* - Categories = items at ParentID=0 that have menu items as children
* - Templates = items at ParentID=0 that appear in ItemTemplateLinks
* - Orphans = ParentID=0 items that are neither (cleanup candidates)
*
* Steps:
* 1. Add ItemBusinessID column to Items
* 2. For each Category: create Item, re-parent menu items, set BusinessID
* 3. Set ItemBusinessID on templates based on linked items
*
* Query param: ?dryRun=1 to preview without making changes
*/
response = { "OK": false, "steps": [], "migrations": [], "dryRun": false };
try {
dryRun = structKeyExists(url, "dryRun") && url.dryRun == 1;
response["dryRun"] = dryRun;
// Step 1: Add ItemBusinessID column if it doesn't exist
try {
if (!dryRun) {
queryExecute("
ALTER TABLE Items ADD COLUMN ItemBusinessID INT DEFAULT 0 AFTER ItemID
", {}, { datasource: "payfrit" });
}
arrayAppend(response.steps, "Added ItemBusinessID column to Items table");
} catch (any e) {
if (findNoCase("Duplicate column", e.message)) {
arrayAppend(response.steps, "ItemBusinessID column already exists");
} else {
throw(e);
}
}
// Step 2: Add index on ItemBusinessID
try {
if (!dryRun) {
queryExecute("
CREATE INDEX idx_item_business ON Items (ItemBusinessID)
", {}, { datasource: "payfrit" });
}
arrayAppend(response.steps, "Added index on ItemBusinessID");
} catch (any e) {
if (findNoCase("Duplicate key name", e.message)) {
arrayAppend(response.steps, "Index idx_item_business already exists");
} else {
arrayAppend(response.steps, "Index warning: " & e.message);
}
}
// Step 3: Drop foreign key constraint on ItemCategoryID if it exists
try {
if (!dryRun) {
queryExecute("
ALTER TABLE Items DROP FOREIGN KEY Items_ibfk_1
", {}, { datasource: "payfrit" });
}
arrayAppend(response.steps, "Dropped foreign key constraint Items_ibfk_1");
} catch (any e) {
if (findNoCase("check that column", e.message) || findNoCase("Can't DROP", e.message)) {
arrayAppend(response.steps, "Foreign key Items_ibfk_1 already dropped or doesn't exist");
} else {
arrayAppend(response.steps, "FK warning: " & e.message);
}
}
// Step 4: Get all Categories
qCategories = queryExecute("
SELECT CategoryID, CategoryBusinessID, CategoryName
FROM Categories
ORDER BY CategoryBusinessID, CategoryName
", {}, { datasource: "payfrit" });
arrayAppend(response.steps, "Found " & qCategories.recordCount & " categories to migrate");
// Step 4: Migrate each category
for (cat in qCategories) {
migration = {
"oldCategoryID": cat.CategoryID,
"categoryName": cat.CategoryName,
"businessID": cat.CategoryBusinessID,
"newItemID": 0,
"itemsUpdated": 0
};
if (!dryRun) {
// Create new Item for this category (ParentID=0, no template flag needed)
// Note: ItemCategoryID set to 0 temporarily until we drop that column
queryExecute("
INSERT INTO Items (
ItemBusinessID,
ItemCategoryID,
ItemName,
ItemDescription,
ItemParentItemID,
ItemPrice,
ItemIsActive,
ItemIsCheckedByDefault,
ItemRequiresChildSelection,
ItemSortOrder,
ItemAddedOn
) VALUES (
:businessID,
0,
:categoryName,
'',
0,
0,
1,
0,
0,
0,
NOW()
)
", {
businessID: cat.CategoryBusinessID,
categoryName: cat.CategoryName
}, { datasource: "payfrit" });
// Get the new Item ID
qNewItem = queryExecute("
SELECT ItemID FROM Items
WHERE ItemBusinessID = :businessID
AND ItemName = :categoryName
AND ItemParentItemID = 0
ORDER BY ItemID DESC
LIMIT 1
", {
businessID: cat.CategoryBusinessID,
categoryName: cat.CategoryName
}, { datasource: "payfrit" });
newItemID = qNewItem.ItemID;
migration["newItemID"] = newItemID;
// Update menu items in this category:
// - Set ItemParentItemID = newItemID (for top-level items only)
// - Set ItemBusinessID = businessID (for all items)
queryExecute("
UPDATE Items
SET ItemBusinessID = :businessID,
ItemParentItemID = :newItemID
WHERE ItemCategoryID = :categoryID
AND ItemParentItemID = 0
", {
businessID: cat.CategoryBusinessID,
newItemID: newItemID,
categoryID: cat.CategoryID
}, { datasource: "payfrit" });
// Set ItemBusinessID on ALL items in this category (including nested)
queryExecute("
UPDATE Items
SET ItemBusinessID = :businessID
WHERE ItemCategoryID = :categoryID
AND (ItemBusinessID IS NULL OR ItemBusinessID = 0)
", {
businessID: cat.CategoryBusinessID,
categoryID: cat.CategoryID
}, { datasource: "payfrit" });
// Count how many were updated
qCount = queryExecute("
SELECT COUNT(*) as cnt FROM Items
WHERE ItemParentItemID = :newItemID
", { newItemID: newItemID }, { datasource: "payfrit" });
migration["itemsUpdated"] = qCount.cnt;
} else {
// Dry run - count what would be updated
qCount = queryExecute("
SELECT COUNT(*) as cnt FROM Items
WHERE ItemCategoryID = :categoryID
AND ItemParentItemID = 0
", { categoryID: cat.CategoryID }, { datasource: "payfrit" });
migration["itemsToUpdate"] = qCount.cnt;
}
arrayAppend(response.migrations, migration);
}
// Step 5: Set ItemBusinessID for templates (items in ItemTemplateLinks)
// Templates get their BusinessID from the items they're linked to
if (!dryRun) {
queryExecute("
UPDATE Items t
INNER JOIN ItemTemplateLinks tl ON tl.TemplateItemID = t.ItemID
INNER JOIN Items i ON i.ItemID = tl.ItemID
SET t.ItemBusinessID = i.ItemBusinessID
WHERE (t.ItemBusinessID IS NULL OR t.ItemBusinessID = 0)
AND i.ItemBusinessID > 0
", {}, { datasource: "payfrit" });
arrayAppend(response.steps, "Set ItemBusinessID on templates from linked items");
// Set ItemBusinessID on template children (options)
queryExecute("
UPDATE Items c
INNER JOIN Items t ON t.ItemID = c.ItemParentItemID
SET c.ItemBusinessID = t.ItemBusinessID
WHERE t.ItemBusinessID > 0
AND (c.ItemBusinessID IS NULL OR c.ItemBusinessID = 0)
", {}, { datasource: "payfrit" });
arrayAppend(response.steps, "Set ItemBusinessID on template children");
// Make sure templates have ParentID=0 (they live at top level)
queryExecute("
UPDATE Items t
INNER JOIN ItemTemplateLinks tl ON tl.TemplateItemID = t.ItemID
SET t.ItemParentItemID = 0
WHERE t.ItemParentItemID != 0
", {}, { datasource: "payfrit" });
arrayAppend(response.steps, "Ensured templates have ParentItemID=0");
}
response["OK"] = true;
if (dryRun) {
arrayAppend(response.steps, "DRY RUN COMPLETE - No changes made. Run without ?dryRun=1 to execute.");
} else {
arrayAppend(response.steps, "MIGRATION COMPLETE - Categories converted to Items");
arrayAppend(response.steps, "Run cleanupCategories.cfm to drop old columns/tables after verification");
}
} catch (any e) {
response["ERROR"] = e.message;
response["DETAIL"] = e.detail;
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -0,0 +1,50 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// Fix Big Dean's - remove the fake "category" items that are actually modifier templates
// The real categories start at ItemID 11271 (World Famous Burgers)
bizId = 27;
// These are the modifier template parent items that got created incorrectly as root items
// They should NOT be categories - they're modifier group headers
fakeCategories = [11177, 11180, 11183, 11186, 11190, 11193, 11196, 11199, 11204, 11212, 11220, 11259];
// Deactivate these items (or we could delete them, but deactivate is safer)
for (itemId in fakeCategories) {
queryExecute("
UPDATE Items SET ItemIsActive = 0 WHERE ItemID = :itemId AND ItemBusinessID = :bizId
", { itemId: itemId, bizId: bizId }, { datasource: "payfrit" });
}
// Also deactivate their children (modifier options that belong to these fake parents)
for (itemId in fakeCategories) {
queryExecute("
UPDATE Items SET ItemIsActive = 0 WHERE ItemParentItemID = :itemId AND ItemBusinessID = :bizId
", { itemId: itemId, bizId: bizId }, { datasource: "payfrit" });
}
// Now verify what categories remain
qCategories = queryExecute("
SELECT i.ItemID, i.ItemName
FROM Items i
WHERE i.ItemBusinessID = :bizId
AND i.ItemParentItemID = 0
AND i.ItemIsActive = 1
AND i.ItemIsCollapsible = 0
AND NOT EXISTS (SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = i.ItemID)
ORDER BY i.ItemSortOrder
", { bizId: bizId }, { datasource: "payfrit" });
categories = [];
for (row in qCategories) {
arrayAppend(categories, { "ItemID": row.ItemID, "ItemName": row.ItemName });
}
writeOutput(serializeJSON({
"OK": true,
"MESSAGE": "Deactivated #arrayLen(fakeCategories)# fake category items and their children",
"remainingCategories": categories
}));
</cfscript>

View file

@ -0,0 +1,84 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
bizId = 27;
dryRun = structKeyExists(url, "dryRun") ? true : false;
// These modifier templates were incorrectly deactivated
// We need to reactivate and link them to burgers
modifierTemplates = {
// For burgers: need "Extras" with Add Cheese option
"11196": { name: "Extras", children: "Add Cheese, Add Onions", linkToBurgers: true },
// There's already an active "Onions" (11267), so we don't need 11220
};
// Burger items that need modifiers
burgerIds = [11286, 11287, 11288, 11289, 11290]; // Big Dean's, Single w/Cheese, Single, Beyond, Garden
actions = [];
// First, let's see what templates already exist and are active for burgers
qExistingLinks = queryExecute("
SELECT tl.ItemID as MenuItemID, tl.TemplateItemID, t.ItemName as TemplateName
FROM ItemTemplateLinks tl
JOIN Items t ON t.ItemID = tl.TemplateItemID
WHERE tl.ItemID IN (:burgerIds)
", { burgerIds: { value: arrayToList(burgerIds), list: true } }, { datasource: "payfrit" });
for (row in qExistingLinks) {
arrayAppend(actions, { action: "EXISTS", menuItemID: row.MenuItemID, templateID: row.TemplateItemID, templateName: row.TemplateName });
}
// Reactivate template 11196 (Extras with Add Cheese)
if (!dryRun) {
queryExecute("UPDATE Items SET ItemIsActive = 1 WHERE ItemID = 11196", {}, { datasource: "payfrit" });
queryExecute("UPDATE Items SET ItemIsActive = 1 WHERE ItemParentItemID = 11196", {}, { datasource: "payfrit" });
}
arrayAppend(actions, { action: dryRun ? "WOULD_REACTIVATE" : "REACTIVATED", itemID: 11196, name: "Extras (Add Cheese, Add Onions)" });
// Link template 11196 to all burgers
for (burgerId in burgerIds) {
// Check if link already exists
qCheck = queryExecute("
SELECT COUNT(*) as cnt FROM ItemTemplateLinks WHERE ItemID = :burgerId AND TemplateItemID = 11196
", { burgerId: burgerId }, { datasource: "payfrit" });
if (qCheck.cnt EQ 0) {
if (!dryRun) {
queryExecute("
INSERT INTO ItemTemplateLinks (ItemID, TemplateItemID, SortOrder)
VALUES (:burgerId, 11196, 2)
", { burgerId: burgerId }, { datasource: "payfrit" });
}
arrayAppend(actions, { action: dryRun ? "WOULD_LINK" : "LINKED", menuItemID: burgerId, templateID: 11196 });
}
}
// Verify the result
if (!dryRun) {
qVerify = queryExecute("
SELECT mi.ItemID, mi.ItemName, GROUP_CONCAT(t.ItemName ORDER BY tl.SortOrder) as Templates
FROM Items mi
LEFT JOIN ItemTemplateLinks tl ON tl.ItemID = mi.ItemID
LEFT JOIN Items t ON t.ItemID = tl.TemplateItemID
WHERE mi.ItemID IN (:burgerIds)
GROUP BY mi.ItemID, mi.ItemName
", { burgerIds: { value: arrayToList(burgerIds), list: true } }, { datasource: "payfrit" });
result = [];
for (row in qVerify) {
arrayAppend(result, { itemID: row.ItemID, name: row.ItemName, templates: row.Templates });
}
} else {
result = "Dry run - no changes made";
}
writeOutput(serializeJSON({
"OK": true,
"DryRun": dryRun,
"Actions": actions,
"BurgersAfterFix": result
}));
</cfscript>

View file

@ -0,0 +1,159 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<!--- Only allow from localhost --->
<cfif NOT (cgi.remote_addr EQ "127.0.0.1" OR cgi.remote_addr EQ "::1" OR findNoCase("localhost", cgi.server_name))>
<cfoutput>#serializeJSON({"OK": false, "ERROR": "admin_only"})#</cfoutput>
<cfabort>
</cfif>
<cfscript>
/**
* Fix Real Ice Cream Shake modifier structure
*
* Current (wrong):
* Real Ice Cream Shake (5808)
* ├── Chocolate (6294) - marked as template
* ├── Strawberry (6292) - marked as template
* └── Vanilla (6293) - marked as template
*
* Correct:
* Real Ice Cream Shake (5808)
* └── Choose Flavor (NEW - modifier group, REQUIRED)
* ├── Chocolate
* ├── Strawberry
* └── Vanilla
*
* Steps:
* 1. Create "Choose Flavor" modifier group under Shake (5808)
* 2. Re-parent the three flavors under the new group
* 3. Remove template flag from flavors
* 4. Mark "Choose Flavor" as the template
* 5. Create ItemTemplateLinks entry for Shake -> Choose Flavor
* 6. Set ItemRequiresChildSelection=1 on the shake item
*/
response = { "OK": false, "steps": [] };
try {
shakeItemID = 5808;
chocolateID = 6294;
strawberryID = 6292;
vanillaID = 6293;
categoryID = 46; // Fries and Shakes
// Step 1: Create "Choose Flavor" modifier group under Shake
queryExecute("
INSERT INTO Items (
ItemCategoryID,
ItemName,
ItemDescription,
ItemParentItemID,
ItemPrice,
ItemIsActive,
ItemIsCheckedByDefault,
ItemRequiresChildSelection,
ItemMaxNumSelectionReq,
ItemIsCollapsible,
ItemSortOrder,
ItemIsModifierTemplate,
ItemAddedOn
) VALUES (
:categoryID,
'Choose Flavor',
'',
:shakeItemID,
0,
1,
0,
1,
1,
1,
0,
1,
NOW()
)
", {
categoryID: categoryID,
shakeItemID: shakeItemID
}, { datasource: "payfrit" });
// Get the new Choose Flavor ID
qNewGroup = queryExecute("
SELECT ItemID FROM Items
WHERE ItemName = 'Choose Flavor'
AND ItemParentItemID = :shakeItemID
ORDER BY ItemID DESC
LIMIT 1
", { shakeItemID: shakeItemID }, { datasource: "payfrit" });
chooseFlavorID = qNewGroup.ItemID;
arrayAppend(response.steps, "Created 'Choose Flavor' group with ID: " & chooseFlavorID);
// Step 2: Re-parent the three flavors under Choose Flavor
queryExecute("
UPDATE Items
SET ItemParentItemID = :chooseFlavorID,
ItemIsModifierTemplate = 0,
ItemIsCheckedByDefault = 0
WHERE ItemID IN (:chocolateID, :strawberryID, :vanillaID)
", {
chooseFlavorID: chooseFlavorID,
chocolateID: chocolateID,
strawberryID: strawberryID,
vanillaID: vanillaID
}, { datasource: "payfrit" });
arrayAppend(response.steps, "Re-parented Chocolate, Strawberry, Vanilla under Choose Flavor");
// Step 3: Set Vanilla as default (common choice)
queryExecute("
UPDATE Items SET ItemIsCheckedByDefault = 1 WHERE ItemID = :vanillaID
", { vanillaID: vanillaID }, { datasource: "payfrit" });
arrayAppend(response.steps, "Set Vanilla as default flavor");
// Step 4: Remove old template links for the flavors
queryExecute("
DELETE FROM ItemTemplateLinks
WHERE TemplateItemID IN (:chocolateID, :strawberryID, :vanillaID)
", {
chocolateID: chocolateID,
strawberryID: strawberryID,
vanillaID: vanillaID
}, { datasource: "payfrit" });
arrayAppend(response.steps, "Removed old template links for flavor items");
// Step 5: Create ItemTemplateLinks for Shake -> Choose Flavor
queryExecute("
INSERT INTO ItemTemplateLinks (ItemID, TemplateItemID, SortOrder)
VALUES (:shakeItemID, :chooseFlavorID, 0)
ON DUPLICATE KEY UPDATE SortOrder = 0
", {
shakeItemID: shakeItemID,
chooseFlavorID: chooseFlavorID
}, { datasource: "payfrit" });
arrayAppend(response.steps, "Created template link: Shake -> Choose Flavor");
// Step 6: Set ItemRequiresChildSelection on shake
queryExecute("
UPDATE Items
SET ItemRequiresChildSelection = 1
WHERE ItemID = :shakeItemID
", { shakeItemID: shakeItemID }, { datasource: "payfrit" });
arrayAppend(response.steps, "Set ItemRequiresChildSelection=1 on Shake item");
response["OK"] = true;
response["chooseFlavorID"] = chooseFlavorID;
} catch (any e) {
response["ERROR"] = e.message;
response["DETAIL"] = e.detail;
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -0,0 +1,186 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<!--- Only allow from localhost --->
<cfif NOT (cgi.remote_addr EQ "127.0.0.1" OR cgi.remote_addr EQ "::1" OR findNoCase("localhost", cgi.server_name))>
<cfoutput>#serializeJSON({"OK": false, "ERROR": "admin_only"})#</cfoutput>
<cfabort>
</cfif>
<cfscript>
/**
* Migrate Modifier Templates for In and Out Burger (BusinessID=1)
*
* This script:
* 1. Identifies unique modifier trees (children of parent items)
* 2. Keeps ONE instance of each unique modifier name as the template
* 3. Links all parent items to these templates
* 4. Reports which duplicate items can be deleted
*/
response = { "OK": false, "steps": [], "templates": [], "links": [], "orphans": [] };
try {
businessID = 17; // In and Out Burger
// Step 1: Get all parent items (burgers, combos, etc.)
qParentItems = queryExecute("
SELECT i.ItemID, i.ItemName
FROM Items i
INNER JOIN Categories c ON c.CategoryID = i.ItemCategoryID
WHERE c.CategoryBusinessID = :businessID
AND i.ItemParentItemID = 0
AND i.ItemIsActive = 1
ORDER BY i.ItemName
", { businessID: businessID }, { datasource: "payfrit" });
arrayAppend(response.steps, "Found " & qParentItems.recordCount & " parent items");
// Step 2: Get all direct children (level 1 modifiers) of parent items
qModifiers = queryExecute("
SELECT
m.ItemID,
m.ItemName,
m.ItemParentItemID,
m.ItemPrice,
m.ItemIsCheckedByDefault,
m.ItemSortOrder,
p.ItemName as ParentName
FROM Items m
INNER JOIN Items p ON p.ItemID = m.ItemParentItemID
INNER JOIN Categories c ON c.CategoryID = p.ItemCategoryID
WHERE c.CategoryBusinessID = :businessID
AND m.ItemParentItemID > 0
AND m.ItemIsActive = 1
AND p.ItemParentItemID = 0
ORDER BY m.ItemName, m.ItemID
", { businessID: businessID }, { datasource: "payfrit" });
arrayAppend(response.steps, "Found " & qModifiers.recordCount & " level-1 modifiers");
// Step 3: Group modifiers by name to find duplicates
modifiersByName = {};
for (mod in qModifiers) {
modName = mod.ItemName;
if (!structKeyExists(modifiersByName, modName)) {
modifiersByName[modName] = [];
}
arrayAppend(modifiersByName[modName], {
"ItemID": mod.ItemID,
"ParentItemID": mod.ItemParentItemID,
"ParentName": mod.ParentName,
"Price": mod.ItemPrice,
"IsDefault": mod.ItemIsCheckedByDefault,
"SortOrder": mod.ItemSortOrder
});
}
// Step 4: For each unique modifier name, pick ONE as the template
// Keep the first occurrence (usually oldest/original)
templateMap = {}; // modifierName -> templateItemID
orphanItems = []; // Items to delete later
for (modName in modifiersByName) {
instances = modifiersByName[modName];
if (arrayLen(instances) > 1) {
// Multiple instances - first one becomes template
templateItem = instances[1];
templateItemID = templateItem.ItemID;
// Mark as template
queryExecute("
UPDATE Items SET ItemIsModifierTemplate = 1 WHERE ItemID = :itemID
", { itemID: templateItemID }, { datasource: "payfrit" });
templateMap[modName] = templateItemID;
arrayAppend(response.templates, {
"name": modName,
"templateItemID": templateItemID,
"originalParent": templateItem.ParentName,
"duplicateCount": arrayLen(instances) - 1
});
// Create links for ALL parent items that had this modifier
for (i = 1; i <= arrayLen(instances); i++) {
inst = instances[i];
parentItemID = inst.ParentItemID;
// Insert link (ignore duplicates)
try {
queryExecute("
INSERT INTO ItemTemplateLinks (ItemID, TemplateItemID, SortOrder)
VALUES (:itemID, :templateID, :sortOrder)
ON DUPLICATE KEY UPDATE SortOrder = :sortOrder
", {
itemID: parentItemID,
templateID: templateItemID,
sortOrder: inst.SortOrder
}, { datasource: "payfrit" });
arrayAppend(response.links, {
"parentItemID": parentItemID,
"parentName": inst.ParentName,
"templateItemID": templateItemID,
"templateName": modName
});
} catch (any linkErr) {
// Link already exists, ignore
}
// Mark duplicates (not the template) as orphans
if (i > 1) {
arrayAppend(orphanItems, {
"ItemID": inst.ItemID,
"ItemName": modName,
"WasUnder": inst.ParentName
});
}
}
} else {
// Single instance - still mark as template for consistency
singleItem = instances[1];
queryExecute("
UPDATE Items SET ItemIsModifierTemplate = 1 WHERE ItemID = :itemID
", { itemID: singleItem.ItemID }, { datasource: "payfrit" });
// Create link
queryExecute("
INSERT INTO ItemTemplateLinks (ItemID, TemplateItemID, SortOrder)
VALUES (:itemID, :templateID, :sortOrder)
ON DUPLICATE KEY UPDATE SortOrder = :sortOrder
", {
itemID: singleItem.ParentItemID,
templateID: singleItem.ItemID,
sortOrder: singleItem.SortOrder
}, { datasource: "payfrit" });
arrayAppend(response.templates, {
"name": modName,
"templateItemID": singleItem.ItemID,
"originalParent": singleItem.ParentName,
"duplicateCount": 0
});
}
}
response["orphans"] = orphanItems;
response["orphanCount"] = arrayLen(orphanItems);
response["templateCount"] = arrayLen(response.templates);
response["linkCount"] = arrayLen(response.links);
arrayAppend(response.steps, "Created " & arrayLen(response.templates) & " templates");
arrayAppend(response.steps, "Created " & arrayLen(response.links) & " links");
arrayAppend(response.steps, "Identified " & arrayLen(orphanItems) & " orphan items for deletion");
response["OK"] = true;
} catch (any e) {
response["ERROR"] = e.message;
response["DETAIL"] = e.detail;
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -0,0 +1,60 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<!--- Only allow from localhost --->
<cfif NOT (cgi.remote_addr EQ "127.0.0.1" OR cgi.remote_addr EQ "::1" OR findNoCase("localhost", cgi.server_name))>
<cfoutput>#serializeJSON({"OK": false, "ERROR": "admin_only"})#</cfoutput>
<cfabort>
</cfif>
<cfscript>
/**
* Setup Modifier Templates system
* 1. Add ItemIsModifierTemplate column to Items
* 2. Create ItemTemplateLinks table
*/
response = { "OK": false, "steps": [] };
try {
// Step 1: Add ItemIsModifierTemplate column if it doesn't exist
try {
queryExecute("
ALTER TABLE Items ADD COLUMN ItemIsModifierTemplate TINYINT(1) DEFAULT 0
", {}, { datasource: "payfrit" });
arrayAppend(response.steps, "Added ItemIsModifierTemplate column");
} catch (any e) {
if (findNoCase("Duplicate column", e.message)) {
arrayAppend(response.steps, "ItemIsModifierTemplate column already exists");
} else {
arrayAppend(response.steps, "Error adding column: " & e.message);
}
}
// Step 2: Create ItemTemplateLinks table if it doesn't exist
try {
queryExecute("
CREATE TABLE IF NOT EXISTS ItemTemplateLinks (
LinkID INT AUTO_INCREMENT PRIMARY KEY,
ItemID INT NOT NULL,
TemplateItemID INT NOT NULL,
SortOrder INT DEFAULT 0,
UNIQUE KEY unique_link (ItemID, TemplateItemID),
INDEX idx_item (ItemID),
INDEX idx_template (TemplateItemID)
)
", {}, { datasource: "payfrit" });
arrayAppend(response.steps, "Created ItemTemplateLinks table");
} catch (any e) {
arrayAppend(response.steps, "ItemTemplateLinks: " & e.message);
}
response["OK"] = true;
} catch (any e) {
response["ERROR"] = e.message;
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -0,0 +1,81 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cffunction name="apiAbort" access="public" returntype="void" output="true">
<cfargument name="payload" type="struct" required="true">
<cfcontent type="application/json; charset=utf-8">
<cfoutput>#serializeJSON(arguments.payload)#</cfoutput>
<cfabort>
</cffunction>
<cftry>
<!--- Create Stations table if it doesn't exist --->
<cfset queryExecute("
CREATE TABLE IF NOT EXISTS Stations (
StationID INT AUTO_INCREMENT PRIMARY KEY,
StationBusinessID INT NOT NULL,
StationName VARCHAR(100) NOT NULL,
StationColor VARCHAR(7) DEFAULT '##666666',
StationSortOrder INT DEFAULT 0,
StationIsActive TINYINT(1) DEFAULT 1,
StationAddedOn DATETIME DEFAULT NOW(),
FOREIGN KEY (StationBusinessID) REFERENCES Businesses(BusinessID)
)
", [], { datasource = "payfrit" })>
<!--- Add ItemStationID column to Items table if it doesn't exist --->
<cftry>
<cfset queryExecute("
ALTER TABLE Items ADD COLUMN ItemStationID INT DEFAULT NULL
", [], { datasource = "payfrit" })>
<cfset stationColumnAdded = true>
<cfcatch>
<!--- Column likely already exists --->
<cfset stationColumnAdded = false>
</cfcatch>
</cftry>
<!--- Add foreign key if column was just added --->
<cfif stationColumnAdded>
<cftry>
<cfset queryExecute("
ALTER TABLE Items ADD FOREIGN KEY (ItemStationID) REFERENCES Stations(StationID)
", [], { datasource = "payfrit" })>
<cfcatch></cfcatch>
</cftry>
</cfif>
<!--- Create some default stations for business 1 (In and Out Burger) if none exist --->
<cfset qCheck = queryExecute("
SELECT COUNT(*) AS cnt FROM Stations WHERE StationBusinessID = 1
", [], { datasource = "payfrit" })>
<cfif qCheck.cnt EQ 0>
<cfset queryExecute("
INSERT INTO Stations (StationBusinessID, StationName, StationColor, StationSortOrder) VALUES
(1, 'Grill', '##FF5722', 1),
(1, 'Fry', '##FFC107', 2),
(1, 'Drinks', '##2196F3', 3),
(1, 'Expo', '##4CAF50', 4)
", [], { datasource = "payfrit" })>
<cfset defaultStationsCreated = true>
<cfelse>
<cfset defaultStationsCreated = false>
</cfif>
<cfset apiAbort({
"OK": true,
"MESSAGE": "Stations setup complete",
"StationColumnAdded": stationColumnAdded,
"DefaultStationsCreated": defaultStationsCreated
})>
<cfcatch>
<cfset apiAbort({
"OK": false,
"ERROR": "setup_failed",
"MESSAGE": cfcatch.message,
"DETAIL": cfcatch.detail
})>
</cfcatch>
</cftry>

View file

@ -0,0 +1,38 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// Switch all beacons from one business to another
fromBiz = 17; // In-N-Out
toBiz = 27; // Big Dean's
queryExecute("
UPDATE lt_Beacon_Businesses_ServicePoints
SET BusinessID = :toBiz
WHERE BusinessID = :fromBiz
", { toBiz: toBiz, fromBiz: fromBiz }, { datasource: "payfrit" });
// Get current state
q = queryExecute("
SELECT lt.*, b.BusinessName
FROM lt_Beacon_Businesses_ServicePoints lt
JOIN Businesses b ON b.BusinessID = lt.BusinessID
", {}, { datasource: "payfrit" });
rows = [];
for (row in q) {
arrayAppend(rows, {
"BeaconID": row.BeaconID,
"BusinessID": row.BusinessID,
"BusinessName": row.BusinessName,
"ServicePointID": row.ServicePointID
});
}
writeOutput(serializeJSON({
"OK": true,
"MESSAGE": "Switched beacons from BusinessID #fromBiz# to #toBiz#",
"MAPPINGS": rows
}));
</cfscript>

View file

@ -1,5 +1,7 @@
<cfsetting showdebugoutput="false"> <cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true"> <cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript> <cfscript>
/* /*

View file

@ -1,108 +1,318 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript> <cfscript>
// Get menu data formatted for the builder UI /**
// Input: BusinessID * Get Menu for Builder
// Output: { OK: true, MENU: { categories: [...] } } * Returns categories and items in structured format for the menu builder UI
*
param name="form.BusinessID" default="0"; * POST: { BusinessID: int }
param name="url.BusinessID" default="#form.BusinessID#"; *
* Unified schema:
businessID = val(url.BusinessID); * - Categories = Items at ParentID=0 that have menu items as children
* - Templates = Items at ParentID=0 that appear in ItemTemplateLinks
* - Menu items have ItemParentItemID pointing to their category
* - All items have ItemBusinessID for filtering
*/
response = { "OK": false }; response = { "OK": false };
try { try {
if (businessID == 0) { // Get request body
// Try to get from request body requestBody = toString(getHttpRequestData().content);
requestBody = toString(getHttpRequestData().content); requestData = {};
if (len(requestBody)) { if (len(requestBody)) {
jsonData = deserializeJSON(requestBody); requestData = deserializeJSON(requestBody);
businessID = val(jsonData.BusinessID ?: 0); }
}
businessID = 0;
if (structKeyExists(requestData, "BusinessID")) {
businessID = val(requestData.BusinessID);
} }
if (businessID == 0) { if (businessID == 0) {
throw("BusinessID is required"); response["ERROR"] = "missing_business_id";
response["MESSAGE"] = "BusinessID is required";
writeOutput(serializeJSON(response));
abort;
} }
// Get categories // Check if new schema is active (ItemBusinessID column exists and has data)
categories = queryExecute(" newSchemaActive = false;
SELECT CategoryID, CategoryName, CategoryDescription, CategorySortOrder try {
FROM Categories qCheck = queryExecute("
WHERE CategoryBusinessID = :businessID SELECT COUNT(*) as cnt FROM Items
ORDER BY CategorySortOrder, CategoryName WHERE ItemBusinessID = :businessID AND ItemBusinessID > 0
", { businessID: businessID }); ", { businessID: businessID }, { datasource: "payfrit" });
newSchemaActive = (qCheck.cnt > 0);
} catch (any e) {
newSchemaActive = false;
}
menuCategories = []; if (newSchemaActive) {
// NEW SCHEMA: Categories are Items at ParentID=0 with children (not in ItemTemplateLinks)
qCategories = queryExecute("
SELECT DISTINCT
p.ItemID as CategoryID,
p.ItemName as CategoryName,
p.ItemSortOrder
FROM Items p
INNER JOIN Items c ON c.ItemParentItemID = p.ItemID
WHERE p.ItemBusinessID = :businessID
AND p.ItemParentItemID = 0
AND p.ItemIsActive = 1
AND NOT EXISTS (
SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = p.ItemID
)
ORDER BY p.ItemSortOrder, p.ItemName
", { businessID: businessID }, { datasource: "payfrit" });
for (cat in categories) { // Get all menu items (children of category Items, not templates)
// Get items for this category (without Tasks join which may not exist) qItems = queryExecute("
items = queryExecute(" SELECT
SELECT i.ItemID, i.ItemName, i.ItemDescription, i.ItemPrice, i.ItemID,
i.ItemIsActive, i.ItemSortOrder i.ItemParentItemID as CategoryItemID,
i.ItemName,
i.ItemDescription,
i.ItemPrice,
i.ItemSortOrder,
i.ItemIsActive
FROM Items i FROM Items i
WHERE i.ItemCategoryID = :categoryID INNER JOIN Items cat ON cat.ItemID = i.ItemParentItemID
WHERE i.ItemBusinessID = :businessID
AND i.ItemIsActive = 1
AND cat.ItemParentItemID = 0
AND NOT EXISTS (
SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = cat.ItemID
)
ORDER BY i.ItemSortOrder, i.ItemName
", { businessID: businessID }, { datasource: "payfrit" });
} else {
// OLD SCHEMA: Use Categories table
qCategories = queryExecute("
SELECT
CategoryID,
CategoryName,
0 as ItemSortOrder
FROM Categories
WHERE CategoryBusinessID = :businessID
ORDER BY CategoryName
", { businessID: businessID }, { datasource: "payfrit" });
qItems = queryExecute("
SELECT
i.ItemID,
i.ItemCategoryID as CategoryItemID,
i.ItemName,
i.ItemDescription,
i.ItemPrice,
i.ItemSortOrder,
i.ItemIsActive
FROM Items i
INNER JOIN Categories c ON c.CategoryID = i.ItemCategoryID
WHERE c.CategoryBusinessID = :businessID
AND i.ItemIsActive = 1
AND i.ItemParentItemID = 0 AND i.ItemParentItemID = 0
ORDER BY i.ItemSortOrder, i.ItemName ORDER BY i.ItemSortOrder, i.ItemName
", { categoryID: cat.CategoryID }); ", { businessID: businessID }, { datasource: "payfrit" });
}
categoryItems = []; // Get template links (which templates are linked to which menu items)
qTemplateLinks = queryExecute("
SELECT
tl.ItemID as ParentItemID,
tl.TemplateItemID,
tl.SortOrder,
t.ItemName as TemplateName,
t.ItemPrice as TemplatePrice,
t.ItemIsCheckedByDefault as TemplateIsDefault
FROM ItemTemplateLinks tl
INNER JOIN Items t ON t.ItemID = tl.TemplateItemID
ORDER BY tl.ItemID, tl.SortOrder
", {}, { datasource: "payfrit" });
for (item in items) { // Get all templates for this business (items that appear in ItemTemplateLinks)
// Get modifiers for this item if (newSchemaActive) {
modifiers = queryExecute(" qTemplates = queryExecute("
SELECT ItemID, ItemName, ItemPrice, ItemIsCheckedByDefault, ItemSortOrder SELECT DISTINCT
FROM Items t.ItemID,
WHERE ItemParentItemID = :itemID t.ItemName,
ORDER BY ItemSortOrder, ItemName t.ItemPrice,
", { itemID: item.ItemID }); t.ItemIsCheckedByDefault as IsDefault,
t.ItemSortOrder,
t.ItemRequiresChildSelection as RequiresSelection,
t.ItemMaxNumSelectionReq as MaxSelections
FROM Items t
INNER JOIN ItemTemplateLinks tl ON tl.TemplateItemID = t.ItemID
INNER JOIN Items i ON i.ItemID = tl.ItemID
WHERE i.ItemBusinessID = :businessID
AND t.ItemIsActive = 1
ORDER BY t.ItemSortOrder, t.ItemName
", { businessID: businessID }, { datasource: "payfrit" });
} else {
qTemplates = queryExecute("
SELECT DISTINCT
t.ItemID,
t.ItemName,
t.ItemPrice,
t.ItemIsCheckedByDefault as IsDefault,
t.ItemSortOrder,
t.ItemRequiresChildSelection as RequiresSelection,
t.ItemMaxNumSelectionReq as MaxSelections
FROM Items t
INNER JOIN ItemTemplateLinks tl ON tl.TemplateItemID = t.ItemID
INNER JOIN Items i ON i.ItemID = tl.ItemID
INNER JOIN Categories c ON c.CategoryID = i.ItemCategoryID
WHERE c.CategoryBusinessID = :businessID
AND t.ItemIsActive = 1
ORDER BY t.ItemSortOrder, t.ItemName
", { businessID: businessID }, { datasource: "payfrit" });
}
itemModifiers = []; // Get all children of templates (options within modifier groups)
for (mod in modifiers) { // Filter by business and use DISTINCT to avoid duplicates from multiple template links
arrayAppend(itemModifiers, { qTemplateChildren = queryExecute("
"id": mod.ItemID, SELECT DISTINCT
"name": mod.ItemName, c.ItemID,
"price": mod.ItemPrice, c.ItemParentItemID as ParentItemID,
"isDefault": mod.ItemIsCheckedByDefault == 1, c.ItemName,
"sortOrder": mod.ItemSortOrder c.ItemPrice,
}); c.ItemIsCheckedByDefault as IsDefault,
} c.ItemSortOrder
FROM Items c
WHERE c.ItemParentItemID IN (
SELECT DISTINCT t.ItemID
FROM Items t
INNER JOIN ItemTemplateLinks tl ON tl.TemplateItemID = t.ItemID
INNER JOIN Items i ON i.ItemID = tl.ItemID
WHERE i.ItemBusinessID = :businessID
)
AND c.ItemIsActive = 1
ORDER BY c.ItemSortOrder, c.ItemName
", { businessID: businessID }, { datasource: "payfrit" });
arrayAppend(categoryItems, { // Build lookup of children by template ID
"id": item.ItemID, childrenByTemplate = {};
"name": item.ItemName, for (child in qTemplateChildren) {
"description": item.ItemDescription ?: "", parentID = child.ParentItemID;
"price": item.ItemPrice, if (!structKeyExists(childrenByTemplate, parentID)) {
"imageUrl": "", childrenByTemplate[parentID] = [];
"photoTaskId": "",
"modifiers": itemModifiers,
"sortOrder": item.ItemSortOrder
});
} }
arrayAppend(childrenByTemplate[parentID], {
arrayAppend(menuCategories, { "id": "opt_" & child.ItemID,
"id": cat.CategoryID, "dbId": child.ItemID,
"name": cat.CategoryName, "name": child.ItemName,
"description": cat.CategoryDescription ?: "", "price": child.ItemPrice,
"sortOrder": cat.CategorySortOrder, "isDefault": child.IsDefault == 1 ? true : false,
"items": categoryItems "sortOrder": child.ItemSortOrder,
"options": []
}); });
} }
response = { // Build template lookup with their children
"OK": true, templatesById = {};
"MENU": { for (tmpl in qTemplates) {
"categories": menuCategories templateID = tmpl.ItemID;
children = structKeyExists(childrenByTemplate, templateID) ? childrenByTemplate[templateID] : [];
templatesById[templateID] = {
"id": "mod_" & tmpl.ItemID,
"dbId": tmpl.ItemID,
"name": tmpl.ItemName,
"price": tmpl.ItemPrice,
"isDefault": tmpl.IsDefault == 1 ? true : false,
"sortOrder": tmpl.ItemSortOrder,
"isTemplate": true,
"requiresSelection": isNull(tmpl.RequiresSelection) ? false : (tmpl.RequiresSelection == 1),
"maxSelections": isNull(tmpl.MaxSelections) ? 0 : tmpl.MaxSelections,
"options": children
};
}
// Build modifier lookup by parent ItemID using template links
modifiersByItem = {};
for (link in qTemplateLinks) {
parentID = link.ParentItemID;
templateID = link.TemplateItemID;
if (!structKeyExists(modifiersByItem, parentID)) {
modifiersByItem[parentID] = [];
} }
};
if (structKeyExists(templatesById, templateID)) {
tmpl = duplicate(templatesById[templateID]);
tmpl["sortOrder"] = link.SortOrder;
arrayAppend(modifiersByItem[parentID], tmpl);
}
}
// Build items lookup by CategoryID
itemsByCategory = {};
for (item in qItems) {
catID = item.CategoryItemID;
if (!structKeyExists(itemsByCategory, catID)) {
itemsByCategory[catID] = [];
}
itemID = item.ItemID;
itemModifiers = structKeyExists(modifiersByItem, itemID) ? modifiersByItem[itemID] : [];
arrayAppend(itemsByCategory[catID], {
"id": "item_" & item.ItemID,
"dbId": item.ItemID,
"name": item.ItemName,
"description": isNull(item.ItemDescription) ? "" : item.ItemDescription,
"price": item.ItemPrice,
"imageUrl": javaCast("null", ""),
"photoTaskId": javaCast("null", ""),
"modifiers": itemModifiers,
"sortOrder": item.ItemSortOrder
});
}
// Build categories array
categories = [];
catIndex = 0;
for (cat in qCategories) {
catID = cat.CategoryID;
catItems = structKeyExists(itemsByCategory, catID) ? itemsByCategory[catID] : [];
arrayAppend(categories, {
"id": "cat_" & cat.CategoryID,
"dbId": cat.CategoryID,
"name": cat.CategoryName,
"description": "",
"sortOrder": catIndex,
"items": catItems
});
catIndex++;
}
// Build template library array for the UI
templateLibrary = [];
for (templateID in templatesById) {
arrayAppend(templateLibrary, templatesById[templateID]);
}
response["OK"] = true;
response["MENU"] = { "categories": categories };
response["TEMPLATES"] = templateLibrary;
response["CATEGORY_COUNT"] = arrayLen(categories);
response["TEMPLATE_COUNT"] = arrayLen(templateLibrary);
response["SCHEMA"] = newSchemaActive ? "unified" : "legacy";
totalItems = 0;
for (cat in categories) {
totalItems += arrayLen(cat.items);
}
response["ITEM_COUNT"] = totalItems;
} catch (any e) { } catch (any e) {
response = { response["ERROR"] = "server_error";
"OK": false, response["MESSAGE"] = e.message;
"ERROR": e.message,
"DETAIL": e.detail ?: ""
};
} }
cfheader(name="Content-Type", value="application/json");
writeOutput(serializeJSON(response)); writeOutput(serializeJSON(response));
</cfscript> </cfscript>

View file

@ -37,31 +37,106 @@
</cfif> </cfif>
<cftry> <cftry>
<cfset q = queryExecute( <!--- Check if new schema is active (ItemBusinessID column exists and has data) --->
" <cfset newSchemaActive = false>
SELECT <cftry>
i.ItemID, <cfset qCheck = queryExecute(
i.ItemCategoryID, "SELECT COUNT(*) as cnt FROM Items WHERE ItemBusinessID = ? AND ItemBusinessID > 0",
c.CategoryName, [ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
i.ItemName, { datasource = "payfrit" }
i.ItemDescription, )>
i.ItemParentItemID, <cfset newSchemaActive = (qCheck.cnt GT 0)>
i.ItemPrice, <cfcatch>
i.ItemIsActive, <!--- Column doesn't exist yet, use old schema --->
i.ItemIsCheckedByDefault, <cfset newSchemaActive = false>
i.ItemRequiresChildSelection, </cfcatch>
i.ItemMaxNumSelectionReq, </cftry>
i.ItemIsCollapsible,
i.ItemSortOrder <cfif newSchemaActive>
FROM Items i <!--- NEW SCHEMA: Categories are Items, templates derived from ItemTemplateLinks --->
INNER JOIN Categories c <cfset q = queryExecute(
ON c.CategoryID = i.ItemCategoryID "
WHERE c.CategoryBusinessID = ? SELECT
ORDER BY i.ItemParentItemID, i.ItemSortOrder, i.ItemID i.ItemID,
", CASE
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ], WHEN i.ItemParentItemID = 0 AND i.ItemIsCollapsible = 0 THEN i.ItemID
{ datasource = "payfrit" } ELSE COALESCE(
)> (SELECT cat.ItemID FROM Items cat
WHERE cat.ItemID = i.ItemParentItemID
AND cat.ItemParentItemID = 0
AND cat.ItemIsCollapsible = 0),
0
)
END as ItemCategoryID,
CASE
WHEN i.ItemParentItemID = 0 AND i.ItemIsCollapsible = 0 THEN i.ItemName
ELSE COALESCE(
(SELECT cat.ItemName FROM Items cat
WHERE cat.ItemID = i.ItemParentItemID
AND cat.ItemParentItemID = 0
AND cat.ItemIsCollapsible = 0),
''
)
END as CategoryName,
i.ItemName,
i.ItemDescription,
i.ItemParentItemID,
i.ItemPrice,
i.ItemIsActive,
i.ItemIsCheckedByDefault,
i.ItemRequiresChildSelection,
i.ItemMaxNumSelectionReq,
i.ItemIsCollapsible,
i.ItemSortOrder,
i.ItemStationID,
s.StationName,
s.StationColor
FROM Items i
LEFT JOIN Stations s ON s.StationID = i.ItemStationID
WHERE i.ItemBusinessID = ?
AND i.ItemIsActive = 1
AND NOT EXISTS (SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = i.ItemID)
AND NOT EXISTS (SELECT 1 FROM ItemTemplateLinks tl2 WHERE tl2.TemplateItemID = i.ItemParentItemID)
AND (
i.ItemParentItemID > 0
OR (i.ItemParentItemID = 0 AND i.ItemIsCollapsible = 0)
)
ORDER BY i.ItemParentItemID, i.ItemSortOrder, i.ItemID
",
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<cfelse>
<!--- OLD SCHEMA: Use Categories table --->
<cfset q = queryExecute(
"
SELECT
i.ItemID,
i.ItemCategoryID,
c.CategoryName,
i.ItemName,
i.ItemDescription,
i.ItemParentItemID,
i.ItemPrice,
i.ItemIsActive,
i.ItemIsCheckedByDefault,
i.ItemRequiresChildSelection,
i.ItemMaxNumSelectionReq,
i.ItemIsCollapsible,
i.ItemSortOrder,
i.ItemStationID,
s.StationName,
s.StationColor
FROM Items i
INNER JOIN Categories c ON c.CategoryID = i.ItemCategoryID
LEFT JOIN Stations s ON s.StationID = i.ItemStationID
WHERE c.CategoryBusinessID = ?
ORDER BY i.ItemParentItemID, i.ItemSortOrder, i.ItemID
",
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
</cfif>
<cfset rows = []> <cfset rows = []>
<cfloop query="q"> <cfloop query="q">
@ -78,15 +153,150 @@
"ItemRequiresChildSelection": q.ItemRequiresChildSelection, "ItemRequiresChildSelection": q.ItemRequiresChildSelection,
"ItemMaxNumSelectionReq": q.ItemMaxNumSelectionReq, "ItemMaxNumSelectionReq": q.ItemMaxNumSelectionReq,
"ItemIsCollapsible": q.ItemIsCollapsible, "ItemIsCollapsible": q.ItemIsCollapsible,
"ItemSortOrder": q.ItemSortOrder "ItemSortOrder": q.ItemSortOrder,
"ItemStationID": len(trim(q.ItemStationID)) ? q.ItemStationID : "",
"ItemStationName": len(trim(q.StationName)) ? q.StationName : "",
"ItemStationColor": len(trim(q.StationColor)) ? q.StationColor : ""
})> })>
</cfloop> </cfloop>
<!--- For unified schema: Add template-linked modifiers as virtual children of menu items --->
<cfif newSchemaActive>
<!--- Get template links: which menu items use which templates --->
<cfset qTemplateLinks = queryExecute(
"
SELECT
tl.ItemID as MenuItemID,
tmpl.ItemID as TemplateItemID,
tmpl.ItemName as TemplateName,
tmpl.ItemDescription as TemplateDescription,
tmpl.ItemRequiresChildSelection as TemplateRequired,
tmpl.ItemMaxNumSelectionReq as TemplateMaxSelections,
tmpl.ItemIsCollapsible as TemplateIsCollapsible,
tl.SortOrder as TemplateSortOrder
FROM ItemTemplateLinks tl
INNER JOIN Items tmpl ON tmpl.ItemID = tl.TemplateItemID AND tmpl.ItemIsActive = 1
INNER JOIN Items menuItem ON menuItem.ItemID = tl.ItemID
WHERE menuItem.ItemBusinessID = ?
AND menuItem.ItemIsActive = 1
ORDER BY tl.ItemID, tl.SortOrder
",
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<!--- Get template options --->
<cfset qTemplateOptions = queryExecute(
"
SELECT DISTINCT
opt.ItemID as OptionItemID,
opt.ItemParentItemID as TemplateItemID,
opt.ItemName as OptionName,
opt.ItemDescription as OptionDescription,
opt.ItemPrice as OptionPrice,
opt.ItemIsCheckedByDefault as OptionIsDefault,
opt.ItemSortOrder as OptionSortOrder
FROM Items opt
INNER JOIN ItemTemplateLinks tl ON tl.TemplateItemID = opt.ItemParentItemID
INNER JOIN Items menuItem ON menuItem.ItemID = tl.ItemID
WHERE menuItem.ItemBusinessID = ?
AND menuItem.ItemIsActive = 1
AND opt.ItemIsActive = 1
ORDER BY opt.ItemParentItemID, opt.ItemSortOrder
",
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<!--- Build template options map: templateID -> [options] --->
<cfset templateOptionsMap = {}>
<cfloop query="qTemplateOptions">
<cfif NOT structKeyExists(templateOptionsMap, qTemplateOptions.TemplateItemID)>
<cfset templateOptionsMap[qTemplateOptions.TemplateItemID] = []>
</cfif>
<cfset arrayAppend(templateOptionsMap[qTemplateOptions.TemplateItemID], {
"ItemID": qTemplateOptions.OptionItemID,
"ItemName": qTemplateOptions.OptionName,
"ItemDescription": qTemplateOptions.OptionDescription,
"ItemPrice": qTemplateOptions.OptionPrice,
"ItemIsCheckedByDefault": qTemplateOptions.OptionIsDefault,
"ItemSortOrder": qTemplateOptions.OptionSortOrder
})>
</cfloop>
<!--- Add templates and their options as virtual children --->
<!--- Use virtual IDs to make each template instance unique per menu item --->
<!--- Virtual ID format: menuItemID * 100000 + templateID for templates --->
<!--- menuItemID * 100000 + optionID for options --->
<cfset addedTemplates = {}>
<cfloop query="qTemplateLinks">
<cfset menuItemID = qTemplateLinks.MenuItemID>
<cfset templateID = qTemplateLinks.TemplateItemID>
<cfset linkKey = menuItemID & "_" & templateID>
<!--- Skip duplicates --->
<cfif structKeyExists(addedTemplates, linkKey)>
<cfcontinue>
</cfif>
<cfset addedTemplates[linkKey] = true>
<!--- Generate unique virtual ID for this template instance --->
<cfset virtualTemplateID = menuItemID * 100000 + templateID>
<!--- Add template as modifier group (child of menu item) --->
<cfset arrayAppend(rows, {
"ItemID": virtualTemplateID,
"ItemCategoryID": 0,
"ItemCategoryName": "",
"ItemName": qTemplateLinks.TemplateName,
"ItemDescription": qTemplateLinks.TemplateDescription,
"ItemParentItemID": menuItemID,
"ItemPrice": 0,
"ItemIsActive": 1,
"ItemIsCheckedByDefault": 0,
"ItemRequiresChildSelection": qTemplateLinks.TemplateRequired,
"ItemMaxNumSelectionReq": qTemplateLinks.TemplateMaxSelections,
"ItemIsCollapsible": qTemplateLinks.TemplateIsCollapsible,
"ItemSortOrder": qTemplateLinks.TemplateSortOrder,
"ItemStationID": "",
"ItemStationName": "",
"ItemStationColor": ""
})>
<!--- Add template options as children of virtual template --->
<cfif structKeyExists(templateOptionsMap, templateID)>
<cfloop array="#templateOptionsMap[templateID]#" index="opt">
<!--- Generate unique virtual ID for this option instance --->
<cfset virtualOptionID = menuItemID * 100000 + opt.ItemID>
<cfset arrayAppend(rows, {
"ItemID": virtualOptionID,
"ItemCategoryID": 0,
"ItemCategoryName": "",
"ItemName": opt.ItemName,
"ItemDescription": opt.ItemDescription,
"ItemParentItemID": virtualTemplateID,
"ItemPrice": opt.ItemPrice,
"ItemIsActive": 1,
"ItemIsCheckedByDefault": opt.ItemIsCheckedByDefault,
"ItemRequiresChildSelection": 0,
"ItemMaxNumSelectionReq": 0,
"ItemIsCollapsible": 0,
"ItemSortOrder": opt.ItemSortOrder,
"ItemStationID": "",
"ItemStationName": "",
"ItemStationColor": ""
})>
</cfloop>
</cfif>
</cfloop>
</cfif>
<cfset apiAbort({ <cfset apiAbort({
"OK": true, "OK": true,
"ERROR": "", "ERROR": "",
"Items": rows, "Items": rows,
"COUNT": arrayLen(rows) "COUNT": arrayLen(rows),
"SCHEMA": newSchemaActive ? "unified" : "legacy"
})> })>
<cfcatch> <cfcatch>

View file

@ -2,6 +2,8 @@
// Save menu data from the builder UI // Save menu data from the builder UI
// Input: BusinessID, Menu (JSON structure) // Input: BusinessID, Menu (JSON structure)
// Output: { OK: true } // Output: { OK: true }
//
// Supports both old schema (Categories table) and new unified schema (Categories as Items)
response = { "OK": false }; response = { "OK": false };
@ -23,90 +25,206 @@ try {
throw("Menu categories are required"); throw("Menu categories are required");
} }
// Check if new schema is active (ItemBusinessID column exists and has data)
newSchemaActive = false;
try {
qCheck = queryExecute("
SELECT COUNT(*) as cnt FROM Items
WHERE ItemBusinessID = :businessID AND ItemBusinessID > 0
", { businessID: businessID });
newSchemaActive = (qCheck.cnt > 0);
} catch (any e) {
newSchemaActive = false;
}
// Process each category // Process each category
for (cat in menu.categories) { for (cat in menu.categories) {
categoryID = 0; categoryID = 0;
categoryDbId = structKeyExists(cat, "dbId") ? val(cat.dbId) : 0;
// Check if it's an existing category (numeric ID) or new (temp_ prefix) if (newSchemaActive) {
if (isNumeric(cat.id)) { // NEW SCHEMA: Categories are Items with ParentID=0 and Template=0
categoryID = val(cat.id); if (categoryDbId > 0) {
// Update existing category categoryID = categoryDbId;
queryExecute(" // Update existing category Item
UPDATE Categories queryExecute("
SET CategoryName = :name, UPDATE Items
CategoryDescription = :description, SET ItemName = :name,
CategorySortOrder = :sortOrder ItemSortOrder = :sortOrder
WHERE CategoryID = :categoryID WHERE ItemID = :categoryID
", { AND ItemBusinessID = :businessID
categoryID: categoryID, ", {
name: cat.name, categoryID: categoryID,
description: cat.description ?: "", businessID: businessID,
sortOrder: val(cat.sortOrder ?: 0) name: cat.name,
}); sortOrder: val(cat.sortOrder ?: 0)
});
} else {
// Insert new category as Item
queryExecute("
INSERT INTO Items (
ItemBusinessID, ItemName, ItemDescription,
ItemParentItemID, ItemPrice, ItemIsActive,
ItemSortOrder, ItemIsModifierTemplate, ItemAddedOn
) VALUES (
:businessID, :name, '',
0, 0, 1,
:sortOrder, 0, NOW()
)
", {
businessID: businessID,
name: cat.name,
sortOrder: val(cat.sortOrder ?: 0)
});
result = queryExecute("SELECT LAST_INSERT_ID() as newID");
categoryID = result.newID;
}
} else { } else {
// Insert new category // OLD SCHEMA: Use Categories table
queryExecute(" if (categoryDbId > 0) {
INSERT INTO Categories (CategoryBusinessID, CategoryName, CategoryDescription, CategorySortOrder) categoryID = categoryDbId;
VALUES (:businessID, :name, :description, :sortOrder) queryExecute("
", { UPDATE Categories
businessID: businessID, SET CategoryName = :name,
name: cat.name, CategoryDescription = :description,
description: cat.description ?: "", CategorySortOrder = :sortOrder
sortOrder: val(cat.sortOrder ?: 0) WHERE CategoryID = :categoryID
}); ", {
categoryID: categoryID,
name: cat.name,
description: cat.description ?: "",
sortOrder: val(cat.sortOrder ?: 0)
});
} else {
queryExecute("
INSERT INTO Categories (CategoryBusinessID, CategoryName, CategoryDescription, CategorySortOrder)
VALUES (:businessID, :name, :description, :sortOrder)
", {
businessID: businessID,
name: cat.name,
description: cat.description ?: "",
sortOrder: val(cat.sortOrder ?: 0)
});
// Get the new category ID result = queryExecute("SELECT LAST_INSERT_ID() as newID");
result = queryExecute("SELECT LAST_INSERT_ID() as newID"); categoryID = result.newID;
categoryID = result.newID; }
} }
// Process items in this category // Process items in this category
if (structKeyExists(cat, "items") && isArray(cat.items)) { if (structKeyExists(cat, "items") && isArray(cat.items)) {
for (item in cat.items) { for (item in cat.items) {
itemID = 0; itemID = 0;
itemDbId = structKeyExists(item, "dbId") ? val(item.dbId) : 0;
if (isNumeric(item.id)) { if (itemDbId > 0) {
itemID = val(item.id); itemID = itemDbId;
// Update existing item (without ImageURL which may not exist)
queryExecute(" if (newSchemaActive) {
UPDATE Items // Update existing item - set parent to category Item
SET ItemName = :name, queryExecute("
ItemDescription = :description, UPDATE Items
ItemPrice = :price, SET ItemName = :name,
ItemCategoryID = :categoryID, ItemDescription = :description,
ItemSortOrder = :sortOrder ItemPrice = :price,
WHERE ItemID = :itemID ItemParentItemID = :categoryID,
", { ItemSortOrder = :sortOrder
itemID: itemID, WHERE ItemID = :itemID
name: item.name, ", {
description: item.description ?: "", itemID: itemID,
price: val(item.price ?: 0), name: item.name,
categoryID: categoryID, description: item.description ?: "",
sortOrder: val(item.sortOrder ?: 0) price: val(item.price ?: 0),
}); categoryID: categoryID,
sortOrder: val(item.sortOrder ?: 0)
});
} else {
// Update existing item - old schema with CategoryID
queryExecute("
UPDATE Items
SET ItemName = :name,
ItemDescription = :description,
ItemPrice = :price,
ItemCategoryID = :categoryID,
ItemSortOrder = :sortOrder
WHERE ItemID = :itemID
", {
itemID: itemID,
name: item.name,
description: item.description ?: "",
price: val(item.price ?: 0),
categoryID: categoryID,
sortOrder: val(item.sortOrder ?: 0)
});
}
} else { } else {
// Insert new item (without ImageURL which may not exist) // Insert new item
queryExecute(" if (newSchemaActive) {
INSERT INTO Items (ItemBusinessID, ItemCategoryID, ItemName, ItemDescription, ItemPrice, ItemSortOrder, ItemIsActive, ItemParentItemID) queryExecute("
VALUES (:businessID, :categoryID, :name, :description, :price, :sortOrder, 1, 0) INSERT INTO Items (
", { ItemBusinessID, ItemParentItemID, ItemName, ItemDescription,
businessID: businessID, ItemPrice, ItemSortOrder, ItemIsActive, ItemAddedOn
categoryID: categoryID, ) VALUES (
name: item.name, :businessID, :categoryID, :name, :description,
description: item.description ?: "", :price, :sortOrder, 1, NOW()
price: val(item.price ?: 0), )
sortOrder: val(item.sortOrder ?: 0) ", {
}); businessID: businessID,
categoryID: categoryID,
name: item.name,
description: item.description ?: "",
price: val(item.price ?: 0),
sortOrder: val(item.sortOrder ?: 0)
});
} else {
queryExecute("
INSERT INTO Items (
ItemBusinessID, ItemCategoryID, ItemName, ItemDescription,
ItemPrice, ItemSortOrder, ItemIsActive, ItemParentItemID
) VALUES (
:businessID, :categoryID, :name, :description,
:price, :sortOrder, 1, 0
)
", {
businessID: businessID,
categoryID: categoryID,
name: item.name,
description: item.description ?: "",
price: val(item.price ?: 0),
sortOrder: val(item.sortOrder ?: 0)
});
}
result = queryExecute("SELECT LAST_INSERT_ID() as newID"); result = queryExecute("SELECT LAST_INSERT_ID() as newID");
itemID = result.newID; itemID = result.newID;
} }
// Process modifiers for this item // Handle template links for modifiers
if (structKeyExists(item, "modifiers") && isArray(item.modifiers)) { if (structKeyExists(item, "modifiers") && isArray(item.modifiers)) {
// Clear existing template links for this item
queryExecute("
DELETE FROM ItemTemplateLinks WHERE ItemID = :itemID
", { itemID: itemID });
modSortOrder = 0;
for (mod in item.modifiers) { for (mod in item.modifiers) {
if (isNumeric(mod.id)) { modDbId = structKeyExists(mod, "dbId") ? val(mod.dbId) : 0;
// Update existing modifier
// Check if this is a template reference
if (structKeyExists(mod, "isTemplate") && mod.isTemplate && modDbId > 0) {
// Create template link
queryExecute("
INSERT INTO ItemTemplateLinks (ItemID, TemplateItemID, SortOrder)
VALUES (:itemID, :templateID, :sortOrder)
ON DUPLICATE KEY UPDATE SortOrder = :sortOrder
", {
itemID: itemID,
templateID: modDbId,
sortOrder: modSortOrder
});
} else if (modDbId > 0) {
// Update existing direct modifier
queryExecute(" queryExecute("
UPDATE Items UPDATE Items
SET ItemName = :name, SET ItemName = :name,
@ -115,34 +233,39 @@ try {
ItemSortOrder = :sortOrder ItemSortOrder = :sortOrder
WHERE ItemID = :modID WHERE ItemID = :modID
", { ", {
modID: val(mod.id), modID: modDbId,
name: mod.name, name: mod.name,
price: val(mod.price ?: 0), price: val(mod.price ?: 0),
isDefault: (mod.isDefault ?: false) ? 1 : 0, isDefault: (mod.isDefault ?: false) ? 1 : 0,
sortOrder: val(mod.sortOrder ?: 0) sortOrder: modSortOrder
}); });
} else { } else {
// Insert new modifier // Insert new direct modifier (non-template)
queryExecute(" queryExecute("
INSERT INTO Items (ItemBusinessID, ItemCategoryID, ItemParentItemID, ItemName, ItemPrice, ItemIsCheckedByDefault, ItemSortOrder, ItemIsActive) INSERT INTO Items (
VALUES (:businessID, :categoryID, :parentID, :name, :price, :isDefault, :sortOrder, 1) ItemBusinessID, ItemParentItemID, ItemName, ItemPrice,
ItemIsCheckedByDefault, ItemSortOrder, ItemIsActive, ItemAddedOn
) VALUES (
:businessID, :parentID, :name, :price,
:isDefault, :sortOrder, 1, NOW()
)
", { ", {
businessID: businessID, businessID: businessID,
categoryID: categoryID,
parentID: itemID, parentID: itemID,
name: mod.name, name: mod.name,
price: val(mod.price ?: 0), price: val(mod.price ?: 0),
isDefault: (mod.isDefault ?: false) ? 1 : 0, isDefault: (mod.isDefault ?: false) ? 1 : 0,
sortOrder: val(mod.sortOrder ?: 0) sortOrder: modSortOrder
}); });
} }
modSortOrder++;
} }
} }
} }
} }
} }
response = { "OK": true }; response = { "OK": true, "SCHEMA": newSchemaActive ? "unified" : "legacy" };
} catch (any e) { } catch (any e) {
response = { response = {

View file

@ -0,0 +1,78 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cffunction name="readJsonBody" access="public" returntype="struct" output="false">
<cfset var raw = getHttpRequestData().content>
<cfif isNull(raw) OR len(trim(raw)) EQ 0>
<cfreturn {}>
</cfif>
<cftry>
<cfset var data = deserializeJSON(raw)>
<cfif isStruct(data)>
<cfreturn data>
<cfelse>
<cfreturn {}>
</cfif>
<cfcatch>
<cfreturn {}>
</cfcatch>
</cftry>
</cffunction>
<cffunction name="apiAbort" access="public" returntype="void" output="true">
<cfargument name="payload" type="struct" required="true">
<cfcontent type="application/json; charset=utf-8">
<cfoutput>#serializeJSON(arguments.payload)#</cfoutput>
<cfabort>
</cffunction>
<cfset data = readJsonBody()>
<cfset BusinessID = val( structKeyExists(data,"BusinessID") ? data.BusinessID : 0 )>
<cfset Assignments = structKeyExists(data,"Assignments") ? data.Assignments : []>
<cfif BusinessID LTE 0>
<cfset apiAbort({ "OK": false, "ERROR": "missing_businessid", "MESSAGE": "BusinessID is required." })>
</cfif>
<cftry>
<cfset updateCount = 0>
<!--- First, clear all station assignments for items in this business --->
<cfset queryExecute("
UPDATE Items i
INNER JOIN Categories c ON c.CategoryID = i.ItemCategoryID
SET i.ItemStationID = NULL
WHERE c.CategoryBusinessID = ?
", [ { value = BusinessID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
<!--- Then apply the new assignments --->
<cfloop array="#Assignments#" index="assignment">
<cfif structKeyExists(assignment, "ItemID") AND structKeyExists(assignment, "StationID")>
<cfset queryExecute("
UPDATE Items
SET ItemStationID = ?
WHERE ItemID = ?
", [
{ value = assignment.StationID, cfsqltype = "cf_sql_integer" },
{ value = assignment.ItemID, cfsqltype = "cf_sql_integer" }
], { datasource = "payfrit" })>
<cfset updateCount++>
</cfif>
</cfloop>
<cfset apiAbort({
"OK": true,
"ERROR": "",
"MESSAGE": "Station assignments updated",
"UPDATED_COUNT": updateCount
})>
<cfcatch>
<cfset apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": "Error updating stations",
"DETAIL": cfcatch.message
})>
</cfcatch>
</cftry>

View file

@ -0,0 +1,75 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Get Businesses for User
* Returns list of businesses the authenticated user has access to
*
* POST: { UserID: int }
* Headers: X-User-Token (optional - will use session if not provided)
*/
response = { "OK": false };
try {
// Get UserID from request or session
userID = 0;
// Check token auth first
if (structKeyExists(request, "UserID") && isNumeric(request.UserID)) {
userID = int(request.UserID);
}
// Also check request body
if (userID == 0) {
requestBody = toString(getHttpRequestData().content);
if (len(requestBody)) {
requestData = deserializeJSON(requestBody);
if (structKeyExists(requestData, "UserID")) {
userID = val(requestData.UserID);
}
}
}
if (userID == 0) {
response["ERROR"] = "not_logged_in";
response["MESSAGE"] = "User not authenticated";
writeOutput(serializeJSON(response));
abort;
}
// Get businesses for this user
// Users are linked to businesses via BusinessUserID field (owner)
q = queryExecute("
SELECT
b.BusinessID,
b.BusinessName
FROM Businesses b
WHERE b.BusinessUserID = :userID
ORDER BY b.BusinessName
", { userID: userID }, { datasource: "payfrit" });
businesses = [];
for (row in q) {
arrayAppend(businesses, {
"BusinessID": row.BusinessID,
"BusinessName": row.BusinessName
});
}
response["OK"] = true;
response["BUSINESSES"] = businesses;
response["COUNT"] = arrayLen(businesses);
} catch (any e) {
response["ERROR"] = "server_error";
response["MESSAGE"] = e.message;
}
writeOutput(serializeJSON(response));
</cfscript>

415
api/setup/analyzeMenu.cfm Normal file
View file

@ -0,0 +1,415 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
/**
* Analyze Menu for Modifier Patterns
*
* Parses item descriptions to detect modifier groups and suggests
* which templates should be created and applied.
*
* Detection patterns:
* - Size variants: "Small available", "1/2 order available", "Small/Large"
* - Protein choices: "With Chicken / With Steak or Shrimp"
* - Required choices: "Boneless or bone in"
* - Category-wide add-ons: "Add to any salad: ..."
* - Price modifiers: "Add cheese +$0.50"
*
* POST JSON:
* {
* "categories": [
* {
* "name": "Burgers",
* "categoryNote": "All burgers served with lettuce, tomato...",
* "items": [
* { "name": "Burger", "description": "Served with fries" }
* ]
* }
* ]
* }
*
* Returns analyzed menu with detected modifiers and suggestions
*/
response = { "OK": false };
// Pattern definitions for modifier detection
patterns = {
// Size patterns
"size_small_available": {
"regex": "(?i)\bsmall\s+(available|option)\b",
"templateName": "Size",
"options": [
{ "name": "Regular", "price": 0, "isDefault": true },
{ "name": "Small", "price": 0, "isDefault": false }
],
"required": true,
"maxSelections": 1
},
"size_half_available": {
"regex": "(?i)\b(1/2|half)\s+(order\s+)?(available|option)\b",
"templateName": "Size",
"options": [
{ "name": "Full", "price": 0, "isDefault": true },
{ "name": "Half", "price": 0, "isDefault": false }
],
"required": true,
"maxSelections": 1
},
// Choice patterns (X or Y)
"choice_or": {
"regex": "(?i)\b(\w+)\s+or\s+(\w+)\b(?!\s+shrimp)",
"templateName": "auto", // Will be generated from matched words
"required": true,
"maxSelections": 1
},
// Protein add-ons
"protein_chicken_steak_shrimp": {
"regex": "(?i)with\s+chicken\s*/?\s*(?:with\s+)?(?:steak\s+or\s+shrimp|steak\/shrimp)",
"templateName": "Add Protein",
"options": [
{ "name": "Plain", "price": 0, "isDefault": true },
{ "name": "With Chicken", "price": 0, "isDefault": false },
{ "name": "With Steak or Shrimp", "price": 0, "isDefault": false }
],
"required": false,
"maxSelections": 1
},
// Add-on with price
"addon_with_price": {
"regex": "(?i)add\s+(\w+(?:\s+\w+)?)\s*[\+\$]?\s*\.?(\d+(?:\.\d{2})?)",
"templateName": "Add-ons",
"required": false,
"maxSelections": 0
},
// Category note patterns
"salad_addons": {
"regex": "(?i)add\s+(?:the\s+following\s+)?to\s+any\s+salad[:\s]+(.+)",
"templateName": "Add Protein",
"appliesTo": "category",
"required": false,
"maxSelections": 1
}
};
try {
requestBody = toString(getHttpRequestData().content);
if (!len(requestBody)) {
throw(message="No request body provided");
}
data = deserializeJSON(requestBody);
categories = structKeyExists(data, "categories") ? data.categories : [];
// Track detected templates and their usage
detectedTemplates = {};
itemModifiers = {}; // Maps item name to array of template IDs
// Analyze each category
analyzedCategories = [];
for (cat in categories) {
catName = cat.name;
catNote = structKeyExists(cat, "categoryNote") ? cat.categoryNote : "";
items = structKeyExists(cat, "items") ? cat.items : [];
// Check for category-wide patterns in the note
categoryTemplates = [];
// Check for "Add to any salad" pattern
if (reFindNoCase("add\s+(?:the\s+following\s+)?to\s+any\s+(salad|item)", catNote)) {
// Extract the add-on items
addOnMatch = reMatchNoCase("add[^:]+:\s*(.+)", catNote);
if (arrayLen(addOnMatch)) {
addOnText = addOnMatch[1];
// Split by comma
addOnItems = listToArray(addOnText, ",");
templateID = "cat_" & lCase(reReplace(catName, "\W+", "_", "all")) & "_addons";
if (!structKeyExists(detectedTemplates, templateID)) {
options = [{ "name": "None", "price": 0, "isDefault": true }];
for (addon in addOnItems) {
addon = trim(addon);
if (len(addon)) {
arrayAppend(options, { "name": addon, "price": 0, "isDefault": false });
}
}
detectedTemplates[templateID] = {
"id": templateID,
"name": "Add Protein",
"options": options,
"required": false,
"maxSelections": 1,
"detectedFrom": "Category note: " & catName,
"appliesTo": []
};
}
arrayAppend(categoryTemplates, templateID);
}
}
// Analyze each item
analyzedItems = [];
for (item in items) {
itemName = item.name;
itemDesc = structKeyExists(item, "description") ? item.description : "";
itemMods = [];
// Apply category-wide templates
for (catTmpl in categoryTemplates) {
arrayAppend(itemMods, catTmpl);
arrayAppend(detectedTemplates[catTmpl].appliesTo, itemName);
}
// Check for size patterns
if (reFindNoCase("\bsmall\s*(available|option)?\b", itemDesc) ||
reFindNoCase("\bsmall\b", itemName)) {
templateID = "size_regular_small";
if (!structKeyExists(detectedTemplates, templateID)) {
detectedTemplates[templateID] = {
"id": templateID,
"name": "Size",
"options": [
{ "name": "Regular", "price": 0, "isDefault": true },
{ "name": "Small", "price": 0, "isDefault": false }
],
"required": true,
"maxSelections": 1,
"detectedFrom": "Pattern: 'Small available'",
"appliesTo": []
};
}
arrayAppend(itemMods, templateID);
arrayAppend(detectedTemplates[templateID].appliesTo, itemName);
}
// Check for half order pattern
if (reFindNoCase("\b(1/2|half)\s*(order)?\s*(available)?\b", itemDesc)) {
templateID = "size_full_half";
if (!structKeyExists(detectedTemplates, templateID)) {
detectedTemplates[templateID] = {
"id": templateID,
"name": "Size",
"options": [
{ "name": "Full Order", "price": 0, "isDefault": true },
{ "name": "Half Order", "price": 0, "isDefault": false }
],
"required": true,
"maxSelections": 1,
"detectedFrom": "Pattern: '1/2 order available'",
"appliesTo": []
};
}
arrayAppend(itemMods, templateID);
arrayAppend(detectedTemplates[templateID].appliesTo, itemName);
}
// Check for "boneless or bone in" pattern
if (reFindNoCase("\bboneless\s+(or|/)\s*bone\s*-?\s*in\b", itemDesc)) {
templateID = "wing_style";
if (!structKeyExists(detectedTemplates, templateID)) {
detectedTemplates[templateID] = {
"id": templateID,
"name": "Style",
"options": [
{ "name": "Bone-In", "price": 0, "isDefault": true },
{ "name": "Boneless", "price": 0, "isDefault": false }
],
"required": true,
"maxSelections": 1,
"detectedFrom": "Pattern: 'Boneless or bone in'",
"appliesTo": []
};
}
arrayAppend(itemMods, templateID);
arrayAppend(detectedTemplates[templateID].appliesTo, itemName);
}
// Check for protein add-on pattern (With Chicken / With Steak or Shrimp)
if (reFindNoCase("with\s+chicken.*(?:steak|shrimp)", itemDesc)) {
templateID = "protein_addon";
if (!structKeyExists(detectedTemplates, templateID)) {
detectedTemplates[templateID] = {
"id": templateID,
"name": "Add Protein",
"options": [
{ "name": "Plain", "price": 0, "isDefault": true },
{ "name": "With Chicken", "price": 0, "isDefault": false },
{ "name": "With Steak or Shrimp", "price": 0, "isDefault": false }
],
"required": false,
"maxSelections": 1,
"detectedFrom": "Pattern: 'With Chicken / With Steak or Shrimp'",
"appliesTo": []
};
}
arrayAppend(itemMods, templateID);
arrayAppend(detectedTemplates[templateID].appliesTo, itemName);
}
// Check for "add X +$Y" pattern
priceMatch = reMatchNoCase("add\s+(\w+(?:\s+(?:and\s+)?\w+)?)\s*[\.\+]?\s*(\d+(?:\.\d{2})?)", itemDesc);
if (arrayLen(priceMatch)) {
for (match in priceMatch) {
// Parse the match to get item name and price
parts = reMatchNoCase("add\s+(.+?)\s*[\.\+]?\s*(\d+(?:\.\d{2})?)\s*$", match);
if (arrayLen(parts)) {
templateID = "addon_" & lCase(reReplace(itemName, "\W+", "_", "all"));
if (!structKeyExists(detectedTemplates, templateID)) {
detectedTemplates[templateID] = {
"id": templateID,
"name": "Add-ons",
"options": [],
"required": false,
"maxSelections": 0,
"detectedFrom": "Pattern: 'Add X +$Y' in " & itemName,
"appliesTo": [],
"needsPricing": true
};
}
arrayAppend(itemMods, templateID);
arrayAppend(detectedTemplates[templateID].appliesTo, itemName);
}
}
}
// Check for "with guacamole" type optional add-ons
if (reFindNoCase("\bwith\s+guacamole\b", itemDesc)) {
templateID = "addon_guacamole";
if (!structKeyExists(detectedTemplates, templateID)) {
detectedTemplates[templateID] = {
"id": templateID,
"name": "Add Guacamole",
"options": [
{ "name": "No Guacamole", "price": 0, "isDefault": true },
{ "name": "Add Guacamole", "price": 0, "isDefault": false }
],
"required": false,
"maxSelections": 1,
"detectedFrom": "Pattern: 'With guacamole'",
"appliesTo": [],
"needsPricing": true
};
}
arrayAppend(itemMods, templateID);
arrayAppend(detectedTemplates[templateID].appliesTo, itemName);
}
// Store item with detected modifiers
arrayAppend(analyzedItems, {
"name": itemName,
"description": itemDesc,
"price": structKeyExists(item, "price") ? item.price : 0,
"detectedModifiers": itemMods,
"originalDescription": itemDesc
});
}
arrayAppend(analyzedCategories, {
"name": catName,
"categoryNote": catNote,
"items": analyzedItems,
"categoryWideTemplates": categoryTemplates
});
}
// Build template array sorted by usage count
templateArray = [];
for (key in detectedTemplates) {
tmpl = detectedTemplates[key];
tmpl["usageCount"] = arrayLen(tmpl.appliesTo);
arrayAppend(templateArray, tmpl);
}
// Sort by usage count descending
arraySort(templateArray, function(a, b) {
return b.usageCount - a.usageCount;
});
// Generate questions for owner confirmation
questions = [];
// Question about detected templates
if (arrayLen(templateArray)) {
arrayAppend(questions, {
"type": "confirm_templates",
"question": "I detected these modifier groups. Are they correct?",
"templates": templateArray
});
}
// Question about items needing pricing
itemsNeedingPrices = [];
for (cat in analyzedCategories) {
for (item in cat.items) {
if (item.price == 0) {
arrayAppend(itemsNeedingPrices, {
"category": cat.name,
"item": item.name
});
}
}
}
if (arrayLen(itemsNeedingPrices)) {
arrayAppend(questions, {
"type": "pricing_needed",
"question": "These items need prices:",
"items": itemsNeedingPrices
});
}
// Question about template options needing pricing
templatesNeedingPrices = [];
for (tmpl in templateArray) {
if (structKeyExists(tmpl, "needsPricing") && tmpl.needsPricing) {
arrayAppend(templatesNeedingPrices, {
"templateId": tmpl.id,
"templateName": tmpl.name,
"appliesTo": tmpl.appliesTo
});
}
}
if (arrayLen(templatesNeedingPrices)) {
arrayAppend(questions, {
"type": "modifier_pricing_needed",
"question": "These add-on options need prices:",
"templates": templatesNeedingPrices
});
}
response.OK = true;
response.analyzedMenu = {
"categories": analyzedCategories,
"detectedTemplates": templateArray,
"totalItems": 0,
"totalTemplates": arrayLen(templateArray),
"totalModifierLinks": 0
};
// Count totals
totalItems = 0;
totalLinks = 0;
for (cat in analyzedCategories) {
totalItems += arrayLen(cat.items);
for (item in cat.items) {
totalLinks += arrayLen(item.detectedModifiers);
}
}
response.analyzedMenu.totalItems = totalItems;
response.analyzedMenu.totalModifierLinks = totalLinks;
response.questions = questions;
response.summary = "Analyzed " & totalItems & " items, detected " & arrayLen(templateArray) & " modifier templates with " & totalLinks & " links";
} catch (any e) {
response.error = e.message;
if (len(e.detail)) {
response.errorDetail = e.detail;
}
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -0,0 +1,341 @@
{
"business": {
"name": "Big Dean's Ocean Front Cafe",
"address": "1615 Ocean Front Walk",
"city": "Santa Monica",
"state": "CA",
"zip": "90401",
"phone": "(310) 393-2666",
"email": "mack@payfrit.com",
"ownerPhone": "9494442935",
"website": "https://bigdeansoceanfrontcafe.com",
"logoUrl": "https://bigdeansoceanfrontcafe.com/wp-content/uploads/2025/04/Logo_Smaller_92x87.png",
"headerUrl": "https://bigdeansoceanfrontcafe.com/wp-content/uploads/2025/04/TopBanner___BIGDEANS___.jpg"
},
"modifierTemplates": [
{
"id": "wing_style",
"name": "Wing Style",
"required": true,
"maxSelections": 1,
"options": [
{ "name": "Bone-In", "price": 0, "isDefault": true },
{ "name": "Boneless", "price": 0, "isDefault": false }
]
},
{
"id": "size_regular_small",
"name": "Size",
"required": true,
"maxSelections": 1,
"options": [
{ "name": "Regular", "price": 0, "isDefault": true },
{ "name": "Small", "price": 0, "isDefault": false }
]
},
{
"id": "size_full_half",
"name": "Size",
"required": true,
"maxSelections": 1,
"options": [
{ "name": "Full Order", "price": 0, "isDefault": true },
{ "name": "Half Order", "price": 0, "isDefault": false }
]
},
{
"id": "protein_addon",
"name": "Add Protein",
"required": false,
"maxSelections": 1,
"options": [
{ "name": "Plain", "price": 0, "isDefault": true },
{ "name": "With Chicken", "price": 0, "isDefault": false },
{ "name": "With Steak or Shrimp", "price": 0, "isDefault": false }
]
},
{
"id": "guacamole_addon",
"name": "Guacamole",
"required": false,
"maxSelections": 1,
"options": [
{ "name": "No Guacamole", "price": 0, "isDefault": true },
{ "name": "With Guacamole", "price": 0, "isDefault": false }
]
},
{
"id": "hotdog_extras",
"name": "Extras",
"required": false,
"maxSelections": 0,
"options": [
{ "name": "Add Sauerkraut", "price": 0.50, "isDefault": false },
{ "name": "Add Cheese", "price": 0.50, "isDefault": false }
]
},
{
"id": "chili_fries_extras",
"name": "Extras",
"required": false,
"maxSelections": 0,
"options": [
{ "name": "Add Cheese", "price": 0, "isDefault": false },
{ "name": "Add Onions", "price": 0, "isDefault": false }
]
},
{
"id": "salad_protein",
"name": "Add Protein",
"required": false,
"maxSelections": 1,
"options": [
{ "name": "No Protein", "price": 0, "isDefault": true },
{ "name": "Chicken Breast or Burger Patty", "price": 0, "isDefault": false },
{ "name": "Grilled Shrimp", "price": 0, "isDefault": false },
{ "name": "Grilled Mahi Mahi", "price": 0, "isDefault": false }
]
},
{
"id": "salad_dressing",
"name": "Dressing",
"required": true,
"maxSelections": 1,
"options": [
{ "name": "Italian", "price": 0, "isDefault": false },
{ "name": "Ranch", "price": 0, "isDefault": true },
{ "name": "Balsamic", "price": 0, "isDefault": false },
{ "name": "Caesar", "price": 0, "isDefault": false },
{ "name": "Blue Cheese", "price": 0, "isDefault": false },
{ "name": "Thousand Island", "price": 0, "isDefault": false },
{ "name": "French", "price": 0, "isDefault": false }
]
},
{
"id": "sides_choice",
"name": "Sauce/Side",
"required": false,
"maxSelections": 1,
"options": [
{ "name": "Sour Cream", "price": 0.50, "isDefault": false },
{ "name": "Jalapenos", "price": 0.50, "isDefault": false },
{ "name": "BBQ Sauce", "price": 0.50, "isDefault": false },
{ "name": "Ranch", "price": 0.50, "isDefault": false },
{ "name": "Blue Cheese", "price": 0.50, "isDefault": false },
{ "name": "Mayo", "price": 0.50, "isDefault": false },
{ "name": "Aioli", "price": 0.50, "isDefault": false }
]
},
{
"id": "onion_style",
"name": "Onions",
"required": false,
"maxSelections": 1,
"options": [
{ "name": "No Onions", "price": 0, "isDefault": false },
{ "name": "Grilled Onions", "price": 0, "isDefault": true },
{ "name": "Raw Onions", "price": 0, "isDefault": false }
]
}
],
"categories": [
{
"name": "World Famous Burgers",
"categoryNote": "All burgers served with lettuce, tomato, Big Dean's sauce, and pickle spear. Grilled or raw onions available.",
"items": [
{ "name": "Big Dean's Burger", "description": "The burger that made Santa Monica famous!", "price": 0, "modifiers": ["onion_style"] },
{ "name": "Single Beef Burger with Cheese", "description": "", "price": 0, "modifiers": ["onion_style"] },
{ "name": "Single Beef Burger", "description": "", "price": 0, "modifiers": ["onion_style"] },
{ "name": "Beyond Burger", "description": "Vegan burger patty w/ lettuce, tomato, and pickle spear", "price": 0, "modifiers": ["onion_style"] },
{ "name": "Garden Burger", "description": "Vegetable patty prepared in the style of our burgers", "price": 0, "modifiers": ["onion_style"] },
{ "name": "Chili Size Burger", "description": "Beef burger served open faced topped with all beef chili, cheese, and onions", "price": 0, "modifiers": [] }
]
},
{
"name": "Snacks and Sides",
"categoryNote": "Sides: Sour Cream, Jalapenos, BBQ sauce, Ranch, Blue Cheese, Mayo, or Aioli - $0.50 each",
"items": [
{ "name": "Buffalo Wings", "description": "Boneless or bone in", "price": 0, "modifiers": ["wing_style"] },
{ "name": "Frings", "description": "1/2 French fries and 1/2 onion rings", "price": 0, "modifiers": [] },
{ "name": "French Fries", "description": "Small available", "price": 0, "modifiers": ["size_regular_small"] },
{ "name": "Onion Rings", "description": "Small available", "price": 0, "modifiers": ["size_regular_small"] },
{ "name": "Chili Fries", "description": "Add cheese and onions", "price": 0, "modifiers": ["chili_fries_extras"] },
{ "name": "Chicken Fingers and Fries", "description": "1/2 order available", "price": 0, "modifiers": ["size_full_half"] },
{ "name": "Chips and Salsa", "description": "With guacamole option", "price": 0, "modifiers": ["guacamole_addon"] },
{ "name": "Cheese Quesadilla", "description": "With Chicken / With Steak or Shrimp options", "price": 0, "modifiers": ["protein_addon"] },
{ "name": "Cheese Nachos", "description": "With Chicken / With Steak or Shrimp options", "price": 0, "modifiers": ["protein_addon"] },
{ "name": "Chili Cheese Nachos", "description": "", "price": 0, "modifiers": [] },
{ "name": "Mozzarella Sticks", "description": "", "price": 0, "modifiers": [] }
]
},
{
"name": "Sandwiches",
"items": [
{ "name": "Cajun Mahi Mahi", "description": "Lettuce, tomato, aioli, and guacamole", "price": 0, "modifiers": [] },
{ "name": "Philly Cheese Steak", "description": "Grilled peppers and onions", "price": 0, "modifiers": [] },
{ "name": "Cajun Chicken", "description": "Lettuce, tomato, and spicy aioli", "price": 0, "modifiers": [] },
{ "name": "Turkey", "description": "Lettuce, tomato, and mayo", "price": 0, "modifiers": [] },
{ "name": "Turkey Club", "description": "Bacon, lettuce, tomato, and mayo", "price": 0, "modifiers": [] },
{ "name": "Grilled Cheese", "description": "Sourdough and American cheese", "price": 0, "modifiers": [] },
{ "name": "Grilled Ham and Cheese", "description": "", "price": 0, "modifiers": [] },
{ "name": "Grilled Chicken", "description": "Lettuce, tomato, and mayo", "price": 0, "modifiers": [] },
{ "name": "BBQ Chicken", "description": "Lettuce, tomato, and BBQ sauce", "price": 0, "modifiers": [] },
{ "name": "Fried Chicken", "description": "Lettuce, tomato, and mayo", "price": 0, "modifiers": [] },
{ "name": "Chicken Caesar Wrap", "description": "", "price": 0, "modifiers": [] }
]
},
{
"name": "Soups & Salads",
"categoryNote": "Dressing selections: Italian, Ranch, Balsamic, Caesar, Blue Cheese, Thousand Island, French. Add to any salad: Chicken breast or burger patty, Grilled Shrimp, Grilled Mahi Mahi",
"items": [
{ "name": "New England Clam Chowder", "description": "", "price": 0, "modifiers": [] },
{ "name": "Chili Bowl", "description": "With Cheese and Onion", "price": 0, "modifiers": [] },
{ "name": "Beach Salad", "description": "Spring mix greens, tomatoes, cucumbers, feta cheese, and carrots served in a crisp flour tortilla shell", "price": 0, "modifiers": ["salad_dressing", "salad_protein"] },
{ "name": "Caesar Salad", "description": "Romaine lettuce, croutons, and Caesar dressing", "price": 0, "modifiers": ["salad_protein"] }
]
},
{
"name": "Tacos",
"items": [
{ "name": "Fish Tacos", "description": "Pico sauce, chips, lettuce, tomatoes", "price": 0, "modifiers": [] },
{ "name": "Chicken Tacos", "description": "Cheese, cabbage, and tomatoes on corn tortillas with a side of guacamole and salsa", "price": 0, "modifiers": [] },
{ "name": "Steak Fajita", "description": "Grilled with peppers, onions, and a side of cheese, tomatoes, and lettuce", "price": 0, "modifiers": [] },
{ "name": "Shrimp Fajita", "description": "Grilled with peppers, onions, and a side of cheese, tomatoes, and lettuce", "price": 0, "modifiers": [] }
]
},
{
"name": "Seafood",
"items": [
{ "name": "Fried Fantail Shrimp", "description": "", "price": 0, "modifiers": [] },
{ "name": "Clam Boat with Fries", "description": "", "price": 0, "modifiers": [] },
{ "name": "Fish and Chips", "description": "1/2 order available", "price": 0, "modifiers": ["size_full_half"] },
{ "name": "Grilled Shrimp & Baby Scallop Skewers", "description": "", "price": 0, "modifiers": [] }
]
},
{
"name": "Hot Dogs & Such",
"items": [
{ "name": "Hot Dog", "description": "Topped with onions, tomatoes, and relish", "price": 0, "modifiers": ["hotdog_extras"] },
{ "name": "Corn Dog", "description": "", "price": 0, "modifiers": [] },
{ "name": "Chili Dog", "description": "Topped with all beef chili", "price": 0, "modifiers": ["hotdog_extras"] }
]
},
{
"name": "Draft Beer",
"items": [
{ "name": "Budweiser (20oz)", "description": "", "price": 12, "modifiers": [] },
{ "name": "Bud Light (20oz)", "description": "", "price": 12, "modifiers": [] },
{ "name": "Big Dean's Blonde (20oz)", "description": "", "price": 12, "modifiers": [] }
]
},
{
"name": "Premium Draft Beer",
"items": [
{ "name": "Big Wave (20oz)", "description": "", "price": 14, "modifiers": [] },
{ "name": "Cerveza De La Playa (20oz)", "description": "", "price": 14, "modifiers": [] },
{ "name": "Dos XX (20oz)", "description": "", "price": 14, "modifiers": [] },
{ "name": "Goose Island (20oz)", "description": "", "price": 14, "modifiers": [] },
{ "name": "Guinness (16oz/20oz)", "description": "", "price": 14, "modifiers": [] },
{ "name": "Longboard (20oz)", "description": "", "price": 14, "modifiers": [] },
{ "name": "Mango Cart (20oz)", "description": "", "price": 14, "modifiers": [] },
{ "name": "Pacifico (20oz)", "description": "", "price": 14, "modifiers": [] },
{ "name": "Santa Monica Wit (20oz)", "description": "", "price": 14, "modifiers": [] },
{ "name": "Shock Top (20oz)", "description": "", "price": 14, "modifiers": [] },
{ "name": "Stella Artois (20oz)", "description": "", "price": 14, "modifiers": [] },
{ "name": "Michelob Ultra (20oz)", "description": "", "price": 14, "modifiers": [] },
{ "name": "Space Dust (20oz)", "description": "", "price": 14, "modifiers": [] },
{ "name": "805 Lager (20oz)", "description": "", "price": 14, "modifiers": [] },
{ "name": "Heineken (20oz)", "description": "", "price": 14, "modifiers": [] },
{ "name": "10 Hop Hazy (20oz)", "description": "", "price": 14, "modifiers": [] }
]
},
{
"name": "Tall Cans",
"items": [
{ "name": "Coors", "description": "", "price": 0, "modifiers": [] },
{ "name": "Coors Light", "description": "", "price": 0, "modifiers": [] },
{ "name": "Tecate", "description": "", "price": 0, "modifiers": [] },
{ "name": "Michelob Ultra", "description": "", "price": 0, "modifiers": [] },
{ "name": "Busch Light", "description": "", "price": 0, "modifiers": [] }
]
},
{
"name": "Bottle Beer",
"items": [
{ "name": "Budweiser", "description": "", "price": 0, "modifiers": [] },
{ "name": "Bud Light", "description": "", "price": 0, "modifiers": [] },
{ "name": "Coors Light", "description": "", "price": 0, "modifiers": [] },
{ "name": "High Life", "description": "", "price": 0, "modifiers": [] },
{ "name": "Miller Lite", "description": "", "price": 0, "modifiers": [] },
{ "name": "Michelob Ultra", "description": "", "price": 0, "modifiers": [] },
{ "name": "Corona / Corona Premier", "description": "", "price": 0, "modifiers": [] },
{ "name": "Pacifico", "description": "", "price": 0, "modifiers": [] },
{ "name": "Firestone Union Jack", "description": "", "price": 0, "modifiers": [] },
{ "name": "Amstel", "description": "", "price": 0, "modifiers": [] },
{ "name": "Newcastle Brown", "description": "", "price": 0, "modifiers": [] },
{ "name": "Big Noise", "description": "", "price": 0, "modifiers": [] },
{ "name": "Best Coast Cider 16oz", "description": "", "price": 0, "modifiers": [] },
{ "name": "Stella Cidre", "description": "", "price": 0, "modifiers": [] },
{ "name": "Bud 00 (Non-Alcoholic)", "description": "", "price": 0, "modifiers": [] },
{ "name": "Becks N/A (Non-Alcoholic)", "description": "", "price": 0, "modifiers": [] },
{ "name": "Heineken 00 (Non-Alcoholic)", "description": "", "price": 0, "modifiers": [] },
{ "name": "Guinness 0 (Non-Alcoholic)", "description": "", "price": 0, "modifiers": [] },
{ "name": "Mango Cart N/A (Non-Alcoholic)", "description": "", "price": 0, "modifiers": [] },
{ "name": "Shandy Santa Monica", "description": "", "price": 0, "modifiers": [] }
]
},
{
"name": "Seltzers",
"items": [
{ "name": "High Noon", "description": "", "price": 0, "modifiers": [] },
{ "name": "White Claw", "description": "", "price": 0, "modifiers": [] },
{ "name": "Long Drink", "description": "", "price": 0, "modifiers": [] },
{ "name": "Topo Chico", "description": "", "price": 0, "modifiers": [] },
{ "name": "Suncruiser", "description": "", "price": 0, "modifiers": [] }
]
},
{
"name": "Cocktails",
"items": [
{ "name": "Margarita", "description": "", "price": 0, "modifiers": [] },
{ "name": "Mai Tai", "description": "", "price": 0, "modifiers": [] },
{ "name": "Tequila Sunrise", "description": "", "price": 0, "modifiers": [] },
{ "name": "Paloma", "description": "", "price": 0, "modifiers": [] },
{ "name": "Espresso Martini", "description": "", "price": 0, "modifiers": [] },
{ "name": "Bloody Mary", "description": "", "price": 0, "modifiers": [] },
{ "name": "Green Tea Shot", "description": "", "price": 0, "modifiers": [] }
]
},
{
"name": "Wine",
"items": [
{ "name": "Cabernet, Woodbridge", "description": "Glass / Bottle", "price": 0, "modifiers": [] },
{ "name": "Merlot, Woodbridge", "description": "Glass / Bottle", "price": 0, "modifiers": [] },
{ "name": "Sauvignon Blanc, Woodbridge", "description": "Glass / Bottle", "price": 0, "modifiers": [] },
{ "name": "Pinot Grigio, Woodbridge", "description": "Glass / Bottle", "price": 0, "modifiers": [] },
{ "name": "Hidden Sea Rose", "description": "Glass / Bottle", "price": 0, "modifiers": [] },
{ "name": "Chardonnay, Kendall Jackson", "description": "Glass / Bottle", "price": 0, "modifiers": [] },
{ "name": "Cabernet, Kendall Jackson", "description": "Glass / Bottle", "price": 0, "modifiers": [] },
{ "name": "Pinot Gris, Kendall Jackson", "description": "Glass / Bottle", "price": 0, "modifiers": [] },
{ "name": "Sauvignon Blanc, La Crema", "description": "Glass / Bottle", "price": 0, "modifiers": [] },
{ "name": "Murphy Goode Rose", "description": "Glass / Bottle", "price": 0, "modifiers": [] },
{ "name": "Sparkling Wine Splits", "description": "Single serving", "price": 0, "modifiers": [] },
{ "name": "Mimosa", "description": "Glass only", "price": 0, "modifiers": [] },
{ "name": "Freixenet", "description": "Bottle only", "price": 0, "modifiers": [] },
{ "name": "Mumm", "description": "Bottle only", "price": 0, "modifiers": [] }
]
},
{
"name": "Non-Alcoholic",
"items": [
{ "name": "Soda By the Can", "description": "", "price": 0, "modifiers": [] },
{ "name": "Bottled Water", "description": "", "price": 0, "modifiers": [] },
{ "name": "Sparkling Water", "description": "", "price": 0, "modifiers": [] },
{ "name": "Coffee or Hot Tea", "description": "", "price": 0, "modifiers": [] },
{ "name": "Juice", "description": "Orange, Cranberry, or Apple", "price": 0, "modifiers": [] }
]
}
],
"ownerUserID": 2,
"dryRun": false
}

View file

@ -0,0 +1,150 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfsetting requesttimeout="120">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
/**
* Download Business Images
*
* Downloads logo and header images from external URLs and saves
* them to the uploads directory for a business.
*
* POST JSON:
* {
* "businessID": 123,
* "logoUrl": "https://example.com/logo.png",
* "headerUrl": "https://example.com/header.jpg"
* }
*
* Images are saved to:
* /uploads/logos/{BusinessID}.png
* /uploads/headers/{BusinessID}.jpg
*/
response = { "OK": false, "downloaded": [] };
try {
requestBody = toString(getHttpRequestData().content);
if (!len(requestBody)) {
throw(message="No request body provided");
}
data = deserializeJSON(requestBody);
if (!structKeyExists(data, "businessID") || val(data.businessID) == 0) {
throw(message="businessID is required");
}
businessID = val(data.businessID);
uploadsPath = expandPath("/uploads");
// Create uploads directories if they don't exist
logosPath = uploadsPath & "/logos";
headersPath = uploadsPath & "/headers";
if (!directoryExists(logosPath)) {
directoryCreate(logosPath);
}
if (!directoryExists(headersPath)) {
directoryCreate(headersPath);
}
// Download logo
if (structKeyExists(data, "logoUrl") && len(data.logoUrl)) {
logoUrl = data.logoUrl;
// Determine extension from URL or default to .png
ext = ".png";
if (findNoCase(".jpg", logoUrl) || findNoCase(".jpeg", logoUrl)) {
ext = ".jpg";
} else if (findNoCase(".gif", logoUrl)) {
ext = ".gif";
} else if (findNoCase(".webp", logoUrl)) {
ext = ".webp";
}
logoFile = logosPath & "/" & businessID & ext;
try {
cfhttp(url=logoUrl, method="GET", getasbinary="yes", timeout=30, result="logoResult");
if (logoResult.statusCode contains "200") {
fileWrite(logoFile, logoResult.fileContent);
arrayAppend(response.downloaded, {
"type": "logo",
"url": logoUrl,
"savedTo": "/uploads/logos/" & businessID & ext,
"size": len(logoResult.fileContent)
});
} else {
arrayAppend(response.downloaded, {
"type": "logo",
"url": logoUrl,
"error": "HTTP " & logoResult.statusCode
});
}
} catch (any e) {
arrayAppend(response.downloaded, {
"type": "logo",
"url": logoUrl,
"error": e.message
});
}
}
// Download header
if (structKeyExists(data, "headerUrl") && len(data.headerUrl)) {
headerUrl = data.headerUrl;
// Determine extension from URL or default to .jpg
ext = ".jpg";
if (findNoCase(".png", headerUrl)) {
ext = ".png";
} else if (findNoCase(".gif", headerUrl)) {
ext = ".gif";
} else if (findNoCase(".webp", headerUrl)) {
ext = ".webp";
}
headerFile = headersPath & "/" & businessID & ext;
try {
cfhttp(url=headerUrl, method="GET", getasbinary="yes", timeout=30, result="headerResult");
if (headerResult.statusCode contains "200") {
fileWrite(headerFile, headerResult.fileContent);
arrayAppend(response.downloaded, {
"type": "header",
"url": headerUrl,
"savedTo": "/uploads/headers/" & businessID & ext,
"size": len(headerResult.fileContent)
});
} else {
arrayAppend(response.downloaded, {
"type": "header",
"url": headerUrl,
"error": "HTTP " & headerResult.statusCode
});
}
} catch (any e) {
arrayAppend(response.downloaded, {
"type": "header",
"url": headerUrl,
"error": e.message
});
}
}
response.OK = true;
response.businessID = businessID;
} catch (any e) {
response.error = e.message;
if (len(e.detail)) {
response.errorDetail = e.detail;
}
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -0,0 +1,366 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfsetting requesttimeout="300">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
/**
* Import Business from Scraped Data
*
* Creates a complete business with:
* - Business record with contact info
* - Categories as Items (ParentID=0)
* - Menu items as children of categories
* - Modifier templates linked via ItemTemplateLinks
* - Modifier options as children of templates
*
* POST JSON structure:
* {
* "business": {
* "name": "Big Dean's Ocean Front Cafe",
* "address": "1615 Ocean Front Walk",
* "city": "Santa Monica",
* "state": "CA",
* "zip": "90401",
* "phone": "(310) 393-2666",
* "email": "owner@bigdeans.com",
* "ownerPhone": "3101234567",
* "website": "https://bigdeansoceanfrontcafe.com",
* "logoUrl": "https://...",
* "headerUrl": "https://...",
* "hours": {
* "mon": "12:00 PM - 7:00 PM",
* "tue": "11:00 AM - 8:00 PM",
* ...
* }
* },
* "categories": [
* {
* "name": "World Famous Burgers",
* "sortOrder": 1,
* "items": [
* {
* "name": "Big Dean's Burger",
* "description": "The burger that made Santa Monica famous!",
* "price": 12.99,
* "modifiers": ["onion_style", "add_cheese"] // template references
* }
* ]
* }
* ],
* "modifierTemplates": [
* {
* "id": "onion_style",
* "name": "Onions",
* "required": true,
* "maxSelections": 1,
* "options": [
* { "name": "No Onions", "price": 0, "isDefault": false },
* { "name": "Grilled Onions", "price": 0, "isDefault": true },
* { "name": "Raw Onions", "price": 0, "isDefault": false }
* ]
* }
* ],
* "ownerUserID": 2, // optional, defaults to 1
* "dryRun": false // optional, if true just validates without inserting
* }
*/
response = { "OK": false, "steps": [], "errors": [], "warnings": [] };
try {
requestBody = toString(getHttpRequestData().content);
if (!len(requestBody)) {
throw(message="No request body provided");
}
data = deserializeJSON(requestBody);
dryRun = structKeyExists(data, "dryRun") && data.dryRun == true;
if (dryRun) {
response.steps.append("DRY RUN MODE - no changes will be made");
}
// Validate required fields
if (!structKeyExists(data, "business") || !structKeyExists(data.business, "name")) {
throw(message="business.name is required");
}
biz = data.business;
ownerUserID = structKeyExists(data, "ownerUserID") ? val(data.ownerUserID) : 1;
// Step 1: Create or find business
response.steps.append("Step 1: Creating business record...");
qCheck = queryExecute("
SELECT BusinessID FROM Businesses WHERE BusinessName = :name
", { name: biz.name }, { datasource: "payfrit" });
if (qCheck.recordCount > 0) {
BusinessID = qCheck.BusinessID;
response.steps.append("Business already exists with ID: " & BusinessID);
response.warnings.append("Existing business found - will add to existing menu");
} else if (!dryRun) {
queryExecute("
INSERT INTO Businesses (BusinessName, BusinessUserID, BusinessAddressID, BusinessDeliveryZipCodes, BusinessAddedOn) VALUES (:name, :ownerID, 0, '', NOW())
", {
name: biz.name, ownerID: ownerUserID
}, { datasource: "payfrit" });
qNew = queryExecute("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" });
BusinessID = qNew.id;
response.steps.append("Created business with ID: " & BusinessID);
} else {
BusinessID = 0;
response.steps.append("Would create business: " & biz.name);
}
// Step 2: Create modifier templates first (they need to exist before items reference them)
response.steps.append("Step 2: Creating modifier templates...");
templateMap = {}; // Maps template string ID to database ItemID
templates = structKeyExists(data, "modifierTemplates") ? data.modifierTemplates : [];
for (tmpl in templates) {
templateStringID = tmpl.id;
templateName = tmpl.name;
required = structKeyExists(tmpl, "required") && tmpl.required == true;
maxSelections = structKeyExists(tmpl, "maxSelections") ? val(tmpl.maxSelections) : 0;
if (!dryRun) {
// Check if template already exists for this business
qTmpl = queryExecute("
SELECT ItemID FROM Items
WHERE ItemBusinessID = :bizID
AND ItemName = :name
AND ItemParentItemID = 0
AND ItemID IN (SELECT TemplateItemID FROM ItemTemplateLinks)
", { bizID: BusinessID, name: templateName }, { datasource: "payfrit" });
if (qTmpl.recordCount > 0) {
templateItemID = qTmpl.ItemID;
response.steps.append("Template exists: " & templateName & " (ID: " & templateItemID & ")");
} else {
// Create template as Item at ParentID=0, with ItemIsCollapsible=1 to mark it as a template
// This ensures the API filter excludes it from categories
queryExecute("
INSERT INTO Items (
ItemBusinessID, ItemName, ItemParentItemID, ItemPrice,
ItemIsActive, ItemRequiresChildSelection, ItemMaxNumSelectionReq, ItemSortOrder, ItemIsCollapsible
) VALUES (
:bizID, :name, 0, 0, 1, :required, :maxSelect, 0, 1
)
", {
bizID: BusinessID,
name: templateName,
required: required ? 1 : 0,
maxSelect: maxSelections
}, { datasource: "payfrit" });
qNewTmpl = queryExecute("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" });
templateItemID = qNewTmpl.id;
response.steps.append("Created template: " & templateName & " (ID: " & templateItemID & ")");
}
templateMap[templateStringID] = templateItemID;
// Create template options
options = structKeyExists(tmpl, "options") ? tmpl.options : [];
optionOrder = 1;
for (opt in options) {
optName = opt.name;
optPrice = structKeyExists(opt, "price") ? val(opt.price) : 0;
optDefault = structKeyExists(opt, "isDefault") && opt.isDefault == true;
qOpt = queryExecute("
SELECT ItemID FROM Items
WHERE ItemBusinessID = :bizID AND ItemName = :name AND ItemParentItemID = :parentID
", { bizID: BusinessID, name: optName, parentID: templateItemID }, { datasource: "payfrit" });
if (qOpt.recordCount == 0) {
queryExecute("
INSERT INTO Items (
ItemBusinessID, ItemName, ItemParentItemID, ItemPrice,
ItemIsActive, ItemIsCheckedByDefault, ItemSortOrder
) VALUES (
:bizID, :name, :parentID, :price, 1, :isDefault, :sortOrder
)
", {
bizID: BusinessID,
name: optName,
parentID: templateItemID,
price: optPrice,
isDefault: optDefault ? 1 : 0,
sortOrder: optionOrder
}, { datasource: "payfrit" });
}
optionOrder++;
}
} else {
response.steps.append("Would create template: " & templateName & " with " & arrayLen(tmpl.options) & " options");
templateMap[templateStringID] = 0;
}
}
// Step 3: Create categories (as Items at ParentID=0)
response.steps.append("Step 3: Creating categories...");
categoryMap = {}; // Maps category name to ItemID
categories = structKeyExists(data, "categories") ? data.categories : [];
catOrder = 1;
for (cat in categories) {
catName = cat.name;
if (!dryRun) {
// Check if category exists (Item at ParentID=0, not a template)
qCat = queryExecute("
SELECT ItemID FROM Items
WHERE ItemBusinessID = :bizID
AND ItemName = :name
AND ItemParentItemID = 0
AND ItemID NOT IN (SELECT TemplateItemID FROM ItemTemplateLinks)
", { bizID: BusinessID, name: catName }, { datasource: "payfrit" });
if (qCat.recordCount > 0) {
categoryItemID = qCat.ItemID;
response.steps.append("Category exists: " & catName);
} else {
queryExecute("
INSERT INTO Items (
ItemBusinessID, ItemName, ItemParentItemID, ItemPrice,
ItemIsActive, ItemSortOrder
) VALUES (
:bizID, :name, 0, 0, 1, :sortOrder
)
", {
bizID: BusinessID,
name: catName,
sortOrder: catOrder
}, { datasource: "payfrit" });
qNewCat = queryExecute("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" });
categoryItemID = qNewCat.id;
response.steps.append("Created category: " & catName & " (ID: " & categoryItemID & ")");
}
categoryMap[catName] = categoryItemID;
} else {
response.steps.append("Would create category: " & catName);
categoryMap[catName] = 0;
}
catOrder++;
}
// Step 4: Create menu items as children of categories
response.steps.append("Step 4: Creating menu items...");
totalItems = 0;
totalLinks = 0;
for (cat in categories) {
catName = cat.name;
categoryItemID = categoryMap[catName];
items = structKeyExists(cat, "items") ? cat.items : [];
itemOrder = 1;
for (item in items) {
itemName = item.name;
itemDesc = structKeyExists(item, "description") ? item.description : "";
itemPrice = structKeyExists(item, "price") ? val(item.price) : 0;
if (!dryRun) {
// Check if item exists
qItem = queryExecute("
SELECT ItemID FROM Items
WHERE ItemBusinessID = :bizID AND ItemName = :name AND ItemParentItemID = :parentID
", { bizID: BusinessID, name: itemName, parentID: categoryItemID }, { datasource: "payfrit" });
if (qItem.recordCount > 0) {
menuItemID = qItem.ItemID;
} else {
queryExecute("
INSERT INTO Items (
ItemBusinessID, ItemName, ItemDescription, ItemParentItemID,
ItemPrice, ItemIsActive, ItemSortOrder
) VALUES (
:bizID, :name, :desc, :parentID, :price, 1, :sortOrder
)
", {
bizID: BusinessID,
name: itemName,
desc: itemDesc,
parentID: categoryItemID,
price: itemPrice,
sortOrder: itemOrder
}, { datasource: "payfrit" });
qNewItem = queryExecute("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" });
menuItemID = qNewItem.id;
}
// Link modifier templates to this item
modifiers = structKeyExists(item, "modifiers") ? item.modifiers : [];
modOrder = 1;
for (modRef in modifiers) {
if (structKeyExists(templateMap, modRef)) {
templateItemID = templateMap[modRef];
// Check if link exists
qLink = queryExecute("
SELECT 1 FROM ItemTemplateLinks
WHERE ItemID = :itemID AND TemplateItemID = :templateID
", { itemID: menuItemID, templateID: templateItemID }, { datasource: "payfrit" });
if (qLink.recordCount == 0) {
queryExecute("
INSERT INTO ItemTemplateLinks (ItemID, TemplateItemID, SortOrder)
VALUES (:itemID, :templateID, :sortOrder)
", {
itemID: menuItemID,
templateID: templateItemID,
sortOrder: modOrder
}, { datasource: "payfrit" });
totalLinks++;
}
modOrder++;
} else {
response.warnings.append("Unknown modifier reference: " & modRef & " on item: " & itemName);
}
}
}
totalItems++;
itemOrder++;
}
}
response.steps.append("Created " & totalItems & " menu items with " & totalLinks & " template links");
// Summary
response.OK = true;
response.summary = {
"businessID": BusinessID,
"businessName": biz.name,
"categoriesCreated": arrayLen(categories),
"templatesCreated": arrayLen(templates),
"itemsCreated": totalItems,
"templateLinksCreated": totalLinks,
"dryRun": dryRun
};
if (dryRun) {
response.steps.append("DRY RUN COMPLETE - no changes were made");
} else {
response.steps.append("IMPORT COMPLETE!");
}
} catch (any e) {
response.errors.append(e.message);
if (len(e.detail)) {
response.errors.append(e.detail);
}
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -0,0 +1,346 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// Big Dean's Ocean Front Cafe - Full Menu Reimport
// Based on actual menu image analysis
bizId = 27;
dryRun = structKeyExists(url, "dryRun") ? true : false;
actions = [];
// ============================================================
// STEP 1: Clear existing Big Dean's menu data
// ============================================================
if (!dryRun) {
// Delete template links first
queryExecute("DELETE FROM ItemTemplateLinks WHERE ItemID IN (SELECT ItemID FROM Items WHERE ItemBusinessID = :bizId)", { bizId: bizId });
// Delete all items
queryExecute("DELETE FROM Items WHERE ItemBusinessID = :bizId", { bizId: bizId });
}
arrayAppend(actions, { step: "CLEAR", message: dryRun ? "Would clear existing data" : "Cleared existing data" });
// ============================================================
// STEP 2: Create Modifier Templates (shared across items)
// ============================================================
// Helper function to insert item and return ID
function insertItem(name, description, parentId, price, isActive, isCheckedByDefault, requiresChild, maxSelections, isCollapsible, sortOrder) {
if (dryRun) return 0;
// Note: ItemIsActive is BIT(1) type, use b'1' or b'0' syntax
queryExecute("
INSERT INTO Items (ItemBusinessID, ItemName, ItemDescription, ItemParentItemID, ItemPrice,
ItemIsActive, ItemIsCheckedByDefault, ItemRequiresChildSelection,
ItemMaxNumSelectionReq, ItemIsCollapsible, ItemSortOrder, ItemAddedOn)
VALUES (:bizId, :name, :desc, :parentId, :price, b'#isActive#', :isDefault, :reqChild, :maxSel, :isCollapse, :sort, NOW())
", {
bizId: bizId, name: name, desc: description, parentId: parentId, price: price,
isDefault: isCheckedByDefault, reqChild: requiresChild,
maxSel: maxSelections, isCollapse: isCollapsible, sort: sortOrder
});
qId = queryExecute("SELECT LAST_INSERT_ID() as id");
return qId.id;
}
// Helper to link template to item
function linkTemplate(menuItemId, templateId, sortOrder) {
if (dryRun) return;
queryExecute("
INSERT INTO ItemTemplateLinks (ItemID, TemplateItemID, SortOrder)
VALUES (:itemId, :templateId, :sort)
", { itemId: menuItemId, templateId: templateId, sort: sortOrder });
}
// ============================================================
// MODIFIER TEMPLATES
// ============================================================
// --- Burger Toppings Template: Lettuce ---
tplLettuce = insertItem("Lettuce", "", 0, 0, 1, 0, 0, 1, 1, 1);
insertItem("Regular", "", tplLettuce, 0, 1, 1, 0, 0, 0, 1);
insertItem("Extra", "", tplLettuce, 0, 1, 0, 0, 0, 0, 2);
insertItem("Light", "", tplLettuce, 0, 1, 0, 0, 0, 0, 3);
insertItem("None", "", tplLettuce, 0, 1, 0, 0, 0, 0, 4);
arrayAppend(actions, { step: "TEMPLATE", name: "Lettuce", id: tplLettuce });
// --- Burger Toppings Template: Tomato ---
tplTomato = insertItem("Tomato", "", 0, 0, 1, 0, 0, 1, 1, 1);
insertItem("Regular", "", tplTomato, 0, 1, 1, 0, 0, 0, 1);
insertItem("Extra", "", tplTomato, 0, 1, 0, 0, 0, 0, 2);
insertItem("Light", "", tplTomato, 0, 1, 0, 0, 0, 0, 3);
insertItem("None", "", tplTomato, 0, 1, 0, 0, 0, 0, 4);
arrayAppend(actions, { step: "TEMPLATE", name: "Tomato", id: tplTomato });
// --- Burger Toppings Template: Big Dean's Sauce ---
tplSauce = insertItem("Big Dean's Sauce", "", 0, 0, 1, 0, 0, 1, 1, 1);
insertItem("Regular", "", tplSauce, 0, 1, 1, 0, 0, 0, 1);
insertItem("Extra", "", tplSauce, 0, 1, 0, 0, 0, 0, 2);
insertItem("Light", "", tplSauce, 0, 1, 0, 0, 0, 0, 3);
insertItem("None", "", tplSauce, 0, 1, 0, 0, 0, 0, 4);
arrayAppend(actions, { step: "TEMPLATE", name: "Big Dean's Sauce", id: tplSauce });
// --- Burger Toppings Template: Onions ---
tplOnions = insertItem("Onions", "", 0, 0, 1, 0, 0, 1, 1, 1);
insertItem("None", "", tplOnions, 0, 1, 1, 0, 0, 0, 1);
insertItem("Grilled", "", tplOnions, 0, 1, 0, 0, 0, 0, 2);
insertItem("Raw", "", tplOnions, 0, 1, 0, 0, 0, 0, 3);
arrayAppend(actions, { step: "TEMPLATE", name: "Onions", id: tplOnions });
// --- Burger Toppings Template: Pickle Spear ---
tplPickle = insertItem("Pickle Spear", "", 0, 0, 1, 0, 0, 1, 1, 1);
insertItem("None", "", tplPickle, 0, 1, 1, 0, 0, 0, 1);
insertItem("Add Pickle", "", tplPickle, 0, 1, 0, 0, 0, 0, 2);
arrayAppend(actions, { step: "TEMPLATE", name: "Pickle Spear", id: tplPickle });
// --- Wing Style Template ---
tplWingStyle = insertItem("Wing Style", "", 0, 0, 1, 0, 1, 1, 1, 1);
insertItem("Bone-In", "", tplWingStyle, 0, 1, 1, 0, 0, 0, 1);
insertItem("Boneless", "", tplWingStyle, 0, 1, 0, 0, 0, 0, 2);
arrayAppend(actions, { step: "TEMPLATE", name: "Wing Style", id: tplWingStyle });
// --- Size Template (Fries/Rings) ---
tplSize = insertItem("Size", "", 0, 0, 1, 0, 1, 1, 1, 1);
insertItem("Regular", "", tplSize, 0, 1, 1, 0, 0, 0, 1);
insertItem("Small", "", tplSize, 0, 1, 0, 0, 0, 0, 2);
arrayAppend(actions, { step: "TEMPLATE", name: "Size", id: tplSize });
// --- Portion Size Template (Half/Full) ---
tplPortion = insertItem("Portion", "", 0, 0, 1, 0, 1, 1, 1, 1);
insertItem("Full Order", "", tplPortion, 0, 1, 1, 0, 0, 0, 1);
insertItem("Half Order", "", tplPortion, 0, 1, 0, 0, 0, 0, 2);
arrayAppend(actions, { step: "TEMPLATE", name: "Portion", id: tplPortion });
// --- Sides Extras Template ($.50 each) ---
tplSideExtras = insertItem("Add Extras", "$.50 each", 0, 0, 1, 0, 0, 0, 0, 1);
insertItem("Sour Cream", "", tplSideExtras, 0.50, 1, 0, 0, 0, 0, 1);
insertItem("Jalapenos", "", tplSideExtras, 0.50, 1, 0, 0, 0, 0, 2);
insertItem("BBQ Sauce", "", tplSideExtras, 0.50, 1, 0, 0, 0, 0, 3);
insertItem("Ranch", "", tplSideExtras, 0.50, 1, 0, 0, 0, 0, 4);
insertItem("Blue Cheese", "", tplSideExtras, 0.50, 1, 0, 0, 0, 0, 5);
insertItem("Mayo", "", tplSideExtras, 0.50, 1, 0, 0, 0, 0, 6);
insertItem("Aioli", "", tplSideExtras, 0.50, 1, 0, 0, 0, 0, 7);
arrayAppend(actions, { step: "TEMPLATE", name: "Add Extras", id: tplSideExtras });
// --- Guacamole Option ---
tplGuac = insertItem("Guacamole", "", 0, 0, 1, 0, 0, 1, 1, 1);
insertItem("Without Guacamole", "", tplGuac, 0, 1, 1, 0, 0, 0, 1);
insertItem("With Guacamole", "", tplGuac, 0, 1, 0, 0, 0, 0, 2);
arrayAppend(actions, { step: "TEMPLATE", name: "Guacamole", id: tplGuac });
// --- Quesadilla/Nachos Add-ins (multi-select checkboxes) ---
tplAddIns = insertItem("Add-ins", "", 0, 0, 1, 0, 0, 0, 0, 1);
insertItem("Chicken", "", tplAddIns, 0, 1, 0, 0, 0, 0, 1);
insertItem("Steak", "", tplAddIns, 0, 1, 0, 0, 0, 0, 2);
insertItem("Shrimp", "", tplAddIns, 0, 1, 0, 0, 0, 0, 3);
arrayAppend(actions, { step: "TEMPLATE", name: "Add-ins", id: tplAddIns });
// --- Chili Fries Extras ---
tplChiliExtras = insertItem("Extras", "", 0, 0, 1, 0, 0, 0, 0, 1);
insertItem("Add Cheese", "", tplChiliExtras, 0.50, 1, 0, 0, 0, 0, 1);
insertItem("Add Onions", "", tplChiliExtras, 0.50, 1, 0, 0, 0, 0, 2);
arrayAppend(actions, { step: "TEMPLATE", name: "Extras (Chili)", id: tplChiliExtras });
// --- Salad Dressing ---
tplDressing = insertItem("Dressing", "", 0, 0, 1, 0, 1, 1, 1, 1);
insertItem("Italian", "", tplDressing, 0, 1, 0, 0, 0, 0, 1);
insertItem("Ranch", "", tplDressing, 0, 1, 1, 0, 0, 0, 2);
insertItem("Balsamic", "", tplDressing, 0, 1, 0, 0, 0, 0, 3);
insertItem("Caesar", "", tplDressing, 0, 1, 0, 0, 0, 0, 4);
insertItem("Blue Cheese", "", tplDressing, 0, 1, 0, 0, 0, 0, 5);
insertItem("Thousand Island", "", tplDressing, 0, 1, 0, 0, 0, 0, 6);
insertItem("French", "", tplDressing, 0, 1, 0, 0, 0, 0, 7);
arrayAppend(actions, { step: "TEMPLATE", name: "Dressing", id: tplDressing });
// --- Salad Add Protein ---
tplSaladProtein = insertItem("Add Protein", "", 0, 0, 1, 0, 0, 1, 1, 1);
insertItem("No Protein", "", tplSaladProtein, 0, 1, 1, 0, 0, 0, 1);
insertItem("Chicken Breast or Burger Patty", "", tplSaladProtein, 0, 1, 0, 0, 0, 0, 2);
insertItem("Grilled Shrimp", "", tplSaladProtein, 0, 1, 0, 0, 0, 0, 3);
insertItem("Grilled Mahi Mahi", "", tplSaladProtein, 0, 1, 0, 0, 0, 0, 4);
arrayAppend(actions, { step: "TEMPLATE", name: "Add Protein (Salad)", id: tplSaladProtein });
// --- Hot Dog Extras ---
tplHotDogExtras = insertItem("Extras", "", 0, 0, 1, 0, 0, 0, 0, 1);
insertItem("Add Sauerkraut", "", tplHotDogExtras, 0.50, 1, 0, 0, 0, 0, 1);
insertItem("Add Cheese", "", tplHotDogExtras, 0.50, 1, 0, 0, 0, 0, 2);
arrayAppend(actions, { step: "TEMPLATE", name: "Extras (Hot Dog)", id: tplHotDogExtras });
// ============================================================
// CATEGORIES AND MENU ITEMS
// ============================================================
// --- CATEGORY: World Famous Burgers ---
catBurgers = insertItem("World Famous Burgers", "", 0, 0, 1, 0, 0, 0, 0, 1);
arrayAppend(actions, { step: "CATEGORY", name: "World Famous Burgers", id: catBurgers });
burger1 = insertItem("Big Dean's Cheeseburger", "Double meat double cheese - The burger that made Santa Monica famous!", catBurgers, 0, 1, 0, 0, 0, 0, 1);
burger2 = insertItem("Single Beef Burger with Cheese", "", catBurgers, 0, 1, 0, 0, 0, 0, 2);
burger3 = insertItem("Single Beef Burger", "", catBurgers, 0, 1, 0, 0, 0, 0, 3);
burger4 = insertItem("Beyond Burger", "Vegan burger patty w/ lettuce, tomato, and pickle spear", catBurgers, 0, 1, 0, 0, 0, 0, 4);
burger5 = insertItem("Garden Burger", "Vegetable patty prepared in the style of our burgers", catBurgers, 0, 1, 0, 0, 0, 0, 5);
burger6 = insertItem("Chili Size Burger", "Beef burger served open faced topped with all beef chili, cheese, and onions", catBurgers, 0, 1, 0, 0, 0, 0, 6);
// Link burger templates to all burgers
burgerIds = [burger1, burger2, burger3, burger4, burger5, burger6];
for (bid in burgerIds) {
linkTemplate(bid, tplLettuce, 1);
linkTemplate(bid, tplTomato, 2);
linkTemplate(bid, tplSauce, 3);
linkTemplate(bid, tplOnions, 4);
linkTemplate(bid, tplPickle, 5);
}
arrayAppend(actions, { step: "ITEMS", category: "Burgers", count: 6 });
// --- CATEGORY: Snacks and Sides ---
catSides = insertItem("Snacks and Sides", "", 0, 0, 1, 0, 0, 0, 0, 2);
arrayAppend(actions, { step: "CATEGORY", name: "Snacks and Sides", id: catSides });
side1 = insertItem("Buffalo Wings", "", catSides, 0, 1, 0, 0, 0, 0, 1);
linkTemplate(side1, tplWingStyle, 1);
linkTemplate(side1, tplSideExtras, 2);
side2 = insertItem("Frings", "1/2 French fries and 1/2 onion rings", catSides, 0, 1, 0, 0, 0, 0, 2);
linkTemplate(side2, tplSideExtras, 1);
side3 = insertItem("French Fries", "", catSides, 0, 1, 0, 0, 0, 0, 3);
linkTemplate(side3, tplSize, 1);
linkTemplate(side3, tplSideExtras, 2);
side4 = insertItem("Onion Rings", "", catSides, 0, 1, 0, 0, 0, 0, 4);
linkTemplate(side4, tplSize, 1);
linkTemplate(side4, tplSideExtras, 2);
side5 = insertItem("Chili Fries", "", catSides, 0, 1, 0, 0, 0, 0, 5);
linkTemplate(side5, tplChiliExtras, 1);
linkTemplate(side5, tplSideExtras, 2);
side6 = insertItem("Chicken Fingers and Fries", "", catSides, 0, 1, 0, 0, 0, 0, 6);
linkTemplate(side6, tplPortion, 1);
linkTemplate(side6, tplSideExtras, 2);
side7 = insertItem("Chips and Salsa", "", catSides, 0, 1, 0, 0, 0, 0, 7);
linkTemplate(side7, tplGuac, 1);
side8 = insertItem("Cheese Quesadilla", "", catSides, 0, 1, 0, 0, 0, 0, 8);
linkTemplate(side8, tplAddIns, 1);
side9 = insertItem("Cheese Nachos", "", catSides, 0, 1, 0, 0, 0, 0, 9);
linkTemplate(side9, tplAddIns, 1);
side10 = insertItem("Chili Cheese Nachos", "", catSides, 0, 1, 0, 0, 0, 0, 10);
side11 = insertItem("Mozzarella Sticks", "", catSides, 0, 1, 0, 0, 0, 0, 11);
linkTemplate(side11, tplSideExtras, 1);
arrayAppend(actions, { step: "ITEMS", category: "Snacks and Sides", count: 11 });
// --- CATEGORY: Sandwiches ---
catSandwiches = insertItem("Sandwiches", "", 0, 0, 1, 0, 0, 0, 0, 3);
arrayAppend(actions, { step: "CATEGORY", name: "Sandwiches", id: catSandwiches });
insertItem("Cajun Mahi Mahi", "Lettuce, tomato, aioli, and guacamole", catSandwiches, 0, 1, 0, 0, 0, 0, 1);
insertItem("Philly Cheese Steak", "Grilled peppers and onions", catSandwiches, 0, 1, 0, 0, 0, 0, 2);
insertItem("Cajun Chicken", "Lettuce, tomato, and spicy aioli", catSandwiches, 0, 1, 0, 0, 0, 0, 3);
insertItem("Turkey", "Lettuce, tomato, and mayo", catSandwiches, 0, 1, 0, 0, 0, 0, 4);
insertItem("Turkey Club", "Bacon, lettuce, tomato, and mayo", catSandwiches, 0, 1, 0, 0, 0, 0, 5);
insertItem("Grilled Cheese", "Sourdough and American cheese", catSandwiches, 0, 1, 0, 0, 0, 0, 6);
insertItem("Grilled Ham and Cheese", "", catSandwiches, 0, 1, 0, 0, 0, 0, 7);
insertItem("Grilled Chicken", "Lettuce, tomato, and mayo", catSandwiches, 0, 1, 0, 0, 0, 0, 8);
insertItem("BBQ Chicken", "Lettuce, tomato, and BBQ sauce", catSandwiches, 0, 1, 0, 0, 0, 0, 9);
insertItem("Fried Chicken", "Lettuce, tomato, and mayo", catSandwiches, 0, 1, 0, 0, 0, 0, 10);
insertItem("Chicken Caesar Wrap", "", catSandwiches, 0, 1, 0, 0, 0, 0, 11);
arrayAppend(actions, { step: "ITEMS", category: "Sandwiches", count: 11 });
// --- CATEGORY: Soups & Salads ---
catSoups = insertItem("Soups & Salads", "", 0, 0, 1, 0, 0, 0, 0, 4);
arrayAppend(actions, { step: "CATEGORY", name: "Soups & Salads", id: catSoups });
insertItem("New England Clam Chowder", "", catSoups, 0, 1, 0, 0, 0, 0, 1);
insertItem("Chili Bowl", "With Cheese and Onion", catSoups, 0, 1, 0, 0, 0, 0, 2);
salad1 = insertItem("Beach Salad", "Spring mix greens, tomatoes, cucumbers, feta cheese, and carrots served in a crisp flour tortilla shell", catSoups, 0, 1, 0, 0, 0, 0, 3);
linkTemplate(salad1, tplDressing, 1);
linkTemplate(salad1, tplSaladProtein, 2);
salad2 = insertItem("Caesar Salad", "Romaine lettuce, croutons, and Caesar dressing", catSoups, 0, 1, 0, 0, 0, 0, 4);
linkTemplate(salad2, tplSaladProtein, 1);
arrayAppend(actions, { step: "ITEMS", category: "Soups & Salads", count: 4 });
// --- CATEGORY: Tacos ---
catTacos = insertItem("Tacos", "Served two per order on flour tortillas unless otherwise noted", 0, 0, 1, 0, 0, 0, 0, 5);
arrayAppend(actions, { step: "CATEGORY", name: "Tacos", id: catTacos });
insertItem("Grilled Chicken Tacos", "Taco sauce, cheese, lettuce, tomatoes, and ranch", catTacos, 0, 1, 0, 0, 0, 0, 1);
insertItem("Fried Fish Tacos", "Taco sauce, cheese, lettuce, tomatoes, and ranch", catTacos, 0, 1, 0, 0, 0, 0, 2);
insertItem("Mahi Mahi Tacos", "Cheese, cabbage, and tomatoes on corn tortillas with a side of guacamole and salsa", catTacos, 0, 1, 0, 0, 0, 0, 3);
insertItem("Steak Fajita Tacos", "Grilled with peppers, onions, and a side of cheese, tomatoes, and lettuce", catTacos, 0, 1, 0, 0, 0, 0, 4);
insertItem("Shrimp Fajita Tacos", "Grilled with peppers, onions, and a side of cheese, tomatoes, and lettuce", catTacos, 0, 1, 0, 0, 0, 0, 5);
arrayAppend(actions, { step: "ITEMS", category: "Tacos", count: 5 });
// --- CATEGORY: Seafood ---
catSeafood = insertItem("Seafood", "", 0, 0, 1, 0, 0, 0, 0, 6);
arrayAppend(actions, { step: "CATEGORY", name: "Seafood", id: catSeafood });
insertItem("Fried Fantail Shrimp", "With Fries", catSeafood, 0, 1, 0, 0, 0, 0, 1);
insertItem("Clam Boat with Fries", "", catSeafood, 0, 1, 0, 0, 0, 0, 2);
seafood3 = insertItem("Fish and Chips", "", catSeafood, 0, 1, 0, 0, 0, 0, 3);
linkTemplate(seafood3, tplPortion, 1);
insertItem("Grilled Shrimp & Baby Scallop Skewers", "", catSeafood, 0, 1, 0, 0, 0, 0, 4);
arrayAppend(actions, { step: "ITEMS", category: "Seafood", count: 4 });
// --- CATEGORY: Hot Dogs & Such ---
catHotDogs = insertItem("Hot Dogs & Such", "", 0, 0, 1, 0, 0, 0, 0, 7);
arrayAppend(actions, { step: "CATEGORY", name: "Hot Dogs & Such", id: catHotDogs });
hotdog1 = insertItem("Hot Dog", "Topped with onions, tomatoes, and relish", catHotDogs, 0, 1, 0, 0, 0, 0, 1);
linkTemplate(hotdog1, tplHotDogExtras, 1);
insertItem("Corn Dog", "", catHotDogs, 0, 1, 0, 0, 0, 0, 2);
hotdog3 = insertItem("Chili Dog", "Topped with all beef chili", catHotDogs, 0, 1, 0, 0, 0, 0, 3);
linkTemplate(hotdog3, tplHotDogExtras, 1);
arrayAppend(actions, { step: "ITEMS", category: "Hot Dogs & Such", count: 3 });
// ============================================================
// SUMMARY
// ============================================================
// Count totals
if (!dryRun) {
qCount = queryExecute("SELECT COUNT(*) as cnt FROM Items WHERE ItemBusinessID = :bizId", { bizId: bizId });
totalItems = qCount.cnt;
qTemplateCount = queryExecute("SELECT COUNT(*) as cnt FROM Items WHERE ItemBusinessID = :bizId AND ItemParentItemID = 0 AND ItemIsCollapsible = 1", { bizId: bizId });
templateCount = qTemplateCount.cnt;
qLinkCount = queryExecute("
SELECT COUNT(*) as cnt FROM ItemTemplateLinks tl
JOIN Items i ON i.ItemID = tl.ItemID
WHERE i.ItemBusinessID = :bizId
", { bizId: bizId });
linkCount = qLinkCount.cnt;
} else {
totalItems = "N/A (dry run)";
templateCount = "N/A (dry run)";
linkCount = "N/A (dry run)";
}
writeOutput(serializeJSON({
"OK": true,
"DryRun": dryRun,
"BusinessID": bizId,
"TotalItems": totalItems,
"TemplateCount": templateCount,
"TemplateLinkCount": linkCount,
"Actions": actions
}));
</cfscript>

70
api/stations/list.cfm Normal file
View file

@ -0,0 +1,70 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cffunction name="readJsonBody" access="public" returntype="struct" output="false">
<cfset var raw = getHttpRequestData().content>
<cfif isNull(raw) OR len(trim(raw)) EQ 0>
<cfreturn {}>
</cfif>
<cftry>
<cfset var data = deserializeJSON(raw)>
<cfif isStruct(data)>
<cfreturn data>
<cfelse>
<cfreturn {}>
</cfif>
<cfcatch>
<cfreturn {}>
</cfcatch>
</cftry>
</cffunction>
<cffunction name="apiAbort" access="public" returntype="void" output="true">
<cfargument name="payload" type="struct" required="true">
<cfcontent type="application/json; charset=utf-8">
<cfoutput>#serializeJSON(arguments.payload)#</cfoutput>
<cfabort>
</cffunction>
<cfset data = readJsonBody()>
<cfset BusinessID = val( structKeyExists(data,"BusinessID") ? data.BusinessID : 0 )>
<cfif BusinessID LTE 0>
<cfset apiAbort({ "OK": false, "ERROR": "missing_businessid", "MESSAGE": "BusinessID is required." })>
</cfif>
<cftry>
<cfset q = queryExecute("
SELECT StationID, StationBusinessID, StationName, StationColor, StationSortOrder, StationIsActive
FROM Stations
WHERE StationBusinessID = ? AND StationIsActive = 1
ORDER BY StationSortOrder, StationID
", [ { value = BusinessID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
<cfset rows = []>
<cfloop query="q">
<cfset arrayAppend(rows, {
"StationID": q.StationID,
"StationBusinessID": q.StationBusinessID,
"StationName": q.StationName,
"StationColor": q.StationColor,
"StationSortOrder": q.StationSortOrder
})>
</cfloop>
<cfset apiAbort({
"OK": true,
"ERROR": "",
"STATIONS": rows,
"COUNT": arrayLen(rows)
})>
<cfcatch>
<cfset apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": "DB error loading stations",
"DETAIL": cfcatch.message
})>
</cfcatch>
</cftry>

415
portal/login.html Normal file
View file

@ -0,0 +1,415 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - Payfrit Business Portal</title>
<link rel="stylesheet" href="portal.css">
<style>
body {
background: var(--bg-primary);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-container {
width: 100%;
max-width: 420px;
padding: 20px;
}
.login-card {
background: var(--bg-card);
border-radius: 16px;
padding: 40px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
}
.login-header {
text-align: center;
margin-bottom: 32px;
}
.login-logo {
width: 64px;
height: 64px;
background: var(--primary);
color: #000;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
font-weight: 700;
margin: 0 auto 16px;
}
.login-header h1 {
font-size: 24px;
margin: 0 0 8px;
color: var(--text-primary);
}
.login-header p {
color: var(--text-muted);
margin: 0;
font-size: 14px;
}
.login-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
}
.form-group input {
padding: 14px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 15px;
transition: all 0.2s;
}
.form-group input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(0, 255, 136, 0.1);
}
.form-group input::placeholder {
color: var(--text-muted);
}
.login-btn {
padding: 14px 24px;
background: var(--primary);
color: #000;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
margin-top: 8px;
}
.login-btn:hover {
background: var(--primary-hover);
transform: translateY(-1px);
}
.login-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.login-error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
display: none;
}
.login-error.show {
display: block;
}
.login-footer {
text-align: center;
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid var(--border-color);
}
.login-footer a {
color: var(--primary);
text-decoration: none;
font-size: 14px;
}
.login-footer a:hover {
text-decoration: underline;
}
.business-select {
padding: 14px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 15px;
width: 100%;
cursor: pointer;
}
.business-select:focus {
outline: none;
border-color: var(--primary);
}
.step {
display: none;
}
.step.active {
display: block;
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<div class="login-logo">P</div>
<h1>Business Portal</h1>
<p>Sign in to manage your business</p>
</div>
<!-- Step 1: Login -->
<div class="step active" id="step-login">
<form class="login-form" id="loginForm">
<div class="login-error" id="loginError"></div>
<div class="form-group">
<label for="username">Email or Phone</label>
<input type="text" id="username" name="username" placeholder="Enter your email or phone" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" placeholder="Enter your password" required>
</div>
<button type="submit" class="login-btn" id="loginBtn">Sign In</button>
</form>
<div class="login-footer">
<a href="/index.cfm?mode=forgot">Forgot password?</a>
</div>
</div>
<!-- Step 2: Select Business -->
<div class="step" id="step-business">
<form class="login-form" id="businessForm">
<div class="form-group">
<label for="businessSelect">Select Business</label>
<select id="businessSelect" class="business-select" required>
<option value="">Choose a business...</option>
</select>
</div>
<button type="submit" class="login-btn" id="continueBtn">Continue to Portal</button>
</form>
</div>
</div>
</div>
<script>
// Detect base path for API calls (handles local dev at /biz.payfrit.com/)
const BASE_PATH = (() => {
const path = window.location.pathname;
const portalIndex = path.indexOf('/portal/');
if (portalIndex > 0) {
return path.substring(0, portalIndex);
}
return '';
})();
const PortalLogin = {
userToken: null,
userId: null,
businesses: [],
init() {
// Check if already logged in
const savedToken = localStorage.getItem('payfrit_portal_token');
const savedBusiness = localStorage.getItem('payfrit_portal_business');
if (savedToken && savedBusiness) {
// Verify token is still valid
this.verifyAndRedirect(savedToken, savedBusiness);
return;
}
// Setup form handlers
document.getElementById('loginForm').addEventListener('submit', (e) => {
e.preventDefault();
this.login();
});
document.getElementById('businessForm').addEventListener('submit', (e) => {
e.preventDefault();
this.selectBusiness();
});
},
async verifyAndRedirect(token, businessId) {
// For now, just redirect - could add token verification
window.location.href = BASE_PATH + `/portal/index.html?bid=${businessId}`;
},
async login() {
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
const errorEl = document.getElementById('loginError');
const btn = document.getElementById('loginBtn');
errorEl.classList.remove('show');
btn.disabled = true;
btn.textContent = 'Signing in...';
try {
const response = await fetch(BASE_PATH + '/api/auth/login.cfm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
console.log("[Login] Response status:", response.status);
const text = await response.text();
console.log("[Login] Raw response:", text.substring(0, 500));
let data;
try {
data = JSON.parse(text);
} catch (parseErr) {
console.error("[Login] JSON parse error:", parseErr);
errorEl.textContent = "Server error - check browser console (F12)";
errorEl.classList.add("show");
btn.disabled = false;
btn.textContent = "Sign In";
return;
}
if (data.OK && data.Token) {
this.userToken = data.Token;
this.userId = data.UserID;
// Save token
localStorage.setItem('payfrit_portal_token', data.Token);
localStorage.setItem('payfrit_portal_userid', data.UserID);
// Load user's businesses
await this.loadBusinesses();
if (this.businesses.length === 1) {
// Auto-select if only one business
this.selectBusinessById(this.businesses[0].BusinessID);
} else if (this.businesses.length > 1) {
// Show business selection
this.showStep('business');
} else {
// No businesses - show error
errorEl.textContent = 'No businesses associated with this account.';
errorEl.classList.add('show');
}
} else {
errorEl.textContent = data.ERROR || data.MESSAGE || 'Invalid credentials';
errorEl.classList.add('show');
}
} catch (err) {
console.error('[Login] Error:', err);
errorEl.textContent = 'Connection error. Please try again.';
errorEl.classList.add('show');
}
btn.disabled = false;
btn.textContent = 'Sign In';
},
async loadBusinesses() {
try {
console.log('[Login] Loading businesses for UserID:', this.userId);
const bizResponse = await fetch(BASE_PATH + '/api/portal/myBusinesses.cfm', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Token': this.userToken
},
body: JSON.stringify({ UserID: this.userId })
});
console.log("[Login] Businesses response status:", bizResponse.status);
const bizText = await bizResponse.text();
console.log("[Login] Businesses raw response:", bizText.substring(0, 500));
let bizData;
try {
bizData = JSON.parse(bizText);
} catch (parseErr) {
console.error("[Login] Businesses JSON parse error:", parseErr);
return;
}
if (bizData.OK && bizData.BUSINESSES) {
this.businesses = bizData.BUSINESSES;
console.log('[Login] Loaded', this.businesses.length, 'businesses');
this.populateBusinessSelect();
} else {
console.error('[Login] Businesses API error:', bizData.ERROR || bizData.MESSAGE);
}
} catch (err) {
console.error('[Login] Error loading businesses:', err);
}
},
populateBusinessSelect() {
const select = document.getElementById('businessSelect');
select.innerHTML = '<option value="">Choose a business...</option>';
this.businesses.forEach(biz => {
const option = document.createElement('option');
option.value = biz.BusinessID;
option.textContent = biz.BusinessName;
select.appendChild(option);
});
},
showStep(step) {
document.querySelectorAll('.step').forEach(s => s.classList.remove('active'));
document.getElementById(`step-${step}`).classList.add('active');
},
selectBusiness() {
const businessId = document.getElementById('businessSelect').value;
if (businessId) {
this.selectBusinessById(businessId);
}
},
selectBusinessById(businessId) {
localStorage.setItem('payfrit_portal_business', businessId);
window.location.href = BASE_PATH + `/portal/index.html?bid=${businessId}`;
},
logout() {
localStorage.removeItem('payfrit_portal_token');
localStorage.removeItem('payfrit_portal_userid');
localStorage.removeItem('payfrit_portal_business');
window.location.href = BASE_PATH + '/portal/login.html';
}
};
document.addEventListener('DOMContentLoaded', () => PortalLogin.init());
</script>
</body>
</html>

View file

@ -652,6 +652,14 @@
<div class="app" style="flex-direction: column;"> <div class="app" style="flex-direction: column;">
<!-- Toolbar --> <!-- Toolbar -->
<div class="builder-toolbar"> <div class="builder-toolbar">
<div class="toolbar-group">
<a href="/portal/#menu" class="toolbar-btn" title="Back to Portal" style="text-decoration: none;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Back
</a>
</div>
<div class="toolbar-group"> <div class="toolbar-group">
<button class="toolbar-btn" onclick="MenuBuilder.undo()" title="Undo (Ctrl+Z)"> <button class="toolbar-btn" onclick="MenuBuilder.undo()" title="Undo (Ctrl+Z)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@ -756,29 +764,9 @@
</div> </div>
</div> </div>
<h3>Templates</h3> <h3>Modifier Templates</h3>
<div class="palette-section"> <div class="palette-section" id="templateLibrary">
<div class="palette-item" draggable="true" data-type="template-size"> <div style="color: var(--text-muted); font-size: 13px; padding: 8px;">Loading templates...</div>
<div class="icon">📏</div>
<div>
<div class="label">Size Options</div>
<div class="hint">Small, Medium, Large</div>
</div>
</div>
<div class="palette-item" draggable="true" data-type="template-protein">
<div class="icon">🥩</div>
<div>
<div class="label">Protein Options</div>
<div class="hint">Chicken, Beef, Veggie</div>
</div>
</div>
<div class="palette-item" draggable="true" data-type="template-temp">
<div class="icon">🌡️</div>
<div>
<div class="label">Temperature</div>
<div class="hint">Hot, Iced, Blended</div>
</div>
</div>
</div> </div>
<h3>Quick Stats</h3> <h3>Quick Stats</h3>
@ -791,6 +779,10 @@
<span>Items:</span> <span>Items:</span>
<strong id="statItems">0</strong> <strong id="statItems">0</strong>
</div> </div>
<div style="display: flex; justify-content: space-between; padding: 6px 0;">
<span>Templates:</span>
<strong id="statTemplates">0</strong>
</div>
<div style="display: flex; justify-content: space-between; padding: 6px 0;"> <div style="display: flex; justify-content: space-between; padding: 6px 0;">
<span>Photos Missing:</span> <span>Photos Missing:</span>
<strong id="statPhotosMissing" style="color: var(--warning);">0</strong> <strong id="statPhotosMissing" style="color: var(--warning);">0</strong>
@ -899,6 +891,16 @@
<div class="toast-container" id="toastContainer"></div> <div class="toast-container" id="toastContainer"></div>
<script> <script>
// Detect base path for API calls (handles local dev at /biz.payfrit.com/)
const BASE_PATH = (() => {
const path = window.location.pathname;
const portalIndex = path.indexOf('/portal/');
if (portalIndex > 0) {
return path.substring(0, portalIndex);
}
return '';
})();
/** /**
* Payfrit Menu Builder * Payfrit Menu Builder
* Drag-and-drop menu creation with photo tasks * Drag-and-drop menu creation with photo tasks
@ -906,7 +908,7 @@
const MenuBuilder = { const MenuBuilder = {
// Config // Config
config: { config: {
apiBaseUrl: '/api', apiBaseUrl: BASE_PATH + '/api',
businessId: null, businessId: null,
}, },
@ -914,6 +916,8 @@
menu: { menu: {
categories: [] categories: []
}, },
templates: [],
stations: [],
selectedElement: null, selectedElement: null,
selectedData: null, selectedData: null,
undoStack: [], undoStack: [],
@ -924,9 +928,19 @@
async init() { async init() {
console.log('[MenuBuilder] Initializing...'); console.log('[MenuBuilder] Initializing...');
// Get business ID from URL // Check authentication
const token = localStorage.getItem('payfrit_portal_token');
const savedBusiness = localStorage.getItem('payfrit_portal_business');
if (!token || !savedBusiness) {
window.location.href = BASE_PATH + '/portal/login.html';
return;
}
// Get business ID from URL or saved
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
this.config.businessId = parseInt(urlParams.get('bid')) || 17; this.config.businessId = parseInt(urlParams.get('bid')) || parseInt(savedBusiness);
this.config.token = token;
// Load business info // Load business info
await this.loadBusinessInfo(); await this.loadBusinessInfo();
@ -1512,6 +1526,312 @@
`; `;
}, },
// Select modifier and show its properties
selectModifier(itemId, modifierId) {
this.clearSelection();
// Find the modifier element and highlight it
const element = document.querySelector(`.item-card.modifier[data-modifier-id="${modifierId}"]`);
if (element) {
this.selectedElement = element;
element.classList.add('selected');
}
// Find the modifier data
for (const cat of this.menu.categories) {
const item = cat.items.find(i => i.id === itemId);
if (item) {
const modifier = item.modifiers.find(m => m.id === modifierId);
if (modifier) {
this.selectedData = { type: 'modifier', data: modifier, item: item, category: cat };
this.showPropertiesForModifier(modifier, item, cat);
break;
}
}
}
},
// Show properties for modifier
showPropertiesForModifier(modifier, item, category) {
document.getElementById('propertiesContent').innerHTML = `
<div class="property-group">
<label>Modifier Name</label>
<input type="text" id="propModName" value="${this.escapeHtml(modifier.name)}"
onchange="MenuBuilder.updateModifier('${item.id}', '${modifier.id}', 'name', this.value)">
</div>
<div class="property-row">
<div class="property-group">
<label>Extra Price ($)</label>
<input type="number" step="0.01" id="propModPrice" value="${modifier.price || 0}"
onchange="MenuBuilder.updateModifier('${item.id}', '${modifier.id}', 'price', parseFloat(this.value))">
</div>
<div class="property-group">
<label>Default</label>
<select id="propModDefault"
onchange="MenuBuilder.updateModifier('${item.id}', '${modifier.id}', 'isDefault', this.value === 'true')">
<option value="false" ${!modifier.isDefault ? 'selected' : ''}>No</option>
<option value="true" ${modifier.isDefault ? 'selected' : ''}>Yes</option>
</select>
</div>
</div>
<div class="property-group">
<label>Group Name (optional)</label>
<input type="text" id="propModGroup" value="${this.escapeHtml(modifier.group || '')}"
placeholder="e.g., Size, Protein, Temperature"
onchange="MenuBuilder.updateModifier('${item.id}', '${modifier.id}', 'group', this.value)">
<small style="color: var(--text-muted); font-size: 11px;">
Modifiers with the same group name are shown together
</small>
</div>
<div class="property-group">
<label>Sort Order</label>
<input type="number" id="propModSort" value="${modifier.sortOrder || 0}"
onchange="MenuBuilder.updateModifier('${item.id}', '${modifier.id}', 'sortOrder', parseInt(this.value))">
</div>
<div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border-color);">
<p style="color: var(--text-muted); font-size: 12px; margin: 0 0 8px;">
Parent Item: <strong>${this.escapeHtml(item.name)}</strong>
</p>
</div>
<div class="property-group" style="margin-top: 16px;">
<button class="btn btn-danger" onclick="MenuBuilder.deleteModifier('${item.id}', '${modifier.id}')">
Delete Modifier
</button>
</div>
`;
},
// Update modifier
updateModifier(itemId, modifierId, field, value) {
this.saveState();
for (const cat of this.menu.categories) {
const item = cat.items.find(i => i.id === itemId);
if (item) {
const modifier = item.modifiers.find(m => m.id === modifierId);
if (modifier) {
modifier[field] = value;
this.render();
// Re-select the modifier to keep properties panel open
this.selectModifier(itemId, modifierId);
break;
}
}
}
},
// Delete modifier
deleteModifier(itemId, modifierId) {
if (!confirm('Delete this modifier?')) return;
this.saveState();
for (const cat of this.menu.categories) {
const item = cat.items.find(i => i.id === itemId);
if (item) {
item.modifiers = item.modifiers.filter(m => m.id !== modifierId);
break;
}
}
this.clearSelection();
this.render();
this.toast('Modifier deleted', 'success');
},
// Helper: Find an option recursively in the menu tree
findOptionRecursive(options, optionId) {
for (const opt of options) {
if (opt.id === optionId) return opt;
if (opt.options && opt.options.length > 0) {
const found = this.findOptionRecursive(opt.options, optionId);
if (found) return found;
}
}
return null;
},
// Helper: Find parent info for an option
findOptionWithParent(parentId, optionId) {
// First check if parent is a top-level item
for (const cat of this.menu.categories) {
for (const item of cat.items) {
if (item.id === parentId) {
const opt = item.modifiers.find(m => m.id === optionId);
if (opt) return { option: opt, parent: item, parentType: 'item', category: cat };
}
// Check nested modifiers
const result = this.findInModifiers(item.modifiers, parentId, optionId);
if (result) return { ...result, rootItem: item, category: cat };
}
}
return null;
},
findInModifiers(modifiers, parentId, optionId) {
for (const mod of modifiers) {
if (mod.id === parentId) {
const opt = (mod.options || []).find(o => o.id === optionId);
if (opt) return { option: opt, parent: mod, parentType: 'modifier' };
}
if (mod.options && mod.options.length > 0) {
const result = this.findInModifiers(mod.options, parentId, optionId);
if (result) return result;
}
}
return null;
},
// Select option at any depth
selectOption(parentId, optionId, depth) {
this.clearSelection();
const element = document.querySelector(`.item-card.modifier[data-modifier-id="${optionId}"]`);
if (element) {
this.selectedElement = element;
element.classList.add('selected');
}
const result = this.findOptionWithParent(parentId, optionId);
if (result) {
this.selectedData = { type: 'option', data: result.option, parent: result.parent, depth: depth };
this.showPropertiesForOption(result.option, result.parent, depth);
}
},
// Show properties for option at any depth
showPropertiesForOption(option, parent, depth) {
const hasOptions = option.options && option.options.length > 0;
const levelName = depth === 1 ? 'Modifier' : (depth === 2 ? 'Option' : `Level ${depth} Option`);
document.getElementById('propertiesContent').innerHTML = `
<div class="property-group">
<label>${levelName} Name</label>
<input type="text" value="${this.escapeHtml(option.name)}"
onchange="MenuBuilder.updateOption('${parent.id}', '${option.id}', 'name', this.value)">
</div>
<div class="property-row">
<div class="property-group">
<label>Extra Price ($)</label>
<input type="number" step="0.01" value="${option.price || 0}"
onchange="MenuBuilder.updateOption('${parent.id}', '${option.id}', 'price', parseFloat(this.value))">
</div>
<div class="property-group">
<label>Default</label>
<select onchange="MenuBuilder.updateOption('${parent.id}', '${option.id}', 'isDefault', this.value === 'true')">
<option value="false" ${!option.isDefault ? 'selected' : ''}>No</option>
<option value="true" ${option.isDefault ? 'selected' : ''}>Yes</option>
</select>
</div>
</div>
<div class="property-group">
<label>Sort Order</label>
<input type="number" value="${option.sortOrder || 0}"
onchange="MenuBuilder.updateOption('${parent.id}', '${option.id}', 'sortOrder', parseInt(this.value))">
</div>
<div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border-color);">
<p style="color: var(--text-muted); font-size: 12px; margin: 0 0 8px;">
Parent: <strong>${this.escapeHtml(parent.name)}</strong>
</p>
<p style="color: var(--text-muted); font-size: 12px; margin: 0;">
Sub-options: <strong>${hasOptions ? option.options.length : 0}</strong>
</p>
</div>
<div class="property-group" style="margin-top: 16px;">
<button class="btn btn-secondary" onclick="MenuBuilder.addSubOption('${option.id}')">
+ Add Sub-Option
</button>
</div>
<div class="property-group" style="margin-top: 8px;">
<button class="btn btn-danger" onclick="MenuBuilder.deleteOption('${parent.id}', '${option.id}', ${depth})">
Delete ${levelName}
</button>
</div>
`;
},
// Update option at any depth
updateOption(parentId, optionId, field, value) {
this.saveState();
const result = this.findOptionWithParent(parentId, optionId);
if (result && result.option) {
result.option[field] = value;
this.render();
this.selectOption(parentId, optionId, this.selectedData?.depth || 1);
}
},
// Delete option at any depth
deleteOption(parentId, optionId, depth) {
if (!confirm('Delete this option and all its sub-options?')) return;
this.saveState();
// Find and remove from parent's options array
for (const cat of this.menu.categories) {
for (const item of cat.items) {
// Check if parent is the item itself
if (item.id === parentId) {
item.modifiers = item.modifiers.filter(m => m.id !== optionId);
this.clearSelection();
this.render();
this.toast('Option deleted', 'success');
return;
}
// Check nested
if (this.deleteFromModifiers(item.modifiers, parentId, optionId)) {
this.clearSelection();
this.render();
this.toast('Option deleted', 'success');
return;
}
}
}
},
deleteFromModifiers(modifiers, parentId, optionId) {
for (const mod of modifiers) {
if (mod.id === parentId && mod.options) {
const idx = mod.options.findIndex(o => o.id === optionId);
if (idx !== -1) {
mod.options.splice(idx, 1);
return true;
}
}
if (mod.options && mod.options.length > 0) {
if (this.deleteFromModifiers(mod.options, parentId, optionId)) return true;
}
}
return false;
},
// Add sub-option to any option
addSubOption(parentOptionId) {
this.saveState();
const newOption = {
id: 'opt_new_' + Date.now(),
dbId: null,
name: 'New Option',
price: 0,
isDefault: false,
sortOrder: 0,
options: []
};
// Find the parent option and add to its options array
for (const cat of this.menu.categories) {
for (const item of cat.items) {
const found = this.findOptionRecursive(item.modifiers, parentOptionId);
if (found) {
if (!found.options) found.options = [];
found.options.push(newOption);
this.render();
this.toast('Sub-option added', 'success');
return;
}
}
}
},
// Update category // Update category
updateCategory(categoryId, field, value) { updateCategory(categoryId, field, value) {
this.saveState(); this.saveState();
@ -1989,6 +2309,11 @@
const data = await response.json(); const data = await response.json();
if (data.OK && data.MENU) { if (data.OK && data.MENU) {
this.menu = data.MENU; this.menu = data.MENU;
// Store templates from API
if (data.TEMPLATES) {
this.templates = data.TEMPLATES;
this.renderTemplateLibrary();
}
this.render(); this.render();
} }
} catch (err) { } catch (err) {
@ -1996,6 +2321,31 @@
} }
}, },
// Render template library in sidebar
renderTemplateLibrary() {
const container = document.getElementById('templateLibrary');
if (!container || !this.templates) return;
if (this.templates.length === 0) {
container.innerHTML = '<div style="color: var(--text-muted); font-size: 13px; padding: 8px;">No templates yet</div>';
return;
}
container.innerHTML = this.templates.map(tmpl => `
<div class="palette-item template-item" draggable="true" data-type="template" data-template-id="${tmpl.dbId}">
<div class="icon">📋</div>
<div>
<div class="label">${this.escapeHtml(tmpl.name)}</div>
<div class="hint">${tmpl.options ? tmpl.options.length : 0} options</div>
</div>
</div>
`).join('');
// Update template count stat
const statEl = document.getElementById('statTemplates');
if (statEl) statEl.textContent = this.templates.length;
},
// Save menu to API // Save menu to API
async saveMenu() { async saveMenu() {
try { try {
@ -2019,6 +2369,46 @@
} }
}, },
// Recursive function to render modifiers/options at any depth
renderModifiers(modifiers, parentItemId, depth) {
if (!modifiers || modifiers.length === 0) return '';
const indent = 16 + (depth * 16); // Progressive indentation
const iconSize = Math.max(24, 32 - (depth * 4)); // Smaller icons for deeper levels
const icons = ['⚙️', '🔘', '◉', '•']; // Different icons for different levels
const icon = icons[Math.min(depth - 1, icons.length - 1)];
return modifiers.map(mod => {
const hasOptions = mod.options && mod.options.length > 0;
return `
<div class="item-card modifier depth-${depth}" data-modifier-id="${mod.id}" data-parent-item-id="${parentItemId}" data-depth="${depth}"
style="margin-left: ${indent}px;"
onclick="event.stopPropagation(); MenuBuilder.selectOption('${parentItemId}', '${mod.id}', ${depth})">
<div class="drag-handle" style="visibility: hidden;">
<svg width="12" height="12"></svg>
</div>
<div class="item-image" style="width: ${iconSize}px; height: ${iconSize}px; font-size: ${iconSize/2}px;">${icon}</div>
<div class="item-info">
<div class="item-name">${this.escapeHtml(mod.name)}</div>
<div class="item-meta">
${mod.price > 0 ? `<span>+$${mod.price.toFixed(2)}</span>` : ''}
${mod.isDefault ? '<span class="item-type-badge">Default</span>' : ''}
${hasOptions ? `<span class="item-type-badge">${mod.options.length} options</span>` : ''}
</div>
</div>
<div class="item-actions">
<button onclick="event.stopPropagation(); MenuBuilder.deleteOption('${parentItemId}', '${mod.id}', ${depth})" title="Delete">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
</div>
${hasOptions ? this.renderModifiers(mod.options, mod.id, depth + 1) : ''}
`;
}).join('');
},
// Render menu structure // Render menu structure
render() { render() {
const container = document.getElementById('menuStructure'); const container = document.getElementById('menuStructure');
@ -2110,22 +2500,7 @@
</button> </button>
</div> </div>
</div> </div>
${item.modifiers.map(mod => ` ${this.renderModifiers(item.modifiers, item.id, 1)}
<div class="item-card modifier" data-modifier-id="${mod.id}">
<div class="drag-handle" style="visibility: hidden;">
<svg width="12" height="12"></svg>
</div>
<div class="item-image" style="width: 32px; height: 32px; font-size: 14px;">⚙️</div>
<div class="item-info">
<div class="item-name">${this.escapeHtml(mod.name)}</div>
<div class="item-meta">
${mod.price > 0 ? `<span>+$${mod.price.toFixed(2)}</span>` : ''}
${mod.isDefault ? '<span class="item-type-badge">Default</span>' : ''}
${mod.group ? `<span class="item-type-badge">${this.escapeHtml(mod.group)}</span>` : ''}
</div>
</div>
</div>
`).join('')}
`).join('')} `).join('')}
</div> </div>
</div> </div>

View file

@ -3,10 +3,20 @@
* Modern admin interface for business management * Modern admin interface for business management
*/ */
// Detect base path for API calls (handles local dev at /biz.payfrit.com/)
const BASE_PATH = (() => {
const path = window.location.pathname;
const portalIndex = path.indexOf('/portal/');
if (portalIndex > 0) {
return path.substring(0, portalIndex);
}
return '';
})();
const Portal = { const Portal = {
// Configuration // Configuration
config: { config: {
apiBaseUrl: '/api', apiBaseUrl: BASE_PATH + '/api',
businessId: null, businessId: null,
userId: null, userId: null,
}, },
@ -41,10 +51,51 @@ const Portal = {
// Check authentication // Check authentication
async checkAuth() { async checkAuth() {
// Check URL parameter for businessId, otherwise default to 17 // Check if user is logged in
const token = localStorage.getItem('payfrit_portal_token');
const savedBusiness = localStorage.getItem('payfrit_portal_business');
const userId = localStorage.getItem('payfrit_portal_userid');
if (!token || !savedBusiness) {
// Not logged in - redirect to login
window.location.href = BASE_PATH + '/portal/login.html';
return;
}
// Use saved business ID, but allow URL override
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
this.config.businessId = parseInt(urlParams.get('bid')) || 17; this.config.businessId = parseInt(urlParams.get('bid')) || parseInt(savedBusiness);
this.config.userId = 1; this.config.userId = parseInt(userId) || 1;
this.config.token = token;
// Verify user has access to this business
try {
const response = await fetch(`${this.config.apiBaseUrl}/portal/myBusinesses.cfm`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Token': token
},
body: JSON.stringify({ UserID: this.config.userId })
});
const data = await response.json();
if (data.OK && data.BUSINESSES) {
const hasAccess = data.BUSINESSES.some(b => b.BusinessID === this.config.businessId);
if (!hasAccess && data.BUSINESSES.length > 0) {
// User doesn't have access to requested business, use their first business
this.config.businessId = data.BUSINESSES[0].BusinessID;
localStorage.setItem('payfrit_portal_business', this.config.businessId);
} else if (!hasAccess) {
// User has no businesses
this.toast('No businesses associated with your account', 'error');
this.logout();
return;
}
}
} catch (err) {
console.error('[Portal] Auth verification error:', err);
}
// Fetch actual business info // Fetch actual business info
try { try {
@ -66,13 +117,21 @@ const Portal = {
document.getElementById('userAvatar').textContent = 'U'; document.getElementById('userAvatar').textContent = 'U';
} }
} catch (err) { } catch (err) {
console.error('[Portal] Auth error:', err); console.error('[Portal] Business info error:', err);
document.getElementById('businessName').textContent = 'Business #' + this.config.businessId; document.getElementById('businessName').textContent = 'Business #' + this.config.businessId;
document.getElementById('businessAvatar').textContent = 'B'; document.getElementById('businessAvatar').textContent = 'B';
document.getElementById('userAvatar').textContent = 'U'; document.getElementById('userAvatar').textContent = 'U';
} }
}, },
// Logout
logout() {
localStorage.removeItem('payfrit_portal_token');
localStorage.removeItem('payfrit_portal_userid');
localStorage.removeItem('payfrit_portal_business');
window.location.href = BASE_PATH + '/portal/login.html';
},
// Setup navigation // Setup navigation
setupNavigation() { setupNavigation() {
document.querySelectorAll('.nav-item[data-page]').forEach(item => { document.querySelectorAll('.nav-item[data-page]').forEach(item => {
@ -331,6 +390,19 @@ const Portal = {
Open Builder Open Builder
</a> </a>
</div> </div>
<div class="menu-editor-redirect">
<div class="redirect-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="3" width="20" height="14" rx="2"/>
<path d="M8 21h8M12 17v4"/>
</svg>
</div>
<h3>Station Assignment</h3>
<p>Drag items to stations (Grill, Fry, Drinks, etc.) for KDS routing.</p>
<a href="/portal/station-assignment.html?bid=${this.config.businessId}" class="btn btn-secondary btn-lg">
Assign Stations
</a>
</div>
<div class="menu-editor-redirect"> <div class="menu-editor-redirect">
<div class="redirect-icon"> <div class="redirect-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">

View file

@ -0,0 +1,742 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Station Assignment - Payfrit</title>
<link rel="stylesheet" href="portal.css">
<style>
.assignment-container {
display: flex;
height: calc(100vh - 80px);
gap: 24px;
padding: 24px;
background: var(--bg-primary);
}
.panel {
background: var(--bg-card);
border-radius: 12px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.items-panel {
width: 350px;
flex-shrink: 0;
}
.stations-panel {
flex: 1;
display: flex;
flex-direction: column;
}
.panel-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
}
.panel-header h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.panel-header .count {
background: var(--bg-secondary);
padding: 4px 10px;
border-radius: 12px;
font-size: 13px;
color: var(--text-muted);
}
.panel-body {
flex: 1;
overflow-y: auto;
padding: 16px;
}
/* Items list */
.category-group {
margin-bottom: 20px;
}
.category-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
margin-bottom: 8px;
padding: 0 4px;
}
.draggable-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: var(--bg-secondary);
border: 2px solid var(--border-color);
border-radius: 8px;
margin-bottom: 8px;
cursor: grab;
transition: all 0.2s;
}
.draggable-item:hover {
border-color: var(--primary);
background: rgba(0, 255, 136, 0.05);
}
.draggable-item:active {
cursor: grabbing;
}
.draggable-item.dragging {
opacity: 0.5;
transform: scale(0.98);
}
.draggable-item.assigned {
opacity: 0.4;
border-style: dashed;
}
.item-icon {
font-size: 20px;
}
.item-details {
flex: 1;
min-width: 0;
}
.item-name {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-price {
font-size: 13px;
color: var(--primary);
}
.item-station-badge {
font-size: 11px;
padding: 3px 8px;
border-radius: 4px;
background: var(--primary);
color: #000;
}
/* Stations grid */
.stations-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
.station-card {
background: var(--bg-secondary);
border: 2px solid var(--border-color);
border-radius: 12px;
min-height: 200px;
display: flex;
flex-direction: column;
transition: all 0.2s;
}
.station-card.drag-over {
border-color: var(--primary);
background: rgba(0, 255, 136, 0.05);
box-shadow: 0 0 0 4px rgba(0, 255, 136, 0.1);
}
.station-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-bottom: 1px solid var(--border-color);
}
.station-color {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: white;
font-weight: bold;
}
.station-info {
flex: 1;
}
.station-name {
font-weight: 600;
font-size: 16px;
}
.station-count {
font-size: 13px;
color: var(--text-muted);
}
.station-items {
flex: 1;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 100px;
}
.station-items.empty {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 14px;
}
.station-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--bg-card);
border-radius: 6px;
border: 1px solid var(--border-color);
}
.station-item .item-name {
flex: 1;
font-size: 14px;
}
.station-item .remove-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.station-item .remove-btn:hover {
background: var(--bg-secondary);
color: var(--danger);
}
/* Toolbar */
.toolbar {
display: flex;
gap: 12px;
padding: 16px 24px;
background: var(--bg-card);
border-bottom: 1px solid var(--border-color);
}
.toolbar-title {
flex: 1;
display: flex;
align-items: center;
gap: 12px;
}
.toolbar-title h1 {
margin: 0;
font-size: 20px;
}
.toolbar-title .business-name {
color: var(--text-muted);
font-size: 14px;
}
/* Empty state */
.empty-drop {
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
}
.empty-drop svg {
width: 48px;
height: 48px;
margin-bottom: 12px;
opacity: 0.5;
}
/* Filter */
.filter-input {
padding: 10px 14px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 14px;
width: 100%;
margin-bottom: 16px;
}
.filter-input:focus {
outline: none;
border-color: var(--primary);
}
/* Unassigned station */
.station-card.unassigned {
border-style: dashed;
background: transparent;
}
</style>
</head>
<body>
<div class="app" style="flex-direction: column;">
<!-- Toolbar -->
<div class="toolbar">
<div class="toolbar-title">
<a href="/portal/#menu" style="color: var(--text-muted); text-decoration: none;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
</a>
<h1>Station Assignment</h1>
<span class="business-name" id="businessName"></span>
</div>
<button class="btn btn-primary" onclick="StationAssignment.save()">
<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="M17 21v-8H7v8M7 3v5h8"/>
</svg>
Save Changes
</button>
</div>
<div class="assignment-container">
<!-- Left Panel: Menu Items -->
<div class="panel items-panel">
<div class="panel-header">
<h2>Menu Items</h2>
<span class="count" id="itemCount">0 items</span>
</div>
<div class="panel-body">
<input type="text" class="filter-input" placeholder="Filter items..." id="itemFilter" oninput="StationAssignment.filterItems(this.value)">
<div id="itemsList">
<div class="empty-drop">Loading items...</div>
</div>
</div>
</div>
<!-- Right Panel: Stations -->
<div class="panel stations-panel">
<div class="panel-header">
<h2>Stations</h2>
<button class="btn btn-secondary" onclick="StationAssignment.addStation()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M5 12h14"/>
</svg>
Add Station
</button>
</div>
<div class="panel-body">
<div class="stations-grid" id="stationsGrid">
<div class="empty-drop">Loading stations...</div>
</div>
</div>
</div>
</div>
</div>
<!-- Toast Container -->
<div class="toast-container" id="toastContainer"></div>
<script>
// Detect base path for API calls (handles local dev at /biz.payfrit.com/)
const BASE_PATH = (() => {
const path = window.location.pathname;
const portalIndex = path.indexOf('/portal/');
if (portalIndex > 0) {
return path.substring(0, portalIndex);
}
return '';
})();
const StationAssignment = {
config: {
apiBaseUrl: BASE_PATH + '/api',
businessId: null
},
items: [],
stations: [],
assignments: {}, // itemId -> stationId
filterText: '',
async init() {
console.log('[StationAssignment] Initializing...');
// Check authentication
const token = localStorage.getItem('payfrit_portal_token');
const savedBusiness = localStorage.getItem('payfrit_portal_business');
if (!token || !savedBusiness) {
window.location.href = BASE_PATH + '/portal/login.html';
return;
}
const urlParams = new URLSearchParams(window.location.search);
this.config.businessId = parseInt(urlParams.get('bid')) || parseInt(savedBusiness);
this.config.token = token;
await this.loadBusinessInfo();
await this.loadStations();
await this.loadItems();
console.log('[StationAssignment] Ready');
},
async loadBusinessInfo() {
try {
const response = await fetch(`${this.config.apiBaseUrl}/businesses/get.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ BusinessID: this.config.businessId })
});
const data = await response.json();
if (data.OK && data.BUSINESS) {
document.getElementById('businessName').textContent = `- ${data.BUSINESS.BusinessName}`;
}
} catch (err) {
console.error('[StationAssignment] Error:', err);
}
},
async loadStations() {
try {
const response = await fetch(`${this.config.apiBaseUrl}/stations/list.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ BusinessID: this.config.businessId })
});
const data = await response.json();
if (data.OK) {
this.stations = data.STATIONS || [];
}
} catch (err) {
console.error('[StationAssignment] Stations error:', err);
// Demo stations
this.stations = [
{ StationID: 1, StationName: 'Grill', StationColor: '#FF5722' },
{ StationID: 2, StationName: 'Fry', StationColor: '#FFC107' },
{ StationID: 3, StationName: 'Drinks', StationColor: '#2196F3' },
{ StationID: 4, StationName: 'Expo', StationColor: '#4CAF50' }
];
}
this.renderStations();
},
async loadItems() {
try {
const response = await fetch(`${this.config.apiBaseUrl}/menu/items.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ BusinessID: this.config.businessId })
});
const data = await response.json();
if (data.OK) {
// Only top-level items (not modifiers)
this.items = (data.Items || []).filter(item => item.ItemParentItemID === 0);
// Build assignments from existing data
this.items.forEach(item => {
if (item.ItemStationID) {
this.assignments[item.ItemID] = item.ItemStationID;
}
});
}
} catch (err) {
console.error('[StationAssignment] Items error:', err);
// Demo items
this.items = [
{ ItemID: 1, ItemName: 'Cheeseburger', ItemPrice: 8.99, ItemCategoryName: 'Burgers' },
{ ItemID: 2, ItemName: 'Double-Double', ItemPrice: 10.99, ItemCategoryName: 'Burgers' },
{ ItemID: 3, ItemName: 'French Fries', ItemPrice: 3.99, ItemCategoryName: 'Sides' },
{ ItemID: 4, ItemName: 'Onion Rings', ItemPrice: 4.99, ItemCategoryName: 'Sides' },
{ ItemID: 5, ItemName: 'Chocolate Shake', ItemPrice: 5.99, ItemCategoryName: 'Drinks' },
{ ItemID: 6, ItemName: 'Lemonade', ItemPrice: 2.99, ItemCategoryName: 'Drinks' }
];
}
this.renderItems();
this.updateStationCounts();
},
renderItems() {
const container = document.getElementById('itemsList');
// Group by category
const categories = {};
this.items.forEach(item => {
const cat = item.ItemCategoryName || 'Uncategorized';
if (!categories[cat]) categories[cat] = [];
categories[cat].push(item);
});
// Filter
const filterLower = this.filterText.toLowerCase();
let html = '';
let visibleCount = 0;
Object.entries(categories).forEach(([catName, items]) => {
const filteredItems = items.filter(item =>
item.ItemName.toLowerCase().includes(filterLower) ||
catName.toLowerCase().includes(filterLower)
);
if (filteredItems.length === 0) return;
html += `<div class="category-group">`;
html += `<div class="category-label">${this.escapeHtml(catName)}</div>`;
filteredItems.forEach(item => {
const assigned = this.assignments[item.ItemID];
const station = assigned ? this.stations.find(s => s.StationID === assigned) : null;
visibleCount++;
html += `
<div class="draggable-item ${assigned ? 'assigned' : ''}"
draggable="true"
data-item-id="${item.ItemID}"
ondragstart="StationAssignment.onDragStart(event, ${item.ItemID})"
ondragend="StationAssignment.onDragEnd(event)">
<span class="item-icon">🍽️</span>
<div class="item-details">
<div class="item-name">${this.escapeHtml(item.ItemName)}</div>
<div class="item-price">$${(item.ItemPrice || 0).toFixed(2)}</div>
</div>
${station ? `<span class="item-station-badge" style="background: ${station.StationColor}">${this.escapeHtml(station.StationName)}</span>` : ''}
</div>
`;
});
html += `</div>`;
});
container.innerHTML = html || '<div class="empty-drop">No items found</div>';
document.getElementById('itemCount').textContent = `${visibleCount} items`;
},
renderStations() {
const container = document.getElementById('stationsGrid');
let html = '';
// Render each station
this.stations.forEach(station => {
const itemsInStation = this.items.filter(item =>
this.assignments[item.ItemID] === station.StationID
);
html += `
<div class="station-card"
data-station-id="${station.StationID}"
ondragover="StationAssignment.onDragOver(event)"
ondragleave="StationAssignment.onDragLeave(event)"
ondrop="StationAssignment.onDrop(event, ${station.StationID})">
<div class="station-header">
<div class="station-color" style="background: ${station.StationColor}">
${station.StationName.charAt(0)}
</div>
<div class="station-info">
<div class="station-name">${this.escapeHtml(station.StationName)}</div>
<div class="station-count">${itemsInStation.length} items</div>
</div>
</div>
<div class="station-items ${itemsInStation.length === 0 ? 'empty' : ''}">
${itemsInStation.length === 0 ?
'Drop items here' :
itemsInStation.map(item => `
<div class="station-item">
<span class="item-icon">🍽️</span>
<span class="item-name">${this.escapeHtml(item.ItemName)}</span>
<button class="remove-btn" onclick="StationAssignment.removeFromStation(${item.ItemID})" title="Remove">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
`).join('')
}
</div>
</div>
`;
});
// Unassigned station (optional - shows items without a station)
const unassignedItems = this.items.filter(item => !this.assignments[item.ItemID]);
if (unassignedItems.length > 0) {
html += `
<div class="station-card unassigned">
<div class="station-header">
<div class="station-color" style="background: #666">?</div>
<div class="station-info">
<div class="station-name">Unassigned</div>
<div class="station-count">${unassignedItems.length} items</div>
</div>
</div>
<div class="station-items">
${unassignedItems.slice(0, 5).map(item => `
<div class="station-item" style="opacity: 0.6;">
<span class="item-icon">🍽️</span>
<span class="item-name">${this.escapeHtml(item.ItemName)}</span>
</div>
`).join('')}
${unassignedItems.length > 5 ? `<div style="text-align: center; color: var(--text-muted); font-size: 13px;">+${unassignedItems.length - 5} more</div>` : ''}
</div>
</div>
`;
}
container.innerHTML = html || '<div class="empty-drop">No stations. Add a station to get started.</div>';
},
updateStationCounts() {
this.renderStations();
},
filterItems(text) {
this.filterText = text;
this.renderItems();
},
// Drag and drop handlers
onDragStart(event, itemId) {
event.dataTransfer.setData('itemId', itemId);
event.target.classList.add('dragging');
},
onDragEnd(event) {
event.target.classList.remove('dragging');
},
onDragOver(event) {
event.preventDefault();
event.currentTarget.classList.add('drag-over');
},
onDragLeave(event) {
event.currentTarget.classList.remove('drag-over');
},
onDrop(event, stationId) {
event.preventDefault();
event.currentTarget.classList.remove('drag-over');
const itemId = parseInt(event.dataTransfer.getData('itemId'));
if (itemId) {
this.assignToStation(itemId, stationId);
}
},
assignToStation(itemId, stationId) {
this.assignments[itemId] = stationId;
this.renderItems();
this.renderStations();
const item = this.items.find(i => i.ItemID === itemId);
const station = this.stations.find(s => s.StationID === stationId);
if (item && station) {
this.toast(`${item.ItemName} assigned to ${station.StationName}`, 'success');
}
},
removeFromStation(itemId) {
delete this.assignments[itemId];
this.renderItems();
this.renderStations();
this.toast('Item removed from station', 'info');
},
async save() {
this.toast('Saving assignments...', 'info');
try {
// Build update payload
const updates = Object.entries(this.assignments).map(([itemId, stationId]) => ({
ItemID: parseInt(itemId),
StationID: stationId
}));
const response = await fetch(`${this.config.apiBaseUrl}/menu/updateStations.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
BusinessID: this.config.businessId,
Assignments: updates
})
});
const data = await response.json();
if (data.OK) {
this.toast('Assignments saved!', 'success');
} else {
this.toast(data.ERROR || 'Failed to save', 'error');
}
} catch (err) {
console.error('[StationAssignment] Save error:', err);
this.toast('Error saving (API not available)', 'error');
}
},
addStation() {
const name = prompt('Station name:');
if (!name) return;
const color = '#' + Math.floor(Math.random()*16777215).toString(16).padStart(6, '0');
this.stations.push({
StationID: Date.now(),
StationName: name,
StationColor: color
});
this.renderStations();
this.toast(`Station "${name}" added`, 'success');
},
toast(message, type = 'info') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
},
escapeHtml(str) {
if (!str) return '';
return String(str).replace(/[&<>"']/g, m => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
})[m]);
}
};
document.addEventListener('DOMContentLoaded', () => StationAssignment.init());
</script>
</body>
</html>