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:
parent
ec81af5cdd
commit
634148f727
12 changed files with 842 additions and 571 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
53
api/admin/addItemCategoryColumn.cfm
Normal file
53
api/admin/addItemCategoryColumn.cfm
Normal 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>
|
||||||
134
api/admin/migrateToCategories.cfm
Normal file
134
api/admin/migrateToCategories.cfm
Normal 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>
|
||||||
60
api/admin/setupBigDeansStations.cfm
Normal file
60
api/admin/setupBigDeansStations.cfm
Normal 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>
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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") : "",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
388
kds/index.html
388
kds/index.html
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
247
kds/kds.js
247
kds/kds.js
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue