App Store Version 2: Multi-menu support, beacon lookup, category scheduling

Features:
- Multi-menu support with time-based availability
- Menu hours validation against business operating hours
- Setup wizard now creates Menu records and links categories
- New menus.cfm API for menu CRUD operations
- Category schedule filtering (day/time based visibility)
- Beacon UUID lookup API for customer app
- Parent/child business relationships for franchises
- Category listing API for menu builder

Portal improvements:
- Menu builder theming to match admin UI
- Brand color picker fix
- Header image preview improvements

API fixes:
- Filter demo/hidden businesses from restaurant list
- Improved error handling throughout

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-01-23 19:51:44 -08:00
parent 72f5b7eb12
commit c2ae037e71
35 changed files with 2599 additions and 175 deletions

View file

@ -2,7 +2,7 @@
<cfsetting enablecfoutputonly="true"> <cfsetting enablecfoutputonly="true">
<!--- <!---
Payfrit API Application.cfm (updated) Payfrit API Application.cfm (updated 2026-01-22)
FIX: Provide a DEFAULT datasource so endpoints can call queryExecute() FIX: Provide a DEFAULT datasource so endpoints can call queryExecute()
without specifying { datasource="payfrit" } every time. without specifying { datasource="payfrit" } every time.
@ -33,7 +33,7 @@
> >
<!--- Magic OTP bypass for App Store review (set to true to enable 123456 as universal OTP) ---> <!--- Magic OTP bypass for App Store review (set to true to enable 123456 as universal OTP) --->
<cfset application.MAGIC_OTP_ENABLED = true> <cfset application.MAGIC_OTP_ENABLED = false>
<cfset application.MAGIC_OTP_CODE = "123456"> <cfset application.MAGIC_OTP_CODE = "123456">
<!--- Initialize Twilio for SMS ---> <!--- Initialize Twilio for SMS --->
@ -95,6 +95,7 @@ if (len(request._api_path)) {
if (findNoCase("/api/businesses/list.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/businesses/list.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/businesses/get.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/businesses/get.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/businesses/getChildren.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/businesses/update.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/businesses/update.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/businesses/updateHours.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/businesses/updateHours.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/servicepoints/list.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/servicepoints/list.cfm", request._api_path)) request._api_isPublic = true;
@ -109,6 +110,7 @@ if (len(request._api_path)) {
if (findNoCase("/api/beacons/list_all.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/beacons/list_all.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/beacons/getBusinessFromBeacon.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/beacons/getBusinessFromBeacon.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/beacons/reassign_all.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/beacons/reassign_all.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/beacons/lookup.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/assignments/list.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/assignments/list.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/assignments/save.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/assignments/save.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/assignments/delete.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/assignments/delete.cfm", request._api_path)) request._api_isPublic = true;
@ -173,6 +175,8 @@ if (len(request._api_path)) {
if (findNoCase("/api/menu/saveFromBuilder.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/menu/saveFromBuilder.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/menu/updateStations.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/menu/updateStations.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/menu/uploadHeader.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/menu/uploadHeader.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/menu/listCategories.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/menu/saveCategory.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/businesses/saveBrandColor.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/businesses/saveBrandColor.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/tasks/create.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/tasks/create.cfm", request._api_path)) request._api_isPublic = true;

View file

@ -0,0 +1,75 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Add Schedule Fields to Categories Table
*
* Adds time-based scheduling fields:
* - CategoryScheduleStart: TIME - Start time when category is available (e.g., 06:00:00 for breakfast)
* - CategoryScheduleEnd: TIME - End time when category stops being available (e.g., 11:00:00)
* - CategoryScheduleDays: VARCHAR(20) - Comma-separated list of day IDs (1=Sun, 2=Mon, etc.) or NULL for all days
*
* Run this once to migrate the schema.
*/
response = { "OK": false };
try {
// Check if columns already exist
qCheck = queryExecute("
SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'payfrit'
AND TABLE_NAME = 'Categories'
AND COLUMN_NAME IN ('CategoryScheduleStart', 'CategoryScheduleEnd', 'CategoryScheduleDays')
", {}, { datasource: "payfrit" });
existingCols = valueList(qCheck.COLUMN_NAME);
added = [];
// Add CategoryScheduleStart if not exists
if (!listFindNoCase(existingCols, "CategoryScheduleStart")) {
queryExecute("
ALTER TABLE Categories
ADD COLUMN CategoryScheduleStart TIME NULL
", {}, { datasource: "payfrit" });
arrayAppend(added, "CategoryScheduleStart");
}
// Add CategoryScheduleEnd if not exists
if (!listFindNoCase(existingCols, "CategoryScheduleEnd")) {
queryExecute("
ALTER TABLE Categories
ADD COLUMN CategoryScheduleEnd TIME NULL
", {}, { datasource: "payfrit" });
arrayAppend(added, "CategoryScheduleEnd");
}
// Add CategoryScheduleDays if not exists
if (!listFindNoCase(existingCols, "CategoryScheduleDays")) {
queryExecute("
ALTER TABLE Categories
ADD COLUMN CategoryScheduleDays VARCHAR(20) NULL
", {}, { datasource: "payfrit" });
arrayAppend(added, "CategoryScheduleDays");
}
response["OK"] = true;
response["ColumnsAdded"] = added;
response["AlreadyExisted"] = listToArray(existingCols);
response["MESSAGE"] = arrayLen(added) > 0
? "Added #arrayLen(added)# column(s): #arrayToList(added)#"
: "All columns already exist";
} catch (any e) {
response["ERROR"] = "server_error";
response["MESSAGE"] = e.message;
response["DETAIL"] = e.detail;
}
writeOutput(serializeJSON(response));
</cfscript>

30
api/admin/addLatLng.cfm Normal file
View file

@ -0,0 +1,30 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8">
<cfscript>
try {
// Check if columns already exist
checkCols = queryExecute(
"SHOW COLUMNS FROM Addresses LIKE 'AddressLat'",
[],
{ datasource = "payfrit" }
);
if (checkCols.recordCount EQ 0) {
// Add the columns
queryExecute(
"ALTER TABLE Addresses
ADD COLUMN AddressLat DECIMAL(10,7) NULL,
ADD COLUMN AddressLng DECIMAL(10,7) NULL",
[],
{ datasource = "payfrit" }
);
writeOutput(serializeJSON({ "OK": true, "MESSAGE": "Columns added successfully" }));
} else {
writeOutput(serializeJSON({ "OK": true, "MESSAGE": "Columns already exist" }));
}
} catch (any e) {
writeOutput(serializeJSON({ "OK": false, "ERROR": e.message }));
}
</cfscript>

View file

@ -0,0 +1,85 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Create Menus table for multiple menu support
*
* Menus can have:
* - Name (e.g., "Lunch Menu", "Dinner Menu", "Happy Hour")
* - Active days (bitmask linked to business hours days)
* - Start/End times for when the menu is available
* - Sort order for display
*/
response = { "OK": false };
try {
// Check if Menus table exists
qCheck = queryExecute("
SELECT 1 FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = 'payfrit'
AND TABLE_NAME = 'Menus'
", {}, { datasource: "payfrit" });
if (qCheck.recordCount > 0) {
response["OK"] = true;
response["MESSAGE"] = "Menus table already exists";
} else {
// Create Menus table
queryExecute("
CREATE TABLE Menus (
MenuID INT AUTO_INCREMENT PRIMARY KEY,
MenuBusinessID INT NOT NULL,
MenuName VARCHAR(100) NOT NULL,
MenuDescription VARCHAR(500) NULL,
MenuDaysActive INT NOT NULL DEFAULT 127,
MenuStartTime TIME NULL,
MenuEndTime TIME NULL,
MenuSortOrder INT NOT NULL DEFAULT 0,
MenuIsActive TINYINT NOT NULL DEFAULT 1,
MenuAddedOn DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_menus_business (MenuBusinessID),
INDEX idx_menus_active (MenuBusinessID, MenuIsActive)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
", {}, { datasource: "payfrit" });
response["OK"] = true;
response["MESSAGE"] = "Menus table created successfully";
response["SCHEMA"] = {
"MenuDaysActive": "Bitmask: 1=Sun, 2=Mon, 4=Tue, 8=Wed, 16=Thu, 32=Fri, 64=Sat (127 = all days)"
};
}
// Check if CategoryMenuID column exists in Categories table
qCatCol = queryExecute("
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'payfrit'
AND TABLE_NAME = 'Categories'
AND COLUMN_NAME = 'CategoryMenuID'
", {}, { datasource: "payfrit" });
if (qCatCol.recordCount == 0) {
// Add CategoryMenuID column to Categories table
queryExecute("
ALTER TABLE Categories
ADD COLUMN CategoryMenuID INT NULL DEFAULT NULL AFTER CategoryBusinessID,
ADD INDEX idx_categories_menu (CategoryMenuID)
", {}, { datasource: "payfrit" });
response["CATEGORIES_UPDATED"] = true;
response["CATEGORIES_MESSAGE"] = "Added CategoryMenuID column to Categories table";
} else {
response["CATEGORIES_UPDATED"] = false;
response["CATEGORIES_MESSAGE"] = "CategoryMenuID column already exists";
}
} catch (any e) {
response["ERROR"] = "server_error";
response["MESSAGE"] = e.message;
response["DETAIL"] = e.detail ?: "";
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -0,0 +1,119 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Create Parent Business (Shell)
*
* Creates a minimal business record to serve as a parent for other businesses.
* No menu items, categories, or hours required.
*
* POST body:
* {
* "BusinessName": "Century Casino",
* "UserID": 1,
* "ChildBusinessIDs": [47, 48] // Optional: link existing businesses as children
* }
*
* Returns the new BusinessID
*/
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw) || len(trim(raw)) == 0) return {};
try {
var data = deserializeJSON(raw);
return isStruct(data) ? data : {};
} catch (any e) {
return {};
}
}
response = { "OK": false };
try {
data = readJsonBody();
BusinessName = structKeyExists(data, "BusinessName") ? trim(data.BusinessName) : "";
UserID = structKeyExists(data, "UserID") ? val(data.UserID) : 0;
ChildBusinessIDs = structKeyExists(data, "ChildBusinessIDs") && isArray(data.ChildBusinessIDs) ? data.ChildBusinessIDs : [];
if (!len(BusinessName)) {
response["ERROR"] = "missing_name";
response["MESSAGE"] = "BusinessName is required";
writeOutput(serializeJSON(response));
abort;
}
if (UserID <= 0) {
response["ERROR"] = "missing_userid";
response["MESSAGE"] = "UserID is required";
writeOutput(serializeJSON(response));
abort;
}
// Create minimal address record (just a placeholder)
queryExecute("
INSERT INTO Addresses (AddressLine1, AddressUserID, AddressTypeID, AddressAddedOn)
VALUES ('Parent Business - No Physical Location', :userID, 2, NOW())
", {
userID: UserID
}, { datasource = "payfrit" });
qAddr = queryExecute("SELECT LAST_INSERT_ID() as id", {}, { datasource = "payfrit" });
addressId = qAddr.id;
// Create parent business (no menu, no hours, just a shell)
queryExecute("
INSERT INTO Businesses (BusinessName, BusinessUserID, BusinessAddressID, BusinessParentBusinessID, BusinessDeliveryZipCodes, BusinessAddedOn)
VALUES (:name, :userId, :addressId, NULL, '', NOW())
", {
name: BusinessName,
userId: UserID,
addressId: addressId
}, { datasource = "payfrit" });
qBiz = queryExecute("SELECT LAST_INSERT_ID() as id", {}, { datasource = "payfrit" });
newBusinessID = qBiz.id;
// Link address back to business
queryExecute("
UPDATE Addresses SET AddressBusinessID = :bizId WHERE AddressID = :addrId
", {
bizId: newBusinessID,
addrId: addressId
}, { datasource = "payfrit" });
// Update child businesses if provided
linkedChildren = [];
for (childID in ChildBusinessIDs) {
childID = val(childID);
if (childID > 0) {
queryExecute("
UPDATE Businesses SET BusinessParentBusinessID = :parentId WHERE BusinessID = :childId
", {
parentId: newBusinessID,
childId: childID
}, { datasource = "payfrit" });
arrayAppend(linkedChildren, childID);
}
}
response["OK"] = true;
response["BusinessID"] = newBusinessID;
response["BusinessName"] = BusinessName;
response["MESSAGE"] = "Parent business created";
if (arrayLen(linkedChildren) > 0) {
response["LinkedChildren"] = linkedChildren;
}
} catch (any e) {
response["ERROR"] = "server_error";
response["MESSAGE"] = e.message;
response["DETAIL"] = e.detail;
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -0,0 +1,55 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
/**
* Delete a business (and its address)
* POST: { "BusinessID": 52 }
*/
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw) || len(trim(raw)) == 0) return {};
try {
return deserializeJSON(raw);
} catch (any e) {
return {};
}
}
response = { "OK": false };
try {
data = readJsonBody();
bizID = val(data.BusinessID ?: 0);
if (bizID <= 0) {
response["ERROR"] = "BusinessID required";
} else {
// Get address ID first
qBiz = queryExecute("SELECT BusinessAddressID FROM Businesses WHERE BusinessID = :id", { id: bizID }, { datasource = "payfrit" });
if (qBiz.recordCount == 0) {
response["ERROR"] = "Business not found";
} else {
addrID = qBiz.BusinessAddressID;
// Delete business
queryExecute("DELETE FROM Businesses WHERE BusinessID = :id", { id: bizID }, { datasource = "payfrit" });
// Delete address if exists
if (val(addrID) > 0) {
queryExecute("DELETE FROM Addresses WHERE AddressID = :id", { id: addrID }, { datasource = "payfrit" });
}
response["OK"] = true;
response["MESSAGE"] = "Deleted BusinessID " & bizID;
}
}
} catch (any e) {
response["ERROR"] = e.message;
}
writeOutput(serializeJSON(response));
</cfscript>

204
api/admin/geocode.cfm Normal file
View file

@ -0,0 +1,204 @@
<cfsetting showdebugoutput="false">
<!DOCTYPE html>
<html>
<head>
<title>Address Geocoding</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 1000px; margin: 40px auto; padding: 20px; background: ##1a1a1a; color: ##fff; }
h1 { color: ##4CAF50; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { padding: 10px; text-align: left; border-bottom: 1px solid ##333; }
th { background: ##2a2a2a; }
tr:hover { background: ##2a2a2a; }
.address { color: ##aaa; font-size: 13px; }
.success { color: ##4CAF50; padding: 10px; background: ##1b3d1b; border-radius: 4px; margin: 10px 0; }
.error { color: ##f44336; padding: 10px; background: ##3d1b1b; border-radius: 4px; margin: 10px 0; }
.has-coords { color: ##4CAF50; }
.no-coords { color: ##ff9800; }
button { padding: 6px 12px; background: ##4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; }
button:hover { background: ##45a049; }
.btn-lookup { background: ##2196F3; }
.btn-lookup:hover { background: ##1976D2; }
.coords { font-family: monospace; font-size: 12px; color: ##888; }
</style>
</head>
<body>
<h1>Address Geocoding</h1>
<p>Auto-geocode addresses using OpenStreetMap Nominatim (free, no API key required)</p>
<cfscript>
function geocodeAddress(addressString) {
var result = { "success": false, "error": "" };
if (len(trim(addressString)) EQ 0) {
result.error = "Empty address";
return result;
}
try {
var httpService = new http();
httpService.setMethod("GET");
httpService.setUrl("https://nominatim.openstreetmap.org/search?q=" & urlEncodedFormat(addressString) & "&format=json&limit=1");
httpService.addParam(type="header", name="User-Agent", value="Payfrit/1.0");
httpService.setTimeout(10);
var httpResult = httpService.send().getPrefix();
if (httpResult.statusCode CONTAINS "200") {
var data = deserializeJSON(httpResult.fileContent);
if (arrayLen(data) GT 0) {
result.success = true;
result.lat = data[1].lat;
result.lng = data[1].lon;
return result;
}
result.error = "No results found";
return result;
}
result.error = "HTTP " & httpResult.statusCode;
return result;
} catch (any e) {
result.error = e.message;
return result;
}
}
function buildAddressString(line1, line2, city, zipCode) {
var parts = [];
if (len(trim(line1))) arrayAppend(parts, trim(line1));
if (len(trim(line2))) arrayAppend(parts, trim(line2));
if (len(trim(city))) arrayAppend(parts, trim(city));
if (len(trim(zipCode))) arrayAppend(parts, trim(zipCode));
return arrayToList(parts, ", ");
}
</cfscript>
<cfif structKeyExists(url, "geocode") AND structKeyExists(url, "addressId")>
<cfset addressId = val(url.addressId)>
<cfquery name="addr" datasource="payfrit">
SELECT AddressLine1, AddressLine2, AddressCity, AddressZIPCode
FROM Addresses WHERE AddressID = <cfqueryparam value="#addressId#" cfsqltype="cf_sql_integer">
</cfquery>
<cfif addr.recordCount GT 0>
<cfset fullAddress = buildAddressString(addr.AddressLine1, addr.AddressLine2, addr.AddressCity, addr.AddressZIPCode)>
<cfset geo = geocodeAddress(fullAddress)>
<cfif geo.success>
<cfquery datasource="payfrit">
UPDATE Addresses SET AddressLat = <cfqueryparam value="#geo.lat#" cfsqltype="cf_sql_decimal">,
AddressLng = <cfqueryparam value="#geo.lng#" cfsqltype="cf_sql_decimal">
WHERE AddressID = <cfqueryparam value="#addressId#" cfsqltype="cf_sql_integer">
</cfquery>
<cfoutput><div class="success">Geocoded Address ID #addressId#: #geo.lat#, #geo.lng#</div></cfoutput>
<cfelse>
<cfoutput><div class="error">Failed: #geo.error#</div></cfoutput>
</cfif>
</cfif>
</cfif>
<cfif structKeyExists(url, "geocodeAll")>
<cfquery name="missing" datasource="payfrit">
SELECT AddressID, AddressLine1, AddressLine2, AddressCity, AddressZIPCode
FROM Addresses
WHERE (AddressLat IS NULL OR AddressLat = 0)
AND AddressLine1 IS NOT NULL AND AddressLine1 != ''
</cfquery>
<cfset successCount = 0>
<cfset failCount = 0>
<cfloop query="missing">
<cfset fullAddress = buildAddressString(missing.AddressLine1, missing.AddressLine2, missing.AddressCity, missing.AddressZIPCode)>
<cfset geo = geocodeAddress(fullAddress)>
<cfif geo.success>
<cfquery datasource="payfrit">
UPDATE Addresses SET AddressLat = <cfqueryparam value="#geo.lat#" cfsqltype="cf_sql_decimal">,
AddressLng = <cfqueryparam value="#geo.lng#" cfsqltype="cf_sql_decimal">
WHERE AddressID = <cfqueryparam value="#missing.AddressID#" cfsqltype="cf_sql_integer">
</cfquery>
<cfset successCount = successCount + 1>
<cfelse>
<cfset failCount = failCount + 1>
</cfif>
<cfset sleep(1100)>
</cfloop>
<cfoutput><div class="success">Geocoded #successCount# addresses. #failCount# failed.</div></cfoutput>
</cfif>
<cfquery name="addresses" datasource="payfrit">
SELECT
b.BusinessID,
b.BusinessName,
a.AddressID,
a.AddressLine1,
a.AddressLine2,
a.AddressCity,
a.AddressZIPCode,
a.AddressLat,
a.AddressLng
FROM Businesses b
LEFT JOIN Addresses a ON b.BusinessAddressID = a.AddressID
ORDER BY b.BusinessName
</cfquery>
<cfset missingCount = 0>
<cfloop query="addresses">
<cfif (NOT len(addresses.AddressLat) OR val(addresses.AddressLat) EQ 0) AND len(addresses.AddressLine1)>
<cfset missingCount = missingCount + 1>
</cfif>
</cfloop>
<cfoutput>
<p>
<strong>#missingCount#</strong> addresses missing coordinates.
<cfif missingCount GT 0>
<a href="?geocodeAll=1"><button class="btn-lookup">Geocode All Missing (#missingCount#)</button></a>
<small style="color:##888;">(~#missingCount# seconds due to rate limiting)</small>
</cfif>
</p>
<table>
<thead>
<tr>
<th>Business</th>
<th>Address</th>
<th>Coordinates</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<cfloop query="addresses">
<tr>
<td>
#addresses.BusinessName#
<cfif len(addresses.AddressLat) AND val(addresses.AddressLat) NEQ 0>
<span class="has-coords">#chr(10003)#</span>
<cfelseif len(addresses.AddressLine1)>
<span class="no-coords">#chr(9679)#</span>
</cfif>
</td>
<td class="address">
<cfif len(addresses.AddressLine1)>
#addresses.AddressLine1#<cfif len(addresses.AddressLine2)>, #addresses.AddressLine2#</cfif><br>
#addresses.AddressCity# #addresses.AddressZIPCode#
<cfelse>
<em style="color:##666;">No address</em>
</cfif>
</td>
<td class="coords">
<cfif len(addresses.AddressLat) AND val(addresses.AddressLat) NEQ 0>
#numberFormat(addresses.AddressLat, "_.______")#<br>
#numberFormat(addresses.AddressLng, "_.______")#
<cfelse>
-
</cfif>
</td>
<td>
<cfif len(addresses.AddressLine1) AND len(addresses.AddressID)>
<a href="?geocode=1&addressId=#addresses.AddressID#"><button class="btn-lookup">Lookup</button></a>
</cfif>
</td>
</tr>
</cfloop>
</tbody>
</table>
</cfoutput>
</body>
</html>

View file

@ -0,0 +1,46 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
/**
* Link a child business to a parent
* POST: { "ChildBusinessID": 49, "ParentBusinessID": 51 }
*/
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw) || len(trim(raw)) == 0) return {};
try {
return deserializeJSON(raw);
} catch (any e) {
return {};
}
}
response = { "OK": false };
try {
data = readJsonBody();
childID = val(data.ChildBusinessID ?: 0);
parentID = val(data.ParentBusinessID ?: 0);
if (childID <= 0) {
response["ERROR"] = "ChildBusinessID required";
} else {
queryExecute("
UPDATE Businesses SET BusinessParentBusinessID = :parentId WHERE BusinessID = :childId
", {
parentId: { value = parentID > 0 ? parentID : javaCast("null", ""), cfsqltype = "cf_sql_integer", null = parentID == 0 },
childId: childID
}, { datasource = "payfrit" });
response["OK"] = true;
response["MESSAGE"] = "Updated BusinessID " & childID & " parent to " & (parentID > 0 ? parentID : "NULL");
}
} catch (any e) {
response["ERROR"] = e.message;
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -46,7 +46,6 @@ beaconId = int(data.BeaconID);
</cfif> </cfif>
<!--- Get all businesses that have assignments to this beacon ---> <!--- Get all businesses that have assignments to this beacon --->
<!--- This includes the beacon owner AND any child businesses that have claimed this beacon --->
<cfquery name="qAssignments" datasource="payfrit"> <cfquery name="qAssignments" datasource="payfrit">
SELECT SELECT
lt.BusinessID, lt.BusinessID,
@ -62,17 +61,63 @@ beaconId = int(data.BeaconID);
ORDER BY biz.BusinessParentBusinessID IS NULL DESC, biz.BusinessName ASC ORDER BY biz.BusinessParentBusinessID IS NULL DESC, biz.BusinessName ASC
</cfquery> </cfquery>
<!--- Check if any assigned business is a parent (has children) --->
<cfset parentBusinessID = 0>
<cfloop query="qAssignments">
<!--- Check if this business has children --->
<cfquery name="qChildren" datasource="payfrit">
SELECT COUNT(*) as cnt FROM Businesses
WHERE BusinessParentBusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#qAssignments.BusinessID#">
</cfquery>
<cfif qChildren.cnt GT 0>
<cfset parentBusinessID = qAssignments.BusinessID>
<cfbreak>
</cfif>
</cfloop>
<!--- Build response with array of businesses ---> <!--- Build response with array of businesses --->
<cfset businesses = []> <cfset businesses = []>
<cfloop query="qAssignments">
<cfset arrayAppend(businesses, { <!--- If beacon is assigned to a parent, return the child businesses instead --->
"BusinessID" = qAssignments.BusinessID, <cfif parentBusinessID GT 0>
"BusinessName" = qAssignments.BusinessName, <!--- Get parent business info for header image --->
"ServicePointID" = qAssignments.ServicePointID, <cfquery name="qParent" datasource="payfrit">
"ServicePointName" = qAssignments.ServicePointName, SELECT BusinessName, BusinessHeaderImageExtension
"IsParent" = isNull(qAssignments.BusinessParentBusinessID) OR qAssignments.BusinessParentBusinessID EQ 0 FROM Businesses
})> WHERE BusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#parentBusinessID#">
</cfloop> </cfquery>
<cfquery name="qChildBusinesses" datasource="payfrit">
SELECT
BusinessID,
BusinessName,
BusinessParentBusinessID,
BusinessHeaderImageExtension
FROM Businesses
WHERE BusinessParentBusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#parentBusinessID#">
ORDER BY BusinessName ASC
</cfquery>
<cfloop query="qChildBusinesses">
<cfset arrayAppend(businesses, {
"BusinessID" = qChildBusinesses.BusinessID,
"BusinessName" = qChildBusinesses.BusinessName,
"ServicePointID" = qAssignments.ServicePointID,
"ServicePointName" = qAssignments.ServicePointName,
"IsParent" = false,
"ParentBusinessID" = parentBusinessID
})>
</cfloop>
<cfelse>
<!--- Normal case: return directly assigned businesses --->
<cfloop query="qAssignments">
<cfset arrayAppend(businesses, {
"BusinessID" = qAssignments.BusinessID,
"BusinessName" = qAssignments.BusinessName,
"ServicePointID" = qAssignments.ServicePointID,
"ServicePointName" = qAssignments.ServicePointName,
"IsParent" = isNull(qAssignments.BusinessParentBusinessID) OR qAssignments.BusinessParentBusinessID EQ 0
})>
</cfloop>
</cfif>
<cfset response = { <cfset response = {
"OK" = true, "OK" = true,
@ -91,4 +136,13 @@ beaconId = int(data.BeaconID);
} : {} } : {}
}> }>
<!--- Add parent info if this is a parent-child scenario --->
<cfif parentBusinessID GT 0>
<cfset response["PARENT"] = {
"BusinessID" = parentBusinessID,
"BusinessName" = qParent.BusinessName,
"BusinessHeaderImageExtension" = len(trim(qParent.BusinessHeaderImageExtension)) ? qParent.BusinessHeaderImageExtension : ""
}>
</cfif>
<cfoutput>#serializeJSON(response)#</cfoutput> <cfoutput>#serializeJSON(response)#</cfoutput>

113
api/beacons/lookup.cfm Normal file
View file

@ -0,0 +1,113 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
/**
* Lookup beacons by UUID
*
* POST: {
* UUIDs: array of UUID strings (without dashes, uppercase)
* }
*
* Returns: {
* OK: true,
* BEACONS: [
* {
* UUID: "...",
* BeaconID: int,
* BeaconName: string,
* BusinessID: int,
* BusinessName: string,
* ServicePointID: int,
* ServicePointName: string,
* ParentBusinessID: int (if applicable),
* ParentBusinessName: string (if applicable),
* HasChildren: boolean
* }
* ]
* }
*/
response = { "OK": false };
try {
requestData = deserializeJSON(toString(getHttpRequestData().content));
uuids = requestData.UUIDs ?: [];
if (!isArray(uuids) || arrayLen(uuids) == 0) {
response["OK"] = true;
response["BEACONS"] = [];
writeOutput(serializeJSON(response));
abort;
}
// Clean and normalize UUIDs (remove dashes, uppercase)
cleanUUIDs = [];
for (uuid in uuids) {
cleanUUID = uCase(reReplace(uuid, "-", "", "all"));
if (len(cleanUUID) == 32) {
arrayAppend(cleanUUIDs, cleanUUID);
}
}
if (arrayLen(cleanUUIDs) == 0) {
response["OK"] = true;
response["BEACONS"] = [];
writeOutput(serializeJSON(response));
abort;
}
// Query for matching beacons with business info
// Beacons link to ServicePoints via lt_Beacon_Businesses_ServicePoints
qBeacons = queryExecute("
SELECT
b.BeaconID,
b.BeaconName,
b.BeaconUUID,
COALESCE(link.ServicePointID, 0) AS ServicePointID,
COALESCE(sp.ServicePointName, '') AS ServicePointName,
COALESCE(link.BusinessID, b.BeaconBusinessID) AS BusinessID,
biz.BusinessName,
biz.BusinessParentBusinessID,
parent.BusinessName AS ParentBusinessName,
(SELECT COUNT(*) FROM Businesses WHERE BusinessParentBusinessID = biz.BusinessID) AS ChildCount
FROM Beacons b
LEFT JOIN lt_Beacon_Businesses_ServicePoints link ON b.BeaconID = link.BeaconID
LEFT JOIN ServicePoints sp ON link.ServicePointID = sp.ServicePointID
INNER JOIN Businesses biz ON COALESCE(link.BusinessID, b.BeaconBusinessID) = biz.BusinessID
LEFT JOIN Businesses parent ON biz.BusinessParentBusinessID = parent.BusinessID
WHERE b.BeaconUUID IN (:uuids)
AND b.BeaconIsActive = 1
AND biz.BusinessIsDemo = 0
AND biz.BusinessIsPrivate = 0
", {
uuids: { value: arrayToList(cleanUUIDs), cfsqltype: "cf_sql_varchar", list: true }
}, { datasource: "payfrit" });
beacons = [];
for (row in qBeacons) {
arrayAppend(beacons, {
"UUID": row.BeaconUUID,
"BeaconID": row.BeaconID,
"BeaconName": row.BeaconName,
"BusinessID": row.BusinessID,
"BusinessName": row.BusinessName,
"ServicePointID": row.ServicePointID,
"ServicePointName": row.ServicePointName,
"ParentBusinessID": val(row.BusinessParentBusinessID),
"ParentBusinessName": row.ParentBusinessName ?: "",
"HasChildren": row.ChildCount > 0
});
}
response["OK"] = true;
response["BEACONS"] = beacons;
} catch (any e) {
response["ERROR"] = e.message;
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -0,0 +1,76 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
function apiAbort(payload) {
writeOutput(serializeJSON(payload));
abort;
}
function readJsonBody() {
raw = toString(getHttpRequestData().content);
if (isNull(raw) || len(trim(raw)) EQ 0) return {};
try {
parsed = deserializeJSON(raw);
} catch(any e) {
apiAbort({ OK=false, ERROR="bad_json", MESSAGE="Invalid JSON body" });
}
if (!isStruct(parsed)) return {};
return parsed;
}
// Support GET param or POST body
parentBusinessId = 0;
if (structKeyExists(url, "BusinessID") && isNumeric(url.BusinessID)) {
parentBusinessId = int(url.BusinessID);
} else {
data = readJsonBody();
if (structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID)) {
parentBusinessId = int(data.BusinessID);
}
}
if (parentBusinessId LTE 0) {
apiAbort({ OK=false, ERROR="missing_business_id", MESSAGE="BusinessID is required" });
}
try {
q = queryExecute(
"
SELECT
BusinessID,
BusinessName
FROM Businesses
WHERE BusinessParentBusinessID = :parentId
ORDER BY BusinessName
",
{ parentId = { value = parentBusinessId, cfsqltype = "cf_sql_integer" } },
{ datasource = "payfrit" }
);
rows = [];
for (i = 1; i <= q.recordCount; i++) {
arrayAppend(rows, {
"BusinessID": q.BusinessID[i],
"BusinessName": q.BusinessName[i]
});
}
writeOutput(serializeJSON({
"OK": true,
"ERROR": "",
"BUSINESSES": rows
}));
abort;
} catch (any e) {
apiAbort({
"OK": false,
"ERROR": "server_error",
"DETAIL": e.message
});
}
</cfscript>

View file

@ -31,7 +31,10 @@ function haversineDistance(lat1, lng1, lat2, lng2) {
var a = sin(dLat/2) * sin(dLat/2) + var a = sin(dLat/2) * sin(dLat/2) +
cos(lat1 * 3.14159265359 / 180) * cos(lat2 * 3.14159265359 / 180) * cos(lat1 * 3.14159265359 / 180) * cos(lat2 * 3.14159265359 / 180) *
sin(dLng/2) * sin(dLng/2); sin(dLng/2) * sin(dLng/2);
var c = 2 * atn(sqr(a) / sqr(1-a)); // Clamp a to avoid NaN from sqrt of negative or division by zero
if (a < 0) a = 0;
if (a > 1) a = 1;
var c = 2 * asin(sqr(a));
return R * c; return R * c;
} }
@ -41,7 +44,7 @@ try {
userLng = structKeyExists(data, "lng") ? val(data.lng) : 0; userLng = structKeyExists(data, "lng") ? val(data.lng) : 0;
hasUserLocation = (userLat != 0 AND userLng != 0); hasUserLocation = (userLat != 0 AND userLng != 0);
// Get businesses with their address coordinates (exclude demo and hidden) // Get businesses with their address coordinates (exclude demo and private)
q = queryExecute( q = queryExecute(
" "
SELECT SELECT
@ -54,7 +57,7 @@ try {
FROM Businesses b FROM Businesses b
LEFT JOIN Addresses a ON b.BusinessAddressID = a.AddressID LEFT JOIN Addresses a ON b.BusinessAddressID = a.AddressID
WHERE (b.BusinessIsDemo = 0 OR b.BusinessIsDemo IS NULL) WHERE (b.BusinessIsDemo = 0 OR b.BusinessIsDemo IS NULL)
AND (b.BusinessIsHidden = 0 OR b.BusinessIsHidden IS NULL) AND (b.BusinessIsPrivate = 0 OR b.BusinessIsPrivate IS NULL)
ORDER BY b.BusinessName ORDER BY b.BusinessName
", ",
[], [],

View file

@ -3,8 +3,8 @@
<cfcontent type="application/json; charset=utf-8" reset="true"> <cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript> <cfscript>
// Check for an active (uncompleted) chat task at a business // Check for an active (uncompleted) chat task at a service point
// Input: BusinessID, ServicePointID (optional), UserID (optional) // Input: BusinessID, ServicePointID
// Output: { OK: true, HAS_ACTIVE_CHAT: true/false, TASK_ID: ... } // Output: { OK: true, HAS_ACTIVE_CHAT: true/false, TASK_ID: ... }
function apiAbort(required struct payload) { function apiAbort(required struct payload) {
@ -27,31 +27,39 @@ try {
data = readJsonBody(); data = readJsonBody();
businessID = val(structKeyExists(data, "BusinessID") ? data.BusinessID : 0); businessID = val(structKeyExists(data, "BusinessID") ? data.BusinessID : 0);
servicePointID = val(structKeyExists(data, "ServicePointID") ? data.ServicePointID : 0); servicePointID = val(structKeyExists(data, "ServicePointID") ? data.ServicePointID : 0);
userID = val(structKeyExists(data, "UserID") ? data.UserID : 0);
if (businessID == 0) { if (businessID == 0) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" }); apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
} }
// Look for any active chat task at this business (TaskTypeID = 2, not completed) if (servicePointID == 0) {
// Priority order: // No service point - can't find specific chat
// 1. Chats that are claimed (worker is responding) apiAbort({
// 2. Chats that have messages (ongoing conversation) "OK": true,
// 3. Most recently created chat "HAS_ACTIVE_CHAT": false,
"TASK_ID": 0,
"TASK_TITLE": ""
});
}
// Look for any active chat task at this service point
// TaskTypeID = 2 (Chat), not completed, at this service point
qChat = queryExecute(" qChat = queryExecute("
SELECT t.TaskID, t.TaskTitle, t.TaskSourceID, SELECT t.TaskID, t.TaskTitle,
(SELECT COUNT(*) FROM ChatMessages m WHERE m.TaskID = t.TaskID) as MessageCount (SELECT MAX(cm.CreatedOn) FROM ChatMessages cm WHERE cm.TaskID = t.TaskID) as LastMessageTime
FROM Tasks t FROM Tasks t
WHERE t.TaskBusinessID = :businessID WHERE t.TaskBusinessID = :businessID
AND t.TaskTypeID = 2 AND t.TaskTypeID = 2
AND t.TaskCompletedOn IS NULL AND t.TaskCompletedOn IS NULL
AND t.TaskSourceType = 'servicepoint'
AND t.TaskSourceID = :servicePointID
ORDER BY ORDER BY
CASE WHEN t.TaskClaimedByUserID > 0 THEN 0 ELSE 1 END, CASE WHEN t.TaskClaimedByUserID > 0 THEN 0 ELSE 1 END,
(SELECT COUNT(*) FROM ChatMessages m WHERE m.TaskID = t.TaskID) DESC,
t.TaskAddedOn DESC t.TaskAddedOn DESC
LIMIT 1 LIMIT 1
", { ", {
businessID: { value: businessID, cfsqltype: "cf_sql_integer" } businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
servicePointID: { value: servicePointID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" }); }, { datasource: "payfrit" });
if (qChat.recordCount > 0) { if (qChat.recordCount > 0) {

View file

@ -52,15 +52,20 @@ try {
senderType = "customer"; senderType = "customer";
} }
// Verify task exists // Verify task exists and is still open
taskQuery = queryExecute(" taskQuery = queryExecute("
SELECT TaskID, TaskClaimedByUserID FROM Tasks WHERE TaskID = :taskID SELECT TaskID, TaskClaimedByUserID, TaskCompletedOn FROM Tasks WHERE TaskID = :taskID
", { taskID: { value: taskID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); ", { taskID: { value: taskID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
if (taskQuery.recordCount == 0) { if (taskQuery.recordCount == 0) {
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Task not found" }); apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Task not found" });
} }
// Check if chat has been closed
if (len(trim(taskQuery.TaskCompletedOn)) > 0) {
apiAbort({ "OK": false, "ERROR": "chat_closed", "MESSAGE": "This chat has ended" });
}
// Insert message // Insert message
queryExecute(" queryExecute("
INSERT INTO ChatMessages (TaskID, SenderUserID, SenderType, MessageText) INSERT INTO ChatMessages (TaskID, SenderUserID, SenderType, MessageText)

View file

@ -57,6 +57,35 @@ try {
abort; abort;
} }
// Check for MenuID filter (optional - if provided, only return categories for that menu)
menuID = structKeyExists(requestData, "MenuID") ? val(requestData.MenuID) : 0;
// Get all menus for this business
allMenus = [];
try {
qMenus = queryExecute("
SELECT MenuID, MenuName, MenuDescription, MenuDaysActive,
MenuStartTime, MenuEndTime, MenuSortOrder
FROM Menus
WHERE MenuBusinessID = :businessID AND MenuIsActive = 1
ORDER BY MenuSortOrder, MenuName
", { businessID: businessID }, { datasource: "payfrit" });
for (m = 1; m <= qMenus.recordCount; m++) {
arrayAppend(allMenus, {
"MenuID": qMenus.MenuID[m],
"MenuName": qMenus.MenuName[m],
"MenuDescription": isNull(qMenus.MenuDescription[m]) ? "" : qMenus.MenuDescription[m],
"MenuDaysActive": qMenus.MenuDaysActive[m],
"MenuStartTime": isNull(qMenus.MenuStartTime[m]) ? "" : timeFormat(qMenus.MenuStartTime[m], "HH:mm"),
"MenuEndTime": isNull(qMenus.MenuEndTime[m]) ? "" : timeFormat(qMenus.MenuEndTime[m], "HH:mm"),
"MenuSortOrder": qMenus.MenuSortOrder[m]
});
}
} catch (any e) {
// Menus table might not exist yet
}
// Check if Categories table has data for this business // Check if Categories table has data for this business
hasCategoriesData = false; hasCategoriesData = false;
try { try {
@ -70,15 +99,24 @@ try {
if (hasCategoriesData) { if (hasCategoriesData) {
// OLD SCHEMA: Use Categories table for categories // OLD SCHEMA: Use Categories table for categories
// Build menu filter clause
menuFilter = "";
menuParams = { businessID: businessID };
if (menuID > 0) {
menuFilter = " AND CategoryMenuID = :menuID";
menuParams["menuID"] = menuID;
}
qCategories = queryExecute(" qCategories = queryExecute("
SELECT SELECT
CategoryID, CategoryID,
CategoryName, CategoryName,
CategorySortOrder as ItemSortOrder CategorySortOrder as ItemSortOrder,
CategoryMenuID
FROM Categories FROM Categories
WHERE CategoryBusinessID = :businessID WHERE CategoryBusinessID = :businessID #menuFilter#
ORDER BY CategorySortOrder, CategoryName ORDER BY CategorySortOrder, CategoryName
", { businessID: businessID }, { datasource: "payfrit" }); ", menuParams, { datasource: "payfrit" });
// Get menu items - items that belong to categories (not modifiers) // Get menu items - items that belong to categories (not modifiers)
qItems = queryExecute(" qItems = queryExecute("
@ -328,14 +366,25 @@ try {
catID = qCategories.CategoryID[i]; catID = qCategories.CategoryID[i];
catItems = structKeyExists(itemsByCategory, catID) ? itemsByCategory[catID] : []; catItems = structKeyExists(itemsByCategory, catID) ? itemsByCategory[catID] : [];
arrayAppend(categories, { catStruct = {
"id": "cat_" & qCategories.CategoryID[i], "id": "cat_" & qCategories.CategoryID[i],
"dbId": qCategories.CategoryID[i], "dbId": qCategories.CategoryID[i],
"name": qCategories.CategoryName[i], "name": qCategories.CategoryName[i],
"description": "", "description": "",
"sortOrder": catIndex, "sortOrder": catIndex,
"items": catItems "items": catItems
}); };
// Include MenuID if available (legacy schema with Categories table)
if (hasCategoriesData) {
try {
catStruct["menuId"] = isNull(qCategories.CategoryMenuID[i]) ? 0 : val(qCategories.CategoryMenuID[i]);
} catch (any e) {
catStruct["menuId"] = 0;
}
}
arrayAppend(categories, catStruct);
catIndex++; catIndex++;
} }
@ -360,10 +409,13 @@ try {
response["OK"] = true; response["OK"] = true;
response["MENU"] = { "categories": categories }; response["MENU"] = { "categories": categories };
response["MENUS"] = allMenus;
response["SELECTED_MENU_ID"] = menuID;
response["TEMPLATES"] = templateLibrary; response["TEMPLATES"] = templateLibrary;
response["BRANDCOLOR"] = brandColor; response["BRANDCOLOR"] = brandColor;
response["CATEGORY_COUNT"] = arrayLen(categories); response["CATEGORY_COUNT"] = arrayLen(categories);
response["TEMPLATE_COUNT"] = arrayLen(templateLibrary); response["TEMPLATE_COUNT"] = arrayLen(templateLibrary);
response["MENU_COUNT"] = arrayLen(allMenus);
response["SCHEMA"] = hasCategoriesData ? "legacy" : "unified"; response["SCHEMA"] = hasCategoriesData ? "legacy" : "unified";
totalItems = 0; totalItems = 0;

View file

@ -32,10 +32,20 @@
<cfset BusinessID = val(data.BusinessID)> <cfset BusinessID = val(data.BusinessID)>
</cfif> </cfif>
<!--- Optional OrderTypeID for channel filtering (1=Dine-In, 2=Takeaway, 3=Delivery) --->
<cfset OrderTypeID = 0>
<cfif structKeyExists(data, "OrderTypeID")>
<cfset OrderTypeID = val(data.OrderTypeID)>
</cfif>
<cfif BusinessID LTE 0> <cfif BusinessID LTE 0>
<cfset apiAbort({ "OK": false, "ERROR": "missing_businessid", "MESSAGE": "BusinessID is required.", "DETAIL": "" })> <cfset apiAbort({ "OK": false, "ERROR": "missing_businessid", "MESSAGE": "BusinessID is required.", "DETAIL": "" })>
</cfif> </cfif>
<!--- Get current time and day for schedule filtering --->
<cfset currentTime = timeFormat(now(), "HH:mm:ss")>
<cfset currentDayID = dayOfWeek(now())>
<cftry> <cftry>
<!--- Check if new schema is active (ItemBusinessID column exists and has data) ---> <!--- Check if new schema is active (ItemBusinessID column exists and has data) --->
<cfset newSchemaActive = false> <cfset newSchemaActive = false>
@ -70,24 +80,89 @@
<cfif hasCategoriesData> <cfif hasCategoriesData>
<!--- Use Categories table with ItemCategoryID ---> <!--- Use Categories table with ItemCategoryID --->
<!--- First, get category headers as virtual items ---> <!--- First, find which menus are currently active based on day/time --->
<cfset activeMenuIds = "">
<cftry>
<cfset qActiveMenus = queryExecute(
"
SELECT MenuID FROM Menus
WHERE MenuBusinessID = :bizId
AND MenuIsActive = 1
AND (MenuDaysActive & :dayBit) > 0
AND (
(MenuStartTime IS NULL OR MenuEndTime IS NULL)
OR (TIME(:currentTime) >= MenuStartTime AND TIME(:currentTime) <= MenuEndTime)
)
",
{
bizId: { value = BusinessID, cfsqltype = "cf_sql_integer" },
dayBit: { value = 2 ^ (currentDayID - 1), cfsqltype = "cf_sql_integer" },
currentTime: { value = currentTime, cfsqltype = "cf_sql_varchar" }
},
{ datasource = "payfrit" }
)>
<cfset activeMenuIds = valueList(qActiveMenus.MenuID)>
<cfcatch>
<!--- Menus table might not exist yet --->
</cfcatch>
</cftry>
<!--- Get category headers as virtual items --->
<!--- Apply schedule filtering, order type filtering, and menu filtering --->
<cfset qCategories = queryExecute( <cfset qCategories = queryExecute(
" "
SELECT SELECT
CategoryID, CategoryID,
CategoryName, CategoryName,
CategorySortOrder CategorySortOrder,
CategoryOrderTypes,
CategoryScheduleStart,
CategoryScheduleEnd,
CategoryScheduleDays,
CategoryMenuID
FROM Categories FROM Categories
WHERE CategoryBusinessID = ? WHERE CategoryBusinessID = :bizId
AND (
:orderTypeId = 0
OR FIND_IN_SET(:orderTypeId, CategoryOrderTypes) > 0
)
AND (
CategoryScheduleStart IS NULL
OR CategoryScheduleEnd IS NULL
OR (
TIME(:currentTime) >= CategoryScheduleStart
AND TIME(:currentTime) <= CategoryScheduleEnd
)
)
AND (
CategoryScheduleDays IS NULL
OR CategoryScheduleDays = ''
OR FIND_IN_SET(:currentDay, CategoryScheduleDays) > 0
)
AND (
CategoryMenuID IS NULL
OR CategoryMenuID = 0
#len(activeMenuIds) ? "OR CategoryMenuID IN (#activeMenuIds#)" : ""#
)
ORDER BY CategorySortOrder ORDER BY CategorySortOrder
", ",
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ], {
bizId: { value = BusinessID, cfsqltype = "cf_sql_integer" },
orderTypeId: { value = OrderTypeID, cfsqltype = "cf_sql_integer" },
currentTime: { value = currentTime, cfsqltype = "cf_sql_varchar" },
currentDay: { value = currentDayID, cfsqltype = "cf_sql_integer" }
},
{ datasource = "payfrit" } { datasource = "payfrit" }
)> )>
<!--- Get menu items ---> <!--- Get menu items --->
<!--- Exclude old-style category headers (ParentID=0 AND CategoryID=0 AND no children with CategoryID>0) ---> <!--- Exclude old-style category headers (ParentID=0 AND CategoryID=0 AND no children with CategoryID>0) --->
<!--- These are legacy category headers that should be replaced by Categories table entries ---> <!--- These are legacy category headers that should be replaced by Categories table entries --->
<!--- Only include items from visible categories (after schedule/channel filtering) --->
<cfset visibleCategoryIds = valueList(qCategories.CategoryID)>
<cfif len(trim(visibleCategoryIds)) EQ 0>
<cfset visibleCategoryIds = "0">
</cfif>
<cfset q = queryExecute( <cfset q = queryExecute(
" "
SELECT SELECT
@ -110,14 +185,15 @@
FROM Items i FROM Items i
LEFT JOIN Categories c ON c.CategoryID = i.ItemCategoryID LEFT JOIN Categories c ON c.CategoryID = i.ItemCategoryID
LEFT JOIN Stations s ON s.StationID = i.ItemStationID LEFT JOIN Stations s ON s.StationID = i.ItemStationID
WHERE i.ItemBusinessID = ? WHERE i.ItemBusinessID = :bizId
AND i.ItemIsActive = 1 AND i.ItemIsActive = 1
AND i.ItemCategoryID IN (#visibleCategoryIds#)
AND NOT EXISTS (SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = i.ItemID) AND NOT EXISTS (SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = i.ItemID)
AND NOT EXISTS (SELECT 1 FROM ItemTemplateLinks tl2 WHERE tl2.TemplateItemID = i.ItemParentItemID) AND NOT EXISTS (SELECT 1 FROM ItemTemplateLinks tl2 WHERE tl2.TemplateItemID = i.ItemParentItemID)
AND NOT (i.ItemParentItemID = 0 AND i.ItemCategoryID = 0 AND i.ItemPrice = 0) AND NOT (i.ItemParentItemID = 0 AND i.ItemCategoryID = 0 AND i.ItemPrice = 0)
ORDER BY COALESCE(c.CategorySortOrder, 999), i.ItemSortOrder, i.ItemID ORDER BY COALESCE(c.CategorySortOrder, 999), i.ItemSortOrder, i.ItemID
", ",
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ], { bizId: { value = BusinessID, cfsqltype = "cf_sql_integer" } },
{ datasource = "payfrit" } { datasource = "payfrit" }
)> )>
<cfelse> <cfelse>

View file

@ -0,0 +1,89 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* List Categories for a Business
*
* POST body:
* {
* "BusinessID": 37
* }
*
* Returns all categories with their schedule settings.
*/
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw) || len(trim(raw)) == 0) return {};
try {
var data = deserializeJSON(raw);
return isStruct(data) ? data : {};
} catch (any e) {
return {};
}
}
response = { "OK": false };
try {
data = readJsonBody();
BusinessID = structKeyExists(data, "BusinessID") ? val(data.BusinessID) : 0;
if (BusinessID <= 0) {
response["ERROR"] = "missing_businessid";
response["MESSAGE"] = "BusinessID is required";
writeOutput(serializeJSON(response));
abort;
}
q = queryExecute("
SELECT
CategoryID,
CategoryBusinessID,
CategoryParentCategoryID,
CategoryName,
CategoryImageExtension,
CategoryOrderTypes,
CategoryScheduleStart,
CategoryScheduleEnd,
CategoryScheduleDays,
CategorySortOrder,
CategoryAddedOn
FROM Categories
WHERE CategoryBusinessID = :bizId
ORDER BY CategorySortOrder, CategoryName
", { bizId: BusinessID }, { datasource = "payfrit" });
categories = [];
for (row in q) {
arrayAppend(categories, {
"CategoryID": row.CategoryID,
"CategoryBusinessID": row.CategoryBusinessID,
"CategoryParentCategoryID": row.CategoryParentCategoryID,
"CategoryName": row.CategoryName,
"CategoryImageExtension": len(trim(row.CategoryImageExtension)) ? row.CategoryImageExtension : "",
"CategoryOrderTypes": row.CategoryOrderTypes,
"CategoryScheduleStart": isNull(row.CategoryScheduleStart) ? "" : timeFormat(row.CategoryScheduleStart, "HH:mm:ss"),
"CategoryScheduleEnd": isNull(row.CategoryScheduleEnd) ? "" : timeFormat(row.CategoryScheduleEnd, "HH:mm:ss"),
"CategoryScheduleDays": isNull(row.CategoryScheduleDays) ? "" : row.CategoryScheduleDays,
"CategorySortOrder": row.CategorySortOrder,
"CategoryAddedOn": dateTimeFormat(row.CategoryAddedOn, "yyyy-mm-dd HH:nn:ss")
});
}
response["OK"] = true;
response["Categories"] = categories;
response["COUNT"] = arrayLen(categories);
} catch (any e) {
response["ERROR"] = "server_error";
response["MESSAGE"] = e.message;
response["DETAIL"] = e.detail;
}
writeOutput(serializeJSON(response));
</cfscript>

248
api/menu/menus.cfm Normal file
View file

@ -0,0 +1,248 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Menu CRUD API
*
* GET: List all menus for a business
* POST: Create or update a menu
* DELETE: Soft-delete a menu
*
* Input: BusinessID, and optionally Menu data for POST
*/
response = { "OK": false };
function apiAbort(payload) {
writeOutput(serializeJSON(payload));
abort;
}
try {
requestBody = toString(getHttpRequestData().content);
requestData = {};
if (len(requestBody)) {
requestData = deserializeJSON(requestBody);
}
businessID = 0;
if (structKeyExists(requestData, "BusinessID")) {
businessID = val(requestData.BusinessID);
}
if (businessID == 0) {
apiAbort({ "OK": false, "ERROR": "missing_business_id", "MESSAGE": "BusinessID is required" });
}
method = cgi.REQUEST_METHOD;
action = structKeyExists(requestData, "action") ? lCase(requestData.action) : "list";
// Handle different actions
switch (action) {
case "list":
// Get all active menus for this business
qMenus = queryExecute("
SELECT
MenuID,
MenuName,
MenuDescription,
MenuDaysActive,
MenuStartTime,
MenuEndTime,
MenuSortOrder,
MenuIsActive
FROM Menus
WHERE MenuBusinessID = :businessID
AND MenuIsActive = 1
ORDER BY MenuSortOrder, MenuName
", { businessID: businessID }, { datasource: "payfrit" });
menus = [];
for (i = 1; i <= qMenus.recordCount; i++) {
// Count categories in this menu
qCatCount = queryExecute("
SELECT COUNT(*) as cnt FROM Categories
WHERE CategoryBusinessID = :businessID
AND CategoryMenuID = :menuID
", { businessID: businessID, menuID: qMenus.MenuID[i] }, { datasource: "payfrit" });
arrayAppend(menus, {
"MenuID": qMenus.MenuID[i],
"MenuName": qMenus.MenuName[i],
"MenuDescription": isNull(qMenus.MenuDescription[i]) ? "" : qMenus.MenuDescription[i],
"MenuDaysActive": qMenus.MenuDaysActive[i],
"MenuStartTime": isNull(qMenus.MenuStartTime[i]) ? "" : timeFormat(qMenus.MenuStartTime[i], "HH:mm"),
"MenuEndTime": isNull(qMenus.MenuEndTime[i]) ? "" : timeFormat(qMenus.MenuEndTime[i], "HH:mm"),
"MenuSortOrder": qMenus.MenuSortOrder[i],
"CategoryCount": qCatCount.cnt
});
}
response = {
"OK": true,
"MENUS": menus,
"COUNT": arrayLen(menus)
};
break;
case "get":
// Get a single menu by ID
menuID = structKeyExists(requestData, "MenuID") ? val(requestData.MenuID) : 0;
if (menuID == 0) {
apiAbort({ "OK": false, "ERROR": "missing_menu_id", "MESSAGE": "MenuID is required" });
}
qMenu = queryExecute("
SELECT * FROM Menus
WHERE MenuID = :menuID AND MenuBusinessID = :businessID
", { menuID: menuID, businessID: businessID }, { datasource: "payfrit" });
if (qMenu.recordCount == 0) {
apiAbort({ "OK": false, "ERROR": "menu_not_found", "MESSAGE": "Menu not found" });
}
response = {
"OK": true,
"MENU": {
"MenuID": qMenu.MenuID,
"MenuName": qMenu.MenuName,
"MenuDescription": isNull(qMenu.MenuDescription) ? "" : qMenu.MenuDescription,
"MenuDaysActive": qMenu.MenuDaysActive,
"MenuStartTime": isNull(qMenu.MenuStartTime) ? "" : timeFormat(qMenu.MenuStartTime, "HH:mm"),
"MenuEndTime": isNull(qMenu.MenuEndTime) ? "" : timeFormat(qMenu.MenuEndTime, "HH:mm"),
"MenuSortOrder": qMenu.MenuSortOrder,
"MenuIsActive": qMenu.MenuIsActive
}
};
break;
case "save":
// Create or update a menu
menuID = structKeyExists(requestData, "MenuID") ? val(requestData.MenuID) : 0;
menuName = structKeyExists(requestData, "MenuName") ? trim(requestData.MenuName) : "";
menuDescription = structKeyExists(requestData, "MenuDescription") ? trim(requestData.MenuDescription) : "";
menuDaysActive = structKeyExists(requestData, "MenuDaysActive") ? val(requestData.MenuDaysActive) : 127;
menuStartTime = structKeyExists(requestData, "MenuStartTime") && len(trim(requestData.MenuStartTime)) ? trim(requestData.MenuStartTime) : javaCast("null", "");
menuEndTime = structKeyExists(requestData, "MenuEndTime") && len(trim(requestData.MenuEndTime)) ? trim(requestData.MenuEndTime) : javaCast("null", "");
menuSortOrder = structKeyExists(requestData, "MenuSortOrder") ? val(requestData.MenuSortOrder) : 0;
if (len(menuName) == 0) {
apiAbort({ "OK": false, "ERROR": "missing_menu_name", "MESSAGE": "Menu name is required" });
}
if (menuID > 0) {
// Update existing menu
queryExecute("
UPDATE Menus SET
MenuName = :menuName,
MenuDescription = :menuDescription,
MenuDaysActive = :menuDaysActive,
MenuStartTime = :menuStartTime,
MenuEndTime = :menuEndTime,
MenuSortOrder = :menuSortOrder
WHERE MenuID = :menuID AND MenuBusinessID = :businessID
", {
menuID: menuID,
businessID: businessID,
menuName: menuName,
menuDescription: menuDescription,
menuDaysActive: menuDaysActive,
menuStartTime: menuStartTime,
menuEndTime: menuEndTime,
menuSortOrder: menuSortOrder
}, { datasource: "payfrit" });
response = { "OK": true, "MenuID": menuID, "ACTION": "updated" };
} else {
// Create new menu
queryExecute("
INSERT INTO Menus (
MenuBusinessID, MenuName, MenuDescription,
MenuDaysActive, MenuStartTime, MenuEndTime,
MenuSortOrder, MenuIsActive, MenuAddedOn
) VALUES (
:businessID, :menuName, :menuDescription,
:menuDaysActive, :menuStartTime, :menuEndTime,
:menuSortOrder, 1, NOW()
)
", {
businessID: businessID,
menuName: menuName,
menuDescription: menuDescription,
menuDaysActive: menuDaysActive,
menuStartTime: menuStartTime,
menuEndTime: menuEndTime,
menuSortOrder: menuSortOrder
}, { datasource: "payfrit" });
result = queryExecute("SELECT LAST_INSERT_ID() as newID", {}, { datasource: "payfrit" });
response = { "OK": true, "MenuID": result.newID, "ACTION": "created" };
}
break;
case "delete":
// Soft-delete a menu
menuID = structKeyExists(requestData, "MenuID") ? val(requestData.MenuID) : 0;
if (menuID == 0) {
apiAbort({ "OK": false, "ERROR": "missing_menu_id", "MESSAGE": "MenuID is required" });
}
// Check if menu has categories
qCatCheck = queryExecute("
SELECT COUNT(*) as cnt FROM Categories
WHERE CategoryMenuID = :menuID
", { menuID: menuID }, { datasource: "payfrit" });
if (qCatCheck.cnt > 0) {
apiAbort({
"OK": false,
"ERROR": "menu_has_categories",
"MESSAGE": "Cannot delete menu with categories. Move or delete categories first.",
"CATEGORY_COUNT": qCatCheck.cnt
});
}
queryExecute("
UPDATE Menus SET MenuIsActive = 0
WHERE MenuID = :menuID AND MenuBusinessID = :businessID
", { menuID: menuID, businessID: businessID }, { datasource: "payfrit" });
response = { "OK": true, "MenuID": menuID, "ACTION": "deleted" };
break;
case "reorder":
// Reorder menus
menuOrder = structKeyExists(requestData, "MenuOrder") ? requestData.MenuOrder : [];
if (!isArray(menuOrder) || arrayLen(menuOrder) == 0) {
apiAbort({ "OK": false, "ERROR": "missing_menu_order", "MESSAGE": "MenuOrder array is required" });
}
for (i = 1; i <= arrayLen(menuOrder); i++) {
queryExecute("
UPDATE Menus SET MenuSortOrder = :sortOrder
WHERE MenuID = :menuID AND MenuBusinessID = :businessID
", {
menuID: val(menuOrder[i]),
businessID: businessID,
sortOrder: i - 1
}, { datasource: "payfrit" });
}
response = { "OK": true, "ACTION": "reordered" };
break;
default:
apiAbort({ "OK": false, "ERROR": "invalid_action", "MESSAGE": "Unknown action: " & action });
}
} catch (any e) {
response["ERROR"] = "server_error";
response["MESSAGE"] = e.message;
response["DETAIL"] = e.detail ?: "";
}
writeOutput(serializeJSON(response));
</cfscript>

111
api/menu/saveCategory.cfm Normal file
View file

@ -0,0 +1,111 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Save/Update Category
*
* POST body:
* {
* "CategoryID": 123, // Required for update
* "CategoryBusinessID": 37, // Required for insert
* "CategoryName": "Breakfast",
* "CategorySortOrder": 1,
* "CategoryOrderTypes": "1,2,3", // 1=Dine-In, 2=Takeaway, 3=Delivery
* "CategoryScheduleStart": "06:00:00", // Optional, null = always available
* "CategoryScheduleEnd": "11:00:00", // Optional
* "CategoryScheduleDays": "2,3,4,5,6" // Optional, 1=Sun..7=Sat, null = all days
* }
*/
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw) || len(trim(raw)) == 0) return {};
try {
var data = deserializeJSON(raw);
return isStruct(data) ? data : {};
} catch (any e) {
return {};
}
}
response = { "OK": false };
try {
data = readJsonBody();
CategoryID = structKeyExists(data, "CategoryID") ? val(data.CategoryID) : 0;
CategoryBusinessID = structKeyExists(data, "CategoryBusinessID") ? val(data.CategoryBusinessID) : 0;
CategoryName = structKeyExists(data, "CategoryName") ? left(trim(data.CategoryName), 30) : "";
CategorySortOrder = structKeyExists(data, "CategorySortOrder") ? val(data.CategorySortOrder) : 0;
CategoryOrderTypes = structKeyExists(data, "CategoryOrderTypes") ? trim(data.CategoryOrderTypes) : "1,2,3";
CategoryScheduleStart = structKeyExists(data, "CategoryScheduleStart") && len(trim(data.CategoryScheduleStart))
? trim(data.CategoryScheduleStart) : javaCast("null", "");
CategoryScheduleEnd = structKeyExists(data, "CategoryScheduleEnd") && len(trim(data.CategoryScheduleEnd))
? trim(data.CategoryScheduleEnd) : javaCast("null", "");
CategoryScheduleDays = structKeyExists(data, "CategoryScheduleDays") && len(trim(data.CategoryScheduleDays))
? trim(data.CategoryScheduleDays) : javaCast("null", "");
if (CategoryID > 0) {
// Update existing category
queryExecute("
UPDATE Categories SET
CategoryName = :name,
CategorySortOrder = :sortOrder,
CategoryOrderTypes = :orderTypes,
CategoryScheduleStart = :schedStart,
CategoryScheduleEnd = :schedEnd,
CategoryScheduleDays = :schedDays
WHERE CategoryID = :catId
", {
catId: CategoryID,
name: CategoryName,
sortOrder: CategorySortOrder,
orderTypes: CategoryOrderTypes,
schedStart: { value = CategoryScheduleStart, cfsqltype = "cf_sql_time", null = isNull(CategoryScheduleStart) },
schedEnd: { value = CategoryScheduleEnd, cfsqltype = "cf_sql_time", null = isNull(CategoryScheduleEnd) },
schedDays: { value = CategoryScheduleDays, cfsqltype = "cf_sql_varchar", null = isNull(CategoryScheduleDays) }
}, { datasource = "payfrit" });
response["OK"] = true;
response["CategoryID"] = CategoryID;
response["MESSAGE"] = "Category updated";
} else if (CategoryBusinessID > 0 && len(CategoryName)) {
// Insert new category
queryExecute("
INSERT INTO Categories
(CategoryBusinessID, CategoryName, CategorySortOrder, CategoryOrderTypes,
CategoryScheduleStart, CategoryScheduleEnd, CategoryScheduleDays, CategoryAddedOn)
VALUES
(:bizId, :name, :sortOrder, :orderTypes, :schedStart, :schedEnd, :schedDays, NOW())
", {
bizId: CategoryBusinessID,
name: CategoryName,
sortOrder: CategorySortOrder,
orderTypes: CategoryOrderTypes,
schedStart: { value = CategoryScheduleStart, cfsqltype = "cf_sql_time", null = isNull(CategoryScheduleStart) },
schedEnd: { value = CategoryScheduleEnd, cfsqltype = "cf_sql_time", null = isNull(CategoryScheduleEnd) },
schedDays: { value = CategoryScheduleDays, cfsqltype = "cf_sql_varchar", null = isNull(CategoryScheduleDays) }
}, { datasource = "payfrit" });
qNew = queryExecute("SELECT LAST_INSERT_ID() as newId", {}, { datasource = "payfrit" });
response["OK"] = true;
response["CategoryID"] = qNew.newId;
response["MESSAGE"] = "Category created";
} else {
response["ERROR"] = "invalid_params";
response["MESSAGE"] = "CategoryID required for update, or CategoryBusinessID and CategoryName for insert";
}
} catch (any e) {
response["ERROR"] = "server_error";
response["MESSAGE"] = e.message;
response["DETAIL"] = e.detail;
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -151,24 +151,31 @@ try {
categoryID = result.newID; categoryID = result.newID;
} }
} else { } else {
// Get menu ID from category if provided
categoryMenuId = structKeyExists(cat, "menuId") ? val(cat.menuId) : 0;
categoryMenuIdParam = categoryMenuId > 0 ? categoryMenuId : javaCast("null", "");
if (categoryDbId > 0) { if (categoryDbId > 0) {
categoryID = categoryDbId; categoryID = categoryDbId;
queryExecute(" queryExecute("
UPDATE Categories UPDATE Categories
SET CategoryName = :name, SET CategoryName = :name,
CategorySortOrder = :sortOrder CategorySortOrder = :sortOrder,
CategoryMenuID = :menuId
WHERE CategoryID = :categoryID WHERE CategoryID = :categoryID
", { ", {
categoryID: categoryID, categoryID: categoryID,
name: cat.name, name: cat.name,
sortOrder: catSortOrder sortOrder: catSortOrder,
menuId: categoryMenuIdParam
}); });
} else { } else {
queryExecute(" queryExecute("
INSERT INTO Categories (CategoryBusinessID, CategoryName, CategorySortOrder, CategoryAddedOn) INSERT INTO Categories (CategoryBusinessID, CategoryMenuID, CategoryName, CategorySortOrder, CategoryAddedOn)
VALUES (:businessID, :name, :sortOrder, NOW()) VALUES (:businessID, :menuId, :name, :sortOrder, NOW())
", { ", {
businessID: businessID, businessID: businessID,
menuId: categoryMenuIdParam,
name: cat.name, name: cat.name,
sortOrder: catSortOrder sortOrder: catSortOrder
}); });

View file

@ -50,17 +50,16 @@
<cfset apiAbort({ "OK": false, "ERROR": "invalid_status", "MESSAGE": "Only cart orders can be abandoned.", "DETAIL": "" })> <cfset apiAbort({ "OK": false, "ERROR": "invalid_status", "MESSAGE": "Only cart orders can be abandoned.", "DETAIL": "" })>
</cfif> </cfif>
<!--- Delete the order completely (cascades to line items via FK or we delete them first) ---> <!--- Delete line items --->
<!--- First delete all line items --->
<cfset queryExecute( <cfset queryExecute(
"DELETE FROM OrderLineItems WHERE OrderLineItemOrderID = ?", "DELETE FROM OrderLineItems WHERE OrderLineItemOrderID = ?",
[{ value = OrderID, cfsqltype = "cf_sql_integer" }], [{ value = OrderID, cfsqltype = "cf_sql_integer" }],
{ datasource = "payfrit" } { datasource = "payfrit" }
)> )>
<!--- Then delete the order itself ---> <!--- Mark order with status 7 (Deleted and started new cart) --->
<cfset queryExecute( <cfset queryExecute(
"DELETE FROM Orders WHERE OrderID = ?", "UPDATE Orders SET OrderStatusID = 7, OrderLastEditedOn = NOW() WHERE OrderID = ?",
[{ value = OrderID, cfsqltype = "cf_sql_integer" }], [{ value = OrderID, cfsqltype = "cf_sql_integer" }],
{ datasource = "payfrit" } { datasource = "payfrit" }
)> )>

View file

@ -83,7 +83,7 @@ try {
", { ", {
line1: len(addressLine1) ? addressLine1 : "Address pending", line1: len(addressLine1) ? addressLine1 : "Address pending",
city: len(city) ? city : "", city: len(city) ? city : "",
stateID: stateID, stateID: { value = stateID > 0 ? stateID : javaCast("null", ""), cfsqltype = "cf_sql_integer", null = stateID == 0 },
zip: len(zip) ? zip : "", zip: len(zip) ? zip : "",
userID: userId, userID: userId,
typeID: 2 typeID: 2
@ -118,6 +118,32 @@ try {
}, { datasource: "payfrit" }); }, { datasource: "payfrit" });
response.steps.append("Linked address to business"); response.steps.append("Linked address to business");
// Create default task types for the business
// 1. Call Server (notifications icon, purple)
// 2. Chat With Staff (chat icon, blue)
// 3. Pay With Cash (payments icon, green)
defaultTaskTypes = [
{ name: "Call Server", icon: "notifications", color: "##9C27B0", description: "Request server assistance" },
{ name: "Chat With Staff", icon: "chat", color: "##2196F3", description: "Open a chat conversation" },
{ name: "Pay With Cash", icon: "payments", color: "##4CAF50", description: "Request to pay with cash" }
];
for (tt = 1; tt <= arrayLen(defaultTaskTypes); tt++) {
taskType = defaultTaskTypes[tt];
queryExecute("
INSERT INTO tt_TaskTypes (tt_TaskTypeName, tt_TaskTypeDescription, tt_TaskTypeIcon, tt_TaskTypeColor, tt_TaskTypeBusinessID, tt_TaskTypeSortOrder)
VALUES (:name, :description, :icon, :color, :businessID, :sortOrder)
", {
name: { value: taskType.name, cfsqltype: "cf_sql_varchar" },
description: { value: taskType.description, cfsqltype: "cf_sql_varchar" },
icon: { value: taskType.icon, cfsqltype: "cf_sql_varchar" },
color: { value: taskType.color, cfsqltype: "cf_sql_varchar" },
businessID: { value: businessId, cfsqltype: "cf_sql_integer" },
sortOrder: { value: tt, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
}
response.steps.append("Created 3 default task types (Call Server, Chat With Staff, Pay With Cash)");
// Save business hours from structured schedule // Save business hours from structured schedule
if (structKeyExists(biz, "hoursSchedule") && isArray(biz.hoursSchedule)) { if (structKeyExists(biz, "hoursSchedule") && isArray(biz.hoursSchedule)) {
hoursSchedule = biz.hoursSchedule; hoursSchedule = biz.hoursSchedule;
@ -255,6 +281,83 @@ try {
} }
} }
// Create a Menu record for this business (or get existing menu with same name)
menuName = structKeyExists(wizardData, "menuName") && isSimpleValue(wizardData.menuName) && len(trim(wizardData.menuName))
? trim(wizardData.menuName)
: "Main Menu";
// Get menu time range (optional)
menuStartTime = structKeyExists(wizardData, "menuStartTime") && isSimpleValue(wizardData.menuStartTime) && len(trim(wizardData.menuStartTime))
? trim(wizardData.menuStartTime)
: "";
menuEndTime = structKeyExists(wizardData, "menuEndTime") && isSimpleValue(wizardData.menuEndTime) && len(trim(wizardData.menuEndTime))
? trim(wizardData.menuEndTime)
: "";
// Convert HH:MM to HH:MM:SS if needed
if (len(menuStartTime) == 5) menuStartTime = menuStartTime & ":00";
if (len(menuEndTime) == 5) menuEndTime = menuEndTime & ":00";
// Validate menu hours fall within business operating hours
if (len(menuStartTime) && len(menuEndTime)) {
qHours = queryExecute("
SELECT MIN(HoursOpenTime) as earliestOpen, MAX(HoursClosingTime) as latestClose
FROM Hours
WHERE HoursBusinessID = :bizID
", { bizID: businessId }, { datasource: "payfrit" });
if (qHours.recordCount > 0 && !isNull(qHours.earliestOpen) && !isNull(qHours.latestClose)) {
earliestOpen = timeFormat(qHours.earliestOpen, "HH:mm:ss");
latestClose = timeFormat(qHours.latestClose, "HH:mm:ss");
if (menuStartTime < earliestOpen || menuEndTime > latestClose) {
throw(message="Menu hours (" & menuStartTime & " - " & menuEndTime & ") must be within business operating hours (" & earliestOpen & " - " & latestClose & ")");
}
response.steps.append("Validated menu hours against business hours (" & earliestOpen & " - " & latestClose & ")");
}
}
qMenu = queryExecute("
SELECT MenuID FROM Menus
WHERE MenuBusinessID = :bizID AND MenuName = :name AND MenuIsActive = 1
", { bizID: businessId, name: menuName }, { datasource: "payfrit" });
if (qMenu.recordCount > 0) {
menuID = qMenu.MenuID;
// Update existing menu with new time range if provided
if (len(menuStartTime) && len(menuEndTime)) {
queryExecute("
UPDATE Menus SET MenuStartTime = :startTime, MenuEndTime = :endTime
WHERE MenuID = :menuID
", {
menuID: menuID,
startTime: menuStartTime,
endTime: menuEndTime
}, { datasource: "payfrit" });
response.steps.append("Updated existing menu: " & menuName & " (ID: " & menuID & ") with hours " & menuStartTime & " - " & menuEndTime);
} else {
response.steps.append("Using existing menu: " & menuName & " (ID: " & menuID & ")");
}
} else {
queryExecute("
INSERT INTO Menus (
MenuBusinessID, MenuName, MenuDaysActive, MenuStartTime, MenuEndTime, MenuSortOrder, MenuIsActive, MenuAddedOn
) VALUES (
:bizID, :name, 127, :startTime, :endTime, 0, 1, NOW()
)
", {
bizID: businessId,
name: menuName,
startTime: len(menuStartTime) ? menuStartTime : javaCast("null", ""),
endTime: len(menuEndTime) ? menuEndTime : javaCast("null", "")
}, { datasource: "payfrit" });
qNewMenu = queryExecute("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" });
menuID = qNewMenu.id;
timeInfo = len(menuStartTime) && len(menuEndTime) ? " (" & menuStartTime & " - " & menuEndTime & ")" : " (all day)";
response.steps.append("Created menu: " & menuName & timeInfo & " (ID: " & menuID & ")");
}
// Build category map // Build category map
categories = structKeyExists(wizardData, "categories") ? wizardData.categories : []; categories = structKeyExists(wizardData, "categories") ? wizardData.categories : [];
categoryMap = {}; // Maps category name to CategoryID categoryMap = {}; // Maps category name to CategoryID
@ -270,32 +373,33 @@ try {
continue; continue;
} }
// Check if category exists in Categories table // Check if category exists in Categories table for this menu
qCat = queryExecute(" qCat = queryExecute("
SELECT CategoryID FROM Categories SELECT CategoryID FROM Categories
WHERE CategoryBusinessID = :bizID AND CategoryName = :name WHERE CategoryBusinessID = :bizID AND CategoryName = :name AND CategoryMenuID = :menuID
", { bizID: businessId, name: catName }, { datasource: "payfrit" }); ", { bizID: businessId, name: catName, menuID: menuID }, { datasource: "payfrit" });
if (qCat.recordCount > 0) { if (qCat.recordCount > 0) {
categoryID = qCat.CategoryID; categoryID = qCat.CategoryID;
response.steps.append("Category exists: " & catName & " (ID: " & categoryID & ")"); response.steps.append("Category exists: " & catName & " (ID: " & categoryID & ")");
} else { } else {
// Create category in Categories table // Create category in Categories table with MenuID
queryExecute(" queryExecute("
INSERT INTO Categories ( INSERT INTO Categories (
CategoryBusinessID, CategoryName, CategorySortOrder CategoryBusinessID, CategoryMenuID, CategoryName, CategorySortOrder
) VALUES ( ) VALUES (
:bizID, :name, :sortOrder :bizID, :menuID, :name, :sortOrder
) )
", { ", {
bizID: businessId, bizID: businessId,
menuID: menuID,
name: catName, name: catName,
sortOrder: catOrder sortOrder: catOrder
}, { datasource: "payfrit" }); }, { datasource: "payfrit" });
qNewCat = queryExecute("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" }); qNewCat = queryExecute("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" });
categoryID = qNewCat.id; categoryID = qNewCat.id;
response.steps.append("Created category: " & catName & " (ID: " & categoryID & ")"); response.steps.append("Created category: " & catName & " in menu " & menuName & " (ID: " & categoryID & ")");
} }
categoryMap[catName] = categoryID; categoryMap[catName] = categoryID;

View file

@ -30,6 +30,7 @@ try {
orderID = val(structKeyExists(data, "OrderID") ? data.OrderID : 0); orderID = val(structKeyExists(data, "OrderID") ? data.OrderID : 0);
message = trim(structKeyExists(data, "Message") ? data.Message : ""); message = trim(structKeyExists(data, "Message") ? data.Message : "");
userID = val(structKeyExists(data, "UserID") ? data.UserID : 0); userID = val(structKeyExists(data, "UserID") ? data.UserID : 0);
taskTypeID = val(structKeyExists(data, "TaskTypeID") ? data.TaskTypeID : 0);
if (businessID == 0) { if (businessID == 0) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" }); apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
@ -105,7 +106,7 @@ try {
:businessID, :businessID,
:categoryID, :categoryID,
:orderID, :orderID,
1, :taskTypeID,
:title, :title,
:details, :details,
0, 0,
@ -115,6 +116,7 @@ try {
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }, businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
categoryID: { value: categoryID, cfsqltype: "cf_sql_integer" }, categoryID: { value: categoryID, cfsqltype: "cf_sql_integer" },
orderID: { value: orderID > 0 ? orderID : javaCast("null", ""), cfsqltype: "cf_sql_integer", null: orderID == 0 }, orderID: { value: orderID > 0 ? orderID : javaCast("null", ""), cfsqltype: "cf_sql_integer", null: orderID == 0 },
taskTypeID: { value: taskTypeID > 0 ? taskTypeID : javaCast("null", ""), cfsqltype: "cf_sql_integer", null: taskTypeID == 0 },
title: { value: taskTitle, cfsqltype: "cf_sql_varchar" }, title: { value: taskTitle, cfsqltype: "cf_sql_varchar" },
details: { value: taskDetails, cfsqltype: "cf_sql_varchar" } details: { value: taskDetails, cfsqltype: "cf_sql_varchar" }
}, { datasource: "payfrit" }); }, { datasource: "payfrit" });

View file

@ -4,7 +4,7 @@
<cfscript> <cfscript>
// Customer initiates a chat with staff // Customer initiates a chat with staff
// Input: BusinessID, ServicePointID, OrderID (optional), Message (optional initial message) // Input: BusinessID, ServicePointID, OrderID (optional), UserID (optional), Message (optional initial message)
// Output: { OK: true, TaskID: ... } // Output: { OK: true, TaskID: ... }
function apiAbort(required struct payload) { function apiAbort(required struct payload) {
@ -35,41 +35,48 @@ try {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" }); apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
} }
if (servicePointID == 0) { // ServicePointID = 0 is allowed for remote chats (non-dine-in users)
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "ServicePointID is required" }); // In that case, we use userID to match existing chats
}
// Check for existing open chat for this user at this business // Check for existing open chat at this service point
// An open chat is one where TaskTypeID=2 (Chat) and TaskCompletedOn IS NULL // An open chat is one where TaskTypeID=2 (Chat) and TaskCompletedOn IS NULL
// Also check it's tied to the current order if we have one
forceNew = structKeyExists(data, "ForceNew") && data.ForceNew == true; forceNew = structKeyExists(data, "ForceNew") && data.ForceNew == true;
if (userID > 0 && !forceNew) { if (!forceNew) {
// Look for any active chat for this business
// Check by: order match, service point match, OR user match (for remote chats)
existingChat = queryExecute(" existingChat = queryExecute("
SELECT t.TaskID, t.TaskAddedOn SELECT t.TaskID, t.TaskAddedOn,
(SELECT MAX(cm.CreatedOn) FROM ChatMessages cm WHERE cm.TaskID = t.TaskID) as LastMessageTime
FROM Tasks t FROM Tasks t
LEFT JOIN Orders o ON o.OrderID = t.TaskOrderID LEFT JOIN ChatMessages cm2 ON cm2.TaskID = t.TaskID AND cm2.SenderUserID = :userID
WHERE t.TaskBusinessID = :businessID WHERE t.TaskBusinessID = :businessID
AND t.TaskTypeID = 2 AND t.TaskTypeID = 2
AND t.TaskCompletedOn IS NULL AND t.TaskCompletedOn IS NULL
AND ( AND (
(t.TaskOrderID = :orderID AND :orderID > 0) (t.TaskOrderID = :orderID AND :orderID > 0)
OR (t.TaskOrderID IS NULL AND o.OrderUserID = :userID) OR (t.TaskSourceType = 'servicepoint' AND t.TaskSourceID = :servicePointID AND :servicePointID > 0)
OR (t.TaskOrderID IS NULL AND t.TaskSourceID = :servicePointID) OR (t.TaskSourceType = 'user' AND t.TaskSourceID = :userID AND :userID > 0)
OR (cm2.SenderUserID = :userID AND :userID > 0)
) )
ORDER BY t.TaskAddedOn DESC ORDER BY t.TaskAddedOn DESC
LIMIT 1 LIMIT 1
", { ", {
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }, businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
userID: { value: userID, cfsqltype: "cf_sql_integer" },
servicePointID: { value: servicePointID, cfsqltype: "cf_sql_integer" }, servicePointID: { value: servicePointID, cfsqltype: "cf_sql_integer" },
orderID: { value: orderID, cfsqltype: "cf_sql_integer" } orderID: { value: orderID, cfsqltype: "cf_sql_integer" },
userID: { value: userID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" }); }, { datasource: "payfrit" });
if (existingChat.recordCount > 0) { if (existingChat.recordCount > 0) {
// Check if chat is stale (more than 20 minutes old with no activity) // Check if chat is stale (more than 30 minutes since last message, or 30 min since creation if no messages)
chatAge = dateDiff("n", existingChat.TaskAddedOn, now()); lastActivity = existingChat.LastMessageTime;
if (chatAge > 20) { if (isNull(lastActivity) || !isDate(lastActivity)) {
lastActivity = existingChat.TaskAddedOn;
}
chatAge = dateDiff("n", lastActivity, now());
if (chatAge > 30) {
// Auto-close stale chat // Auto-close stale chat
queryExecute(" queryExecute("
UPDATE Tasks SET TaskCompletedOn = NOW() UPDATE Tasks SET TaskCompletedOn = NOW()
@ -87,12 +94,14 @@ try {
} }
} }
// Get service point info (table name) // Get service point info (table name) - only if dine-in
spQuery = queryExecute(" tableName = "";
SELECT ServicePointName FROM ServicePoints WHERE ServicePointID = :spID if (servicePointID > 0) {
", { spID: { value: servicePointID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); spQuery = queryExecute("
SELECT ServicePointName FROM ServicePoints WHERE ServicePointID = :spID
tableName = spQuery.recordCount ? spQuery.ServicePointName : "Table ##" & servicePointID; ", { spID: { value: servicePointID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
tableName = spQuery.recordCount ? spQuery.ServicePointName : "Table ##" & servicePointID;
}
// Get user name if available // Get user name if available
userName = ""; userName = "";
@ -105,10 +114,20 @@ try {
} }
} }
// Create task title // Create task title - different format for dine-in vs remote
taskTitle = "Chat - " & tableName; if (servicePointID > 0) {
if (len(userName)) { // Dine-in: "Chat - UserName (TableName)" or "Chat - TableName"
taskTitle = "Chat - " & userName & " (" & tableName & ")"; taskTitle = "Chat - " & tableName;
if (len(userName)) {
taskTitle = "Chat - " & userName & " (" & tableName & ")";
}
} else {
// Remote: "Chat - UserName (Remote)" or "Remote Chat"
if (len(userName)) {
taskTitle = "Chat - " & userName & " (Remote)";
} else {
taskTitle = "Remote Chat";
}
} }
taskDetails = "Customer initiated chat"; taskDetails = "Customer initiated chat";
@ -136,6 +155,15 @@ try {
categoryID = catQuery.TaskCategoryID; categoryID = catQuery.TaskCategoryID;
} }
// Determine source type and ID based on dine-in vs remote
if (servicePointID > 0) {
sourceType = "servicepoint";
sourceID = servicePointID;
} else {
sourceType = "user";
sourceID = userID;
}
// Insert task with TaskTypeID = 2 (Chat) // Insert task with TaskTypeID = 2 (Chat)
queryExecute(" queryExecute("
INSERT INTO Tasks ( INSERT INTO Tasks (
@ -157,8 +185,8 @@ try {
:title, :title,
:details, :details,
0, 0,
'servicepoint', :sourceType,
:servicePointID, :sourceID,
NOW() NOW()
) )
", { ", {
@ -167,7 +195,8 @@ try {
orderID: { value: orderID > 0 ? orderID : javaCast("null", ""), cfsqltype: "cf_sql_integer", null: orderID == 0 }, orderID: { value: orderID > 0 ? orderID : javaCast("null", ""), cfsqltype: "cf_sql_integer", null: orderID == 0 },
title: { value: taskTitle, cfsqltype: "cf_sql_varchar" }, title: { value: taskTitle, cfsqltype: "cf_sql_varchar" },
details: { value: taskDetails, cfsqltype: "cf_sql_varchar" }, details: { value: taskDetails, cfsqltype: "cf_sql_varchar" },
servicePointID: { value: servicePointID, cfsqltype: "cf_sql_integer" } sourceType: { value: sourceType, cfsqltype: "cf_sql_varchar" },
sourceID: { value: sourceID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" }); }, { datasource: "payfrit" });
// Get the new task ID // Get the new task ID

86
api/tasks/deleteType.cfm Normal file
View file

@ -0,0 +1,86 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// Delete a task type for a business
// Input: TaskTypeID (required), BusinessID (required)
// Output: { OK: true }
function apiAbort(required struct payload) {
writeOutput(serializeJSON(payload));
abort;
}
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw)) raw = "";
if (!len(trim(raw))) return {};
try {
var data = deserializeJSON(raw);
if (isStruct(data)) return data;
} catch (any e) {}
return {};
}
try {
data = readJsonBody();
// Get TaskTypeID
taskTypeID = 0;
if (structKeyExists(data, "TaskTypeID") && isNumeric(data.TaskTypeID)) {
taskTypeID = int(data.TaskTypeID);
}
if (taskTypeID == 0) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "TaskTypeID is required" });
}
// Get BusinessID
businessID = 0;
if (structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID)) {
businessID = int(data.BusinessID);
}
if (businessID == 0) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
}
// Verify task type exists and belongs to this business
qCheck = queryExecute("
SELECT tt_TaskTypeID, tt_TaskTypeBusinessID
FROM tt_TaskTypes
WHERE tt_TaskTypeID = :taskTypeID
", {
taskTypeID: { value: taskTypeID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
if (qCheck.recordCount == 0) {
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Task type not found" });
}
if (isNull(qCheck.tt_TaskTypeBusinessID) || qCheck.tt_TaskTypeBusinessID != businessID) {
apiAbort({ "OK": false, "ERROR": "not_authorized", "MESSAGE": "Task type does not belong to this business" });
}
// Delete the task type
queryExecute("
DELETE FROM tt_TaskTypes
WHERE tt_TaskTypeID = :taskTypeID
AND tt_TaskTypeBusinessID = :businessID
", {
taskTypeID: { value: taskTypeID, cfsqltype: "cf_sql_integer" },
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
apiAbort({
"OK": true,
"MESSAGE": "Task type deleted"
});
} catch (any e) {
apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": e.message
});
}
</cfscript>

View file

@ -0,0 +1,85 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// Returns all task types for a business (for portal management)
// Input: BusinessID (required)
// Output: { OK: true, TASK_TYPES: [...] }
function apiAbort(required struct payload) {
writeOutput(serializeJSON(payload));
abort;
}
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw)) raw = "";
if (!len(trim(raw))) return {};
try {
var data = deserializeJSON(raw);
if (isStruct(data)) return data;
} catch (any e) {}
return {};
}
try {
data = readJsonBody();
// Get BusinessID from body, header, or URL
businessID = 0;
httpHeaders = getHttpRequestData().headers;
if (structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID)) {
businessID = int(data.BusinessID);
} else if (structKeyExists(httpHeaders, "X-Business-ID") && isNumeric(httpHeaders["X-Business-ID"])) {
businessID = int(httpHeaders["X-Business-ID"]);
} else if (structKeyExists(url, "BusinessID") && isNumeric(url.BusinessID)) {
businessID = int(url.BusinessID);
}
if (businessID == 0) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
}
// Get task types for this business
q = queryExecute("
SELECT
tt_TaskTypeID as TaskTypeID,
tt_TaskTypeName as TaskTypeName,
tt_TaskTypeDescription as TaskTypeDescription,
tt_TaskTypeIcon as TaskTypeIcon,
tt_TaskTypeColor as TaskTypeColor,
tt_TaskTypeSortOrder as SortOrder
FROM tt_TaskTypes
WHERE tt_TaskTypeBusinessID = :businessID
ORDER BY tt_TaskTypeSortOrder, tt_TaskTypeID
", {
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
taskTypes = [];
for (row in q) {
arrayAppend(taskTypes, {
"TaskTypeID": row.TaskTypeID,
"TaskTypeName": row.TaskTypeName,
"TaskTypeDescription": isNull(row.TaskTypeDescription) ? "" : row.TaskTypeDescription,
"TaskTypeIcon": isNull(row.TaskTypeIcon) ? "notifications" : row.TaskTypeIcon,
"TaskTypeColor": isNull(row.TaskTypeColor) ? "##9C27B0" : row.TaskTypeColor
});
}
apiAbort({
"OK": true,
"TASK_TYPES": taskTypes,
"COUNT": arrayLen(taskTypes)
});
} catch (any e) {
apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": e.message
});
}
</cfscript>

View file

@ -71,15 +71,21 @@
t.TaskCategoryID, t.TaskCategoryID,
t.TaskOrderID, t.TaskOrderID,
t.TaskTypeID, t.TaskTypeID,
t.TaskTitle,
t.TaskDetails,
t.TaskAddedOn, t.TaskAddedOn,
t.TaskClaimedByUserID, t.TaskClaimedByUserID,
t.TaskClaimedOn, t.TaskClaimedOn,
t.TaskCompletedOn, t.TaskCompletedOn,
tc.TaskCategoryName, tc.TaskCategoryName,
tc.TaskCategoryColor, tc.TaskCategoryColor,
tt.tt_TaskTypeName AS TaskTypeName,
tt.tt_TaskTypeIcon AS TaskTypeIcon,
tt.tt_TaskTypeColor AS TaskTypeColor,
b.BusinessName b.BusinessName
FROM Tasks t FROM Tasks t
LEFT JOIN TaskCategories tc ON tc.TaskCategoryID = t.TaskCategoryID LEFT JOIN TaskCategories tc ON tc.TaskCategoryID = t.TaskCategoryID
LEFT JOIN tt_TaskTypes tt ON tt.tt_TaskTypeID = t.TaskTypeID
LEFT JOIN Businesses b ON b.BusinessID = t.TaskBusinessID LEFT JOIN Businesses b ON b.BusinessID = t.TaskBusinessID
WHERE #whereSQL# WHERE #whereSQL#
ORDER BY t.TaskClaimedOn DESC ORDER BY t.TaskClaimedOn DESC
@ -88,9 +94,19 @@
<cfset tasks = []> <cfset tasks = []>
<cfloop query="qTasks"> <cfloop query="qTasks">
<cfset taskTitle = "Task ##" & qTasks.TaskID> <!--- Use stored title if available, otherwise build from order --->
<cfif qTasks.TaskOrderID GT 0> <cfset taskTitle = "">
<cfif NOT isNull(qTasks.TaskTitle) AND len(trim(qTasks.TaskTitle)) GT 0>
<cfset taskTitle = qTasks.TaskTitle>
<cfelseif qTasks.TaskOrderID GT 0>
<cfset taskTitle = "Order ##" & qTasks.TaskOrderID> <cfset taskTitle = "Order ##" & qTasks.TaskOrderID>
<cfelse>
<cfset taskTitle = "Task ##" & qTasks.TaskID>
</cfif>
<cfset taskDetails = "">
<cfif NOT isNull(qTasks.TaskDetails) AND len(trim(qTasks.TaskDetails)) GT 0>
<cfset taskDetails = qTasks.TaskDetails>
</cfif> </cfif>
<cfset arrayAppend(tasks, { <cfset arrayAppend(tasks, {
@ -100,7 +116,7 @@
"TaskCategoryID": qTasks.TaskCategoryID, "TaskCategoryID": qTasks.TaskCategoryID,
"TaskTypeID": qTasks.TaskTypeID, "TaskTypeID": qTasks.TaskTypeID,
"TaskTitle": taskTitle, "TaskTitle": taskTitle,
"TaskDetails": "", "TaskDetails": taskDetails,
"TaskCreatedOn": dateFormat(qTasks.TaskAddedOn, "yyyy-mm-dd") & "T" & timeFormat(qTasks.TaskAddedOn, "HH:mm:ss"), "TaskCreatedOn": dateFormat(qTasks.TaskAddedOn, "yyyy-mm-dd") & "T" & timeFormat(qTasks.TaskAddedOn, "HH:mm:ss"),
"TaskClaimedOn": (isNull(qTasks.TaskClaimedOn) OR len(trim(qTasks.TaskClaimedOn)) EQ 0) ? "" : dateFormat(qTasks.TaskClaimedOn, "yyyy-mm-dd") & "T" & timeFormat(qTasks.TaskClaimedOn, "HH:mm:ss"), "TaskClaimedOn": (isNull(qTasks.TaskClaimedOn) OR len(trim(qTasks.TaskClaimedOn)) EQ 0) ? "" : dateFormat(qTasks.TaskClaimedOn, "yyyy-mm-dd") & "T" & timeFormat(qTasks.TaskClaimedOn, "HH:mm:ss"),
"TaskCompletedOn": (isNull(qTasks.TaskCompletedOn) OR len(trim(qTasks.TaskCompletedOn)) EQ 0) ? "" : dateFormat(qTasks.TaskCompletedOn, "yyyy-mm-dd") & "T" & timeFormat(qTasks.TaskCompletedOn, "HH:mm:ss"), "TaskCompletedOn": (isNull(qTasks.TaskCompletedOn) OR len(trim(qTasks.TaskCompletedOn)) EQ 0) ? "" : dateFormat(qTasks.TaskCompletedOn, "yyyy-mm-dd") & "T" & timeFormat(qTasks.TaskCompletedOn, "HH:mm:ss"),
@ -108,7 +124,10 @@
"TaskSourceType": "order", "TaskSourceType": "order",
"TaskSourceID": qTasks.TaskOrderID, "TaskSourceID": qTasks.TaskOrderID,
"TaskCategoryName": len(trim(qTasks.TaskCategoryName)) ? qTasks.TaskCategoryName : "General", "TaskCategoryName": len(trim(qTasks.TaskCategoryName)) ? qTasks.TaskCategoryName : "General",
"TaskCategoryColor": len(trim(qTasks.TaskCategoryColor)) ? qTasks.TaskCategoryColor : "##888888" "TaskCategoryColor": len(trim(qTasks.TaskCategoryColor)) ? qTasks.TaskCategoryColor : "##888888",
"TaskTypeName": len(trim(qTasks.TaskTypeName)) ? qTasks.TaskTypeName : "",
"TaskTypeIcon": len(trim(qTasks.TaskTypeIcon)) ? qTasks.TaskTypeIcon : "notifications",
"TaskTypeColor": len(trim(qTasks.TaskTypeColor)) ? qTasks.TaskTypeColor : "##9C27B0"
})> })>
</cfloop> </cfloop>

View file

@ -66,9 +66,13 @@
t.TaskAddedOn, t.TaskAddedOn,
t.TaskClaimedByUserID, t.TaskClaimedByUserID,
tc.TaskCategoryName, tc.TaskCategoryName,
tc.TaskCategoryColor tc.TaskCategoryColor,
tt.tt_TaskTypeName AS TaskTypeName,
tt.tt_TaskTypeIcon AS TaskTypeIcon,
tt.tt_TaskTypeColor AS TaskTypeColor
FROM Tasks t FROM Tasks t
LEFT JOIN TaskCategories tc ON tc.TaskCategoryID = t.TaskCategoryID LEFT JOIN TaskCategories tc ON tc.TaskCategoryID = t.TaskCategoryID
LEFT JOIN tt_TaskTypes tt ON tt.tt_TaskTypeID = t.TaskTypeID
WHERE #whereSQL# WHERE #whereSQL#
ORDER BY t.TaskAddedOn ASC ORDER BY t.TaskAddedOn ASC
", params, { datasource = "payfrit" })> ", params, { datasource = "payfrit" })>
@ -100,7 +104,10 @@
"TaskSourceType": "order", "TaskSourceType": "order",
"TaskSourceID": qTasks.TaskOrderID, "TaskSourceID": qTasks.TaskOrderID,
"TaskCategoryName": len(trim(qTasks.TaskCategoryName)) ? qTasks.TaskCategoryName : "General", "TaskCategoryName": len(trim(qTasks.TaskCategoryName)) ? qTasks.TaskCategoryName : "General",
"TaskCategoryColor": len(trim(qTasks.TaskCategoryColor)) ? qTasks.TaskCategoryColor : "##888888" "TaskCategoryColor": len(trim(qTasks.TaskCategoryColor)) ? qTasks.TaskCategoryColor : "##888888",
"TaskTypeName": len(trim(qTasks.TaskTypeName)) ? qTasks.TaskTypeName : "",
"TaskTypeIcon": len(trim(qTasks.TaskTypeIcon)) ? qTasks.TaskTypeIcon : "notifications",
"TaskTypeColor": len(trim(qTasks.TaskTypeColor)) ? qTasks.TaskTypeColor : "##9C27B0"
})> })>
</cfloop> </cfloop>

86
api/tasks/listTypes.cfm Normal file
View file

@ -0,0 +1,86 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// Returns task types for a business
// Input: BusinessID (required)
// Output: { OK: true, TASK_TYPES: [...] }
// Each business has their own task types (no system defaults)
function apiAbort(required struct payload) {
writeOutput(serializeJSON(payload));
abort;
}
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw)) raw = "";
if (!len(trim(raw))) return {};
try {
var data = deserializeJSON(raw);
if (isStruct(data)) return data;
} catch (any e) {}
return {};
}
try {
data = readJsonBody();
// Get BusinessID from body, header, or URL
businessID = 0;
httpHeaders = getHttpRequestData().headers;
if (structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID)) {
businessID = int(data.BusinessID);
} else if (structKeyExists(httpHeaders, "X-Business-ID") && isNumeric(httpHeaders["X-Business-ID"])) {
businessID = int(httpHeaders["X-Business-ID"]);
} else if (structKeyExists(url, "BusinessID") && isNumeric(url.BusinessID)) {
businessID = int(url.BusinessID);
}
if (businessID == 0) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
}
// Get task types for this business
q = queryExecute("
SELECT
tt_TaskTypeID as TaskTypeID,
tt_TaskTypeName as TaskTypeName,
tt_TaskTypeDescription as TaskTypeDescription,
tt_TaskTypeIcon as TaskTypeIcon,
tt_TaskTypeColor as TaskTypeColor,
tt_TaskTypeSortOrder as SortOrder
FROM tt_TaskTypes
WHERE tt_TaskTypeBusinessID = :businessID
ORDER BY tt_TaskTypeSortOrder, tt_TaskTypeID
", {
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
taskTypes = [];
for (row in q) {
arrayAppend(taskTypes, {
"TaskTypeID": row.TaskTypeID,
"TaskTypeName": row.TaskTypeName,
"TaskTypeDescription": isNull(row.TaskTypeDescription) ? "" : row.TaskTypeDescription,
"TaskTypeIcon": isNull(row.TaskTypeIcon) ? "notifications" : row.TaskTypeIcon,
"TaskTypeColor": isNull(row.TaskTypeColor) ? "##9C27B0" : row.TaskTypeColor
});
}
apiAbort({
"OK": true,
"TASK_TYPES": taskTypes,
"COUNT": arrayLen(taskTypes)
});
} catch (any e) {
apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": e.message
});
}
</cfscript>

