Compare commits

..

10 commits

Author SHA1 Message Date
John Mizerek
ed3f9192d5 Add Task Admin feature to portal
- Add Quick Task Templates: admin creates task shortcuts, tap to create tasks instantly
- Add Scheduled Tasks: admin defines recurring tasks with cron expressions
- New API endpoints: /api/admin/quickTasks/* and /api/admin/scheduledTasks/*
- New database tables: QuickTaskTemplates, ScheduledTaskDefinitions
- Portal UI: Task Admin page with shortcut buttons and scheduled task management

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 01:51:41 -08:00
John Mizerek
43a8d18541 Update callServer task endpoint
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 01:40:41 -08:00
John Mizerek
405914c586 Fix subtotal/itemCount null handling in history API
Use val() instead of ?: for SUM results that may be empty strings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:59:33 -08:00
John Mizerek
e9eb708de1 Fix null value handling in setOrderType API
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:41:31 -08:00
John Mizerek
d783f91075 Fix null value handling in setLineItem API
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:40:32 -08:00
John Mizerek
186c3fcf68 Fix null value handling in getActiveCart API
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:39:25 -08:00
John Mizerek
6727f42194 Fix null/empty value handling in order APIs
Wrap numeric fields with val() to prevent "can't cast empty string to number"
errors when database values are null or empty strings.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:30:13 -08:00
John Mizerek
5f3f5a7345 Enable address endpoints and switch Stripe to live mode
- Add address list/add/delete/setDefault to public endpoint allowlist
- Switch Stripe from test to live mode for production payments

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:26:14 -08:00
John Mizerek
c2ae037e71 App Store Version 2: Multi-menu support, beacon lookup, category scheduling
Features:
- Multi-menu support with time-based availability
- Menu hours validation against business operating hours
- Setup wizard now creates Menu records and links categories
- New menus.cfm API for menu CRUD operations
- Category schedule filtering (day/time based visibility)
- Beacon UUID lookup API for customer app
- Parent/child business relationships for franchises
- Category listing API for menu builder

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 19:51:44 -08:00
John Mizerek
72f5b7eb12 Filter out demo and hidden businesses from restaurant list
Businesses with BusinessIsDemo=1 or BusinessIsHidden=1 will not appear
in the nearby restaurants list. Hidden businesses will be searchable
in a future update.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 12:14:45 -08:00
56 changed files with 4743 additions and 310 deletions

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View file

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

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

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

View file

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

View file

@ -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
}));

View file

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

View file

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

View file

@ -11,7 +11,7 @@
*/
// Mode: "test" or "live"
stripeMode = "test";
stripeMode = "live";
// Test keys (safe to commit)
stripeTestSecretKey = "sk_test_LfbmDduJxTwbVZmvcByYmirw";

View file

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

View file

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

View file

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

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

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

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

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

View file

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

View file

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

View file

@ -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 {

View file

@ -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
})>

View file

@ -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;

View file

@ -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>

View file

@ -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
});

View file

@ -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>

View file

@ -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>

View file

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

View file

@ -30,6 +30,7 @@ try {
orderID = val(structKeyExists(data, "OrderID") ? data.OrderID : 0);
message = trim(structKeyExists(data, "Message") ? data.Message : "");
userID = val(structKeyExists(data, "UserID") ? data.UserID : 0);
taskTypeID = val(structKeyExists(data, "TaskTypeID") ? data.TaskTypeID : 0);
if (businessID == 0) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
@ -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" });

View file

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

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

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

View file

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

View file

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

View file

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

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

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

View file

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

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

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

View file

@ -24,14 +24,14 @@
<nav class="sidebar-nav">
<a href="#dashboard" class="nav-item active" data-page="dashboard">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>
</svg>
<span>Dashboard</span>
</a>
<a href="#orders" class="nav-item" data-page="orders">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"/>
<rect x="9" y="3" width="6" height="4" rx="1"/>
<path d="M9 12h6M9 16h6"/>
@ -39,19 +39,19 @@
<span>Orders</span>
</a>
<a href="menu-builder.html" class="nav-item">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M3 12h18M3 18h18"/>
</svg>
<span>Menu</span>
</a>
<a href="#reports" class="nav-item" data-page="reports">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 20V10M12 20V4M6 20v-6"/>
</svg>
<span>Reports</span>
</a>
<a href="#team" class="nav-item" data-page="team">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/>
@ -59,7 +59,7 @@
<span>Team</span>
</a>
<a href="#beacons" class="nav-item" data-page="beacons">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M12 2v4M12 18v4M2 12h4M18 12h4"/>
<path d="M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
@ -67,14 +67,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">

View file

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

View file

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

View file

@ -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');
}
}
};

View file

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