staff_management.php
28.94 KB
<?php
require_once __DIR__ . '/../bootstrap.php';
requireAdmin();
$pageTitle = 'Staff Management';
$msg = [];
$err = [];
// Get current tab
$tab = $_GET['tab'] ?? 'staff';
// Handle form submissions
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Create/Update User
if (isset($_POST['action']) && $_POST['action'] === 'save_user') {
$userId = isset($_POST['user_id']) && $_POST['user_id'] ? (int)$_POST['user_id'] : null;
$username = trim($_POST['username'] ?? '');
$email = trim($_POST['email'] ?? '');
$displayName = trim($_POST['display_name'] ?? '');
$role = trim($_POST['role'] ?? '');
$secondaryGroup = trim($_POST['secondary_group'] ?? '');
$isActive = isset($_POST['is_active']) ? 1 : 0;
$password = trim($_POST['password'] ?? '');
// Validation
if (!$username || !$email || !$displayName || !$role) {
$err[] = 'All fields are required';
}
// Validate role
$allowedRoles = ['administrator', 'director', 'manager', 'driver', 'porter'];
if (!in_array($role, $allowedRoles)) {
$err[] = 'Invalid role selected';
}
// Validate secondary group (only for non-driver/porter roles)
if (!in_array($role, ['driver', 'porter'])) {
if (!$secondaryGroup || !in_array($secondaryGroup, ['office', 'driver', 'porter'])) {
$err[] = 'Secondary group is required for non-driver/porter roles';
}
} else {
// Drivers and porters don't need secondary group
$secondaryGroup = null;
}
if (empty($err)) {
try {
if ($userId) {
// Update existing user
if ($password) {
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $pdo->prepare("UPDATE users SET username=?, email=?, display_name=?, role=?, secondary_group=?, is_active=?, password_hash=? WHERE id=?");
$stmt->execute([$username, $email, $displayName, $role, $secondaryGroup, $isActive, $passwordHash, $userId]);
} else {
$stmt = $pdo->prepare("UPDATE users SET username=?, email=?, display_name=?, role=?, secondary_group=?, is_active=? WHERE id=?");
$stmt->execute([$username, $email, $displayName, $role, $secondaryGroup, $isActive, $userId]);
}
$msg[] = 'User updated successfully';
logActivity('users.update', 'user', $userId, "Updated user: $username");
} else {
// Create new user
if (!$password) {
$err[] = 'Password is required for new users';
} else {
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $pdo->prepare("INSERT INTO users (username, email, display_name, role, secondary_group, is_active, password_hash) VALUES (?, ?, ?, ?, ?, ?, ?)");
$stmt->execute([$username, $email, $displayName, $role, $secondaryGroup, $isActive, $passwordHash]);
$userId = $pdo->lastInsertId();
$msg[] = 'User created successfully';
logActivity('users.create', 'user', $userId, "Created user: $username");
}
}
} catch (PDOException $e) {
if (strpos($e->getMessage(), 'Duplicate entry') !== false) {
$err[] = 'Username or email already exists';
} else {
$err[] = 'Database error: ' . $e->getMessage();
}
}
}
}
// Delete User
if (isset($_POST['action']) && $_POST['action'] === 'delete_user') {
$userId = (int)$_POST['user_id'];
// Prevent deleting yourself
if ($userId == $_SESSION['user_id']) {
$err[] = 'You cannot delete your own account';
} else {
try {
$stmt = $pdo->prepare("DELETE FROM users WHERE id=?");
$stmt->execute([$userId]);
$msg[] = 'User deleted successfully';
logActivity('users.delete', 'user', $userId, 'Deleted user');
} catch (PDOException $e) {
$err[] = 'Failed to delete user: ' . $e->getMessage();
}
}
}
// Update Role Permissions
if (isset($_POST['action']) && $_POST['action'] === 'update_permissions') {
$roleId = (int)$_POST['role_id'];
$permissions = $_POST['permissions'] ?? [];
$roleName = '';
try {
$roleStmt = $pdo->prepare("SELECT name FROM roles WHERE id=? LIMIT 1");
$roleStmt->execute([$roleId]);
$roleName = wp_normalize_role_name((string)($roleStmt->fetchColumn() ?: ''));
// Driver and Porter roles are intentionally blocked from Calendar access.
if (in_array($roleName, ['driver', 'porter'], true) && !empty($permissions)) {
$blockedPermissionIds = [];
$blockStmt = $pdo->query("SELECT id FROM permissions WHERE name LIKE 'calendar.%'");
if ($blockStmt) {
$blockedPermissionIds = array_map('intval', $blockStmt->fetchAll(PDO::FETCH_COLUMN));
}
if (!empty($blockedPermissionIds)) {
$permissions = array_values(array_filter($permissions, static function ($permId) use ($blockedPermissionIds) {
return !in_array((int)$permId, $blockedPermissionIds, true);
}));
}
}
// Delete existing permissions for this role
$stmt = $pdo->prepare("DELETE FROM role_permissions WHERE role_id=?");
$stmt->execute([$roleId]);
// Insert new permissions
if (!empty($permissions)) {
$stmt = $pdo->prepare("INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)");
foreach ($permissions as $permId) {
$stmt->execute([$roleId, (int)$permId]);
}
}
$msg[] = 'Permissions updated successfully';
logActivity('roles.update', 'role', $roleId, 'Updated role permissions');
} catch (PDOException $e) {
$err[] = 'Failed to update permissions: ' . $e->getMessage();
}
}
}
// Fetch all users
$users = [];
try {
$stmt = $pdo->query("SELECT id, username, display_name, email, role, secondary_group, is_active, created_at FROM users ORDER BY is_active DESC, display_name ASC");
$users = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable $e) {
error_log("Users fetch error: " . $e->getMessage());
}
// Fetch all roles
$roles = [];
try {
$stmt = $pdo->query("SELECT id, name, display_name FROM roles WHERE name IN ('administrator', 'director', 'manager', 'driver', 'porter') ORDER BY FIELD(name, 'administrator', 'director', 'manager', 'driver', 'porter')");
$roles = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable $e) {
error_log("Roles fetch error: " . $e->getMessage());
}
// Fetch all permissions
$permissions = [];
try {
$stmt = $pdo->query("SELECT id, name, display_name, category FROM permissions ORDER BY category, display_name");
$permissions = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable $e) {
error_log("Permissions fetch error: " . $e->getMessage());
}
// Fetch role permissions
$rolePermissions = [];
try {
$stmt = $pdo->query("SELECT role_id, permission_id FROM role_permissions");
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($result as $row) {
if (!isset($rolePermissions[$row['role_id']])) {
$rolePermissions[$row['role_id']] = [];
}
$rolePermissions[$row['role_id']][] = $row['permission_id'];
}
} catch (Throwable $e) {
error_log("Role permissions fetch error: " . $e->getMessage());
}
$permissionDescriptions = [
'dashboard.view' => 'Can open the main dashboard.',
'management.access' => 'Can open the management area and its operational pages.',
'calendar.view' => 'Can view the calendar.',
'calendar.create' => 'Can add calendar events.',
'calendar.edit' => 'Can edit calendar events.',
'calendar.delete' => 'Can delete calendar events.',
'calendar.categories.manage' => 'Can manage calendar categories and defaults.',
'staff.view' => 'Can view staff lists and profiles.',
'staff.edit' => 'Can create, edit and remove staff accounts.',
'vehicles.view' => 'Can view vehicles.',
'vehicles.edit' => 'Can add and edit vehicles.',
'trutrak.view' => 'Can view the TruTrak Map.',
'trutrak.history' => 'Can view vehicle history in TruTrak.',
'trutrak.manage' => 'Can edit TruTrak connector settings.',
'rota.view' => 'Can view rota entries.',
'rota.edit' => 'Can create and edit rota entries.',
'clock.use' => 'Can use the shift clock.',
'clock.driver_start' => 'Can complete driver start-of-shift details and checks.',
'clock.porter_start' => 'Can complete porter start-of-shift confirmations.',
'clock.view_team' => 'Can review team clock records for the selected day.',
'clock.driver_checkout' => 'Can complete driver end-of-shift checks.',
'clock.porter_checkout' => 'Can complete porter end-of-shift checks.',
'clock.photo_upload' => 'Can upload required shift clock photos.',
'holidays.request' => 'Can submit holiday requests.',
'holidays.view' => 'Can view holiday requests in the management queue.',
'holidays.manage' => 'Can approve or decline holiday requests.',
'incidents.create' => 'Can report incidents.',
'incidents.view' => 'Can view incident records.',
'incidents.manage' => 'Can review and update incidents.',
'contacts.view' => 'Can open the shared contacts book.',
'contacts.manage' => 'Can add and edit contacts.',
'alerts.view' => 'Can open the alerts centre and read alerts.',
'alerts.send' => 'Can send alerts to staff.',
'exports.manage' => 'Can open the exports area.',
'exports.calendar' => 'Can export calendar data.',
'exports.clock' => 'Can export clocking data.',
'exports.rota' => 'Can export rota data.',
'exports.holidays' => 'Can export holiday data.',
'exports.incidents' => 'Can export incident data.',
'integrations.manage' => 'Can configure OneDrive and other integrations.',
'logs.view' => 'Can review activity logs.',
];
$roleGuidance = [
'administrator' => 'Full control of the app, system setup and maintenance.',
'director' => 'Full operational and reporting access, including admin pages.',
'manager' => 'Runs daily operations: vehicles, rota, holidays, incidents, contacts, alerts and team clock records. Staff accounts stay in Administration.',
'driver' => 'Front-line access only: dashboard, shift clock, own rota, holiday requests, contacts, alerts and incident reporting. Driver clock out can require checks and photos. Calendar is system-blocked for this role.',
'porter' => 'Front-line access only: dashboard, shift clock, own rota, holiday requests, contacts, alerts and incident reporting. Porter clock out can require completion checks. Calendar is system-blocked for this role.',
];
include __DIR__ . '/../partials/header.php';
?>
<div class="content-header">
<div>
<h1 class="content-title">👥 Staff Management</h1>
<p class="content-subtitle">Manage staff accounts and role permissions</p>
</div>
<div style="display: flex; gap: 12px;">
<a href="?tab=staff" class="btn <?= $tab === 'staff' ? 'btn-primary' : 'btn-secondary' ?>">
👥 Staff Management
</a>
<a href="?tab=permissions" class="btn <?= $tab === 'permissions' ? 'btn-primary' : 'btn-secondary' ?>">
🔐 Role Permissions
</a>
</div>
</div>
<?php if ($msg): foreach ($msg as $m): ?>
<div class="alert alert-success"><?= htmlspecialchars($m) ?></div>
<?php endforeach; endif; ?>
<?php if ($err): foreach ($err as $e): ?>
<div class="alert alert-error"><?= htmlspecialchars($e) ?></div>
<?php endforeach; endif; ?>
<?php if ($tab === 'staff'): ?>
<!-- Staff Management Tab -->
<div class="card">
<div class="card-header">
<h3 class="card-title">All Staff (<?= count($users) ?>)</h3>
<button class="btn btn-primary" type="button" onclick="openUserModal()">
➕ Add Staff
</button>
</div>
<div class="card-body">
<?php if (empty($users)): ?>
<p class="text-muted">No staff members found.</p>
<?php else: ?>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Display Name</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($users as $user): ?>
<tr>
<td><?= (int)$user['id'] ?></td>
<td><?= htmlspecialchars($user['username']) ?></td>
<td><?= htmlspecialchars($user['display_name']) ?></td>
<td><?= htmlspecialchars($user['email']) ?></td>
<td>
<span class="badge badge-secondary">
<?= htmlspecialchars(ucwords(str_replace('-', ' ', $user['role']))) ?>
</span>
</td>
<td>
<?php if ($user['is_active']): ?>
<span class="badge badge-success">Active</span>
<?php else: ?>
<span class="badge badge-danger">Inactive</span>
<?php endif; ?>
</td>
<td>
<div style="display: flex; gap: 6px;">
<button class="btn btn-xs btn-secondary" onclick='editUser(<?= json_encode($user) ?>)'>Edit</button>
<?php if ($user['id'] != $_SESSION['user_id']): ?>
<form method="POST" style="margin: 0;" onsubmit="return confirm('Delete this user? This cannot be undone.');">
<input type="hidden" name="action" value="delete_user">
<input type="hidden" name="user_id" value="<?= $user['id'] ?>">
<button type="submit" class="btn btn-danger btn-xs">🗑</button>
</form>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
<!-- User Modal -->
<div class="modal-overlay" id="userModal" style="display: none;">
<div class="modal modal-medium">
<div class="modal-header">
<h3 class="modal-title" id="userModalTitle">Create Staff</h3>
<button class="modal-close" type="button" onclick="closeUserModal()">×</button>
</div>
<form method="POST" id="userForm">
<input type="hidden" name="action" value="save_user">
<input type="hidden" name="user_id" id="user_id">
<div class="modal-body">
<div class="form-group">
<label>Username *</label>
<input type="text" name="username" id="username" class="form-control" required>
</div>
<div class="form-group">
<label>Display Name *</label>
<input type="text" name="display_name" id="display_name" class="form-control" required>
</div>
<div class="form-group">
<label>Email *</label>
<input type="email" name="email" id="email" class="form-control" required>
</div>
<div class="form-group">
<label>Password <span id="passwordHint">(leave blank to keep current)</span></label>
<input type="password" name="password" id="password" class="form-control">
</div>
<div class="form-group">
<label>Role *</label>
<select name="role" id="role" class="form-control" required onchange="toggleSecondaryGroup()">
<option value="">Select Role</option>
<option value="administrator">Administrator</option>
<option value="director">Director</option>
<option value="manager">Manager</option>
<option value="driver">Driver</option>
<option value="porter">Porter</option>
</select>
</div>
<div class="form-group" id="secondaryGroupField" style="display: none;">
<label>Secondary Group * <small>(Required for non-driver/porter roles)</small></label>
<select name="secondary_group" id="secondary_group" class="form-control">
<option value="">Select Group</option>
<option value="office">Office</option>
<option value="driver">Driver</option>
<option value="porter">Porter</option>
</select>
<p class="small text-muted" style="margin-top: 8px;">
<strong>Office:</strong> Can only be assigned as office staff in calendar<br>
<strong>Driver:</strong> Can be assigned as driver in calendar<br>
<strong>Porter:</strong> Can be assigned as porter in calendar
</p>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="is_active" id="is_active" checked>
Active
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeUserModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
<script>
function toggleSecondaryGroup() {
const role = document.getElementById('role').value;
const field = document.getElementById('secondaryGroupField');
const select = document.getElementById('secondary_group');
if (role && role !== 'driver' && role !== 'porter') {
// Show secondary group field for non-driver/porter roles
field.style.display = 'block';
select.required = true;
} else {
// Hide for driver/porter roles
field.style.display = 'none';
select.required = false;
select.value = '';
}
}
function openUserModal() {
document.getElementById('userModalTitle').textContent = 'Create Staff';
document.getElementById('userForm').reset();
document.getElementById('user_id').value = '';
document.getElementById('passwordHint').textContent = '*';
document.getElementById('password').required = true;
document.getElementById('secondaryGroupField').style.display = 'none';
document.getElementById('secondary_group').required = false;
document.getElementById('userModal').style.display = 'flex';
}
function editUser(user) {
document.getElementById('userModalTitle').textContent = 'Edit Staff';
document.getElementById('user_id').value = user.id;
document.getElementById('username').value = user.username;
document.getElementById('display_name').value = user.display_name;
document.getElementById('email').value = user.email;
document.getElementById('role').value = user.role;
document.getElementById('secondary_group').value = user.secondary_group || '';
document.getElementById('is_active').checked = user.is_active == 1;
document.getElementById('password').value = '';
document.getElementById('passwordHint').textContent = '(leave blank to keep current)';
document.getElementById('password').required = false;
toggleSecondaryGroup(); // Show/hide secondary group based on role
document.getElementById('userModal').style.display = 'flex';
}
function closeUserModal() {
document.getElementById('userModal').style.display = 'none';
}
</script>
<?php else: ?>
<!-- Role Permissions Tab -->
<div class="card">
<div class="card-header">
<h3 class="card-title">🔐 Role Permissions</h3>
</div>
<div class="card-body">
<?php if (empty($roles)): ?>
<p class="text-muted">No roles found.</p>
<?php else: ?>
<?php foreach ($roles as $role): ?>
<?php
$roleName = wp_normalize_role_name((string)$role['name']);
$systemRole = in_array($roleName, ['administrator', 'director'], true);
$calendarBlockedRole = in_array($roleName, ['driver', 'porter'], true);
$enabledCount = isset($rolePermissions[$role['id']]) ? count($rolePermissions[$role['id']]) : 0;
$totalCount = count($permissions);
?>
<div class="card perm-role-card <?= $systemRole ? 'collapsed' : '' ?>" style="margin-bottom: 20px;">
<div class="card-header perm-role-header">
<button type="button" class="perm-role-toggle" aria-expanded="<?= $systemRole ? 'false' : 'true' ?>" aria-controls="perm-body-<?= (int)$role['id'] ?>">
<span class="perm-role-title"><?= htmlspecialchars($role['display_name']) ?></span>
<?php if ($systemRole): ?>
<span class="badge badge-success">System controlled</span>
<span class="perm-count">Full access</span>
<?php else: ?>
<?php if ($calendarBlockedRole): ?><span class="badge badge-warning">Calendar blocked</span><?php endif; ?>
<span class="perm-count"><?= (int)$enabledCount ?> / <?= (int)$totalCount ?></span>
<?php endif; ?>
</button>
<div class="text-muted" style="padding:0 16px 14px 16px;"><?= htmlspecialchars($roleGuidance[$roleName] ?? '') ?></div>
<?php if (!$systemRole): ?>
<button type="submit" class="btn btn-primary" form="perm-form-<?= (int)$role['id'] ?>">Save Permissions</button>
<?php endif; ?>
</div>
<div class="card-body">
<div class="perm-role-card-body" id="perm-body-<?= (int)$role['id'] ?>">
<div class="perm-role-body">
<?php if ($systemRole): ?>
<div class="alert alert-info" style="margin:0;">
<strong><?= htmlspecialchars($role['display_name']) ?> permissions are system controlled.</strong><br>
This role always has full app access and does not need individual permission checkboxes saved.
</div>
<?php else: ?>
<form method="POST" id="perm-form-<?= (int)$role['id'] ?>">
<input type="hidden" name="action" value="update_permissions">
<input type="hidden" name="role_id" value="<?= (int)$role['id'] ?>">
<?php
$permissionByName = [];
foreach ($permissions as $perm) {
$permissionByName[$perm['name']] = $perm;
}
$permissionGroups = [
'Navigation & Access' => ['dashboard.view', 'management.access'],
'Calendar' => ['calendar.view', 'calendar.create', 'calendar.edit', 'calendar.delete', 'calendar.categories.manage'],
'Staff' => ['staff.view', 'staff.edit'],
'Vehicles & Tracking' => ['vehicles.view', 'vehicles.edit', 'trutrak.view', 'trutrak.history', 'trutrak.manage'],
'Rota & Clocking' => ['rota.view', 'rota.edit', 'clock.use', 'clock.view_team', 'clock.driver_start', 'clock.driver_checkout', 'clock.porter_start', 'clock.porter_checkout', 'clock.photo_upload'],
'Holiday Requests' => ['holidays.request', 'holidays.view', 'holidays.manage'],
'Incidents' => ['incidents.create', 'incidents.view', 'incidents.manage'],
'Contacts & Alerts' => ['contacts.view', 'contacts.manage', 'alerts.view', 'alerts.send'],
'Exports & System' => ['exports.manage', 'exports.calendar', 'exports.clock', 'exports.rota', 'exports.holidays', 'exports.incidents', 'integrations.manage', 'logs.view'],
];
if ($calendarBlockedRole) {
unset($permissionGroups['Calendar']);
}
?>
<?php if ($calendarBlockedRole): ?>
<div class="alert alert-warning">
Calendar access is disabled for <?= htmlspecialchars($role['display_name']) ?> by system policy, so Calendar permissions are not shown or saved for this role.
</div>
<?php endif; ?>
<div class="grid-2 perm-grid">
<?php foreach ($permissionGroups as $category => $permissionNames): ?>
<div class="perm-group-card">
<div class="perm-group-header">
<span><?= htmlspecialchars($category) ?></span>
<span class="perm-count"><?= (int)count($permissionNames) ?></span>
</div>
<div class="perm-items">
<?php foreach ($permissionNames as $permissionName): ?>
<?php if (empty($permissionByName[$permissionName])) continue; $perm = $permissionByName[$permissionName]; ?>
<label class="perm-item">
<input type="checkbox"
name="permissions[]"
value="<?= (int)$perm['id'] ?>"
<?= isset($rolePermissions[$role['id']]) && in_array($perm['id'], $rolePermissions[$role['id']]) ? 'checked' : '' ?>>
<span><?= htmlspecialchars($perm['display_name']) ?><small class="text-muted" style="display:block;font-size:.78rem;line-height:1.35;"><?= htmlspecialchars($permissionDescriptions[$perm['name']] ?? $perm['name']) ?></small></span>
</label>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
</div>
</form>
<?php endif; ?>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<script>
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('.perm-role-toggle').forEach(function (btn) {
btn.addEventListener('click', function () {
var card = btn.closest('.perm-role-card');
if (!card) return;
var collapsed = card.classList.toggle('collapsed');
btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
});
});
});
</script>
<?php include __DIR__ . '/../partials/footer.php'; ?>