View file

@ -0,0 +1,74 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// Update sort order for task types
// Input: BusinessID (required), Order (array of TaskTypeIDs in desired order)
// Output: { OK: true }
function apiAbort(required struct payload) {
writeOutput(serializeJSON(payload));
abort;
}
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw)) raw = "";
if (!len(trim(raw))) return {};
try {
var data = deserializeJSON(raw);
if (isStruct(data)) return data;
} catch (any e) {}
return {};
}
try {
data = readJsonBody();
// Get BusinessID
businessID = 0;
if (structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID)) {
businessID = int(data.BusinessID);
}
if (businessID == 0) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
}
// Get Order array
if (!structKeyExists(data, "Order") || !isArray(data.Order)) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Order array is required" });
}
orderArray = data.Order;
// Update sort order for each task type
sortOrder = 0;
for (taskTypeID in orderArray) {
if (isNumeric(taskTypeID)) {
sortOrder++;
queryExecute("
UPDATE tt_TaskTypes
SET tt_TaskTypeSortOrder = :sortOrder
WHERE tt_TaskTypeID = :taskTypeID
AND tt_TaskTypeBusinessID = :businessID
", {
sortOrder: { value: sortOrder, cfsqltype: "cf_sql_integer" },
taskTypeID: { value: int(taskTypeID), cfsqltype: "cf_sql_integer" },
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
}
}
apiAbort({
"OK": true,
"MESSAGE": "Sort order updated"
});
} catch (any e) {
apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": e.message
});
}
</cfscript>

