feat: bootstrap JOBcard CRM foundation

This commit is contained in:
Marco0300
2026-09-01 18:54:47 +02:00
commit 480494c5ed
31 changed files with 1300 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
APP_ENV=development
APP_KEY=replace-with-a-long-random-secret
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=replace-with-a-long-unique-password
DB_PASSWORD=replace-with-a-long-database-password
DB_ROOT_PASSWORD=replace-with-a-long-root-password
+9
View File
@@ -0,0 +1,9 @@
.env
.env.*
!.env.example
vendor/
node_modules/
.DS_Store
storage/logs/
storage/uploads/
.phpunit.result.cache
+14
View File
@@ -0,0 +1,14 @@
FROM php:8.4-fpm-alpine
RUN docker-php-ext-install pdo_mysql
WORKDIR /var/www/html
COPY app ./app
COPY config ./config
COPY database ./database
COPY public ./public
RUN addgroup -g 1000 appgroup && adduser -D -u 1000 -G appgroup appuser \
&& chown -R appuser:appgroup /var/www/html
USER appuser
CMD ["php-fpm", "-F"]
+36
View File
@@ -0,0 +1,36 @@
# JOBcard & Client Management System
Greenfield PHP/MariaDB implementation of the approved JOBcard scope.
## Current increment
Verified foundation and early domain slices: Docker runtime, MariaDB schema, secure session authentication, environment-based Administrator bootstrap, role-aware navigation/dashboard, CSRF protection, password hashing, audit events, client/contact validation, client search/detail views, jobcard creation/listing, jobcard references and status rules, time-entry validation/aggregation, SLA calculations/classification, and safe internal versus client-facing reporting contracts.
## Requirements
- Docker Engine with Compose v2
- A `.env` file copied from `.env.example` with unique values filled in
## Run locally
```bash
cp .env.example .env
# Replace every placeholder in .env with unique local values.
docker compose up --build
```
Open http://localhost:8082. The first application boot creates the Administrator user from `ADMIN_EMAIL` and `ADMIN_PASSWORD`; the password is hashed with PHP's password API and is never stored in configuration or SQL.
## Verification
```bash
docker compose config
find app config database public -type f -name '*.php' -print0 | xargs -0 -n1 php -l
```
## Security notes
- Do not commit `.env` or production credentials.
- Set `APP_KEY` to a long random value and keep it in a secrets manager in production.
- Credential vault encryption and the remaining domain modules are scheduled in later phases.
- The initial schema is intentionally migration-ready but is delivered as an idempotent bootstrap SQL file for the first Docker increment.
+1
View File
@@ -0,0 +1 @@
# Domain services and controllers will be added in subsequent phases.
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
/**
* Normalize the fields accepted by a client contact form.
*
* Empty optional values are represented as null and the primary flag is a bool.
*/
function normalize_client_contact(array $input): array
{
$email = trim((string)($input['email'] ?? ''));
$phone = trim((string)($input['phone'] ?? ''));
return [
'name' => trim((string)($input['name'] ?? '')),
'email' => $email === '' ? null : strtolower($email),
'phone' => $phone === '' ? null : $phone,
'is_primary' => normalize_client_contact_primary($input['is_primary'] ?? $input['primary'] ?? false),
];
}
/**
* Validate and normalize a client contact in one reusable operation.
*/
function validate_client_contact(array $input): array
{
$contact = normalize_client_contact($input);
$errors = [];
if ($contact['name'] === '') {
$errors['name'] = 'Contact name is required.';
} elseif (mb_strlen($contact['name']) > 120) {
$errors['name'] = 'Contact name must be 120 characters or fewer.';
}
if ($contact['email'] !== null && filter_var($contact['email'], FILTER_VALIDATE_EMAIL) === false) {
$errors['email'] = 'Contact email must be a valid email address.';
} elseif ($contact['email'] !== null && mb_strlen($contact['email']) > 190) {
$errors['email'] = 'Contact email must be 190 characters or fewer.';
}
if ($contact['phone'] !== null && mb_strlen($contact['phone']) > 60) {
$errors['phone'] = 'Contact phone must be 60 characters or fewer.';
}
if (!is_bool($contact['is_primary'])) {
$errors['is_primary'] = 'Primary contact flag must be boolean.';
$contact['is_primary'] = false;
}
return [...$contact, 'errors' => $errors];
}
function normalize_client_contact_primary(mixed $value): bool|int|string
{
if (is_bool($value)) {
return $value;
}
if (is_int($value) && ($value === 0 || $value === 1)) {
return $value === 1;
}
if (is_string($value)) {
$normalized = strtolower(trim($value));
if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) {
return true;
}
if (in_array($normalized, ['', '0', 'false', 'no', 'off'], true)) {
return false;
}
}
return $value;
}
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
/**
* Return whether a client name already occurs in a list, ignoring case and outer whitespace.
* Existing entries may be strings or rows containing a `name` field.
*/
function client_name_is_duplicate(string $name, array $existingClients): bool
{
$candidate = strtolower(trim($name));
foreach ($existingClients as $existing) {
$existingName = is_array($existing) ? ($existing['name'] ?? '') : $existing;
if (is_string($existingName) && strtolower(trim($existingName)) === $candidate) {
return true;
}
}
return false;
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
function validate_client(array $input): array
{
$name = trim((string)($input['name'] ?? ''));
$status = (string)($input['status'] ?? 'active');
$errors = [];
if ($name === '') $errors['name'] = 'Client name is required.';
if (mb_strlen($name) > 190) $errors['name'] = 'Client name must be 190 characters or fewer.';
if (!in_array($status, ['active', 'inactive'], true)) $errors['status'] = 'Invalid client status.';
return ['name' => $name, 'status' => $status, 'errors' => $errors];
}
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Domain\Jobcard;
final class JobcardReference
{
public static function generate(int $sequence, ?int $year = null): string
{
if ($sequence < 1) {
throw new \InvalidArgumentException('Sequence must be positive.');
}
$year ??= (int) date('Y');
if ($year < 2000 || $year > 9999) {
throw new \InvalidArgumentException('Year must be four digits.');
}
return sprintf('JC-%04d-%06d', $year, $sequence);
}
public static function isValid(string $reference): bool
{
return preg_match('/^JC-\d{4}-\d{6}$/', $reference) === 1;
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Domain\Jobcard;
final class StatusTransitionValidator
{
public const STATUSES = ['new', 'assigned', 'in_progress', 'awaiting_client', 'awaiting_parts', 'completed', 'closed'];
private const TRANSITIONS = [
'new' => ['assigned'],
'assigned' => ['in_progress'],
'in_progress' => ['awaiting_client', 'awaiting_parts', 'completed'],
'awaiting_client' => ['in_progress', 'completed'],
'awaiting_parts' => ['in_progress', 'completed'],
'completed' => ['closed'],
'closed' => [],
];
public function canTransition(string $from, string $to): bool
{
return in_array($from, self::STATUSES, true)
&& in_array($to, self::STATUSES, true)
&& ($from === $to || in_array($to, self::TRANSITIONS[$from], true));
}
public function allowedFrom(string $from): array
{
return in_array($from, self::STATUSES, true) ? array_merge([$from], self::TRANSITIONS[$from]) : [];
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Domain\Jobcard;
final class TimeAggregator
{
public function total(array $entries): float
{
$total = 0.0;
foreach ($entries as $entry) {
if (is_array($entry) && isset($entry['hours']) && is_numeric($entry['hours'])) {
$total += max(0.0, (float) $entry['hours']);
} elseif (is_numeric($entry)) {
$total += max(0.0, (float) $entry);
}
}
return round($total, 2);
}
public function slaTotal(array $entries): float
{
return $this->total(array_values(array_filter(
$entries,
static fn ($entry): bool => is_array($entry) && (($entry['counts_toward_sla'] ?? true) === true)
)));
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
function calculate_duration_hours(?string $start, ?string $end, ?float $manualHours = null): ?float
{
if ($manualHours !== null) {
return $manualHours >= 0 ? round($manualHours, 2) : null;
}
if ($start === null || $end === null || !preg_match('/^\d{2}:\d{2}$/', $start) || !preg_match('/^\d{2}:\d{2}$/', $end)) {
return null;
}
[$startHour, $startMinute] = array_map('intval', explode(':', $start));
[$endHour, $endMinute] = array_map('intval', explode(':', $end));
if ($startHour > 23 || $endHour > 23 || $startMinute > 59 || $endMinute > 59) return null;
$minutes = ($endHour * 60 + $endMinute) - ($startHour * 60 + $startMinute);
return $minutes > 0 ? round($minutes / 60, 2) : null;
}
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Domain\Jobcard;
require_once __DIR__ . '/TimeCalculator.php';
final class TimeEntryValidator
{
public function validate(array $entry): array
{
$errors = [];
$date = trim((string)($entry['work_date'] ?? ''));
if (!$this->validDate($date)) $errors['work_date'] = 'Work date must be a valid date.';
$manual = null;
if (array_key_exists('hours', $entry) && $entry['hours'] !== null) {
if (!is_numeric($entry['hours'])) {
$errors['hours'] = 'Hours must be numeric.';
} else {
$manual = (float) $entry['hours'];
if ($manual < 0) $errors['hours'] = 'Hours must not be negative.';
}
}
$start = $entry['start_time'] ?? null;
$end = $entry['end_time'] ?? null;
if ($manual === null && (($start === null) xor ($end === null))) {
$errors['time'] = 'Start and end time must be supplied together.';
}
$hours = \calculate_duration_hours($start !== null ? (string)$start : null, $end !== null ? (string)$end : null, $manual);
if ($hours === null && !isset($errors['hours']) && !isset($errors['time'])) {
$errors['time'] = 'A positive duration or manual hours is required.';
}
return ['valid' => $errors === [], 'hours' => $hours, 'errors' => $errors];
}
public function isValid(array $entry): bool
{
return $this->validate($entry)['valid'];
}
private function validDate(string $date): bool
{
$parsed = \DateTimeImmutable::createFromFormat('!Y-m-d', $date);
return $parsed !== false && $parsed->format('Y-m-d') === $date;
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/ReportDataMapper.php';
final class HoursPerClientReport
{
/** @param list<array<string, mixed>> $entries
* @return list<array{client_id:int, client_name:string, hours:float}>
*/
public function aggregate(array $entries): array
{
$totals = [];
foreach ($entries as $entry) {
$id = (int)($entry['client_id'] ?? 0);
$key = (string)$id;
if (!isset($totals[$key])) {
$totals[$key] = [
'client_id' => $id,
'client_name' => (string)($entry['client_name'] ?? ''),
'hours' => 0.0,
];
}
$totals[$key]['hours'] += max(0.0, (float)($entry['hours'] ?? 0));
}
$rows = array_values($totals);
foreach ($rows as &$row) {
$row['hours'] = round($row['hours'], 2);
}
unset($row);
usort($rows, static fn (array $a, array $b): int => strcmp($a['client_name'], $b['client_name']) ?: $a['client_id'] <=> $b['client_id']);
return $rows;
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
/**
* Maps raw domain rows into an explicitly allow-listed client view and a
* separately retained internal view. This class has no framework or storage
* dependency and is safe to use before an export format is selected.
*/
final class ReportDataMapper
{
/** @var list<string> */
private const CLIENT_FIELDS = [
'id',
'name',
'registration_number',
'status',
'support_email',
'support_phone',
'preferred_contact_method',
'physical_address',
'postal_address',
'general_notes',
'client_id',
'client_name',
'reference_no',
'priority',
'work_requested',
'completed_at',
'closed_at',
'allocated_hours',
'used_hours',
'remaining_hours',
'usage_percentage',
'status_label',
'period_type',
'start_date',
'end_date',
'hours',
];
/** @return array<string, mixed> */
public function clientFacing(array $record): array
{
$safe = [];
foreach (self::CLIENT_FIELDS as $field) {
if (array_key_exists($field, $record)) {
$safe[$field] = $record[$field];
}
}
return $safe;
}
/** @return array<string, mixed> */
public function internal(array $record): array
{
return array_diff_key($record, $this->clientFacing($record));
}
/** @return array{client: array<string, mixed>, internal: array<string, mixed>} */
public function map(array $record): array
{
return ['client' => $this->clientFacing($record), 'internal' => $this->internal($record)];
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
final class SlaReport
{
/** @param list<array<string, mixed>> $agreements
* @return list<array<string, mixed>>
*/
public function rows(array $agreements): array
{
$rows = [];
foreach ($agreements as $agreement) {
$allocated = max(0.0, (float)($agreement['allocated_hours'] ?? 0));
$used = 0.0;
foreach ((array)($agreement['hours'] ?? []) as $hours) {
$used += max(0.0, (float)$hours);
}
$used = round($used, 2);
$remaining = round(max(0.0, $allocated - $used), 2);
$percentage = $allocated > 0
? round(($used / $allocated) * 100, 2)
: ($used > 0 ? 100.0 : 0.0);
$status = $used > $allocated
? 'exceeded'
: ($percentage >= 90 ? 'critical' : ($percentage >= 75 ? 'warning' : 'within_limit'));
$rows[] = [
'client_id' => (int)($agreement['client_id'] ?? 0),
'client_name' => (string)($agreement['client_name'] ?? ''),
'allocated_hours' => $allocated,
'used_hours' => $used,
'remaining_hours' => $remaining,
'usage_percentage' => $percentage,
'status' => $status,
];
}
usort($rows, static fn (array $a, array $b): int => strcmp($a['client_name'], $b['client_name']) ?: $a['client_id'] <=> $b['client_id']);
return $rows;
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
function calculate_sla_usage(float $allocatedHours, array $hours): array
{
$used = round(array_sum(array_map(static fn ($value): float => max(0.0, (float)$value), $hours)), 2);
$remaining = round(max(0.0, $allocatedHours - $used), 2);
$percentage = $allocatedHours > 0 ? round(($used / $allocatedHours) * 100, 2) : ($used > 0 ? 100.0 : 0.0);
$status = $used > $allocatedHours ? 'exceeded' : ($percentage >= 90 ? 'critical' : ($percentage >= 75 ? 'warning' : 'within_limit'));
return ['used' => $used, 'remaining' => $remaining, 'percentage' => $percentage, 'status' => $status];
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Domain\SLA;
final class SlaThresholdClassifier
{
public const WITHIN_LIMIT = 'within_limit';
public const WARNING = 'warning';
public const CRITICAL = 'critical';
public const EXCEEDED = 'exceeded';
public function classify(float $usedHours, float $allocatedHours): string
{
if ($allocatedHours < 0) throw new \InvalidArgumentException('Allocated hours must not be negative.');
$usedHours = max(0.0, $usedHours);
if ($allocatedHours === 0.0) return $usedHours > 0.0 ? self::EXCEEDED : self::WITHIN_LIMIT;
if ($usedHours > $allocatedHours) return self::EXCEEDED;
$percentage = ($usedHours / $allocatedHours) * 100;
return $percentage >= 90.0 ? self::CRITICAL : ($percentage >= 75.0 ? self::WARNING : self::WITHIN_LIMIT);
}
public function percentage(float $usedHours, float $allocatedHours): float
{
if ($allocatedHours < 0) throw new \InvalidArgumentException('Allocated hours must not be negative.');
$usedHours = max(0.0, $usedHours);
return $allocatedHours > 0.0 ? round(($usedHours / $allocatedHours) * 100, 2) : ($usedHours > 0.0 ? 100.0 : 0.0);
}
}
+141
View File
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
function env_required(string $name): string
{
$value = getenv($name);
if ($value === false || trim($value) === '') {
throw new RuntimeException("Missing required environment variable: {$name}");
}
return $value;
}
function db(): PDO
{
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
$dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
env_required('DB_HOST'), getenv('DB_PORT') ?: '3306', env_required('DB_DATABASE'));
$pdo = new PDO($dsn, env_required('DB_USERNAME'), env_required('DB_PASSWORD'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
}
function csrf_token(): string
{
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf'];
}
function verify_csrf(): void
{
$provided = (string)($_POST['_csrf'] ?? '');
if (!hash_equals((string)($_SESSION['csrf'] ?? ''), $provided)) {
http_response_code(419);
exit('Invalid CSRF token');
}
}
function ensure_initial_administrator(): void
{
static $checked = false;
if ($checked) {
return;
}
$checked = true;
$count = (int)db()->query('SELECT COUNT(*) FROM users')->fetchColumn();
if ($count !== 0) {
return;
}
$email = strtolower(trim(env_required('ADMIN_EMAIL')));
$password = env_required('ADMIN_PASSWORD');
if (strlen($password) < 12) {
throw new RuntimeException('ADMIN_PASSWORD must be at least 12 characters');
}
$roleId = (int)db()->query("SELECT id FROM roles WHERE name = 'Administrator'")->fetchColumn();
if ($roleId < 1) {
throw new RuntimeException('Administrator role is missing from the database');
}
$stmt = db()->prepare('INSERT INTO users (role_id, email, name, password_hash) VALUES (:role, :email, :name, :hash)');
$stmt->execute([
'role' => $roleId,
'email' => $email,
'name' => 'System Administrator',
'hash' => password_hash($password, PASSWORD_DEFAULT),
]);
}
function current_user(): ?array
{
ensure_initial_administrator();
static $user = false;
if ($user !== false) {
return $user;
}
$id = $_SESSION['user_id'] ?? null;
if (!$id) {
return $user = null;
}
$stmt = db()->prepare('SELECT u.*, r.name AS role_name FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = :id AND u.is_active = 1');
$stmt->execute(['id' => $id]);
return $user = ($stmt->fetch() ?: null);
}
function require_login(): array
{
$user = current_user();
if (!$user) {
header('Location: /?route=login');
exit;
}
return $user;
}
function can(string $permission): bool
{
$user = current_user();
if (!$user) return false;
static $permissions = null;
if ($permissions === null) {
$stmt = db()->prepare('SELECT p.name FROM permissions p JOIN role_permissions rp ON rp.permission_id = p.id WHERE rp.role_id = :role');
$stmt->execute(['role' => $user['role_id']]);
$permissions = array_column($stmt->fetchAll(), 'name');
}
return in_array($permission, $permissions, true);
}
function require_permission(string $permission): void
{
if (!can($permission)) {
http_response_code(403);
exit('Forbidden');
}
}
function audit(string $action, string $entityType, ?int $entityId = null, array $metadata = []): void
{
$user = current_user();
$stmt = db()->prepare('INSERT INTO audit_events (user_id, action, entity_type, entity_id, metadata, ip_address) VALUES (:user_id, :action, :entity_type, :entity_id, :metadata, :ip)');
$stmt->execute([
'user_id' => $user['id'] ?? null,
'action' => $action,
'entity_type' => $entityType,
'entity_id' => $entityId,
'metadata' => $metadata ? json_encode($metadata, JSON_THROW_ON_ERROR) : null,
'ip' => $_SERVER['REMOTE_ADDR'] ?? null,
]);
}
function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
+180
View File
@@ -0,0 +1,180 @@
CREATE TABLE roles (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(80) NOT NULL UNIQUE,
description VARCHAR(255) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE TABLE permissions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(120) NOT NULL UNIQUE,
description VARCHAR(255) NULL
) ENGINE=InnoDB;
CREATE TABLE role_permissions (
role_id BIGINT UNSIGNED NOT NULL,
permission_id BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (role_id, permission_id),
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE users (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
role_id BIGINT UNSIGNED NOT NULL,
email VARCHAR(190) NOT NULL UNIQUE,
name VARCHAR(120) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
last_login_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (role_id) REFERENCES roles(id)
) ENGINE=InnoDB;
CREATE TABLE clients (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(190) NOT NULL,
registration_number VARCHAR(120) NULL,
status ENUM('active', 'inactive') NOT NULL DEFAULT 'active',
support_email VARCHAR(190) NULL,
support_phone VARCHAR(60) NULL,
preferred_contact_method VARCHAR(40) NULL,
physical_address TEXT NULL,
postal_address TEXT NULL,
general_notes TEXT NULL,
created_by BIGINT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX clients_name_idx (name),
INDEX clients_status_idx (status),
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB;
CREATE TABLE client_contacts (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
client_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(120) NOT NULL,
email VARCHAR(190) NULL,
phone VARCHAR(60) NULL,
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
notes TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE CASCADE,
INDEX contacts_client_idx (client_id)
) ENGINE=InnoDB;
CREATE TABLE sla_agreements (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
client_id BIGINT UNSIGNED NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
agreement_type VARCHAR(120) NULL,
allocated_hours DECIMAL(10,2) NOT NULL DEFAULT 0,
period_type ENUM('monthly', 'annual', 'custom') NOT NULL DEFAULT 'monthly',
start_date DATE NULL,
end_date DATE NULL,
rollover_enabled BOOLEAN NOT NULL DEFAULT FALSE,
notes TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE CASCADE,
INDEX sla_client_idx (client_id)
) ENGINE=InnoDB;
CREATE TABLE jobcards (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
reference_no VARCHAR(40) NOT NULL UNIQUE,
client_id BIGINT UNSIGNED NOT NULL,
created_by BIGINT UNSIGNED NULL,
priority VARCHAR(40) NOT NULL DEFAULT 'normal',
status VARCHAR(60) NOT NULL DEFAULT 'new',
work_requested TEXT NOT NULL,
technician_notes TEXT NULL,
internal_notes TEXT NULL,
completed_at DATETIME NULL,
closed_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (client_id) REFERENCES clients(id),
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX jobcards_client_idx (client_id),
INDEX jobcards_status_idx (status),
INDEX jobcards_created_idx (created_at)
) ENGINE=InnoDB;
CREATE TABLE jobcard_assignments (
jobcard_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
assigned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
assigned_by BIGINT UNSIGNED NULL,
PRIMARY KEY (jobcard_id, user_id),
FOREIGN KEY (jobcard_id) REFERENCES jobcards(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (assigned_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB;
CREATE TABLE time_entries (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
jobcard_id BIGINT UNSIGNED NOT NULL,
technician_id BIGINT UNSIGNED NOT NULL,
work_date DATE NOT NULL,
start_time TIME NULL,
end_time TIME NULL,
hours DECIMAL(10,2) NOT NULL,
notes TEXT NULL,
counts_toward_sla BOOLEAN NOT NULL DEFAULT TRUE,
created_by BIGINT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (jobcard_id) REFERENCES jobcards(id) ON DELETE CASCADE,
FOREIGN KEY (technician_id) REFERENCES users(id),
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX time_jobcard_idx (jobcard_id),
INDEX time_date_idx (work_date)
) ENGINE=InnoDB;
CREATE TABLE audit_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NULL,
action VARCHAR(120) NOT NULL,
entity_type VARCHAR(80) NOT NULL,
entity_id BIGINT UNSIGNED NULL,
metadata JSON NULL,
ip_address VARCHAR(45) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX audit_entity_idx (entity_type, entity_id),
INDEX audit_created_idx (created_at),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB;
INSERT INTO roles (name, description) VALUES
('Administrator', 'Full system access'),
('Accounts', 'Client, jobcard and reporting access'),
('Technician', 'Assigned support work access');
INSERT INTO permissions (name, description) VALUES
('dashboard.view', 'View the operational dashboard'),
('clients.view', 'View client records'),
('clients.manage', 'Create and edit client records'),
('jobcards.view', 'View jobcards'),
('jobcards.manage', 'Create and update jobcards'),
('reports.view', 'View reports'),
('reports.export', 'Export reports'),
('users.manage', 'Manage users'),
('roles.manage', 'Manage roles and permissions'),
('audit.view', 'View audit events'),
('credentials.view', 'View protected credentials');
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r CROSS JOIN permissions p WHERE r.name = 'Administrator';
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r JOIN permissions p ON p.name IN
('dashboard.view','clients.view','jobcards.view','reports.view','reports.export')
WHERE r.name = 'Accounts';
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r JOIN permissions p ON p.name IN
('dashboard.view','clients.view','jobcards.view','jobcards.manage')
WHERE r.name = 'Technician';
+55
View File
@@ -0,0 +1,55 @@
services:
app:
build: .
environment:
APP_ENV: ${APP_ENV:-development}
APP_KEY: ${APP_KEY:?Set APP_KEY in .env}
ADMIN_EMAIL: ${ADMIN_EMAIL:?Set ADMIN_EMAIL in .env}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Set ADMIN_PASSWORD in .env}
DB_HOST: db
DB_PORT: 3306
DB_DATABASE: jobcard
DB_USERNAME: jobcard
DB_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD in .env}
volumes:
- ./public:/var/www/html/public
- ./app:/var/www/html/app
- ./database:/var/www/html/database
- ./config:/var/www/html/config
depends_on:
db:
condition: service_healthy
networks: [jobcard]
web:
image: nginx:1.27-alpine
ports:
- "8082:80"
volumes:
- ./public:/var/www/html/public:ro
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on: [app]
networks: [jobcard]
db:
image: mariadb:11.4
environment:
MARIADB_DATABASE: jobcard
MARIADB_USER: jobcard
MARIADB_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD in .env}
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:?Set DB_ROOT_PASSWORD in .env}
volumes:
- db-data:/var/lib/mysql
- ./database/schema.sql:/docker-entrypoint-initdb.d/001-schema.sql:ro
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 5s
timeout: 5s
retries: 20
networks: [jobcard]
volumes:
db-data:
networks:
jobcard:
+18
View File
@@ -0,0 +1,18 @@
server {
listen 80;
root /var/www/html/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/html/public$fastcgi_script_name;
fastcgi_param HTTP_PROXY "";
fastcgi_pass app:9000;
}
location ~ /\. { deny all; }
}
+10
View File
@@ -0,0 +1,10 @@
body { background: #f5f7fb; }
.navbar-brand { letter-spacing: .02em; }
.metric-card { border: 0; box-shadow: 0 .25rem 1rem rgba(24, 39, 75, .06); }
.sidebar-link.active { background: rgba(13, 110, 253, .1); color: #0d6efd; font-weight: 600; }
.login-card { max-width: 430px; margin: 10vh auto; }
@media (max-width: 767.98px) {
.desktop-table { display: none; }
.mobile-card { display: block; }
}
@media (min-width: 768px) { .mobile-card { display: none; } }
+176
View File
@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../config/bootstrap.php';
require_once __DIR__ . '/../app/Domain/Client/ClientValidator.php';
session_set_cookie_params(['httponly' => true, 'secure' => !empty($_SERVER['HTTPS']), 'samesite' => 'Lax']);
session_start();
function render_header(string $title): void
{
$user = current_user();
echo '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>' . e($title) . ' · JOBcard</title><link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"><link href="/assets/app.css" rel="stylesheet"></head><body>';
if ($user) {
echo '<nav class="navbar navbar-dark bg-primary"><div class="container-fluid"><a class="navbar-brand fw-bold" href="/?route=dashboard">JOBcard</a><span class="text-white small">' . e($user['name']) . ' · ' . e($user['role_name']) . ' <a class="btn btn-sm btn-light ms-2" href="/?route=logout">Sign out</a></span></div></nav><div class="container-fluid"><div class="row"><aside class="col-md-2 col-lg-2 border-end bg-white min-vh-100 p-3"><nav class="nav flex-column gap-1"><a class="nav-link sidebar-link" href="/?route=dashboard">Dashboard</a>';
if (can('clients.view')) echo '<a class="nav-link sidebar-link" href="/?route=clients">Clients</a>';
if (can('jobcards.view')) echo '<a class="nav-link sidebar-link" href="/?route=jobcards">Jobcards</a>';
if (can('reports.view')) echo '<a class="nav-link sidebar-link" href="/?route=reports">Reports</a>';
if (can('users.manage')) echo '<a class="nav-link sidebar-link" href="/?route=users">Users & roles</a>';
if (can('audit.view')) echo '<a class="nav-link sidebar-link" href="/?route=audit">Audit trail</a>';
echo '</nav></aside><main class="col-md-10 col-lg-10 p-3 p-lg-4">';
} else {
echo '<main class="container">';
}
}
function render_footer(): void
{
$user = current_user();
echo '</main>' . ($user ? '</div></div>' : '') . '<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script></body></html>';
}
$route = $_GET['route'] ?? (current_user() ? 'dashboard' : 'login');
if ($route === 'logout') {
if (current_user()) audit('logout', 'user', (int)current_user()['id']);
$_SESSION = [];
session_destroy();
header('Location: /?route=login');
exit;
}
if ($route === 'login') {
if (current_user()) { header('Location: /?route=dashboard'); exit; }
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
$stmt = db()->prepare('SELECT u.*, r.name AS role_name FROM users u JOIN roles r ON r.id = u.role_id WHERE u.email = :email LIMIT 1');
$stmt->execute(['email' => strtolower(trim((string)($_POST['email'] ?? ''))) ]);
$user = $stmt->fetch();
if (!$user || !$user['is_active'] || !password_verify((string)($_POST['password'] ?? ''), $user['password_hash'])) {
$error = 'The email or password is incorrect.';
} else {
session_regenerate_id(true);
$_SESSION['user_id'] = (int)$user['id'];
$_SESSION['csrf'] = bin2hex(random_bytes(32));
db()->prepare('UPDATE users SET last_login_at = CURRENT_TIMESTAMP WHERE id = :id')->execute(['id' => $user['id']]);
audit('login_success', 'user', (int)$user['id']);
header('Location: /?route=dashboard'); exit;
}
}
render_header('Sign in'); ?><div class="card shadow-sm login-card"><div class="card-body p-4"><h1 class="h3 mb-1">JOBcard</h1><p class="text-muted mb-4">Sign in to the support workspace.</p><?php if ($error): ?><div class="alert alert-danger"><?= e($error) ?></div><?php endif; ?><form method="post"><input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>"><div class="mb-3"><label class="form-label" for="email">Email</label><input class="form-control" id="email" name="email" type="email" autocomplete="username" required></div><div class="mb-4"><label class="form-label" for="password">Password</label><input class="form-control" id="password" name="password" type="password" autocomplete="current-password" required></div><button class="btn btn-primary w-100">Sign in</button></form></div></div><?php render_footer(); exit;
}
$user = require_login();
if ($route === 'dashboard') {
require_permission('dashboard.view');
render_header('Dashboard'); ?><div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1">Dashboard</h1><p class="text-muted mb-0">Your operational overview.</p></div><span class="badge text-bg-primary"><?= e($user['role_name']) ?></span></div><div class="row g-3"><div class="col-sm-6 col-xl-3"><div class="card metric-card"><div class="card-body"><div class="text-muted small">New jobcards</div><div class="display-6 fw-semibold">0</div></div></div></div><div class="col-sm-6 col-xl-3"><div class="card metric-card"><div class="card-body"><div class="text-muted small">Open jobcards</div><div class="display-6 fw-semibold">0</div></div></div></div><div class="col-sm-6 col-xl-3"><div class="card metric-card"><div class="card-body"><div class="text-muted small">Hours this week</div><div class="display-6 fw-semibold">0.0</div></div></div></div><div class="col-sm-6 col-xl-3"><div class="card metric-card"><div class="card-body"><div class="text-muted small">SLA warnings</div><div class="display-6 fw-semibold">0</div></div></div></div></div><div class="card mt-4"><div class="card-body"><h2 class="h5">Foundation ready</h2><p class="mb-0 text-muted">Authentication, role-aware navigation, CSRF protection, password hashing and audit logging are active. Client and jobcard modules will populate this dashboard in the next increments.</p></div></div><?php render_footer(); exit;
}
$permissionByRoute = ['clients'=>'clients.view','jobcards'=>'jobcards.view','reports'=>'reports.view','users'=>'users.manage','audit'=>'audit.view'];
if ($route === 'jobcards') {
require_permission('jobcards.view');
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
require_permission('jobcards.manage');
verify_csrf();
$clientId = filter_var($_POST['client_id'] ?? null, FILTER_VALIDATE_INT);
$workRequested = trim((string)($_POST['work_requested'] ?? ''));
$priority = (string)($_POST['priority'] ?? 'normal');
if (!$clientId || $workRequested === '' || mb_strlen($workRequested) > 10000 || !in_array($priority, ['low', 'normal', 'high', 'critical'], true)) {
$errors[] = 'Select a client, enter the requested work, and choose a valid priority.';
} else {
$clientCheck = db()->prepare("SELECT id FROM clients WHERE id = :id AND status = 'active'");
$clientCheck->execute(['id' => $clientId]);
if (!$clientCheck->fetchColumn()) $errors[] = 'The selected client is not active or does not exist.';
}
if (!$errors) {
$reference = 'JC-' . date('Ymd') . '-' . strtoupper(bin2hex(random_bytes(3)));
$stmt = db()->prepare('INSERT INTO jobcards (reference_no, client_id, created_by, priority, status, work_requested) VALUES (:reference, :client, :created_by, :priority, \'new\', :requested)');
$stmt->execute(['reference' => $reference, 'client' => $clientId, 'created_by' => $user['id'], 'priority' => $priority, 'requested' => $workRequested]);
$jobcardId = (int)db()->lastInsertId();
audit('jobcard_created', 'jobcard', $jobcardId, ['reference_no' => $reference]);
header('Location: /?route=jobcards&created=1'); exit;
}
}
$clients = db()->query("SELECT id, name FROM clients WHERE status = 'active' ORDER BY name")->fetchAll();
$jobcards = db()->query('SELECT j.reference_no, j.priority, j.status, j.work_requested, j.created_at, c.name AS client_name FROM jobcards j JOIN clients c ON c.id = j.client_id ORDER BY j.created_at DESC LIMIT 100')->fetchAll();
render_header('Jobcards');
echo '<div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1">Jobcards</h1><p class="text-muted mb-0">Track requested work and operational status.</p></div>';
if (can('jobcards.manage')) echo '<button class="btn btn-primary" data-bs-toggle="collapse" data-bs-target="#new-jobcard">New jobcard</button>';
echo '</div>';
if (isset($_GET['created'])) echo '<div class="alert alert-success">Jobcard created successfully.</div>';
if ($errors) echo '<div class="alert alert-danger">' . e(implode(' ', $errors)) . '</div>';
if (can('jobcards.manage')) { echo '<div class="collapse mb-4" id="new-jobcard"><div class="card"><div class="card-body"><h2 class="h5">Create jobcard</h2><form method="post" class="row g-3"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><div class="col-md-6"><label class="form-label" for="jobcard-client">Client</label><select class="form-select" id="jobcard-client" name="client_id" required><option value="">Choose client</option>'; foreach ($clients as $client) echo '<option value="' . (int)$client['id'] . '">' . e($client['name']) . '</option>'; echo '</select></div><div class="col-md-3"><label class="form-label" for="jobcard-priority">Priority</label><select class="form-select" id="jobcard-priority" name="priority"><option>low</option><option selected>normal</option><option>high</option><option>critical</option></select></div><div class="col-12"><label class="form-label" for="work-requested">Work requested</label><textarea class="form-control" id="work-requested" name="work_requested" rows="4" maxlength="10000" required></textarea></div><div class="col-12"><button class="btn btn-primary">Create jobcard</button></div></form></div></div></div>'; }
echo '<div class="card"><div class="table-responsive"><table class="table table-hover align-middle mb-0"><thead><tr><th>Reference</th><th>Client</th><th>Priority</th><th>Status</th><th>Work requested</th><th>Created</th></tr></thead><tbody>';
if (!$jobcards) echo '<tr><td colspan="6" class="text-center text-muted py-4">No jobcards found.</td></tr>';
foreach ($jobcards as $jobcard) echo '<tr><td class="fw-semibold">' . e($jobcard['reference_no']) . '</td><td>' . e($jobcard['client_name']) . '</td><td>' . e(ucfirst($jobcard['priority'])) . '</td><td>' . e(ucwords(str_replace('_', ' ', $jobcard['status']))) . '</td><td class="text-truncate" style="max-width: 320px">' . e($jobcard['work_requested']) . '</td><td>' . e($jobcard['created_at']) . '</td></tr>';
echo '</tbody></table></div></div>';
render_footer(); exit;
}
if ($route === 'client') {
require_permission('clients.view');
$clientId = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
if (!$clientId) { http_response_code(400); exit('Invalid client'); }
$stmt = db()->prepare('SELECT * FROM clients WHERE id = :id');
$stmt->execute(['id' => $clientId]);
$client = $stmt->fetch();
if (!$client) { http_response_code(404); exit('Client not found'); }
$contactsStmt = db()->prepare('SELECT name, email, phone, is_primary, notes FROM client_contacts WHERE client_id = :id ORDER BY is_primary DESC, name');
$contactsStmt->execute(['id' => $clientId]);
$contacts = $contactsStmt->fetchAll();
render_header('Client details');
echo '<div class="d-flex justify-content-between align-items-center mb-4"><div><a href="/?route=clients" class="text-decoration-none">← Back to clients</a><h1 class="h3 mt-2 mb-1">' . e($client['name']) . '</h1><p class="text-muted mb-0">Client profile and support contacts.</p></div><span class="badge text-bg-' . ($client['status'] === 'active' ? 'success' : 'secondary') . '">' . e(ucfirst($client['status'])) . '</span></div><div class="row g-4"><div class="col-lg-6"><div class="card h-100"><div class="card-body"><h2 class="h5">Support information</h2><dl class="row mb-0"><dt class="col-sm-5">Email</dt><dd class="col-sm-7">' . e((string)($client['support_email'] ?? '—')) . '</dd><dt class="col-sm-5">Phone</dt><dd class="col-sm-7">' . e((string)($client['support_phone'] ?? '—')) . '</dd><dt class="col-sm-5">Preferred method</dt><dd class="col-sm-7">' . e((string)($client['preferred_contact_method'] ?? '—')) . '</dd><dt class="col-sm-5">Address</dt><dd class="col-sm-7">' . nl2br(e((string)($client['physical_address'] ?? '—'))) . '</dd></dl></div></div></div><div class="col-lg-6"><div class="card h-100"><div class="card-body"><h2 class="h5">Contacts</h2>';
if (!$contacts) echo '<p class="text-muted mb-0">No contacts recorded.</p>';
foreach ($contacts as $contact) echo '<div class="border-bottom py-2"><div class="fw-semibold">' . e($contact['name']) . ($contact['is_primary'] ? ' <span class="badge text-bg-primary">Primary</span>' : '') . '</div><div class="small text-muted">' . e((string)($contact['email'] ?? '')) . ' ' . e((string)($contact['phone'] ?? '')) . '</div></div>';
echo '</div></div></div></div>';
render_footer();
exit;
}
if ($route === 'clients') {
require_permission('clients.view');
$errors = [];
$old = ['name' => '', 'status' => 'active'];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
require_permission('clients.manage');
verify_csrf();
$validated = validate_client($_POST);
$old = $validated;
$errors = $validated['errors'];
if ($errors === []) {
$stmt = db()->prepare('INSERT INTO clients (name, status, created_by) VALUES (:name, :status, :created_by)');
$stmt->execute(['name' => $validated['name'], 'status' => $validated['status'], 'created_by' => $user['id']]);
$clientId = (int)db()->lastInsertId();
audit('client_created', 'client', $clientId, ['name' => $validated['name']]);
header('Location: /?route=clients&created=1');
exit;
}
}
$search = trim((string)($_GET['q'] ?? ''));
$stmt = db()->prepare('SELECT id, name, status, support_email, support_phone, created_at FROM clients WHERE (:search = \'\' OR name LIKE :like_name OR support_email LIKE :like_email) ORDER BY name LIMIT 100');
$stmt->execute(['search' => $search, 'like_name' => "%{$search}%", 'like_email' => "%{$search}%"]);
$clients = $stmt->fetchAll();
render_header('Clients');
echo '<div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1">Clients</h1><p class="text-muted mb-0">Manage client records and support contacts.</p></div>';
if (can('clients.manage')) echo '<button class="btn btn-primary" data-bs-toggle="collapse" data-bs-target="#new-client">New client</button>';
echo '</div>';
if (isset($_GET['created'])) echo '<div class="alert alert-success">Client created successfully.</div>';
if (can('clients.manage')) {
echo '<div class="collapse mb-4" id="new-client"><div class="card"><div class="card-body"><h2 class="h5">Create client</h2><form method="post" class="row g-3"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><div class="col-md-8"><label class="form-label" for="client-name">Client/company name</label><input class="form-control" id="client-name" name="name" value="' . e((string)$old['name']) . '" required>' . (isset($errors['name']) ? '<div class="text-danger small">' . e($errors['name']) . '</div>' : '') . '</div><div class="col-md-4"><label class="form-label" for="client-status">Status</label><select class="form-select" id="client-status" name="status"><option value="active">Active</option><option value="inactive">Inactive</option></select></div><div class="col-12"><button class="btn btn-primary">Save client</button></div></form></div></div></div>';
}
echo '<form class="row g-2 mb-3"><input type="hidden" name="route" value="clients"><div class="col-sm-8 col-lg-5"><label class="visually-hidden" for="client-search">Search clients</label><input class="form-control" id="client-search" name="q" value="' . e($search) . '" placeholder="Search by client or support email"></div><div class="col-auto"><button class="btn btn-outline-secondary">Search</button></div></form><div class="card"><div class="table-responsive"><table class="table table-hover align-middle mb-0"><thead><tr><th>Client</th><th>Status</th><th>Support email</th><th>Phone</th></tr></thead><tbody>';
if (!$clients) echo '<tr><td colspan="4" class="text-center text-muted py-4">No clients found.</td></tr>';
foreach ($clients as $client) echo '<tr><td class="fw-semibold"><a href="/?route=client&id=' . (int)$client['id'] . '" class="text-decoration-none">' . e($client['name']) . '</a></td><td><span class="badge text-bg-' . ($client['status'] === 'active' ? 'success' : 'secondary') . '">' . e(ucfirst($client['status'])) . '</span></td><td>' . e((string)($client['support_email'] ?? '—')) . '</td><td>' . e((string)($client['support_phone'] ?? '—')) . '</td></tr>';
echo '</tbody></table></div></div>';
render_footer();
exit;
}
if (isset($permissionByRoute[$route])) {
require_permission($permissionByRoute[$route]);
render_header(ucfirst($route)); ?><div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1"><?= e(ucfirst($route)) ?></h1><p class="text-muted mb-0">This module is scaffolded for the next implementation phase.</p></div></div><div class="alert alert-info">The route is permission-protected and ready for its domain workflow.</div><?php render_footer(); exit;
}
http_response_code(404); render_header('Not found'); ?><div class="alert alert-warning">Page not found.</div><?php render_footer();
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/Client/ClientContactValidator.php';
$invalid = validate_client_contact([
'name' => ' ',
'email' => 'not-an-email',
'phone' => str_repeat('1', 61),
'is_primary' => 'maybe',
]);
foreach (['name', 'email', 'phone', 'is_primary'] as $key) {
if (!isset($invalid['errors'][$key])) {
throw new RuntimeException("Expected validation error for {$key}");
}
}
$valid = validate_client_contact([
'name' => ' Jane Doe ',
'email' => ' JANE@example.com ',
'phone' => ' +27 11 555 0100 ',
'is_primary' => '1',
]);
if ($valid['errors'] !== []
|| $valid['name'] !== 'Jane Doe'
|| $valid['email'] !== 'jane@example.com'
|| $valid['phone'] !== '+27 11 555 0100'
|| $valid['is_primary'] !== true
) {
throw new RuntimeException('Expected contact input to be normalized');
}
$optional = normalize_client_contact(['name' => 'Sam']);
if ($optional !== ['name' => 'Sam', 'email' => null, 'phone' => null, 'is_primary' => false]) {
throw new RuntimeException('Expected optional contact fields to normalize to null/defaults');
}
printf("Client contact validator tests: 3 passed\n");
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/Client/ClientValidator.php';
require_once __DIR__ . '/../app/Domain/Client/ClientHelpers.php';
$cases = [
[[], 'name'],
[['name' => str_repeat('A', 191)], 'name'],
[['name' => 'Acme', 'status' => 'paused'], 'status'],
];
foreach ($cases as [$input, $errorKey]) {
$result = validate_client($input);
if (!isset($result['errors'][$errorKey])) {
throw new RuntimeException("Expected validation error for {$errorKey}");
}
}
$valid = validate_client(['name' => ' Acme IT ', 'status' => 'active']);
if ($valid['errors'] !== [] || $valid['name'] !== 'Acme IT') {
throw new RuntimeException('Expected valid client input to be normalized');
}
if (!client_name_is_duplicate(' acme IT ', ['Acme IT', 'Other'])) {
throw new RuntimeException('Expected duplicate client names to be detected case-insensitively');
}
if (client_name_is_duplicate('New Client', ['Acme IT'])) {
throw new RuntimeException('Expected unique client name to be accepted');
}
printf("Client validator tests: %d passed\n", count($cases) + 2);
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/Jobcard/TimeCalculator.php';
require_once __DIR__ . '/../app/Domain/SLA/SlaCalculator.php';
if (calculate_duration_hours('09:00', '11:30') !== 2.5) {
throw new RuntimeException('Expected 2.5 hours from start/end');
}
if (calculate_duration_hours(null, null, 1.25) !== 1.25) {
throw new RuntimeException('Expected manual duration');
}
if (calculate_duration_hours('11:30', '09:00') !== null) {
throw new RuntimeException('Expected invalid reverse time to be rejected');
}
$sla = calculate_sla_usage(20.0, [2.0, 1.5, 1.0]);
if ($sla !== ['used' => 4.5, 'remaining' => 15.5, 'percentage' => 22.5, 'status' => 'within_limit']) {
throw new RuntimeException('Unexpected SLA calculation: ' . json_encode($sla));
}
$over = calculate_sla_usage(10.0, [8.0, 4.0]);
if ($over['status'] !== 'exceeded' || $over['remaining'] !== 0.0) {
throw new RuntimeException('Expected exceeded SLA to clamp remaining hours to zero');
}
printf("Jobcard calculation tests: 5 passed\n");
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/Jobcard/JobcardReference.php';
require_once __DIR__ . '/../app/Domain/Jobcard/StatusTransitionValidator.php';
require_once __DIR__ . '/../app/Domain/Jobcard/TimeEntryValidator.php';
require_once __DIR__ . '/../app/Domain/Jobcard/TimeAggregator.php';
use App\Domain\Jobcard\JobcardReference;
use App\Domain\Jobcard\StatusTransitionValidator;
use App\Domain\Jobcard\TimeEntryValidator;
use App\Domain\Jobcard\TimeAggregator;
$reference = JobcardReference::generate(42, 2026);
if ($reference !== 'JC-2026-000042') throw new RuntimeException('Expected generated jobcard reference.');
if (!JobcardReference::isValid($reference) || JobcardReference::isValid('bad-reference')) throw new RuntimeException('Expected reference format validation.');
$transitions = new StatusTransitionValidator();
if (!$transitions->canTransition('new', 'assigned')) throw new RuntimeException('new should transition to assigned.');
if ($transitions->canTransition('new', 'completed')) throw new RuntimeException('new should not skip to completed.');
if (!$transitions->canTransition('in_progress', 'in_progress')) throw new RuntimeException('Same status should be allowed.');
$validEntry = (new TimeEntryValidator())->validate(['work_date' => '2026-09-01', 'start_time' => '09:00', 'end_time' => '11:30']);
if (!$validEntry['valid'] || $validEntry['hours'] !== 2.5 || $validEntry['errors'] !== []) throw new RuntimeException('Expected valid time entry.');
$invalidEntry = (new TimeEntryValidator())->validate(['work_date' => 'not-a-date', 'start_time' => '11:00', 'end_time' => '10:00']);
if ($invalidEntry['valid'] || count($invalidEntry['errors']) !== 2) throw new RuntimeException('Expected invalid time entry errors.');
$nonNumericEntry = (new TimeEntryValidator())->validate(['work_date' => '2026-09-01', 'hours' => 'not-a-number']);
if ($nonNumericEntry['valid'] || !isset($nonNumericEntry['errors']['hours'])) throw new RuntimeException('Expected non-numeric hours to be rejected.');
$aggregator = new TimeAggregator();
if ($aggregator->total([['hours' => 2.25], ['hours' => 1.5], ['hours' => -4]]) !== 3.75) throw new RuntimeException('Expected positive time aggregation.');
if ($aggregator->slaTotal([['hours' => 2, 'counts_toward_sla' => true], ['hours' => 3, 'counts_toward_sla' => false]]) !== 2.0) throw new RuntimeException('Expected SLA-filtered aggregation.');
printf("Jobcard domain tests: 12 passed\n");
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/Reporting/ReportDataMapper.php';
require_once __DIR__ . '/../app/Domain/Reporting/HoursPerClientReport.php';
require_once __DIR__ . '/../app/Domain/Reporting/SlaReport.php';
function reporting_assert_same(mixed $expected, mixed $actual, string $message): void
{
if ($expected !== $actual) {
throw new RuntimeException($message . "\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true));
}
}
$record = [
'id' => 7,
'name' => 'Acme IT',
'status' => 'active',
'support_email' => 'support@example.test',
'internal_notes' => 'never disclose',
'password' => 'secret',
'credentials' => 'token',
'technical_ip' => '10.0.0.1',
'unknown_field' => 'not approved',
];
$mapper = new ReportDataMapper();
reporting_assert_same(
['id' => 7, 'name' => 'Acme IT', 'status' => 'active', 'support_email' => 'support@example.test'],
$mapper->clientFacing($record),
'Client-facing report data must be allow-listed.'
);
reporting_assert_same(
['internal_notes' => 'never disclose', 'password' => 'secret', 'credentials' => 'token', 'technical_ip' => '10.0.0.1', 'unknown_field' => 'not approved'],
$mapper->internal($record),
'Internal report data must remain separate from client-facing data.'
);
$hours = new HoursPerClientReport();
reporting_assert_same(
[
['client_id' => 7, 'client_name' => 'Acme IT', 'hours' => 3.0],
['client_id' => 2, 'client_name' => 'Beta', 'hours' => 3.5],
],
$hours->aggregate([
['client_id' => 7, 'client_name' => 'Acme IT', 'hours' => 1.25],
['client_id' => 2, 'client_name' => 'Beta', 'hours' => 3.5, 'internal_notes' => 'omit'],
['client_id' => 7, 'client_name' => 'Acme IT', 'hours' => 1.75, 'password' => 'omit'],
]),
'Hours must aggregate deterministically per client.'
);
$sla = new SlaReport();
reporting_assert_same(
[
['client_id' => 7, 'client_name' => 'Acme IT', 'allocated_hours' => 10.0, 'used_hours' => 9.0, 'remaining_hours' => 1.0, 'usage_percentage' => 90.0, 'status' => 'critical'],
],
$sla->rows([[
'client_id' => 7,
'client_name' => 'Acme IT',
'allocated_hours' => 10,
'hours' => [4, 5, -2],
'internal_notes' => 'omit',
'credentials' => 'omit',
]]),
'SLA rows must expose only safe, deterministic report fields.'
);
printf("Reporting contract tests: 3 passed\n");
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/SLA/SlaThresholdClassifier.php';
use App\Domain\SLA\SlaThresholdClassifier;
$classifier = new SlaThresholdClassifier();
if ($classifier->classify(7.5, 10.0) !== 'warning') throw new RuntimeException('75% should be warning.');
if ($classifier->classify(9.0, 10.0) !== 'critical') throw new RuntimeException('90% should be critical.');
if ($classifier->classify(10.01, 10.0) !== 'exceeded') throw new RuntimeException('Over allocation should be exceeded.');
if ($classifier->classify(0.0, 0.0) !== 'within_limit') throw new RuntimeException('No allocation and no usage should be within limit.');
printf("SLA domain tests: 4 passed\n");
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
putenv('JOBcard_TEST_VALUE=present');
require_once __DIR__ . '/../config/bootstrap.php';
$checks = 0;
assert(env_required('JOBcard_TEST_VALUE') === 'present');
$checks++;
assert(e('<script>alert("x")</script>') === '&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;');
$checks++;
$missingRaised = false;
try {
env_required('JOBcard_MISSING_VALUE');
} catch (RuntimeException $exception) {
$missingRaised = str_contains($exception->getMessage(), 'JOBcard_MISSING_VALUE');
}
assert($missingRaised === true);
$checks++;
printf("Foundation smoke tests: %d passed\n", $checks);