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>
|
</svg>
|
||||||
<span>Services</span>
|
<span>Services</span>
|
||||||
</a>
|
</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">
|
<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">
|
<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"/>
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
|
@ -416,6 +422,40 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 -->
|
<!-- Settings Page -->
|
||||||
<section class="page" id="page-settings">
|
<section class="page" id="page-settings">
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
|
|
|
||||||
|
|
@ -1233,3 +1233,57 @@ body {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--gray-500);
|
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',
|
team: 'Team',
|
||||||
beacons: 'Beacons',
|
beacons: 'Beacons',
|
||||||
services: 'Service Requests',
|
services: 'Service Requests',
|
||||||
|
'admin-tasks': 'Task Admin',
|
||||||
settings: 'Settings'
|
settings: 'Settings'
|
||||||
};
|
};
|
||||||
document.getElementById('pageTitle').textContent = titles[page] || page;
|
document.getElementById('pageTitle').textContent = titles[page] || page;
|
||||||
|
|
@ -237,6 +238,9 @@ const Portal = {
|
||||||
case 'services':
|
case 'services':
|
||||||
await this.loadServicesPage();
|
await this.loadServicesPage();
|
||||||
break;
|
break;
|
||||||
|
case 'admin-tasks':
|
||||||
|
await this.loadAdminTasksPage();
|
||||||
|
break;
|
||||||
case 'settings':
|
case 'settings':
|
||||||
await this.loadSettings();
|
await this.loadSettings();
|
||||||
break;
|
break;
|
||||||
|
|
@ -2472,6 +2476,493 @@ const Portal = {
|
||||||
console.error('[Portal] Error deleting service:', err);
|
console.error('[Portal] Error deleting service:', err);
|
||||||
this.toast('Error deleting service', 'error');
|
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