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:
parent
1f4d06edba
commit
51a80b537d
37 changed files with 5893 additions and 225 deletions
|
|
@ -5,7 +5,7 @@
|
|||
sessiontimeout="#CreateTimeSpan(0,0,30,0)#"
|
||||
clientstorage="cookie">
|
||||
|
||||
<CFSET application.datasource = "payfrit_local">
|
||||
<CFSET application.datasource = "payfrit">
|
||||
<cfset application.businessMasterObj = new library.cfc.businessMaster(odbc = application.datasource) />
|
||||
<cfset application.twilioObj = new library.cfc.twilio() />
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
clientmanagement="false"
|
||||
setclientcookies="false"
|
||||
datasource="payfrit"
|
||||
showdebugoutput="false"
|
||||
>
|
||||
|
||||
<!--- Stripe Configuration --->
|
||||
|
|
@ -90,17 +91,38 @@ if (len(request._api_path)) {
|
|||
|
||||
// Portal endpoints
|
||||
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
|
||||
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/updateStations.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/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/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
|
||||
if (findNoCase("/api/stripe/onboard.cfm", request._api_path)) request._api_isPublic = true;
|
||||
|
|
@ -154,3 +176,4 @@ if (!request._api_isPublic) {
|
|||
}
|
||||
}
|
||||
</cfscript>
|
||||
|
||||
|
|
|
|||
35
api/admin/checkUser.cfm
Normal file
35
api/admin/checkUser.cfm
Normal 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>
|
||||
173
api/admin/cleanupCategories.cfm
Normal file
173
api/admin/cleanupCategories.cfm
Normal 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>
|
||||
38
api/admin/debugBigDeansDeactivated.cfm
Normal file
38
api/admin/debugBigDeansDeactivated.cfm
Normal 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>
|
||||
70
api/admin/debugBigDeansLinks.cfm
Normal file
70
api/admin/debugBigDeansLinks.cfm
Normal 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>
|
||||
64
api/admin/debugBigDeansTemplates.cfm
Normal file
64
api/admin/debugBigDeansTemplates.cfm
Normal 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>
|
||||
79
api/admin/debugBigDeansTemplates2.cfm
Normal file
79
api/admin/debugBigDeansTemplates2.cfm
Normal 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>
|
||||
75
api/admin/debugBigDeansTemplates3.cfm
Normal file
75
api/admin/debugBigDeansTemplates3.cfm
Normal 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>
|
||||
47
api/admin/debugTemplateLinks.cfm
Normal file
47
api/admin/debugTemplateLinks.cfm
Normal 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>
|
||||
85
api/admin/deleteOrphanModifiers.cfm
Normal file
85
api/admin/deleteOrphanModifiers.cfm
Normal 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>
|
||||
60
api/admin/deleteOrphans.cfm
Normal file
60
api/admin/deleteOrphans.cfm
Normal 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>
|
||||
19
api/admin/describeItems.cfm
Normal file
19
api/admin/describeItems.cfm
Normal 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>
|
||||
252
api/admin/eliminateCategories.cfm
Normal file
252
api/admin/eliminateCategories.cfm
Normal 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>
|
||||
50
api/admin/fixBigDeansCategories.cfm
Normal file
50
api/admin/fixBigDeansCategories.cfm
Normal 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>
|
||||
84
api/admin/fixBigDeansModifiers.cfm
Normal file
84
api/admin/fixBigDeansModifiers.cfm
Normal 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>
|
||||
159
api/admin/fixShakeFlavors.cfm
Normal file
159
api/admin/fixShakeFlavors.cfm
Normal 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>
|
||||
186
api/admin/migrateModifierTemplates.cfm
Normal file
186
api/admin/migrateModifierTemplates.cfm
Normal 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>
|
||||
60
api/admin/setupModifierTemplates.cfm
Normal file
60
api/admin/setupModifierTemplates.cfm
Normal 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>
|
||||
81
api/admin/setupStations.cfm
Normal file
81
api/admin/setupStations.cfm
Normal 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>
|
||||
38
api/admin/switchBeacons.cfm
Normal file
38
api/admin/switchBeacons.cfm
Normal 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>
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
<cfsetting showdebugoutput="false">
|
||||
<cfsetting enablecfoutputonly="true">
|
||||
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||
<cfheader name="Cache-Control" value="no-store">
|
||||
|
||||
<cfscript>
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
// Get menu data formatted for the builder UI
|
||||
// Input: BusinessID
|
||||
// Output: { OK: true, MENU: { categories: [...] } }
|
||||
|
||||
param name="form.BusinessID" default="0";
|
||||
param name="url.BusinessID" default="#form.BusinessID#";
|
||||
|
||||
businessID = val(url.BusinessID);
|
||||
/**
|
||||
* Get Menu for Builder
|
||||
* Returns categories and items in structured format for the menu builder UI
|
||||
*
|
||||
* POST: { BusinessID: int }
|
||||
*
|
||||
* Unified schema:
|
||||
* - 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 };
|
||||
|
||||
try {
|
||||
if (businessID == 0) {
|
||||
// Try to get from request body
|
||||
// Get request body
|
||||
requestBody = toString(getHttpRequestData().content);
|
||||
requestData = {};
|
||||
if (len(requestBody)) {
|
||||
jsonData = deserializeJSON(requestBody);
|
||||
businessID = val(jsonData.BusinessID ?: 0);
|
||||
requestData = deserializeJSON(requestBody);
|
||||
}
|
||||
|
||||
businessID = 0;
|
||||
if (structKeyExists(requestData, "BusinessID")) {
|
||||
businessID = val(requestData.BusinessID);
|
||||
}
|
||||
|
||||
if (businessID == 0) {
|
||||
throw("BusinessID is required");
|
||||
response["ERROR"] = "missing_business_id";
|
||||
response["MESSAGE"] = "BusinessID is required";
|
||||
writeOutput(serializeJSON(response));
|
||||
abort;
|
||||
}
|
||||
|
||||
// Get categories
|
||||
categories = queryExecute("
|
||||
SELECT CategoryID, CategoryName, CategoryDescription, CategorySortOrder
|
||||
// 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 }, { datasource: "payfrit" });
|
||||
newSchemaActive = (qCheck.cnt > 0);
|
||||
} catch (any e) {
|
||||
newSchemaActive = false;
|
||||
}
|
||||
|
||||
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" });
|
||||
|
||||
// Get all menu items (children of category Items, not templates)
|
||||
qItems = queryExecute("
|
||||
SELECT
|
||||
i.ItemID,
|
||||
i.ItemParentItemID as CategoryItemID,
|
||||
i.ItemName,
|
||||
i.ItemDescription,
|
||||
i.ItemPrice,
|
||||
i.ItemSortOrder,
|
||||
i.ItemIsActive
|
||||
FROM Items i
|
||||
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 CategorySortOrder, CategoryName
|
||||
", { businessID: businessID });
|
||||
ORDER BY CategoryName
|
||||
", { businessID: businessID }, { datasource: "payfrit" });
|
||||
|
||||
menuCategories = [];
|
||||
|
||||
for (cat in categories) {
|
||||
// Get items for this category (without Tasks join which may not exist)
|
||||
items = queryExecute("
|
||||
SELECT i.ItemID, i.ItemName, i.ItemDescription, i.ItemPrice,
|
||||
i.ItemIsActive, i.ItemSortOrder
|
||||
qItems = queryExecute("
|
||||
SELECT
|
||||
i.ItemID,
|
||||
i.ItemCategoryID as CategoryItemID,
|
||||
i.ItemName,
|
||||
i.ItemDescription,
|
||||
i.ItemPrice,
|
||||
i.ItemSortOrder,
|
||||
i.ItemIsActive
|
||||
FROM Items i
|
||||
WHERE i.ItemCategoryID = :categoryID
|
||||
INNER JOIN Categories c ON c.CategoryID = i.ItemCategoryID
|
||||
WHERE c.CategoryBusinessID = :businessID
|
||||
AND i.ItemIsActive = 1
|
||||
AND i.ItemParentItemID = 0
|
||||
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 modifiers for this item
|
||||
modifiers = queryExecute("
|
||||
SELECT ItemID, ItemName, ItemPrice, ItemIsCheckedByDefault, ItemSortOrder
|
||||
FROM Items
|
||||
WHERE ItemParentItemID = :itemID
|
||||
ORDER BY ItemSortOrder, ItemName
|
||||
", { itemID: item.ItemID });
|
||||
// Get all templates for this business (items that appear in ItemTemplateLinks)
|
||||
if (newSchemaActive) {
|
||||
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
|
||||
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 = [];
|
||||
for (mod in modifiers) {
|
||||
arrayAppend(itemModifiers, {
|
||||
"id": mod.ItemID,
|
||||
"name": mod.ItemName,
|
||||
"price": mod.ItemPrice,
|
||||
"isDefault": mod.ItemIsCheckedByDefault == 1,
|
||||
"sortOrder": mod.ItemSortOrder
|
||||
// Get all children of templates (options within modifier groups)
|
||||
// Filter by business and use DISTINCT to avoid duplicates from multiple template links
|
||||
qTemplateChildren = queryExecute("
|
||||
SELECT DISTINCT
|
||||
c.ItemID,
|
||||
c.ItemParentItemID as ParentItemID,
|
||||
c.ItemName,
|
||||
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" });
|
||||
|
||||
// Build lookup of children by template ID
|
||||
childrenByTemplate = {};
|
||||
for (child in qTemplateChildren) {
|
||||
parentID = child.ParentItemID;
|
||||
if (!structKeyExists(childrenByTemplate, parentID)) {
|
||||
childrenByTemplate[parentID] = [];
|
||||
}
|
||||
arrayAppend(childrenByTemplate[parentID], {
|
||||
"id": "opt_" & child.ItemID,
|
||||
"dbId": child.ItemID,
|
||||
"name": child.ItemName,
|
||||
"price": child.ItemPrice,
|
||||
"isDefault": child.IsDefault == 1 ? true : false,
|
||||
"sortOrder": child.ItemSortOrder,
|
||||
"options": []
|
||||
});
|
||||
}
|
||||
|
||||
arrayAppend(categoryItems, {
|
||||
"id": item.ItemID,
|
||||
// Build template lookup with their children
|
||||
templatesById = {};
|
||||
for (tmpl in qTemplates) {
|
||||
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": item.ItemDescription ?: "",
|
||||
"description": isNull(item.ItemDescription) ? "" : item.ItemDescription,
|
||||
"price": item.ItemPrice,
|
||||
"imageUrl": "",
|
||||
"photoTaskId": "",
|
||||
"imageUrl": javaCast("null", ""),
|
||||
"photoTaskId": javaCast("null", ""),
|
||||
"modifiers": itemModifiers,
|
||||
"sortOrder": item.ItemSortOrder
|
||||
});
|
||||
}
|
||||
|
||||
arrayAppend(menuCategories, {
|
||||
"id": cat.CategoryID,
|
||||
// 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": cat.CategoryDescription ?: "",
|
||||
"sortOrder": cat.CategorySortOrder,
|
||||
"items": categoryItems
|
||||
"description": "",
|
||||
"sortOrder": catIndex,
|
||||
"items": catItems
|
||||
});
|
||||
catIndex++;
|
||||
}
|
||||
|
||||
response = {
|
||||
"OK": true,
|
||||
"MENU": {
|
||||
"categories": menuCategories
|
||||
// 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) {
|
||||
response = {
|
||||
"OK": false,
|
||||
"ERROR": e.message,
|
||||
"DETAIL": e.detail ?: ""
|
||||
};
|
||||
response["ERROR"] = "server_error";
|
||||
response["MESSAGE"] = e.message;
|
||||
}
|
||||
|
||||
cfheader(name="Content-Type", value="application/json");
|
||||
writeOutput(serializeJSON(response));
|
||||
</cfscript>
|
||||
|
|
|
|||
|
|
@ -37,6 +37,77 @@
|
|||
</cfif>
|
||||
|
||||
<cftry>
|
||||
<!--- Check if new schema is active (ItemBusinessID column exists and has data) --->
|
||||
<cfset newSchemaActive = false>
|
||||
<cftry>
|
||||
<cfset qCheck = queryExecute(
|
||||
"SELECT COUNT(*) as cnt FROM Items WHERE ItemBusinessID = ? AND ItemBusinessID > 0",
|
||||
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
|
||||
{ datasource = "payfrit" }
|
||||
)>
|
||||
<cfset newSchemaActive = (qCheck.cnt GT 0)>
|
||||
<cfcatch>
|
||||
<!--- Column doesn't exist yet, use old schema --->
|
||||
<cfset newSchemaActive = false>
|
||||
</cfcatch>
|
||||
</cftry>
|
||||
|
||||
<cfif newSchemaActive>
|
||||
<!--- NEW SCHEMA: Categories are Items, templates derived from ItemTemplateLinks --->
|
||||
<cfset q = queryExecute(
|
||||
"
|
||||
SELECT
|
||||
i.ItemID,
|
||||
CASE
|
||||
WHEN i.ItemParentItemID = 0 AND i.ItemIsCollapsible = 0 THEN i.ItemID
|
||||
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
|
||||
|
|
@ -52,16 +123,20 @@
|
|||
i.ItemRequiresChildSelection,
|
||||
i.ItemMaxNumSelectionReq,
|
||||
i.ItemIsCollapsible,
|
||||
i.ItemSortOrder
|
||||
i.ItemSortOrder,
|
||||
i.ItemStationID,
|
||||
s.StationName,
|
||||
s.StationColor
|
||||
FROM Items i
|
||||
INNER JOIN Categories c
|
||||
ON c.CategoryID = i.ItemCategoryID
|
||||
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 = []>
|
||||
<cfloop query="q">
|
||||
|
|
@ -78,15 +153,150 @@
|
|||
"ItemRequiresChildSelection": q.ItemRequiresChildSelection,
|
||||
"ItemMaxNumSelectionReq": q.ItemMaxNumSelectionReq,
|
||||
"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>
|
||||
|
||||
<!--- 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({
|
||||
"OK": true,
|
||||
"ERROR": "",
|
||||
"Items": rows,
|
||||
"COUNT": arrayLen(rows)
|
||||
"COUNT": arrayLen(rows),
|
||||
"SCHEMA": newSchemaActive ? "unified" : "legacy"
|
||||
})>
|
||||
|
||||
<cfcatch>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
// Save menu data from the builder UI
|
||||
// Input: BusinessID, Menu (JSON structure)
|
||||
// Output: { OK: true }
|
||||
//
|
||||
// Supports both old schema (Categories table) and new unified schema (Categories as Items)
|
||||
|
||||
response = { "OK": false };
|
||||
|
||||
|
|
@ -23,14 +25,65 @@ try {
|
|||
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
|
||||
for (cat in menu.categories) {
|
||||
categoryID = 0;
|
||||
categoryDbId = structKeyExists(cat, "dbId") ? val(cat.dbId) : 0;
|
||||
|
||||
// Check if it's an existing category (numeric ID) or new (temp_ prefix)
|
||||
if (isNumeric(cat.id)) {
|
||||
categoryID = val(cat.id);
|
||||
// Update existing category
|
||||
if (newSchemaActive) {
|
||||
// NEW SCHEMA: Categories are Items with ParentID=0 and Template=0
|
||||
if (categoryDbId > 0) {
|
||||
categoryID = categoryDbId;
|
||||
// Update existing category Item
|
||||
queryExecute("
|
||||
UPDATE Items
|
||||
SET ItemName = :name,
|
||||
ItemSortOrder = :sortOrder
|
||||
WHERE ItemID = :categoryID
|
||||
AND ItemBusinessID = :businessID
|
||||
", {
|
||||
categoryID: categoryID,
|
||||
businessID: businessID,
|
||||
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 {
|
||||
// OLD SCHEMA: Use Categories table
|
||||
if (categoryDbId > 0) {
|
||||
categoryID = categoryDbId;
|
||||
queryExecute("
|
||||
UPDATE Categories
|
||||
SET CategoryName = :name,
|
||||
|
|
@ -44,7 +97,6 @@ try {
|
|||
sortOrder: val(cat.sortOrder ?: 0)
|
||||
});
|
||||
} else {
|
||||
// Insert new category
|
||||
queryExecute("
|
||||
INSERT INTO Categories (CategoryBusinessID, CategoryName, CategoryDescription, CategorySortOrder)
|
||||
VALUES (:businessID, :name, :description, :sortOrder)
|
||||
|
|
@ -55,19 +107,40 @@ try {
|
|||
sortOrder: val(cat.sortOrder ?: 0)
|
||||
});
|
||||
|
||||
// Get the new category ID
|
||||
result = queryExecute("SELECT LAST_INSERT_ID() as newID");
|
||||
categoryID = result.newID;
|
||||
}
|
||||
}
|
||||
|
||||
// Process items in this category
|
||||
if (structKeyExists(cat, "items") && isArray(cat.items)) {
|
||||
for (item in cat.items) {
|
||||
itemID = 0;
|
||||
itemDbId = structKeyExists(item, "dbId") ? val(item.dbId) : 0;
|
||||
|
||||
if (isNumeric(item.id)) {
|
||||
itemID = val(item.id);
|
||||
// Update existing item (without ImageURL which may not exist)
|
||||
if (itemDbId > 0) {
|
||||
itemID = itemDbId;
|
||||
|
||||
if (newSchemaActive) {
|
||||
// Update existing item - set parent to category Item
|
||||
queryExecute("
|
||||
UPDATE Items
|
||||
SET ItemName = :name,
|
||||
ItemDescription = :description,
|
||||
ItemPrice = :price,
|
||||
ItemParentItemID = :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 {
|
||||
// Update existing item - old schema with CategoryID
|
||||
queryExecute("
|
||||
UPDATE Items
|
||||
SET ItemName = :name,
|
||||
|
|
@ -84,11 +157,18 @@ try {
|
|||
categoryID: categoryID,
|
||||
sortOrder: val(item.sortOrder ?: 0)
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Insert new item (without ImageURL which may not exist)
|
||||
// Insert new item
|
||||
if (newSchemaActive) {
|
||||
queryExecute("
|
||||
INSERT INTO Items (ItemBusinessID, ItemCategoryID, ItemName, ItemDescription, ItemPrice, ItemSortOrder, ItemIsActive, ItemParentItemID)
|
||||
VALUES (:businessID, :categoryID, :name, :description, :price, :sortOrder, 1, 0)
|
||||
INSERT INTO Items (
|
||||
ItemBusinessID, ItemParentItemID, ItemName, ItemDescription,
|
||||
ItemPrice, ItemSortOrder, ItemIsActive, ItemAddedOn
|
||||
) VALUES (
|
||||
:businessID, :categoryID, :name, :description,
|
||||
:price, :sortOrder, 1, NOW()
|
||||
)
|
||||
", {
|
||||
businessID: businessID,
|
||||
categoryID: categoryID,
|
||||
|
|
@ -97,16 +177,54 @@ try {
|
|||
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");
|
||||
itemID = result.newID;
|
||||
}
|
||||
|
||||
// Process modifiers for this item
|
||||
// Handle template links for 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) {
|
||||
if (isNumeric(mod.id)) {
|
||||
// Update existing modifier
|
||||
modDbId = structKeyExists(mod, "dbId") ? val(mod.dbId) : 0;
|
||||
|
||||
// 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("
|
||||
UPDATE Items
|
||||
SET ItemName = :name,
|
||||
|
|
@ -115,34 +233,39 @@ try {
|
|||
ItemSortOrder = :sortOrder
|
||||
WHERE ItemID = :modID
|
||||
", {
|
||||
modID: val(mod.id),
|
||||
modID: modDbId,
|
||||
name: mod.name,
|
||||
price: val(mod.price ?: 0),
|
||||
isDefault: (mod.isDefault ?: false) ? 1 : 0,
|
||||
sortOrder: val(mod.sortOrder ?: 0)
|
||||
sortOrder: modSortOrder
|
||||
});
|
||||
} else {
|
||||
// Insert new modifier
|
||||
// Insert new direct modifier (non-template)
|
||||
queryExecute("
|
||||
INSERT INTO Items (ItemBusinessID, ItemCategoryID, ItemParentItemID, ItemName, ItemPrice, ItemIsCheckedByDefault, ItemSortOrder, ItemIsActive)
|
||||
VALUES (:businessID, :categoryID, :parentID, :name, :price, :isDefault, :sortOrder, 1)
|
||||
INSERT INTO Items (
|
||||
ItemBusinessID, ItemParentItemID, ItemName, ItemPrice,
|
||||
ItemIsCheckedByDefault, ItemSortOrder, ItemIsActive, ItemAddedOn
|
||||
) VALUES (
|
||||
:businessID, :parentID, :name, :price,
|
||||
:isDefault, :sortOrder, 1, NOW()
|
||||
)
|
||||
", {
|
||||
businessID: businessID,
|
||||
categoryID: categoryID,
|
||||
parentID: itemID,
|
||||
name: mod.name,
|
||||
price: val(mod.price ?: 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) {
|
||||
response = {
|
||||
|
|
|
|||
78
api/menu/updateStations.cfm
Normal file
78
api/menu/updateStations.cfm
Normal 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>
|
||||
75
api/portal/myBusinesses.cfm
Normal file
75
api/portal/myBusinesses.cfm
Normal 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
415
api/setup/analyzeMenu.cfm
Normal 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>
|
||||
341
api/setup/bigdeans_import.json
Normal file
341
api/setup/bigdeans_import.json
Normal 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
|
||||
}
|
||||
150
api/setup/downloadImages.cfm
Normal file
150
api/setup/downloadImages.cfm
Normal 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>
|
||||
366
api/setup/importBusiness.cfm
Normal file
366
api/setup/importBusiness.cfm
Normal 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>
|
||||
346
api/setup/reimportBigDeans.cfm
Normal file
346
api/setup/reimportBigDeans.cfm
Normal 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
70
api/stations/list.cfm
Normal 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
415
portal/login.html
Normal 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>
|
||||
|
|
@ -652,6 +652,14 @@
|
|||
<div class="app" style="flex-direction: column;">
|
||||
<!-- 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">
|
||||
<button class="toolbar-btn" onclick="MenuBuilder.undo()" title="Undo (Ctrl+Z)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
|
|
@ -756,29 +764,9 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Templates</h3>
|
||||
<div class="palette-section">
|
||||
<div class="palette-item" draggable="true" data-type="template-size">
|
||||
<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>
|
||||
<h3>Modifier Templates</h3>
|
||||
<div class="palette-section" id="templateLibrary">
|
||||
<div style="color: var(--text-muted); font-size: 13px; padding: 8px;">Loading templates...</div>
|
||||
</div>
|
||||
|
||||
<h3>Quick Stats</h3>
|
||||
|
|
@ -791,6 +779,10 @@
|
|||
<span>Items:</span>
|
||||
<strong id="statItems">0</strong>
|
||||
</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;">
|
||||
<span>Photos Missing:</span>
|
||||
<strong id="statPhotosMissing" style="color: var(--warning);">0</strong>
|
||||
|
|
@ -899,6 +891,16 @@
|
|||
<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 '';
|
||||
})();
|
||||
|
||||
/**
|
||||
* Payfrit Menu Builder
|
||||
* Drag-and-drop menu creation with photo tasks
|
||||
|
|
@ -906,7 +908,7 @@
|
|||
const MenuBuilder = {
|
||||
// Config
|
||||
config: {
|
||||
apiBaseUrl: '/api',
|
||||
apiBaseUrl: BASE_PATH + '/api',
|
||||
businessId: null,
|
||||
},
|
||||
|
||||
|
|
@ -914,6 +916,8 @@
|
|||
menu: {
|
||||
categories: []
|
||||
},
|
||||
templates: [],
|
||||
stations: [],
|
||||
selectedElement: null,
|
||||
selectedData: null,
|
||||
undoStack: [],
|
||||
|
|
@ -924,9 +928,19 @@
|
|||
async init() {
|
||||
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);
|
||||
this.config.businessId = parseInt(urlParams.get('bid')) || 17;
|
||||
this.config.businessId = parseInt(urlParams.get('bid')) || parseInt(savedBusiness);
|
||||
this.config.token = token;
|
||||
|
||||
// Load business info
|
||||
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
|
||||
updateCategory(categoryId, field, value) {
|
||||
this.saveState();
|
||||
|
|
@ -1989,6 +2309,11 @@
|
|||
const data = await response.json();
|
||||
if (data.OK && data.MENU) {
|
||||
this.menu = data.MENU;
|
||||
// Store templates from API
|
||||
if (data.TEMPLATES) {
|
||||
this.templates = data.TEMPLATES;
|
||||
this.renderTemplateLibrary();
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
} 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
|
||||
async saveMenu() {
|
||||
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() {
|
||||
const container = document.getElementById('menuStructure');
|
||||
|
|
@ -2110,22 +2500,7 @@
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
${item.modifiers.map(mod => `
|
||||
<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('')}
|
||||
${this.renderModifiers(item.modifiers, item.id, 1)}
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,10 +3,20 @@
|
|||
* 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 = {
|
||||
// Configuration
|
||||
config: {
|
||||
apiBaseUrl: '/api',
|
||||
apiBaseUrl: BASE_PATH + '/api',
|
||||
businessId: null,
|
||||
userId: null,
|
||||
},
|
||||
|
|
@ -41,10 +51,51 @@ const Portal = {
|
|||
|
||||
// Check authentication
|
||||
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);
|
||||
this.config.businessId = parseInt(urlParams.get('bid')) || 17;
|
||||
this.config.userId = 1;
|
||||
this.config.businessId = parseInt(urlParams.get('bid')) || parseInt(savedBusiness);
|
||||
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
|
||||
try {
|
||||
|
|
@ -66,13 +117,21 @@ const Portal = {
|
|||
document.getElementById('userAvatar').textContent = 'U';
|
||||
}
|
||||
} 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('businessAvatar').textContent = 'B';
|
||||
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
|
||||
setupNavigation() {
|
||||
document.querySelectorAll('.nav-item[data-page]').forEach(item => {
|
||||
|
|
@ -331,6 +390,19 @@ const Portal = {
|
|||
Open Builder
|
||||
</a>
|
||||
</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="redirect-icon">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
|
|
|
|||
742
portal/station-assignment.html
Normal file
742
portal/station-assignment.html
Normal 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 => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
})[m]);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => StationAssignment.init());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Reference in a new issue