Add staff role system: Staff keeps cash, Manager/Admin collect for restaurant
- Create tt_StaffRoles lookup table (Staff, Manager, Admin) - Add RoleID column to Employees table (default: Staff) - Wire portal role dropdown to addTeamMember API - Return RoleName in team list and RoleID to Android - Skip worker payout ledger and cash_debit for Manager/Admin roles on cash task completion (they collect on behalf of the restaurant) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
476d7f9df1
commit
d1910a7d34
6 changed files with 34 additions and 14 deletions
|
|
@ -22,6 +22,8 @@ function readJsonBody() {
|
||||||
data = readJsonBody();
|
data = readJsonBody();
|
||||||
businessId = structKeyExists(data, "BusinessID") ? val(data.BusinessID) : 0;
|
businessId = structKeyExists(data, "BusinessID") ? val(data.BusinessID) : 0;
|
||||||
userId = structKeyExists(data, "UserID") ? val(data.UserID) : 0;
|
userId = structKeyExists(data, "UserID") ? val(data.UserID) : 0;
|
||||||
|
roleId = structKeyExists(data, "RoleID") ? val(data.RoleID) : 1;
|
||||||
|
if (roleId < 1 || roleId > 3) roleId = 1;
|
||||||
|
|
||||||
if (businessId <= 0) {
|
if (businessId <= 0) {
|
||||||
apiAbort({ "OK": false, "ERROR": "missing_business_id" });
|
apiAbort({ "OK": false, "ERROR": "missing_business_id" });
|
||||||
|
|
@ -41,12 +43,13 @@ try {
|
||||||
], { datasource: "payfrit" });
|
], { datasource: "payfrit" });
|
||||||
|
|
||||||
if (qCheck.recordCount > 0) {
|
if (qCheck.recordCount > 0) {
|
||||||
// Update to active
|
// Update to active with role
|
||||||
queryTimed("
|
queryTimed("
|
||||||
UPDATE Employees
|
UPDATE Employees
|
||||||
SET IsActive = 1, StatusID = 2
|
SET IsActive = 1, StatusID = 2, RoleID = ?
|
||||||
WHERE BusinessID = ? AND UserID = ?
|
WHERE BusinessID = ? AND UserID = ?
|
||||||
", [
|
", [
|
||||||
|
{ value: roleId, cfsqltype: "cf_sql_integer" },
|
||||||
{ value: businessId, cfsqltype: "cf_sql_integer" },
|
{ value: businessId, cfsqltype: "cf_sql_integer" },
|
||||||
{ value: userId, cfsqltype: "cf_sql_integer" }
|
{ value: userId, cfsqltype: "cf_sql_integer" }
|
||||||
], { datasource: "payfrit" });
|
], { datasource: "payfrit" });
|
||||||
|
|
@ -58,11 +61,12 @@ try {
|
||||||
// the business relationship is established via ServicePoint -> Beacon chain.
|
// the business relationship is established via ServicePoint -> Beacon chain.
|
||||||
// Kept for legacy/convenience but could be derived from context.
|
// Kept for legacy/convenience but could be derived from context.
|
||||||
queryTimed("
|
queryTimed("
|
||||||
INSERT INTO Employees (BusinessID, UserID, StatusID, IsActive)
|
INSERT INTO Employees (BusinessID, UserID, StatusID, IsActive, RoleID)
|
||||||
VALUES (?, ?, 2, 1)
|
VALUES (?, ?, 2, 1, ?)
|
||||||
", [
|
", [
|
||||||
{ value: businessId, cfsqltype: "cf_sql_integer" },
|
{ value: businessId, cfsqltype: "cf_sql_integer" },
|
||||||
{ value: userId, cfsqltype: "cf_sql_integer" }
|
{ value: userId, cfsqltype: "cf_sql_integer" },
|
||||||
|
{ value: roleId, cfsqltype: "cf_sql_integer" }
|
||||||
], { datasource: "payfrit" });
|
], { datasource: "payfrit" });
|
||||||
|
|
||||||
qNew = queryTimed("SELECT LAST_INSERT_ID() AS EmployeeID", {}, { datasource: "payfrit" });
|
qNew = queryTimed("SELECT LAST_INSERT_ID() AS EmployeeID", {}, { datasource: "payfrit" });
|
||||||
|
|
|
||||||
|
|
@ -45,11 +45,13 @@ try {
|
||||||
e.ID,
|
e.ID,
|
||||||
e.UserID,
|
e.UserID,
|
||||||
e.StatusID,
|
e.StatusID,
|
||||||
|
e.RoleID,
|
||||||
CAST(e.IsActive AS UNSIGNED) AS IsActive,
|
CAST(e.IsActive AS UNSIGNED) AS IsActive,
|
||||||
u.FirstName,
|
u.FirstName,
|
||||||
u.LastName,
|
u.LastName,
|
||||||
u.EmailAddress,
|
u.EmailAddress,
|
||||||
u.ContactNumber,
|
u.ContactNumber,
|
||||||
|
COALESCE(sr.Name, 'Staff') AS RoleName,
|
||||||
CASE e.StatusID
|
CASE e.StatusID
|
||||||
WHEN 0 THEN 'Pending'
|
WHEN 0 THEN 'Pending'
|
||||||
WHEN 1 THEN 'Invited'
|
WHEN 1 THEN 'Invited'
|
||||||
|
|
@ -59,6 +61,7 @@ try {
|
||||||
END AS StatusName
|
END AS StatusName
|
||||||
FROM Employees e
|
FROM Employees e
|
||||||
JOIN Users u ON e.UserID = u.ID
|
JOIN Users u ON e.UserID = u.ID
|
||||||
|
LEFT JOIN tt_StaffRoles sr ON sr.ID = e.RoleID
|
||||||
WHERE e.BusinessID = ?
|
WHERE e.BusinessID = ?
|
||||||
ORDER BY e.IsActive DESC, u.FirstName ASC
|
ORDER BY e.IsActive DESC, u.FirstName ASC
|
||||||
", [
|
", [
|
||||||
|
|
@ -75,6 +78,8 @@ try {
|
||||||
"LastName": row.LastName,
|
"LastName": row.LastName,
|
||||||
"Email": row.EmailAddress,
|
"Email": row.EmailAddress,
|
||||||
"Phone": row.ContactNumber,
|
"Phone": row.ContactNumber,
|
||||||
|
"RoleID": row.RoleID,
|
||||||
|
"RoleName": row.RoleName,
|
||||||
"StatusID": row.StatusID,
|
"StatusID": row.StatusID,
|
||||||
"StatusName": row.StatusName,
|
"StatusName": row.StatusName,
|
||||||
"IsActive": val(row.IsActive) == 1
|
"IsActive": val(row.IsActive) == 1
|
||||||
|
|
|
||||||
|
|
@ -50,11 +50,13 @@
|
||||||
SELECT t.ID, t.ClaimedByUserID, t.CompletedOn, t.OrderID, t.TaskTypeID, t.BusinessID,
|
SELECT t.ID, t.ClaimedByUserID, t.CompletedOn, t.OrderID, t.TaskTypeID, t.BusinessID,
|
||||||
o.UserID AS CustomerUserID, o.ServicePointID,
|
o.UserID AS CustomerUserID, o.ServicePointID,
|
||||||
tt.Name AS TaskTypeName,
|
tt.Name AS TaskTypeName,
|
||||||
b.UserID AS BusinessOwnerUserID
|
b.UserID AS BusinessOwnerUserID,
|
||||||
|
COALESCE(emp.RoleID, 1) AS WorkerRoleID
|
||||||
FROM Tasks t
|
FROM Tasks t
|
||||||
LEFT JOIN Orders o ON o.ID = t.OrderID
|
LEFT JOIN Orders o ON o.ID = t.OrderID
|
||||||
LEFT JOIN tt_TaskTypes tt ON tt.ID = t.TaskTypeID
|
LEFT JOIN tt_TaskTypes tt ON tt.ID = t.TaskTypeID
|
||||||
LEFT JOIN Businesses b ON b.ID = t.BusinessID
|
LEFT JOIN Businesses b ON b.ID = t.BusinessID
|
||||||
|
LEFT JOIN Employees emp ON emp.BusinessID = t.BusinessID AND emp.UserID = t.ClaimedByUserID AND emp.IsActive = 1
|
||||||
WHERE t.ID = ?
|
WHERE t.ID = ?
|
||||||
", [ { value = TaskID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
|
", [ { value = TaskID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
|
||||||
|
|
||||||
|
|
@ -65,6 +67,8 @@
|
||||||
<!--- Chat tasks (TaskTypeID = 2) can be closed without being claimed first --->
|
<!--- Chat tasks (TaskTypeID = 2) can be closed without being claimed first --->
|
||||||
<cfset isChatTask = (qTask.TaskTypeID EQ 2)>
|
<cfset isChatTask = (qTask.TaskTypeID EQ 2)>
|
||||||
<cfset isCashTask = (len(trim(qTask.TaskTypeName)) GT 0 AND findNoCase("Cash", qTask.TaskTypeName) GT 0)>
|
<cfset isCashTask = (len(trim(qTask.TaskTypeName)) GT 0 AND findNoCase("Cash", qTask.TaskTypeName) GT 0)>
|
||||||
|
<!--- Manager (2) and Admin (3) collect cash on behalf of the restaurant; Staff (1) keeps cash as payout --->
|
||||||
|
<cfset isAdminRole = (val(qTask.WorkerRoleID) GTE 2)>
|
||||||
|
|
||||||
<cfif (NOT isChatTask) AND (qTask.ClaimedByUserID EQ 0)>
|
<cfif (NOT isChatTask) AND (qTask.ClaimedByUserID EQ 0)>
|
||||||
<cfset apiAbort({ "OK": false, "ERROR": "not_claimed", "MESSAGE": "Task has not been claimed yet." })>
|
<cfset apiAbort({ "OK": false, "ERROR": "not_claimed", "MESSAGE": "Task has not been claimed yet." })>
|
||||||
|
|
@ -221,9 +225,10 @@
|
||||||
</cfif>
|
</cfif>
|
||||||
|
|
||||||
<!--- === PAYOUT LEDGER + ACTIVATION WITHHOLDING === --->
|
<!--- === PAYOUT LEDGER + ACTIVATION WITHHOLDING === --->
|
||||||
|
<!--- Skip for admin/manager roles — they aren't earning personal task pay --->
|
||||||
<cfset ledgerCreated = false>
|
<cfset ledgerCreated = false>
|
||||||
<cfset workerUserID_for_payout = val(qTask.ClaimedByUserID)>
|
<cfset workerUserID_for_payout = val(qTask.ClaimedByUserID)>
|
||||||
<cfif workerUserID_for_payout GT 0>
|
<cfif workerUserID_for_payout GT 0 AND NOT isAdminRole>
|
||||||
<!--- Get PayCents from the task --->
|
<!--- Get PayCents from the task --->
|
||||||
<cfset qTaskPay = queryTimed("
|
<cfset qTaskPay = queryTimed("
|
||||||
SELECT PayCents FROM Tasks WHERE ID = ?
|
SELECT PayCents FROM Tasks WHERE ID = ?
|
||||||
|
|
@ -301,7 +306,8 @@
|
||||||
</cfif>
|
</cfif>
|
||||||
|
|
||||||
<!--- Debit worker (received physical cash, debit digitally) --->
|
<!--- Debit worker (received physical cash, debit digitally) --->
|
||||||
<cfif workerUserID_for_payout GT 0>
|
<!--- Skip for admin/manager — they collected cash on behalf of the restaurant --->
|
||||||
|
<cfif workerUserID_for_payout GT 0 AND NOT isAdminRole>
|
||||||
<cfset queryTimed("
|
<cfset queryTimed("
|
||||||
INSERT INTO WorkPayoutLedgers
|
INSERT INTO WorkPayoutLedgers
|
||||||
(UserID, TaskID, GrossEarningsCents, ActivationWithheldCents, NetTransferCents, Status)
|
(UserID, TaskID, GrossEarningsCents, ActivationWithheldCents, NetTransferCents, Status)
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@
|
||||||
e.BusinessID,
|
e.BusinessID,
|
||||||
MIN(e.StatusID) AS StatusID,
|
MIN(e.StatusID) AS StatusID,
|
||||||
MAX(e.IsActive) AS IsActive,
|
MAX(e.IsActive) AS IsActive,
|
||||||
|
MAX(e.RoleID) AS RoleID,
|
||||||
b.Name AS Name,
|
b.Name AS Name,
|
||||||
(SELECT COUNT(*) FROM Tasks t WHERE t.BusinessID = e.BusinessID AND t.ClaimedByUserID = 0 AND t.CompletedOn IS NULL) AS PendingTaskCount,
|
(SELECT COUNT(*) FROM Tasks t WHERE t.BusinessID = e.BusinessID AND t.ClaimedByUserID = 0 AND t.CompletedOn IS NULL) AS PendingTaskCount,
|
||||||
(SELECT COUNT(*) FROM Tasks t WHERE t.BusinessID = e.BusinessID AND t.ClaimedByUserID = ? AND t.CompletedOn IS NULL) AS ActiveTaskCount
|
(SELECT COUNT(*) FROM Tasks t WHERE t.BusinessID = e.BusinessID AND t.ClaimedByUserID = ? AND t.CompletedOn IS NULL) AS ActiveTaskCount
|
||||||
|
|
@ -60,6 +61,7 @@
|
||||||
"Address": "",
|
"Address": "",
|
||||||
"City": "",
|
"City": "",
|
||||||
"StatusID": qBusinesses.StatusID,
|
"StatusID": qBusinesses.StatusID,
|
||||||
|
"RoleID": qBusinesses.RoleID,
|
||||||
"PendingTaskCount": qBusinesses.PendingTaskCount,
|
"PendingTaskCount": qBusinesses.PendingTaskCount,
|
||||||
"ActiveTaskCount": qBusinesses.ActiveTaskCount
|
"ActiveTaskCount": qBusinesses.ActiveTaskCount
|
||||||
})>
|
})>
|
||||||
|
|
|
||||||
|
|
@ -372,12 +372,13 @@
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
<th>Phone</th>
|
<th>Phone</th>
|
||||||
|
<th>Role</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="teamTableBody">
|
<tbody id="teamTableBody">
|
||||||
<tr><td colspan="5" class="empty-state">Loading team...</td></tr>
|
<tr><td colspan="6" class="empty-state">Loading team...</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -625,7 +625,7 @@ const Portal = {
|
||||||
async loadTeam() {
|
async loadTeam() {
|
||||||
console.log('[Portal] Loading team for business:', this.config.businessId);
|
console.log('[Portal] Loading team for business:', this.config.businessId);
|
||||||
const tbody = document.getElementById('teamTableBody');
|
const tbody = document.getElementById('teamTableBody');
|
||||||
tbody.innerHTML = '<tr><td colspan="5" class="empty-state">Loading team...</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="6" class="empty-state">Loading team...</td></tr>';
|
||||||
|
|
||||||
// Load hiring toggle state from business data
|
// Load hiring toggle state from business data
|
||||||
this.loadHiringToggle();
|
this.loadHiringToggle();
|
||||||
|
|
@ -643,7 +643,7 @@ const Portal = {
|
||||||
|
|
||||||
if (data.OK && data.TEAM) {
|
if (data.OK && data.TEAM) {
|
||||||
if (data.TEAM.length === 0) {
|
if (data.TEAM.length === 0) {
|
||||||
tbody.innerHTML = '<tr><td colspan="5" class="empty-state">No team members yet</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="6" class="empty-state">No team members yet</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -657,6 +657,7 @@ const Portal = {
|
||||||
</td>
|
</td>
|
||||||
<td>${member.Email || '-'}</td>
|
<td>${member.Email || '-'}</td>
|
||||||
<td>${this.formatPhone(member.Phone)}</td>
|
<td>${this.formatPhone(member.Phone)}</td>
|
||||||
|
<td>${member.RoleName || 'Staff'}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="status-badge ${this.getStatusClass(member.StatusID)}">
|
<span class="status-badge ${this.getStatusClass(member.StatusID)}">
|
||||||
${member.StatusName || 'Unknown'}
|
${member.StatusName || 'Unknown'}
|
||||||
|
|
@ -668,11 +669,11 @@ const Portal = {
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`).join('');
|
||||||
} else {
|
} else {
|
||||||
tbody.innerHTML = '<tr><td colspan="5" class="empty-state">Failed to load team</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="6" class="empty-state">Failed to load team</td></tr>';
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Portal] Error loading team:', err);
|
console.error('[Portal] Error loading team:', err);
|
||||||
tbody.innerHTML = '<tr><td colspan="5" class="empty-state">Error loading team</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="6" class="empty-state">Error loading team</td></tr>';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -1604,7 +1605,8 @@ const Portal = {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
BusinessID: this.config.businessId,
|
BusinessID: this.config.businessId,
|
||||||
UserID: foundUserId
|
UserID: foundUserId,
|
||||||
|
RoleID: { staff: 1, manager: 2, admin: 3 }[document.getElementById('inviteRole').value] || 1
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
|
||||||
Reference in a new issue