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)#"
|
sessiontimeout="#CreateTimeSpan(0,0,30,0)#"
|
||||||
clientstorage="cookie">
|
clientstorage="cookie">
|
||||||
|
|
||||||
<CFSET application.datasource = "payfrit_local">
|
<CFSET application.datasource = "payfrit">
|
||||||
<cfset application.businessMasterObj = new library.cfc.businessMaster(odbc = application.datasource) />
|
<cfset application.businessMasterObj = new library.cfc.businessMaster(odbc = application.datasource) />
|
||||||
<cfset application.twilioObj = new library.cfc.twilio() />
|
<cfset application.twilioObj = new library.cfc.twilio() />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@
|
||||||
clientmanagement="false"
|
clientmanagement="false"
|
||||||
setclientcookies="false"
|
setclientcookies="false"
|
||||||
datasource="payfrit"
|
datasource="payfrit"
|
||||||
|
showdebugoutput="false"
|
||||||
>
|
>
|
||||||
|
|
||||||
<!--- Stripe Configuration --->
|
<!--- Stripe Configuration --->
|
||||||
|
|
@ -90,17 +91,38 @@ if (len(request._api_path)) {
|
||||||
|
|
||||||
// Portal endpoints
|
// Portal endpoints
|
||||||
if (findNoCase("/api/portal/stats.cfm", request._api_path)) request._api_isPublic = true;
|
if (findNoCase("/api/portal/stats.cfm", request._api_path)) request._api_isPublic = true;
|
||||||
|
if (findNoCase("/api/portal/myBusinesses.cfm", request._api_path)) request._api_isPublic = true;
|
||||||
|
|
||||||
// Menu builder endpoints
|
// Menu builder endpoints
|
||||||
if (findNoCase("/api/menu/getForBuilder.cfm", request._api_path)) request._api_isPublic = true;
|
if (findNoCase("/api/menu/getForBuilder.cfm", request._api_path)) request._api_isPublic = true;
|
||||||
if (findNoCase("/api/menu/saveFromBuilder.cfm", request._api_path)) request._api_isPublic = true;
|
if (findNoCase("/api/menu/saveFromBuilder.cfm", request._api_path)) request._api_isPublic = true;
|
||||||
|
if (findNoCase("/api/menu/updateStations.cfm", request._api_path)) request._api_isPublic = true;
|
||||||
if (findNoCase("/api/tasks/create.cfm", request._api_path)) request._api_isPublic = true;
|
if (findNoCase("/api/tasks/create.cfm", request._api_path)) request._api_isPublic = true;
|
||||||
|
|
||||||
// Admin endpoints
|
// Admin endpoints (protected by localhost check in each file)
|
||||||
if (findNoCase("/api/admin/resetTestData.cfm", request._api_path)) request._api_isPublic = true;
|
if (findNoCase("/api/admin/resetTestData.cfm", request._api_path)) request._api_isPublic = true;
|
||||||
if (findNoCase("/api/admin/debugTasks.cfm", request._api_path)) request._api_isPublic = true;
|
if (findNoCase("/api/admin/debugTasks.cfm", request._api_path)) request._api_isPublic = true;
|
||||||
if (findNoCase("/api/admin/testTaskInsert.cfm", request._api_path)) request._api_isPublic = true;
|
if (findNoCase("/api/admin/testTaskInsert.cfm", request._api_path)) request._api_isPublic = true;
|
||||||
if (findNoCase("/api/admin/debugBusinesses.cfm", request._api_path)) request._api_isPublic = true;
|
if (findNoCase("/api/admin/debugBusinesses.cfm", request._api_path)) request._api_isPublic = true;
|
||||||
|
if (findNoCase("/api/admin/setupStations.cfm", request._api_path)) request._api_isPublic = true;
|
||||||
|
if (findNoCase("/api/admin/setupModifierTemplates.cfm", request._api_path)) request._api_isPublic = true;
|
||||||
|
if (findNoCase("/api/admin/migrateModifierTemplates.cfm", request._api_path)) request._api_isPublic = true;
|
||||||
|
if (findNoCase("/api/admin/deleteOrphanModifiers.cfm", request._api_path)) request._api_isPublic = true;
|
||||||
|
if (findNoCase("/api/admin/fixShakeFlavors.cfm", request._api_path)) request._api_isPublic = true;
|
||||||
|
if (findNoCase("/api/admin/eliminateCategories.cfm", request._api_path)) request._api_isPublic = true;
|
||||||
|
if (findNoCase("/api/admin/cleanupCategories.cfm", request._api_path)) request._api_isPublic = true;
|
||||||
|
if (findNoCase("/api/admin/deleteOrphans.cfm", request._api_path)) request._api_isPublic = true;
|
||||||
|
if (findNoCase("/api/admin/switchBeacons.cfm", request._api_path)) request._api_isPublic = true;
|
||||||
|
if (findNoCase("/api/admin/debugTemplateLinks.cfm", request._api_path)) request._api_isPublic = true;
|
||||||
|
if (findNoCase("/api/admin/fixBigDeansCategories.cfm", request._api_path)) request._api_isPublic = true;
|
||||||
|
|
||||||
|
// Setup/Import endpoints
|
||||||
|
if (findNoCase("/api/setup/importBusiness.cfm", request._api_path)) request._api_isPublic = true;
|
||||||
|
if (findNoCase("/api/setup/analyzeMenu.cfm", request._api_path)) request._api_isPublic = true;
|
||||||
|
if (findNoCase("/api/setup/downloadImages.cfm", request._api_path)) request._api_isPublic = true;
|
||||||
|
|
||||||
|
// Stations endpoints
|
||||||
|
if (findNoCase("/api/stations/list.cfm", request._api_path)) request._api_isPublic = true;
|
||||||
|
|
||||||
// Stripe endpoints
|
// Stripe endpoints
|
||||||
if (findNoCase("/api/stripe/onboard.cfm", request._api_path)) request._api_isPublic = true;
|
if (findNoCase("/api/stripe/onboard.cfm", request._api_path)) request._api_isPublic = true;
|
||||||
|
|
@ -154,3 +176,4 @@ if (!request._api_isPublic) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</cfscript>
|
</cfscript>
|
||||||
|
|
||||||
|
|
|
||||||
35
api/admin/checkUser.cfm
Normal file
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 showdebugoutput="false">
|
||||||
<cfsetting enablecfoutputonly="true">
|
<cfsetting enablecfoutputonly="true">
|
||||||
|
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||||
|
<cfheader name="Cache-Control" value="no-store">
|
||||||
|
|
||||||
<cfscript>
|
<cfscript>
|
||||||
/*
|
/*
|
||||||
|
|
|
||||||
|
|
@ -1,108 +1,318 @@
|
||||||
|
<cfsetting showdebugoutput="false">
|
||||||
|
<cfsetting enablecfoutputonly="true">
|
||||||
|
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||||
|
<cfheader name="Cache-Control" value="no-store">
|
||||||
|
|
||||||
<cfscript>
|
<cfscript>
|
||||||
// Get menu data formatted for the builder UI
|
/**
|
||||||
// Input: BusinessID
|
* Get Menu for Builder
|
||||||
// Output: { OK: true, MENU: { categories: [...] } }
|
* Returns categories and items in structured format for the menu builder UI
|
||||||
|
*
|
||||||
param name="form.BusinessID" default="0";
|
* POST: { BusinessID: int }
|
||||||
param name="url.BusinessID" default="#form.BusinessID#";
|
*
|
||||||
|
* Unified schema:
|
||||||
businessID = val(url.BusinessID);
|
* - Categories = Items at ParentID=0 that have menu items as children
|
||||||
|
* - Templates = Items at ParentID=0 that appear in ItemTemplateLinks
|
||||||
|
* - Menu items have ItemParentItemID pointing to their category
|
||||||
|
* - All items have ItemBusinessID for filtering
|
||||||
|
*/
|
||||||
|
|
||||||
response = { "OK": false };
|
response = { "OK": false };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (businessID == 0) {
|
// Get request body
|
||||||
// Try to get from request body
|
requestBody = toString(getHttpRequestData().content);
|
||||||
requestBody = toString(getHttpRequestData().content);
|
requestData = {};
|
||||||
if (len(requestBody)) {
|
if (len(requestBody)) {
|
||||||
jsonData = deserializeJSON(requestBody);
|
requestData = deserializeJSON(requestBody);
|
||||||
businessID = val(jsonData.BusinessID ?: 0);
|
}
|
||||||
}
|
|
||||||
|
businessID = 0;
|
||||||
|
if (structKeyExists(requestData, "BusinessID")) {
|
||||||
|
businessID = val(requestData.BusinessID);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (businessID == 0) {
|
if (businessID == 0) {
|
||||||
throw("BusinessID is required");
|
response["ERROR"] = "missing_business_id";
|
||||||
|
response["MESSAGE"] = "BusinessID is required";
|
||||||
|
writeOutput(serializeJSON(response));
|
||||||
|
abort;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get categories
|
// Check if new schema is active (ItemBusinessID column exists and has data)
|
||||||
categories = queryExecute("
|
newSchemaActive = false;
|
||||||
SELECT CategoryID, CategoryName, CategoryDescription, CategorySortOrder
|
try {
|
||||||
FROM Categories
|
qCheck = queryExecute("
|
||||||
WHERE CategoryBusinessID = :businessID
|
SELECT COUNT(*) as cnt FROM Items
|
||||||
ORDER BY CategorySortOrder, CategoryName
|
WHERE ItemBusinessID = :businessID AND ItemBusinessID > 0
|
||||||
", { businessID: businessID });
|
", { businessID: businessID }, { datasource: "payfrit" });
|
||||||
|
newSchemaActive = (qCheck.cnt > 0);
|
||||||
|
} catch (any e) {
|
||||||
|
newSchemaActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
menuCategories = [];
|
if (newSchemaActive) {
|
||||||
|
// NEW SCHEMA: Categories are Items at ParentID=0 with children (not in ItemTemplateLinks)
|
||||||
|
qCategories = queryExecute("
|
||||||
|
SELECT DISTINCT
|
||||||
|
p.ItemID as CategoryID,
|
||||||
|
p.ItemName as CategoryName,
|
||||||
|
p.ItemSortOrder
|
||||||
|
FROM Items p
|
||||||
|
INNER JOIN Items c ON c.ItemParentItemID = p.ItemID
|
||||||
|
WHERE p.ItemBusinessID = :businessID
|
||||||
|
AND p.ItemParentItemID = 0
|
||||||
|
AND p.ItemIsActive = 1
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = p.ItemID
|
||||||
|
)
|
||||||
|
ORDER BY p.ItemSortOrder, p.ItemName
|
||||||
|
", { businessID: businessID }, { datasource: "payfrit" });
|
||||||
|
|
||||||
for (cat in categories) {
|
// Get all menu items (children of category Items, not templates)
|
||||||
// Get items for this category (without Tasks join which may not exist)
|
qItems = queryExecute("
|
||||||
items = queryExecute("
|
SELECT
|
||||||
SELECT i.ItemID, i.ItemName, i.ItemDescription, i.ItemPrice,
|
i.ItemID,
|
||||||
i.ItemIsActive, i.ItemSortOrder
|
i.ItemParentItemID as CategoryItemID,
|
||||||
|
i.ItemName,
|
||||||
|
i.ItemDescription,
|
||||||
|
i.ItemPrice,
|
||||||
|
i.ItemSortOrder,
|
||||||
|
i.ItemIsActive
|
||||||
FROM Items i
|
FROM Items i
|
||||||
WHERE i.ItemCategoryID = :categoryID
|
INNER JOIN Items cat ON cat.ItemID = i.ItemParentItemID
|
||||||
|
WHERE i.ItemBusinessID = :businessID
|
||||||
|
AND i.ItemIsActive = 1
|
||||||
|
AND cat.ItemParentItemID = 0
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = cat.ItemID
|
||||||
|
)
|
||||||
|
ORDER BY i.ItemSortOrder, i.ItemName
|
||||||
|
", { businessID: businessID }, { datasource: "payfrit" });
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// OLD SCHEMA: Use Categories table
|
||||||
|
qCategories = queryExecute("
|
||||||
|
SELECT
|
||||||
|
CategoryID,
|
||||||
|
CategoryName,
|
||||||
|
0 as ItemSortOrder
|
||||||
|
FROM Categories
|
||||||
|
WHERE CategoryBusinessID = :businessID
|
||||||
|
ORDER BY CategoryName
|
||||||
|
", { businessID: businessID }, { datasource: "payfrit" });
|
||||||
|
|
||||||
|
qItems = queryExecute("
|
||||||
|
SELECT
|
||||||
|
i.ItemID,
|
||||||
|
i.ItemCategoryID as CategoryItemID,
|
||||||
|
i.ItemName,
|
||||||
|
i.ItemDescription,
|
||||||
|
i.ItemPrice,
|
||||||
|
i.ItemSortOrder,
|
||||||
|
i.ItemIsActive
|
||||||
|
FROM Items i
|
||||||
|
INNER JOIN Categories c ON c.CategoryID = i.ItemCategoryID
|
||||||
|
WHERE c.CategoryBusinessID = :businessID
|
||||||
|
AND i.ItemIsActive = 1
|
||||||
AND i.ItemParentItemID = 0
|
AND i.ItemParentItemID = 0
|
||||||
ORDER BY i.ItemSortOrder, i.ItemName
|
ORDER BY i.ItemSortOrder, i.ItemName
|
||||||
", { categoryID: cat.CategoryID });
|
", { businessID: businessID }, { datasource: "payfrit" });
|
||||||
|
}
|
||||||
|
|
||||||
categoryItems = [];
|
// Get template links (which templates are linked to which menu items)
|
||||||
|
qTemplateLinks = queryExecute("
|
||||||
|
SELECT
|
||||||
|
tl.ItemID as ParentItemID,
|
||||||
|
tl.TemplateItemID,
|
||||||
|
tl.SortOrder,
|
||||||
|
t.ItemName as TemplateName,
|
||||||
|
t.ItemPrice as TemplatePrice,
|
||||||
|
t.ItemIsCheckedByDefault as TemplateIsDefault
|
||||||
|
FROM ItemTemplateLinks tl
|
||||||
|
INNER JOIN Items t ON t.ItemID = tl.TemplateItemID
|
||||||
|
ORDER BY tl.ItemID, tl.SortOrder
|
||||||
|
", {}, { datasource: "payfrit" });
|
||||||
|
|
||||||
for (item in items) {
|
// Get all templates for this business (items that appear in ItemTemplateLinks)
|
||||||
// Get modifiers for this item
|
if (newSchemaActive) {
|
||||||
modifiers = queryExecute("
|
qTemplates = queryExecute("
|
||||||
SELECT ItemID, ItemName, ItemPrice, ItemIsCheckedByDefault, ItemSortOrder
|
SELECT DISTINCT
|
||||||
FROM Items
|
t.ItemID,
|
||||||
WHERE ItemParentItemID = :itemID
|
t.ItemName,
|
||||||
ORDER BY ItemSortOrder, ItemName
|
t.ItemPrice,
|
||||||
", { itemID: item.ItemID });
|
t.ItemIsCheckedByDefault as IsDefault,
|
||||||
|
t.ItemSortOrder,
|
||||||
|
t.ItemRequiresChildSelection as RequiresSelection,
|
||||||
|
t.ItemMaxNumSelectionReq as MaxSelections
|
||||||
|
FROM Items t
|
||||||
|
INNER JOIN ItemTemplateLinks tl ON tl.TemplateItemID = t.ItemID
|
||||||
|
INNER JOIN Items i ON i.ItemID = tl.ItemID
|
||||||
|
WHERE i.ItemBusinessID = :businessID
|
||||||
|
AND t.ItemIsActive = 1
|
||||||
|
ORDER BY t.ItemSortOrder, t.ItemName
|
||||||
|
", { businessID: businessID }, { datasource: "payfrit" });
|
||||||
|
} else {
|
||||||
|
qTemplates = queryExecute("
|
||||||
|
SELECT DISTINCT
|
||||||
|
t.ItemID,
|
||||||
|
t.ItemName,
|
||||||
|
t.ItemPrice,
|
||||||
|
t.ItemIsCheckedByDefault as IsDefault,
|
||||||
|
t.ItemSortOrder,
|
||||||
|
t.ItemRequiresChildSelection as RequiresSelection,
|
||||||
|
t.ItemMaxNumSelectionReq as MaxSelections
|
||||||
|
FROM Items t
|
||||||
|
INNER JOIN ItemTemplateLinks tl ON tl.TemplateItemID = t.ItemID
|
||||||
|
INNER JOIN Items i ON i.ItemID = tl.ItemID
|
||||||
|
INNER JOIN Categories c ON c.CategoryID = i.ItemCategoryID
|
||||||
|
WHERE c.CategoryBusinessID = :businessID
|
||||||
|
AND t.ItemIsActive = 1
|
||||||
|
ORDER BY t.ItemSortOrder, t.ItemName
|
||||||
|
", { businessID: businessID }, { datasource: "payfrit" });
|
||||||
|
}
|
||||||
|
|
||||||
itemModifiers = [];
|
// Get all children of templates (options within modifier groups)
|
||||||
for (mod in modifiers) {
|
// Filter by business and use DISTINCT to avoid duplicates from multiple template links
|
||||||
arrayAppend(itemModifiers, {
|
qTemplateChildren = queryExecute("
|
||||||
"id": mod.ItemID,
|
SELECT DISTINCT
|
||||||
"name": mod.ItemName,
|
c.ItemID,
|
||||||
"price": mod.ItemPrice,
|
c.ItemParentItemID as ParentItemID,
|
||||||
"isDefault": mod.ItemIsCheckedByDefault == 1,
|
c.ItemName,
|
||||||
"sortOrder": mod.ItemSortOrder
|
c.ItemPrice,
|
||||||
});
|
c.ItemIsCheckedByDefault as IsDefault,
|
||||||
}
|
c.ItemSortOrder
|
||||||
|
FROM Items c
|
||||||
|
WHERE c.ItemParentItemID IN (
|
||||||
|
SELECT DISTINCT t.ItemID
|
||||||
|
FROM Items t
|
||||||
|
INNER JOIN ItemTemplateLinks tl ON tl.TemplateItemID = t.ItemID
|
||||||
|
INNER JOIN Items i ON i.ItemID = tl.ItemID
|
||||||
|
WHERE i.ItemBusinessID = :businessID
|
||||||
|
)
|
||||||
|
AND c.ItemIsActive = 1
|
||||||
|
ORDER BY c.ItemSortOrder, c.ItemName
|
||||||
|
", { businessID: businessID }, { datasource: "payfrit" });
|
||||||
|
|
||||||
arrayAppend(categoryItems, {
|
// Build lookup of children by template ID
|
||||||
"id": item.ItemID,
|
childrenByTemplate = {};
|
||||||
"name": item.ItemName,
|
for (child in qTemplateChildren) {
|
||||||
"description": item.ItemDescription ?: "",
|
parentID = child.ParentItemID;
|
||||||
"price": item.ItemPrice,
|
if (!structKeyExists(childrenByTemplate, parentID)) {
|
||||||
"imageUrl": "",
|
childrenByTemplate[parentID] = [];
|
||||||
"photoTaskId": "",
|
|
||||||
"modifiers": itemModifiers,
|
|
||||||
"sortOrder": item.ItemSortOrder
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
arrayAppend(childrenByTemplate[parentID], {
|
||||||
arrayAppend(menuCategories, {
|
"id": "opt_" & child.ItemID,
|
||||||
"id": cat.CategoryID,
|
"dbId": child.ItemID,
|
||||||
"name": cat.CategoryName,
|
"name": child.ItemName,
|
||||||
"description": cat.CategoryDescription ?: "",
|
"price": child.ItemPrice,
|
||||||
"sortOrder": cat.CategorySortOrder,
|
"isDefault": child.IsDefault == 1 ? true : false,
|
||||||
"items": categoryItems
|
"sortOrder": child.ItemSortOrder,
|
||||||
|
"options": []
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
response = {
|
// Build template lookup with their children
|
||||||
"OK": true,
|
templatesById = {};
|
||||||
"MENU": {
|
for (tmpl in qTemplates) {
|
||||||
"categories": menuCategories
|
templateID = tmpl.ItemID;
|
||||||
|
children = structKeyExists(childrenByTemplate, templateID) ? childrenByTemplate[templateID] : [];
|
||||||
|
templatesById[templateID] = {
|
||||||
|
"id": "mod_" & tmpl.ItemID,
|
||||||
|
"dbId": tmpl.ItemID,
|
||||||
|
"name": tmpl.ItemName,
|
||||||
|
"price": tmpl.ItemPrice,
|
||||||
|
"isDefault": tmpl.IsDefault == 1 ? true : false,
|
||||||
|
"sortOrder": tmpl.ItemSortOrder,
|
||||||
|
"isTemplate": true,
|
||||||
|
"requiresSelection": isNull(tmpl.RequiresSelection) ? false : (tmpl.RequiresSelection == 1),
|
||||||
|
"maxSelections": isNull(tmpl.MaxSelections) ? 0 : tmpl.MaxSelections,
|
||||||
|
"options": children
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build modifier lookup by parent ItemID using template links
|
||||||
|
modifiersByItem = {};
|
||||||
|
for (link in qTemplateLinks) {
|
||||||
|
parentID = link.ParentItemID;
|
||||||
|
templateID = link.TemplateItemID;
|
||||||
|
|
||||||
|
if (!structKeyExists(modifiersByItem, parentID)) {
|
||||||
|
modifiersByItem[parentID] = [];
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
if (structKeyExists(templatesById, templateID)) {
|
||||||
|
tmpl = duplicate(templatesById[templateID]);
|
||||||
|
tmpl["sortOrder"] = link.SortOrder;
|
||||||
|
arrayAppend(modifiersByItem[parentID], tmpl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build items lookup by CategoryID
|
||||||
|
itemsByCategory = {};
|
||||||
|
for (item in qItems) {
|
||||||
|
catID = item.CategoryItemID;
|
||||||
|
if (!structKeyExists(itemsByCategory, catID)) {
|
||||||
|
itemsByCategory[catID] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
itemID = item.ItemID;
|
||||||
|
itemModifiers = structKeyExists(modifiersByItem, itemID) ? modifiersByItem[itemID] : [];
|
||||||
|
|
||||||
|
arrayAppend(itemsByCategory[catID], {
|
||||||
|
"id": "item_" & item.ItemID,
|
||||||
|
"dbId": item.ItemID,
|
||||||
|
"name": item.ItemName,
|
||||||
|
"description": isNull(item.ItemDescription) ? "" : item.ItemDescription,
|
||||||
|
"price": item.ItemPrice,
|
||||||
|
"imageUrl": javaCast("null", ""),
|
||||||
|
"photoTaskId": javaCast("null", ""),
|
||||||
|
"modifiers": itemModifiers,
|
||||||
|
"sortOrder": item.ItemSortOrder
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build categories array
|
||||||
|
categories = [];
|
||||||
|
catIndex = 0;
|
||||||
|
for (cat in qCategories) {
|
||||||
|
catID = cat.CategoryID;
|
||||||
|
catItems = structKeyExists(itemsByCategory, catID) ? itemsByCategory[catID] : [];
|
||||||
|
|
||||||
|
arrayAppend(categories, {
|
||||||
|
"id": "cat_" & cat.CategoryID,
|
||||||
|
"dbId": cat.CategoryID,
|
||||||
|
"name": cat.CategoryName,
|
||||||
|
"description": "",
|
||||||
|
"sortOrder": catIndex,
|
||||||
|
"items": catItems
|
||||||
|
});
|
||||||
|
catIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build template library array for the UI
|
||||||
|
templateLibrary = [];
|
||||||
|
for (templateID in templatesById) {
|
||||||
|
arrayAppend(templateLibrary, templatesById[templateID]);
|
||||||
|
}
|
||||||
|
|
||||||
|
response["OK"] = true;
|
||||||
|
response["MENU"] = { "categories": categories };
|
||||||
|
response["TEMPLATES"] = templateLibrary;
|
||||||
|
response["CATEGORY_COUNT"] = arrayLen(categories);
|
||||||
|
response["TEMPLATE_COUNT"] = arrayLen(templateLibrary);
|
||||||
|
response["SCHEMA"] = newSchemaActive ? "unified" : "legacy";
|
||||||
|
|
||||||
|
totalItems = 0;
|
||||||
|
for (cat in categories) {
|
||||||
|
totalItems += arrayLen(cat.items);
|
||||||
|
}
|
||||||
|
response["ITEM_COUNT"] = totalItems;
|
||||||
|
|
||||||
} catch (any e) {
|
} catch (any e) {
|
||||||
response = {
|
response["ERROR"] = "server_error";
|
||||||
"OK": false,
|
response["MESSAGE"] = e.message;
|
||||||
"ERROR": e.message,
|
|
||||||
"DETAIL": e.detail ?: ""
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cfheader(name="Content-Type", value="application/json");
|
|
||||||
writeOutput(serializeJSON(response));
|
writeOutput(serializeJSON(response));
|
||||||
</cfscript>
|
</cfscript>
|
||||||
|
|
|
||||||
|
|
@ -37,31 +37,106 @@
|
||||||
</cfif>
|
</cfif>
|
||||||
|
|
||||||
<cftry>
|
<cftry>
|
||||||
<cfset q = queryExecute(
|
<!--- Check if new schema is active (ItemBusinessID column exists and has data) --->
|
||||||
"
|
<cfset newSchemaActive = false>
|
||||||
SELECT
|
<cftry>
|
||||||
i.ItemID,
|
<cfset qCheck = queryExecute(
|
||||||
i.ItemCategoryID,
|
"SELECT COUNT(*) as cnt FROM Items WHERE ItemBusinessID = ? AND ItemBusinessID > 0",
|
||||||
c.CategoryName,
|
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
|
||||||
i.ItemName,
|
{ datasource = "payfrit" }
|
||||||
i.ItemDescription,
|
)>
|
||||||
i.ItemParentItemID,
|
<cfset newSchemaActive = (qCheck.cnt GT 0)>
|
||||||
i.ItemPrice,
|
<cfcatch>
|
||||||
i.ItemIsActive,
|
<!--- Column doesn't exist yet, use old schema --->
|
||||||
i.ItemIsCheckedByDefault,
|
<cfset newSchemaActive = false>
|
||||||
i.ItemRequiresChildSelection,
|
</cfcatch>
|
||||||
i.ItemMaxNumSelectionReq,
|
</cftry>
|
||||||
i.ItemIsCollapsible,
|
|
||||||
i.ItemSortOrder
|
<cfif newSchemaActive>
|
||||||
FROM Items i
|
<!--- NEW SCHEMA: Categories are Items, templates derived from ItemTemplateLinks --->
|
||||||
INNER JOIN Categories c
|
<cfset q = queryExecute(
|
||||||
ON c.CategoryID = i.ItemCategoryID
|
"
|
||||||
WHERE c.CategoryBusinessID = ?
|
SELECT
|
||||||
ORDER BY i.ItemParentItemID, i.ItemSortOrder, i.ItemID
|
i.ItemID,
|
||||||
",
|
CASE
|
||||||
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
|
WHEN i.ItemParentItemID = 0 AND i.ItemIsCollapsible = 0 THEN i.ItemID
|
||||||
{ datasource = "payfrit" }
|
ELSE COALESCE(
|
||||||
)>
|
(SELECT cat.ItemID FROM Items cat
|
||||||
|
WHERE cat.ItemID = i.ItemParentItemID
|
||||||
|
AND cat.ItemParentItemID = 0
|
||||||
|
AND cat.ItemIsCollapsible = 0),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
END as ItemCategoryID,
|
||||||
|
CASE
|
||||||
|
WHEN i.ItemParentItemID = 0 AND i.ItemIsCollapsible = 0 THEN i.ItemName
|
||||||
|
ELSE COALESCE(
|
||||||
|
(SELECT cat.ItemName FROM Items cat
|
||||||
|
WHERE cat.ItemID = i.ItemParentItemID
|
||||||
|
AND cat.ItemParentItemID = 0
|
||||||
|
AND cat.ItemIsCollapsible = 0),
|
||||||
|
''
|
||||||
|
)
|
||||||
|
END as CategoryName,
|
||||||
|
i.ItemName,
|
||||||
|
i.ItemDescription,
|
||||||
|
i.ItemParentItemID,
|
||||||
|
i.ItemPrice,
|
||||||
|
i.ItemIsActive,
|
||||||
|
i.ItemIsCheckedByDefault,
|
||||||
|
i.ItemRequiresChildSelection,
|
||||||
|
i.ItemMaxNumSelectionReq,
|
||||||
|
i.ItemIsCollapsible,
|
||||||
|
i.ItemSortOrder,
|
||||||
|
i.ItemStationID,
|
||||||
|
s.StationName,
|
||||||
|
s.StationColor
|
||||||
|
FROM Items i
|
||||||
|
LEFT JOIN Stations s ON s.StationID = i.ItemStationID
|
||||||
|
WHERE i.ItemBusinessID = ?
|
||||||
|
AND i.ItemIsActive = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = i.ItemID)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM ItemTemplateLinks tl2 WHERE tl2.TemplateItemID = i.ItemParentItemID)
|
||||||
|
AND (
|
||||||
|
i.ItemParentItemID > 0
|
||||||
|
OR (i.ItemParentItemID = 0 AND i.ItemIsCollapsible = 0)
|
||||||
|
)
|
||||||
|
ORDER BY i.ItemParentItemID, i.ItemSortOrder, i.ItemID
|
||||||
|
",
|
||||||
|
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
|
||||||
|
{ datasource = "payfrit" }
|
||||||
|
)>
|
||||||
|
<cfelse>
|
||||||
|
<!--- OLD SCHEMA: Use Categories table --->
|
||||||
|
<cfset q = queryExecute(
|
||||||
|
"
|
||||||
|
SELECT
|
||||||
|
i.ItemID,
|
||||||
|
i.ItemCategoryID,
|
||||||
|
c.CategoryName,
|
||||||
|
i.ItemName,
|
||||||
|
i.ItemDescription,
|
||||||
|
i.ItemParentItemID,
|
||||||
|
i.ItemPrice,
|
||||||
|
i.ItemIsActive,
|
||||||
|
i.ItemIsCheckedByDefault,
|
||||||
|
i.ItemRequiresChildSelection,
|
||||||
|
i.ItemMaxNumSelectionReq,
|
||||||
|
i.ItemIsCollapsible,
|
||||||
|
i.ItemSortOrder,
|
||||||
|
i.ItemStationID,
|
||||||
|
s.StationName,
|
||||||
|
s.StationColor
|
||||||
|
FROM Items i
|
||||||
|
INNER JOIN Categories c ON c.CategoryID = i.ItemCategoryID
|
||||||
|
LEFT JOIN Stations s ON s.StationID = i.ItemStationID
|
||||||
|
WHERE c.CategoryBusinessID = ?
|
||||||
|
ORDER BY i.ItemParentItemID, i.ItemSortOrder, i.ItemID
|
||||||
|
",
|
||||||
|
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
|
||||||
|
{ datasource = "payfrit" }
|
||||||
|
)>
|
||||||
|
</cfif>
|
||||||
|
|
||||||
<cfset rows = []>
|
<cfset rows = []>
|
||||||
<cfloop query="q">
|
<cfloop query="q">
|
||||||
|
|
@ -78,15 +153,150 @@
|
||||||
"ItemRequiresChildSelection": q.ItemRequiresChildSelection,
|
"ItemRequiresChildSelection": q.ItemRequiresChildSelection,
|
||||||
"ItemMaxNumSelectionReq": q.ItemMaxNumSelectionReq,
|
"ItemMaxNumSelectionReq": q.ItemMaxNumSelectionReq,
|
||||||
"ItemIsCollapsible": q.ItemIsCollapsible,
|
"ItemIsCollapsible": q.ItemIsCollapsible,
|
||||||
"ItemSortOrder": q.ItemSortOrder
|
"ItemSortOrder": q.ItemSortOrder,
|
||||||
|
"ItemStationID": len(trim(q.ItemStationID)) ? q.ItemStationID : "",
|
||||||
|
"ItemStationName": len(trim(q.StationName)) ? q.StationName : "",
|
||||||
|
"ItemStationColor": len(trim(q.StationColor)) ? q.StationColor : ""
|
||||||
})>
|
})>
|
||||||
</cfloop>
|
</cfloop>
|
||||||
|
|
||||||
|
<!--- For unified schema: Add template-linked modifiers as virtual children of menu items --->
|
||||||
|
<cfif newSchemaActive>
|
||||||
|
<!--- Get template links: which menu items use which templates --->
|
||||||
|
<cfset qTemplateLinks = queryExecute(
|
||||||
|
"
|
||||||
|
SELECT
|
||||||
|
tl.ItemID as MenuItemID,
|
||||||
|
tmpl.ItemID as TemplateItemID,
|
||||||
|
tmpl.ItemName as TemplateName,
|
||||||
|
tmpl.ItemDescription as TemplateDescription,
|
||||||
|
tmpl.ItemRequiresChildSelection as TemplateRequired,
|
||||||
|
tmpl.ItemMaxNumSelectionReq as TemplateMaxSelections,
|
||||||
|
tmpl.ItemIsCollapsible as TemplateIsCollapsible,
|
||||||
|
tl.SortOrder as TemplateSortOrder
|
||||||
|
FROM ItemTemplateLinks tl
|
||||||
|
INNER JOIN Items tmpl ON tmpl.ItemID = tl.TemplateItemID AND tmpl.ItemIsActive = 1
|
||||||
|
INNER JOIN Items menuItem ON menuItem.ItemID = tl.ItemID
|
||||||
|
WHERE menuItem.ItemBusinessID = ?
|
||||||
|
AND menuItem.ItemIsActive = 1
|
||||||
|
ORDER BY tl.ItemID, tl.SortOrder
|
||||||
|
",
|
||||||
|
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
|
||||||
|
{ datasource = "payfrit" }
|
||||||
|
)>
|
||||||
|
|
||||||
|
<!--- Get template options --->
|
||||||
|
<cfset qTemplateOptions = queryExecute(
|
||||||
|
"
|
||||||
|
SELECT DISTINCT
|
||||||
|
opt.ItemID as OptionItemID,
|
||||||
|
opt.ItemParentItemID as TemplateItemID,
|
||||||
|
opt.ItemName as OptionName,
|
||||||
|
opt.ItemDescription as OptionDescription,
|
||||||
|
opt.ItemPrice as OptionPrice,
|
||||||
|
opt.ItemIsCheckedByDefault as OptionIsDefault,
|
||||||
|
opt.ItemSortOrder as OptionSortOrder
|
||||||
|
FROM Items opt
|
||||||
|
INNER JOIN ItemTemplateLinks tl ON tl.TemplateItemID = opt.ItemParentItemID
|
||||||
|
INNER JOIN Items menuItem ON menuItem.ItemID = tl.ItemID
|
||||||
|
WHERE menuItem.ItemBusinessID = ?
|
||||||
|
AND menuItem.ItemIsActive = 1
|
||||||
|
AND opt.ItemIsActive = 1
|
||||||
|
ORDER BY opt.ItemParentItemID, opt.ItemSortOrder
|
||||||
|
",
|
||||||
|
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
|
||||||
|
{ datasource = "payfrit" }
|
||||||
|
)>
|
||||||
|
|
||||||
|
<!--- Build template options map: templateID -> [options] --->
|
||||||
|
<cfset templateOptionsMap = {}>
|
||||||
|
<cfloop query="qTemplateOptions">
|
||||||
|
<cfif NOT structKeyExists(templateOptionsMap, qTemplateOptions.TemplateItemID)>
|
||||||
|
<cfset templateOptionsMap[qTemplateOptions.TemplateItemID] = []>
|
||||||
|
</cfif>
|
||||||
|
<cfset arrayAppend(templateOptionsMap[qTemplateOptions.TemplateItemID], {
|
||||||
|
"ItemID": qTemplateOptions.OptionItemID,
|
||||||
|
"ItemName": qTemplateOptions.OptionName,
|
||||||
|
"ItemDescription": qTemplateOptions.OptionDescription,
|
||||||
|
"ItemPrice": qTemplateOptions.OptionPrice,
|
||||||
|
"ItemIsCheckedByDefault": qTemplateOptions.OptionIsDefault,
|
||||||
|
"ItemSortOrder": qTemplateOptions.OptionSortOrder
|
||||||
|
})>
|
||||||
|
</cfloop>
|
||||||
|
|
||||||
|
<!--- Add templates and their options as virtual children --->
|
||||||
|
<!--- Use virtual IDs to make each template instance unique per menu item --->
|
||||||
|
<!--- Virtual ID format: menuItemID * 100000 + templateID for templates --->
|
||||||
|
<!--- menuItemID * 100000 + optionID for options --->
|
||||||
|
<cfset addedTemplates = {}>
|
||||||
|
<cfloop query="qTemplateLinks">
|
||||||
|
<cfset menuItemID = qTemplateLinks.MenuItemID>
|
||||||
|
<cfset templateID = qTemplateLinks.TemplateItemID>
|
||||||
|
<cfset linkKey = menuItemID & "_" & templateID>
|
||||||
|
|
||||||
|
<!--- Skip duplicates --->
|
||||||
|
<cfif structKeyExists(addedTemplates, linkKey)>
|
||||||
|
<cfcontinue>
|
||||||
|
</cfif>
|
||||||
|
<cfset addedTemplates[linkKey] = true>
|
||||||
|
|
||||||
|
<!--- Generate unique virtual ID for this template instance --->
|
||||||
|
<cfset virtualTemplateID = menuItemID * 100000 + templateID>
|
||||||
|
|
||||||
|
<!--- Add template as modifier group (child of menu item) --->
|
||||||
|
<cfset arrayAppend(rows, {
|
||||||
|
"ItemID": virtualTemplateID,
|
||||||
|
"ItemCategoryID": 0,
|
||||||
|
"ItemCategoryName": "",
|
||||||
|
"ItemName": qTemplateLinks.TemplateName,
|
||||||
|
"ItemDescription": qTemplateLinks.TemplateDescription,
|
||||||
|
"ItemParentItemID": menuItemID,
|
||||||
|
"ItemPrice": 0,
|
||||||
|
"ItemIsActive": 1,
|
||||||
|
"ItemIsCheckedByDefault": 0,
|
||||||
|
"ItemRequiresChildSelection": qTemplateLinks.TemplateRequired,
|
||||||
|
"ItemMaxNumSelectionReq": qTemplateLinks.TemplateMaxSelections,
|
||||||
|
"ItemIsCollapsible": qTemplateLinks.TemplateIsCollapsible,
|
||||||
|
"ItemSortOrder": qTemplateLinks.TemplateSortOrder,
|
||||||
|
"ItemStationID": "",
|
||||||
|
"ItemStationName": "",
|
||||||
|
"ItemStationColor": ""
|
||||||
|
})>
|
||||||
|
|
||||||
|
<!--- Add template options as children of virtual template --->
|
||||||
|
<cfif structKeyExists(templateOptionsMap, templateID)>
|
||||||
|
<cfloop array="#templateOptionsMap[templateID]#" index="opt">
|
||||||
|
<!--- Generate unique virtual ID for this option instance --->
|
||||||
|
<cfset virtualOptionID = menuItemID * 100000 + opt.ItemID>
|
||||||
|
<cfset arrayAppend(rows, {
|
||||||
|
"ItemID": virtualOptionID,
|
||||||
|
"ItemCategoryID": 0,
|
||||||
|
"ItemCategoryName": "",
|
||||||
|
"ItemName": opt.ItemName,
|
||||||
|
"ItemDescription": opt.ItemDescription,
|
||||||
|
"ItemParentItemID": virtualTemplateID,
|
||||||
|
"ItemPrice": opt.ItemPrice,
|
||||||
|
"ItemIsActive": 1,
|
||||||
|
"ItemIsCheckedByDefault": opt.ItemIsCheckedByDefault,
|
||||||
|
"ItemRequiresChildSelection": 0,
|
||||||
|
"ItemMaxNumSelectionReq": 0,
|
||||||
|
"ItemIsCollapsible": 0,
|
||||||
|
"ItemSortOrder": opt.ItemSortOrder,
|
||||||
|
"ItemStationID": "",
|
||||||
|
"ItemStationName": "",
|
||||||
|
"ItemStationColor": ""
|
||||||
|
})>
|
||||||
|
</cfloop>
|
||||||
|
</cfif>
|
||||||
|
</cfloop>
|
||||||
|
</cfif>
|
||||||
|
|
||||||
<cfset apiAbort({
|
<cfset apiAbort({
|
||||||
"OK": true,
|
"OK": true,
|
||||||
"ERROR": "",
|
"ERROR": "",
|
||||||
"Items": rows,
|
"Items": rows,
|
||||||
"COUNT": arrayLen(rows)
|
"COUNT": arrayLen(rows),
|
||||||
|
"SCHEMA": newSchemaActive ? "unified" : "legacy"
|
||||||
})>
|
})>
|
||||||
|
|
||||||
<cfcatch>
|
<cfcatch>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
// Save menu data from the builder UI
|
// Save menu data from the builder UI
|
||||||
// Input: BusinessID, Menu (JSON structure)
|
// Input: BusinessID, Menu (JSON structure)
|
||||||
// Output: { OK: true }
|
// Output: { OK: true }
|
||||||
|
//
|
||||||
|
// Supports both old schema (Categories table) and new unified schema (Categories as Items)
|
||||||
|
|
||||||
response = { "OK": false };
|
response = { "OK": false };
|
||||||
|
|
||||||
|
|
@ -23,90 +25,206 @@ try {
|
||||||
throw("Menu categories are required");
|
throw("Menu categories are required");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if new schema is active (ItemBusinessID column exists and has data)
|
||||||
|
newSchemaActive = false;
|
||||||
|
try {
|
||||||
|
qCheck = queryExecute("
|
||||||
|
SELECT COUNT(*) as cnt FROM Items
|
||||||
|
WHERE ItemBusinessID = :businessID AND ItemBusinessID > 0
|
||||||
|
", { businessID: businessID });
|
||||||
|
newSchemaActive = (qCheck.cnt > 0);
|
||||||
|
} catch (any e) {
|
||||||
|
newSchemaActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Process each category
|
// Process each category
|
||||||
for (cat in menu.categories) {
|
for (cat in menu.categories) {
|
||||||
categoryID = 0;
|
categoryID = 0;
|
||||||
|
categoryDbId = structKeyExists(cat, "dbId") ? val(cat.dbId) : 0;
|
||||||
|
|
||||||
// Check if it's an existing category (numeric ID) or new (temp_ prefix)
|
if (newSchemaActive) {
|
||||||
if (isNumeric(cat.id)) {
|
// NEW SCHEMA: Categories are Items with ParentID=0 and Template=0
|
||||||
categoryID = val(cat.id);
|
if (categoryDbId > 0) {
|
||||||
// Update existing category
|
categoryID = categoryDbId;
|
||||||
queryExecute("
|
// Update existing category Item
|
||||||
UPDATE Categories
|
queryExecute("
|
||||||
SET CategoryName = :name,
|
UPDATE Items
|
||||||
CategoryDescription = :description,
|
SET ItemName = :name,
|
||||||
CategorySortOrder = :sortOrder
|
ItemSortOrder = :sortOrder
|
||||||
WHERE CategoryID = :categoryID
|
WHERE ItemID = :categoryID
|
||||||
", {
|
AND ItemBusinessID = :businessID
|
||||||
categoryID: categoryID,
|
", {
|
||||||
name: cat.name,
|
categoryID: categoryID,
|
||||||
description: cat.description ?: "",
|
businessID: businessID,
|
||||||
sortOrder: val(cat.sortOrder ?: 0)
|
name: cat.name,
|
||||||
});
|
sortOrder: val(cat.sortOrder ?: 0)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Insert new category as Item
|
||||||
|
queryExecute("
|
||||||
|
INSERT INTO Items (
|
||||||
|
ItemBusinessID, ItemName, ItemDescription,
|
||||||
|
ItemParentItemID, ItemPrice, ItemIsActive,
|
||||||
|
ItemSortOrder, ItemIsModifierTemplate, ItemAddedOn
|
||||||
|
) VALUES (
|
||||||
|
:businessID, :name, '',
|
||||||
|
0, 0, 1,
|
||||||
|
:sortOrder, 0, NOW()
|
||||||
|
)
|
||||||
|
", {
|
||||||
|
businessID: businessID,
|
||||||
|
name: cat.name,
|
||||||
|
sortOrder: val(cat.sortOrder ?: 0)
|
||||||
|
});
|
||||||
|
|
||||||
|
result = queryExecute("SELECT LAST_INSERT_ID() as newID");
|
||||||
|
categoryID = result.newID;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Insert new category
|
// OLD SCHEMA: Use Categories table
|
||||||
queryExecute("
|
if (categoryDbId > 0) {
|
||||||
INSERT INTO Categories (CategoryBusinessID, CategoryName, CategoryDescription, CategorySortOrder)
|
categoryID = categoryDbId;
|
||||||
VALUES (:businessID, :name, :description, :sortOrder)
|
queryExecute("
|
||||||
", {
|
UPDATE Categories
|
||||||
businessID: businessID,
|
SET CategoryName = :name,
|
||||||
name: cat.name,
|
CategoryDescription = :description,
|
||||||
description: cat.description ?: "",
|
CategorySortOrder = :sortOrder
|
||||||
sortOrder: val(cat.sortOrder ?: 0)
|
WHERE CategoryID = :categoryID
|
||||||
});
|
", {
|
||||||
|
categoryID: categoryID,
|
||||||
|
name: cat.name,
|
||||||
|
description: cat.description ?: "",
|
||||||
|
sortOrder: val(cat.sortOrder ?: 0)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
queryExecute("
|
||||||
|
INSERT INTO Categories (CategoryBusinessID, CategoryName, CategoryDescription, CategorySortOrder)
|
||||||
|
VALUES (:businessID, :name, :description, :sortOrder)
|
||||||
|
", {
|
||||||
|
businessID: businessID,
|
||||||
|
name: cat.name,
|
||||||
|
description: cat.description ?: "",
|
||||||
|
sortOrder: val(cat.sortOrder ?: 0)
|
||||||
|
});
|
||||||
|
|
||||||
// Get the new category ID
|
result = queryExecute("SELECT LAST_INSERT_ID() as newID");
|
||||||
result = queryExecute("SELECT LAST_INSERT_ID() as newID");
|
categoryID = result.newID;
|
||||||
categoryID = result.newID;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process items in this category
|
// Process items in this category
|
||||||
if (structKeyExists(cat, "items") && isArray(cat.items)) {
|
if (structKeyExists(cat, "items") && isArray(cat.items)) {
|
||||||
for (item in cat.items) {
|
for (item in cat.items) {
|
||||||
itemID = 0;
|
itemID = 0;
|
||||||
|
itemDbId = structKeyExists(item, "dbId") ? val(item.dbId) : 0;
|
||||||
|
|
||||||
if (isNumeric(item.id)) {
|
if (itemDbId > 0) {
|
||||||
itemID = val(item.id);
|
itemID = itemDbId;
|
||||||
// Update existing item (without ImageURL which may not exist)
|
|
||||||
queryExecute("
|
if (newSchemaActive) {
|
||||||
UPDATE Items
|
// Update existing item - set parent to category Item
|
||||||
SET ItemName = :name,
|
queryExecute("
|
||||||
ItemDescription = :description,
|
UPDATE Items
|
||||||
ItemPrice = :price,
|
SET ItemName = :name,
|
||||||
ItemCategoryID = :categoryID,
|
ItemDescription = :description,
|
||||||
ItemSortOrder = :sortOrder
|
ItemPrice = :price,
|
||||||
WHERE ItemID = :itemID
|
ItemParentItemID = :categoryID,
|
||||||
", {
|
ItemSortOrder = :sortOrder
|
||||||
itemID: itemID,
|
WHERE ItemID = :itemID
|
||||||
name: item.name,
|
", {
|
||||||
description: item.description ?: "",
|
itemID: itemID,
|
||||||
price: val(item.price ?: 0),
|
name: item.name,
|
||||||
categoryID: categoryID,
|
description: item.description ?: "",
|
||||||
sortOrder: val(item.sortOrder ?: 0)
|
price: val(item.price ?: 0),
|
||||||
});
|
categoryID: categoryID,
|
||||||
|
sortOrder: val(item.sortOrder ?: 0)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Update existing item - old schema with CategoryID
|
||||||
|
queryExecute("
|
||||||
|
UPDATE Items
|
||||||
|
SET ItemName = :name,
|
||||||
|
ItemDescription = :description,
|
||||||
|
ItemPrice = :price,
|
||||||
|
ItemCategoryID = :categoryID,
|
||||||
|
ItemSortOrder = :sortOrder
|
||||||
|
WHERE ItemID = :itemID
|
||||||
|
", {
|
||||||
|
itemID: itemID,
|
||||||
|
name: item.name,
|
||||||
|
description: item.description ?: "",
|
||||||
|
price: val(item.price ?: 0),
|
||||||
|
categoryID: categoryID,
|
||||||
|
sortOrder: val(item.sortOrder ?: 0)
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Insert new item (without ImageURL which may not exist)
|
// Insert new item
|
||||||
queryExecute("
|
if (newSchemaActive) {
|
||||||
INSERT INTO Items (ItemBusinessID, ItemCategoryID, ItemName, ItemDescription, ItemPrice, ItemSortOrder, ItemIsActive, ItemParentItemID)
|
queryExecute("
|
||||||
VALUES (:businessID, :categoryID, :name, :description, :price, :sortOrder, 1, 0)
|
INSERT INTO Items (
|
||||||
", {
|
ItemBusinessID, ItemParentItemID, ItemName, ItemDescription,
|
||||||
businessID: businessID,
|
ItemPrice, ItemSortOrder, ItemIsActive, ItemAddedOn
|
||||||
categoryID: categoryID,
|
) VALUES (
|
||||||
name: item.name,
|
:businessID, :categoryID, :name, :description,
|
||||||
description: item.description ?: "",
|
:price, :sortOrder, 1, NOW()
|
||||||
price: val(item.price ?: 0),
|
)
|
||||||
sortOrder: val(item.sortOrder ?: 0)
|
", {
|
||||||
});
|
businessID: businessID,
|
||||||
|
categoryID: categoryID,
|
||||||
|
name: item.name,
|
||||||
|
description: item.description ?: "",
|
||||||
|
price: val(item.price ?: 0),
|
||||||
|
sortOrder: val(item.sortOrder ?: 0)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
queryExecute("
|
||||||
|
INSERT INTO Items (
|
||||||
|
ItemBusinessID, ItemCategoryID, ItemName, ItemDescription,
|
||||||
|
ItemPrice, ItemSortOrder, ItemIsActive, ItemParentItemID
|
||||||
|
) VALUES (
|
||||||
|
:businessID, :categoryID, :name, :description,
|
||||||
|
:price, :sortOrder, 1, 0
|
||||||
|
)
|
||||||
|
", {
|
||||||
|
businessID: businessID,
|
||||||
|
categoryID: categoryID,
|
||||||
|
name: item.name,
|
||||||
|
description: item.description ?: "",
|
||||||
|
price: val(item.price ?: 0),
|
||||||
|
sortOrder: val(item.sortOrder ?: 0)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
result = queryExecute("SELECT LAST_INSERT_ID() as newID");
|
result = queryExecute("SELECT LAST_INSERT_ID() as newID");
|
||||||
itemID = result.newID;
|
itemID = result.newID;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process modifiers for this item
|
// Handle template links for modifiers
|
||||||
if (structKeyExists(item, "modifiers") && isArray(item.modifiers)) {
|
if (structKeyExists(item, "modifiers") && isArray(item.modifiers)) {
|
||||||
|
// Clear existing template links for this item
|
||||||
|
queryExecute("
|
||||||
|
DELETE FROM ItemTemplateLinks WHERE ItemID = :itemID
|
||||||
|
", { itemID: itemID });
|
||||||
|
|
||||||
|
modSortOrder = 0;
|
||||||
for (mod in item.modifiers) {
|
for (mod in item.modifiers) {
|
||||||
if (isNumeric(mod.id)) {
|
modDbId = structKeyExists(mod, "dbId") ? val(mod.dbId) : 0;
|
||||||
// Update existing modifier
|
|
||||||
|
// Check if this is a template reference
|
||||||
|
if (structKeyExists(mod, "isTemplate") && mod.isTemplate && modDbId > 0) {
|
||||||
|
// Create template link
|
||||||
|
queryExecute("
|
||||||
|
INSERT INTO ItemTemplateLinks (ItemID, TemplateItemID, SortOrder)
|
||||||
|
VALUES (:itemID, :templateID, :sortOrder)
|
||||||
|
ON DUPLICATE KEY UPDATE SortOrder = :sortOrder
|
||||||
|
", {
|
||||||
|
itemID: itemID,
|
||||||
|
templateID: modDbId,
|
||||||
|
sortOrder: modSortOrder
|
||||||
|
});
|
||||||
|
} else if (modDbId > 0) {
|
||||||
|
// Update existing direct modifier
|
||||||
queryExecute("
|
queryExecute("
|
||||||
UPDATE Items
|
UPDATE Items
|
||||||
SET ItemName = :name,
|
SET ItemName = :name,
|
||||||
|
|
@ -115,34 +233,39 @@ try {
|
||||||
ItemSortOrder = :sortOrder
|
ItemSortOrder = :sortOrder
|
||||||
WHERE ItemID = :modID
|
WHERE ItemID = :modID
|
||||||
", {
|
", {
|
||||||
modID: val(mod.id),
|
modID: modDbId,
|
||||||
name: mod.name,
|
name: mod.name,
|
||||||
price: val(mod.price ?: 0),
|
price: val(mod.price ?: 0),
|
||||||
isDefault: (mod.isDefault ?: false) ? 1 : 0,
|
isDefault: (mod.isDefault ?: false) ? 1 : 0,
|
||||||
sortOrder: val(mod.sortOrder ?: 0)
|
sortOrder: modSortOrder
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Insert new modifier
|
// Insert new direct modifier (non-template)
|
||||||
queryExecute("
|
queryExecute("
|
||||||
INSERT INTO Items (ItemBusinessID, ItemCategoryID, ItemParentItemID, ItemName, ItemPrice, ItemIsCheckedByDefault, ItemSortOrder, ItemIsActive)
|
INSERT INTO Items (
|
||||||
VALUES (:businessID, :categoryID, :parentID, :name, :price, :isDefault, :sortOrder, 1)
|
ItemBusinessID, ItemParentItemID, ItemName, ItemPrice,
|
||||||
|
ItemIsCheckedByDefault, ItemSortOrder, ItemIsActive, ItemAddedOn
|
||||||
|
) VALUES (
|
||||||
|
:businessID, :parentID, :name, :price,
|
||||||
|
:isDefault, :sortOrder, 1, NOW()
|
||||||
|
)
|
||||||
", {
|
", {
|
||||||
businessID: businessID,
|
businessID: businessID,
|
||||||
categoryID: categoryID,
|
|
||||||
parentID: itemID,
|
parentID: itemID,
|
||||||
name: mod.name,
|
name: mod.name,
|
||||||
price: val(mod.price ?: 0),
|
price: val(mod.price ?: 0),
|
||||||
isDefault: (mod.isDefault ?: false) ? 1 : 0,
|
isDefault: (mod.isDefault ?: false) ? 1 : 0,
|
||||||
sortOrder: val(mod.sortOrder ?: 0)
|
sortOrder: modSortOrder
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
modSortOrder++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
response = { "OK": true };
|
response = { "OK": true, "SCHEMA": newSchemaActive ? "unified" : "legacy" };
|
||||||
|
|
||||||
} catch (any e) {
|
} catch (any e) {
|
||||||
response = {
|
response = {
|
||||||
|
|
|
||||||
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;">
|
<div class="app" style="flex-direction: column;">
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
<div class="builder-toolbar">
|
<div class="builder-toolbar">
|
||||||
|
<div class="toolbar-group">
|
||||||
|
<a href="/portal/#menu" class="toolbar-btn" title="Back to Portal" style="text-decoration: none;">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
Back
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<div class="toolbar-group">
|
<div class="toolbar-group">
|
||||||
<button class="toolbar-btn" onclick="MenuBuilder.undo()" title="Undo (Ctrl+Z)">
|
<button class="toolbar-btn" onclick="MenuBuilder.undo()" title="Undo (Ctrl+Z)">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
|
@ -756,29 +764,9 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>Templates</h3>
|
<h3>Modifier Templates</h3>
|
||||||
<div class="palette-section">
|
<div class="palette-section" id="templateLibrary">
|
||||||
<div class="palette-item" draggable="true" data-type="template-size">
|
<div style="color: var(--text-muted); font-size: 13px; padding: 8px;">Loading templates...</div>
|
||||||
<div class="icon">📏</div>
|
|
||||||
<div>
|
|
||||||
<div class="label">Size Options</div>
|
|
||||||
<div class="hint">Small, Medium, Large</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="palette-item" draggable="true" data-type="template-protein">
|
|
||||||
<div class="icon">🥩</div>
|
|
||||||
<div>
|
|
||||||
<div class="label">Protein Options</div>
|
|
||||||
<div class="hint">Chicken, Beef, Veggie</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="palette-item" draggable="true" data-type="template-temp">
|
|
||||||
<div class="icon">🌡️</div>
|
|
||||||
<div>
|
|
||||||
<div class="label">Temperature</div>
|
|
||||||
<div class="hint">Hot, Iced, Blended</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>Quick Stats</h3>
|
<h3>Quick Stats</h3>
|
||||||
|
|
@ -791,6 +779,10 @@
|
||||||
<span>Items:</span>
|
<span>Items:</span>
|
||||||
<strong id="statItems">0</strong>
|
<strong id="statItems">0</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="display: flex; justify-content: space-between; padding: 6px 0;">
|
||||||
|
<span>Templates:</span>
|
||||||
|
<strong id="statTemplates">0</strong>
|
||||||
|
</div>
|
||||||
<div style="display: flex; justify-content: space-between; padding: 6px 0;">
|
<div style="display: flex; justify-content: space-between; padding: 6px 0;">
|
||||||
<span>Photos Missing:</span>
|
<span>Photos Missing:</span>
|
||||||
<strong id="statPhotosMissing" style="color: var(--warning);">0</strong>
|
<strong id="statPhotosMissing" style="color: var(--warning);">0</strong>
|
||||||
|
|
@ -899,6 +891,16 @@
|
||||||
<div class="toast-container" id="toastContainer"></div>
|
<div class="toast-container" id="toastContainer"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// Detect base path for API calls (handles local dev at /biz.payfrit.com/)
|
||||||
|
const BASE_PATH = (() => {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
const portalIndex = path.indexOf('/portal/');
|
||||||
|
if (portalIndex > 0) {
|
||||||
|
return path.substring(0, portalIndex);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
})();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Payfrit Menu Builder
|
* Payfrit Menu Builder
|
||||||
* Drag-and-drop menu creation with photo tasks
|
* Drag-and-drop menu creation with photo tasks
|
||||||
|
|
@ -906,7 +908,7 @@
|
||||||
const MenuBuilder = {
|
const MenuBuilder = {
|
||||||
// Config
|
// Config
|
||||||
config: {
|
config: {
|
||||||
apiBaseUrl: '/api',
|
apiBaseUrl: BASE_PATH + '/api',
|
||||||
businessId: null,
|
businessId: null,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -914,6 +916,8 @@
|
||||||
menu: {
|
menu: {
|
||||||
categories: []
|
categories: []
|
||||||
},
|
},
|
||||||
|
templates: [],
|
||||||
|
stations: [],
|
||||||
selectedElement: null,
|
selectedElement: null,
|
||||||
selectedData: null,
|
selectedData: null,
|
||||||
undoStack: [],
|
undoStack: [],
|
||||||
|
|
@ -924,9 +928,19 @@
|
||||||
async init() {
|
async init() {
|
||||||
console.log('[MenuBuilder] Initializing...');
|
console.log('[MenuBuilder] Initializing...');
|
||||||
|
|
||||||
// Get business ID from URL
|
// Check authentication
|
||||||
|
const token = localStorage.getItem('payfrit_portal_token');
|
||||||
|
const savedBusiness = localStorage.getItem('payfrit_portal_business');
|
||||||
|
|
||||||
|
if (!token || !savedBusiness) {
|
||||||
|
window.location.href = BASE_PATH + '/portal/login.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get business ID from URL or saved
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
this.config.businessId = parseInt(urlParams.get('bid')) || 17;
|
this.config.businessId = parseInt(urlParams.get('bid')) || parseInt(savedBusiness);
|
||||||
|
this.config.token = token;
|
||||||
|
|
||||||
// Load business info
|
// Load business info
|
||||||
await this.loadBusinessInfo();
|
await this.loadBusinessInfo();
|
||||||
|
|
@ -1512,6 +1526,312 @@
|
||||||
`;
|
`;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Select modifier and show its properties
|
||||||
|
selectModifier(itemId, modifierId) {
|
||||||
|
this.clearSelection();
|
||||||
|
|
||||||
|
// Find the modifier element and highlight it
|
||||||
|
const element = document.querySelector(`.item-card.modifier[data-modifier-id="${modifierId}"]`);
|
||||||
|
if (element) {
|
||||||
|
this.selectedElement = element;
|
||||||
|
element.classList.add('selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the modifier data
|
||||||
|
for (const cat of this.menu.categories) {
|
||||||
|
const item = cat.items.find(i => i.id === itemId);
|
||||||
|
if (item) {
|
||||||
|
const modifier = item.modifiers.find(m => m.id === modifierId);
|
||||||
|
if (modifier) {
|
||||||
|
this.selectedData = { type: 'modifier', data: modifier, item: item, category: cat };
|
||||||
|
this.showPropertiesForModifier(modifier, item, cat);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Show properties for modifier
|
||||||
|
showPropertiesForModifier(modifier, item, category) {
|
||||||
|
document.getElementById('propertiesContent').innerHTML = `
|
||||||
|
<div class="property-group">
|
||||||
|
<label>Modifier Name</label>
|
||||||
|
<input type="text" id="propModName" value="${this.escapeHtml(modifier.name)}"
|
||||||
|
onchange="MenuBuilder.updateModifier('${item.id}', '${modifier.id}', 'name', this.value)">
|
||||||
|
</div>
|
||||||
|
<div class="property-row">
|
||||||
|
<div class="property-group">
|
||||||
|
<label>Extra Price ($)</label>
|
||||||
|
<input type="number" step="0.01" id="propModPrice" value="${modifier.price || 0}"
|
||||||
|
onchange="MenuBuilder.updateModifier('${item.id}', '${modifier.id}', 'price', parseFloat(this.value))">
|
||||||
|
</div>
|
||||||
|
<div class="property-group">
|
||||||
|
<label>Default</label>
|
||||||
|
<select id="propModDefault"
|
||||||
|
onchange="MenuBuilder.updateModifier('${item.id}', '${modifier.id}', 'isDefault', this.value === 'true')">
|
||||||
|
<option value="false" ${!modifier.isDefault ? 'selected' : ''}>No</option>
|
||||||
|
<option value="true" ${modifier.isDefault ? 'selected' : ''}>Yes</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="property-group">
|
||||||
|
<label>Group Name (optional)</label>
|
||||||
|
<input type="text" id="propModGroup" value="${this.escapeHtml(modifier.group || '')}"
|
||||||
|
placeholder="e.g., Size, Protein, Temperature"
|
||||||
|
onchange="MenuBuilder.updateModifier('${item.id}', '${modifier.id}', 'group', this.value)">
|
||||||
|
<small style="color: var(--text-muted); font-size: 11px;">
|
||||||
|
Modifiers with the same group name are shown together
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="property-group">
|
||||||
|
<label>Sort Order</label>
|
||||||
|
<input type="number" id="propModSort" value="${modifier.sortOrder || 0}"
|
||||||
|
onchange="MenuBuilder.updateModifier('${item.id}', '${modifier.id}', 'sortOrder', parseInt(this.value))">
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border-color);">
|
||||||
|
<p style="color: var(--text-muted); font-size: 12px; margin: 0 0 8px;">
|
||||||
|
Parent Item: <strong>${this.escapeHtml(item.name)}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="property-group" style="margin-top: 16px;">
|
||||||
|
<button class="btn btn-danger" onclick="MenuBuilder.deleteModifier('${item.id}', '${modifier.id}')">
|
||||||
|
Delete Modifier
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update modifier
|
||||||
|
updateModifier(itemId, modifierId, field, value) {
|
||||||
|
this.saveState();
|
||||||
|
for (const cat of this.menu.categories) {
|
||||||
|
const item = cat.items.find(i => i.id === itemId);
|
||||||
|
if (item) {
|
||||||
|
const modifier = item.modifiers.find(m => m.id === modifierId);
|
||||||
|
if (modifier) {
|
||||||
|
modifier[field] = value;
|
||||||
|
this.render();
|
||||||
|
// Re-select the modifier to keep properties panel open
|
||||||
|
this.selectModifier(itemId, modifierId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete modifier
|
||||||
|
deleteModifier(itemId, modifierId) {
|
||||||
|
if (!confirm('Delete this modifier?')) return;
|
||||||
|
this.saveState();
|
||||||
|
for (const cat of this.menu.categories) {
|
||||||
|
const item = cat.items.find(i => i.id === itemId);
|
||||||
|
if (item) {
|
||||||
|
item.modifiers = item.modifiers.filter(m => m.id !== modifierId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.clearSelection();
|
||||||
|
this.render();
|
||||||
|
this.toast('Modifier deleted', 'success');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Helper: Find an option recursively in the menu tree
|
||||||
|
findOptionRecursive(options, optionId) {
|
||||||
|
for (const opt of options) {
|
||||||
|
if (opt.id === optionId) return opt;
|
||||||
|
if (opt.options && opt.options.length > 0) {
|
||||||
|
const found = this.findOptionRecursive(opt.options, optionId);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Helper: Find parent info for an option
|
||||||
|
findOptionWithParent(parentId, optionId) {
|
||||||
|
// First check if parent is a top-level item
|
||||||
|
for (const cat of this.menu.categories) {
|
||||||
|
for (const item of cat.items) {
|
||||||
|
if (item.id === parentId) {
|
||||||
|
const opt = item.modifiers.find(m => m.id === optionId);
|
||||||
|
if (opt) return { option: opt, parent: item, parentType: 'item', category: cat };
|
||||||
|
}
|
||||||
|
// Check nested modifiers
|
||||||
|
const result = this.findInModifiers(item.modifiers, parentId, optionId);
|
||||||
|
if (result) return { ...result, rootItem: item, category: cat };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
findInModifiers(modifiers, parentId, optionId) {
|
||||||
|
for (const mod of modifiers) {
|
||||||
|
if (mod.id === parentId) {
|
||||||
|
const opt = (mod.options || []).find(o => o.id === optionId);
|
||||||
|
if (opt) return { option: opt, parent: mod, parentType: 'modifier' };
|
||||||
|
}
|
||||||
|
if (mod.options && mod.options.length > 0) {
|
||||||
|
const result = this.findInModifiers(mod.options, parentId, optionId);
|
||||||
|
if (result) return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Select option at any depth
|
||||||
|
selectOption(parentId, optionId, depth) {
|
||||||
|
this.clearSelection();
|
||||||
|
|
||||||
|
const element = document.querySelector(`.item-card.modifier[data-modifier-id="${optionId}"]`);
|
||||||
|
if (element) {
|
||||||
|
this.selectedElement = element;
|
||||||
|
element.classList.add('selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = this.findOptionWithParent(parentId, optionId);
|
||||||
|
if (result) {
|
||||||
|
this.selectedData = { type: 'option', data: result.option, parent: result.parent, depth: depth };
|
||||||
|
this.showPropertiesForOption(result.option, result.parent, depth);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Show properties for option at any depth
|
||||||
|
showPropertiesForOption(option, parent, depth) {
|
||||||
|
const hasOptions = option.options && option.options.length > 0;
|
||||||
|
const levelName = depth === 1 ? 'Modifier' : (depth === 2 ? 'Option' : `Level ${depth} Option`);
|
||||||
|
|
||||||
|
document.getElementById('propertiesContent').innerHTML = `
|
||||||
|
<div class="property-group">
|
||||||
|
<label>${levelName} Name</label>
|
||||||
|
<input type="text" value="${this.escapeHtml(option.name)}"
|
||||||
|
onchange="MenuBuilder.updateOption('${parent.id}', '${option.id}', 'name', this.value)">
|
||||||
|
</div>
|
||||||
|
<div class="property-row">
|
||||||
|
<div class="property-group">
|
||||||
|
<label>Extra Price ($)</label>
|
||||||
|
<input type="number" step="0.01" value="${option.price || 0}"
|
||||||
|
onchange="MenuBuilder.updateOption('${parent.id}', '${option.id}', 'price', parseFloat(this.value))">
|
||||||
|
</div>
|
||||||
|
<div class="property-group">
|
||||||
|
<label>Default</label>
|
||||||
|
<select onchange="MenuBuilder.updateOption('${parent.id}', '${option.id}', 'isDefault', this.value === 'true')">
|
||||||
|
<option value="false" ${!option.isDefault ? 'selected' : ''}>No</option>
|
||||||
|
<option value="true" ${option.isDefault ? 'selected' : ''}>Yes</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="property-group">
|
||||||
|
<label>Sort Order</label>
|
||||||
|
<input type="number" value="${option.sortOrder || 0}"
|
||||||
|
onchange="MenuBuilder.updateOption('${parent.id}', '${option.id}', 'sortOrder', parseInt(this.value))">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border-color);">
|
||||||
|
<p style="color: var(--text-muted); font-size: 12px; margin: 0 0 8px;">
|
||||||
|
Parent: <strong>${this.escapeHtml(parent.name)}</strong>
|
||||||
|
</p>
|
||||||
|
<p style="color: var(--text-muted); font-size: 12px; margin: 0;">
|
||||||
|
Sub-options: <strong>${hasOptions ? option.options.length : 0}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="property-group" style="margin-top: 16px;">
|
||||||
|
<button class="btn btn-secondary" onclick="MenuBuilder.addSubOption('${option.id}')">
|
||||||
|
+ Add Sub-Option
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="property-group" style="margin-top: 8px;">
|
||||||
|
<button class="btn btn-danger" onclick="MenuBuilder.deleteOption('${parent.id}', '${option.id}', ${depth})">
|
||||||
|
Delete ${levelName}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update option at any depth
|
||||||
|
updateOption(parentId, optionId, field, value) {
|
||||||
|
this.saveState();
|
||||||
|
const result = this.findOptionWithParent(parentId, optionId);
|
||||||
|
if (result && result.option) {
|
||||||
|
result.option[field] = value;
|
||||||
|
this.render();
|
||||||
|
this.selectOption(parentId, optionId, this.selectedData?.depth || 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete option at any depth
|
||||||
|
deleteOption(parentId, optionId, depth) {
|
||||||
|
if (!confirm('Delete this option and all its sub-options?')) return;
|
||||||
|
this.saveState();
|
||||||
|
|
||||||
|
// Find and remove from parent's options array
|
||||||
|
for (const cat of this.menu.categories) {
|
||||||
|
for (const item of cat.items) {
|
||||||
|
// Check if parent is the item itself
|
||||||
|
if (item.id === parentId) {
|
||||||
|
item.modifiers = item.modifiers.filter(m => m.id !== optionId);
|
||||||
|
this.clearSelection();
|
||||||
|
this.render();
|
||||||
|
this.toast('Option deleted', 'success');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Check nested
|
||||||
|
if (this.deleteFromModifiers(item.modifiers, parentId, optionId)) {
|
||||||
|
this.clearSelection();
|
||||||
|
this.render();
|
||||||
|
this.toast('Option deleted', 'success');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteFromModifiers(modifiers, parentId, optionId) {
|
||||||
|
for (const mod of modifiers) {
|
||||||
|
if (mod.id === parentId && mod.options) {
|
||||||
|
const idx = mod.options.findIndex(o => o.id === optionId);
|
||||||
|
if (idx !== -1) {
|
||||||
|
mod.options.splice(idx, 1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mod.options && mod.options.length > 0) {
|
||||||
|
if (this.deleteFromModifiers(mod.options, parentId, optionId)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Add sub-option to any option
|
||||||
|
addSubOption(parentOptionId) {
|
||||||
|
this.saveState();
|
||||||
|
|
||||||
|
const newOption = {
|
||||||
|
id: 'opt_new_' + Date.now(),
|
||||||
|
dbId: null,
|
||||||
|
name: 'New Option',
|
||||||
|
price: 0,
|
||||||
|
isDefault: false,
|
||||||
|
sortOrder: 0,
|
||||||
|
options: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find the parent option and add to its options array
|
||||||
|
for (const cat of this.menu.categories) {
|
||||||
|
for (const item of cat.items) {
|
||||||
|
const found = this.findOptionRecursive(item.modifiers, parentOptionId);
|
||||||
|
if (found) {
|
||||||
|
if (!found.options) found.options = [];
|
||||||
|
found.options.push(newOption);
|
||||||
|
this.render();
|
||||||
|
this.toast('Sub-option added', 'success');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Update category
|
// Update category
|
||||||
updateCategory(categoryId, field, value) {
|
updateCategory(categoryId, field, value) {
|
||||||
this.saveState();
|
this.saveState();
|
||||||
|
|
@ -1989,6 +2309,11 @@
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.OK && data.MENU) {
|
if (data.OK && data.MENU) {
|
||||||
this.menu = data.MENU;
|
this.menu = data.MENU;
|
||||||
|
// Store templates from API
|
||||||
|
if (data.TEMPLATES) {
|
||||||
|
this.templates = data.TEMPLATES;
|
||||||
|
this.renderTemplateLibrary();
|
||||||
|
}
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -1996,6 +2321,31 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Render template library in sidebar
|
||||||
|
renderTemplateLibrary() {
|
||||||
|
const container = document.getElementById('templateLibrary');
|
||||||
|
if (!container || !this.templates) return;
|
||||||
|
|
||||||
|
if (this.templates.length === 0) {
|
||||||
|
container.innerHTML = '<div style="color: var(--text-muted); font-size: 13px; padding: 8px;">No templates yet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = this.templates.map(tmpl => `
|
||||||
|
<div class="palette-item template-item" draggable="true" data-type="template" data-template-id="${tmpl.dbId}">
|
||||||
|
<div class="icon">📋</div>
|
||||||
|
<div>
|
||||||
|
<div class="label">${this.escapeHtml(tmpl.name)}</div>
|
||||||
|
<div class="hint">${tmpl.options ? tmpl.options.length : 0} options</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Update template count stat
|
||||||
|
const statEl = document.getElementById('statTemplates');
|
||||||
|
if (statEl) statEl.textContent = this.templates.length;
|
||||||
|
},
|
||||||
|
|
||||||
// Save menu to API
|
// Save menu to API
|
||||||
async saveMenu() {
|
async saveMenu() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -2019,6 +2369,46 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Recursive function to render modifiers/options at any depth
|
||||||
|
renderModifiers(modifiers, parentItemId, depth) {
|
||||||
|
if (!modifiers || modifiers.length === 0) return '';
|
||||||
|
|
||||||
|
const indent = 16 + (depth * 16); // Progressive indentation
|
||||||
|
const iconSize = Math.max(24, 32 - (depth * 4)); // Smaller icons for deeper levels
|
||||||
|
const icons = ['⚙️', '🔘', '◉', '•']; // Different icons for different levels
|
||||||
|
const icon = icons[Math.min(depth - 1, icons.length - 1)];
|
||||||
|
|
||||||
|
return modifiers.map(mod => {
|
||||||
|
const hasOptions = mod.options && mod.options.length > 0;
|
||||||
|
return `
|
||||||
|
<div class="item-card modifier depth-${depth}" data-modifier-id="${mod.id}" data-parent-item-id="${parentItemId}" data-depth="${depth}"
|
||||||
|
style="margin-left: ${indent}px;"
|
||||||
|
onclick="event.stopPropagation(); MenuBuilder.selectOption('${parentItemId}', '${mod.id}', ${depth})">
|
||||||
|
<div class="drag-handle" style="visibility: hidden;">
|
||||||
|
<svg width="12" height="12"></svg>
|
||||||
|
</div>
|
||||||
|
<div class="item-image" style="width: ${iconSize}px; height: ${iconSize}px; font-size: ${iconSize/2}px;">${icon}</div>
|
||||||
|
<div class="item-info">
|
||||||
|
<div class="item-name">${this.escapeHtml(mod.name)}</div>
|
||||||
|
<div class="item-meta">
|
||||||
|
${mod.price > 0 ? `<span>+$${mod.price.toFixed(2)}</span>` : ''}
|
||||||
|
${mod.isDefault ? '<span class="item-type-badge">Default</span>' : ''}
|
||||||
|
${hasOptions ? `<span class="item-type-badge">${mod.options.length} options</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="item-actions">
|
||||||
|
<button onclick="event.stopPropagation(); MenuBuilder.deleteOption('${parentItemId}', '${mod.id}', ${depth})" title="Delete">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${hasOptions ? this.renderModifiers(mod.options, mod.id, depth + 1) : ''}
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
},
|
||||||
|
|
||||||
// Render menu structure
|
// Render menu structure
|
||||||
render() {
|
render() {
|
||||||
const container = document.getElementById('menuStructure');
|
const container = document.getElementById('menuStructure');
|
||||||
|
|
@ -2110,22 +2500,7 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${item.modifiers.map(mod => `
|
${this.renderModifiers(item.modifiers, item.id, 1)}
|
||||||
<div class="item-card modifier" data-modifier-id="${mod.id}">
|
|
||||||
<div class="drag-handle" style="visibility: hidden;">
|
|
||||||
<svg width="12" height="12"></svg>
|
|
||||||
</div>
|
|
||||||
<div class="item-image" style="width: 32px; height: 32px; font-size: 14px;">⚙️</div>
|
|
||||||
<div class="item-info">
|
|
||||||
<div class="item-name">${this.escapeHtml(mod.name)}</div>
|
|
||||||
<div class="item-meta">
|
|
||||||
${mod.price > 0 ? `<span>+$${mod.price.toFixed(2)}</span>` : ''}
|
|
||||||
${mod.isDefault ? '<span class="item-type-badge">Default</span>' : ''}
|
|
||||||
${mod.group ? `<span class="item-type-badge">${this.escapeHtml(mod.group)}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('')}
|
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,20 @@
|
||||||
* Modern admin interface for business management
|
* Modern admin interface for business management
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Detect base path for API calls (handles local dev at /biz.payfrit.com/)
|
||||||
|
const BASE_PATH = (() => {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
const portalIndex = path.indexOf('/portal/');
|
||||||
|
if (portalIndex > 0) {
|
||||||
|
return path.substring(0, portalIndex);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
})();
|
||||||
|
|
||||||
const Portal = {
|
const Portal = {
|
||||||
// Configuration
|
// Configuration
|
||||||
config: {
|
config: {
|
||||||
apiBaseUrl: '/api',
|
apiBaseUrl: BASE_PATH + '/api',
|
||||||
businessId: null,
|
businessId: null,
|
||||||
userId: null,
|
userId: null,
|
||||||
},
|
},
|
||||||
|
|
@ -41,10 +51,51 @@ const Portal = {
|
||||||
|
|
||||||
// Check authentication
|
// Check authentication
|
||||||
async checkAuth() {
|
async checkAuth() {
|
||||||
// Check URL parameter for businessId, otherwise default to 17
|
// Check if user is logged in
|
||||||
|
const token = localStorage.getItem('payfrit_portal_token');
|
||||||
|
const savedBusiness = localStorage.getItem('payfrit_portal_business');
|
||||||
|
const userId = localStorage.getItem('payfrit_portal_userid');
|
||||||
|
|
||||||
|
if (!token || !savedBusiness) {
|
||||||
|
// Not logged in - redirect to login
|
||||||
|
window.location.href = BASE_PATH + '/portal/login.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use saved business ID, but allow URL override
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
this.config.businessId = parseInt(urlParams.get('bid')) || 17;
|
this.config.businessId = parseInt(urlParams.get('bid')) || parseInt(savedBusiness);
|
||||||
this.config.userId = 1;
|
this.config.userId = parseInt(userId) || 1;
|
||||||
|
this.config.token = token;
|
||||||
|
|
||||||
|
// Verify user has access to this business
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.config.apiBaseUrl}/portal/myBusinesses.cfm`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-User-Token': token
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ UserID: this.config.userId })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.OK && data.BUSINESSES) {
|
||||||
|
const hasAccess = data.BUSINESSES.some(b => b.BusinessID === this.config.businessId);
|
||||||
|
if (!hasAccess && data.BUSINESSES.length > 0) {
|
||||||
|
// User doesn't have access to requested business, use their first business
|
||||||
|
this.config.businessId = data.BUSINESSES[0].BusinessID;
|
||||||
|
localStorage.setItem('payfrit_portal_business', this.config.businessId);
|
||||||
|
} else if (!hasAccess) {
|
||||||
|
// User has no businesses
|
||||||
|
this.toast('No businesses associated with your account', 'error');
|
||||||
|
this.logout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Portal] Auth verification error:', err);
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch actual business info
|
// Fetch actual business info
|
||||||
try {
|
try {
|
||||||
|
|
@ -66,13 +117,21 @@ const Portal = {
|
||||||
document.getElementById('userAvatar').textContent = 'U';
|
document.getElementById('userAvatar').textContent = 'U';
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Portal] Auth error:', err);
|
console.error('[Portal] Business info error:', err);
|
||||||
document.getElementById('businessName').textContent = 'Business #' + this.config.businessId;
|
document.getElementById('businessName').textContent = 'Business #' + this.config.businessId;
|
||||||
document.getElementById('businessAvatar').textContent = 'B';
|
document.getElementById('businessAvatar').textContent = 'B';
|
||||||
document.getElementById('userAvatar').textContent = 'U';
|
document.getElementById('userAvatar').textContent = 'U';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
logout() {
|
||||||
|
localStorage.removeItem('payfrit_portal_token');
|
||||||
|
localStorage.removeItem('payfrit_portal_userid');
|
||||||
|
localStorage.removeItem('payfrit_portal_business');
|
||||||
|
window.location.href = BASE_PATH + '/portal/login.html';
|
||||||
|
},
|
||||||
|
|
||||||
// Setup navigation
|
// Setup navigation
|
||||||
setupNavigation() {
|
setupNavigation() {
|
||||||
document.querySelectorAll('.nav-item[data-page]').forEach(item => {
|
document.querySelectorAll('.nav-item[data-page]').forEach(item => {
|
||||||
|
|
@ -331,6 +390,19 @@ const Portal = {
|
||||||
Open Builder
|
Open Builder
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="menu-editor-redirect">
|
||||||
|
<div class="redirect-icon">
|
||||||
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<rect x="2" y="3" width="20" height="14" rx="2"/>
|
||||||
|
<path d="M8 21h8M12 17v4"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3>Station Assignment</h3>
|
||||||
|
<p>Drag items to stations (Grill, Fry, Drinks, etc.) for KDS routing.</p>
|
||||||
|
<a href="/portal/station-assignment.html?bid=${this.config.businessId}" class="btn btn-secondary btn-lg">
|
||||||
|
Assign Stations
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<div class="menu-editor-redirect">
|
<div class="menu-editor-redirect">
|
||||||
<div class="redirect-icon">
|
<div class="redirect-icon">
|
||||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
|
|
||||||
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