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:
parent
72f5b7eb12
commit
c2ae037e71
35 changed files with 2599 additions and 175 deletions
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
75
api/admin/addCategoryScheduleFields.cfm
Normal file
75
api/admin/addCategoryScheduleFields.cfm
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<cfsetting showdebugoutput="false">
|
||||
<cfsetting enablecfoutputonly="true">
|
||||
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||
<cfheader name="Cache-Control" value="no-store">
|
||||
|
||||
<cfscript>
|
||||
/**
|
||||
* 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
30
api/admin/addLatLng.cfm
Normal 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>
|
||||
85
api/admin/createMenusTable.cfm
Normal file
85
api/admin/createMenusTable.cfm
Normal 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>
|
||||
119
api/admin/createParentBusiness.cfm
Normal file
119
api/admin/createParentBusiness.cfm
Normal 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>
|
||||
55
api/admin/deleteBusiness.cfm
Normal file
55
api/admin/deleteBusiness.cfm
Normal 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
204
api/admin/geocode.cfm
Normal 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>
|
||||
46
api/admin/linkChildBusiness.cfm
Normal file
46
api/admin/linkChildBusiness.cfm
Normal 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>
|
||||
|
|
@ -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
113
api/beacons/lookup.cfm
Normal 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>
|
||||
76
api/businesses/getChildren.cfm
Normal file
76
api/businesses/getChildren.cfm
Normal 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>
|
||||
|
|
@ -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
|
||||
",
|
||||
[],
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
89
api/menu/listCategories.cfm
Normal file
89
api/menu/listCategories.cfm
Normal 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
248
api/menu/menus.cfm
Normal 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
111
api/menu/saveCategory.cfm
Normal 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>
|
||||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
)>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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
86
api/tasks/deleteType.cfm
Normal 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>
|
||||
85
api/tasks/listAllTypes.cfm
Normal file
85
api/tasks/listAllTypes.cfm
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
86
api/tasks/listTypes.cfm
Normal 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>
|
||||
74
api/tasks/reorderTypes.cfm
Normal file
74
api/tasks/reorderTypes.cfm
Normal 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
152
api/tasks/saveType.cfm
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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...';
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue