Compare commits
10 commits
3b7225e57f
...
ed3f9192d5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed3f9192d5 | ||
|
|
43a8d18541 | ||
|
|
405914c586 | ||
|
|
e9eb708de1 | ||
|
|
d783f91075 | ||
|
|
186c3fcf68 | ||
|
|
6727f42194 | ||
|
|
5f3f5a7345 | ||
|
|
c2ae037e71 | ||
|
|
72f5b7eb12 |
56 changed files with 4743 additions and 310 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;
|
||||
|
|
@ -117,6 +119,10 @@ if (len(request._api_path)) {
|
|||
if (findNoCase("/api/menu/clearOrders.cfm", request._api_path)) request._api_isPublic = true;
|
||||
if (findNoCase("/api/addresses/states.cfm", request._api_path)) request._api_isPublic = true;
|
||||
if (findNoCase("/api/addresses/debug.cfm", request._api_path)) request._api_isPublic = true;
|
||||
if (findNoCase("/api/addresses/list.cfm", request._api_path)) request._api_isPublic = true;
|
||||
if (findNoCase("/api/addresses/add.cfm", request._api_path)) request._api_isPublic = true;
|
||||
if (findNoCase("/api/addresses/delete.cfm", request._api_path)) request._api_isPublic = true;
|
||||
if (findNoCase("/api/addresses/setDefault.cfm", request._api_path)) request._api_isPublic = true;
|
||||
if (findNoCase("/api/debug/", request._api_path)) request._api_isPublic = true;
|
||||
if (findNoCase("/api/admin/", request._api_path)) request._api_isPublic = true;
|
||||
if (findNoCase("/api/orders/getOrCreateCart.cfm", request._api_path)) request._api_isPublic = true;
|
||||
|
|
@ -173,6 +179,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>
|
||||
105
api/admin/quickTasks/create.cfm
Normal file
105
api/admin/quickTasks/create.cfm
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<cfsetting showdebugoutput="false">
|
||||
<cfsetting enablecfoutputonly="true">
|
||||
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||
|
||||
<cfscript>
|
||||
// Create a task from a quick task template (instant creation)
|
||||
// Input: BusinessID (required), QuickTaskTemplateID (required)
|
||||
// Output: { OK: true, TASK_ID: int }
|
||||
|
||||
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;
|
||||
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"]);
|
||||
}
|
||||
|
||||
if (businessID == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
|
||||
}
|
||||
|
||||
// Get template ID
|
||||
templateID = structKeyExists(data, "QuickTaskTemplateID") && isNumeric(data.QuickTaskTemplateID) ? int(data.QuickTaskTemplateID) : 0;
|
||||
|
||||
if (templateID == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "QuickTaskTemplateID is required" });
|
||||
}
|
||||
|
||||
// Get template details
|
||||
qTemplate = queryExecute("
|
||||
SELECT
|
||||
QuickTaskTemplateTitle as Title,
|
||||
QuickTaskTemplateDetails as Details,
|
||||
QuickTaskTemplateCategoryID as CategoryID,
|
||||
QuickTaskTemplateTypeID as TypeID
|
||||
FROM QuickTaskTemplates
|
||||
WHERE QuickTaskTemplateID = :id
|
||||
AND QuickTaskTemplateBusinessID = :businessID
|
||||
AND QuickTaskTemplateIsActive = 1
|
||||
", {
|
||||
id: { value: templateID, cfsqltype: "cf_sql_integer" },
|
||||
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
if (qTemplate.recordCount == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Template not found" });
|
||||
}
|
||||
|
||||
// Create the task
|
||||
queryExecute("
|
||||
INSERT INTO Tasks (
|
||||
TaskBusinessID, TaskCategoryID, TaskTypeID,
|
||||
TaskTitle, TaskDetails, TaskStatusID, TaskAddedOn,
|
||||
TaskSourceType, TaskSourceID
|
||||
) VALUES (
|
||||
:businessID, :categoryID, :typeID,
|
||||
:title, :details, 0, NOW(),
|
||||
'quicktask', :templateID
|
||||
)
|
||||
", {
|
||||
businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
|
||||
categoryID: { value: qTemplate.CategoryID, cfsqltype: "cf_sql_integer", null: isNull(qTemplate.CategoryID) },
|
||||
typeID: { value: qTemplate.TypeID, cfsqltype: "cf_sql_integer", null: isNull(qTemplate.TypeID) },
|
||||
title: { value: qTemplate.Title, cfsqltype: "cf_sql_varchar" },
|
||||
details: { value: qTemplate.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(qTemplate.Details) },
|
||||
templateID: { value: templateID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
|
||||
|
||||
apiAbort({
|
||||
"OK": true,
|
||||
"TASK_ID": qNew.newID,
|
||||
"MESSAGE": "Task created"
|
||||
});
|
||||
|
||||
} catch (any e) {
|
||||
apiAbort({
|
||||
"OK": false,
|
||||
"ERROR": "server_error",
|
||||
"MESSAGE": e.message
|
||||
});
|
||||
}
|
||||
</cfscript>
|
||||
83
api/admin/quickTasks/delete.cfm
Normal file
83
api/admin/quickTasks/delete.cfm
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<cfsetting showdebugoutput="false">
|
||||
<cfsetting enablecfoutputonly="true">
|
||||
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||
|
||||
<cfscript>
|
||||
// Delete (soft) a quick task template
|
||||
// Input: BusinessID (required), QuickTaskTemplateID (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 BusinessID
|
||||
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"]);
|
||||
}
|
||||
|
||||
if (businessID == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
|
||||
}
|
||||
|
||||
// Get template ID
|
||||
templateID = structKeyExists(data, "QuickTaskTemplateID") && isNumeric(data.QuickTaskTemplateID) ? int(data.QuickTaskTemplateID) : 0;
|
||||
|
||||
if (templateID == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "QuickTaskTemplateID is required" });
|
||||
}
|
||||
|
||||
// Verify template exists and belongs to this business
|
||||
qCheck = queryExecute("
|
||||
SELECT QuickTaskTemplateID FROM QuickTaskTemplates
|
||||
WHERE QuickTaskTemplateID = :id AND QuickTaskTemplateBusinessID = :businessID
|
||||
", {
|
||||
id: { value: templateID, cfsqltype: "cf_sql_integer" },
|
||||
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
if (qCheck.recordCount == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Template not found" });
|
||||
}
|
||||
|
||||
// Soft delete by setting IsActive to 0
|
||||
queryExecute("
|
||||
UPDATE QuickTaskTemplates SET QuickTaskTemplateIsActive = 0
|
||||
WHERE QuickTaskTemplateID = :id
|
||||
", {
|
||||
id: { value: templateID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
apiAbort({
|
||||
"OK": true,
|
||||
"MESSAGE": "Template deleted"
|
||||
});
|
||||
|
||||
} catch (any e) {
|
||||
apiAbort({
|
||||
"OK": false,
|
||||
"ERROR": "server_error",
|
||||
"MESSAGE": e.message
|
||||
});
|
||||
}
|
||||
</cfscript>
|
||||
100
api/admin/quickTasks/list.cfm
Normal file
100
api/admin/quickTasks/list.cfm
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<cfsetting showdebugoutput="false">
|
||||
<cfsetting enablecfoutputonly="true">
|
||||
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||
|
||||
<cfscript>
|
||||
// Returns quick task templates for a business
|
||||
// Input: BusinessID (required)
|
||||
// Output: { OK: true, TEMPLATES: [...] }
|
||||
|
||||
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 quick task templates for this business
|
||||
q = queryExecute("
|
||||
SELECT
|
||||
qt.QuickTaskTemplateID,
|
||||
qt.QuickTaskTemplateName as Name,
|
||||
qt.QuickTaskTemplateCategoryID as CategoryID,
|
||||
qt.QuickTaskTemplateTypeID as TypeID,
|
||||
qt.QuickTaskTemplateTitle as Title,
|
||||
qt.QuickTaskTemplateDetails as Details,
|
||||
qt.QuickTaskTemplateIcon as Icon,
|
||||
qt.QuickTaskTemplateColor as Color,
|
||||
qt.QuickTaskTemplateSortOrder as SortOrder,
|
||||
qt.QuickTaskTemplateIsActive as IsActive,
|
||||
tc.TaskCategoryName as CategoryName,
|
||||
tc.TaskCategoryColor as CategoryColor
|
||||
FROM QuickTaskTemplates qt
|
||||
LEFT JOIN TaskCategories tc ON qt.QuickTaskTemplateCategoryID = tc.TaskCategoryID
|
||||
WHERE qt.QuickTaskTemplateBusinessID = :businessID
|
||||
AND qt.QuickTaskTemplateIsActive = 1
|
||||
ORDER BY qt.QuickTaskTemplateSortOrder, qt.QuickTaskTemplateID
|
||||
", {
|
||||
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
templates = [];
|
||||
for (row in q) {
|
||||
arrayAppend(templates, {
|
||||
"QuickTaskTemplateID": row.QuickTaskTemplateID,
|
||||
"Name": row.Name,
|
||||
"CategoryID": isNull(row.CategoryID) ? "" : row.CategoryID,
|
||||
"TypeID": isNull(row.TypeID) ? "" : row.TypeID,
|
||||
"Title": row.Title,
|
||||
"Details": isNull(row.Details) ? "" : row.Details,
|
||||
"Icon": isNull(row.Icon) ? "add_box" : row.Icon,
|
||||
"Color": isNull(row.Color) ? "##6366f1" : row.Color,
|
||||
"SortOrder": row.SortOrder,
|
||||
"IsActive": row.IsActive,
|
||||
"CategoryName": isNull(row.CategoryName) ? "" : row.CategoryName,
|
||||
"CategoryColor": isNull(row.CategoryColor) ? "" : row.CategoryColor
|
||||
});
|
||||
}
|
||||
|
||||
apiAbort({
|
||||
"OK": true,
|
||||
"TEMPLATES": templates,
|
||||
"COUNT": arrayLen(templates)
|
||||
});
|
||||
|
||||
} catch (any e) {
|
||||
apiAbort({
|
||||
"OK": false,
|
||||
"ERROR": "server_error",
|
||||
"MESSAGE": e.message
|
||||
});
|
||||
}
|
||||
</cfscript>
|
||||
151
api/admin/quickTasks/save.cfm
Normal file
151
api/admin/quickTasks/save.cfm
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
<cfsetting showdebugoutput="false">
|
||||
<cfsetting enablecfoutputonly="true">
|
||||
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||
|
||||
<cfscript>
|
||||
// Create or update a quick task template
|
||||
// Input: BusinessID (required), QuickTaskTemplateID (optional - for update),
|
||||
// Name, Title, Details, CategoryID, TypeID, Icon, Color
|
||||
// Output: { OK: true, TEMPLATE_ID: int }
|
||||
|
||||
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;
|
||||
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"]);
|
||||
}
|
||||
|
||||
if (businessID == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
|
||||
}
|
||||
|
||||
// Get fields
|
||||
templateID = structKeyExists(data, "QuickTaskTemplateID") && isNumeric(data.QuickTaskTemplateID) ? int(data.QuickTaskTemplateID) : 0;
|
||||
templateName = structKeyExists(data, "Name") ? trim(toString(data.Name)) : "";
|
||||
templateTitle = structKeyExists(data, "Title") ? trim(toString(data.Title)) : "";
|
||||
templateDetails = structKeyExists(data, "Details") ? trim(toString(data.Details)) : "";
|
||||
categoryID = structKeyExists(data, "CategoryID") && isNumeric(data.CategoryID) && data.CategoryID > 0 ? int(data.CategoryID) : javaCast("null", "");
|
||||
typeID = structKeyExists(data, "TypeID") && isNumeric(data.TypeID) && data.TypeID > 0 ? int(data.TypeID) : javaCast("null", "");
|
||||
templateIcon = structKeyExists(data, "Icon") && len(trim(data.Icon)) ? trim(toString(data.Icon)) : "add_box";
|
||||
templateColor = structKeyExists(data, "Color") && len(trim(data.Color)) ? trim(toString(data.Color)) : "##6366f1";
|
||||
|
||||
// Validate required fields
|
||||
if (!len(templateName)) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Name is required" });
|
||||
}
|
||||
if (!len(templateTitle)) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Title is required" });
|
||||
}
|
||||
|
||||
if (templateID > 0) {
|
||||
// UPDATE existing template
|
||||
qCheck = queryExecute("
|
||||
SELECT QuickTaskTemplateID FROM QuickTaskTemplates
|
||||
WHERE QuickTaskTemplateID = :id AND QuickTaskTemplateBusinessID = :businessID
|
||||
", {
|
||||
id: { value: templateID, cfsqltype: "cf_sql_integer" },
|
||||
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
if (qCheck.recordCount == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Template not found" });
|
||||
}
|
||||
|
||||
queryExecute("
|
||||
UPDATE QuickTaskTemplates SET
|
||||
QuickTaskTemplateName = :name,
|
||||
QuickTaskTemplateTitle = :title,
|
||||
QuickTaskTemplateDetails = :details,
|
||||
QuickTaskTemplateCategoryID = :categoryID,
|
||||
QuickTaskTemplateTypeID = :typeID,
|
||||
QuickTaskTemplateIcon = :icon,
|
||||
QuickTaskTemplateColor = :color
|
||||
WHERE QuickTaskTemplateID = :id
|
||||
", {
|
||||
name: { value: templateName, cfsqltype: "cf_sql_varchar" },
|
||||
title: { value: templateTitle, cfsqltype: "cf_sql_varchar" },
|
||||
details: { value: templateDetails, cfsqltype: "cf_sql_longvarchar", null: !len(templateDetails) },
|
||||
categoryID: { value: categoryID, cfsqltype: "cf_sql_integer", null: isNull(categoryID) },
|
||||
typeID: { value: typeID, cfsqltype: "cf_sql_integer", null: isNull(typeID) },
|
||||
icon: { value: templateIcon, cfsqltype: "cf_sql_varchar" },
|
||||
color: { value: templateColor, cfsqltype: "cf_sql_varchar" },
|
||||
id: { value: templateID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
apiAbort({
|
||||
"OK": true,
|
||||
"TEMPLATE_ID": templateID,
|
||||
"MESSAGE": "Template updated"
|
||||
});
|
||||
|
||||
} else {
|
||||
// INSERT new template
|
||||
// Get next sort order
|
||||
qSort = queryExecute("
|
||||
SELECT COALESCE(MAX(QuickTaskTemplateSortOrder), 0) + 1 as nextSort
|
||||
FROM QuickTaskTemplates WHERE QuickTaskTemplateBusinessID = :businessID
|
||||
", {
|
||||
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
nextSort = qSort.nextSort;
|
||||
|
||||
queryExecute("
|
||||
INSERT INTO QuickTaskTemplates (
|
||||
QuickTaskTemplateBusinessID, QuickTaskTemplateName, QuickTaskTemplateTitle,
|
||||
QuickTaskTemplateDetails, QuickTaskTemplateCategoryID, QuickTaskTemplateTypeID,
|
||||
QuickTaskTemplateIcon, QuickTaskTemplateColor, QuickTaskTemplateSortOrder
|
||||
) VALUES (
|
||||
:businessID, :name, :title, :details, :categoryID, :typeID, :icon, :color, :sortOrder
|
||||
)
|
||||
", {
|
||||
businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
|
||||
name: { value: templateName, cfsqltype: "cf_sql_varchar" },
|
||||
title: { value: templateTitle, cfsqltype: "cf_sql_varchar" },
|
||||
details: { value: templateDetails, cfsqltype: "cf_sql_longvarchar", null: !len(templateDetails) },
|
||||
categoryID: { value: categoryID, cfsqltype: "cf_sql_integer", null: isNull(categoryID) },
|
||||
typeID: { value: typeID, cfsqltype: "cf_sql_integer", null: isNull(typeID) },
|
||||
icon: { value: templateIcon, cfsqltype: "cf_sql_varchar" },
|
||||
color: { value: templateColor, cfsqltype: "cf_sql_varchar" },
|
||||
sortOrder: { value: nextSort, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
|
||||
|
||||
apiAbort({
|
||||
"OK": true,
|
||||
"TEMPLATE_ID": qNew.newID,
|
||||
"MESSAGE": "Template created"
|
||||
});
|
||||
}
|
||||
|
||||
} catch (any e) {
|
||||
apiAbort({
|
||||
"OK": false,
|
||||
"ERROR": "server_error",
|
||||
"MESSAGE": e.message
|
||||
});
|
||||
}
|
||||
</cfscript>
|
||||
47
api/admin/quickTasks/setup.cfm
Normal file
47
api/admin/quickTasks/setup.cfm
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<cfsetting showdebugoutput="false">
|
||||
<cfsetting enablecfoutputonly="true">
|
||||
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||
|
||||
<cfscript>
|
||||
// Creates QuickTaskTemplates table if not exists
|
||||
// Public endpoint for setup
|
||||
|
||||
function apiAbort(required struct payload) {
|
||||
writeOutput(serializeJSON(payload));
|
||||
abort;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create QuickTaskTemplates table
|
||||
queryExecute("
|
||||
CREATE TABLE IF NOT EXISTS QuickTaskTemplates (
|
||||
QuickTaskTemplateID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
QuickTaskTemplateBusinessID INT NOT NULL,
|
||||
QuickTaskTemplateName VARCHAR(100) NOT NULL,
|
||||
QuickTaskTemplateCategoryID INT NULL,
|
||||
QuickTaskTemplateTypeID INT NULL,
|
||||
QuickTaskTemplateTitle VARCHAR(255) NOT NULL,
|
||||
QuickTaskTemplateDetails TEXT NULL,
|
||||
QuickTaskTemplateIcon VARCHAR(30) DEFAULT 'add_box',
|
||||
QuickTaskTemplateColor VARCHAR(20) DEFAULT '##6366f1',
|
||||
QuickTaskTemplateSortOrder INT DEFAULT 0,
|
||||
QuickTaskTemplateIsActive BIT(1) DEFAULT b'1',
|
||||
QuickTaskTemplateCreatedOn DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_business_active (QuickTaskTemplateBusinessID, QuickTaskTemplateIsActive),
|
||||
INDEX idx_sort (QuickTaskTemplateBusinessID, QuickTaskTemplateSortOrder)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
", [], { datasource: "payfrit" });
|
||||
|
||||
apiAbort({
|
||||
"OK": true,
|
||||
"MESSAGE": "QuickTaskTemplates table created/verified"
|
||||
});
|
||||
|
||||
} catch (any e) {
|
||||
apiAbort({
|
||||
"OK": false,
|
||||
"ERROR": "server_error",
|
||||
"MESSAGE": e.message
|
||||
});
|
||||
}
|
||||
</cfscript>
|
||||
82
api/admin/scheduledTasks/delete.cfm
Normal file
82
api/admin/scheduledTasks/delete.cfm
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<cfsetting showdebugoutput="false">
|
||||
<cfsetting enablecfoutputonly="true">
|
||||
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||
|
||||
<cfscript>
|
||||
// Delete a scheduled task definition (hard delete since it's just a definition)
|
||||
// Input: BusinessID (required), ScheduledTaskID (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 BusinessID
|
||||
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"]);
|
||||
}
|
||||
|
||||
if (businessID == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
|
||||
}
|
||||
|
||||
// Get task ID
|
||||
taskID = structKeyExists(data, "ScheduledTaskID") && isNumeric(data.ScheduledTaskID) ? int(data.ScheduledTaskID) : 0;
|
||||
|
||||
if (taskID == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "ScheduledTaskID is required" });
|
||||
}
|
||||
|
||||
// Verify exists and belongs to business
|
||||
qCheck = queryExecute("
|
||||
SELECT ScheduledTaskID FROM ScheduledTaskDefinitions
|
||||
WHERE ScheduledTaskID = :id AND ScheduledTaskBusinessID = :businessID
|
||||
", {
|
||||
id: { value: taskID, cfsqltype: "cf_sql_integer" },
|
||||
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
if (qCheck.recordCount == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Scheduled task not found" });
|
||||
}
|
||||
|
||||
// Hard delete the definition
|
||||
queryExecute("
|
||||
DELETE FROM ScheduledTaskDefinitions WHERE ScheduledTaskID = :id
|
||||
", {
|
||||
id: { value: taskID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
apiAbort({
|
||||
"OK": true,
|
||||
"MESSAGE": "Scheduled task deleted"
|
||||
});
|
||||
|
||||
} catch (any e) {
|
||||
apiAbort({
|
||||
"OK": false,
|
||||
"ERROR": "server_error",
|
||||
"MESSAGE": e.message
|
||||
});
|
||||
}
|
||||
</cfscript>
|
||||
101
api/admin/scheduledTasks/list.cfm
Normal file
101
api/admin/scheduledTasks/list.cfm
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<cfsetting showdebugoutput="false">
|
||||
<cfsetting enablecfoutputonly="true">
|
||||
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||
|
||||
<cfscript>
|
||||
// Returns scheduled task definitions for a business
|
||||
// Input: BusinessID (required)
|
||||
// Output: { OK: true, SCHEDULED_TASKS: [...] }
|
||||
|
||||
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 scheduled task definitions for this business
|
||||
q = queryExecute("
|
||||
SELECT
|
||||
st.ScheduledTaskID,
|
||||
st.ScheduledTaskName as Name,
|
||||
st.ScheduledTaskCategoryID as CategoryID,
|
||||
st.ScheduledTaskTypeID as TypeID,
|
||||
st.ScheduledTaskTitle as Title,
|
||||
st.ScheduledTaskDetails as Details,
|
||||
st.ScheduledTaskCronExpression as CronExpression,
|
||||
st.ScheduledTaskIsActive as IsActive,
|
||||
st.ScheduledTaskLastRunOn as LastRunOn,
|
||||
st.ScheduledTaskNextRunOn as NextRunOn,
|
||||
st.ScheduledTaskCreatedOn as CreatedOn,
|
||||
tc.TaskCategoryName as CategoryName,
|
||||
tc.TaskCategoryColor as CategoryColor
|
||||
FROM ScheduledTaskDefinitions st
|
||||
LEFT JOIN TaskCategories tc ON st.ScheduledTaskCategoryID = tc.TaskCategoryID
|
||||
WHERE st.ScheduledTaskBusinessID = :businessID
|
||||
ORDER BY st.ScheduledTaskIsActive DESC, st.ScheduledTaskName
|
||||
", {
|
||||
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
scheduledTasks = [];
|
||||
for (row in q) {
|
||||
arrayAppend(scheduledTasks, {
|
||||
"ScheduledTaskID": row.ScheduledTaskID,
|
||||
"Name": row.Name,
|
||||
"CategoryID": isNull(row.CategoryID) ? "" : row.CategoryID,
|
||||
"TypeID": isNull(row.TypeID) ? "" : row.TypeID,
|
||||
"Title": row.Title,
|
||||
"Details": isNull(row.Details) ? "" : row.Details,
|
||||
"CronExpression": row.CronExpression,
|
||||
"IsActive": row.IsActive ? true : false,
|
||||
"LastRunOn": isNull(row.LastRunOn) ? "" : dateTimeFormat(row.LastRunOn, "yyyy-mm-dd HH:nn:ss"),
|
||||
"NextRunOn": isNull(row.NextRunOn) ? "" : dateTimeFormat(row.NextRunOn, "yyyy-mm-dd HH:nn:ss"),
|
||||
"CreatedOn": dateTimeFormat(row.CreatedOn, "yyyy-mm-dd HH:nn:ss"),
|
||||
"CategoryName": isNull(row.CategoryName) ? "" : row.CategoryName,
|
||||
"CategoryColor": isNull(row.CategoryColor) ? "" : row.CategoryColor
|
||||
});
|
||||
}
|
||||
|
||||
apiAbort({
|
||||
"OK": true,
|
||||
"SCHEDULED_TASKS": scheduledTasks,
|
||||
"COUNT": arrayLen(scheduledTasks)
|
||||
});
|
||||
|
||||
} catch (any e) {
|
||||
apiAbort({
|
||||
"OK": false,
|
||||
"ERROR": "server_error",
|
||||
"MESSAGE": e.message
|
||||
});
|
||||
}
|
||||
</cfscript>
|
||||
104
api/admin/scheduledTasks/run.cfm
Normal file
104
api/admin/scheduledTasks/run.cfm
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<cfsetting showdebugoutput="false">
|
||||
<cfsetting enablecfoutputonly="true">
|
||||
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||
|
||||
<cfscript>
|
||||
// Manually trigger a scheduled task (for testing)
|
||||
// Creates a task from the scheduled task definition without updating next run time
|
||||
// Input: BusinessID (required), ScheduledTaskID (required)
|
||||
// Output: { OK: true, TASK_ID: int }
|
||||
|
||||
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;
|
||||
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"]);
|
||||
}
|
||||
|
||||
if (businessID == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
|
||||
}
|
||||
|
||||
// Get scheduled task ID
|
||||
scheduledTaskID = structKeyExists(data, "ScheduledTaskID") && isNumeric(data.ScheduledTaskID) ? int(data.ScheduledTaskID) : 0;
|
||||
|
||||
if (scheduledTaskID == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "ScheduledTaskID is required" });
|
||||
}
|
||||
|
||||
// Get scheduled task definition
|
||||
qDef = queryExecute("
|
||||
SELECT
|
||||
ScheduledTaskTitle as Title,
|
||||
ScheduledTaskDetails as Details,
|
||||
ScheduledTaskCategoryID as CategoryID,
|
||||
ScheduledTaskTypeID as TypeID
|
||||
FROM ScheduledTaskDefinitions
|
||||
WHERE ScheduledTaskID = :id AND ScheduledTaskBusinessID = :businessID
|
||||
", {
|
||||
id: { value: scheduledTaskID, cfsqltype: "cf_sql_integer" },
|
||||
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
if (qDef.recordCount == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Scheduled task not found" });
|
||||
}
|
||||
|
||||
// Create the task
|
||||
queryExecute("
|
||||
INSERT INTO Tasks (
|
||||
TaskBusinessID, TaskCategoryID, TaskTypeID,
|
||||
TaskTitle, TaskDetails, TaskStatusID, TaskAddedOn,
|
||||
TaskSourceType, TaskSourceID
|
||||
) VALUES (
|
||||
:businessID, :categoryID, :typeID,
|
||||
:title, :details, 0, NOW(),
|
||||
'scheduled_manual', :scheduledTaskID
|
||||
)
|
||||
", {
|
||||
businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
|
||||
categoryID: { value: qDef.CategoryID, cfsqltype: "cf_sql_integer", null: isNull(qDef.CategoryID) },
|
||||
typeID: { value: qDef.TypeID, cfsqltype: "cf_sql_integer", null: isNull(qDef.TypeID) },
|
||||
title: { value: qDef.Title, cfsqltype: "cf_sql_varchar" },
|
||||
details: { value: qDef.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(qDef.Details) },
|
||||
scheduledTaskID: { value: scheduledTaskID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
|
||||
|
||||
apiAbort({
|
||||
"OK": true,
|
||||
"TASK_ID": qNew.newID,
|
||||
"MESSAGE": "Task created from scheduled task (manual trigger)"
|
||||
});
|
||||
|
||||
} catch (any e) {
|
||||
apiAbort({
|
||||
"OK": false,
|
||||
"ERROR": "server_error",
|
||||
"MESSAGE": e.message
|
||||
});
|
||||
}
|
||||
</cfscript>
|
||||
143
api/admin/scheduledTasks/runDue.cfm
Normal file
143
api/admin/scheduledTasks/runDue.cfm
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
<cfsetting showdebugoutput="false" requesttimeout="60">
|
||||
<cfsetting enablecfoutputonly="true">
|
||||
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||
|
||||
<cfscript>
|
||||
// Process all due scheduled tasks
|
||||
// Called by cron every minute (or manually for testing)
|
||||
// Creates Tasks entries for any ScheduledTaskDefinitions that are due
|
||||
// Public endpoint - no auth required (should be restricted by IP in production)
|
||||
|
||||
function apiAbort(required struct payload) {
|
||||
writeOutput(serializeJSON(payload));
|
||||
abort;
|
||||
}
|
||||
|
||||
// Calculate next run time from cron expression
|
||||
function calculateNextRun(required string cronExpression) {
|
||||
var parts = listToArray(cronExpression, " ");
|
||||
if (arrayLen(parts) != 5) {
|
||||
return dateAdd("d", 1, now());
|
||||
}
|
||||
|
||||
var cronMinute = parts[1];
|
||||
var cronHour = parts[2];
|
||||
var cronDay = parts[3];
|
||||
var cronMonth = parts[4];
|
||||
var cronWeekday = parts[5];
|
||||
|
||||
// Start from current time + 1 minute
|
||||
var checkDate = dateAdd("n", 1, now());
|
||||
checkDate = createDateTime(year(checkDate), month(checkDate), day(checkDate), hour(checkDate), minute(checkDate), 0);
|
||||
|
||||
var maxIterations = 400 * 24 * 60; // 400 days in minutes
|
||||
var iterations = 0;
|
||||
|
||||
while (iterations < maxIterations) {
|
||||
var matchMinute = (cronMinute == "*" || (isNumeric(cronMinute) && minute(checkDate) == int(cronMinute)));
|
||||
var matchHour = (cronHour == "*" || (isNumeric(cronHour) && hour(checkDate) == int(cronHour)));
|
||||
var matchDay = (cronDay == "*" || (isNumeric(cronDay) && day(checkDate) == int(cronDay)));
|
||||
var matchMonth = (cronMonth == "*" || (isNumeric(cronMonth) && month(checkDate) == int(cronMonth)));
|
||||
|
||||
var dow = dayOfWeek(checkDate) - 1; // Convert to 0-based (0=Sunday)
|
||||
var matchWeekday = (cronWeekday == "*");
|
||||
if (!matchWeekday) {
|
||||
if (find("-", cronWeekday)) {
|
||||
var range = listToArray(cronWeekday, "-");
|
||||
if (arrayLen(range) == 2 && isNumeric(range[1]) && isNumeric(range[2])) {
|
||||
matchWeekday = (dow >= int(range[1]) && dow <= int(range[2]));
|
||||
}
|
||||
} else if (isNumeric(cronWeekday)) {
|
||||
matchWeekday = (dow == int(cronWeekday));
|
||||
}
|
||||
}
|
||||
|
||||
if (matchMinute && matchHour && matchDay && matchMonth && matchWeekday) {
|
||||
return checkDate;
|
||||
}
|
||||
|
||||
checkDate = dateAdd("n", 1, checkDate);
|
||||
iterations++;
|
||||
}
|
||||
|
||||
return dateAdd("d", 1, now());
|
||||
}
|
||||
|
||||
try {
|
||||
// Find all active scheduled tasks that are due
|
||||
dueTasks = queryExecute("
|
||||
SELECT
|
||||
ScheduledTaskID,
|
||||
ScheduledTaskBusinessID as BusinessID,
|
||||
ScheduledTaskCategoryID as CategoryID,
|
||||
ScheduledTaskTypeID as TypeID,
|
||||
ScheduledTaskTitle as Title,
|
||||
ScheduledTaskDetails as Details,
|
||||
ScheduledTaskCronExpression as CronExpression
|
||||
FROM ScheduledTaskDefinitions
|
||||
WHERE ScheduledTaskIsActive = 1
|
||||
AND ScheduledTaskNextRunOn <= NOW()
|
||||
", {}, { datasource: "payfrit" });
|
||||
|
||||
createdTasks = [];
|
||||
|
||||
for (task in dueTasks) {
|
||||
// Create the actual task
|
||||
queryExecute("
|
||||
INSERT INTO Tasks (
|
||||
TaskBusinessID, TaskCategoryID, TaskTypeID,
|
||||
TaskTitle, TaskDetails, TaskStatusID, TaskAddedOn,
|
||||
TaskSourceType, TaskSourceID
|
||||
) VALUES (
|
||||
:businessID, :categoryID, :typeID,
|
||||
:title, :details, 0, NOW(),
|
||||
'scheduled', :scheduledTaskID
|
||||
)
|
||||
", {
|
||||
businessID: { value: task.BusinessID, cfsqltype: "cf_sql_integer" },
|
||||
categoryID: { value: task.CategoryID, cfsqltype: "cf_sql_integer", null: isNull(task.CategoryID) },
|
||||
typeID: { value: task.TypeID, cfsqltype: "cf_sql_integer", null: isNull(task.TypeID) },
|
||||
title: { value: task.Title, cfsqltype: "cf_sql_varchar" },
|
||||
details: { value: task.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(task.Details) },
|
||||
scheduledTaskID: { value: task.ScheduledTaskID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
|
||||
|
||||
// Calculate next run and update the scheduled task
|
||||
nextRun = calculateNextRun(task.CronExpression);
|
||||
|
||||
queryExecute("
|
||||
UPDATE ScheduledTaskDefinitions SET
|
||||
ScheduledTaskLastRunOn = NOW(),
|
||||
ScheduledTaskNextRunOn = :nextRun
|
||||
WHERE ScheduledTaskID = :id
|
||||
", {
|
||||
nextRun: { value: nextRun, cfsqltype: "cf_sql_timestamp" },
|
||||
id: { value: task.ScheduledTaskID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
arrayAppend(createdTasks, {
|
||||
"ScheduledTaskID": task.ScheduledTaskID,
|
||||
"TaskID": qNew.newID,
|
||||
"BusinessID": task.BusinessID,
|
||||
"Title": task.Title
|
||||
});
|
||||
}
|
||||
|
||||
apiAbort({
|
||||
"OK": true,
|
||||
"MESSAGE": "Processed #arrayLen(createdTasks)# scheduled task(s)",
|
||||
"CREATED_TASKS": createdTasks,
|
||||
"CHECKED_COUNT": dueTasks.recordCount,
|
||||
"RAN_AT": dateTimeFormat(now(), "yyyy-mm-dd HH:nn:ss")
|
||||
});
|
||||
|
||||
} catch (any e) {
|
||||
apiAbort({
|
||||
"OK": false,
|
||||
"ERROR": "server_error",
|
||||
"MESSAGE": e.message
|
||||
});
|
||||
}
|
||||
</cfscript>
|
||||
218
api/admin/scheduledTasks/save.cfm
Normal file
218
api/admin/scheduledTasks/save.cfm
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
<cfsetting showdebugoutput="false">
|
||||
<cfsetting enablecfoutputonly="true">
|
||||
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||
|
||||
<cfscript>
|
||||
// Create or update a scheduled task definition
|
||||
// Input: BusinessID (required), ScheduledTaskID (optional - for update),
|
||||
// Name, Title, Details, CategoryID, TypeID, CronExpression, IsActive
|
||||
// Output: { OK: true, SCHEDULED_TASK_ID: int }
|
||||
|
||||
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 {};
|
||||
}
|
||||
|
||||
// Parse cron expression and calculate next run time
|
||||
// Supports: minute hour day month weekday
|
||||
// Examples: "0 9 * * *" (daily 9am), "0 9 * * 1-5" (weekdays 9am), "30 14 * * *" (daily 2:30pm)
|
||||
function calculateNextRun(required string cronExpression) {
|
||||
var parts = listToArray(cronExpression, " ");
|
||||
if (arrayLen(parts) != 5) {
|
||||
return dateAdd("d", 1, now()); // Fallback: tomorrow
|
||||
}
|
||||
|
||||
var cronMinute = parts[1];
|
||||
var cronHour = parts[2];
|
||||
var cronDay = parts[3];
|
||||
var cronMonth = parts[4];
|
||||
var cronWeekday = parts[5];
|
||||
|
||||
// Start from next minute
|
||||
var checkDate = dateAdd("n", 1, now());
|
||||
checkDate = createDateTime(year(checkDate), month(checkDate), day(checkDate), hour(checkDate), minute(checkDate), 0);
|
||||
|
||||
// Check up to 400 days ahead (handles yearly schedules)
|
||||
var maxIterations = 400 * 24 * 60; // 400 days in minutes
|
||||
var iterations = 0;
|
||||
|
||||
while (iterations < maxIterations) {
|
||||
var matchMinute = (cronMinute == "*" || cronMinute == minute(checkDate) ||
|
||||
(isNumeric(cronMinute) && minute(checkDate) == int(cronMinute)));
|
||||
var matchHour = (cronHour == "*" || cronHour == hour(checkDate) ||
|
||||
(isNumeric(cronHour) && hour(checkDate) == int(cronHour)));
|
||||
var matchDay = (cronDay == "*" || cronDay == day(checkDate) ||
|
||||
(isNumeric(cronDay) && day(checkDate) == int(cronDay)));
|
||||
var matchMonth = (cronMonth == "*" || cronMonth == month(checkDate) ||
|
||||
(isNumeric(cronMonth) && month(checkDate) == int(cronMonth)));
|
||||
|
||||
// Weekday: 0=Sunday, 1=Monday, ... 6=Saturday (Lucee dayOfWeek: 1=Sunday)
|
||||
var dow = dayOfWeek(checkDate) - 1; // Convert to 0-based
|
||||
var matchWeekday = (cronWeekday == "*");
|
||||
if (!matchWeekday) {
|
||||
// Handle ranges like 1-5 (Mon-Fri)
|
||||
if (find("-", cronWeekday)) {
|
||||
var range = listToArray(cronWeekday, "-");
|
||||
if (arrayLen(range) == 2 && isNumeric(range[1]) && isNumeric(range[2])) {
|
||||
matchWeekday = (dow >= int(range[1]) && dow <= int(range[2]));
|
||||
}
|
||||
} else if (isNumeric(cronWeekday)) {
|
||||
matchWeekday = (dow == int(cronWeekday));
|
||||
}
|
||||
}
|
||||
|
||||
if (matchMinute && matchHour && matchDay && matchMonth && matchWeekday) {
|
||||
return checkDate;
|
||||
}
|
||||
|
||||
// Move to next minute
|
||||
checkDate = dateAdd("n", 1, checkDate);
|
||||
iterations++;
|
||||
}
|
||||
|
||||
// Fallback if no match found
|
||||
return dateAdd("d", 1, now());
|
||||
}
|
||||
|
||||
try {
|
||||
data = readJsonBody();
|
||||
|
||||
// Get BusinessID
|
||||
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"]);
|
||||
}
|
||||
|
||||
if (businessID == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
|
||||
}
|
||||
|
||||
// Get fields
|
||||
taskID = structKeyExists(data, "ScheduledTaskID") && isNumeric(data.ScheduledTaskID) ? int(data.ScheduledTaskID) : 0;
|
||||
taskName = structKeyExists(data, "Name") ? trim(toString(data.Name)) : "";
|
||||
taskTitle = structKeyExists(data, "Title") ? trim(toString(data.Title)) : "";
|
||||
taskDetails = structKeyExists(data, "Details") ? trim(toString(data.Details)) : "";
|
||||
categoryID = structKeyExists(data, "CategoryID") && isNumeric(data.CategoryID) && data.CategoryID > 0 ? int(data.CategoryID) : javaCast("null", "");
|
||||
typeID = structKeyExists(data, "TypeID") && isNumeric(data.TypeID) && data.TypeID > 0 ? int(data.TypeID) : javaCast("null", "");
|
||||
cronExpression = structKeyExists(data, "CronExpression") ? trim(toString(data.CronExpression)) : "";
|
||||
isActive = structKeyExists(data, "IsActive") ? (data.IsActive ? 1 : 0) : 1;
|
||||
|
||||
// Validate required fields
|
||||
if (!len(taskName)) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Name is required" });
|
||||
}
|
||||
if (!len(taskTitle)) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Title is required" });
|
||||
}
|
||||
if (!len(cronExpression)) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "CronExpression is required" });
|
||||
}
|
||||
|
||||
// Validate cron format (5 parts)
|
||||
cronParts = listToArray(cronExpression, " ");
|
||||
if (arrayLen(cronParts) != 5) {
|
||||
apiAbort({ "OK": false, "ERROR": "invalid_cron", "MESSAGE": "Cron expression must have 5 parts: minute hour day month weekday" });
|
||||
}
|
||||
|
||||
// Calculate next run time
|
||||
nextRunOn = calculateNextRun(cronExpression);
|
||||
|
||||
if (taskID > 0) {
|
||||
// UPDATE existing
|
||||
qCheck = queryExecute("
|
||||
SELECT ScheduledTaskID FROM ScheduledTaskDefinitions
|
||||
WHERE ScheduledTaskID = :id AND ScheduledTaskBusinessID = :businessID
|
||||
", {
|
||||
id: { value: taskID, cfsqltype: "cf_sql_integer" },
|
||||
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
if (qCheck.recordCount == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Scheduled task not found" });
|
||||
}
|
||||
|
||||
queryExecute("
|
||||
UPDATE ScheduledTaskDefinitions SET
|
||||
ScheduledTaskName = :name,
|
||||
ScheduledTaskTitle = :title,
|
||||
ScheduledTaskDetails = :details,
|
||||
ScheduledTaskCategoryID = :categoryID,
|
||||
ScheduledTaskTypeID = :typeID,
|
||||
ScheduledTaskCronExpression = :cron,
|
||||
ScheduledTaskIsActive = :isActive,
|
||||
ScheduledTaskNextRunOn = :nextRun
|
||||
WHERE ScheduledTaskID = :id
|
||||
", {
|
||||
name: { value: taskName, cfsqltype: "cf_sql_varchar" },
|
||||
title: { value: taskTitle, cfsqltype: "cf_sql_varchar" },
|
||||
details: { value: taskDetails, cfsqltype: "cf_sql_longvarchar", null: !len(taskDetails) },
|
||||
categoryID: { value: categoryID, cfsqltype: "cf_sql_integer", null: isNull(categoryID) },
|
||||
typeID: { value: typeID, cfsqltype: "cf_sql_integer", null: isNull(typeID) },
|
||||
cron: { value: cronExpression, cfsqltype: "cf_sql_varchar" },
|
||||
isActive: { value: isActive, cfsqltype: "cf_sql_bit" },
|
||||
nextRun: { value: nextRunOn, cfsqltype: "cf_sql_timestamp" },
|
||||
id: { value: taskID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
apiAbort({
|
||||
"OK": true,
|
||||
"SCHEDULED_TASK_ID": taskID,
|
||||
"NEXT_RUN": dateTimeFormat(nextRunOn, "yyyy-mm-dd HH:nn:ss"),
|
||||
"MESSAGE": "Scheduled task updated"
|
||||
});
|
||||
|
||||
} else {
|
||||
// INSERT new
|
||||
queryExecute("
|
||||
INSERT INTO ScheduledTaskDefinitions (
|
||||
ScheduledTaskBusinessID, ScheduledTaskName, ScheduledTaskTitle,
|
||||
ScheduledTaskDetails, ScheduledTaskCategoryID, ScheduledTaskTypeID,
|
||||
ScheduledTaskCronExpression, ScheduledTaskIsActive, ScheduledTaskNextRunOn
|
||||
) VALUES (
|
||||
:businessID, :name, :title, :details, :categoryID, :typeID, :cron, :isActive, :nextRun
|
||||
)
|
||||
", {
|
||||
businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
|
||||
name: { value: taskName, cfsqltype: "cf_sql_varchar" },
|
||||
title: { value: taskTitle, cfsqltype: "cf_sql_varchar" },
|
||||
details: { value: taskDetails, cfsqltype: "cf_sql_longvarchar", null: !len(taskDetails) },
|
||||
categoryID: { value: categoryID, cfsqltype: "cf_sql_integer", null: isNull(categoryID) },
|
||||
typeID: { value: typeID, cfsqltype: "cf_sql_integer", null: isNull(typeID) },
|
||||
cron: { value: cronExpression, cfsqltype: "cf_sql_varchar" },
|
||||
isActive: { value: isActive, cfsqltype: "cf_sql_bit" },
|
||||
nextRun: { value: nextRunOn, cfsqltype: "cf_sql_timestamp" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
|
||||
|
||||
apiAbort({
|
||||
"OK": true,
|
||||
"SCHEDULED_TASK_ID": qNew.newID,
|
||||
"NEXT_RUN": dateTimeFormat(nextRunOn, "yyyy-mm-dd HH:nn:ss"),
|
||||
"MESSAGE": "Scheduled task created"
|
||||
});
|
||||
}
|
||||
|
||||
} catch (any e) {
|
||||
apiAbort({
|
||||
"OK": false,
|
||||
"ERROR": "server_error",
|
||||
"MESSAGE": e.message
|
||||
});
|
||||
}
|
||||
</cfscript>
|
||||
48
api/admin/scheduledTasks/setup.cfm
Normal file
48
api/admin/scheduledTasks/setup.cfm
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<cfsetting showdebugoutput="false">
|
||||
<cfsetting enablecfoutputonly="true">
|
||||
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||
|
||||
<cfscript>
|
||||
// Creates ScheduledTaskDefinitions table if not exists
|
||||
// Public endpoint for setup
|
||||
|
||||
function apiAbort(required struct payload) {
|
||||
writeOutput(serializeJSON(payload));
|
||||
abort;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create ScheduledTaskDefinitions table
|
||||
queryExecute("
|
||||
CREATE TABLE IF NOT EXISTS ScheduledTaskDefinitions (
|
||||
ScheduledTaskID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
ScheduledTaskBusinessID INT NOT NULL,
|
||||
ScheduledTaskName VARCHAR(100) NOT NULL,
|
||||
ScheduledTaskCategoryID INT NULL,
|
||||
ScheduledTaskTypeID INT NULL,
|
||||
ScheduledTaskTitle VARCHAR(255) NOT NULL,
|
||||
ScheduledTaskDetails TEXT NULL,
|
||||
ScheduledTaskCronExpression VARCHAR(100) NOT NULL,
|
||||
ScheduledTaskIsActive BIT(1) DEFAULT b'1',
|
||||
ScheduledTaskLastRunOn DATETIME NULL,
|
||||
ScheduledTaskNextRunOn DATETIME NULL,
|
||||
ScheduledTaskCreatedOn DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
ScheduledTaskCreatedByUserID INT NULL,
|
||||
INDEX idx_business (ScheduledTaskBusinessID),
|
||||
INDEX idx_active_next (ScheduledTaskIsActive, ScheduledTaskNextRunOn)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
", [], { datasource: "payfrit" });
|
||||
|
||||
apiAbort({
|
||||
"OK": true,
|
||||
"MESSAGE": "ScheduledTaskDefinitions table created/verified"
|
||||
});
|
||||
|
||||
} catch (any e) {
|
||||
apiAbort({
|
||||
"OK": false,
|
||||
"ERROR": "server_error",
|
||||
"MESSAGE": e.message
|
||||
});
|
||||
}
|
||||
</cfscript>
|
||||
155
api/admin/scheduledTasks/toggle.cfm
Normal file
155
api/admin/scheduledTasks/toggle.cfm
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
<cfsetting showdebugoutput="false">
|
||||
<cfsetting enablecfoutputonly="true">
|
||||
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||
|
||||
<cfscript>
|
||||
// Enable or disable a scheduled task
|
||||
// Input: BusinessID (required), ScheduledTaskID (required), IsActive (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 {};
|
||||
}
|
||||
|
||||
// Calculate next run time from cron expression
|
||||
function calculateNextRun(required string cronExpression) {
|
||||
var parts = listToArray(cronExpression, " ");
|
||||
if (arrayLen(parts) != 5) {
|
||||
return dateAdd("d", 1, now());
|
||||
}
|
||||
|
||||
var cronMinute = parts[1];
|
||||
var cronHour = parts[2];
|
||||
var cronDay = parts[3];
|
||||
var cronMonth = parts[4];
|
||||
var cronWeekday = parts[5];
|
||||
|
||||
var checkDate = dateAdd("n", 1, now());
|
||||
checkDate = createDateTime(year(checkDate), month(checkDate), day(checkDate), hour(checkDate), minute(checkDate), 0);
|
||||
|
||||
var maxIterations = 400 * 24 * 60;
|
||||
var iterations = 0;
|
||||
|
||||
while (iterations < maxIterations) {
|
||||
var matchMinute = (cronMinute == "*" || (isNumeric(cronMinute) && minute(checkDate) == int(cronMinute)));
|
||||
var matchHour = (cronHour == "*" || (isNumeric(cronHour) && hour(checkDate) == int(cronHour)));
|
||||
var matchDay = (cronDay == "*" || (isNumeric(cronDay) && day(checkDate) == int(cronDay)));
|
||||
var matchMonth = (cronMonth == "*" || (isNumeric(cronMonth) && month(checkDate) == int(cronMonth)));
|
||||
|
||||
var dow = dayOfWeek(checkDate) - 1;
|
||||
var matchWeekday = (cronWeekday == "*");
|
||||
if (!matchWeekday) {
|
||||
if (find("-", cronWeekday)) {
|
||||
var range = listToArray(cronWeekday, "-");
|
||||
if (arrayLen(range) == 2 && isNumeric(range[1]) && isNumeric(range[2])) {
|
||||
matchWeekday = (dow >= int(range[1]) && dow <= int(range[2]));
|
||||
}
|
||||
} else if (isNumeric(cronWeekday)) {
|
||||
matchWeekday = (dow == int(cronWeekday));
|
||||
}
|
||||
}
|
||||
|
||||
if (matchMinute && matchHour && matchDay && matchMonth && matchWeekday) {
|
||||
return checkDate;
|
||||
}
|
||||
|
||||
checkDate = dateAdd("n", 1, checkDate);
|
||||
iterations++;
|
||||
}
|
||||
|
||||
return dateAdd("d", 1, now());
|
||||
}
|
||||
|
||||
try {
|
||||
data = readJsonBody();
|
||||
|
||||
// Get BusinessID
|
||||
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"]);
|
||||
}
|
||||
|
||||
if (businessID == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
|
||||
}
|
||||
|
||||
// Get task ID and new state
|
||||
taskID = structKeyExists(data, "ScheduledTaskID") && isNumeric(data.ScheduledTaskID) ? int(data.ScheduledTaskID) : 0;
|
||||
isActive = structKeyExists(data, "IsActive") ? (data.IsActive ? 1 : 0) : 0;
|
||||
|
||||
if (taskID == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "ScheduledTaskID is required" });
|
||||
}
|
||||
|
||||
// Verify exists and get cron expression
|
||||
qCheck = queryExecute("
|
||||
SELECT ScheduledTaskID, ScheduledTaskCronExpression as CronExpression
|
||||
FROM ScheduledTaskDefinitions
|
||||
WHERE ScheduledTaskID = :id AND ScheduledTaskBusinessID = :businessID
|
||||
", {
|
||||
id: { value: taskID, cfsqltype: "cf_sql_integer" },
|
||||
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
if (qCheck.recordCount == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Scheduled task not found" });
|
||||
}
|
||||
|
||||
// If enabling, recalculate next run time
|
||||
nextRunUpdate = "";
|
||||
if (isActive) {
|
||||
nextRunOn = calculateNextRun(qCheck.CronExpression);
|
||||
nextRunUpdate = ", ScheduledTaskNextRunOn = :nextRun";
|
||||
}
|
||||
|
||||
// Update status
|
||||
if (isActive) {
|
||||
queryExecute("
|
||||
UPDATE ScheduledTaskDefinitions SET
|
||||
ScheduledTaskIsActive = :isActive,
|
||||
ScheduledTaskNextRunOn = :nextRun
|
||||
WHERE ScheduledTaskID = :id
|
||||
", {
|
||||
isActive: { value: isActive, cfsqltype: "cf_sql_bit" },
|
||||
nextRun: { value: nextRunOn, cfsqltype: "cf_sql_timestamp" },
|
||||
id: { value: taskID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
} else {
|
||||
queryExecute("
|
||||
UPDATE ScheduledTaskDefinitions SET ScheduledTaskIsActive = :isActive
|
||||
WHERE ScheduledTaskID = :id
|
||||
", {
|
||||
isActive: { value: isActive, cfsqltype: "cf_sql_bit" },
|
||||
id: { value: taskID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
}
|
||||
|
||||
apiAbort({
|
||||
"OK": true,
|
||||
"MESSAGE": isActive ? "Scheduled task enabled" : "Scheduled task disabled"
|
||||
});
|
||||
|
||||
} catch (any e) {
|
||||
apiAbort({
|
||||
"OK": false,
|
||||
"ERROR": "server_error",
|
||||
"MESSAGE": e.message
|
||||
});
|
||||
}
|
||||
</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,9 +61,54 @@ 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 = []>
|
||||
<cfloop query="qAssignments">
|
||||
|
||||
<!--- 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,
|
||||
"BusinessName" = qAssignments.BusinessName,
|
||||
|
|
@ -72,7 +116,8 @@ beaconId = int(data.BeaconID);
|
|||
"ServicePointName" = qAssignments.ServicePointName,
|
||||
"IsParent" = isNull(qAssignments.BusinessParentBusinessID) OR qAssignments.BusinessParentBusinessID EQ 0
|
||||
})>
|
||||
</cfloop>
|
||||
</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>
|
||||
|
|
@ -10,33 +10,100 @@ function apiAbort(payload) {
|
|||
abort;
|
||||
}
|
||||
|
||||
// Read JSON body for user location
|
||||
function readJsonBody() {
|
||||
var raw = getHttpRequestData().content;
|
||||
if (isNull(raw) OR len(trim(raw)) EQ 0) return {};
|
||||
try {
|
||||
var data = deserializeJSON(raw);
|
||||
if (isStruct(data)) return data;
|
||||
return {};
|
||||
} catch (any e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Haversine formula to calculate distance in miles
|
||||
function haversineDistance(lat1, lng1, lat2, lng2) {
|
||||
var R = 3959; // Earth radius in miles
|
||||
var dLat = (lat2 - lat1) * 3.14159265359 / 180;
|
||||
var dLng = (lng2 - lng1) * 3.14159265359 / 180;
|
||||
var a = sin(dLat/2) * sin(dLat/2) +
|
||||
cos(lat1 * 3.14159265359 / 180) * cos(lat2 * 3.14159265359 / 180) *
|
||||
sin(dLng/2) * sin(dLng/2);
|
||||
// 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;
|
||||
}
|
||||
|
||||
try {
|
||||
data = readJsonBody();
|
||||
userLat = structKeyExists(data, "lat") ? val(data.lat) : 0;
|
||||
userLng = structKeyExists(data, "lng") ? val(data.lng) : 0;
|
||||
hasUserLocation = (userLat != 0 AND userLng != 0);
|
||||
|
||||
// Get businesses with their address coordinates (exclude demo and private)
|
||||
q = queryExecute(
|
||||
"
|
||||
SELECT
|
||||
BusinessID,
|
||||
BusinessName
|
||||
FROM Businesses
|
||||
ORDER BY BusinessName
|
||||
b.BusinessID,
|
||||
b.BusinessName,
|
||||
a.AddressLat,
|
||||
a.AddressLng,
|
||||
a.AddressCity,
|
||||
a.AddressLine1
|
||||
FROM Businesses b
|
||||
LEFT JOIN Addresses a ON b.BusinessAddressID = a.AddressID
|
||||
WHERE (b.BusinessIsDemo = 0 OR b.BusinessIsDemo IS NULL)
|
||||
AND (b.BusinessIsPrivate = 0 OR b.BusinessIsPrivate IS NULL)
|
||||
ORDER BY b.BusinessName
|
||||
",
|
||||
[],
|
||||
{ datasource = "payfrit" }
|
||||
);
|
||||
|
||||
// Convert query -> array of structs (JSON-native)
|
||||
// Convert query -> array of structs with distance
|
||||
rows = [];
|
||||
for (i = 1; i <= q.recordCount; i++) {
|
||||
arrayAppend(rows, {
|
||||
row = {
|
||||
"BusinessID": q.BusinessID[i],
|
||||
"BusinessName": q.BusinessName[i]
|
||||
"BusinessName": q.BusinessName[i],
|
||||
"AddressCity": isNull(q.AddressCity[i]) ? "" : q.AddressCity[i],
|
||||
"AddressLine1": isNull(q.AddressLine1[i]) ? "" : q.AddressLine1[i]
|
||||
};
|
||||
|
||||
// Calculate distance if we have both user location and business location
|
||||
bizLat = isNull(q.AddressLat[i]) ? 0 : val(q.AddressLat[i]);
|
||||
bizLng = isNull(q.AddressLng[i]) ? 0 : val(q.AddressLng[i]);
|
||||
|
||||
if (hasUserLocation AND bizLat != 0 AND bizLng != 0) {
|
||||
row["DistanceMiles"] = haversineDistance(userLat, userLng, bizLat, bizLng);
|
||||
} else {
|
||||
row["DistanceMiles"] = 99999; // No location = sort to end
|
||||
}
|
||||
|
||||
arrayAppend(rows, row);
|
||||
}
|
||||
|
||||
// Sort by distance if user location provided
|
||||
if (hasUserLocation) {
|
||||
arraySort(rows, function(a, b) {
|
||||
return compare(a.DistanceMiles, b.DistanceMiles);
|
||||
});
|
||||
}
|
||||
|
||||
// Limit to 20 nearest restaurants
|
||||
if (arrayLen(rows) > 20) {
|
||||
rows = arraySlice(rows, 1, 20);
|
||||
}
|
||||
|
||||
// Provide BOTH keys to satisfy any Flutter casing expectation
|
||||
writeOutput(serializeJSON({
|
||||
"OK": true,
|
||||
"ERROR": "",
|
||||
"VERSION": "businesses_list_v3",
|
||||
"VERSION": "businesses_list_v5",
|
||||
"BUSINESSES": rows,
|
||||
"Businesses": rows
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
*/
|
||||
|
||||
// Mode: "test" or "live"
|
||||
stripeMode = "test";
|
||||
stripeMode = "live";
|
||||
|
||||
// Test keys (safe to commit)
|
||||
stripeTestSecretKey = "sk_test_LfbmDduJxTwbVZmvcByYmirw";
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
)>
|
||||
|
|
|
|||
|
|
@ -71,16 +71,16 @@ try {
|
|||
response["OK"] = true;
|
||||
response["HAS_CART"] = true;
|
||||
response["CART"] = {
|
||||
"OrderID": qCart.OrderID,
|
||||
"OrderUUID": qCart.OrderUUID,
|
||||
"BusinessID": qCart.OrderBusinessID,
|
||||
"OrderID": val(qCart.OrderID),
|
||||
"OrderUUID": qCart.OrderUUID ?: "",
|
||||
"BusinessID": val(qCart.OrderBusinessID),
|
||||
"BusinessName": len(trim(qCart.BusinessName)) ? qCart.BusinessName : "",
|
||||
"BusinessOrderTypes": orderTypesArray,
|
||||
"OrderTypeID": qCart.OrderTypeID,
|
||||
"OrderTypeID": val(qCart.OrderTypeID),
|
||||
"OrderTypeName": orderTypeName,
|
||||
"ServicePointID": qCart.OrderServicePointID,
|
||||
"ServicePointID": val(qCart.OrderServicePointID),
|
||||
"ServicePointName": len(trim(qCart.ServicePointName)) ? qCart.ServicePointName : "",
|
||||
"ItemCount": qCart.ItemCount,
|
||||
"ItemCount": val(qCart.ItemCount),
|
||||
"AddedOn": dateTimeFormat(qCart.OrderAddedOn, "yyyy-mm-dd HH:nn:ss")
|
||||
};
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -105,20 +105,20 @@
|
|||
<cfset rows = []>
|
||||
<cfloop query="qLI">
|
||||
<cfset arrayAppend(rows, {
|
||||
"OrderLineItemID": qLI.OrderLineItemID,
|
||||
"OrderLineItemParentOrderLineItemID": qLI.OrderLineItemParentOrderLineItemID,
|
||||
"OrderLineItemOrderID": qLI.OrderLineItemOrderID,
|
||||
"OrderLineItemItemID": qLI.OrderLineItemItemID,
|
||||
"OrderLineItemStatusID": qLI.OrderLineItemStatusID,
|
||||
"OrderLineItemPrice": qLI.OrderLineItemPrice,
|
||||
"OrderLineItemQuantity": qLI.OrderLineItemQuantity,
|
||||
"OrderLineItemRemark": qLI.OrderLineItemRemark,
|
||||
"OrderLineItemIsDeleted": qLI.OrderLineItemIsDeleted,
|
||||
"OrderLineItemID": val(qLI.OrderLineItemID),
|
||||
"OrderLineItemParentOrderLineItemID": val(qLI.OrderLineItemParentOrderLineItemID),
|
||||
"OrderLineItemOrderID": val(qLI.OrderLineItemOrderID),
|
||||
"OrderLineItemItemID": val(qLI.OrderLineItemItemID),
|
||||
"OrderLineItemStatusID": val(qLI.OrderLineItemStatusID),
|
||||
"OrderLineItemPrice": val(qLI.OrderLineItemPrice),
|
||||
"OrderLineItemQuantity": val(qLI.OrderLineItemQuantity),
|
||||
"OrderLineItemRemark": qLI.OrderLineItemRemark ?: "",
|
||||
"OrderLineItemIsDeleted": val(qLI.OrderLineItemIsDeleted),
|
||||
"OrderLineItemAddedOn": qLI.OrderLineItemAddedOn,
|
||||
"ItemName": qLI.ItemName,
|
||||
"ItemParentItemID": qLI.ItemParentItemID,
|
||||
"ItemParentName": qLI.ItemParentName,
|
||||
"ItemIsCheckedByDefault": qLI.ItemIsCheckedByDefault
|
||||
"ItemName": qLI.ItemName ?: "",
|
||||
"ItemParentItemID": val(qLI.ItemParentItemID),
|
||||
"ItemParentName": qLI.ItemParentName ?: "",
|
||||
"ItemIsCheckedByDefault": val(qLI.ItemIsCheckedByDefault)
|
||||
})>
|
||||
</cfloop>
|
||||
|
||||
|
|
@ -126,24 +126,24 @@
|
|||
"OK": true,
|
||||
"ERROR": "",
|
||||
"ORDER": {
|
||||
"OrderID": qOrder.OrderID,
|
||||
"OrderUUID": qOrder.OrderUUID,
|
||||
"OrderUserID": qOrder.OrderUserID,
|
||||
"OrderBusinessID": qOrder.OrderBusinessID,
|
||||
"OrderBusinessDeliveryMultiplier": qOrder.OrderBusinessDeliveryMultiplier,
|
||||
"OrderTypeID": qOrder.OrderTypeID,
|
||||
"OrderDeliveryFee": qOrder.OrderDeliveryFee,
|
||||
"BusinessDeliveryFee": businessDeliveryFee,
|
||||
"OrderID": val(qOrder.OrderID),
|
||||
"OrderUUID": qOrder.OrderUUID ?: "",
|
||||
"OrderUserID": val(qOrder.OrderUserID),
|
||||
"OrderBusinessID": val(qOrder.OrderBusinessID),
|
||||
"OrderBusinessDeliveryMultiplier": val(qOrder.OrderBusinessDeliveryMultiplier),
|
||||
"OrderTypeID": val(qOrder.OrderTypeID),
|
||||
"OrderDeliveryFee": val(qOrder.OrderDeliveryFee),
|
||||
"BusinessDeliveryFee": val(businessDeliveryFee),
|
||||
"BusinessOrderTypes": businessOrderTypesArray,
|
||||
"OrderStatusID": qOrder.OrderStatusID,
|
||||
"OrderAddressID": qOrder.OrderAddressID,
|
||||
"OrderPaymentID": qOrder.OrderPaymentID,
|
||||
"OrderPaymentStatus": qOrder.OrderPaymentStatus,
|
||||
"OrderRemarks": qOrder.OrderRemarks,
|
||||
"OrderStatusID": val(qOrder.OrderStatusID),
|
||||
"OrderAddressID": val(qOrder.OrderAddressID),
|
||||
"OrderPaymentID": val(qOrder.OrderPaymentID),
|
||||
"OrderPaymentStatus": qOrder.OrderPaymentStatus ?: "",
|
||||
"OrderRemarks": qOrder.OrderRemarks ?: "",
|
||||
"OrderAddedOn": qOrder.OrderAddedOn,
|
||||
"OrderLastEditedOn": qOrder.OrderLastEditedOn,
|
||||
"OrderSubmittedOn": qOrder.OrderSubmittedOn,
|
||||
"OrderServicePointID": qOrder.OrderServicePointID
|
||||
"OrderServicePointID": val(qOrder.OrderServicePointID)
|
||||
},
|
||||
"ORDERLINEITEMS": rows
|
||||
})>
|
||||
|
|
|
|||
|
|
@ -103,14 +103,14 @@ try {
|
|||
// First pass: create all items
|
||||
for (row in qItems) {
|
||||
item = {
|
||||
"LineItemID": row.OrderLineItemID,
|
||||
"ItemID": row.OrderLineItemItemID,
|
||||
"ParentLineItemID": row.OrderLineItemParentOrderLineItemID,
|
||||
"ItemName": row.ItemName,
|
||||
"Quantity": row.OrderLineItemQuantity,
|
||||
"UnitPrice": row.OrderLineItemPrice,
|
||||
"Remarks": row.OrderLineItemRemark,
|
||||
"IsDefault": (row.ItemIsCheckedByDefault == 1),
|
||||
"LineItemID": val(row.OrderLineItemID),
|
||||
"ItemID": val(row.OrderLineItemItemID),
|
||||
"ParentLineItemID": val(row.OrderLineItemParentOrderLineItemID),
|
||||
"ItemName": row.ItemName ?: "",
|
||||
"Quantity": val(row.OrderLineItemQuantity),
|
||||
"UnitPrice": val(row.OrderLineItemPrice),
|
||||
"Remarks": row.OrderLineItemRemark ?: "",
|
||||
"IsDefault": (val(row.ItemIsCheckedByDefault) == 1),
|
||||
"Modifiers": []
|
||||
};
|
||||
itemsById[row.OrderLineItemID] = item;
|
||||
|
|
|
|||
|
|
@ -69,22 +69,22 @@
|
|||
<cfset var businessDeliveryFee = qBusiness.recordCount GT 0 ? qBusiness.BusinessDeliveryFlatFee : 0>
|
||||
|
||||
<cfset out.Order = {
|
||||
"OrderID": qOrder.OrderID,
|
||||
"OrderUUID": qOrder.OrderUUID,
|
||||
"OrderUserID": qOrder.OrderUserID,
|
||||
"OrderBusinessID": qOrder.OrderBusinessID,
|
||||
"OrderBusinessDeliveryMultiplier": qOrder.OrderBusinessDeliveryMultiplier,
|
||||
"OrderTypeID": qOrder.OrderTypeID,
|
||||
"OrderDeliveryFee": qOrder.OrderDeliveryFee,
|
||||
"BusinessDeliveryFee": businessDeliveryFee,
|
||||
"OrderStatusID": qOrder.OrderStatusID,
|
||||
"OrderAddressID": qOrder.OrderAddressID,
|
||||
"OrderPaymentID": qOrder.OrderPaymentID,
|
||||
"OrderRemarks": qOrder.OrderRemarks,
|
||||
"OrderID": val(qOrder.OrderID),
|
||||
"OrderUUID": qOrder.OrderUUID ?: "",
|
||||
"OrderUserID": val(qOrder.OrderUserID),
|
||||
"OrderBusinessID": val(qOrder.OrderBusinessID),
|
||||
"OrderBusinessDeliveryMultiplier": val(qOrder.OrderBusinessDeliveryMultiplier),
|
||||
"OrderTypeID": val(qOrder.OrderTypeID),
|
||||
"OrderDeliveryFee": val(qOrder.OrderDeliveryFee),
|
||||
"BusinessDeliveryFee": val(businessDeliveryFee),
|
||||
"OrderStatusID": val(qOrder.OrderStatusID),
|
||||
"OrderAddressID": val(qOrder.OrderAddressID),
|
||||
"OrderPaymentID": val(qOrder.OrderPaymentID),
|
||||
"OrderRemarks": qOrder.OrderRemarks ?: "",
|
||||
"OrderAddedOn": qOrder.OrderAddedOn,
|
||||
"OrderLastEditedOn": qOrder.OrderLastEditedOn,
|
||||
"OrderSubmittedOn": qOrder.OrderSubmittedOn,
|
||||
"OrderServicePointID": qOrder.OrderServicePointID
|
||||
"OrderServicePointID": val(qOrder.OrderServicePointID)
|
||||
}>
|
||||
|
||||
<cfset var qLI = queryExecute(
|
||||
|
|
@ -117,20 +117,20 @@
|
|||
<cfset var rows = []>
|
||||
<cfloop query="qLI">
|
||||
<cfset arrayAppend(rows, {
|
||||
"OrderLineItemID": qLI.OrderLineItemID,
|
||||
"OrderLineItemParentOrderLineItemID": qLI.OrderLineItemParentOrderLineItemID,
|
||||
"OrderLineItemOrderID": qLI.OrderLineItemOrderID,
|
||||
"OrderLineItemItemID": qLI.OrderLineItemItemID,
|
||||
"OrderLineItemStatusID": qLI.OrderLineItemStatusID,
|
||||
"OrderLineItemPrice": qLI.OrderLineItemPrice,
|
||||
"OrderLineItemQuantity": qLI.OrderLineItemQuantity,
|
||||
"OrderLineItemRemark": qLI.OrderLineItemRemark,
|
||||
"OrderLineItemIsDeleted": qLI.OrderLineItemIsDeleted,
|
||||
"OrderLineItemID": val(qLI.OrderLineItemID),
|
||||
"OrderLineItemParentOrderLineItemID": val(qLI.OrderLineItemParentOrderLineItemID),
|
||||
"OrderLineItemOrderID": val(qLI.OrderLineItemOrderID),
|
||||
"OrderLineItemItemID": val(qLI.OrderLineItemItemID),
|
||||
"OrderLineItemStatusID": val(qLI.OrderLineItemStatusID),
|
||||
"OrderLineItemPrice": val(qLI.OrderLineItemPrice),
|
||||
"OrderLineItemQuantity": val(qLI.OrderLineItemQuantity),
|
||||
"OrderLineItemRemark": qLI.OrderLineItemRemark ?: "",
|
||||
"OrderLineItemIsDeleted": val(qLI.OrderLineItemIsDeleted),
|
||||
"OrderLineItemAddedOn": qLI.OrderLineItemAddedOn,
|
||||
"ItemName": qLI.ItemName,
|
||||
"ItemParentItemID": qLI.ItemParentItemID,
|
||||
"ItemParentName": qLI.ItemParentName,
|
||||
"ItemIsCheckedByDefault": qLI.ItemIsCheckedByDefault
|
||||
"ItemName": qLI.ItemName ?: "",
|
||||
"ItemParentItemID": val(qLI.ItemParentItemID),
|
||||
"ItemParentName": qLI.ItemParentName ?: "",
|
||||
"ItemIsCheckedByDefault": val(qLI.ItemIsCheckedByDefault)
|
||||
})>
|
||||
</cfloop>
|
||||
|
||||
|
|
|
|||
|
|
@ -109,8 +109,8 @@ try {
|
|||
AND (OrderLineItemIsDeleted = 0 OR OrderLineItemIsDeleted IS NULL)
|
||||
", { orderId: { value = row.OrderID, cfsqltype = "cf_sql_integer" } });
|
||||
|
||||
itemCount = qItems.ItemCount ?: 0;
|
||||
subtotal = qItems.Subtotal ?: 0;
|
||||
itemCount = val(qItems.ItemCount);
|
||||
subtotal = val(qItems.Subtotal);
|
||||
tax = subtotal * 0.0875;
|
||||
total = subtotal + tax;
|
||||
|
||||
|
|
@ -141,16 +141,16 @@ try {
|
|||
} catch (any de) { completedAt = ""; }
|
||||
|
||||
arrayAppend(orders, {
|
||||
"OrderID": row.OrderID,
|
||||
"OrderID": val(row.OrderID),
|
||||
"OrderUUID": row.OrderUUID ?: "",
|
||||
"BusinessID": row.OrderBusinessID,
|
||||
"BusinessID": val(row.OrderBusinessID),
|
||||
"BusinessName": row.BusinessName ?: "Unknown",
|
||||
"OrderTotal": round(total * 100) / 100,
|
||||
"OrderStatusID": row.OrderStatusID,
|
||||
"OrderTotal": round(val(total) * 100) / 100,
|
||||
"OrderStatusID": val(row.OrderStatusID),
|
||||
"StatusName": statusText,
|
||||
"OrderTypeID": row.OrderTypeID ?: 0,
|
||||
"OrderTypeID": val(row.OrderTypeID),
|
||||
"TypeName": row.OrderTypeName ?: "Unknown",
|
||||
"ItemCount": itemCount,
|
||||
"ItemCount": val(itemCount),
|
||||
"CreatedAt": createdAt,
|
||||
"CompletedAt": completedAt
|
||||
});
|
||||
|
|
|
|||
|
|
@ -206,22 +206,22 @@
|
|||
</cfif>
|
||||
|
||||
<cfset out.ORDER = {
|
||||
"OrderID": qOrder.OrderID,
|
||||
"OrderUUID": qOrder.OrderUUID,
|
||||
"OrderUserID": qOrder.OrderUserID,
|
||||
"OrderBusinessID": qOrder.OrderBusinessID,
|
||||
"OrderBusinessDeliveryMultiplier": qOrder.OrderBusinessDeliveryMultiplier,
|
||||
"OrderTypeID": qOrder.OrderTypeID,
|
||||
"OrderDeliveryFee": qOrder.OrderDeliveryFee,
|
||||
"OrderStatusID": qOrder.OrderStatusID,
|
||||
"OrderAddressID": qOrder.OrderAddressID,
|
||||
"OrderPaymentID": qOrder.OrderPaymentID,
|
||||
"OrderRemarks": qOrder.OrderRemarks,
|
||||
"OrderID": val(qOrder.OrderID),
|
||||
"OrderUUID": qOrder.OrderUUID ?: "",
|
||||
"OrderUserID": val(qOrder.OrderUserID),
|
||||
"OrderBusinessID": val(qOrder.OrderBusinessID),
|
||||
"OrderBusinessDeliveryMultiplier": val(qOrder.OrderBusinessDeliveryMultiplier),
|
||||
"OrderTypeID": val(qOrder.OrderTypeID),
|
||||
"OrderDeliveryFee": val(qOrder.OrderDeliveryFee),
|
||||
"OrderStatusID": val(qOrder.OrderStatusID),
|
||||
"OrderAddressID": val(qOrder.OrderAddressID),
|
||||
"OrderPaymentID": val(qOrder.OrderPaymentID),
|
||||
"OrderRemarks": qOrder.OrderRemarks ?: "",
|
||||
"OrderAddedOn": qOrder.OrderAddedOn,
|
||||
"OrderLastEditedOn": qOrder.OrderLastEditedOn,
|
||||
"OrderSubmittedOn": qOrder.OrderSubmittedOn,
|
||||
"OrderServicePointID": qOrder.OrderServicePointID,
|
||||
"BusinessDeliveryFee": qOrder.BusinessDeliveryFee
|
||||
"OrderServicePointID": val(qOrder.OrderServicePointID),
|
||||
"BusinessDeliveryFee": val(qOrder.BusinessDeliveryFee)
|
||||
}>
|
||||
|
||||
<cfset var qLI = queryExecute(
|
||||
|
|
@ -254,20 +254,20 @@
|
|||
<cfset var rows = []>
|
||||
<cfloop query="qLI">
|
||||
<cfset arrayAppend(rows, {
|
||||
"OrderLineItemID": qLI.OrderLineItemID,
|
||||
"OrderLineItemParentOrderLineItemID": qLI.OrderLineItemParentOrderLineItemID,
|
||||
"OrderLineItemOrderID": qLI.OrderLineItemOrderID,
|
||||
"OrderLineItemItemID": qLI.OrderLineItemItemID,
|
||||
"OrderLineItemStatusID": qLI.OrderLineItemStatusID,
|
||||
"OrderLineItemPrice": qLI.OrderLineItemPrice,
|
||||
"OrderLineItemQuantity": qLI.OrderLineItemQuantity,
|
||||
"OrderLineItemRemark": qLI.OrderLineItemRemark,
|
||||
"OrderLineItemIsDeleted": qLI.OrderLineItemIsDeleted,
|
||||
"OrderLineItemID": val(qLI.OrderLineItemID),
|
||||
"OrderLineItemParentOrderLineItemID": val(qLI.OrderLineItemParentOrderLineItemID),
|
||||
"OrderLineItemOrderID": val(qLI.OrderLineItemOrderID),
|
||||
"OrderLineItemItemID": val(qLI.OrderLineItemItemID),
|
||||
"OrderLineItemStatusID": val(qLI.OrderLineItemStatusID),
|
||||
"OrderLineItemPrice": val(qLI.OrderLineItemPrice),
|
||||
"OrderLineItemQuantity": val(qLI.OrderLineItemQuantity),
|
||||
"OrderLineItemRemark": qLI.OrderLineItemRemark ?: "",
|
||||
"OrderLineItemIsDeleted": val(qLI.OrderLineItemIsDeleted),
|
||||
"OrderLineItemAddedOn": qLI.OrderLineItemAddedOn,
|
||||
"ItemName": qLI.ItemName,
|
||||
"ItemParentItemID": qLI.ItemParentItemID,
|
||||
"ItemParentName": qLI.ItemParentName,
|
||||
"ItemIsCheckedByDefault": qLI.ItemIsCheckedByDefault
|
||||
"ItemName": qLI.ItemName ?: "",
|
||||
"ItemParentItemID": val(qLI.ItemParentItemID),
|
||||
"ItemParentName": qLI.ItemParentName ?: "",
|
||||
"ItemIsCheckedByDefault": val(qLI.ItemIsCheckedByDefault)
|
||||
})>
|
||||
</cfloop>
|
||||
|
||||
|
|
|
|||
|
|
@ -69,22 +69,22 @@
|
|||
<cfset var businessDeliveryFee = qBusiness.recordCount GT 0 ? qBusiness.BusinessDeliveryFlatFee : 0>
|
||||
|
||||
<cfset out.Order = {
|
||||
"OrderID": qOrder.OrderID,
|
||||
"OrderUUID": qOrder.OrderUUID,
|
||||
"OrderUserID": qOrder.OrderUserID,
|
||||
"OrderBusinessID": qOrder.OrderBusinessID,
|
||||
"OrderBusinessDeliveryMultiplier": qOrder.OrderBusinessDeliveryMultiplier,
|
||||
"OrderTypeID": qOrder.OrderTypeID,
|
||||
"OrderDeliveryFee": qOrder.OrderDeliveryFee,
|
||||
"BusinessDeliveryFee": businessDeliveryFee,
|
||||
"OrderStatusID": qOrder.OrderStatusID,
|
||||
"OrderAddressID": qOrder.OrderAddressID,
|
||||
"OrderPaymentID": qOrder.OrderPaymentID,
|
||||
"OrderRemarks": qOrder.OrderRemarks,
|
||||
"OrderID": val(qOrder.OrderID),
|
||||
"OrderUUID": qOrder.OrderUUID ?: "",
|
||||
"OrderUserID": val(qOrder.OrderUserID),
|
||||
"OrderBusinessID": val(qOrder.OrderBusinessID),
|
||||
"OrderBusinessDeliveryMultiplier": val(qOrder.OrderBusinessDeliveryMultiplier),
|
||||
"OrderTypeID": val(qOrder.OrderTypeID),
|
||||
"OrderDeliveryFee": val(qOrder.OrderDeliveryFee),
|
||||
"BusinessDeliveryFee": val(businessDeliveryFee),
|
||||
"OrderStatusID": val(qOrder.OrderStatusID),
|
||||
"OrderAddressID": val(qOrder.OrderAddressID),
|
||||
"OrderPaymentID": val(qOrder.OrderPaymentID),
|
||||
"OrderRemarks": qOrder.OrderRemarks ?: "",
|
||||
"OrderAddedOn": qOrder.OrderAddedOn,
|
||||
"OrderLastEditedOn": qOrder.OrderLastEditedOn,
|
||||
"OrderSubmittedOn": qOrder.OrderSubmittedOn,
|
||||
"OrderServicePointID": qOrder.OrderServicePointID
|
||||
"OrderServicePointID": val(qOrder.OrderServicePointID)
|
||||
}>
|
||||
|
||||
<cfset var qLI = queryExecute(
|
||||
|
|
@ -117,20 +117,20 @@
|
|||
<cfset var rows = []>
|
||||
<cfloop query="qLI">
|
||||
<cfset arrayAppend(rows, {
|
||||
"OrderLineItemID": qLI.OrderLineItemID,
|
||||
"OrderLineItemParentOrderLineItemID": qLI.OrderLineItemParentOrderLineItemID,
|
||||
"OrderLineItemOrderID": qLI.OrderLineItemOrderID,
|
||||
"OrderLineItemItemID": qLI.OrderLineItemItemID,
|
||||
"OrderLineItemStatusID": qLI.OrderLineItemStatusID,
|
||||
"OrderLineItemPrice": qLI.OrderLineItemPrice,
|
||||
"OrderLineItemQuantity": qLI.OrderLineItemQuantity,
|
||||
"OrderLineItemRemark": qLI.OrderLineItemRemark,
|
||||
"OrderLineItemIsDeleted": qLI.OrderLineItemIsDeleted,
|
||||
"OrderLineItemID": val(qLI.OrderLineItemID),
|
||||
"OrderLineItemParentOrderLineItemID": val(qLI.OrderLineItemParentOrderLineItemID),
|
||||
"OrderLineItemOrderID": val(qLI.OrderLineItemOrderID),
|
||||
"OrderLineItemItemID": val(qLI.OrderLineItemItemID),
|
||||
"OrderLineItemStatusID": val(qLI.OrderLineItemStatusID),
|
||||
"OrderLineItemPrice": val(qLI.OrderLineItemPrice),
|
||||
"OrderLineItemQuantity": val(qLI.OrderLineItemQuantity),
|
||||
"OrderLineItemRemark": qLI.OrderLineItemRemark ?: "",
|
||||
"OrderLineItemIsDeleted": val(qLI.OrderLineItemIsDeleted),
|
||||
"OrderLineItemAddedOn": qLI.OrderLineItemAddedOn,
|
||||
"ItemName": qLI.ItemName,
|
||||
"ItemParentItemID": qLI.ItemParentItemID,
|
||||
"ItemParentName": qLI.ItemParentName,
|
||||
"ItemIsCheckedByDefault": qLI.ItemIsCheckedByDefault
|
||||
"ItemName": qLI.ItemName ?: "",
|
||||
"ItemParentItemID": val(qLI.ItemParentItemID),
|
||||
"ItemParentName": qLI.ItemParentName ?: "",
|
||||
"ItemIsCheckedByDefault": val(qLI.ItemIsCheckedByDefault)
|
||||
})>
|
||||
</cfloop>
|
||||
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
@ -57,13 +58,32 @@ try {
|
|||
}
|
||||
}
|
||||
|
||||
// Create task title and details
|
||||
// Get task type name if TaskTypeID provided
|
||||
taskTypeName = "";
|
||||
if (taskTypeID > 0) {
|
||||
typeQuery = queryExecute("
|
||||
SELECT tt_TaskTypeName FROM tt_TaskTypes WHERE tt_TaskTypeID = :typeID
|
||||
", { typeID: { value: taskTypeID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
|
||||
if (typeQuery.recordCount && len(trim(typeQuery.tt_TaskTypeName))) {
|
||||
taskTypeName = typeQuery.tt_TaskTypeName;
|
||||
}
|
||||
}
|
||||
|
||||
// Create task title and details - use task type name if available
|
||||
if (len(taskTypeName)) {
|
||||
taskTitle = taskTypeName & " - " & tableName;
|
||||
} else {
|
||||
taskTitle = "Service Request - " & tableName;
|
||||
}
|
||||
|
||||
taskDetails = "";
|
||||
if (len(taskTypeName)) {
|
||||
taskDetails &= "Task: " & taskTypeName & chr(10);
|
||||
}
|
||||
if (len(userName)) {
|
||||
taskDetails &= "Customer: " & userName & chr(10);
|
||||
}
|
||||
taskDetails &= "Location: " & tableName & chr(10);
|
||||
if (len(message)) {
|
||||
taskDetails &= "Request: " & message;
|
||||
} else {
|
||||
|
|
@ -105,7 +125,7 @@ try {
|
|||
:businessID,
|
||||
:categoryID,
|
||||
:orderID,
|
||||
1,
|
||||
:taskTypeID,
|
||||
:title,
|
||||
:details,
|
||||
0,
|
||||
|
|
@ -115,6 +135,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,20 @@
|
|||
<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="#admin-tasks" class="nav-item" data-page="admin-tasks">
|
||||
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span>Task Admin</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 +97,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>
|
||||
|
|
@ -416,6 +422,40 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Admin Tasks Page -->
|
||||
<section class="page" id="page-admin-tasks">
|
||||
<div class="admin-tasks-layout">
|
||||
<!-- Quick Task Templates Section -->
|
||||
<div class="card">
|
||||
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h3>Quick Tasks</h3>
|
||||
<button class="btn btn-sm btn-primary" onclick="Portal.showAddQuickTaskModal()">+ Add Template</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p style="color:#666;margin-bottom:16px;">Create common tasks instantly with one tap.</p>
|
||||
<div class="quick-tasks-grid" id="quickTasksGrid">
|
||||
<div class="empty-state">Loading...</div>
|
||||
</div>
|
||||
<div id="quickTasksManageList" style="margin-top:24px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scheduled Tasks Section -->
|
||||
<div class="card">
|
||||
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h3>Scheduled Tasks</h3>
|
||||
<button class="btn btn-sm btn-primary" onclick="Portal.showAddScheduledTaskModal()">+ Add Scheduled Task</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p style="color:#666;margin-bottom:16px;">Define recurring tasks that automatically create entries in the Tasks system.</p>
|
||||
<div class="list-group" id="scheduledTasksList">
|
||||
<div class="empty-state">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Settings Page -->
|
||||
<section class="page" id="page-settings">
|
||||
<div class="settings-grid">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -1209,3 +1233,57 @@ body {
|
|||
font-size: 13px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
/* Admin Tasks Page */
|
||||
.admin-tasks-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.admin-tasks-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-tasks-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.quick-task-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.quick-task-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.status-badge.active {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge.inactive {
|
||||
background: var(--gray-400);
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
code {
|
||||
background: var(--gray-100);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
|
|
|||
491
portal/portal.js
491
portal/portal.js
|
|
@ -200,6 +200,7 @@ const Portal = {
|
|||
team: 'Team',
|
||||
beacons: 'Beacons',
|
||||
services: 'Service Requests',
|
||||
'admin-tasks': 'Task Admin',
|
||||
settings: 'Settings'
|
||||
};
|
||||
document.getElementById('pageTitle').textContent = titles[page] || page;
|
||||
|
|
@ -237,6 +238,9 @@ const Portal = {
|
|||
case 'services':
|
||||
await this.loadServicesPage();
|
||||
break;
|
||||
case 'admin-tasks':
|
||||
await this.loadAdminTasksPage();
|
||||
break;
|
||||
case 'settings':
|
||||
await this.loadSettings();
|
||||
break;
|
||||
|
|
@ -2472,6 +2476,493 @@ const Portal = {
|
|||
console.error('[Portal] Error deleting service:', err);
|
||||
this.toast('Error deleting service', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// =====================
|
||||
// Admin Tasks Management
|
||||
// =====================
|
||||
|
||||
quickTaskTemplates: [],
|
||||
scheduledTasks: [],
|
||||
|
||||
async loadAdminTasksPage() {
|
||||
console.log('[Portal] Loading admin tasks page...');
|
||||
await Promise.all([
|
||||
this.loadQuickTaskTemplates(),
|
||||
this.loadScheduledTasks()
|
||||
]);
|
||||
},
|
||||
|
||||
// Quick Task Templates
|
||||
async loadQuickTaskTemplates() {
|
||||
try {
|
||||
const response = await fetch(`${this.config.apiBaseUrl}/admin/quickTasks/list.cfm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ BusinessID: this.config.businessId })
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.OK) {
|
||||
this.quickTaskTemplates = data.TEMPLATES || [];
|
||||
this.renderQuickTaskTemplates();
|
||||
} else {
|
||||
document.getElementById('quickTasksGrid').innerHTML = '<div class="empty-state">Failed to load quick task templates</div>';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Portal] Error loading quick task templates:', err);
|
||||
document.getElementById('quickTasksGrid').innerHTML = '<div class="empty-state">Error loading templates</div>';
|
||||
}
|
||||
},
|
||||
|
||||
renderQuickTaskTemplates() {
|
||||
const gridContainer = document.getElementById('quickTasksGrid');
|
||||
const manageContainer = document.getElementById('quickTasksManageList');
|
||||
|
||||
if (!this.quickTaskTemplates.length) {
|
||||
gridContainer.innerHTML = '<div class="empty-state">No quick task templates. Click "+ Add Template" to create one.</div>';
|
||||
manageContainer.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Shortcut buttons grid
|
||||
gridContainer.innerHTML = this.quickTaskTemplates.map(t => `
|
||||
<button class="quick-task-btn" onclick="Portal.createQuickTask(${t.QuickTaskTemplateID})"
|
||||
style="background:${t.Color || '#6366f1'};color:#fff;border:none;padding:16px;border-radius:8px;cursor:pointer;text-align:left;transition:transform 0.1s,box-shadow 0.1s;">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||
${this.getServiceIconSvg(t.Icon || 'add_box')}
|
||||
<strong>${this.escapeHtml(t.Name)}</strong>
|
||||
</div>
|
||||
<div style="font-size:12px;opacity:0.9;">${this.escapeHtml(t.Title)}</div>
|
||||
</button>
|
||||
`).join('');
|
||||
|
||||
// Management list
|
||||
manageContainer.innerHTML = `
|
||||
<h4 style="margin-bottom:12px;color:#666;">Manage Templates</h4>
|
||||
<div class="list-group">
|
||||
${this.quickTaskTemplates.map(t => `
|
||||
<div class="list-group-item" style="display:flex;justify-content:space-between;align-items:center;padding:12px;border-bottom:1px solid #eee;">
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<div style="width:8px;height:36px;background:${t.Color || '#6366f1'};border-radius:4px;"></div>
|
||||
<div>
|
||||
<strong>${this.escapeHtml(t.Name)}</strong>
|
||||
<div style="color:#666;font-size:12px;">${this.escapeHtml(t.Title)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button class="btn btn-sm btn-secondary" onclick="Portal.editQuickTask(${t.QuickTaskTemplateID})">Edit</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="Portal.deleteQuickTask(${t.QuickTaskTemplateID})">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
showAddQuickTaskModal(templateId = null) {
|
||||
const isEdit = templateId !== null;
|
||||
const template = isEdit ? this.quickTaskTemplates.find(t => t.QuickTaskTemplateID === templateId) : {};
|
||||
|
||||
// Build category options
|
||||
const categoryOptions = (this._taskCategories || []).map(c =>
|
||||
`<option value="${c.TaskCategoryID}" ${template.CategoryID == c.TaskCategoryID ? 'selected' : ''}>${this.escapeHtml(c.TaskCategoryName)}</option>`
|
||||
).join('');
|
||||
|
||||
// Build icon options
|
||||
const iconOptions = Object.keys(this.serviceIcons).map(key =>
|
||||
`<option value="${key}" ${(template.Icon || 'add_box') === key ? 'selected' : ''}>${this.serviceIcons[key].label}</option>`
|
||||
).join('');
|
||||
|
||||
document.getElementById('modalTitle').textContent = isEdit ? 'Edit Quick Task Template' : 'Add Quick Task Template';
|
||||
document.getElementById('modalBody').innerHTML = `
|
||||
<form id="quickTaskForm" class="form">
|
||||
<input type="hidden" id="quickTaskTemplateId" value="${templateId || ''}">
|
||||
<div class="form-group">
|
||||
<label>Template Name</label>
|
||||
<input type="text" id="quickTaskName" class="form-input" value="${this.escapeHtml(template.Name || '')}" required placeholder="e.g., Check Trash">
|
||||
<small style="color:#666;">Name shown on the shortcut button</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Task Title</label>
|
||||
<input type="text" id="quickTaskTitle" class="form-input" value="${this.escapeHtml(template.Title || '')}" required placeholder="e.g., Check and empty trash bins">
|
||||
<small style="color:#666;">Title shown on the created task</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Task Details (optional)</label>
|
||||
<textarea id="quickTaskDetails" class="form-textarea" rows="2" placeholder="Optional instructions">${this.escapeHtml(template.Details || '')}</textarea>
|
||||
</div>
|
||||
<div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||||
<div class="form-group">
|
||||
<label>Category (optional)</label>
|
||||
<select id="quickTaskCategory" class="form-input">
|
||||
<option value="">None</option>
|
||||
${categoryOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Icon</label>
|
||||
<select id="quickTaskIcon" class="form-input">
|
||||
${iconOptions}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Button Color</label>
|
||||
<input type="color" id="quickTaskColor" class="form-input" value="${template.Color || '#6366f1'}" style="height:40px;padding:4px;">
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;justify-content:flex-end;margin-top:20px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="Portal.closeModal()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">${isEdit ? 'Save Changes' : 'Create Template'}</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
this.showModal();
|
||||
|
||||
// Load categories if not loaded
|
||||
if (!this._taskCategories) {
|
||||
this.loadTaskCategories();
|
||||
}
|
||||
|
||||
document.getElementById('quickTaskForm').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
this.saveQuickTask();
|
||||
});
|
||||
},
|
||||
|
||||
editQuickTask(templateId) {
|
||||
this.showAddQuickTaskModal(templateId);
|
||||
},
|
||||
|
||||
async loadTaskCategories() {
|
||||
try {
|
||||
const response = await fetch(`${this.config.apiBaseUrl}/tasks/listCategories.cfm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ BusinessID: this.config.businessId })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.OK) {
|
||||
this._taskCategories = data.CATEGORIES || [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Portal] Error loading task categories:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async saveQuickTask() {
|
||||
const id = document.getElementById('quickTaskTemplateId').value;
|
||||
const payload = {
|
||||
BusinessID: this.config.businessId,
|
||||
Name: document.getElementById('quickTaskName').value,
|
||||
Title: document.getElementById('quickTaskTitle').value,
|
||||
Details: document.getElementById('quickTaskDetails').value,
|
||||
CategoryID: document.getElementById('quickTaskCategory').value || null,
|
||||
Icon: document.getElementById('quickTaskIcon').value,
|
||||
Color: document.getElementById('quickTaskColor').value
|
||||
};
|
||||
|
||||
if (id) payload.QuickTaskTemplateID = parseInt(id);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.config.apiBaseUrl}/admin/quickTasks/save.cfm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.OK) {
|
||||
this.toast('Template saved!', 'success');
|
||||
this.closeModal();
|
||||
await this.loadQuickTaskTemplates();
|
||||
} else {
|
||||
this.toast(data.MESSAGE || 'Failed to save', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Portal] Error saving quick task template:', err);
|
||||
this.toast('Error saving template', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteQuickTask(templateId) {
|
||||
if (!confirm('Delete this quick task template?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.config.apiBaseUrl}/admin/quickTasks/delete.cfm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
BusinessID: this.config.businessId,
|
||||
QuickTaskTemplateID: templateId
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.OK) {
|
||||
this.toast('Template deleted', 'success');
|
||||
await this.loadQuickTaskTemplates();
|
||||
} else {
|
||||
this.toast(data.MESSAGE || 'Failed to delete', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Portal] Error deleting quick task template:', err);
|
||||
this.toast('Error deleting template', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async createQuickTask(templateId) {
|
||||
try {
|
||||
const response = await fetch(`${this.config.apiBaseUrl}/admin/quickTasks/create.cfm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
BusinessID: this.config.businessId,
|
||||
QuickTaskTemplateID: templateId
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.OK) {
|
||||
this.toast('Task created!', 'success');
|
||||
} else {
|
||||
this.toast(data.MESSAGE || 'Failed to create task', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Portal] Error creating quick task:', err);
|
||||
this.toast('Error creating task', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// Scheduled Tasks
|
||||
async loadScheduledTasks() {
|
||||
try {
|
||||
const response = await fetch(`${this.config.apiBaseUrl}/admin/scheduledTasks/list.cfm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ BusinessID: this.config.businessId })
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.OK) {
|
||||
this.scheduledTasks = data.SCHEDULED_TASKS || [];
|
||||
this.renderScheduledTasks();
|
||||
} else {
|
||||
document.getElementById('scheduledTasksList').innerHTML = '<div class="empty-state">Failed to load scheduled tasks</div>';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Portal] Error loading scheduled tasks:', err);
|
||||
document.getElementById('scheduledTasksList').innerHTML = '<div class="empty-state">Error loading scheduled tasks</div>';
|
||||
}
|
||||
},
|
||||
|
||||
renderScheduledTasks() {
|
||||
const container = document.getElementById('scheduledTasksList');
|
||||
if (!this.scheduledTasks.length) {
|
||||
container.innerHTML = '<div class="empty-state">No scheduled tasks configured. Click "+ Add Scheduled Task" to create one.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = this.scheduledTasks.map(s => `
|
||||
<div class="list-group-item" style="display:flex;justify-content:space-between;align-items:center;padding:12px;border-bottom:1px solid #eee;">
|
||||
<div style="flex:1;">
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<span class="status-badge ${s.IsActive ? 'active' : 'inactive'}">${s.IsActive ? 'Active' : 'Paused'}</span>
|
||||
<strong>${this.escapeHtml(s.Name)}</strong>
|
||||
</div>
|
||||
<div style="color:#666;font-size:12px;margin-top:4px;">
|
||||
${this.escapeHtml(s.Title)} | Schedule: <code>${this.escapeHtml(s.CronExpression)}</code>
|
||||
</div>
|
||||
${s.NextRunOn ? `<div style="color:#999;font-size:11px;">Next run: ${s.NextRunOn}</div>` : ''}
|
||||
${s.LastRunOn ? `<div style="color:#999;font-size:11px;">Last run: ${s.LastRunOn}</div>` : ''}
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<button class="btn btn-sm btn-secondary" onclick="Portal.runScheduledTaskNow(${s.ScheduledTaskID})" title="Run Now">
|
||||
Run Now
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick="Portal.toggleScheduledTask(${s.ScheduledTaskID}, ${!s.IsActive})">
|
||||
${s.IsActive ? 'Pause' : 'Enable'}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick="Portal.editScheduledTask(${s.ScheduledTaskID})">Edit</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="Portal.deleteScheduledTask(${s.ScheduledTaskID})">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
},
|
||||
|
||||
showAddScheduledTaskModal(taskId = null) {
|
||||
const isEdit = taskId !== null;
|
||||
const task = isEdit ? this.scheduledTasks.find(t => t.ScheduledTaskID === taskId) : {};
|
||||
|
||||
// Build category options
|
||||
const categoryOptions = (this._taskCategories || []).map(c =>
|
||||
`<option value="${c.TaskCategoryID}" ${task.CategoryID == c.TaskCategoryID ? 'selected' : ''}>${this.escapeHtml(c.TaskCategoryName)}</option>`
|
||||
).join('');
|
||||
|
||||
document.getElementById('modalTitle').textContent = isEdit ? 'Edit Scheduled Task' : 'Add Scheduled Task';
|
||||
document.getElementById('modalBody').innerHTML = `
|
||||
<form id="scheduledTaskForm" class="form">
|
||||
<input type="hidden" id="scheduledTaskId" value="${taskId || ''}">
|
||||
<div class="form-group">
|
||||
<label>Definition Name</label>
|
||||
<input type="text" id="scheduledTaskName" class="form-input" value="${this.escapeHtml(task.Name || '')}" required placeholder="e.g., Daily Opening Checklist">
|
||||
<small style="color:#666;">Internal name for this scheduled task</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Task Title</label>
|
||||
<input type="text" id="scheduledTaskTitle" class="form-input" value="${this.escapeHtml(task.Title || '')}" required placeholder="e.g., Complete Opening Checklist">
|
||||
<small style="color:#666;">Title shown on the created task</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Task Details (optional)</label>
|
||||
<textarea id="scheduledTaskDetails" class="form-textarea" rows="2" placeholder="Optional instructions">${this.escapeHtml(task.Details || '')}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Category (optional)</label>
|
||||
<select id="scheduledTaskCategory" class="form-input">
|
||||
<option value="">None</option>
|
||||
${categoryOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Schedule (Cron Expression)</label>
|
||||
<input type="text" id="scheduledTaskCron" class="form-input" value="${task.CronExpression || '0 9 * * *'}" required placeholder="0 9 * * *">
|
||||
<small style="color:#666;">Format: minute hour day month weekday<br>
|
||||
Examples: <code>0 9 * * *</code> (daily 9am), <code>0 9 * * 1-5</code> (weekdays 9am), <code>30 14 * * *</code> (daily 2:30pm)</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label><input type="checkbox" id="scheduledTaskActive" ${task.IsActive !== false ? 'checked' : ''}> Active</label>
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;justify-content:flex-end;margin-top:20px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="Portal.closeModal()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">${isEdit ? 'Save Changes' : 'Create'}</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
this.showModal();
|
||||
|
||||
// Load categories if not loaded
|
||||
if (!this._taskCategories) {
|
||||
this.loadTaskCategories();
|
||||
}
|
||||
|
||||
document.getElementById('scheduledTaskForm').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
this.saveScheduledTask();
|
||||
});
|
||||
},
|
||||
|
||||
editScheduledTask(taskId) {
|
||||
this.showAddScheduledTaskModal(taskId);
|
||||
},
|
||||
|
||||
async saveScheduledTask() {
|
||||
const id = document.getElementById('scheduledTaskId').value;
|
||||
const payload = {
|
||||
BusinessID: this.config.businessId,
|
||||
Name: document.getElementById('scheduledTaskName').value,
|
||||
Title: document.getElementById('scheduledTaskTitle').value,
|
||||
Details: document.getElementById('scheduledTaskDetails').value,
|
||||
CategoryID: document.getElementById('scheduledTaskCategory').value || null,
|
||||
CronExpression: document.getElementById('scheduledTaskCron').value,
|
||||
IsActive: document.getElementById('scheduledTaskActive').checked
|
||||
};
|
||||
|
||||
if (id) payload.ScheduledTaskID = parseInt(id);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.config.apiBaseUrl}/admin/scheduledTasks/save.cfm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.OK) {
|
||||
this.toast(`Scheduled task saved! Next run: ${data.NEXT_RUN}`, 'success');
|
||||
this.closeModal();
|
||||
await this.loadScheduledTasks();
|
||||
} else {
|
||||
this.toast(data.MESSAGE || 'Failed to save', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Portal] Error saving scheduled task:', err);
|
||||
this.toast('Error saving scheduled task', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteScheduledTask(taskId) {
|
||||
if (!confirm('Delete this scheduled task?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.config.apiBaseUrl}/admin/scheduledTasks/delete.cfm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
BusinessID: this.config.businessId,
|
||||
ScheduledTaskID: taskId
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.OK) {
|
||||
this.toast('Scheduled task deleted', 'success');
|
||||
await this.loadScheduledTasks();
|
||||
} else {
|
||||
this.toast(data.MESSAGE || 'Failed to delete', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Portal] Error deleting scheduled task:', err);
|
||||
this.toast('Error deleting scheduled task', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async toggleScheduledTask(taskId, isActive) {
|
||||
try {
|
||||
const response = await fetch(`${this.config.apiBaseUrl}/admin/scheduledTasks/toggle.cfm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
BusinessID: this.config.businessId,
|
||||
ScheduledTaskID: taskId,
|
||||
IsActive: isActive
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.OK) {
|
||||
this.toast(isActive ? 'Scheduled task enabled' : 'Scheduled task paused', 'success');
|
||||
await this.loadScheduledTasks();
|
||||
} else {
|
||||
this.toast(data.MESSAGE || 'Failed to toggle', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Portal] Error toggling scheduled task:', err);
|
||||
this.toast('Error toggling scheduled task', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async runScheduledTaskNow(taskId) {
|
||||
try {
|
||||
const response = await fetch(`${this.config.apiBaseUrl}/admin/scheduledTasks/run.cfm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
BusinessID: this.config.businessId,
|
||||
ScheduledTaskID: taskId
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.OK) {
|
||||
this.toast(`Task #${data.TASK_ID} created!`, 'success');
|
||||
} else {
|
||||
this.toast(data.MESSAGE || 'Failed to run task', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Portal] Error running scheduled task:', err);
|
||||
this.toast('Error running scheduled task', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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