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">
<!---
Payfrit API Application.cfm (updated)
Payfrit API Application.cfm (updated 2026-01-22)
FIX: Provide a DEFAULT datasource so endpoints can call queryExecute()
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) --->
<cfset application.MAGIC_OTP_ENABLED = true>
<cfset application.MAGIC_OTP_ENABLED = false>
<cfset application.MAGIC_OTP_CODE = "123456">
<!--- 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/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/updateHours.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/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/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/save.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/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/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/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>
<!--- 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">
SELECT
lt.BusinessID,
@ -62,8 +61,53 @@ beaconId = int(data.BeaconID);
ORDER BY biz.BusinessParentBusinessID IS NULL DESC, biz.BusinessName ASC
</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 --->
<cfset businesses = []>
<!--- If beacon is assigned to a parent, return the child businesses instead --->
<cfif parentBusinessID GT 0>
<!--- Get parent business info for header image --->
<cfquery name="qParent" datasource="payfrit">
SELECT BusinessName, BusinessHeaderImageExtension
FROM Businesses
WHERE BusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#parentBusinessID#">
</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,
@ -73,6 +117,7 @@ beaconId = int(data.BeaconID);
"IsParent" = isNull(qAssignments.BusinessParentBusinessID) OR qAssignments.BusinessParentBusinessID EQ 0
})>
</cfloop>
</cfif>
<cfset response = {
"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>

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) +
cos(lat1 * 3.14159265359 / 180) * cos(lat2 * 3.14159265359 / 180) *
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;
}
@ -41,7 +44,7 @@ try {
userLng = structKeyExists(data, "lng") ? val(data.lng) : 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(
"
SELECT
@ -54,7 +57,7 @@ try {
FROM Businesses b
LEFT JOIN Addresses a ON b.BusinessAddressID = a.AddressID
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
",
[],

View file

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

View file

@ -52,15 +52,20 @@ try {
senderType = "customer";
}
// Verify task exists
// Verify task exists and is still open
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" });
if (taskQuery.recordCount == 0) {
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
queryExecute("
INSERT INTO ChatMessages (TaskID, SenderUserID, SenderType, MessageText)

View file

@ -57,6 +57,35 @@ try {
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
hasCategoriesData = false;
try {
@ -70,15 +99,24 @@ try {
if (hasCategoriesData) {
// 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("
SELECT
CategoryID,
CategoryName,
CategorySortOrder as ItemSortOrder
CategorySortOrder as ItemSortOrder,
CategoryMenuID
FROM Categories
WHERE CategoryBusinessID = :businessID
WHERE CategoryBusinessID = :businessID #menuFilter#
ORDER BY CategorySortOrder, CategoryName
", { businessID: businessID }, { datasource: "payfrit" });
", menuParams, { datasource: "payfrit" });
// Get menu items - items that belong to categories (not modifiers)
qItems = queryExecute("
@ -328,14 +366,25 @@ try {
catID = qCategories.CategoryID[i];
catItems = structKeyExists(itemsByCategory, catID) ? itemsByCategory[catID] : [];
arrayAppend(categories, {
catStruct = {
"id": "cat_" & qCategories.CategoryID[i],
"dbId": qCategories.CategoryID[i],
"name": qCategories.CategoryName[i],
"description": "",
"sortOrder": catIndex,
"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++;
}
@ -360,10 +409,13 @@ try {
response["OK"] = true;
response["MENU"] = { "categories": categories };
response["MENUS"] = allMenus;
response["SELECTED_MENU_ID"] = menuID;
response["TEMPLATES"] = templateLibrary;
response["BRANDCOLOR"] = brandColor;
response["CATEGORY_COUNT"] = arrayLen(categories);
response["TEMPLATE_COUNT"] = arrayLen(templateLibrary);
response["MENU_COUNT"] = arrayLen(allMenus);
response["SCHEMA"] = hasCategoriesData ? "legacy" : "unified";
totalItems = 0;

View file

@ -32,10 +32,20 @@
<cfset BusinessID = val(data.BusinessID)>
</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>
<cfset apiAbort({ "OK": false, "ERROR": "missing_businessid", "MESSAGE": "BusinessID is required.", "DETAIL": "" })>
</cfif>
<!--- Get current time and day for schedule filtering --->
<cfset currentTime = timeFormat(now(), "HH:mm:ss")>
<cfset currentDayID = dayOfWeek(now())>
<cftry>
<!--- Check if new schema is active (ItemBusinessID column exists and has data) --->
<cfset newSchemaActive = false>
@ -70,24 +80,89 @@
<cfif hasCategoriesData>
<!--- 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(
"
SELECT
CategoryID,
CategoryName,
CategorySortOrder
CategorySortOrder,
CategoryOrderTypes,
CategoryScheduleStart,
CategoryScheduleEnd,
CategoryScheduleDays,
CategoryMenuID
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
",
[ { 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" }
)>
<!--- Get menu items --->
<!--- 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 --->
<!--- 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(
"
SELECT
@ -110,14 +185,15 @@
FROM Items i
LEFT JOIN Categories c ON c.CategoryID = i.ItemCategoryID
LEFT JOIN Stations s ON s.StationID = i.ItemStationID
WHERE i.ItemBusinessID = ?
WHERE i.ItemBusinessID = :bizId
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 tl2 WHERE tl2.TemplateItemID = i.ItemParentItemID)
AND NOT (i.ItemParentItemID = 0 AND i.ItemCategoryID = 0 AND i.ItemPrice = 0)
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" }
)>
<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;
}
} 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) {
categoryID = categoryDbId;
queryExecute("
UPDATE Categories
SET CategoryName = :name,
CategorySortOrder = :sortOrder
CategorySortOrder = :sortOrder,
CategoryMenuID = :menuId
WHERE CategoryID = :categoryID
", {
categoryID: categoryID,
name: cat.name,
sortOrder: catSortOrder
sortOrder: catSortOrder,
menuId: categoryMenuIdParam
});
} else {
queryExecute("
INSERT INTO Categories (CategoryBusinessID, CategoryName, CategorySortOrder, CategoryAddedOn)
VALUES (:businessID, :name, :sortOrder, NOW())
INSERT INTO Categories (CategoryBusinessID, CategoryMenuID, CategoryName, CategorySortOrder, CategoryAddedOn)
VALUES (:businessID, :menuId, :name, :sortOrder, NOW())
", {
businessID: businessID,
menuId: categoryMenuIdParam,
name: cat.name,
sortOrder: catSortOrder
});

View file

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

View file

@ -83,7 +83,7 @@ try {
", {
line1: len(addressLine1) ? addressLine1 : "Address pending",
city: len(city) ? city : "",
stateID: stateID,
stateID: { value = stateID > 0 ? stateID : javaCast("null", ""), cfsqltype = "cf_sql_integer", null = stateID == 0 },
zip: len(zip) ? zip : "",
userID: userId,
typeID: 2
@ -118,6 +118,32 @@ try {
}, { datasource: "payfrit" });
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
if (structKeyExists(biz, "hoursSchedule") && isArray(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
categories = structKeyExists(wizardData, "categories") ? wizardData.categories : [];
categoryMap = {}; // Maps category name to CategoryID
@ -270,32 +373,33 @@ try {
continue;
}
// Check if category exists in Categories table
// Check if category exists in Categories table for this menu
qCat = queryExecute("
SELECT CategoryID FROM Categories
WHERE CategoryBusinessID = :bizID AND CategoryName = :name
", { bizID: businessId, name: catName }, { datasource: "payfrit" });
WHERE CategoryBusinessID = :bizID AND CategoryName = :name AND CategoryMenuID = :menuID
", { bizID: businessId, name: catName, menuID: menuID }, { datasource: "payfrit" });
if (qCat.recordCount > 0) {
categoryID = qCat.CategoryID;
response.steps.append("Category exists: " & catName & " (ID: " & categoryID & ")");
} else {
// Create category in Categories table
// Create category in Categories table with MenuID
queryExecute("
INSERT INTO Categories (
CategoryBusinessID, CategoryName, CategorySortOrder
CategoryBusinessID, CategoryMenuID, CategoryName, CategorySortOrder
) VALUES (
:bizID, :name, :sortOrder
:bizID, :menuID, :name, :sortOrder
)
", {
bizID: businessId,
menuID: menuID,
name: catName,
sortOrder: catOrder
}, { datasource: "payfrit" });
qNewCat = queryExecute("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" });
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;

View file

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

View file

@ -4,7 +4,7 @@
<cfscript>
// 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: ... }
function apiAbort(required struct payload) {
@ -35,41 +35,48 @@ try {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
}
if (servicePointID == 0) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "ServicePointID is required" });
}
// ServicePointID = 0 is allowed for remote chats (non-dine-in users)
// 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
// Also check it's tied to the current order if we have one
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("
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
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
AND t.TaskTypeID = 2
AND t.TaskCompletedOn IS NULL
AND (
(t.TaskOrderID = :orderID AND :orderID > 0)
OR (t.TaskOrderID IS NULL AND o.OrderUserID = :userID)
OR (t.TaskOrderID IS NULL AND t.TaskSourceID = :servicePointID)
OR (t.TaskSourceType = 'servicepoint' AND t.TaskSourceID = :servicePointID AND :servicePointID > 0)
OR (t.TaskSourceType = 'user' AND t.TaskSourceID = :userID AND :userID > 0)
OR (cm2.SenderUserID = :userID AND :userID > 0)
)
ORDER BY t.TaskAddedOn DESC
LIMIT 1
", {
businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
userID: { value: userID, 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" });
if (existingChat.recordCount > 0) {
// Check if chat is stale (more than 20 minutes old with no activity)
chatAge = dateDiff("n", existingChat.TaskAddedOn, now());
if (chatAge > 20) {
// Check if chat is stale (more than 30 minutes since last message, or 30 min since creation if no messages)
lastActivity = existingChat.LastMessageTime;
if (isNull(lastActivity) || !isDate(lastActivity)) {
lastActivity = existingChat.TaskAddedOn;
}
chatAge = dateDiff("n", lastActivity, now());
if (chatAge > 30) {
// Auto-close stale chat
queryExecute("
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
tableName = "";
if (servicePointID > 0) {
spQuery = queryExecute("
SELECT ServicePointName FROM ServicePoints WHERE ServicePointID = :spID
", { spID: { value: servicePointID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
tableName = spQuery.recordCount ? spQuery.ServicePointName : "Table ##" & servicePointID;
}
// Get user name if available
userName = "";
@ -105,11 +114,21 @@ try {
}
}
// Create task title
// Create task title - different format for dine-in vs remote
if (servicePointID > 0) {
// Dine-in: "Chat - UserName (TableName)" or "Chat - 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";
if (len(initialMessage)) {
@ -136,6 +155,15 @@ try {
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)
queryExecute("
INSERT INTO Tasks (
@ -157,8 +185,8 @@ try {
:title,
:details,
0,
'servicepoint',
:servicePointID,
:sourceType,
:sourceID,
NOW()
)
", {
@ -167,7 +195,8 @@ try {
orderID: { value: orderID > 0 ? orderID : javaCast("null", ""), cfsqltype: "cf_sql_integer", null: orderID == 0 },
title: { value: taskTitle, 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" });
// 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.TaskOrderID,
t.TaskTypeID,
t.TaskTitle,
t.TaskDetails,
t.TaskAddedOn,
t.TaskClaimedByUserID,
t.TaskClaimedOn,
t.TaskCompletedOn,
tc.TaskCategoryName,
tc.TaskCategoryColor,
tt.tt_TaskTypeName AS TaskTypeName,
tt.tt_TaskTypeIcon AS TaskTypeIcon,
tt.tt_TaskTypeColor AS TaskTypeColor,
b.BusinessName
FROM Tasks t
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
WHERE #whereSQL#
ORDER BY t.TaskClaimedOn DESC
@ -88,9 +94,19 @@
<cfset tasks = []>
<cfloop query="qTasks">
<cfset taskTitle = "Task ##" & qTasks.TaskID>
<cfif qTasks.TaskOrderID GT 0>
<!--- Use stored title if available, otherwise build from order --->
<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>
<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>
<cfset arrayAppend(tasks, {
@ -100,7 +116,7 @@
"TaskCategoryID": qTasks.TaskCategoryID,
"TaskTypeID": qTasks.TaskTypeID,
"TaskTitle": taskTitle,
"TaskDetails": "",
"TaskDetails": taskDetails,
"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"),
"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",
"TaskSourceID": qTasks.TaskOrderID,
"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>

View file

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

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">
<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="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>
</svg>
<span>Dashboard</span>
</a>
<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"/>
<rect x="9" y="3" width="6" height="4" rx="1"/>
<path d="M9 12h6M9 16h6"/>
@ -39,19 +39,19 @@
<span>Orders</span>
</a>
<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"/>
</svg>
<span>Menu</span>
</a>
<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"/>
</svg>
<span>Reports</span>
</a>
<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"/>
<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"/>
@ -59,7 +59,7 @@
<span>Team</span>
</a>
<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"/>
<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"/>
@ -67,14 +67,14 @@
<span>Beacons</span>
</a>
<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="M13.73 21a2 2 0 01-3.46 0"/>
</svg>
<span>Services</span>
</a>
<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"/>
<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>
@ -91,7 +91,7 @@
</div>
</div>
<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"/>
</svg>
<span>Logout</span>

View file

@ -779,14 +779,14 @@
<nav class="sidebar-nav">
<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="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>
</svg>
<span>Dashboard</span>
</a>
<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"/>
<rect x="9" y="3" width="6" height="4" rx="1"/>
<path d="M9 12h6M9 16h6"/>
@ -794,19 +794,19 @@
<span>Orders</span>
</a>
<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"/>
</svg>
<span>Menu</span>
</a>
<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"/>
</svg>
<span>Reports</span>
</a>
<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"/>
<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"/>
@ -814,7 +814,7 @@
<span>Team</span>
</a>
<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"/>
<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"/>
@ -822,14 +822,14 @@
<span>Beacons</span>
</a>
<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="M13.73 21a2 2 0 01-3.46 0"/>
</svg>
<span>Services</span>
</a>
<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"/>
<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>
@ -846,7 +846,7 @@
</div>
</div>
<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"/>
</svg>
<span>Logout</span>
@ -860,15 +860,14 @@
<!-- Toolbar -->
<div class="builder-toolbar">
<div class="toolbar-group">
<div class="toolbar-group">
<button class="toolbar-btn" onclick="MenuBuilder.undo()" title="Undo (Ctrl+Z)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 7v6h6M3 13a9 9 0 103-7.5"/>
</svg>
Undo
</button>
<button class="toolbar-btn" onclick="MenuBuilder.redo()" title="Redo (Ctrl+Y)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 7v6h-6M21 13a9 9 0 10-3-7.5"/>
</svg>
Redo
@ -876,13 +875,13 @@
</div>
<div class="toolbar-group">
<button class="toolbar-btn" onclick="MenuBuilder.addCategory()" title="Add Category">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M5 12h14"/>
</svg>
Category
</button>
<button class="toolbar-btn" onclick="MenuBuilder.addItem()" title="Add Item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M5 12h14"/>
</svg>
Item
@ -890,14 +889,14 @@
</div>
<div class="toolbar-group">
<button class="toolbar-btn" onclick="MenuBuilder.cloneSelected()" title="Clone (Ctrl+D)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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"/>
</svg>
Clone
</button>
<button class="toolbar-btn" onclick="MenuBuilder.deleteSelected()" title="Delete (Del)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg width="16" height="16" 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"/>
</svg>
Delete
@ -905,7 +904,7 @@
</div>
<div class="toolbar-group">
<button class="toolbar-btn" onclick="MenuBuilder.showOutlineModal()" title="View Full Menu Outline">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 6h16M4 12h16M4 18h12"/>
</svg>
Outline
@ -913,7 +912,7 @@
</div>
<div class="toolbar-group" style="margin-left: auto;">
<button class="toolbar-btn primary" onclick="MenuBuilder.saveMenu()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/>
<path d="M17 21v-8H7v8M7 3v5h8"/>
</svg>
@ -1011,6 +1010,16 @@
<span id="menuName">Menu Builder</span>
<span style="font-size: 13px; color: var(--gray-500); font-weight: normal;" id="businessLabel"></span>
</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 class="menu-structure" id="menuStructure">
@ -1132,6 +1141,8 @@
menu: {
categories: []
},
menus: [], // List of all menus for this business
selectedMenuId: 0, // Currently selected menu ID (0 = all/default)
templates: [],
stations: [],
selectedElement: null,
@ -1685,6 +1696,10 @@
// Show properties for 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 = `
<div class="property-group">
<label>Category Name</label>
@ -1696,6 +1711,15 @@
<textarea id="propCatDesc" rows="3"
onchange="MenuBuilder.updateCategory('${category.id}', 'description', this.value)">${this.escapeHtml(category.description || '')}</textarea>
</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">
<label>Sort Order</label>
<input type="number" id="propCatSort" value="${category.sortOrder}"
@ -3226,14 +3250,21 @@
},
// Load menu from API
async loadMenu() {
async loadMenu(menuId = null) {
try {
console.log('[MenuBuilder] Loading menu for BusinessID:', this.config.businessId);
console.log('[MenuBuilder] API URL:', `${this.config.apiBaseUrl}/menu/getForBuilder.cfm`);
// Use provided menuId or current selected
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`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ BusinessID: this.config.businessId })
body: JSON.stringify(payload)
});
console.log('[MenuBuilder] Response status:', response.status);
const data = await response.json();
@ -3243,6 +3274,12 @@
if (data.OK && data.MENU) {
this.menu = data.MENU;
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)
this.templates = data.TEMPLATES || [];
// Load brand color if set
@ -3257,6 +3294,8 @@
console.log('[MenuBuilder] No MENU in response or OK=false');
// Still clear the loading message
this.templates = [];
this.menus = data.MENUS || [];
this.updateMenuSelector();
this.renderTemplateLibrary();
}
} 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
renderTemplateLibrary() {
const container = document.getElementById('templateLibrary');

View file

@ -153,7 +153,17 @@ body {
.nav-icon {
width: 20px;
height: 20px;
min-width: 20px;
min-height: 20px;
flex-shrink: 0;
display: block;
}
.sidebar svg {
width: 20px;
height: 20px;
flex-shrink: 0;
display: block;
}
.sidebar-footer {
@ -282,6 +292,20 @@ body {
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 {
width: 40px;
height: 40px;

View file

@ -807,6 +807,20 @@
<h3>Menu Summary</h3>
</div>
<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">
<span class="summary-stat-label">Categories</span>
<span class="summary-stat-value" id="summaryCategories">0</span>
@ -1951,6 +1965,19 @@
document.getElementById('summaryModifiers').textContent = modifiers.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', `
<p>Your menu is ready to save!</p>
<p><strong>${business.name || 'Your Restaurant'}</strong></p>
@ -1970,6 +1997,33 @@
console.log('=== SAVE MENU CALLED ===');
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 originalText = saveBtn.innerHTML;
saveBtn.innerHTML = '<div class="loading-spinner" style="width:16px;height:16px;border-width:2px;"></div> Saving...';