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:
John Mizerek 2026-02-28 11:19:22 -08:00
parent 476d7f9df1
commit d1910a7d34
6 changed files with 34 additions and 14 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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