feat: bootstrap JOBcard CRM foundation
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
vendor/
|
||||
node_modules/
|
||||
.DS_Store
|
||||
storage/logs/
|
||||
storage/uploads/
|
||||
.phpunit.result.cache
|
||||
+14
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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]) : [];
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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:
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; } }
|
||||
@@ -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();
|
||||
@@ -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");
|
||||
@@ -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);
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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>') === '<script>alert("x")</script>');
|
||||
$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);
|
||||
Reference in New Issue
Block a user