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>
This commit is contained in:
parent
43a8d18541
commit
ed3f9192d5
15 changed files with 1922 additions and 0 deletions
105
api/admin/quickTasks/create.cfm
Normal file
105
api/admin/quickTasks/create.cfm
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<cfsetting showdebugoutput="false">
|
||||
<cfsetting enablecfoutputonly="true">
|
||||
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||
|
||||
<cfscript>
|
||||
// Create a task from a quick task template (instant creation)
|
||||
// Input: BusinessID (required), QuickTaskTemplateID (required)
|
||||
// Output: { OK: true, TASK_ID: int }
|
||||
|
||||
function apiAbort(required struct payload) {
|
||||
writeOutput(serializeJSON(payload));
|
||||
abort;
|
||||
}
|
||||
|
||||
function readJsonBody() {
|
||||
var raw = getHttpRequestData().content;
|
||||
if (isNull(raw)) raw = "";
|
||||
if (!len(trim(raw))) return {};
|
||||
try {
|
||||
var data = deserializeJSON(raw);
|
||||
if (isStruct(data)) return data;
|
||||
} catch (any e) {}
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
data = readJsonBody();
|
||||
|
||||
// Get BusinessID
|
||||
businessID = 0;
|
||||
httpHeaders = getHttpRequestData().headers;
|
||||
|
||||
if (structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID)) {
|
||||
businessID = int(data.BusinessID);
|
||||
} else if (structKeyExists(httpHeaders, "X-Business-ID") && isNumeric(httpHeaders["X-Business-ID"])) {
|
||||
businessID = int(httpHeaders["X-Business-ID"]);
|
||||
}
|
||||
|
||||
if (businessID == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
|
||||
}
|
||||
|
||||
// Get template ID
|
||||
templateID = structKeyExists(data, "QuickTaskTemplateID") && isNumeric(data.QuickTaskTemplateID) ? int(data.QuickTaskTemplateID) : 0;
|
||||
|
||||
if (templateID == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "QuickTaskTemplateID is required" });
|
||||
}
|
||||
|
||||
// Get template details
|
||||
qTemplate = queryExecute("
|
||||
SELECT
|
||||
QuickTaskTemplateTitle as Title,
|
||||
QuickTaskTemplateDetails as Details,
|
||||
QuickTaskTemplateCategoryID as CategoryID,
|
||||
QuickTaskTemplateTypeID as TypeID
|
||||
FROM QuickTaskTemplates
|
||||
WHERE QuickTaskTemplateID = :id
|
||||
AND QuickTaskTemplateBusinessID = :businessID
|
||||
AND QuickTaskTemplateIsActive = 1
|
||||
", {
|
||||
id: { value: templateID, cfsqltype: "cf_sql_integer" },
|
||||
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
if (qTemplate.recordCount == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Template not found" });
|
||||
}
|
||||
|
||||
// Create the task
|
||||
queryExecute("
|
||||
INSERT INTO Tasks (
|
||||
TaskBusinessID, TaskCategoryID, TaskTypeID,
|
||||
TaskTitle, TaskDetails, TaskStatusID, TaskAddedOn,
|
||||
TaskSourceType, TaskSourceID
|
||||
) VALUES (
|
||||
:businessID, :categoryID, :typeID,
|
||||
:title, :details, 0, NOW(),
|
||||
'quicktask', :templateID
|
||||
)
|
||||
", {
|
||||
businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
|
||||
categoryID: { value: qTemplate.CategoryID, cfsqltype: "cf_sql_integer", null: isNull(qTemplate.CategoryID) },
|
||||
typeID: { value: qTemplate.TypeID, cfsqltype: "cf_sql_integer", null: isNull(qTemplate.TypeID) },
|
||||
title: { value: qTemplate.Title, cfsqltype: "cf_sql_varchar" },
|
||||
details: { value: qTemplate.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(qTemplate.Details) },
|
||||
templateID: { value: templateID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
|
||||
|
||||
apiAbort({
|
||||
"OK": true,
|
||||
"TASK_ID": qNew.newID,
|
||||
"MESSAGE": "Task created"
|
||||
});
|
||||
|
||||
} catch (any e) {
|
||||
apiAbort({
|
||||
"OK": false,
|
||||
"ERROR": "server_error",
|
||||
"MESSAGE": e.message
|
||||
});
|
||||
}
|
||||
</cfscript>
|
||||
83
api/admin/quickTasks/delete.cfm
Normal file
83
api/admin/quickTasks/delete.cfm
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<cfsetting showdebugoutput="false">
|
||||
<cfsetting enablecfoutputonly="true">
|
||||
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||
|
||||
<cfscript>
|
||||
// Delete (soft) a quick task template
|
||||
// Input: BusinessID (required), QuickTaskTemplateID (required)
|
||||
// Output: { OK: true }
|
||||
|
||||
function apiAbort(required struct payload) {
|
||||
writeOutput(serializeJSON(payload));
|
||||
abort;
|
||||
}
|
||||
|
||||
function readJsonBody() {
|
||||
var raw = getHttpRequestData().content;
|
||||
if (isNull(raw)) raw = "";
|
||||
if (!len(trim(raw))) return {};
|
||||
try {
|
||||
var data = deserializeJSON(raw);
|
||||
if (isStruct(data)) return data;
|
||||
} catch (any e) {}
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
data = readJsonBody();
|
||||
|
||||
// Get BusinessID
|
||||
businessID = 0;
|
||||
httpHeaders = getHttpRequestData().headers;
|
||||
|
||||
if (structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID)) {
|
||||
businessID = int(data.BusinessID);
|
||||
} else if (structKeyExists(httpHeaders, "X-Business-ID") && isNumeric(httpHeaders["X-Business-ID"])) {
|
||||
businessID = int(httpHeaders["X-Business-ID"]);
|
||||
}
|
||||
|
||||
if (businessID == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
|
||||
}
|
||||
|
||||
// Get template ID
|
||||
templateID = structKeyExists(data, "QuickTaskTemplateID") && isNumeric(data.QuickTaskTemplateID) ? int(data.QuickTaskTemplateID) : 0;
|
||||
|
||||
if (templateID == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "QuickTaskTemplateID is required" });
|
||||
}
|
||||
|
||||
// Verify template exists and belongs to this business
|
||||
qCheck = queryExecute("
|
||||
SELECT QuickTaskTemplateID FROM QuickTaskTemplates
|
||||
WHERE QuickTaskTemplateID = :id AND QuickTaskTemplateBusinessID = :businessID
|
||||
", {
|
||||
id: { value: templateID, cfsqltype: "cf_sql_integer" },
|
||||
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
if (qCheck.recordCount == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Template not found" });
|
||||
}
|
||||
|
||||
// Soft delete by setting IsActive to 0
|
||||
queryExecute("
|
||||
UPDATE QuickTaskTemplates SET QuickTaskTemplateIsActive = 0
|
||||
WHERE QuickTaskTemplateID = :id
|
||||
", {
|
||||
id: { value: templateID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
apiAbort({
|
||||
"OK": true,
|
||||
"MESSAGE": "Template deleted"
|
||||
});
|
||||
|
||||
} catch (any e) {
|
||||
apiAbort({
|
||||
"OK": false,
|
||||
"ERROR": "server_error",
|
||||
"MESSAGE": e.message
|
||||
});
|
||||
}
|
||||
</cfscript>
|
||||
100
api/admin/quickTasks/list.cfm
Normal file
100
api/admin/quickTasks/list.cfm
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<cfsetting showdebugoutput="false">
|
||||
<cfsetting enablecfoutputonly="true">
|
||||
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||
|
||||
<cfscript>
|
||||
// Returns quick task templates for a business
|
||||
// Input: BusinessID (required)
|
||||
// Output: { OK: true, TEMPLATES: [...] }
|
||||
|
||||
function apiAbort(required struct payload) {
|
||||
writeOutput(serializeJSON(payload));
|
||||
abort;
|
||||
}
|
||||
|
||||
function readJsonBody() {
|
||||
var raw = getHttpRequestData().content;
|
||||
if (isNull(raw)) raw = "";
|
||||
if (!len(trim(raw))) return {};
|
||||
try {
|
||||
var data = deserializeJSON(raw);
|
||||
if (isStruct(data)) return data;
|
||||
} catch (any e) {}
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
data = readJsonBody();
|
||||
|
||||
// Get BusinessID from body, header, or URL
|
||||
businessID = 0;
|
||||
httpHeaders = getHttpRequestData().headers;
|
||||
|
||||
if (structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID)) {
|
||||
businessID = int(data.BusinessID);
|
||||
} else if (structKeyExists(httpHeaders, "X-Business-ID") && isNumeric(httpHeaders["X-Business-ID"])) {
|
||||
businessID = int(httpHeaders["X-Business-ID"]);
|
||||
} else if (structKeyExists(url, "BusinessID") && isNumeric(url.BusinessID)) {
|
||||
businessID = int(url.BusinessID);
|
||||
}
|
||||
|
||||
if (businessID == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
|
||||
}
|
||||
|
||||
// Get quick task templates for this business
|
||||
q = queryExecute("
|
||||
SELECT
|
||||
qt.QuickTaskTemplateID,
|
||||
qt.QuickTaskTemplateName as Name,
|
||||
qt.QuickTaskTemplateCategoryID as CategoryID,
|
||||
qt.QuickTaskTemplateTypeID as TypeID,
|
||||
qt.QuickTaskTemplateTitle as Title,
|
||||
qt.QuickTaskTemplateDetails as Details,
|
||||
qt.QuickTaskTemplateIcon as Icon,
|
||||
qt.QuickTaskTemplateColor as Color,
|
||||
qt.QuickTaskTemplateSortOrder as SortOrder,
|
||||
qt.QuickTaskTemplateIsActive as IsActive,
|
||||
tc.TaskCategoryName as CategoryName,
|
||||
tc.TaskCategoryColor as CategoryColor
|
||||
FROM QuickTaskTemplates qt
|
||||
LEFT JOIN TaskCategories tc ON qt.QuickTaskTemplateCategoryID = tc.TaskCategoryID
|
||||
WHERE qt.QuickTaskTemplateBusinessID = :businessID
|
||||
AND qt.QuickTaskTemplateIsActive = 1
|
||||
ORDER BY qt.QuickTaskTemplateSortOrder, qt.QuickTaskTemplateID
|
||||
", {
|
||||
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
templates = [];
|
||||
for (row in q) {
|
||||
arrayAppend(templates, {
|
||||
"QuickTaskTemplateID": row.QuickTaskTemplateID,
|
||||
"Name": row.Name,
|
||||
"CategoryID": isNull(row.CategoryID) ? "" : row.CategoryID,
|
||||
"TypeID": isNull(row.TypeID) ? "" : row.TypeID,
|
||||
"Title": row.Title,
|
||||
"Details": isNull(row.Details) ? "" : row.Details,
|
||||
"Icon": isNull(row.Icon) ? "add_box" : row.Icon,
|
||||
"Color": isNull(row.Color) ? "##6366f1" : row.Color,
|
||||
"SortOrder": row.SortOrder,
|
||||
"IsActive": row.IsActive,
|
||||
"CategoryName": isNull(row.CategoryName) ? "" : row.CategoryName,
|
||||
"CategoryColor": isNull(row.CategoryColor) ? "" : row.CategoryColor
|
||||
});
|
||||
}
|
||||
|
||||
apiAbort({
|
||||
"OK": true,
|
||||
"TEMPLATES": templates,
|
||||
"COUNT": arrayLen(templates)
|
||||
});
|
||||
|
||||
} catch (any e) {
|
||||
apiAbort({
|
||||
"OK": false,
|
||||
"ERROR": "server_error",
|
||||
"MESSAGE": e.message
|
||||
});
|
||||
}
|
||||
</cfscript>
|
||||
151
api/admin/quickTasks/save.cfm
Normal file
151
api/admin/quickTasks/save.cfm
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
<cfsetting showdebugoutput="false">
|
||||
<cfsetting enablecfoutputonly="true">
|
||||
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||
|
||||
<cfscript>
|
||||
// Create or update a quick task template
|
||||
// Input: BusinessID (required), QuickTaskTemplateID (optional - for update),
|
||||
// Name, Title, Details, CategoryID, TypeID, Icon, Color
|
||||
// Output: { OK: true, TEMPLATE_ID: int }
|
||||
|
||||
function apiAbort(required struct payload) {
|
||||
writeOutput(serializeJSON(payload));
|
||||
abort;
|
||||
}
|
||||
|
||||
function readJsonBody() {
|
||||
var raw = getHttpRequestData().content;
|
||||
if (isNull(raw)) raw = "";
|
||||
if (!len(trim(raw))) return {};
|
||||
try {
|
||||
var data = deserializeJSON(raw);
|
||||
if (isStruct(data)) return data;
|
||||
} catch (any e) {}
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
data = readJsonBody();
|
||||
|
||||
// Get BusinessID
|
||||
businessID = 0;
|
||||
httpHeaders = getHttpRequestData().headers;
|
||||
|
||||
if (structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID)) {
|
||||
businessID = int(data.BusinessID);
|
||||
} else if (structKeyExists(httpHeaders, "X-Business-ID") && isNumeric(httpHeaders["X-Business-ID"])) {
|
||||
businessID = int(httpHeaders["X-Business-ID"]);
|
||||
}
|
||||
|
||||
if (businessID == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
|
||||
}
|
||||
|
||||
// Get fields
|
||||
templateID = structKeyExists(data, "QuickTaskTemplateID") && isNumeric(data.QuickTaskTemplateID) ? int(data.QuickTaskTemplateID) : 0;
|
||||
templateName = structKeyExists(data, "Name") ? trim(toString(data.Name)) : "";
|
||||
templateTitle = structKeyExists(data, "Title") ? trim(toString(data.Title)) : "";
|
||||
templateDetails = structKeyExists(data, "Details") ? trim(toString(data.Details)) : "";
|
||||
categoryID = structKeyExists(data, "CategoryID") && isNumeric(data.CategoryID) && data.CategoryID > 0 ? int(data.CategoryID) : javaCast("null", "");
|
||||
typeID = structKeyExists(data, "TypeID") && isNumeric(data.TypeID) && data.TypeID > 0 ? int(data.TypeID) : javaCast("null", "");
|
||||
templateIcon = structKeyExists(data, "Icon") && len(trim(data.Icon)) ? trim(toString(data.Icon)) : "add_box";
|
||||
templateColor = structKeyExists(data, "Color") && len(trim(data.Color)) ? trim(toString(data.Color)) : "##6366f1";
|
||||
|
||||
// Validate required fields
|
||||
if (!len(templateName)) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Name is required" });
|
||||
}
|
||||
if (!len(templateTitle)) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Title is required" });
|
||||
}
|
||||
|
||||
if (templateID > 0) {
|
||||
// UPDATE existing template
|
||||
qCheck = queryExecute("
|
||||
SELECT QuickTaskTemplateID FROM QuickTaskTemplates
|
||||
WHERE QuickTaskTemplateID = :id AND QuickTaskTemplateBusinessID = :businessID
|
||||
", {
|
||||
id: { value: templateID, cfsqltype: "cf_sql_integer" },
|
||||
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
if (qCheck.recordCount == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Template not found" });
|
||||
}
|
||||
|
||||
queryExecute("
|
||||
UPDATE QuickTaskTemplates SET
|
||||
QuickTaskTemplateName = :name,
|
||||
QuickTaskTemplateTitle = :title,
|
||||
QuickTaskTemplateDetails = :details,
|
||||
QuickTaskTemplateCategoryID = :categoryID,
|
||||
QuickTaskTemplateTypeID = :typeID,
|
||||
QuickTaskTemplateIcon = :icon,
|
||||
QuickTaskTemplateColor = :color
|
||||
WHERE QuickTaskTemplateID = :id
|
||||
", {
|
||||
name: { value: templateName, cfsqltype: "cf_sql_varchar" },
|
||||
title: { value: templateTitle, cfsqltype: "cf_sql_varchar" },
|
||||
details: { value: templateDetails, cfsqltype: "cf_sql_longvarchar", null: !len(templateDetails) },
|
||||
categoryID: { value: categoryID, cfsqltype: "cf_sql_integer", null: isNull(categoryID) },
|
||||
typeID: { value: typeID, cfsqltype: "cf_sql_integer", null: isNull(typeID) },
|
||||
icon: { value: templateIcon, cfsqltype: "cf_sql_varchar" },
|
||||
color: { value: templateColor, cfsqltype: "cf_sql_varchar" },
|
||||
id: { value: templateID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
apiAbort({
|
||||
"OK": true,
|
||||
"TEMPLATE_ID": templateID,
|
||||
"MESSAGE": "Template updated"
|
||||
});
|
||||
|
||||
} else {
|
||||
// INSERT new template
|
||||
// Get next sort order
|
||||
qSort = queryExecute("
|
||||
SELECT COALESCE(MAX(QuickTaskTemplateSortOrder), 0) + 1 as nextSort
|
||||
FROM QuickTaskTemplates WHERE QuickTaskTemplateBusinessID = :businessID
|
||||
", {
|
||||
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
nextSort = qSort.nextSort;
|
||||
|
||||
queryExecute("
|
||||
INSERT INTO QuickTaskTemplates (
|
||||
QuickTaskTemplateBusinessID, QuickTaskTemplateName, QuickTaskTemplateTitle,
|
||||
QuickTaskTemplateDetails, QuickTaskTemplateCategoryID, QuickTaskTemplateTypeID,
|
||||
QuickTaskTemplateIcon, QuickTaskTemplateColor, QuickTaskTemplateSortOrder
|
||||
) VALUES (
|
||||
:businessID, :name, :title, :details, :categoryID, :typeID, :icon, :color, :sortOrder
|
||||
)
|
||||
", {
|
||||
businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
|
||||
name: { value: templateName, cfsqltype: "cf_sql_varchar" },
|
||||
title: { value: templateTitle, cfsqltype: "cf_sql_varchar" },
|
||||
details: { value: templateDetails, cfsqltype: "cf_sql_longvarchar", null: !len(templateDetails) },
|
||||
categoryID: { value: categoryID, cfsqltype: "cf_sql_integer", null: isNull(categoryID) },
|
||||
typeID: { value: typeID, cfsqltype: "cf_sql_integer", null: isNull(typeID) },
|
||||
icon: { value: templateIcon, cfsqltype: "cf_sql_varchar" },
|
||||
color: { value: templateColor, cfsqltype: "cf_sql_varchar" },
|
||||
sortOrder: { value: nextSort, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
|
||||
|
||||
apiAbort({
|
||||
"OK": true,
|
||||
"TEMPLATE_ID": qNew.newID,
|
||||
"MESSAGE": "Template created"
|
||||
});
|
||||
}
|
||||
|
||||
} catch (any e) {
|
||||
apiAbort({
|
||||
"OK": false,
|
||||
"ERROR": "server_error",
|
||||
"MESSAGE": e.message
|
||||
});
|
||||
}
|
||||
</cfscript>
|
||||
47
api/admin/quickTasks/setup.cfm
Normal file
47
api/admin/quickTasks/setup.cfm
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<cfsetting showdebugoutput="false">
|
||||
<cfsetting enablecfoutputonly="true">
|
||||
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||
|
||||
<cfscript>
|
||||
// Creates QuickTaskTemplates table if not exists
|
||||
// Public endpoint for setup
|
||||
|
||||
function apiAbort(required struct payload) {
|
||||
writeOutput(serializeJSON(payload));
|
||||
abort;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create QuickTaskTemplates table
|
||||
queryExecute("
|
||||
CREATE TABLE IF NOT EXISTS QuickTaskTemplates (
|
||||
QuickTaskTemplateID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
QuickTaskTemplateBusinessID INT NOT NULL,
|
||||
QuickTaskTemplateName VARCHAR(100) NOT NULL,
|
||||
QuickTaskTemplateCategoryID INT NULL,
|
||||
QuickTaskTemplateTypeID INT NULL,
|
||||
QuickTaskTemplateTitle VARCHAR(255) NOT NULL,
|
||||
QuickTaskTemplateDetails TEXT NULL,
|
||||
QuickTaskTemplateIcon VARCHAR(30) DEFAULT 'add_box',
|
||||
QuickTaskTemplateColor VARCHAR(20) DEFAULT '##6366f1',
|
||||
QuickTaskTemplateSortOrder INT DEFAULT 0,
|
||||
QuickTaskTemplateIsActive BIT(1) DEFAULT b'1',
|
||||
QuickTaskTemplateCreatedOn DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_business_active (QuickTaskTemplateBusinessID, QuickTaskTemplateIsActive),
|
||||
INDEX idx_sort (QuickTaskTemplateBusinessID, QuickTaskTemplateSortOrder)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
", [], { datasource: "payfrit" });
|
||||
|
||||
apiAbort({
|
||||
"OK": true,
|
||||
"MESSAGE": "QuickTaskTemplates table created/verified"
|
||||
});
|
||||
|
||||
} catch (any e) {
|
||||
apiAbort({
|
||||
"OK": false,
|
||||
"ERROR": "server_error",
|
||||
"MESSAGE": e.message
|
||||
});
|
||||
}
|
||||
</cfscript>
|
||||
82
api/admin/scheduledTasks/delete.cfm
Normal file
82
api/admin/scheduledTasks/delete.cfm
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<cfsetting showdebugoutput="false">
|
||||
<cfsetting enablecfoutputonly="true">
|
||||
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||
|
||||
<cfscript>
|
||||
// Delete a scheduled task definition (hard delete since it's just a definition)
|
||||
// Input: BusinessID (required), ScheduledTaskID (required)
|
||||
// Output: { OK: true }
|
||||
|
||||
function apiAbort(required struct payload) {
|
||||
writeOutput(serializeJSON(payload));
|
||||
abort;
|
||||
}
|
||||
|
||||
function readJsonBody() {
|
||||
var raw = getHttpRequestData().content;
|
||||
if (isNull(raw)) raw = "";
|
||||
if (!len(trim(raw))) return {};
|
||||
try {
|
||||
var data = deserializeJSON(raw);
|
||||
if (isStruct(data)) return data;
|
||||
} catch (any e) {}
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
data = readJsonBody();
|
||||
|
||||
// Get BusinessID
|
||||
businessID = 0;
|
||||
httpHeaders = getHttpRequestData().headers;
|
||||
|
||||
if (structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID)) {
|
||||
businessID = int(data.BusinessID);
|
||||
} else if (structKeyExists(httpHeaders, "X-Business-ID") && isNumeric(httpHeaders["X-Business-ID"])) {
|
||||
businessID = int(httpHeaders["X-Business-ID"]);
|
||||
}
|
||||
|
||||
if (businessID == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
|
||||
}
|
||||
|
||||
// Get task ID
|
||||
taskID = structKeyExists(data, "ScheduledTaskID") && isNumeric(data.ScheduledTaskID) ? int(data.ScheduledTaskID) : 0;
|
||||
|
||||
if (taskID == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "ScheduledTaskID is required" });
|
||||
}
|
||||
|
||||
// Verify exists and belongs to business
|
||||
qCheck = queryExecute("
|
||||
SELECT ScheduledTaskID FROM ScheduledTaskDefinitions
|
||||
WHERE ScheduledTaskID = :id AND ScheduledTaskBusinessID = :businessID
|
||||
", {
|
||||
id: { value: taskID, cfsqltype: "cf_sql_integer" },
|
||||
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
if (qCheck.recordCount == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Scheduled task not found" });
|
||||
}
|
||||
|
||||
// Hard delete the definition
|
||||
queryExecute("
|
||||
DELETE FROM ScheduledTaskDefinitions WHERE ScheduledTaskID = :id
|
||||
", {
|
||||
id: { value: taskID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
apiAbort({
|
||||
"OK": true,
|
||||
"MESSAGE": "Scheduled task deleted"
|
||||
});
|
||||
|
||||
} catch (any e) {
|
||||
apiAbort({
|
||||
"OK": false,
|
||||
"ERROR": "server_error",
|
||||
"MESSAGE": e.message
|
||||
});
|
||||
}
|
||||
</cfscript>
|
||||
101
api/admin/scheduledTasks/list.cfm
Normal file
101
api/admin/scheduledTasks/list.cfm
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<cfsetting showdebugoutput="false">
|
||||
<cfsetting enablecfoutputonly="true">
|
||||
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||
|
||||
<cfscript>
|
||||
// Returns scheduled task definitions for a business
|
||||
// Input: BusinessID (required)
|
||||
// Output: { OK: true, SCHEDULED_TASKS: [...] }
|
||||
|
||||
function apiAbort(required struct payload) {
|
||||
writeOutput(serializeJSON(payload));
|
||||
abort;
|
||||
}
|
||||
|
||||
function readJsonBody() {
|
||||
var raw = getHttpRequestData().content;
|
||||
if (isNull(raw)) raw = "";
|
||||
if (!len(trim(raw))) return {};
|
||||
try {
|
||||
var data = deserializeJSON(raw);
|
||||
if (isStruct(data)) return data;
|
||||
} catch (any e) {}
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
data = readJsonBody();
|
||||
|
||||
// Get BusinessID from body, header, or URL
|
||||
businessID = 0;
|
||||
httpHeaders = getHttpRequestData().headers;
|
||||
|
||||
if (structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID)) {
|
||||
businessID = int(data.BusinessID);
|
||||
} else if (structKeyExists(httpHeaders, "X-Business-ID") && isNumeric(httpHeaders["X-Business-ID"])) {
|
||||
businessID = int(httpHeaders["X-Business-ID"]);
|
||||
} else if (structKeyExists(url, "BusinessID") && isNumeric(url.BusinessID)) {
|
||||
businessID = int(url.BusinessID);
|
||||
}
|
||||
|
||||
if (businessID == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
|
||||
}
|
||||
|
||||
// Get scheduled task definitions for this business
|
||||
q = queryExecute("
|
||||
SELECT
|
||||
st.ScheduledTaskID,
|
||||
st.ScheduledTaskName as Name,
|
||||
st.ScheduledTaskCategoryID as CategoryID,
|
||||
st.ScheduledTaskTypeID as TypeID,
|
||||
st.ScheduledTaskTitle as Title,
|
||||
st.ScheduledTaskDetails as Details,
|
||||
st.ScheduledTaskCronExpression as CronExpression,
|
||||
st.ScheduledTaskIsActive as IsActive,
|
||||
st.ScheduledTaskLastRunOn as LastRunOn,
|
||||
st.ScheduledTaskNextRunOn as NextRunOn,
|
||||
st.ScheduledTaskCreatedOn as CreatedOn,
|
||||
tc.TaskCategoryName as CategoryName,
|
||||
tc.TaskCategoryColor as CategoryColor
|
||||
FROM ScheduledTaskDefinitions st
|
||||
LEFT JOIN TaskCategories tc ON st.ScheduledTaskCategoryID = tc.TaskCategoryID
|
||||
WHERE st.ScheduledTaskBusinessID = :businessID
|
||||
ORDER BY st.ScheduledTaskIsActive DESC, st.ScheduledTaskName
|
||||
", {
|
||||
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
scheduledTasks = [];
|
||||
for (row in q) {
|
||||
arrayAppend(scheduledTasks, {
|
||||
"ScheduledTaskID": row.ScheduledTaskID,
|
||||
"Name": row.Name,
|
||||
"CategoryID": isNull(row.CategoryID) ? "" : row.CategoryID,
|
||||
"TypeID": isNull(row.TypeID) ? "" : row.TypeID,
|
||||
"Title": row.Title,
|
||||
"Details": isNull(row.Details) ? "" : row.Details,
|
||||
"CronExpression": row.CronExpression,
|
||||
"IsActive": row.IsActive ? true : false,
|
||||
"LastRunOn": isNull(row.LastRunOn) ? "" : dateTimeFormat(row.LastRunOn, "yyyy-mm-dd HH:nn:ss"),
|
||||
"NextRunOn": isNull(row.NextRunOn) ? "" : dateTimeFormat(row.NextRunOn, "yyyy-mm-dd HH:nn:ss"),
|
||||
"CreatedOn": dateTimeFormat(row.CreatedOn, "yyyy-mm-dd HH:nn:ss"),
|
||||
"CategoryName": isNull(row.CategoryName) ? "" : row.CategoryName,
|
||||
"CategoryColor": isNull(row.CategoryColor) ? "" : row.CategoryColor
|
||||
});
|
||||
}
|
||||
|
||||
apiAbort({
|
||||
"OK": true,
|
||||
"SCHEDULED_TASKS": scheduledTasks,
|
||||
"COUNT": arrayLen(scheduledTasks)
|
||||
});
|
||||
|
||||
} catch (any e) {
|
||||
apiAbort({
|
||||
"OK": false,
|
||||
"ERROR": "server_error",
|
||||
"MESSAGE": e.message
|
||||
});
|
||||
}
|
||||
</cfscript>
|
||||
104
api/admin/scheduledTasks/run.cfm
Normal file
104
api/admin/scheduledTasks/run.cfm
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<cfsetting showdebugoutput="false">
|
||||
<cfsetting enablecfoutputonly="true">
|
||||
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||
|
||||
<cfscript>
|
||||
// Manually trigger a scheduled task (for testing)
|
||||
// Creates a task from the scheduled task definition without updating next run time
|
||||
// Input: BusinessID (required), ScheduledTaskID (required)
|
||||
// Output: { OK: true, TASK_ID: int }
|
||||
|
||||
function apiAbort(required struct payload) {
|
||||
writeOutput(serializeJSON(payload));
|
||||
abort;
|
||||
}
|
||||
|
||||
function readJsonBody() {
|
||||
var raw = getHttpRequestData().content;
|
||||
if (isNull(raw)) raw = "";
|
||||
if (!len(trim(raw))) return {};
|
||||
try {
|
||||
var data = deserializeJSON(raw);
|
||||
if (isStruct(data)) return data;
|
||||
} catch (any e) {}
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
data = readJsonBody();
|
||||
|
||||
// Get BusinessID
|
||||
businessID = 0;
|
||||
httpHeaders = getHttpRequestData().headers;
|
||||
|
||||
if (structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID)) {
|
||||
businessID = int(data.BusinessID);
|
||||
} else if (structKeyExists(httpHeaders, "X-Business-ID") && isNumeric(httpHeaders["X-Business-ID"])) {
|
||||
businessID = int(httpHeaders["X-Business-ID"]);
|
||||
}
|
||||
|
||||
if (businessID == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
|
||||
}
|
||||
|
||||
// Get scheduled task ID
|
||||
scheduledTaskID = structKeyExists(data, "ScheduledTaskID") && isNumeric(data.ScheduledTaskID) ? int(data.ScheduledTaskID) : 0;
|
||||
|
||||
if (scheduledTaskID == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "ScheduledTaskID is required" });
|
||||
}
|
||||
|
||||
// Get scheduled task definition
|
||||
qDef = queryExecute("
|
||||
SELECT
|
||||
ScheduledTaskTitle as Title,
|
||||
ScheduledTaskDetails as Details,
|
||||
ScheduledTaskCategoryID as CategoryID,
|
||||
ScheduledTaskTypeID as TypeID
|
||||
FROM ScheduledTaskDefinitions
|
||||
WHERE ScheduledTaskID = :id AND ScheduledTaskBusinessID = :businessID
|
||||
", {
|
||||
id: { value: scheduledTaskID, cfsqltype: "cf_sql_integer" },
|
||||
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
if (qDef.recordCount == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Scheduled task not found" });
|
||||
}
|
||||
|
||||
// Create the task
|
||||
queryExecute("
|
||||
INSERT INTO Tasks (
|
||||
TaskBusinessID, TaskCategoryID, TaskTypeID,
|
||||
TaskTitle, TaskDetails, TaskStatusID, TaskAddedOn,
|
||||
TaskSourceType, TaskSourceID
|
||||
) VALUES (
|
||||
:businessID, :categoryID, :typeID,
|
||||
:title, :details, 0, NOW(),
|
||||
'scheduled_manual', :scheduledTaskID
|
||||
)
|
||||
", {
|
||||
businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
|
||||
categoryID: { value: qDef.CategoryID, cfsqltype: "cf_sql_integer", null: isNull(qDef.CategoryID) },
|
||||
typeID: { value: qDef.TypeID, cfsqltype: "cf_sql_integer", null: isNull(qDef.TypeID) },
|
||||
title: { value: qDef.Title, cfsqltype: "cf_sql_varchar" },
|
||||
details: { value: qDef.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(qDef.Details) },
|
||||
scheduledTaskID: { value: scheduledTaskID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
|
||||
|
||||
apiAbort({
|
||||
"OK": true,
|
||||
"TASK_ID": qNew.newID,
|
||||
"MESSAGE": "Task created from scheduled task (manual trigger)"
|
||||
});
|
||||
|
||||
} catch (any e) {
|
||||
apiAbort({
|
||||
"OK": false,
|
||||
"ERROR": "server_error",
|
||||
"MESSAGE": e.message
|
||||
});
|
||||
}
|
||||
</cfscript>
|
||||
143
api/admin/scheduledTasks/runDue.cfm
Normal file
143
api/admin/scheduledTasks/runDue.cfm
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
<cfsetting showdebugoutput="false" requesttimeout="60">
|
||||
<cfsetting enablecfoutputonly="true">
|
||||
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||
|
||||
<cfscript>
|
||||
// Process all due scheduled tasks
|
||||
// Called by cron every minute (or manually for testing)
|
||||
// Creates Tasks entries for any ScheduledTaskDefinitions that are due
|
||||
// Public endpoint - no auth required (should be restricted by IP in production)
|
||||
|
||||
function apiAbort(required struct payload) {
|
||||
writeOutput(serializeJSON(payload));
|
||||
abort;
|
||||
}
|
||||
|
||||
// Calculate next run time from cron expression
|
||||
function calculateNextRun(required string cronExpression) {
|
||||
var parts = listToArray(cronExpression, " ");
|
||||
if (arrayLen(parts) != 5) {
|
||||
return dateAdd("d", 1, now());
|
||||
}
|
||||
|
||||
var cronMinute = parts[1];
|
||||
var cronHour = parts[2];
|
||||
var cronDay = parts[3];
|
||||
var cronMonth = parts[4];
|
||||
var cronWeekday = parts[5];
|
||||
|
||||
// Start from current time + 1 minute
|
||||
var checkDate = dateAdd("n", 1, now());
|
||||
checkDate = createDateTime(year(checkDate), month(checkDate), day(checkDate), hour(checkDate), minute(checkDate), 0);
|
||||
|
||||
var maxIterations = 400 * 24 * 60; // 400 days in minutes
|
||||
var iterations = 0;
|
||||
|
||||
while (iterations < maxIterations) {
|
||||
var matchMinute = (cronMinute == "*" || (isNumeric(cronMinute) && minute(checkDate) == int(cronMinute)));
|
||||
var matchHour = (cronHour == "*" || (isNumeric(cronHour) && hour(checkDate) == int(cronHour)));
|
||||
var matchDay = (cronDay == "*" || (isNumeric(cronDay) && day(checkDate) == int(cronDay)));
|
||||
var matchMonth = (cronMonth == "*" || (isNumeric(cronMonth) && month(checkDate) == int(cronMonth)));
|
||||
|
||||
var dow = dayOfWeek(checkDate) - 1; // Convert to 0-based (0=Sunday)
|
||||
var matchWeekday = (cronWeekday == "*");
|
||||
if (!matchWeekday) {
|
||||
if (find("-", cronWeekday)) {
|
||||
var range = listToArray(cronWeekday, "-");
|
||||
if (arrayLen(range) == 2 && isNumeric(range[1]) && isNumeric(range[2])) {
|
||||
matchWeekday = (dow >= int(range[1]) && dow <= int(range[2]));
|
||||
}
|
||||
} else if (isNumeric(cronWeekday)) {
|
||||
matchWeekday = (dow == int(cronWeekday));
|
||||
}
|
||||
}
|
||||
|
||||
if (matchMinute && matchHour && matchDay && matchMonth && matchWeekday) {
|
||||
return checkDate;
|
||||
}
|
||||
|
||||
checkDate = dateAdd("n", 1, checkDate);
|
||||
iterations++;
|
||||
}
|
||||
|
||||
return dateAdd("d", 1, now());
|
||||
}
|
||||
|
||||
try {
|
||||
// Find all active scheduled tasks that are due
|
||||
dueTasks = queryExecute("
|
||||
SELECT
|
||||
ScheduledTaskID,
|
||||
ScheduledTaskBusinessID as BusinessID,
|
||||
ScheduledTaskCategoryID as CategoryID,
|
||||
ScheduledTaskTypeID as TypeID,
|
||||
ScheduledTaskTitle as Title,
|
||||
ScheduledTaskDetails as Details,
|
||||
ScheduledTaskCronExpression as CronExpression
|
||||
FROM ScheduledTaskDefinitions
|
||||
WHERE ScheduledTaskIsActive = 1
|
||||
AND ScheduledTaskNextRunOn <= NOW()
|
||||
", {}, { datasource: "payfrit" });
|
||||
|
||||
createdTasks = [];
|
||||
|
||||
for (task in dueTasks) {
|
||||
// Create the actual task
|
||||
queryExecute("
|
||||
INSERT INTO Tasks (
|
||||
TaskBusinessID, TaskCategoryID, TaskTypeID,
|
||||
TaskTitle, TaskDetails, TaskStatusID, TaskAddedOn,
|
||||
TaskSourceType, TaskSourceID
|
||||
) VALUES (
|
||||
:businessID, :categoryID, :typeID,
|
||||
:title, :details, 0, NOW(),
|
||||
'scheduled', :scheduledTaskID
|
||||
)
|
||||
", {
|
||||
businessID: { value: task.BusinessID, cfsqltype: "cf_sql_integer" },
|
||||
categoryID: { value: task.CategoryID, cfsqltype: "cf_sql_integer", null: isNull(task.CategoryID) },
|
||||
typeID: { value: task.TypeID, cfsqltype: "cf_sql_integer", null: isNull(task.TypeID) },
|
||||
title: { value: task.Title, cfsqltype: "cf_sql_varchar" },
|
||||
details: { value: task.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(task.Details) },
|
||||
scheduledTaskID: { value: task.ScheduledTaskID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
|
||||
|
||||
// Calculate next run and update the scheduled task
|
||||
nextRun = calculateNextRun(task.CronExpression);
|
||||
|
||||
queryExecute("
|
||||
UPDATE ScheduledTaskDefinitions SET
|
||||
ScheduledTaskLastRunOn = NOW(),
|
||||
ScheduledTaskNextRunOn = :nextRun
|
||||
WHERE ScheduledTaskID = :id
|
||||
", {
|
||||
nextRun: { value: nextRun, cfsqltype: "cf_sql_timestamp" },
|
||||
id: { value: task.ScheduledTaskID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
arrayAppend(createdTasks, {
|
||||
"ScheduledTaskID": task.ScheduledTaskID,
|
||||
"TaskID": qNew.newID,
|
||||
"BusinessID": task.BusinessID,
|
||||
"Title": task.Title
|
||||
});
|
||||
}
|
||||
|
||||
apiAbort({
|
||||
"OK": true,
|
||||
"MESSAGE": "Processed #arrayLen(createdTasks)# scheduled task(s)",
|
||||
"CREATED_TASKS": createdTasks,
|
||||
"CHECKED_COUNT": dueTasks.recordCount,
|
||||
"RAN_AT": dateTimeFormat(now(), "yyyy-mm-dd HH:nn:ss")
|
||||
});
|
||||
|
||||
} catch (any e) {
|
||||
apiAbort({
|
||||
"OK": false,
|
||||
"ERROR": "server_error",
|
||||
"MESSAGE": e.message
|
||||
});
|
||||
}
|
||||
</cfscript>
|
||||
218
api/admin/scheduledTasks/save.cfm
Normal file
218
api/admin/scheduledTasks/save.cfm
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
<cfsetting showdebugoutput="false">
|
||||
<cfsetting enablecfoutputonly="true">
|
||||
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||
|
||||
<cfscript>
|
||||
// Create or update a scheduled task definition
|
||||
// Input: BusinessID (required), ScheduledTaskID (optional - for update),
|
||||
// Name, Title, Details, CategoryID, TypeID, CronExpression, IsActive
|
||||
// Output: { OK: true, SCHEDULED_TASK_ID: int }
|
||||
|
||||
function apiAbort(required struct payload) {
|
||||
writeOutput(serializeJSON(payload));
|
||||
abort;
|
||||
}
|
||||
|
||||
function readJsonBody() {
|
||||
var raw = getHttpRequestData().content;
|
||||
if (isNull(raw)) raw = "";
|
||||
if (!len(trim(raw))) return {};
|
||||
try {
|
||||
var data = deserializeJSON(raw);
|
||||
if (isStruct(data)) return data;
|
||||
} catch (any e) {}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Parse cron expression and calculate next run time
|
||||
// Supports: minute hour day month weekday
|
||||
// Examples: "0 9 * * *" (daily 9am), "0 9 * * 1-5" (weekdays 9am), "30 14 * * *" (daily 2:30pm)
|
||||
function calculateNextRun(required string cronExpression) {
|
||||
var parts = listToArray(cronExpression, " ");
|
||||
if (arrayLen(parts) != 5) {
|
||||
return dateAdd("d", 1, now()); // Fallback: tomorrow
|
||||
}
|
||||
|
||||
var cronMinute = parts[1];
|
||||
var cronHour = parts[2];
|
||||
var cronDay = parts[3];
|
||||
var cronMonth = parts[4];
|
||||
var cronWeekday = parts[5];
|
||||
|
||||
// Start from next minute
|
||||
var checkDate = dateAdd("n", 1, now());
|
||||
checkDate = createDateTime(year(checkDate), month(checkDate), day(checkDate), hour(checkDate), minute(checkDate), 0);
|
||||
|
||||
// Check up to 400 days ahead (handles yearly schedules)
|
||||
var maxIterations = 400 * 24 * 60; // 400 days in minutes
|
||||
var iterations = 0;
|
||||
|
||||
while (iterations < maxIterations) {
|
||||
var matchMinute = (cronMinute == "*" || cronMinute == minute(checkDate) ||
|
||||
(isNumeric(cronMinute) && minute(checkDate) == int(cronMinute)));
|
||||
var matchHour = (cronHour == "*" || cronHour == hour(checkDate) ||
|
||||
(isNumeric(cronHour) && hour(checkDate) == int(cronHour)));
|
||||
var matchDay = (cronDay == "*" || cronDay == day(checkDate) ||
|
||||
(isNumeric(cronDay) && day(checkDate) == int(cronDay)));
|
||||
var matchMonth = (cronMonth == "*" || cronMonth == month(checkDate) ||
|
||||
(isNumeric(cronMonth) && month(checkDate) == int(cronMonth)));
|
||||
|
||||
// Weekday: 0=Sunday, 1=Monday, ... 6=Saturday (Lucee dayOfWeek: 1=Sunday)
|
||||
var dow = dayOfWeek(checkDate) - 1; // Convert to 0-based
|
||||
var matchWeekday = (cronWeekday == "*");
|
||||
if (!matchWeekday) {
|
||||
// Handle ranges like 1-5 (Mon-Fri)
|
||||
if (find("-", cronWeekday)) {
|
||||
var range = listToArray(cronWeekday, "-");
|
||||
if (arrayLen(range) == 2 && isNumeric(range[1]) && isNumeric(range[2])) {
|
||||
matchWeekday = (dow >= int(range[1]) && dow <= int(range[2]));
|
||||
}
|
||||
} else if (isNumeric(cronWeekday)) {
|
||||
matchWeekday = (dow == int(cronWeekday));
|
||||
}
|
||||
}
|
||||
|
||||
if (matchMinute && matchHour && matchDay && matchMonth && matchWeekday) {
|
||||
return checkDate;
|
||||
}
|
||||
|
||||
// Move to next minute
|
||||
checkDate = dateAdd("n", 1, checkDate);
|
||||
iterations++;
|
||||
}
|
||||
|
||||
// Fallback if no match found
|
||||
return dateAdd("d", 1, now());
|
||||
}
|
||||
|
||||
try {
|
||||
data = readJsonBody();
|
||||
|
||||
// Get BusinessID
|
||||
businessID = 0;
|
||||
httpHeaders = getHttpRequestData().headers;
|
||||
|
||||
if (structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID)) {
|
||||
businessID = int(data.BusinessID);
|
||||
} else if (structKeyExists(httpHeaders, "X-Business-ID") && isNumeric(httpHeaders["X-Business-ID"])) {
|
||||
businessID = int(httpHeaders["X-Business-ID"]);
|
||||
}
|
||||
|
||||
if (businessID == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
|
||||
}
|
||||
|
||||
// Get fields
|
||||
taskID = structKeyExists(data, "ScheduledTaskID") && isNumeric(data.ScheduledTaskID) ? int(data.ScheduledTaskID) : 0;
|
||||
taskName = structKeyExists(data, "Name") ? trim(toString(data.Name)) : "";
|
||||
taskTitle = structKeyExists(data, "Title") ? trim(toString(data.Title)) : "";
|
||||
taskDetails = structKeyExists(data, "Details") ? trim(toString(data.Details)) : "";
|
||||
categoryID = structKeyExists(data, "CategoryID") && isNumeric(data.CategoryID) && data.CategoryID > 0 ? int(data.CategoryID) : javaCast("null", "");
|
||||
typeID = structKeyExists(data, "TypeID") && isNumeric(data.TypeID) && data.TypeID > 0 ? int(data.TypeID) : javaCast("null", "");
|
||||
cronExpression = structKeyExists(data, "CronExpression") ? trim(toString(data.CronExpression)) : "";
|
||||
isActive = structKeyExists(data, "IsActive") ? (data.IsActive ? 1 : 0) : 1;
|
||||
|
||||
// Validate required fields
|
||||
if (!len(taskName)) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Name is required" });
|
||||
}
|
||||
if (!len(taskTitle)) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Title is required" });
|
||||
}
|
||||
if (!len(cronExpression)) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "CronExpression is required" });
|
||||
}
|
||||
|
||||
// Validate cron format (5 parts)
|
||||
cronParts = listToArray(cronExpression, " ");
|
||||
if (arrayLen(cronParts) != 5) {
|
||||
apiAbort({ "OK": false, "ERROR": "invalid_cron", "MESSAGE": "Cron expression must have 5 parts: minute hour day month weekday" });
|
||||
}
|
||||
|
||||
// Calculate next run time
|
||||
nextRunOn = calculateNextRun(cronExpression);
|
||||
|
||||
if (taskID > 0) {
|
||||
// UPDATE existing
|
||||
qCheck = queryExecute("
|
||||
SELECT ScheduledTaskID FROM ScheduledTaskDefinitions
|
||||
WHERE ScheduledTaskID = :id AND ScheduledTaskBusinessID = :businessID
|
||||
", {
|
||||
id: { value: taskID, cfsqltype: "cf_sql_integer" },
|
||||
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
if (qCheck.recordCount == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Scheduled task not found" });
|
||||
}
|
||||
|
||||
queryExecute("
|
||||
UPDATE ScheduledTaskDefinitions SET
|
||||
ScheduledTaskName = :name,
|
||||
ScheduledTaskTitle = :title,
|
||||
ScheduledTaskDetails = :details,
|
||||
ScheduledTaskCategoryID = :categoryID,
|
||||
ScheduledTaskTypeID = :typeID,
|
||||
ScheduledTaskCronExpression = :cron,
|
||||
ScheduledTaskIsActive = :isActive,
|
||||
ScheduledTaskNextRunOn = :nextRun
|
||||
WHERE ScheduledTaskID = :id
|
||||
", {
|
||||
name: { value: taskName, cfsqltype: "cf_sql_varchar" },
|
||||
title: { value: taskTitle, cfsqltype: "cf_sql_varchar" },
|
||||
details: { value: taskDetails, cfsqltype: "cf_sql_longvarchar", null: !len(taskDetails) },
|
||||
categoryID: { value: categoryID, cfsqltype: "cf_sql_integer", null: isNull(categoryID) },
|
||||
typeID: { value: typeID, cfsqltype: "cf_sql_integer", null: isNull(typeID) },
|
||||
cron: { value: cronExpression, cfsqltype: "cf_sql_varchar" },
|
||||
isActive: { value: isActive, cfsqltype: "cf_sql_bit" },
|
||||
nextRun: { value: nextRunOn, cfsqltype: "cf_sql_timestamp" },
|
||||
id: { value: taskID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
apiAbort({
|
||||
"OK": true,
|
||||
"SCHEDULED_TASK_ID": taskID,
|
||||
"NEXT_RUN": dateTimeFormat(nextRunOn, "yyyy-mm-dd HH:nn:ss"),
|
||||
"MESSAGE": "Scheduled task updated"
|
||||
});
|
||||
|
||||
} else {
|
||||
// INSERT new
|
||||
queryExecute("
|
||||
INSERT INTO ScheduledTaskDefinitions (
|
||||
ScheduledTaskBusinessID, ScheduledTaskName, ScheduledTaskTitle,
|
||||
ScheduledTaskDetails, ScheduledTaskCategoryID, ScheduledTaskTypeID,
|
||||
ScheduledTaskCronExpression, ScheduledTaskIsActive, ScheduledTaskNextRunOn
|
||||
) VALUES (
|
||||
:businessID, :name, :title, :details, :categoryID, :typeID, :cron, :isActive, :nextRun
|
||||
)
|
||||
", {
|
||||
businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
|
||||
name: { value: taskName, cfsqltype: "cf_sql_varchar" },
|
||||
title: { value: taskTitle, cfsqltype: "cf_sql_varchar" },
|
||||
details: { value: taskDetails, cfsqltype: "cf_sql_longvarchar", null: !len(taskDetails) },
|
||||
categoryID: { value: categoryID, cfsqltype: "cf_sql_integer", null: isNull(categoryID) },
|
||||
typeID: { value: typeID, cfsqltype: "cf_sql_integer", null: isNull(typeID) },
|
||||
cron: { value: cronExpression, cfsqltype: "cf_sql_varchar" },
|
||||
isActive: { value: isActive, cfsqltype: "cf_sql_bit" },
|
||||
nextRun: { value: nextRunOn, cfsqltype: "cf_sql_timestamp" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
|
||||
|
||||
apiAbort({
|
||||
"OK": true,
|
||||
"SCHEDULED_TASK_ID": qNew.newID,
|
||||
"NEXT_RUN": dateTimeFormat(nextRunOn, "yyyy-mm-dd HH:nn:ss"),
|
||||
"MESSAGE": "Scheduled task created"
|
||||
});
|
||||
}
|
||||
|
||||
} catch (any e) {
|
||||
apiAbort({
|
||||
"OK": false,
|
||||
"ERROR": "server_error",
|
||||
"MESSAGE": e.message
|
||||
});
|
||||
}
|
||||
</cfscript>
|
||||
48
api/admin/scheduledTasks/setup.cfm
Normal file
48
api/admin/scheduledTasks/setup.cfm
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<cfsetting showdebugoutput="false">
|
||||
<cfsetting enablecfoutputonly="true">
|
||||
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||
|
||||
<cfscript>
|
||||
// Creates ScheduledTaskDefinitions table if not exists
|
||||
// Public endpoint for setup
|
||||
|
||||
function apiAbort(required struct payload) {
|
||||
writeOutput(serializeJSON(payload));
|
||||
abort;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create ScheduledTaskDefinitions table
|
||||
queryExecute("
|
||||
CREATE TABLE IF NOT EXISTS ScheduledTaskDefinitions (
|
||||
ScheduledTaskID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
ScheduledTaskBusinessID INT NOT NULL,
|
||||
ScheduledTaskName VARCHAR(100) NOT NULL,
|
||||
ScheduledTaskCategoryID INT NULL,
|
||||
ScheduledTaskTypeID INT NULL,
|
||||
ScheduledTaskTitle VARCHAR(255) NOT NULL,
|
||||
ScheduledTaskDetails TEXT NULL,
|
||||
ScheduledTaskCronExpression VARCHAR(100) NOT NULL,
|
||||
ScheduledTaskIsActive BIT(1) DEFAULT b'1',
|
||||
ScheduledTaskLastRunOn DATETIME NULL,
|
||||
ScheduledTaskNextRunOn DATETIME NULL,
|
||||
ScheduledTaskCreatedOn DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
ScheduledTaskCreatedByUserID INT NULL,
|
||||
INDEX idx_business (ScheduledTaskBusinessID),
|
||||
INDEX idx_active_next (ScheduledTaskIsActive, ScheduledTaskNextRunOn)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
", [], { datasource: "payfrit" });
|
||||
|
||||
apiAbort({
|
||||
"OK": true,
|
||||
"MESSAGE": "ScheduledTaskDefinitions table created/verified"
|
||||
});
|
||||
|
||||
} catch (any e) {
|
||||
apiAbort({
|
||||
"OK": false,
|
||||
"ERROR": "server_error",
|
||||
"MESSAGE": e.message
|
||||
});
|
||||
}
|
||||
</cfscript>
|
||||
155
api/admin/scheduledTasks/toggle.cfm
Normal file
155
api/admin/scheduledTasks/toggle.cfm
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
<cfsetting showdebugoutput="false">
|
||||
<cfsetting enablecfoutputonly="true">
|
||||
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||
|
||||
<cfscript>
|
||||
// Enable or disable a scheduled task
|
||||
// Input: BusinessID (required), ScheduledTaskID (required), IsActive (required)
|
||||
// Output: { OK: true }
|
||||
|
||||
function apiAbort(required struct payload) {
|
||||
writeOutput(serializeJSON(payload));
|
||||
abort;
|
||||
}
|
||||
|
||||
function readJsonBody() {
|
||||
var raw = getHttpRequestData().content;
|
||||
if (isNull(raw)) raw = "";
|
||||
if (!len(trim(raw))) return {};
|
||||
try {
|
||||
var data = deserializeJSON(raw);
|
||||
if (isStruct(data)) return data;
|
||||
} catch (any e) {}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Calculate next run time from cron expression
|
||||
function calculateNextRun(required string cronExpression) {
|
||||
var parts = listToArray(cronExpression, " ");
|
||||
if (arrayLen(parts) != 5) {
|
||||
return dateAdd("d", 1, now());
|
||||
}
|
||||
|
||||
var cronMinute = parts[1];
|
||||
var cronHour = parts[2];
|
||||
var cronDay = parts[3];
|
||||
var cronMonth = parts[4];
|
||||
var cronWeekday = parts[5];
|
||||
|
||||
var checkDate = dateAdd("n", 1, now());
|
||||
checkDate = createDateTime(year(checkDate), month(checkDate), day(checkDate), hour(checkDate), minute(checkDate), 0);
|
||||
|
||||
var maxIterations = 400 * 24 * 60;
|
||||
var iterations = 0;
|
||||
|
||||
while (iterations < maxIterations) {
|
||||
var matchMinute = (cronMinute == "*" || (isNumeric(cronMinute) && minute(checkDate) == int(cronMinute)));
|
||||
var matchHour = (cronHour == "*" || (isNumeric(cronHour) && hour(checkDate) == int(cronHour)));
|
||||
var matchDay = (cronDay == "*" || (isNumeric(cronDay) && day(checkDate) == int(cronDay)));
|
||||
var matchMonth = (cronMonth == "*" || (isNumeric(cronMonth) && month(checkDate) == int(cronMonth)));
|
||||
|
||||
var dow = dayOfWeek(checkDate) - 1;
|
||||
var matchWeekday = (cronWeekday == "*");
|
||||
if (!matchWeekday) {
|
||||
if (find("-", cronWeekday)) {
|
||||
var range = listToArray(cronWeekday, "-");
|
||||
if (arrayLen(range) == 2 && isNumeric(range[1]) && isNumeric(range[2])) {
|
||||
matchWeekday = (dow >= int(range[1]) && dow <= int(range[2]));
|
||||
}
|
||||
} else if (isNumeric(cronWeekday)) {
|
||||
matchWeekday = (dow == int(cronWeekday));
|
||||
}
|
||||
}
|
||||
|
||||
if (matchMinute && matchHour && matchDay && matchMonth && matchWeekday) {
|
||||
return checkDate;
|
||||
}
|
||||
|
||||
checkDate = dateAdd("n", 1, checkDate);
|
||||
iterations++;
|
||||
}
|
||||
|
||||
return dateAdd("d", 1, now());
|
||||
}
|
||||
|
||||
try {
|
||||
data = readJsonBody();
|
||||
|
||||
// Get BusinessID
|
||||
businessID = 0;
|
||||
httpHeaders = getHttpRequestData().headers;
|
||||
|
||||
if (structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID)) {
|
||||
businessID = int(data.BusinessID);
|
||||
} else if (structKeyExists(httpHeaders, "X-Business-ID") && isNumeric(httpHeaders["X-Business-ID"])) {
|
||||
businessID = int(httpHeaders["X-Business-ID"]);
|
||||
}
|
||||
|
||||
if (businessID == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
|
||||
}
|
||||
|
||||
// Get task ID and new state
|
||||
taskID = structKeyExists(data, "ScheduledTaskID") && isNumeric(data.ScheduledTaskID) ? int(data.ScheduledTaskID) : 0;
|
||||
isActive = structKeyExists(data, "IsActive") ? (data.IsActive ? 1 : 0) : 0;
|
||||
|
||||
if (taskID == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "ScheduledTaskID is required" });
|
||||
}
|
||||
|
||||
// Verify exists and get cron expression
|
||||
qCheck = queryExecute("
|
||||
SELECT ScheduledTaskID, ScheduledTaskCronExpression as CronExpression
|
||||
FROM ScheduledTaskDefinitions
|
||||
WHERE ScheduledTaskID = :id AND ScheduledTaskBusinessID = :businessID
|
||||
", {
|
||||
id: { value: taskID, cfsqltype: "cf_sql_integer" },
|
||||
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
if (qCheck.recordCount == 0) {
|
||||
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Scheduled task not found" });
|
||||
}
|
||||
|
||||
// If enabling, recalculate next run time
|
||||
nextRunUpdate = "";
|
||||
if (isActive) {
|
||||
nextRunOn = calculateNextRun(qCheck.CronExpression);
|
||||
nextRunUpdate = ", ScheduledTaskNextRunOn = :nextRun";
|
||||
}
|
||||
|
||||
// Update status
|
||||
if (isActive) {
|
||||
queryExecute("
|
||||
UPDATE ScheduledTaskDefinitions SET
|
||||
ScheduledTaskIsActive = :isActive,
|
||||
ScheduledTaskNextRunOn = :nextRun
|
||||
WHERE ScheduledTaskID = :id
|
||||
", {
|
||||
isActive: { value: isActive, cfsqltype: "cf_sql_bit" },
|
||||
nextRun: { value: nextRunOn, cfsqltype: "cf_sql_timestamp" },
|
||||
id: { value: taskID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
} else {
|
||||
queryExecute("
|
||||
UPDATE ScheduledTaskDefinitions SET ScheduledTaskIsActive = :isActive
|
||||
WHERE ScheduledTaskID = :id
|
||||
", {
|
||||
isActive: { value: isActive, cfsqltype: "cf_sql_bit" },
|
||||
id: { value: taskID, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
}
|
||||
|
||||
apiAbort({
|
||||
"OK": true,
|
||||
"MESSAGE": isActive ? "Scheduled task enabled" : "Scheduled task disabled"
|
||||
});
|
||||
|
||||
} catch (any e) {
|
||||
apiAbort({
|
||||
"OK": false,
|
||||
"ERROR": "server_error",
|
||||
"MESSAGE": e.message
|
||||
});
|
||||
}
|
||||
</cfscript>
|
||||
|
|
@ -73,6 +73,12 @@
|
|||
</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" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -1233,3 +1233,57 @@ body {
|
|||
font-size: 13px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
/* Admin Tasks Page */
|
||||
.admin-tasks-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.admin-tasks-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-tasks-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.quick-task-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.quick-task-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.status-badge.active {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge.inactive {
|
||||
background: var(--gray-400);
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
code {
|
||||
background: var(--gray-100);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
|
|
|||
491
portal/portal.js
491
portal/portal.js
|
|
@ -200,6 +200,7 @@ const Portal = {
|
|||
team: 'Team',
|
||||
beacons: 'Beacons',
|
||||
services: 'Service Requests',
|
||||
'admin-tasks': 'Task Admin',
|
||||
settings: 'Settings'
|
||||
};
|
||||
document.getElementById('pageTitle').textContent = titles[page] || page;
|
||||
|
|
@ -237,6 +238,9 @@ const Portal = {
|
|||
case 'services':
|
||||
await this.loadServicesPage();
|
||||
break;
|
||||
case 'admin-tasks':
|
||||
await this.loadAdminTasksPage();
|
||||
break;
|
||||
case 'settings':
|
||||
await this.loadSettings();
|
||||
break;
|
||||
|
|
@ -2472,6 +2476,493 @@ const Portal = {
|
|||
console.error('[Portal] Error deleting service:', err);
|
||||
this.toast('Error deleting service', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// =====================
|
||||
// Admin Tasks Management
|
||||
// =====================
|
||||
|
||||
quickTaskTemplates: [],
|
||||
scheduledTasks: [],
|
||||
|
||||
async loadAdminTasksPage() {
|
||||
console.log('[Portal] Loading admin tasks page...');
|
||||
await Promise.all([
|
||||
this.loadQuickTaskTemplates(),
|
||||
this.loadScheduledTasks()
|
||||
]);
|
||||
},
|
||||
|
||||
// Quick Task Templates
|
||||
async loadQuickTaskTemplates() {
|
||||
try {
|
||||
const response = await fetch(`${this.config.apiBaseUrl}/admin/quickTasks/list.cfm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ BusinessID: this.config.businessId })
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.OK) {
|
||||
this.quickTaskTemplates = data.TEMPLATES || [];
|
||||
this.renderQuickTaskTemplates();
|
||||
} else {
|
||||
document.getElementById('quickTasksGrid').innerHTML = '<div class="empty-state">Failed to load quick task templates</div>';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Portal] Error loading quick task templates:', err);
|
||||
document.getElementById('quickTasksGrid').innerHTML = '<div class="empty-state">Error loading templates</div>';
|
||||
}
|
||||
},
|
||||
|
||||
renderQuickTaskTemplates() {
|
||||
const gridContainer = document.getElementById('quickTasksGrid');
|
||||
const manageContainer = document.getElementById('quickTasksManageList');
|
||||
|
||||
if (!this.quickTaskTemplates.length) {
|
||||
gridContainer.innerHTML = '<div class="empty-state">No quick task templates. Click "+ Add Template" to create one.</div>';
|
||||
manageContainer.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Shortcut buttons grid
|
||||
gridContainer.innerHTML = this.quickTaskTemplates.map(t => `
|
||||
<button class="quick-task-btn" onclick="Portal.createQuickTask(${t.QuickTaskTemplateID})"
|
||||
style="background:${t.Color || '#6366f1'};color:#fff;border:none;padding:16px;border-radius:8px;cursor:pointer;text-align:left;transition:transform 0.1s,box-shadow 0.1s;">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||
${this.getServiceIconSvg(t.Icon || 'add_box')}
|
||||
<strong>${this.escapeHtml(t.Name)}</strong>
|
||||
</div>
|
||||
<div style="font-size:12px;opacity:0.9;">${this.escapeHtml(t.Title)}</div>
|
||||
</button>
|
||||
`).join('');
|
||||
|
||||
// Management list
|
||||
manageContainer.innerHTML = `
|
||||
<h4 style="margin-bottom:12px;color:#666;">Manage Templates</h4>
|
||||
<div class="list-group">
|
||||
${this.quickTaskTemplates.map(t => `
|
||||
<div class="list-group-item" style="display:flex;justify-content:space-between;align-items:center;padding:12px;border-bottom:1px solid #eee;">
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<div style="width:8px;height:36px;background:${t.Color || '#6366f1'};border-radius:4px;"></div>
|
||||
<div>
|
||||
<strong>${this.escapeHtml(t.Name)}</strong>
|
||||
<div style="color:#666;font-size:12px;">${this.escapeHtml(t.Title)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button class="btn btn-sm btn-secondary" onclick="Portal.editQuickTask(${t.QuickTaskTemplateID})">Edit</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="Portal.deleteQuickTask(${t.QuickTaskTemplateID})">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
showAddQuickTaskModal(templateId = null) {
|
||||
const isEdit = templateId !== null;
|
||||
const template = isEdit ? this.quickTaskTemplates.find(t => t.QuickTaskTemplateID === templateId) : {};
|
||||
|
||||
// Build category options
|
||||
const categoryOptions = (this._taskCategories || []).map(c =>
|
||||
`<option value="${c.TaskCategoryID}" ${template.CategoryID == c.TaskCategoryID ? 'selected' : ''}>${this.escapeHtml(c.TaskCategoryName)}</option>`
|
||||
).join('');
|
||||
|
||||
// Build icon options
|
||||
const iconOptions = Object.keys(this.serviceIcons).map(key =>
|
||||
`<option value="${key}" ${(template.Icon || 'add_box') === key ? 'selected' : ''}>${this.serviceIcons[key].label}</option>`
|
||||
).join('');
|
||||
|
||||
document.getElementById('modalTitle').textContent = isEdit ? 'Edit Quick Task Template' : 'Add Quick Task Template';
|
||||
document.getElementById('modalBody').innerHTML = `
|
||||
<form id="quickTaskForm" class="form">
|
||||
<input type="hidden" id="quickTaskTemplateId" value="${templateId || ''}">
|
||||
<div class="form-group">
|
||||
<label>Template Name</label>
|
||||
<input type="text" id="quickTaskName" class="form-input" value="${this.escapeHtml(template.Name || '')}" required placeholder="e.g., Check Trash">
|
||||
<small style="color:#666;">Name shown on the shortcut button</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Task Title</label>
|
||||
<input type="text" id="quickTaskTitle" class="form-input" value="${this.escapeHtml(template.Title || '')}" required placeholder="e.g., Check and empty trash bins">
|
||||
<small style="color:#666;">Title shown on the created task</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Task Details (optional)</label>
|
||||
<textarea id="quickTaskDetails" class="form-textarea" rows="2" placeholder="Optional instructions">${this.escapeHtml(template.Details || '')}</textarea>
|
||||
</div>
|
||||
<div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||||
<div class="form-group">
|
||||
<label>Category (optional)</label>
|
||||
<select id="quickTaskCategory" class="form-input">
|
||||
<option value="">None</option>
|
||||
${categoryOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Icon</label>
|
||||
<select id="quickTaskIcon" class="form-input">
|
||||
${iconOptions}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Button Color</label>
|
||||
<input type="color" id="quickTaskColor" class="form-input" value="${template.Color || '#6366f1'}" style="height:40px;padding:4px;">
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;justify-content:flex-end;margin-top:20px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="Portal.closeModal()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">${isEdit ? 'Save Changes' : 'Create Template'}</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
this.showModal();
|
||||
|
||||
// Load categories if not loaded
|
||||
if (!this._taskCategories) {
|
||||
this.loadTaskCategories();
|
||||
}
|
||||
|
||||
document.getElementById('quickTaskForm').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
this.saveQuickTask();
|
||||
});
|
||||
},
|
||||
|
||||
editQuickTask(templateId) {
|
||||
this.showAddQuickTaskModal(templateId);
|
||||
},
|
||||
|
||||
async loadTaskCategories() {
|
||||
try {
|
||||
const response = await fetch(`${this.config.apiBaseUrl}/tasks/listCategories.cfm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ BusinessID: this.config.businessId })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.OK) {
|
||||
this._taskCategories = data.CATEGORIES || [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Portal] Error loading task categories:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async saveQuickTask() {
|
||||
const id = document.getElementById('quickTaskTemplateId').value;
|
||||
const payload = {
|
||||
BusinessID: this.config.businessId,
|
||||
Name: document.getElementById('quickTaskName').value,
|
||||
Title: document.getElementById('quickTaskTitle').value,
|
||||
Details: document.getElementById('quickTaskDetails').value,
|
||||
CategoryID: document.getElementById('quickTaskCategory').value || null,
|
||||
Icon: document.getElementById('quickTaskIcon').value,
|
||||
Color: document.getElementById('quickTaskColor').value
|
||||
};
|
||||
|
||||
if (id) payload.QuickTaskTemplateID = parseInt(id);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.config.apiBaseUrl}/admin/quickTasks/save.cfm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.OK) {
|
||||
this.toast('Template saved!', 'success');
|
||||
this.closeModal();
|
||||
await this.loadQuickTaskTemplates();
|
||||
} else {
|
||||
this.toast(data.MESSAGE || 'Failed to save', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Portal] Error saving quick task template:', err);
|
||||
this.toast('Error saving template', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteQuickTask(templateId) {
|
||||
if (!confirm('Delete this quick task template?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.config.apiBaseUrl}/admin/quickTasks/delete.cfm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
BusinessID: this.config.businessId,
|
||||
QuickTaskTemplateID: templateId
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.OK) {
|
||||
this.toast('Template deleted', 'success');
|
||||
await this.loadQuickTaskTemplates();
|
||||
} else {
|
||||
this.toast(data.MESSAGE || 'Failed to delete', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Portal] Error deleting quick task template:', err);
|
||||
this.toast('Error deleting template', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async createQuickTask(templateId) {
|
||||
try {
|
||||
const response = await fetch(`${this.config.apiBaseUrl}/admin/quickTasks/create.cfm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
BusinessID: this.config.businessId,
|
||||
QuickTaskTemplateID: templateId
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.OK) {
|
||||
this.toast('Task created!', 'success');
|
||||
} else {
|
||||
this.toast(data.MESSAGE || 'Failed to create task', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Portal] Error creating quick task:', err);
|
||||
this.toast('Error creating task', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// Scheduled Tasks
|
||||
async loadScheduledTasks() {
|
||||
try {
|
||||
const response = await fetch(`${this.config.apiBaseUrl}/admin/scheduledTasks/list.cfm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ BusinessID: this.config.businessId })
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.OK) {
|
||||
this.scheduledTasks = data.SCHEDULED_TASKS || [];
|
||||
this.renderScheduledTasks();
|
||||
} else {
|
||||
document.getElementById('scheduledTasksList').innerHTML = '<div class="empty-state">Failed to load scheduled tasks</div>';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Portal] Error loading scheduled tasks:', err);
|
||||
document.getElementById('scheduledTasksList').innerHTML = '<div class="empty-state">Error loading scheduled tasks</div>';
|
||||
}
|
||||
},
|
||||
|
||||
renderScheduledTasks() {
|
||||
const container = document.getElementById('scheduledTasksList');
|
||||
if (!this.scheduledTasks.length) {
|
||||
container.innerHTML = '<div class="empty-state">No scheduled tasks configured. Click "+ Add Scheduled Task" to create one.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = this.scheduledTasks.map(s => `
|
||||
<div class="list-group-item" style="display:flex;justify-content:space-between;align-items:center;padding:12px;border-bottom:1px solid #eee;">
|
||||
<div style="flex:1;">
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<span class="status-badge ${s.IsActive ? 'active' : 'inactive'}">${s.IsActive ? 'Active' : 'Paused'}</span>
|
||||
<strong>${this.escapeHtml(s.Name)}</strong>
|
||||
</div>
|
||||
<div style="color:#666;font-size:12px;margin-top:4px;">
|
||||
${this.escapeHtml(s.Title)} | Schedule: <code>${this.escapeHtml(s.CronExpression)}</code>
|
||||
</div>
|
||||
${s.NextRunOn ? `<div style="color:#999;font-size:11px;">Next run: ${s.NextRunOn}</div>` : ''}
|
||||
${s.LastRunOn ? `<div style="color:#999;font-size:11px;">Last run: ${s.LastRunOn}</div>` : ''}
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<button class="btn btn-sm btn-secondary" onclick="Portal.runScheduledTaskNow(${s.ScheduledTaskID})" title="Run Now">
|
||||
Run Now
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick="Portal.toggleScheduledTask(${s.ScheduledTaskID}, ${!s.IsActive})">
|
||||
${s.IsActive ? 'Pause' : 'Enable'}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick="Portal.editScheduledTask(${s.ScheduledTaskID})">Edit</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="Portal.deleteScheduledTask(${s.ScheduledTaskID})">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
},
|
||||
|
||||
showAddScheduledTaskModal(taskId = null) {
|
||||
const isEdit = taskId !== null;
|
||||
const task = isEdit ? this.scheduledTasks.find(t => t.ScheduledTaskID === taskId) : {};
|
||||
|
||||
// Build category options
|
||||
const categoryOptions = (this._taskCategories || []).map(c =>
|
||||
`<option value="${c.TaskCategoryID}" ${task.CategoryID == c.TaskCategoryID ? 'selected' : ''}>${this.escapeHtml(c.TaskCategoryName)}</option>`
|
||||
).join('');
|
||||
|
||||
document.getElementById('modalTitle').textContent = isEdit ? 'Edit Scheduled Task' : 'Add Scheduled Task';
|
||||
document.getElementById('modalBody').innerHTML = `
|
||||
<form id="scheduledTaskForm" class="form">
|
||||
<input type="hidden" id="scheduledTaskId" value="${taskId || ''}">
|
||||
<div class="form-group">
|
||||
<label>Definition Name</label>
|
||||
<input type="text" id="scheduledTaskName" class="form-input" value="${this.escapeHtml(task.Name || '')}" required placeholder="e.g., Daily Opening Checklist">
|
||||
<small style="color:#666;">Internal name for this scheduled task</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Task Title</label>
|
||||
<input type="text" id="scheduledTaskTitle" class="form-input" value="${this.escapeHtml(task.Title || '')}" required placeholder="e.g., Complete Opening Checklist">
|
||||
<small style="color:#666;">Title shown on the created task</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Task Details (optional)</label>
|
||||
<textarea id="scheduledTaskDetails" class="form-textarea" rows="2" placeholder="Optional instructions">${this.escapeHtml(task.Details || '')}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Category (optional)</label>
|
||||
<select id="scheduledTaskCategory" class="form-input">
|
||||
<option value="">None</option>
|
||||
${categoryOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Schedule (Cron Expression)</label>
|
||||
<input type="text" id="scheduledTaskCron" class="form-input" value="${task.CronExpression || '0 9 * * *'}" required placeholder="0 9 * * *">
|
||||
<small style="color:#666;">Format: minute hour day month weekday<br>
|
||||
Examples: <code>0 9 * * *</code> (daily 9am), <code>0 9 * * 1-5</code> (weekdays 9am), <code>30 14 * * *</code> (daily 2:30pm)</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label><input type="checkbox" id="scheduledTaskActive" ${task.IsActive !== false ? 'checked' : ''}> Active</label>
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;justify-content:flex-end;margin-top:20px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="Portal.closeModal()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">${isEdit ? 'Save Changes' : 'Create'}</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
this.showModal();
|
||||
|
||||
// Load categories if not loaded
|
||||
if (!this._taskCategories) {
|
||||
this.loadTaskCategories();
|
||||
}
|
||||
|
||||
document.getElementById('scheduledTaskForm').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
this.saveScheduledTask();
|
||||
});
|
||||
},
|
||||
|
||||
editScheduledTask(taskId) {
|
||||
this.showAddScheduledTaskModal(taskId);
|
||||
},
|
||||
|
||||
async saveScheduledTask() {
|
||||
const id = document.getElementById('scheduledTaskId').value;
|
||||
const payload = {
|
||||
BusinessID: this.config.businessId,
|
||||
Name: document.getElementById('scheduledTaskName').value,
|
||||
Title: document.getElementById('scheduledTaskTitle').value,
|
||||
Details: document.getElementById('scheduledTaskDetails').value,
|
||||
CategoryID: document.getElementById('scheduledTaskCategory').value || null,
|
||||
CronExpression: document.getElementById('scheduledTaskCron').value,
|
||||
IsActive: document.getElementById('scheduledTaskActive').checked
|
||||
};
|
||||
|
||||
if (id) payload.ScheduledTaskID = parseInt(id);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.config.apiBaseUrl}/admin/scheduledTasks/save.cfm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.OK) {
|
||||
this.toast(`Scheduled task saved! Next run: ${data.NEXT_RUN}`, 'success');
|
||||
this.closeModal();
|
||||
await this.loadScheduledTasks();
|
||||
} else {
|
||||
this.toast(data.MESSAGE || 'Failed to save', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Portal] Error saving scheduled task:', err);
|
||||
this.toast('Error saving scheduled task', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteScheduledTask(taskId) {
|
||||
if (!confirm('Delete this scheduled task?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.config.apiBaseUrl}/admin/scheduledTasks/delete.cfm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
BusinessID: this.config.businessId,
|
||||
ScheduledTaskID: taskId
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.OK) {
|
||||
this.toast('Scheduled task deleted', 'success');
|
||||
await this.loadScheduledTasks();
|
||||
} else {
|
||||
this.toast(data.MESSAGE || 'Failed to delete', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Portal] Error deleting scheduled task:', err);
|
||||
this.toast('Error deleting scheduled task', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async toggleScheduledTask(taskId, isActive) {
|
||||
try {
|
||||
const response = await fetch(`${this.config.apiBaseUrl}/admin/scheduledTasks/toggle.cfm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
BusinessID: this.config.businessId,
|
||||
ScheduledTaskID: taskId,
|
||||
IsActive: isActive
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.OK) {
|
||||
this.toast(isActive ? 'Scheduled task enabled' : 'Scheduled task paused', 'success');
|
||||
await this.loadScheduledTasks();
|
||||
} else {
|
||||
this.toast(data.MESSAGE || 'Failed to toggle', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Portal] Error toggling scheduled task:', err);
|
||||
this.toast('Error toggling scheduled task', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async runScheduledTaskNow(taskId) {
|
||||
try {
|
||||
const response = await fetch(`${this.config.apiBaseUrl}/admin/scheduledTasks/run.cfm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
BusinessID: this.config.businessId,
|
||||
ScheduledTaskID: taskId
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.OK) {
|
||||
this.toast(`Task #${data.TASK_ID} created!`, 'success');
|
||||
} else {
|
||||
this.toast(data.MESSAGE || 'Failed to run task', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Portal] Error running scheduled task:', err);
|
||||
this.toast('Error running scheduled task', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue