Add Categories table support, KDS station selection, and portal fixes

Categories Migration:
- Add ItemCategoryID column to Items table (api/admin/addItemCategoryColumn.cfm)
- Migration script to populate Categories from unified schema (api/admin/migrateToCategories.cfm)
- Updated items.cfm and getForBuilder.cfm to use Categories table with fallback

KDS Station Selection:
- KDS now prompts for station selection on load (Kitchen, Bar, or All Stations)
- Station filter persists in localStorage
- Updated listForKDS.cfm to filter orders by station
- Simplified KDS UI with station badge in header

Portal Improvements:
- Fixed drag-and-drop in station assignment (proper event propagation)
- Fixed Back button links to use BASE_PATH for local development
- Added console logging for debugging station assignment
- Order detail API now calculates Subtotal, Tax, Tip, Total properly

Admin Tools:
- setupBigDeansStations.cfm - Create Kitchen and Bar stations for Big Dean's

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-01-07 15:31:45 -08:00
parent ec81af5cdd
commit 634148f727
12 changed files with 842 additions and 571 deletions

View file

@ -1,4 +1,4 @@
<cfsetting showdebugoutput="false"> <cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true"> <cfsetting enablecfoutputonly="true">
<!--- <!---
@ -116,6 +116,8 @@ if (len(request._api_path)) {
if (findNoCase("/api/admin/randomizePrices.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/admin/randomizePrices.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/debugTemplateLinks.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/fixBigDeansCategories.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/admin/fixBigDeansCategories.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/migrateToCategories.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/addItemCategoryColumn.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/checkBigDeans.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/admin/checkBigDeans.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/updateBigDeans.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/admin/updateBigDeans.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/debugBigDeansMenu.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/admin/debugBigDeansMenu.cfm", request._api_path)) request._api_isPublic = true;
@ -124,6 +126,7 @@ if (len(request._api_path)) {
if (findNoCase("/api/admin/setupBigDeansInfo.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/admin/setupBigDeansInfo.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/beaconStatus.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/admin/beaconStatus.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/updateBeaconMapping.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/admin/updateBeaconMapping.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/setupBigDeansStations.cfm", request._api_path)) request._api_isPublic = true;
// Setup/Import endpoints // Setup/Import endpoints
if (findNoCase("/api/setup/importBusiness.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/setup/importBusiness.cfm", request._api_path)) request._api_isPublic = true;

View file

@ -0,0 +1,53 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Add ItemCategoryID column to Items table
*/
response = { "OK": false };
try {
// Check if column already exists
qCheck = queryExecute("
SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'payfrit'
AND TABLE_NAME = 'Items'
AND COLUMN_NAME = 'ItemCategoryID'
", {}, { datasource: "payfrit" });
if (qCheck.recordCount > 0) {
response["OK"] = true;
response["MESSAGE"] = "ItemCategoryID column already exists";
} else {
// Add the column
queryExecute("
ALTER TABLE Items
ADD COLUMN ItemCategoryID INT NULL DEFAULT 0 AFTER ItemParentItemID
", {}, { datasource: "payfrit" });
// Add index for performance
try {
queryExecute("
CREATE INDEX idx_items_categoryid ON Items(ItemCategoryID)
", {}, { datasource: "payfrit" });
} catch (any indexErr) {
// Index might already exist
}
response["OK"] = true;
response["MESSAGE"] = "ItemCategoryID column added successfully";
}
} catch (any e) {
response["ERROR"] = "server_error";
response["MESSAGE"] = e.message;
response["DETAIL"] = e.detail;
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -0,0 +1,134 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Migrate Unified Schema to Categories
*
* This script:
* 1. Finds all "category" Items (ParentID=0, not templates, not collapsible)
* 2. Creates corresponding entries in Categories table
* 3. Updates child Items with the new CategoryID
*
* GET: ?BusinessID=27 (or all if not specified)
*/
response = { "OK": false, "BusinessesProcessed": [], "Errors": [] };
try {
// Get BusinessID from URL if specified
businessFilter = "";
if (structKeyExists(url, "BusinessID") && val(url.BusinessID) > 0) {
businessFilter = val(url.BusinessID);
}
// Find all businesses with items in unified schema
if (len(businessFilter)) {
qBusinesses = queryExecute("
SELECT DISTINCT ItemBusinessID as BusinessID
FROM Items
WHERE ItemBusinessID = :bid AND ItemBusinessID > 0
", { bid: businessFilter }, { datasource: "payfrit" });
} else {
qBusinesses = queryExecute("
SELECT DISTINCT ItemBusinessID as BusinessID
FROM Items
WHERE ItemBusinessID > 0
", {}, { datasource: "payfrit" });
}
for (biz in qBusinesses) {
bizId = biz.BusinessID;
bizResult = { "BusinessID": bizId, "CategoriesCreated": 0, "ItemsUpdated": 0 };
try {
// Find category-like Items (parent=0, not collapsible, has children, not in template links)
qCategoryItems = queryExecute("
SELECT DISTINCT
p.ItemID,
p.ItemName,
p.ItemSortOrder
FROM Items p
INNER JOIN Items c ON c.ItemParentItemID = p.ItemID
WHERE p.ItemBusinessID = :bizId
AND p.ItemParentItemID = 0
AND (p.ItemIsCollapsible = 0 OR p.ItemIsCollapsible IS NULL)
AND NOT EXISTS (
SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = p.ItemID
)
ORDER BY p.ItemSortOrder, p.ItemName
", { bizId: bizId }, { datasource: "payfrit" });
sortOrder = 0;
for (catItem in qCategoryItems) {
// Check if category already exists for this business with same name
qExisting = queryExecute("
SELECT CategoryID FROM Categories
WHERE CategoryBusinessID = :bizId AND CategoryName = :name
", { bizId: bizId, name: left(catItem.ItemName, 30) }, { datasource: "payfrit" });
if (qExisting.recordCount == 0) {
// Get next CategoryID
qMaxId = queryExecute("
SELECT COALESCE(MAX(CategoryID), 0) + 1 as nextId FROM Categories
", {}, { datasource: "payfrit" });
newCatId = qMaxId.nextId;
// Create new category with explicit ID
queryExecute("
INSERT INTO Categories
(CategoryID, CategoryBusinessID, CategoryParentCategoryID, CategoryName, CategorySortOrder, CategoryAddedOn)
VALUES (:catId, :bizId, 0, :name, :sortOrder, NOW())
", {
catId: newCatId,
bizId: bizId,
name: left(catItem.ItemName, 30),
sortOrder: sortOrder
}, { datasource: "payfrit" });
bizResult.CategoriesCreated++;
} else {
newCatId = qExisting.CategoryID;
}
// Update all children of this category Item to have the new CategoryID
queryExecute("
UPDATE Items
SET ItemCategoryID = :catId
WHERE ItemParentItemID = :parentId
AND ItemBusinessID = :bizId
", {
catId: newCatId,
parentId: catItem.ItemID,
bizId: bizId
}, { datasource: "payfrit" });
qUpdated = queryExecute("SELECT ROW_COUNT() as cnt", {}, { datasource: "payfrit" });
bizResult.ItemsUpdated += qUpdated.cnt;
sortOrder++;
}
arrayAppend(response.BusinessesProcessed, bizResult);
} catch (any bizErr) {
arrayAppend(response.Errors, {
"BusinessID": bizId,
"Error": bizErr.message
});
}
}
response["OK"] = true;
response["TotalBusinesses"] = qBusinesses.recordCount;
} catch (any e) {
response["ERROR"] = "server_error";
response["MESSAGE"] = e.message;
response["DETAIL"] = e.detail;
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -0,0 +1,60 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
businessId = 27; // Big Dean's
response = { "OK": false };
try {
// Check if Big Dean's already has stations
existing = queryExecute("
SELECT COUNT(*) as cnt FROM Stations WHERE StationBusinessID = :bizId
", { bizId: businessId });
if (existing.cnt == 0) {
// Insert Kitchen station
queryExecute("
INSERT INTO Stations (StationBusinessID, StationName, StationColor, StationSortOrder, StationIsActive)
VALUES (:bizId, 'Kitchen', :color1, 1, 1)
", { bizId: businessId, color1: "##FF5722" });
// Insert Bar station
queryExecute("
INSERT INTO Stations (StationBusinessID, StationName, StationColor, StationSortOrder, StationIsActive)
VALUES (:bizId, 'Bar', :color2, 2, 1)
", { bizId: businessId, color2: "##2196F3" });
response["ACTION"] = "inserted";
} else {
response["ACTION"] = "already_exists";
}
// Get current stations
stations = queryExecute("
SELECT StationID, StationName, StationColor, StationSortOrder
FROM Stations
WHERE StationBusinessID = :bizId AND StationIsActive = 1
ORDER BY StationSortOrder
", { bizId: businessId });
stationArr = [];
for (s in stations) {
arrayAppend(stationArr, {
"StationID": s.StationID,
"StationName": s.StationName,
"StationColor": s.StationColor
});
}
response["OK"] = true;
response["BUSINESS_ID"] = businessId;
response["STATIONS"] = stationArr;
} catch (any e) {
response["ERROR"] = e.message;
response["DETAIL"] = e.detail;
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -52,7 +52,50 @@ try {
} }
if (newSchemaActive) { if (newSchemaActive) {
// NEW SCHEMA: Categories are Items at ParentID=0 with children (not in ItemTemplateLinks) // NEW SCHEMA: Check if Categories table has data for this business
hasCategoriesData = false;
try {
qCatCheck = queryExecute("
SELECT COUNT(*) as cnt FROM Categories WHERE CategoryBusinessID = :businessID
", { businessID: businessID }, { datasource: "payfrit" });
hasCategoriesData = (qCatCheck.cnt > 0);
} catch (any e) {
hasCategoriesData = false;
}
if (hasCategoriesData) {
// Use Categories table
qCategories = queryExecute("
SELECT
CategoryID,
CategoryName,
CategorySortOrder as ItemSortOrder
FROM Categories
WHERE CategoryBusinessID = :businessID
ORDER BY CategorySortOrder, CategoryName
", { businessID: businessID }, { datasource: "payfrit" });
// Get menu items with CategoryID
qItems = queryExecute("
SELECT
i.ItemID,
i.ItemCategoryID as CategoryItemID,
i.ItemName,
i.ItemDescription,
i.ItemPrice,
i.ItemSortOrder,
i.ItemIsActive
FROM Items i
WHERE i.ItemBusinessID = :businessID
AND i.ItemIsActive = 1
AND i.ItemCategoryID > 0
AND NOT EXISTS (
SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = i.ItemID
)
ORDER BY i.ItemSortOrder, i.ItemName
", { businessID: businessID }, { datasource: "payfrit" });
} else {
// Fallback: Categories are Items at ParentID=0 with children (not in ItemTemplateLinks)
qCategories = queryExecute(" qCategories = queryExecute("
SELECT DISTINCT SELECT DISTINCT
p.ItemID as CategoryID, p.ItemID as CategoryID,
@ -89,6 +132,7 @@ try {
) )
ORDER BY i.ItemSortOrder, i.ItemName ORDER BY i.ItemSortOrder, i.ItemName
", { businessID: businessID }, { datasource: "payfrit" }); ", { businessID: businessID }, { datasource: "payfrit" });
}
} else { } else {
// OLD SCHEMA: Use Categories table // OLD SCHEMA: Use Categories table

View file

@ -53,7 +53,58 @@
</cftry> </cftry>
<cfif newSchemaActive> <cfif newSchemaActive>
<!--- NEW SCHEMA: Categories are Items, templates derived from ItemTemplateLinks ---> <!--- NEW SCHEMA: Items have ItemBusinessID, try Categories table first, fallback to parent Items --->
<!--- Check if Categories table has data for this business --->
<cfset hasCategoriesData = false>
<cftry>
<cfset qCatCheck = queryExecute(
"SELECT COUNT(*) as cnt FROM Categories WHERE CategoryBusinessID = ?",
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<cfset hasCategoriesData = (qCatCheck.cnt GT 0)>
<cfcatch>
<cfset hasCategoriesData = false>
</cfcatch>
</cftry>
<cfif hasCategoriesData>
<!--- Use Categories table with ItemCategoryID --->
<!--- Only return items that have a valid CategoryID (actual menu items, not category headers) --->
<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 i.ItemBusinessID = ?
AND i.ItemIsActive = 1
AND i.ItemCategoryID > 0
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)
ORDER BY c.CategorySortOrder, i.ItemSortOrder, i.ItemID
",
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<cfelse>
<!--- Fallback: Derive categories from parent Items --->
<cfset q = queryExecute( <cfset q = queryExecute(
" "
SELECT SELECT
@ -106,6 +157,7 @@
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ], [ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" } { datasource = "payfrit" }
)> )>
</cfif>
<cfelse> <cfelse>
<!--- OLD SCHEMA: Use Categories table ---> <!--- OLD SCHEMA: Use Categories table --->
<cfset q = queryExecute( <cfset q = queryExecute(

View file

@ -52,7 +52,6 @@ try {
o.OrderRemarks, o.OrderRemarks,
o.OrderAddedOn, o.OrderAddedOn,
o.OrderLastEditedOn, o.OrderLastEditedOn,
o.OrderTipAmount,
u.UserFirstName, u.UserFirstName,
u.UserLastName, u.UserLastName,
u.UserContactNumber, u.UserContactNumber,
@ -122,13 +121,50 @@ try {
} }
} }
// Calculate subtotal from root line items
subtotal = 0;
for (item in lineItems) {
itemTotal = item.UnitPrice * item.Quantity;
// Add modifier prices
for (mod in item.Modifiers) {
itemTotal += mod.UnitPrice * mod.Quantity;
}
subtotal += itemTotal;
}
// Calculate tax (assume 8.75% if not stored)
taxRate = 0.0875;
tax = subtotal * taxRate;
// Look up tip from Payments table if exists
tip = 0;
try {
qPayment = queryExecute("
SELECT PaymentTipAmount
FROM Payments
WHERE PaymentOrderID = :orderID
LIMIT 1
", { orderID: orderID });
if (qPayment.recordCount > 0 && !isNull(qPayment.PaymentTipAmount)) {
tip = qPayment.PaymentTipAmount;
}
} catch (any e) {
// Payments table may not exist or have this column, ignore
}
// Calculate total
total = subtotal + tax + tip;
// Build response // Build response
order = { order = {
"OrderID": qOrder.OrderID, "OrderID": qOrder.OrderID,
"BusinessID": qOrder.OrderBusinessID, "BusinessID": qOrder.OrderBusinessID,
"Status": qOrder.OrderStatusID, "Status": qOrder.OrderStatusID,
"StatusText": getStatusText(qOrder.OrderStatusID), "StatusText": getStatusText(qOrder.OrderStatusID),
"Tip": qOrder.OrderTipAmount, "Subtotal": subtotal,
"Tax": tax,
"Tip": tip,
"Total": total,
"Notes": qOrder.OrderRemarks, "Notes": qOrder.OrderRemarks,
"CreatedOn": dateTimeFormat(qOrder.OrderAddedOn, "yyyy-mm-dd HH:nn:ss"), "CreatedOn": dateTimeFormat(qOrder.OrderAddedOn, "yyyy-mm-dd HH:nn:ss"),
"UpdatedOn": len(qOrder.OrderLastEditedOn) ? dateTimeFormat(qOrder.OrderLastEditedOn, "yyyy-mm-dd HH:nn:ss") : "", "UpdatedOn": len(qOrder.OrderLastEditedOn) ? dateTimeFormat(qOrder.OrderLastEditedOn, "yyyy-mm-dd HH:nn:ss") : "",

View file

@ -1,4 +1,4 @@
<cfsetting showdebugoutput="false"> <cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true"> <cfsetting enablecfoutputonly="true">
<cffunction name="apiAbort" access="public" returntype="void" output="true"> <cffunction name="apiAbort" access="public" returntype="void" output="true">
@ -29,6 +29,7 @@
<cfset data = readJsonBody()> <cfset data = readJsonBody()>
<cfset BusinessID = val( structKeyExists(data,"BusinessID") ? data.BusinessID : 0 )> <cfset BusinessID = val( structKeyExists(data,"BusinessID") ? data.BusinessID : 0 )>
<cfset ServicePointID = val( structKeyExists(data,"ServicePointID") ? data.ServicePointID : 0 )> <cfset ServicePointID = val( structKeyExists(data,"ServicePointID") ? data.ServicePointID : 0 )>
<cfset StationID = val( structKeyExists(data,"StationID") ? data.StationID : 0 )>
<cfif BusinessID LTE 0> <cfif BusinessID LTE 0>
<cfset apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required.", "DETAIL": "" })> <cfset apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required.", "DETAIL": "" })>
@ -53,6 +54,36 @@
<cfset whereSQL = arrayToList(whereClauses, " AND ")> <cfset whereSQL = arrayToList(whereClauses, " AND ")>
<!--- If filtering by station, only get orders that have items for that station --->
<cfif StationID GT 0>
<cfset stationParams = duplicate(params)>
<cfset arrayAppend(stationParams, { value = StationID, cfsqltype = "cf_sql_integer" })>
<cfset qOrders = queryExecute("
SELECT DISTINCT
o.OrderID,
o.OrderUUID,
o.OrderUserID,
o.OrderBusinessID,
o.OrderTypeID,
o.OrderStatusID,
o.OrderServicePointID,
o.OrderRemarks,
o.OrderSubmittedOn,
o.OrderLastEditedOn,
sp.ServicePointName,
u.UserFirstName,
u.UserLastName
FROM Orders o
LEFT JOIN ServicePoints sp ON sp.ServicePointID = o.OrderServicePointID
LEFT JOIN Users u ON u.UserID = o.OrderUserID
INNER JOIN OrderLineItems oli ON oli.OrderLineItemOrderID = o.OrderID
INNER JOIN Items i ON i.ItemID = oli.OrderLineItemItemID
WHERE #whereSQL#
AND i.ItemStationID = ?
AND oli.OrderLineItemIsDeleted = b'0'
ORDER BY o.OrderSubmittedOn ASC, o.OrderID ASC
", stationParams, { datasource = "payfrit" })>
<cfelse>
<cfset qOrders = queryExecute(" <cfset qOrders = queryExecute("
SELECT SELECT
o.OrderID, o.OrderID,
@ -74,11 +105,14 @@
WHERE #whereSQL# WHERE #whereSQL#
ORDER BY o.OrderSubmittedOn ASC, o.OrderID ASC ORDER BY o.OrderSubmittedOn ASC, o.OrderID ASC
", params, { datasource = "payfrit" })> ", params, { datasource = "payfrit" })>
</cfif>
<cfset orders = []> <cfset orders = []>
<cfloop query="qOrders"> <cfloop query="qOrders">
<!--- Get line items for this order ---> <!--- Get line items for this order --->
<!--- If filtering by station, only show items for that station (plus modifiers) --->
<cfif StationID GT 0>
<cfset qLineItems = queryExecute(" <cfset qLineItems = queryExecute("
SELECT SELECT
oli.OrderLineItemID, oli.OrderLineItemID,
@ -90,13 +124,39 @@
oli.OrderLineItemIsDeleted, oli.OrderLineItemIsDeleted,
i.ItemName, i.ItemName,
i.ItemParentItemID, i.ItemParentItemID,
i.ItemIsCheckedByDefault i.ItemIsCheckedByDefault,
i.ItemStationID
FROM OrderLineItems oli
INNER JOIN Items i ON i.ItemID = oli.OrderLineItemItemID
WHERE oli.OrderLineItemOrderID = ?
AND oli.OrderLineItemIsDeleted = b'0'
AND (i.ItemStationID = ? OR i.ItemStationID = 0 OR i.ItemStationID IS NULL OR oli.OrderLineItemParentOrderLineItemID > 0)
ORDER BY oli.OrderLineItemID
", [
{ value = qOrders.OrderID, cfsqltype = "cf_sql_integer" },
{ value = StationID, cfsqltype = "cf_sql_integer" }
], { datasource = "payfrit" })>
<cfelse>
<cfset qLineItems = queryExecute("
SELECT
oli.OrderLineItemID,
oli.OrderLineItemParentOrderLineItemID,
oli.OrderLineItemItemID,
oli.OrderLineItemPrice,
oli.OrderLineItemQuantity,
oli.OrderLineItemRemark,
oli.OrderLineItemIsDeleted,
i.ItemName,
i.ItemParentItemID,
i.ItemIsCheckedByDefault,
i.ItemStationID
FROM OrderLineItems oli FROM OrderLineItems oli
INNER JOIN Items i ON i.ItemID = oli.OrderLineItemItemID INNER JOIN Items i ON i.ItemID = oli.OrderLineItemItemID
WHERE oli.OrderLineItemOrderID = ? WHERE oli.OrderLineItemOrderID = ?
AND oli.OrderLineItemIsDeleted = b'0' AND oli.OrderLineItemIsDeleted = b'0'
ORDER BY oli.OrderLineItemID ORDER BY oli.OrderLineItemID
", [ { value = qOrders.OrderID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })> ", [ { value = qOrders.OrderID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
</cfif>
<cfset lineItems = []> <cfset lineItems = []>
<cfloop query="qLineItems"> <cfloop query="qLineItems">
@ -109,7 +169,8 @@
"OrderLineItemRemark": qLineItems.OrderLineItemRemark, "OrderLineItemRemark": qLineItems.OrderLineItemRemark,
"ItemName": qLineItems.ItemName, "ItemName": qLineItems.ItemName,
"ItemParentItemID": qLineItems.ItemParentItemID, "ItemParentItemID": qLineItems.ItemParentItemID,
"ItemIsCheckedByDefault": qLineItems.ItemIsCheckedByDefault "ItemIsCheckedByDefault": qLineItems.ItemIsCheckedByDefault,
"ItemStationID": qLineItems.ItemStationID
})> })>
</cfloop> </cfloop>
@ -134,7 +195,8 @@
<cfset apiAbort({ <cfset apiAbort({
"OK": true, "OK": true,
"ERROR": "", "ERROR": "",
"ORDERS": orders "ORDERS": orders,
"STATION_FILTER": StationID
})> })>
<cfcatch> <cfcatch>

View file

@ -1,338 +1,96 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Payfrit KDS - Kitchen Display System</title> <title>Payfrit KDS - Kitchen Display System</title>
<style> <style>
* { * { margin: 0; padding: 0; box-sizing: border-box; }
margin: 0; body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #1a1a1a; color: #fff; padding: 20px; }
padding: 0; .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; padding: 20px; background: #2a2a2a; border-radius: 8px; }
box-sizing: border-box; .header h1 { font-size: 28px; font-weight: 600; }
} .header .status { display: flex; gap: 10px; align-items: center; }
.status-dot { width: 12px; height: 12px; border-radius: 50%; background: #4ade80; animation: pulse 2s ease-in-out infinite; }
body { @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; .status-dot.error { background: #ef4444; }
background: #1a1a1a; .orders-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 20px; }
color: #fff; .order-card { background: #2a2a2a; border-radius: 12px; padding: 20px; border: 3px solid transparent; transition: all 0.3s ease; }
padding: 20px; .order-card.new { border-color: #3b82f6; box-shadow: 0 0 20px rgba(59, 130, 246, 0.3); }
} .order-card.preparing { border-color: #f59e0b; box-shadow: 0 0 20px rgba(245, 158, 11, 0.3); }
.order-card.ready { border-color: #10b981; box-shadow: 0 0 20px rgba(16, 185, 129, 0.3); }
.header { .order-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 15px; padding-bottom: 15px; border-bottom: 1px solid #3a3a3a; }
display: flex; .order-number { font-size: 24px; font-weight: 700; }
justify-content: space-between; .order-time { text-align: right; }
align-items: center; .elapsed-time { font-size: 20px; font-weight: 600; margin-bottom: 5px; }
margin-bottom: 30px; .elapsed-time.warning { color: #f59e0b; }
padding: 20px; .elapsed-time.critical { color: #ef4444; animation: blink 1s ease-in-out infinite; }
background: #2a2a2a; @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
border-radius: 8px; .submit-time { font-size: 12px; color: #888; }
} .order-info { margin-bottom: 15px; font-size: 14px; color: #aaa; }
.order-info div { margin-bottom: 5px; }
.header h1 { .line-items { margin-bottom: 20px; }
font-size: 28px; .line-item { margin-bottom: 15px; padding: 12px; background: #1a1a1a; border-radius: 8px; }
font-weight: 600; .line-item-main { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 8px; }
} .item-name { font-size: 16px; font-weight: 600; flex: 1; }
.item-qty { font-size: 18px; font-weight: 700; color: #3b82f6; margin-left: 10px; min-width: 30px; text-align: right; }
.header .status { .modifiers { margin-left: 15px; margin-top: 8px; }
display: flex; .modifier { font-size: 14px; color: #aaa; margin-bottom: 4px; padding-left: 12px; border-left: 2px solid #3a3a3a; }
gap: 10px; .item-remark { margin-top: 8px; padding: 8px; background: #f59e0b22; border-left: 3px solid #f59e0b; font-size: 14px; color: #fbbf24; font-style: italic; }
align-items: center; .order-remarks { margin-bottom: 15px; padding: 10px; background: #ef444422; border-left: 3px solid #ef4444; font-size: 14px; color: #fca5a5; font-style: italic; }
} .action-buttons { display: flex; gap: 10px; }
.btn { flex: 1; padding: 12px; border: none; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s ease; }
.status-dot { .btn:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); }
width: 12px; .btn:active { transform: translateY(0); }
height: 12px; .btn-start { background: #f59e0b; color: #000; }
border-radius: 50%; .btn-ready { background: #10b981; color: #000; }
background: #4ade80; .btn-complete { background: #3b82f6; color: #fff; }
animation: pulse 2s ease-in-out infinite; .empty-state { text-align: center; padding: 60px 20px; color: #666; }
} .empty-state svg { width: 80px; height: 80px; margin-bottom: 20px; opacity: 0.5; }
.empty-state h2 { font-size: 24px; margin-bottom: 10px; }
@keyframes pulse { .empty-state p { font-size: 16px; }
0%, 100% { opacity: 1; } .config-panel { position: fixed; top: 20px; right: 20px; background: #2a2a2a; padding: 15px; border-radius: 8px; display: none; z-index: 100; }
50% { opacity: 0.5; } .config-panel.show { display: block; }
} .config-panel label { display: block; margin-bottom: 8px; font-size: 14px; }
.config-panel input, .config-panel select { width: 100%; padding: 8px; margin-bottom: 12px; background: #1a1a1a; border: 1px solid #3a3a3a; border-radius: 4px; color: #fff; }
.status-dot.error { .btn-config { background: #3a3a3a; color: #fff; padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; }
background: #ef4444; /* Station Selection Overlay */
} .station-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.95); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 1000; }
.station-overlay.hidden { display: none; }
.orders-grid { .station-overlay h1 { font-size: 36px; margin-bottom: 10px; color: #fff; }
display: grid; .station-overlay p { font-size: 18px; color: #888; margin-bottom: 40px; }
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); .station-buttons { display: flex; flex-wrap: wrap; gap: 20px; justify-content: center; max-width: 800px; }
gap: 20px; .station-btn { width: 200px; height: 150px; border: none; border-radius: 16px; font-size: 24px; font-weight: 700; cursor: pointer; transition: all 0.2s ease; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; }
} .station-btn:hover { transform: scale(1.05); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5); }
.station-btn.all-stations { background: linear-gradient(135deg, #3b82f6, #1d4ed8); color: #fff; }
.order-card { .station-btn svg { width: 40px; height: 40px; }
background: #2a2a2a; .station-name-display { padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: 600; margin-left: 10px; }
border-radius: 12px;
padding: 20px;
border: 3px solid transparent;
transition: all 0.3s ease;
}
.order-card.new {
border-color: #3b82f6;
box-shadow: 0 0 20px rgba(59, 130, 246, 0.3);
}
.order-card.preparing {
border-color: #f59e0b;
box-shadow: 0 0 20px rgba(245, 158, 11, 0.3);
}
.order-card.ready {
border-color: #10b981;
box-shadow: 0 0 20px rgba(16, 185, 129, 0.3);
}
.order-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 1px solid #3a3a3a;
}
.order-number {
font-size: 24px;
font-weight: 700;
}
.order-time {
text-align: right;
}
.elapsed-time {
font-size: 20px;
font-weight: 600;
margin-bottom: 5px;
}
.elapsed-time.warning {
color: #f59e0b;
}
.elapsed-time.critical {
color: #ef4444;
animation: blink 1s ease-in-out infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.submit-time {
font-size: 12px;
color: #888;
}
.order-info {
margin-bottom: 15px;
font-size: 14px;
color: #aaa;
}
.order-info div {
margin-bottom: 5px;
}
.line-items {
margin-bottom: 20px;
}
.line-item {
margin-bottom: 15px;
padding: 12px;
background: #1a1a1a;
border-radius: 8px;
}
.line-item-main {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
}
.item-name {
font-size: 16px;
font-weight: 600;
flex: 1;
}
.item-qty {
font-size: 18px;
font-weight: 700;
color: #3b82f6;
margin-left: 10px;
min-width: 30px;
text-align: right;
}
.modifiers {
margin-left: 15px;
margin-top: 8px;
}
.modifier {
font-size: 14px;
color: #aaa;
margin-bottom: 4px;
padding-left: 12px;
border-left: 2px solid #3a3a3a;
}
.item-remark {
margin-top: 8px;
padding: 8px;
background: #f59e0b22;
border-left: 3px solid #f59e0b;
font-size: 14px;
color: #fbbf24;
font-style: italic;
}
.order-remarks {
margin-bottom: 15px;
padding: 10px;
background: #ef444422;
border-left: 3px solid #ef4444;
font-size: 14px;
color: #fca5a5;
font-style: italic;
}
.action-buttons {
display: flex;
gap: 10px;
}
.btn {
flex: 1;
padding: 12px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.btn:active {
transform: translateY(0);
}
.btn-start {
background: #f59e0b;
color: #000;
}
.btn-ready {
background: #10b981;
color: #000;
}
.btn-complete {
background: #3b82f6;
color: #fff;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #666;
}
.empty-state svg {
width: 80px;
height: 80px;
margin-bottom: 20px;
opacity: 0.5;
}
.empty-state h2 {
font-size: 24px;
margin-bottom: 10px;
}
.empty-state p {
font-size: 16px;
}
.config-panel {
position: fixed;
top: 20px;
right: 20px;
background: #2a2a2a;
padding: 15px;
border-radius: 8px;
display: none;
}
.config-panel.show {
display: block;
}
.config-panel label {
display: block;
margin-bottom: 8px;
font-size: 14px;
}
.config-panel input,
.config-panel select {
width: 100%;
padding: 8px;
margin-bottom: 12px;
background: #1a1a1a;
border: 1px solid #3a3a3a;
border-radius: 4px;
color: #fff;
}
.btn-config {
background: #3a3a3a;
color: #fff;
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style> </style>
</head> </head>
<body> <body>
<!-- Station Selection Overlay -->
<div class="station-overlay hidden" id="stationOverlay">
<h1>Select Station</h1>
<p>Choose which station to display orders for</p>
<div class="station-buttons" id="stationButtons"></div>
</div>
<div class="header"> <div class="header">
<div> <div>
<h1>Kitchen Display System</h1> <h1>Kitchen Display System <span id="stationBadge"></span></h1>
<div style="margin-top: 5px; font-size: 14px; color: #888;" id="servicePointName">Loading...</div> <div style="margin-top: 5px; font-size: 14px; color: #888;" id="servicePointName">Loading...</div>
</div> </div>
<div class="status"> <div class="status">
<div class="status-dot" id="statusDot"></div> <div class="status-dot" id="statusDot"></div>
<span id="statusText">Connected</span> <span id="statusText">Connected</span>
<button class="btn-config" onclick="toggleConfig()">⚙️ Settings</button> <button class="btn-config" onclick="showStationSelection()">Switch Station</button>
<button class="btn-config" onclick="toggleConfig()">Settings</button>
</div> </div>
</div> </div>
<div class="config-panel" id="configPanel"> <div class="config-panel" id="configPanel">
<label> <label>Business ID: <input type="number" id="businessIdInput" placeholder="Enter Business ID" /></label>
Business ID: <label>Service Point ID (optional): <input type="number" id="servicePointIdInput" placeholder="Leave blank for all" /></label>
<input type="number" id="businessIdInput" placeholder="Enter Business ID" /> <label>Refresh Interval (seconds): <input type="number" id="refreshIntervalInput" value="5" min="1" max="60" /></label>
</label>
<label>
Service Point ID (optional):
<input type="number" id="servicePointIdInput" placeholder="Leave blank for all" />
</label>
<label>
Refresh Interval (seconds):
<input type="number" id="refreshIntervalInput" value="5" min="1" max="60" />
</label>
<button class="btn-config" onclick="saveConfig()" style="width: 100%; background: #3b82f6;">Save & Reload</button> <button class="btn-config" onclick="saveConfig()" style="width: 100%; background: #3b82f6;">Save & Reload</button>
</div> </div>

View file

@ -3,32 +3,25 @@ let config = {
apiBaseUrl: '/biz.payfrit.com/api', apiBaseUrl: '/biz.payfrit.com/api',
businessId: null, businessId: null,
servicePointId: null, servicePointId: null,
refreshInterval: 5000, // 5 seconds stationId: null,
stationName: null,
stationColor: null,
refreshInterval: 5000,
}; };
// State // State
let orders = []; let orders = [];
let stations = [];
let refreshTimer = null; let refreshTimer = null;
// Status ID mapping // Status ID mapping
const STATUS = { const STATUS = { NEW: 1, PREPARING: 2, READY: 3, COMPLETED: 4 };
NEW: 1, const STATUS_NAMES = { 1: 'New', 2: 'Preparing', 3: 'Ready', 4: 'Completed' };
PREPARING: 2,
READY: 3,
COMPLETED: 4
};
const STATUS_NAMES = {
1: 'New',
2: 'Preparing',
3: 'Ready',
4: 'Completed'
};
// Initialize // Initialize
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadConfig(); loadConfig();
startAutoRefresh(); checkStationSelection();
}); });
// Load config from localStorage // Load config from localStorage
@ -39,6 +32,9 @@ function loadConfig() {
const parsed = JSON.parse(saved); const parsed = JSON.parse(saved);
config.businessId = parsed.businessId || null; config.businessId = parsed.businessId || null;
config.servicePointId = parsed.servicePointId || null; config.servicePointId = parsed.servicePointId || null;
config.stationId = parsed.stationId || null;
config.stationName = parsed.stationName || null;
config.stationColor = parsed.stationColor || null;
config.refreshInterval = (parsed.refreshInterval || 5) * 1000; config.refreshInterval = (parsed.refreshInterval || 5) * 1000;
document.getElementById('businessIdInput').value = config.businessId || ''; document.getElementById('businessIdInput').value = config.businessId || '';
@ -55,6 +51,110 @@ function loadConfig() {
} }
} }
// Check if station selection is needed
async function checkStationSelection() {
if (!config.businessId) return;
// If station already selected, start KDS
if (config.stationId !== null) {
updateStationBadge();
startAutoRefresh();
return;
}
// Load stations and show selection
await loadStations();
if (stations.length > 0) {
showStationSelection();
} else {
// No stations configured, just start with all orders
startAutoRefresh();
}
}
// Load stations from API
async function loadStations() {
if (!config.businessId) return;
try {
const response = await fetch(`${config.apiBaseUrl}/stations/list.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ BusinessID: config.businessId })
});
const data = await response.json();
if (data.OK) {
stations = data.STATIONS || [];
}
} catch (e) {
console.error('Failed to load stations:', e);
}
}
// Show station selection overlay
async function showStationSelection() {
if (stations.length === 0) {
await loadStations();
}
const overlay = document.getElementById('stationOverlay');
const buttons = document.getElementById('stationButtons');
let html = `
<button class="station-btn all-stations" onclick="selectStation(0, 'All Stations', null)">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
All Stations
</button>
`;
stations.forEach(s => {
const color = s.StationColor || '#666';
html += `
<button class="station-btn" style="background: ${color}; color: #fff;" onclick="selectStation(${s.StationID}, '${escapeHtml(s.StationName)}', '${color}')">
${escapeHtml(s.StationName)}
</button>
`;
});
buttons.innerHTML = html;
overlay.classList.remove('hidden');
}
// Select a station
function selectStation(stationId, stationName, stationColor) {
config.stationId = stationId || null;
config.stationName = stationName;
config.stationColor = stationColor;
// Save to localStorage
localStorage.setItem('kds_config', JSON.stringify({
businessId: config.businessId,
servicePointId: config.servicePointId,
stationId: config.stationId,
stationName: config.stationName,
stationColor: config.stationColor,
refreshInterval: config.refreshInterval / 1000
}));
// Hide overlay and start
document.getElementById('stationOverlay').classList.add('hidden');
updateStationBadge();
startAutoRefresh();
}
// Update station badge in header
function updateStationBadge() {
const badge = document.getElementById('stationBadge');
if (config.stationId && config.stationName) {
const color = config.stationColor || '#3b82f6';
badge.innerHTML = `<span class="station-name-display" style="background: ${color}; color: #fff;">${escapeHtml(config.stationName)}</span>`;
} else {
badge.innerHTML = `<span class="station-name-display" style="background: #3b82f6; color: #fff;">All Stations</span>`;
}
}
// Save config to localStorage // Save config to localStorage
function saveConfig() { function saveConfig() {
const businessId = parseInt(document.getElementById('businessIdInput').value) || null; const businessId = parseInt(document.getElementById('businessIdInput').value) || null;
@ -69,10 +169,16 @@ function saveConfig() {
config.businessId = businessId; config.businessId = businessId;
config.servicePointId = servicePointId; config.servicePointId = servicePointId;
config.refreshInterval = refreshInterval * 1000; config.refreshInterval = refreshInterval * 1000;
config.stationId = null; // Reset station selection when changing business
config.stationName = null;
config.stationColor = null;
localStorage.setItem('kds_config', JSON.stringify({ localStorage.setItem('kds_config', JSON.stringify({
businessId: config.businessId, businessId: config.businessId,
servicePointId: config.servicePointId, servicePointId: config.servicePointId,
stationId: null,
stationName: null,
stationColor: null,
refreshInterval: refreshInterval refreshInterval: refreshInterval
})); }));
@ -89,9 +195,7 @@ function toggleConfig() {
// Start auto-refresh // Start auto-refresh
function startAutoRefresh() { function startAutoRefresh() {
if (!config.businessId) return; if (!config.businessId) return;
loadOrders(); loadOrders();
if (refreshTimer) clearInterval(refreshTimer); if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = setInterval(loadOrders, config.refreshInterval); refreshTimer = setInterval(loadOrders, config.refreshInterval);
} }
@ -100,37 +204,25 @@ function startAutoRefresh() {
async function loadOrders() { async function loadOrders() {
if (!config.businessId) return; if (!config.businessId) return;
console.log('[loadOrders] Fetching with BusinessID:', config.businessId, 'ServicePointID:', config.servicePointId);
try { try {
const url = `${config.apiBaseUrl}/orders/listForKDS.cfm`; const url = `${config.apiBaseUrl}/orders/listForKDS.cfm`;
console.log('[loadOrders] URL:', url);
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json',
},
body: JSON.stringify({ body: JSON.stringify({
BusinessID: config.businessId, BusinessID: config.businessId,
ServicePointID: config.servicePointId || 0 ServicePointID: config.servicePointId || 0,
StationID: config.stationId || 0
}) })
}); });
console.log('[loadOrders] Response status:', response.status);
const data = await response.json(); const data = await response.json();
console.log('[loadOrders] Response data:', data);
if (data.OK) { if (data.OK) {
orders = data.ORDERS || []; orders = data.ORDERS || [];
console.log('[loadOrders] Orders received:', orders.length);
if (orders.length > 0) {
console.log('[loadOrders] First order LineItems:', orders[0].LineItems);
}
renderOrders(); renderOrders();
updateStatus(true, `${orders.length} active orders`); updateStatus(true, `${orders.length} active orders`);
} else { } else {
console.error('[loadOrders] API returned OK=false:', data);
updateStatus(false, `Error: ${data.MESSAGE || data.ERROR}`); updateStatus(false, `Error: ${data.MESSAGE || data.ERROR}`);
} }
} catch (error) { } catch (error) {
@ -143,7 +235,6 @@ async function loadOrders() {
function updateStatus(isConnected, message) { function updateStatus(isConnected, message) {
const dot = document.getElementById('statusDot'); const dot = document.getElementById('statusDot');
const text = document.getElementById('statusText'); const text = document.getElementById('statusText');
if (isConnected) { if (isConnected) {
dot.classList.remove('error'); dot.classList.remove('error');
text.textContent = message || 'Connected'; text.textContent = message || 'Connected';
@ -156,7 +247,6 @@ function updateStatus(isConnected, message) {
// Render orders to DOM // Render orders to DOM
function renderOrders() { function renderOrders() {
const grid = document.getElementById('ordersGrid'); const grid = document.getElementById('ordersGrid');
if (orders.length === 0) { if (orders.length === 0) {
grid.innerHTML = ` grid.innerHTML = `
<div class="empty-state"> <div class="empty-state">
@ -169,7 +259,6 @@ function renderOrders() {
`; `;
return; return;
} }
grid.innerHTML = orders.map(order => renderOrder(order)).join(''); grid.innerHTML = orders.map(order => renderOrder(order)).join('');
} }
@ -178,8 +267,6 @@ function renderOrder(order) {
const statusClass = getStatusClass(order.OrderStatusID); const statusClass = getStatusClass(order.OrderStatusID);
const elapsedTime = getElapsedTime(order.OrderSubmittedOn); const elapsedTime = getElapsedTime(order.OrderSubmittedOn);
const timeClass = getTimeClass(elapsedTime); const timeClass = getTimeClass(elapsedTime);
// Group line items by root items and their modifiers
const rootItems = order.LineItems.filter(item => item.OrderLineItemParentOrderLineItemID === 0); const rootItems = order.LineItems.filter(item => item.OrderLineItemParentOrderLineItemID === 0);
return ` return `
@ -214,115 +301,65 @@ function renderOrder(order) {
// Render line item with modifiers // Render line item with modifiers
function renderLineItem(item, allItems) { function renderLineItem(item, allItems) {
const modifiers = allItems.filter(mod => mod.OrderLineItemParentOrderLineItemID === item.OrderLineItemID); const modifiers = allItems.filter(mod => mod.OrderLineItemParentOrderLineItemID === item.OrderLineItemID);
console.log(`[renderLineItem] Item: ${item.ItemName}, ID: ${item.OrderLineItemID}, Modifiers found: ${modifiers.length}`);
if (modifiers.length > 0) {
console.log('[renderLineItem] Modifiers:', modifiers);
}
return ` return `
<div class="line-item"> <div class="line-item">
<div class="line-item-main"> <div class="line-item-main">
<div class="item-name">${escapeHtml(item.ItemName)}</div> <div class="item-name">${escapeHtml(item.ItemName)}</div>
<div class="item-qty">×${item.OrderLineItemQuantity}</div> <div class="item-qty">x${item.OrderLineItemQuantity}</div>
</div> </div>
${modifiers.length > 0 ? renderAllModifiers(modifiers, allItems) : ''} ${modifiers.length > 0 ? renderAllModifiers(modifiers, allItems) : ''}
${item.OrderLineItemRemark ? `<div class="item-remark">Note: ${escapeHtml(item.OrderLineItemRemark)}</div>` : ''}
${item.OrderLineItemRemark ? `
<div class="item-remark">Note: ${escapeHtml(item.OrderLineItemRemark)}</div>
` : ''}
</div> </div>
`; `;
} }
// Render all modifiers (flattened, no nested wrappers) // Render all modifiers
function renderAllModifiers(modifiers, allItems) { function renderAllModifiers(modifiers, allItems) {
let html = '<div class="modifiers">'; let html = '<div class="modifiers">';
// Build path for each leaf modifier
function getModifierPath(mod) { function getModifierPath(mod) {
const path = []; const path = [];
let current = mod; let current = mod;
// Walk up the tree to build the path
while (current) { while (current) {
path.unshift(current.ItemName); path.unshift(current.ItemName);
const parentId = current.OrderLineItemParentOrderLineItemID; const parentId = current.OrderLineItemParentOrderLineItemID;
if (parentId === 0) break; if (parentId === 0) break;
current = allItems.find(item => item.OrderLineItemID === parentId); current = allItems.find(item => item.OrderLineItemID === parentId);
} }
return path; return path;
} }
// Collect all leaf modifiers with their paths
const leafModifiers = []; const leafModifiers = [];
function collectLeafModifiers(mods) { function collectLeafModifiers(mods) {
mods.forEach(mod => { mods.forEach(mod => {
// Skip default items - they weren't explicitly chosen by the customer if (mod.ItemIsCheckedByDefault === 1 || mod.ItemIsCheckedByDefault === true || mod.ItemIsCheckedByDefault === "1") return;
if (mod.ItemIsCheckedByDefault === 1 || mod.ItemIsCheckedByDefault === true || mod.ItemIsCheckedByDefault === "1") {
console.log(`[renderAllModifiers] Skipping default item: ${mod.ItemName}`);
return;
}
const children = allItems.filter(item => item.OrderLineItemParentOrderLineItemID === mod.OrderLineItemID); const children = allItems.filter(item => item.OrderLineItemParentOrderLineItemID === mod.OrderLineItemID);
if (children.length === 0) { if (children.length === 0) {
// This is a leaf - no children leafModifiers.push({ mod, path: getModifierPath(mod) });
const path = getModifierPath(mod);
leafModifiers.push({ mod, path });
} else { } else {
// This has children, recurse into them
collectLeafModifiers(children); collectLeafModifiers(children);
} }
}); });
} }
collectLeafModifiers(modifiers); collectLeafModifiers(modifiers);
// Render leaf modifiers with breadcrumb paths
leafModifiers.forEach(({ path }) => { leafModifiers.forEach(({ path }) => {
const displayText = path.join(': '); html += `<div class="modifier">+ ${escapeHtml(path.join(': '))}</div>`;
html += `<div class="modifier">+ ${escapeHtml(displayText)}</div>`;
}); });
html += '</div>'; html += '</div>';
return html; return html;
} }
// Render modifier recursively with indentation
function renderModifierRecursive(modifier, allItems, level) {
const subModifiers = allItems.filter(mod => mod.OrderLineItemParentOrderLineItemID === modifier.OrderLineItemID);
// For KDS MVP: no indentation, just flat list
const indent = 0; // Changed from: level * 20
console.log(`[renderModifierRecursive] Level ${level}: ${modifier.ItemName} (ID: ${modifier.OrderLineItemID}), Sub-modifiers: ${subModifiers.length}`);
let html = `<div class="modifier" style="padding-left: ${indent}px;">+ ${escapeHtml(modifier.ItemName)}</div>`;
// Recursively render sub-modifiers
if (subModifiers.length > 0) {
subModifiers.forEach(sub => {
html += renderModifierRecursive(sub, allItems, level + 1);
});
}
return html;
}
// Render action buttons based on order status // Render action buttons based on order status
function renderActionButtons(order) { function renderActionButtons(order) {
switch (order.OrderStatusID) { switch (order.OrderStatusID) {
case STATUS.NEW: case STATUS.NEW:
return `<button class="btn btn-start" onclick="updateOrderStatus(${order.OrderID}, ${STATUS.PREPARING})">Start Preparing</button>`; return `<button class="btn btn-start" onclick="updateOrderStatus(${order.OrderID}, ${STATUS.PREPARING})">Start Preparing</button>`;
case STATUS.PREPARING: case STATUS.PREPARING:
return `<button class="btn btn-ready" onclick="updateOrderStatus(${order.OrderID}, ${STATUS.READY})">Mark as Ready</button>`; return `<button class="btn btn-ready" onclick="updateOrderStatus(${order.OrderID}, ${STATUS.READY})">Mark as Ready</button>`;
case STATUS.READY: case STATUS.READY:
return `<button class="btn btn-complete" onclick="updateOrderStatus(${order.OrderID}, ${STATUS.COMPLETED})">Complete</button>`; return `<button class="btn btn-complete" onclick="updateOrderStatus(${order.OrderID}, ${STATUS.COMPLETED})">Complete</button>`;
default: default:
return ''; return '';
} }
@ -333,19 +370,11 @@ async function updateOrderStatus(orderId, newStatusId) {
try { try {
const response = await fetch(`${config.apiBaseUrl}/orders/updateStatus.cfm`, { const response = await fetch(`${config.apiBaseUrl}/orders/updateStatus.cfm`, {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json', body: JSON.stringify({ OrderID: orderId, StatusID: newStatusId })
},
body: JSON.stringify({
OrderID: orderId,
StatusID: newStatusId
})
}); });
const data = await response.json(); const data = await response.json();
if (data.OK) { if (data.OK) {
// Immediately reload orders to reflect the change
await loadOrders(); await loadOrders();
} else { } else {
alert(`Failed to update order: ${data.MESSAGE || data.ERROR}`); alert(`Failed to update order: ${data.MESSAGE || data.ERROR}`);
@ -368,14 +397,12 @@ function getStatusClass(statusId) {
function getElapsedTime(submittedOn) { function getElapsedTime(submittedOn) {
if (!submittedOn) return 0; if (!submittedOn) return 0;
const submitted = new Date(submittedOn); return Math.floor((new Date() - new Date(submittedOn)) / 1000);
const now = new Date();
return Math.floor((now - submitted) / 1000); // seconds
} }
function getTimeClass(seconds) { function getTimeClass(seconds) {
if (seconds > 900) return 'critical'; // > 15 min if (seconds > 900) return 'critical';
if (seconds > 600) return 'warning'; // > 10 min if (seconds > 600) return 'warning';
return ''; return '';
} }
@ -387,11 +414,11 @@ function formatElapsedTime(seconds) {
function formatSubmitTime(dateString) { function formatSubmitTime(dateString) {
if (!dateString) return ''; if (!dateString) return '';
const date = new Date(dateString); return new Date(dateString).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} }
function escapeHtml(text) { function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = text; div.textContent = text;
return div.innerHTML; return div.innerHTML;

View file

@ -717,7 +717,7 @@
<!-- Toolbar --> <!-- Toolbar -->
<div class="builder-toolbar"> <div class="builder-toolbar">
<div class="toolbar-group"> <div class="toolbar-group">
<a href="/portal/index.html#menu" class="toolbar-btn" title="Back to Portal" style="text-decoration: none;"> <a id="backLink" href="#" class="toolbar-btn" title="Back to Portal" style="text-decoration: none;">
<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">
<path d="M19 12H5M12 19l-7-7 7-7"/> <path d="M19 12H5M12 19l-7-7 7-7"/>
</svg> </svg>
@ -993,6 +993,9 @@
async init() { async init() {
console.log('[MenuBuilder] Initializing...'); console.log('[MenuBuilder] Initializing...');
// Set back link with BASE_PATH
document.getElementById('backLink').href = BASE_PATH + '/portal/index.html#menu';
// Check authentication // Check authentication
const token = localStorage.getItem('payfrit_portal_token'); const token = localStorage.getItem('payfrit_portal_token');
const savedBusiness = localStorage.getItem('payfrit_portal_business'); const savedBusiness = localStorage.getItem('payfrit_portal_business');

View file

@ -312,7 +312,7 @@
<!-- Toolbar --> <!-- Toolbar -->
<div class="toolbar"> <div class="toolbar">
<div class="toolbar-title"> <div class="toolbar-title">
<a href="/portal/index.html#menu" style="color: var(--text-muted); text-decoration: none;"> <a id="backLink" href="#" 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"> <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"/> <path d="M19 12H5M12 19l-7-7 7-7"/>
</svg> </svg>
@ -391,6 +391,9 @@
async init() { async init() {
console.log('[StationAssignment] Initializing...'); console.log('[StationAssignment] Initializing...');
// Set back link with BASE_PATH
document.getElementById('backLink').href = BASE_PATH + '/portal/index.html#menu';
// Check authentication // Check authentication
const token = localStorage.getItem('payfrit_portal_token'); const token = localStorage.getItem('payfrit_portal_token');
const savedBusiness = localStorage.getItem('payfrit_portal_business'); const savedBusiness = localStorage.getItem('payfrit_portal_business');
@ -452,22 +455,30 @@
}, },
async loadItems() { async loadItems() {
console.log('[StationAssignment] Loading items for BusinessID:', this.config.businessId);
try { try {
const response = await fetch(`${this.config.apiBaseUrl}/menu/items.cfm`, { const response = await fetch(`${this.config.apiBaseUrl}/menu/items.cfm`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ BusinessID: this.config.businessId }) body: JSON.stringify({ BusinessID: this.config.businessId })
}); });
console.log('[StationAssignment] Response status:', response.status);
const data = await response.json(); const data = await response.json();
console.log('[StationAssignment] Response:', data);
if (data.OK) { if (data.OK) {
// Only top-level items (not modifiers) // Only top-level items (not modifiers) - look for items with categories
this.items = (data.Items || []).filter(item => item.ItemParentItemID === 0); this.items = (data.Items || []).filter(item =>
item.ItemParentItemID === 0 || item.ItemCategoryID > 0
);
console.log('[StationAssignment] Filtered items count:', this.items.length);
// Build assignments from existing data // Build assignments from existing data
this.items.forEach(item => { this.items.forEach(item => {
if (item.ItemStationID) { if (item.ItemStationID) {
this.assignments[item.ItemID] = item.ItemStationID; this.assignments[item.ItemID] = item.ItemStationID;
} }
}); });
} else {
console.error('[StationAssignment] API returned OK=false:', data.ERROR, data.MESSAGE);
} }
} catch (err) { } catch (err) {
console.error('[StationAssignment] Items error:', err); console.error('[StationAssignment] Items error:', err);
@ -558,7 +569,9 @@
ondragover="StationAssignment.onDragOver(event)" ondragover="StationAssignment.onDragOver(event)"
ondragleave="StationAssignment.onDragLeave(event)" ondragleave="StationAssignment.onDragLeave(event)"
ondrop="StationAssignment.onDrop(event, ${station.StationID})"> ondrop="StationAssignment.onDrop(event, ${station.StationID})">
<div class="station-header"> <div class="station-header"
ondragover="StationAssignment.onDragOver(event)"
ondrop="StationAssignment.onDrop(event, ${station.StationID})">
<div class="station-color" style="background: ${station.StationColor}"> <div class="station-color" style="background: ${station.StationColor}">
${station.StationName.charAt(0)} ${station.StationName.charAt(0)}
</div> </div>
@ -567,7 +580,9 @@
<div class="station-count">${itemsInStation.length} items</div> <div class="station-count">${itemsInStation.length} items</div>
</div> </div>
</div> </div>
<div class="station-items ${itemsInStation.length === 0 ? 'empty' : ''}"> <div class="station-items ${itemsInStation.length === 0 ? 'empty' : ''}"
ondragover="StationAssignment.onDragOver(event)"
ondrop="StationAssignment.onDrop(event, ${station.StationID})">
${itemsInStation.length === 0 ? ${itemsInStation.length === 0 ?
'Drop items here' : 'Drop items here' :
itemsInStation.map(item => ` itemsInStation.map(item => `
@ -626,28 +641,52 @@
// Drag and drop handlers // Drag and drop handlers
onDragStart(event, itemId) { onDragStart(event, itemId) {
event.dataTransfer.setData('itemId', itemId); console.log('[StationAssignment] Drag start, itemId:', itemId);
event.dataTransfer.setData('text/plain', itemId.toString());
event.dataTransfer.effectAllowed = 'move';
event.target.classList.add('dragging'); event.target.classList.add('dragging');
}, },
onDragEnd(event) { onDragEnd(event) {
console.log('[StationAssignment] Drag end');
event.target.classList.remove('dragging'); event.target.classList.remove('dragging');
// Clean up any lingering drag-over classes
document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
}, },
onDragOver(event) { onDragOver(event) {
event.preventDefault(); event.preventDefault();
event.currentTarget.classList.add('drag-over'); event.stopPropagation();
event.dataTransfer.dropEffect = 'move';
// Find the station card (might be triggered on child elements)
const stationCard = event.target.closest('.station-card');
if (stationCard) {
stationCard.classList.add('drag-over');
}
}, },
onDragLeave(event) { onDragLeave(event) {
event.currentTarget.classList.remove('drag-over'); // Only remove drag-over if actually leaving the station card
const stationCard = event.target.closest('.station-card');
const relatedTarget = event.relatedTarget;
if (stationCard && (!relatedTarget || !stationCard.contains(relatedTarget))) {
stationCard.classList.remove('drag-over');
}
}, },
onDrop(event, stationId) { onDrop(event, stationId) {
event.preventDefault(); event.preventDefault();
event.currentTarget.classList.remove('drag-over'); event.stopPropagation();
console.log('[StationAssignment] Drop on station:', stationId);
const itemId = parseInt(event.dataTransfer.getData('itemId')); // Find and clean up the station card
const stationCard = event.target.closest('.station-card');
if (stationCard) {
stationCard.classList.remove('drag-over');
}
const itemId = parseInt(event.dataTransfer.getData('text/plain'));
console.log('[StationAssignment] Dropped itemId:', itemId);
if (itemId) { if (itemId) {
this.assignToStation(itemId, stationId); this.assignToStation(itemId, stationId);
} }