152
api/tasks/saveType.cfm Normal file
View file

@ -0,0 +1,152 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// Create or update a task type for a business
// Input: BusinessID (required), TaskTypeName (required), TaskTypeDescription (optional), TaskTypeIcon (optional), TaskTypeID (optional - for update)
// Output: { OK: true, TASK_TYPE_ID: ... }
function apiAbort(required struct payload) {
writeOutput(serializeJSON(payload));
abort;
}
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw)) raw = "";
if (!len(trim(raw))) return {};
try {
var data = deserializeJSON(raw);
if (isStruct(data)) return data;
} catch (any e) {}
return {};
}
try {
data = readJsonBody();
// Get BusinessID
businessID = 0;
if (structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID)) {
businessID = int(data.BusinessID);
}
if (businessID == 0) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
}
// Get TaskTypeName
taskTypeName = "";
if (structKeyExists(data, "TaskTypeName")) {
taskTypeName = trim(toString(data.TaskTypeName));
}
if (!len(taskTypeName)) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "TaskTypeName is required" });
}
if (len(taskTypeName) > 45) {
apiAbort({ "OK": false, "ERROR": "invalid_params", "MESSAGE": "TaskTypeName must be 45 characters or less" });
}
// Get TaskTypeDescription (optional)
taskTypeDescription = "";
if (structKeyExists(data, "TaskTypeDescription")) {
taskTypeDescription = trim(toString(data.TaskTypeDescription));
}
if (len(taskTypeDescription) > 100) {
apiAbort({ "OK": false, "ERROR": "invalid_params", "MESSAGE": "TaskTypeDescription must be 100 characters or less" });
}
// Get TaskTypeIcon (optional, default to 'notifications')
taskTypeIcon = "notifications";
if (structKeyExists(data, "TaskTypeIcon") && len(trim(toString(data.TaskTypeIcon)))) {
taskTypeIcon = trim(toString(data.TaskTypeIcon));
}
if (len(taskTypeIcon) > 30) {
apiAbort({ "OK": false, "ERROR": "invalid_params", "MESSAGE": "TaskTypeIcon must be 30 characters or less" });
}
// Get TaskTypeColor (optional, default to purple)
taskTypeColor = "##9C27B0";
if (structKeyExists(data, "TaskTypeColor") && len(trim(toString(data.TaskTypeColor)))) {
taskTypeColor = trim(toString(data.TaskTypeColor));
// Ensure it starts with # (incoming JSON has single #, we store single #)
if (left(taskTypeColor, 1) != chr(35)) {
taskTypeColor = chr(35) & taskTypeColor;
}
}
if (len(taskTypeColor) > 7) {
apiAbort({ "OK": false, "ERROR": "invalid_params", "MESSAGE": "TaskTypeColor must be a valid hex color" });
}
// Get TaskTypeID (optional - for update)
taskTypeID = 0;
if (structKeyExists(data, "TaskTypeID") && isNumeric(data.TaskTypeID)) {
taskTypeID = int(data.TaskTypeID);
}
if (taskTypeID > 0) {
// UPDATE - verify it belongs to this business
qCheck = queryExecute("
SELECT tt_TaskTypeID FROM tt_TaskTypes
WHERE tt_TaskTypeID = :taskTypeID
AND tt_TaskTypeBusinessID = :businessID
", {
taskTypeID: { value: taskTypeID, cfsqltype: "cf_sql_integer" },
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
if (qCheck.recordCount == 0) {
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Task type not found or does not belong to this business" });
}
queryExecute("
UPDATE tt_TaskTypes
SET tt_TaskTypeName = :taskTypeName,
tt_TaskTypeDescription = :taskTypeDescription,
tt_TaskTypeIcon = :taskTypeIcon,
tt_TaskTypeColor = :taskTypeColor
WHERE tt_TaskTypeID = :taskTypeID
", {
taskTypeName: { value: taskTypeName, cfsqltype: "cf_sql_varchar" },
taskTypeDescription: { value: taskTypeDescription, cfsqltype: "cf_sql_varchar", null: !len(taskTypeDescription) },
taskTypeIcon: { value: taskTypeIcon, cfsqltype: "cf_sql_varchar" },
taskTypeColor: { value: taskTypeColor, cfsqltype: "cf_sql_varchar" },
taskTypeID: { value: taskTypeID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
apiAbort({
"OK": true,
"TASK_TYPE_ID": taskTypeID,
"MESSAGE": "Task type updated"
});
} else {
// INSERT new task type
queryExecute("
INSERT INTO tt_TaskTypes (tt_TaskTypeName, tt_TaskTypeDescription, tt_TaskTypeIcon, tt_TaskTypeColor, tt_TaskTypeBusinessID)
VALUES (:taskTypeName, :taskTypeDescription, :taskTypeIcon, :taskTypeColor, :businessID)
", {
taskTypeName: { value: taskTypeName, cfsqltype: "cf_sql_varchar" },
taskTypeDescription: { value: taskTypeDescription, cfsqltype: "cf_sql_varchar", null: !len(taskTypeDescription) },
taskTypeIcon: { value: taskTypeIcon, cfsqltype: "cf_sql_varchar" },
taskTypeColor: { value: taskTypeColor, cfsqltype: "cf_sql_varchar" },
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
newID = qNew.newID;
apiAbort({
"OK": true,
"TASK_TYPE_ID": newID,
"MESSAGE": "Task type created"
});
}
} catch (any e) {
apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": e.message
});
}
</cfscript>

View file

@ -24,14 +24,14 @@
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<a href="#dashboard" class="nav-item active" data-page="dashboard"> <a href="#dashboard" class="nav-item active" data-page="dashboard">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/> <rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/> <rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>
</svg> </svg>
<span>Dashboard</span> <span>Dashboard</span>
</a> </a>
<a href="#orders" class="nav-item" data-page="orders"> <a href="#orders" class="nav-item" data-page="orders">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"/> <path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"/>
<rect x="9" y="3" width="6" height="4" rx="1"/> <rect x="9" y="3" width="6" height="4" rx="1"/>
<path d="M9 12h6M9 16h6"/> <path d="M9 12h6M9 16h6"/>
@ -39,19 +39,19 @@
<span>Orders</span> <span>Orders</span>
</a> </a>
<a href="menu-builder.html" class="nav-item"> <a href="menu-builder.html" class="nav-item">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M3 12h18M3 18h18"/> <path d="M3 6h18M3 12h18M3 18h18"/>
</svg> </svg>
<span>Menu</span> <span>Menu</span>
</a> </a>
<a href="#reports" class="nav-item" data-page="reports"> <a href="#reports" class="nav-item" data-page="reports">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 20V10M12 20V4M6 20v-6"/> <path d="M18 20V10M12 20V4M6 20v-6"/>
</svg> </svg>
<span>Reports</span> <span>Reports</span>
</a> </a>
<a href="#team" class="nav-item" data-page="team"> <a href="#team" class="nav-item" data-page="team">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/> <path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/>
<circle cx="9" cy="7" r="4"/> <circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/> <path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/>
@ -59,7 +59,7 @@
<span>Team</span> <span>Team</span>
</a> </a>
<a href="#beacons" class="nav-item" data-page="beacons"> <a href="#beacons" class="nav-item" data-page="beacons">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/> <circle cx="12" cy="12" r="3"/>
<path d="M12 2v4M12 18v4M2 12h4M18 12h4"/> <path d="M12 2v4M12 18v4M2 12h4M18 12h4"/>
<path d="M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/> <path d="M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
@ -67,14 +67,14 @@
<span>Beacons</span> <span>Beacons</span>
</a> </a>
<a href="#services" class="nav-item" data-page="services"> <a href="#services" class="nav-item" data-page="services">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/> <path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 01-3.46 0"/> <path d="M13.73 21a2 2 0 01-3.46 0"/>
</svg> </svg>
<span>Services</span> <span>Services</span>
</a> </a>
<a href="#settings" class="nav-item" data-page="settings"> <a href="#settings" class="nav-item" data-page="settings">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/> <circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/> <path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/>
</svg> </svg>
@ -91,7 +91,7 @@
</div> </div>
</div> </div>
<a href="#logout" class="nav-item logout" data-page="logout"> <a href="#logout" class="nav-item logout" data-page="logout">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9"/> <path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9"/>
</svg> </svg>
<span>Logout</span> <span>Logout</span>

View file

@ -779,14 +779,14 @@
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<a href="index.html#dashboard" class="nav-item"> <a href="index.html#dashboard" class="nav-item">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/> <rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/> <rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>
</svg> </svg>
<span>Dashboard</span> <span>Dashboard</span>
</a> </a>
<a href="index.html#orders" class="nav-item"> <a href="index.html#orders" class="nav-item">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"/> <path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"/>
<rect x="9" y="3" width="6" height="4" rx="1"/> <rect x="9" y="3" width="6" height="4" rx="1"/>
<path d="M9 12h6M9 16h6"/> <path d="M9 12h6M9 16h6"/>
@ -794,19 +794,19 @@
<span>Orders</span> <span>Orders</span>
</a> </a>
<a href="#" class="nav-item active"> <a href="#" class="nav-item active">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M3 12h18M3 18h18"/> <path d="M3 6h18M3 12h18M3 18h18"/>
</svg> </svg>
<span>Menu</span> <span>Menu</span>
</a> </a>
<a href="index.html#reports" class="nav-item"> <a href="index.html#reports" class="nav-item">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 20V10M12 20V4M6 20v-6"/> <path d="M18 20V10M12 20V4M6 20v-6"/>
</svg> </svg>
<span>Reports</span> <span>Reports</span>
</a> </a>
<a href="index.html#team" class="nav-item"> <a href="index.html#team" class="nav-item">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/> <path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/>
<circle cx="9" cy="7" r="4"/> <circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/> <path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/>
@ -814,7 +814,7 @@
<span>Team</span> <span>Team</span>
</a> </a>
<a href="index.html#beacons" class="nav-item"> <a href="index.html#beacons" class="nav-item">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/> <circle cx="12" cy="12" r="3"/>
<path d="M12 2v4M12 18v4M2 12h4M18 12h4"/> <path d="M12 2v4M12 18v4M2 12h4M18 12h4"/>
<path d="M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/> <path d="M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
@ -822,14 +822,14 @@
<span>Beacons</span> <span>Beacons</span>
</a> </a>
<a href="index.html#services" class="nav-item"> <a href="index.html#services" class="nav-item">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/> <path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 01-3.46 0"/> <path d="M13.73 21a2 2 0 01-3.46 0"/>
</svg> </svg>
<span>Services</span> <span>Services</span>
</a> </a>
<a href="index.html#settings" class="nav-item"> <a href="index.html#settings" class="nav-item">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/> <circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/> <path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/>
</svg> </svg>
@ -846,7 +846,7 @@
</div> </div>
</div> </div>
<a href="#logout" class="nav-item logout" onclick="MenuBuilder.logout()"> <a href="#logout" class="nav-item logout" onclick="MenuBuilder.logout()">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9"/> <path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9"/>
</svg> </svg>
<span>Logout</span> <span>Logout</span>
@ -860,67 +860,66 @@
<!-- Toolbar --> <!-- Toolbar -->
<div class="builder-toolbar"> <div class="builder-toolbar">
<div class="toolbar-group"> <div class="toolbar-group">
<div class="toolbar-group"> <button class="toolbar-btn" onclick="MenuBuilder.undo()" title="Undo (Ctrl+Z)">
<button class="toolbar-btn" onclick="MenuBuilder.undo()" title="Undo (Ctrl+Z)"> <svg width="16" height="16" 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="M3 7v6h6M3 13a9 9 0 103-7.5"/>
<path d="M3 7v6h6M3 13a9 9 0 103-7.5"/> </svg>
</svg> Undo
Undo </button>
</button> <button class="toolbar-btn" onclick="MenuBuilder.redo()" title="Redo (Ctrl+Y)">
<button class="toolbar-btn" onclick="MenuBuilder.redo()" title="Redo (Ctrl+Y)"> <svg width="16" height="16" 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="M21 7v6h-6M21 13a9 9 0 10-3-7.5"/>
<path d="M21 7v6h-6M21 13a9 9 0 10-3-7.5"/> </svg>
</svg> Redo
Redo </button>
</button> </div>
</div> <div class="toolbar-group">
<div class="toolbar-group"> <button class="toolbar-btn" onclick="MenuBuilder.addCategory()" title="Add Category">
<button class="toolbar-btn" onclick="MenuBuilder.addCategory()" title="Add Category"> <svg width="16" height="16" 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="M12 5v14M5 12h14"/>
<path d="M12 5v14M5 12h14"/> </svg>
</svg> Category
Category </button>
</button> <button class="toolbar-btn" onclick="MenuBuilder.addItem()" title="Add Item">
<button class="toolbar-btn" onclick="MenuBuilder.addItem()" title="Add Item"> <svg width="16" height="16" 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="M12 5v14M5 12h14"/>
<path d="M12 5v14M5 12h14"/> </svg>
</svg> Item
Item </button>
</button> </div>
</div> <div class="toolbar-group">
<div class="toolbar-group"> <button class="toolbar-btn" onclick="MenuBuilder.cloneSelected()" title="Clone (Ctrl+D)">
<button class="toolbar-btn" onclick="MenuBuilder.cloneSelected()" title="Clone (Ctrl+D)"> <svg width="16" height="16" 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"> <rect x="9" y="9" width="13" height="13" rx="2"/>
<rect x="9" y="9" width="13" height="13" rx="2"/> <path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/> </svg>
</svg> Clone
Clone </button>
</button> <button class="toolbar-btn" onclick="MenuBuilder.deleteSelected()" title="Delete (Del)">
<button class="toolbar-btn" onclick="MenuBuilder.deleteSelected()" title="Delete (Del)"> <svg width="16" height="16" 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="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/> </svg>
</svg> Delete
Delete </button>
</button> </div>
</div> <div class="toolbar-group">
<div class="toolbar-group"> <button class="toolbar-btn" onclick="MenuBuilder.showOutlineModal()" title="View Full Menu Outline">
<button class="toolbar-btn" onclick="MenuBuilder.showOutlineModal()" title="View Full Menu Outline"> <svg width="16" height="16" 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="M4 6h16M4 12h16M4 18h12"/>
<path d="M4 6h16M4 12h16M4 18h12"/> </svg>
</svg> Outline
Outline </button>
</button> </div>
</div> <div class="toolbar-group" style="margin-left: auto;">
<div class="toolbar-group" style="margin-left: auto;"> <button class="toolbar-btn primary" onclick="MenuBuilder.saveMenu()">
<button class="toolbar-btn primary" onclick="MenuBuilder.saveMenu()"> <svg width="16" height="16" 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 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/>
<path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/> <path d="M17 21v-8H7v8M7 3v5h8"/>
<path d="M17 21v-8H7v8M7 3v5h8"/> </svg>
</svg> Save Menu
Save Menu </button>
</button> </div>
</div> </div>
</div>
<!-- Main Builder --> <!-- Main Builder -->
<div class="builder-container"> <div class="builder-container">
@ -1011,6 +1010,16 @@
<span id="menuName">Menu Builder</span> <span id="menuName">Menu Builder</span>
<span style="font-size: 13px; color: var(--gray-500); font-weight: normal;" id="businessLabel"></span> <span style="font-size: 13px; color: var(--gray-500); font-weight: normal;" id="businessLabel"></span>
</h2> </h2>
<div class="canvas-actions" style="display: flex; gap: 8px; align-items: center;">
<select id="menuSelector" onchange="MenuBuilder.onMenuSelect(this.value)" style="padding: 8px 12px; border: 1px solid var(--gray-300); border-radius: 6px; background: #fff; font-size: 14px; cursor: pointer;">
<option value="0">All Categories</option>
</select>
<button class="btn btn-sm btn-secondary" onclick="MenuBuilder.showMenuManager()" title="Manage Menus">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/>
</svg>
</button>
</div>
</div> </div>
<div class="menu-structure" id="menuStructure"> <div class="menu-structure" id="menuStructure">
@ -1132,6 +1141,8 @@
menu: { menu: {
categories: [] categories: []
}, },
menus: [], // List of all menus for this business
selectedMenuId: 0, // Currently selected menu ID (0 = all/default)
templates: [], templates: [],
stations: [], stations: [],
selectedElement: null, selectedElement: null,
@ -1685,6 +1696,10 @@
// Show properties for category // Show properties for category
showPropertiesForCategory(category) { showPropertiesForCategory(category) {
const menuOptions = this.menus.map(m =>
`<option value="${m.MenuID}" ${category.menuId === m.MenuID ? 'selected' : ''}>${this.escapeHtml(m.MenuName)}</option>`
).join('');
document.getElementById('propertiesContent').innerHTML = ` document.getElementById('propertiesContent').innerHTML = `
<div class="property-group"> <div class="property-group">
<label>Category Name</label> <label>Category Name</label>
@ -1696,6 +1711,15 @@
<textarea id="propCatDesc" rows="3" <textarea id="propCatDesc" rows="3"
onchange="MenuBuilder.updateCategory('${category.id}', 'description', this.value)">${this.escapeHtml(category.description || '')}</textarea> onchange="MenuBuilder.updateCategory('${category.id}', 'description', this.value)">${this.escapeHtml(category.description || '')}</textarea>
</div> </div>
${this.menus.length > 0 ? `
<div class="property-group">
<label>Assign to Menu</label>
<select onchange="MenuBuilder.updateCategory('${category.id}', 'menuId', parseInt(this.value))">
<option value="0" ${!category.menuId ? 'selected' : ''}>No Menu (Always Show)</option>
${menuOptions}
</select>
</div>
` : ''}
<div class="property-group"> <div class="property-group">
<label>Sort Order</label> <label>Sort Order</label>
<input type="number" id="propCatSort" value="${category.sortOrder}" <input type="number" id="propCatSort" value="${category.sortOrder}"
@ -3226,14 +3250,21 @@
}, },
// Load menu from API // Load menu from API
async loadMenu() { async loadMenu(menuId = null) {
try { try {
console.log('[MenuBuilder] Loading menu for BusinessID:', this.config.businessId); // Use provided menuId or current selected
console.log('[MenuBuilder] API URL:', `${this.config.apiBaseUrl}/menu/getForBuilder.cfm`); const loadMenuId = menuId !== null ? menuId : this.selectedMenuId;
console.log('[MenuBuilder] Loading menu for BusinessID:', this.config.businessId, 'MenuID:', loadMenuId);
const payload = { BusinessID: this.config.businessId };
if (loadMenuId > 0) {
payload.MenuID = loadMenuId;
}
const response = await fetch(`${this.config.apiBaseUrl}/menu/getForBuilder.cfm`, { const response = await fetch(`${this.config.apiBaseUrl}/menu/getForBuilder.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(payload)
}); });
console.log('[MenuBuilder] Response status:', response.status); console.log('[MenuBuilder] Response status:', response.status);
const data = await response.json(); const data = await response.json();
@ -3243,6 +3274,12 @@
if (data.OK && data.MENU) { if (data.OK && data.MENU) {
this.menu = data.MENU; this.menu = data.MENU;
console.log('[MenuBuilder] Menu categories:', this.menu.categories?.length || 0); console.log('[MenuBuilder] Menu categories:', this.menu.categories?.length || 0);
// Store menus list and update selector
this.menus = data.MENUS || [];
this.selectedMenuId = data.SELECTED_MENU_ID || 0;
this.updateMenuSelector();
// Store templates from API (default to empty array if not provided) // Store templates from API (default to empty array if not provided)
this.templates = data.TEMPLATES || []; this.templates = data.TEMPLATES || [];
// Load brand color if set // Load brand color if set
@ -3257,6 +3294,8 @@
console.log('[MenuBuilder] No MENU in response or OK=false'); console.log('[MenuBuilder] No MENU in response or OK=false');
// Still clear the loading message // Still clear the loading message
this.templates = []; this.templates = [];
this.menus = data.MENUS || [];
this.updateMenuSelector();
this.renderTemplateLibrary(); this.renderTemplateLibrary();
} }
} catch (err) { } catch (err) {
@ -3267,6 +3306,210 @@
} }
}, },
// Update menu selector dropdown
updateMenuSelector() {
const selector = document.getElementById('menuSelector');
if (!selector) return;
let options = '<option value="0">All Categories</option>';
for (const menu of this.menus) {
const selected = menu.MenuID === this.selectedMenuId ? 'selected' : '';
options += `<option value="${menu.MenuID}" ${selected}>${this.escapeHtml(menu.MenuName)}</option>`;
}
selector.innerHTML = options;
},
// Handle menu selection change
async onMenuSelect(menuId) {
this.selectedMenuId = parseInt(menuId) || 0;
await this.loadMenu(this.selectedMenuId);
},
// Show menu manager modal
showMenuManager() {
const content = `
<div style="display: flex; flex-direction: column; gap: 16px;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h4 style="margin: 0;">Your Menus</h4>
<button class="btn btn-sm btn-primary" onclick="MenuBuilder.showCreateMenuForm()">+ Add Menu</button>
</div>
<div id="menuManagerList" style="display: flex; flex-direction: column; gap: 8px;">
${this.menus.length === 0 ? '<p style="color: var(--gray-500); text-align: center;">No menus created yet. Click "Add Menu" to create one.</p>' : ''}
${this.menus.map(menu => `
<div style="display: flex; align-items: center; gap: 12px; padding: 12px; background: var(--gray-50); border-radius: 8px;">
<div style="flex: 1;">
<div style="font-weight: 600;">${this.escapeHtml(menu.MenuName)}</div>
<div style="font-size: 12px; color: var(--gray-500);">
${menu.MenuStartTime && menu.MenuEndTime ? `${menu.MenuStartTime} - ${menu.MenuEndTime}` : 'All day'}
· ${this.formatDaysActive(menu.MenuDaysActive)}
</div>
</div>
<button class="btn btn-sm btn-secondary" onclick="MenuBuilder.editMenu(${menu.MenuID})">Edit</button>
<button class="btn btn-sm btn-danger" onclick="MenuBuilder.deleteMenu(${menu.MenuID})">Delete</button>
</div>
`).join('')}
</div>
<div id="menuFormContainer" style="display: none; padding: 16px; background: var(--gray-50); border-radius: 8px;">
<h4 id="menuFormTitle" style="margin: 0 0 16px 0;">Create Menu</h4>
<input type="hidden" id="editMenuId" value="0">
<div class="property-field">
<label>Menu Name</label>
<input type="text" id="menuNameInput" placeholder="e.g., Lunch Menu, Happy Hour">
</div>
<div class="property-field">
<label>Description (optional)</label>
<input type="text" id="menuDescInput" placeholder="Brief description">
</div>
<div style="display: flex; gap: 12px;">
<div class="property-field" style="flex: 1;">
<label>Start Time</label>
<input type="time" id="menuStartInput">
</div>
<div class="property-field" style="flex: 1;">
<label>End Time</label>
<input type="time" id="menuEndInput">
</div>
</div>
<div class="property-field">
<label>Active Days</label>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
${['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((day, i) => `
<label style="display: flex; align-items: center; gap: 4px; cursor: pointer;">
<input type="checkbox" class="menuDayCheck" data-day="${1 << i}" checked>
${day}
</label>
`).join('')}
</div>
</div>
<div style="display: flex; gap: 8px; margin-top: 16px;">
<button class="btn btn-primary" onclick="MenuBuilder.saveMenuForm()">Save</button>
<button class="btn btn-secondary" onclick="MenuBuilder.cancelMenuForm()">Cancel</button>
</div>
</div>
</div>
`;
this.showModal('Manage Menus', content, [
{ text: 'Close', primary: false, action: () => this.closeModal() }
]);
},
// Format days active bitmask to readable string
formatDaysActive(bitmask) {
if (bitmask === 127) return 'Every day';
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const active = [];
for (let i = 0; i < 7; i++) {
if (bitmask & (1 << i)) active.push(days[i]);
}
return active.join(', ') || 'No days';
},
showCreateMenuForm() {
document.getElementById('menuFormContainer').style.display = 'block';
document.getElementById('menuFormTitle').textContent = 'Create Menu';
document.getElementById('editMenuId').value = '0';
document.getElementById('menuNameInput').value = '';
document.getElementById('menuDescInput').value = '';
document.getElementById('menuStartInput').value = '';
document.getElementById('menuEndInput').value = '';
document.querySelectorAll('.menuDayCheck').forEach(cb => cb.checked = true);
},
cancelMenuForm() {
document.getElementById('menuFormContainer').style.display = 'none';
},
async editMenu(menuId) {
const menu = this.menus.find(m => m.MenuID === menuId);
if (!menu) return;
document.getElementById('menuFormContainer').style.display = 'block';
document.getElementById('menuFormTitle').textContent = 'Edit Menu';
document.getElementById('editMenuId').value = menuId;
document.getElementById('menuNameInput').value = menu.MenuName;
document.getElementById('menuDescInput').value = menu.MenuDescription || '';
document.getElementById('menuStartInput').value = menu.MenuStartTime || '';
document.getElementById('menuEndInput').value = menu.MenuEndTime || '';
document.querySelectorAll('.menuDayCheck').forEach(cb => {
const day = parseInt(cb.dataset.day);
cb.checked = (menu.MenuDaysActive & day) !== 0;
});
},
async saveMenuForm() {
const menuId = parseInt(document.getElementById('editMenuId').value) || 0;
const menuName = document.getElementById('menuNameInput').value.trim();
const menuDesc = document.getElementById('menuDescInput').value.trim();
const menuStart = document.getElementById('menuStartInput').value;
const menuEnd = document.getElementById('menuEndInput').value;
let daysActive = 0;
document.querySelectorAll('.menuDayCheck').forEach(cb => {
if (cb.checked) daysActive |= parseInt(cb.dataset.day);
});
if (!menuName) {
this.toast('Menu name is required', 'error');
return;
}
try {
const response = await fetch(`${this.config.apiBaseUrl}/menu/menus.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
BusinessID: this.config.businessId,
action: 'save',
MenuID: menuId,
MenuName: menuName,
MenuDescription: menuDesc,
MenuStartTime: menuStart,
MenuEndTime: menuEnd,
MenuDaysActive: daysActive
})
});
const data = await response.json();
if (data.OK) {
this.toast(`Menu ${data.ACTION}!`, 'success');
this.closeModal();
await this.loadMenu();
} else {
this.toast(data.MESSAGE || 'Failed to save menu', 'error');
}
} catch (err) {
this.toast('Error saving menu', 'error');
}
},
async deleteMenu(menuId) {
if (!confirm('Are you sure you want to delete this menu? Categories in this menu will become unassigned.')) {
return;
}
try {
const response = await fetch(`${this.config.apiBaseUrl}/menu/menus.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
BusinessID: this.config.businessId,
action: 'delete',
MenuID: menuId
})
});
const data = await response.json();
if (data.OK) {
this.toast('Menu deleted', 'success');
this.closeModal();
await this.loadMenu();
} else {
this.toast(data.MESSAGE || 'Failed to delete menu', 'error');
}
} catch (err) {
this.toast('Error deleting menu', 'error');
}
},
// Render template library in sidebar // Render template library in sidebar
renderTemplateLibrary() { renderTemplateLibrary() {
const container = document.getElementById('templateLibrary'); const container = document.getElementById('templateLibrary');

View file

@ -153,7 +153,17 @@ body {
.nav-icon { .nav-icon {
width: 20px; width: 20px;
height: 20px; height: 20px;
min-width: 20px;
min-height: 20px;
flex-shrink: 0; flex-shrink: 0;
display: block;
}
.sidebar svg {
width: 20px;
height: 20px;
flex-shrink: 0;
display: block;
} }
.sidebar-footer { .sidebar-footer {
@ -282,6 +292,20 @@ body {
background: var(--gray-300); background: var(--gray-300);
} }
.btn-danger {
background: var(--danger);
color: #fff;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-sm {
padding: 6px 12px;
font-size: 13px;
}
.btn-icon { .btn-icon {
width: 40px; width: 40px;
height: 40px; height: 40px;

View file

@ -807,6 +807,20 @@
<h3>Menu Summary</h3> <h3>Menu Summary</h3>
</div> </div>
<div class="summary-card-body"> <div class="summary-card-body">
<div class="summary-stat" style="flex-direction: column; align-items: stretch; gap: 8px;">
<span class="summary-stat-label">Menu Name</span>
<input type="text" id="menuNameInput" value="Main Menu" placeholder="e.g., Main Menu, Lunch, Dinner"
style="padding: 8px 12px; border: 1px solid var(--gray-300); border-radius: 6px; font-size: 14px;">
</div>
<div class="summary-stat" style="flex-direction: column; align-items: stretch; gap: 8px;">
<span class="summary-stat-label">Menu Hours</span>
<div style="display: flex; gap: 12px; align-items: center;">
<input type="time" id="menuStartTime" style="padding: 8px 12px; border: 1px solid var(--gray-300); border-radius: 6px; font-size: 14px; flex: 1;">
<span style="color: var(--gray-500);">to</span>
<input type="time" id="menuEndTime" style="padding: 8px 12px; border: 1px solid var(--gray-300); border-radius: 6px; font-size: 14px; flex: 1;">
</div>
<small style="color: var(--gray-500);">Leave empty for all-day availability. You can create additional menus later in the Menu Builder.</small>
</div>
<div class="summary-stat"> <div class="summary-stat">
<span class="summary-stat-label">Categories</span> <span class="summary-stat-label">Categories</span>
<span class="summary-stat-value" id="summaryCategories">0</span> <span class="summary-stat-value" id="summaryCategories">0</span>
@ -1951,6 +1965,19 @@
document.getElementById('summaryModifiers').textContent = modifiers.length; document.getElementById('summaryModifiers').textContent = modifiers.length;
document.getElementById('summaryItems').textContent = items.length; document.getElementById('summaryItems').textContent = items.length;
// Set default menu hours based on business hours (earliest open, latest close)
const hoursSchedule = business.hoursSchedule || [];
if (hoursSchedule.length > 0) {
let earliestOpen = '23:59';
let latestClose = '00:00';
hoursSchedule.forEach(day => {
if (day.open && day.open < earliestOpen) earliestOpen = day.open;
if (day.close && day.close > latestClose) latestClose = day.close;
});
document.getElementById('menuStartTime').value = earliestOpen;
document.getElementById('menuEndTime').value = latestClose;
}
addMessage('ai', ` addMessage('ai', `
<p>Your menu is ready to save!</p> <p>Your menu is ready to save!</p>
<p><strong>${business.name || 'Your Restaurant'}</strong></p> <p><strong>${business.name || 'Your Restaurant'}</strong></p>
@ -1970,6 +1997,33 @@
console.log('=== SAVE MENU CALLED ==='); console.log('=== SAVE MENU CALLED ===');
console.log('Data to save:', config.extractedData); console.log('Data to save:', config.extractedData);
// Get menu name and time range from inputs
const menuName = document.getElementById('menuNameInput').value.trim() || 'Main Menu';
const menuStartTime = document.getElementById('menuStartTime').value || '';
const menuEndTime = document.getElementById('menuEndTime').value || '';
// Validate menu hours fall within business operating hours
if (menuStartTime && menuEndTime) {
const hoursSchedule = config.extractedData.business.hoursSchedule || [];
if (hoursSchedule.length > 0) {
let earliestOpen = '23:59';
let latestClose = '00:00';
hoursSchedule.forEach(day => {
if (day.open && day.open < earliestOpen) earliestOpen = day.open;
if (day.close && day.close > latestClose) latestClose = day.close;
});
if (menuStartTime < earliestOpen || menuEndTime > latestClose) {
showToast(`Menu hours must be within business operating hours (${earliestOpen} - ${latestClose})`, 'error');
return;
}
}
}
config.extractedData.menuName = menuName;
config.extractedData.menuStartTime = menuStartTime;
config.extractedData.menuEndTime = menuEndTime;
const saveBtn = document.querySelector('#finalActions .btn-success'); const saveBtn = document.querySelector('#finalActions .btn-success');
const originalText = saveBtn.innerHTML; const originalText = saveBtn.innerHTML;
saveBtn.innerHTML = '<div class="loading-spinner" style="width:16px;height:16px;border-width:2px;"></div> Saving...'; saveBtn.innerHTML = '<div class="loading-spinner" style="width:16px;height:16px;border-width:2px;"></div> Saving...';