Initial commit: Quatriz timesheet application.

Laravel 13 + Filament admin panel with weekly hours entry, timesheet approval workflow, project management, reports, and PDF export.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Syazwan 2026-06-28 22:22:52 +08:00
commit e90c84970f
278 changed files with 34876 additions and 0 deletions

18
.editorconfig Normal file
View File

@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[{compose,docker-compose}.{yml,yaml}]
indent_size = 4

102
.env.example Normal file
View File

@ -0,0 +1,102 @@
APP_NAME="Quatriz TimeSheet"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost:8000
APP_TIMEZONE=Asia/Kuala_Lumpur
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_LEVEL=debug
# SQLite (dev)
# DB_CONNECTION=sqlite
# PostgreSQL (production)
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=timesheet
DB_USERNAME=postgres
DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_SECURE_COOKIE=
SESSION_ENCRYPT=false
SESSION_HTTP_ONLY=true
SESSION_SAME_SITE=lax
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# Security hardening (production: set APP_DEBUG=false, SESSION_ENCRYPT=true)
SECURITY_CSP_REPORT_ONLY=true
# Set true after verifying no CSP violations in browser console / reports
SECURITY_CSP_ENFORCE=false
SECURITY_COOP=same-origin
SECURITY_CORP=same-origin
SECURITY_CONTACT=security@skysyaz.my
SECURITY_TXT_EXPIRES=2027-06-30T00:00:00.000Z
HEALTH_CHECK_ALLOWED_IPS=
# MFA: admins must enroll TOTP on first login (production)
# UI: prevent stale modal overlays from blocking profile/logout after saves
UI_CONSISTENT_BUTTONS=true
# Cloudflare WAF (optional — see scripts/apply-cloudflare-waf.sh)
# CF_API_TOKEN=
# CF_ZONE_ID=
MAIL_MAILER=smtp
MAIL_HOST=smtp.resend.com
MAIL_PORT=587
MAIL_USERNAME=resend
MAIL_PASSWORD=
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=timesheet@skysyaz.my
MAIL_FROM_NAME="${APP_NAME}"
VITE_APP_NAME="${APP_NAME}"
# --- Observability (Flare, activitylog, Better Uptime) ---
# Flare error tracking — set FLARE_REPORT=true in production after adding key
FLARE_KEY=
FLARE_REPORT=false
FLARE_TRACE=true
FLARE_SAMPLER_RATE=0.1
FLARE_TENANT_TAG=quatriz
# Admin audit trail (spatie/laravel-activitylog)
ACTIVITYLOG_ENABLED=true
ACTIVITYLOG_RETENTION_DAYS=90
# Admin dashboard site traffic counter with daily aggregation
SITE_TRAFFIC_ENABLED=true
# Timesheet workflow emails: queue (default) or send during the HTTP request (false)
TIMESHEET_NOTIFICATIONS_QUEUE=true
# Better Uptime / Better Stack heartbeat monitors
BETTER_UPTIME_ENABLED=false
UPTIME_HEARTBEAT_TOKEN=
UPTIME_SCHEDULER_STALE_MINUTES=5
UPTIME_QUEUE_STALE_MINUTES=5
# --- Production checklist ---
# APP_ENV=production
# APP_DEBUG=false
# APP_URL=https://timesheet.quatriz-sd.my
# APP_URL=https://timesheet.skysyaz.my
# SESSION_SECURE_COOKIE=true
# SESSION_ENCRYPT=true
# LOG_LEVEL=error

11
.gitattributes vendored Normal file
View File

@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore

27
.gitignore vendored Normal file
View File

@ -0,0 +1,27 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.codex
/.cursor/
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/fonts-manifest.dev.json
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
_ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db

2
.npmrc Normal file
View File

@ -0,0 +1,2 @@
ignore-scripts=true
audit=true

482
BUILD_WORKFLOW.md Normal file
View File

@ -0,0 +1,482 @@
# Quatriz TimeSheet — Build Workflow
> Handoff document for AI-assisted development. All paths relative to `syazwan/build-app/timesheet-kilo`.
---
## 1. Project Overview
Admin-facing weekly timesheet application. Employees log hours per day (MonSun) per project; timesheets flow through a two-tier approval chain (Project Manager → Project Director). PDF export, analytics/reports dashboard, audit trail (spatie/activitylog), email notifications (Resend), TOTP MFA for admins, and uptime monitoring heartbeats are all implemented.
---
## 2. Current State Assessment
### What exists (fully implemented)
| Area | Details |
|------|---------|
| **Auth** | Filament-based login with custom subheading, TOTP MFA (admin-required configurable), session-based |
| **User roles** | `employee`, `project_manager` (PM), `project_director` (PD), `admin` |
| **Models** | `User`, `Project`, `Timesheet`, `ApprovalLog`, `Setting` — all with fillables, casts, relations, role-check methods |
| **Migrations** | 14 migrations: users (role, color), cache, jobs, projects (code, name, status, pm/pd ids, creator), timesheets (user, project, week_start, hours JSON[7], tasks JSON[7], status, notes, unique constraint), approval_logs, settings, activity_log, plus performance indexes and multi-factor columns |
| **Filament Resources** | `TimesheetResource` (full CRUD + submit/approve/reject/revert actions, row-level visibility), `ProjectResource`, `UserResource`, `ActivityLogResource` |
| **Filament Pages** | `Dashboard` (custom), `Reports` (date/project filters, group-by, export PDF), `Settings` (weekly hours, director approval toggle, email toggle, admin-only) |
| **Filament Widgets** | `TimesheetStatsOverview` (total hours, approved, pending, overtime), `HoursByDayChart`, `HoursByProjectChart`, `WelcomeBanner` |
| **Form Components** | `DailyHoursGrid` (7-day float array, ValidDailyHours rule), `WeeklyTimesheetPlanner` (extends DailyHoursGrid), `DailyTasksGrid` (7-day string array) |
| **PDF export** | `PdfController` — weekly timesheet PDF (`barryvdh/laravel-dompdf`) with full approval signature block, summary PDF |
| **Notifications** | 4 mail notifications (Submitted, Pending Director, Approved, Rejected) — all queueable via `ShouldQueue`, gated by `Setting::emailNotificationsEnabled()` |
| **Notifications trait** | `BuildsTimesheetMail` — shared summary/viewUrl helpers |
| **Timesheet workflow** | `draft → submitted → pending_pm → pending_pd → approved`; or `rejected` at any point; admin-only revert-to-draft from approved |
| **Access control** | `TimesheetAccess` class — scope queries/view/edit/submit/approve/reject/revert per role, per project assignment |
| **Audit logging** | `AuditLogger` wrapping spatie/activitylog with property redaction |
| **Reports** | `TimesheetSummaryBuilder` — group by project/week/month with date/project/user/status filters, used by Reports page and PDF export |
| **Console** | `schedule:work` heartbeat signal, queue heartbeat job, activity log cleanup |
| **Controllers** | `PdfController`, `UptimeHeartbeatController` |
| **Security** | CSP (report-only + enforce), COOP/CORP, security.txt route, health check IP allowlist, MFA config |
| **Config** | 15 config files: app, auth, cache, database (sqlite default, pgsql for prod), filesystems, flare, logging, mail (Resend SMTP), observability, queue, security, services, session, ui, activitylog |
| **Database** | SQLite for dev, PostgreSQL for production (via `.env.example`), `database.sqlite` already present |
| **Frontend** | Vite + Tailwind CSS v4 + Filament v5.x, Inter font, SPA mode with exceptions for PDF and project routes |
| **Tests** | 18 Feature tests, 11 Unit tests — covering auth, projects, timesheets access/workflow/notifications/PDF, settings, heartbeats, activity log, observability, security hardening |
| **Seeder** | `DatabaseSeeder` — 6 users (all roles), 5 projects with PM/PD, 8 timesheets with various statuses, approval log entries |
| **Docker/deploy** | Composer `setup` script, `dev` script (concurrently runs server, queue, logs, vite), GitHub-flavoured README |
### What is missing or incomplete
- **Missing `created_by` in seeder**: Projects seeded without `created_by` will cause `creator()->name` to return null.
- **Missing `project_role` and `tasks` data in seeder**: Timesheets seeder does not populate these columns (added by migration `2026_06_27_000001`) despite the form requiring `project_role`.
- **`resources/js/filament/ui-interactivity.js`**: Referenced in `vite.config.js` input — likely missing or empty.
- **`resources/css/filament/admin/theme.css`**: Referenced in `AdminPanelProvider` — may need creation.
- **Logo**: `public/logo.webp` referenced by `brandLogo` — may not exist.
- **No `ProjectFactory` / `TimesheetFactory` / `ApprovalLogFactory`**: Only `UserFactory` exists.
- **No `failed_jobs` table**: Queue worker cannot track failed jobs.
- **Policies directory**: Empty — all authorization inline in Resources rather than Laravel policies.
- **Listeners directory**: Empty — notifications triggered inline from Resource actions.
- **Role-transition boundary**: No formal `ProjectPolicy` or `TimesheetPolicy` registered.
---
## 3. Prerequisites
- **PHP**: ^8.3 (8.4 on VPS)
- **Composer**: v2+
- **Node.js**: 20+
- **npm**: 9+
- **Database**: SQLite (dev) or PostgreSQL 15+ (prod)
---
## 4. Tech Stack Summary
| Layer | Technology | Version |
|-------|-----------|---------|
| Backend | Laravel | ^13.8 |
| PHP | PHP | ^8.3 |
| Admin panel | Filament | ^5.6 |
| PDF | barryvdh/laravel-dompdf | ^3.1 |
| Audit | spatie/laravel-activitylog | ^5.0 |
| Error tracking | spatie/laravel-flare | ^3.0 |
| Database (dev) | SQLite | — |
| Database (prod) | PostgreSQL | 15+ |
| Frontend build | Vite | ^8.0 |
| CSS | Tailwind CSS | ^4.0 |
| Mail | Resend (SMTP) | — |
| Queue | Database driver | — |
---
## 5. Architecture & File Structure
```
timesheet/
├── app/
│ ├── Console/
│ ├── Filament/
│ │ ├── Actions/ # ResetUserPasswordAction
│ │ ├── Concerns/ # ConfiguresTableToolbar, RestoresHeaderInteractivity
│ │ ├── Forms/Components/ # DailyHoursGrid, DailyTasksGrid, WeeklyTimesheetPlanner
│ │ ├── Pages/ # Dashboard, Reports, Settings; Auth/Login
│ │ ├── Resources/ # TimesheetResource, ProjectResource, UserResource, ActivityLogResource
│ │ │ └── */Pages/ # List, Create, Edit, View per resource
│ │ └── Widgets/ # TimesheetStatsOverview, HoursByDayChart, HoursByProjectChart, WelcomeBanner
│ ├── Http/
│ │ ├── Controllers/ # PdfController, UptimeHeartbeatController
│ │ └── Middleware/
│ ├── Jobs/ # RecordQueueHeartbeat
│ ├── Models/
│ │ ├── Concerns/LogsAuditableChanges
│ │ ├── User.php # FilamentUser, HasAppAuthentication, role checks
│ │ ├── Project.php # code, name, status, pm/pd/creator FKs
│ │ ├── Timesheet.php # week_start, hours[JSON7], tasks[JSON7], status workflow
│ │ ├── ApprovalLog.php # timesheet_id, user_id, action, comment
│ │ └── Setting.php # key-value store (JSON value)
│ ├── Notifications/ # 4 mail classes (all ShouldQueue)
│ ├── Policies/ # (empty)
│ ├── Providers/ # AppServiceProvider, Filament/AdminPanelProvider
│ ├── Rules/ # ValidDailyHours, WeekStartsOnMonday
│ └── Support/
│ ├── Concerns/BuildsTimesheetMail
│ ├── AuditLogger.php
│ ├── LocalAvatarProvider.php
│ ├── TimesheetAccess.php # RBAC query scoping + permission checks
│ ├── TimesheetNotifier.php
│ └── TimesheetSummaryBuilder.php
├── bootstrap/
├── config/ # 15 config files
├── database/
│ ├── factories/ # UserFactory only
│ ├── migrations/ # 14 migrations
│ ├── seeders/DatabaseSeeder.php
│ └── database.sqlite
├── docs/
├── resources/
│ ├── css/
│ ├── js/
│ └── views/ # Blade: filament/forms/components, hooks, pages, widgets; pdf/
├── routes/
│ ├── web.php # /admin/login redirect, /pdf/*, /uptime/*, security.txt
│ └── console.php # schedule heartbeat + queue heartbeat + activitylog:clean
├── tests/
│ ├── Feature/ # 18 tests
│ ├── Unit/ # 11 tests
│ └── TestCase.php
├── .env.example # Full env template
├── composer.json
├── package.json
├── vite.config.js
└── README.md
```
---
## 6. Build & Run Instructions
### 6.1 Initial Setup
```bash
composer install
cp .env.example .env
php artisan key:generate
php artisan migrate
php artisan db:seed
npm install && npm run build
php artisan storage:link
```
### 6.2 Development
```bash
# All services (server, queue, logs, Vite HMR)
npm run dev
# Or individual terminals:
php artisan serve # Terminal 1
php artisan queue:work # Terminal 2
php artisan pail --timeout=0 # Terminal 3
npm run dev # Terminal 4
# Scheduler
php artisan schedule:work
```
### 6.3 Testing
```bash
php artisan test
php artisan test tests/Feature/TimesheetWorkflowTest.php # single file
php artisan test tests/Unit # unit only
php artisan test --coverage # with coverage
```
### 6.4 Production
```bash
composer install --no-dev --optimize-autoloader
npm ci && npm run build
php artisan optimize
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan migrate --force
```
---
## 7. Feature Implementation Workflow
### Step 1: Seed `project_role` and `tasks` data
**Files**: `database/seeders/DatabaseSeeder.php`
Add `project_role` (string) and `tasks` (7-element string array) to each entry in `$tsData`. Example:
```php
['user_id' => 1, 'project_id' => 1, 'project_role' => 'Site Engineer',
'week_start' => $twoWeeksAgo, 'hours' => [8,8,8,8,8,0,0],
'tasks' => ['Module A', 'Module B', 'Integration', '', '', '', ''],
'status' => 'approved', 'notes' => 'ERP module integration complete.'],
```
**Validation**: Re-seed → `Timesheet::first()->project_role` is non-null, `tasks` is 7-element array.
### Step 2: Seed `created_by` on projects
**Files**: `database/seeders/DatabaseSeeder.php`
After creating each project, set `created_by` to the admin user ID:
```php
$createdProjects[0]->update(['created_by' => $createdUsers[5]->id]);
```
**Validation**: Re-seed → `Project::first()->creator->name === 'Admin'`.
### Step 3: Create missing factories
**Files to create**:
- `database/factories/ProjectFactory.php`
- `database/factories/TimesheetFactory.php`
- `database/factories/ApprovalLogFactory.php`
**ProjectFactory**:
```php
class ProjectFactory extends Factory
{
protected $model = Project::class;
public function definition(): array
{
return [
'code' => strtoupper(fake()->lexify('???-??')),
'name' => fake()->sentence(3),
'status' => 'active',
];
}
}
```
**TimesheetFactory**: Generate `user_id`, `project_id`, `week_start` (Monday), `hours` (array 7 floats), `tasks` (array 7 strings), `status` default `'draft'`, `project_role`.
**ApprovalLogFactory**: `timesheet_id`, `user_id`, `action`.
**Validation**: `Project::factory()->create()` succeeds in `php artisan tinker`.
### Step 4: Add failed_jobs table
```bash
php artisan queue:failed-table
php artisan migrate
```
**Validation**: `failed_jobs` table exists.
### Step 5: Create asset stubs (if missing)
Check for these files; create if absent:
**`resources/css/app.css`**:
```css
@import "tailwindcss";
```
**`resources/css/filament/admin/theme.css`**:
```css
@import "tailwindcss";
@import "../../../vendor/filament/filament/resources/css/app.css";
```
**`resources/js/app.js`**: `import './bootstrap';`
**`resources/js/filament/ui-interactivity.js`**:
```js
document.addEventListener('livewire:navigated', () => {});
```
**Validation**: `npm run build` completes.
### Step 6: Create `public/logo.webp` placeholder
```bash
php -r "imagewebp(imagecreatetruecolor(1,1), 'public/logo.webp', 100);"
```
**Validation**: `file_exists(public_path('logo.webp'))` is `true`.
### Step 7: Create performance index
**File**: `database/migrations/2026_06_28_000001_add_report_indexes.php`
```php
Schema::table('timesheets', function (Blueprint $table) {
$table->index(['status', 'week_start'], 'timesheets_status_week_start_idx');
});
```
**Validation**: `php artisan migrate` succeeds.
### Step 8: Add `TimesheetPolicy` (optional)
**File**: `app/Policies/TimesheetPolicy.php`
```php
class TimesheetPolicy
{
use HandlesAuthorization;
public function view(User $user, Timesheet $timesheet): bool
{
return TimesheetAccess::userCanViewTimesheet($user, $timesheet);
}
public function create(User $user): bool { return true; }
public function update(User $user, Timesheet $timesheet): bool
{
return TimesheetAccess::userCanEditTimesheet($user, $timesheet);
}
public function delete(User $user, Timesheet $timesheet): bool
{
return $timesheet->isDraft()
&& TimesheetAccess::userCanEditTimesheet($user, $timesheet);
}
}
```
Register in `App\Providers\AppServiceProvider`.
**Validation**: All existing tests pass.
### Step 9: Full smoke test
```bash
rm -f database/database.sqlite && touch database/database.sqlite
php artisan migrate --seed
php artisan serve &
# Login as admin@company.com / pass123
# Run full workflow: create → submit → approve PM → approve PD → export PDF
php artisan test
```
**Validation**: All 29+ tests pass; manual workflow succeeds.
---
## 8. Data Model
### 8.1 Schema
```
users
id (PK), name, email (unique), email_verified_at, password,
role (employee|project_manager|project_director|admin),
color (#hex), remember_token, timestamps
projects
id (PK), code (unique), name, status (active|inactive),
project_manager_id (FK→users), project_director_id (FK→users),
created_by (FK→users), timestamps
timesheets
id (PK), user_id (FK→users), project_id (FK→projects),
project_role (varchar), week_start (date), hours (json),
tasks (json), status (draft|pending_pm|pending_pd|approved|rejected),
notes (text), timestamps
UNIQUE(user_id, project_id, week_start)
approval_logs
id (PK), timesheet_id (FK→timesheets), user_id (FK→users),
action (varchar), comment (text), timestamps
settings
id (PK), key (unique), value (json)
activity_log
(spatie/laravel-activitylog default schema)
```
### 8.2 Status workflow
```
draft ──submit──> pending_pm ──PM approve──> pending_pd ──PD approve──> approved
│ │
└──reject──> rejected └──reject──> rejected
approved ──admin revert──> draft
```
### 8.3 Routes
| Method | URI | Auth | Purpose |
|--------|-----|------|---------|
| GET | `/` | No | Redirect to `/admin/login` |
| GET | `/admin/*` | Yes | Filament panel |
| GET | `/pdf/timesheet/{timesheet}` | Yes | Weekly timesheet PDF |
| GET | `/pdf/summary` | Yes | Summary PDF (query params) |
| GET | `/uptime/heartbeat` | Throttled | Scheduler heartbeat |
| GET | `/uptime/queue-heartbeat` | Throttled | Queue heartbeat |
| GET | `/.well-known/security.txt` | No | Security contact |
| GET | `/up` | No | Health check (IP-restricted) |
---
## 9. Testing Strategy
### 9.1 Existing tests (29 total)
**Feature (18)**: ActivityLog, AdminAuth, Example, FlareIntegration, ObservabilityAccess, PdfAuthorization, PdfSummaryExport, ProjectCreate, ProjectResourceAuthorization, ProjectViewRoute, SecurityHardening, SettingsPage, TimesheetApproverHistory, TimesheetEditAuthorization, TimesheetNotification, TimesheetResourceAuthorization, TimesheetWorkflow, UptimeHeartbeat.
**Unit (11)**: Example, LocalAvatarProvider, ProjectApprover, Setting, TimesheetAccessEdit, TimesheetApprovalPdf, TimesheetSummaryBuilder, Timesheet, User, WeekStartsOnMonday, WelcomeBannerGreeting.
### 9.2 Tests to add
| # | Test | Type | Coverage |
|---|------|------|----------|
| 1 | `TimesheetFactoryTest` | Unit | Factory creates valid timesheet |
| 2 | `ProjectFactoryTest` | Unit | Factory creates valid project |
| 3 | `ApprovalLogFactoryTest` | Unit | Factory creates valid log |
| 4 | `TimesheetWorkflowRevertTest` | Feature | Admin revert approved → draft |
| 5 | `TimesheetProjectRoleTest` | Feature | project_role display in view |
| 6 | `TimesheetTaskDayFallbackTest` | Unit | taskForDay() fallback to notes |
| 7 | `DatabaseSeederTest` | Feature | Correct counts after seed |
| 8 | `SettingsFeatureTest` | Feature | Toggle reflects in notifier |
### 9.3 Run
```bash
php artisan test
```
---
## 10. Common Pitfalls & Mitigations
| # | Pitfall | Mitigation |
|---|---------|------------|
| 1 | Seeder missing `project_role` → new timesheet form breaks on PM/PD screens | Step 1 |
| 2 | Seeder missing `created_by``creator()` returns null | Step 2 |
| 3 | No factories → tests cannot create test data systematically | Step 3 |
| 4 | No `failed_jobs` table → queue worker cannot retry | Step 4 |
| 5 | Missing Vite entry points → `npm run build` errors | Step 5 |
| 6 | Missing logo → 404 in browser console | Step 6 |
| 7 | Missing report index → slow queries under high volume | Step 7 |
| 8 | Inline authorization → inconsistent gate checks across Resources | Step 8 (optional) |
| 9 | `QUEUE_CONNECTION=database` but `php artisan queue:table` not run → queue worker may fail | Step 4 handles `failed_jobs`; confirm `jobs` migration exists (`0001_01_01_000002_create_jobs_table.php`) |
| 10 | `ACTIVITYLOG_ENABLED=true` but no `activity_log` table → migration `2026_06_28_130421` handles this | Confirm migration has run |
---
## 11. Handoff Checklist
- [ ] `git init && git add -A && git commit -m "Initial scaffold"`
- [ ] Step 1: Seeder includes `project_role` and `tasks`
- [ ] Step 2: Seeder sets `created_by` on all projects
- [ ] Step 3: `ProjectFactory`, `TimesheetFactory`, `ApprovalLogFactory` exist
- [ ] Step 4: `failed_jobs` table migrated
- [ ] Step 5: All Vite entry points exist and `npm run build` succeeds
- [ ] Step 6: `public/logo.webp` exists (or real logo placed)
- [ ] Step 7: Report index migration applied
- [ ] Step 8: `TimesheetPolicy` registered (optional but recommended)
- [ ] Step 9: `php artisan test` passes all tests (existing + new)
- [ ] Step 9: Manual smoke test — login, create timesheet, submit, approve (PM+PD), export PDF, check reports
- [ ] `composer audit` passes (no vulnerable deps)

73
README.md Normal file
View File

@ -0,0 +1,73 @@
# Quatriz TimeSheet
Admin-facing weekly timesheet application built with **Laravel 13**, **Filament 5**, and **PostgreSQL**.
- **Production:** https://timesheet.quatriz-sd.my
- **Staging (VPS IP):** http://194.233.86.248/admin/login
- **Admin panel:** `/admin`
- **PHP:** ^8.3 (8.4 on VPS)
- **Hosting:** VPS with Caddy + PHP-FPM (`/var/www/timesheet`)
## Local setup
```bash
composer install
cp .env.example .env
php artisan key:generate
php artisan migrate
npm install && npm run build
php artisan serve
```
Run the scheduler and queue in separate terminals for full parity with production:
```bash
php artisan schedule:work
php artisan queue:work
```
## Testing
```bash
php artisan test
composer audit
```
## Observability
This app integrates three production monitoring tools:
| Tool | Purpose |
|------|---------|
| [Flare](https://flareapp.io) | Exception and error tracking |
| [spatie/laravel-activitylog](https://github.com/spatie/laravel-activitylog) | Admin audit trail |
| [Better Uptime](https://betteruptime.com) | Uptime + scheduler/queue heartbeats |
**Full setup, env vars, monitor URLs, and ops runbook:** [docs/OBSERVABILITY.md](docs/OBSERVABILITY.md)
### Quick start (observability)
1. Copy observability vars from `.env.example`
2. `php artisan migrate` (creates `activity_log` table)
3. Flare: set `FLARE_KEY` + `FLARE_REPORT=true`, then `php artisan flare:test --errors`
4. Better Uptime: set `BETTER_UPTIME_ENABLED=true`, generate `UPTIME_HEARTBEAT_TOKEN`, create monitors (see runbook)
5. Audit log: log in as admin → **Administration → Audit Log**
### Troubleshooting
| Symptom | Check |
|---------|-------|
| No Flare events | `FLARE_KEY` set, `FLARE_REPORT=true`, `php artisan config:clear` |
| Heartbeat 503 | Cron running? `php artisan schedule:run` · Queue worker up? |
| Heartbeat 403 | `UPTIME_HEARTBEAT_TOKEN` matches monitor URL query string |
| Audit log empty | `ACTIVITYLOG_ENABLED=true`, migration applied |
| `/up` fails from monitor | Add probe IP to `HEALTH_CHECK_ALLOWED_IPS` |
## Security
- Security contact: see `/.well-known/security.txt`
- Production: `APP_DEBUG=false`, `SESSION_ENCRYPT=true`, CSP enforcement after verification
## License
MIT

View File

@ -0,0 +1,18 @@
<?php
namespace App\Auth\Http\Responses;
use Filament\Auth\Http\Responses\Contracts\LoginResponse as Responsable;
use Filament\Facades\Filament;
use Illuminate\Http\RedirectResponse;
use Livewire\Features\SupportRedirects\Redirector;
class LoginResponse implements Responsable
{
public function toResponse($request): RedirectResponse | Redirector
{
// Do not chain ->header() here: Livewire login returns a Redirector, not
// RedirectResponse, and calling header() causes a 500 after successful auth.
return redirect()->intended(Filament::getUrl());
}
}

View File

@ -0,0 +1,82 @@
<?php
namespace App\Console\Commands;
use App\Models\ApprovalLog;
use App\Models\Timesheet;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Spatie\Activitylog\Models\Activity;
class BackfillActivityLogFromApprovalLogs extends Command
{
protected $signature = 'activitylog:backfill-approval-logs {--dry-run : Preview rows without writing}';
protected $description = 'Import historical approval_logs rows into the activity_log audit table';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$imported = 0;
$skipped = 0;
ApprovalLog::query()
->with(['timesheet', 'user'])
->orderBy('id')
->chunkById(200, function ($logs) use ($dryRun, &$imported, &$skipped): void {
foreach ($logs as $log) {
$sourceKey = 'approval_log:' . $log->id;
$exists = Activity::query()
->where('properties->backfill_source', $sourceKey)
->exists();
if ($exists) {
$skipped++;
continue;
}
if ($dryRun) {
$imported++;
continue;
}
DB::transaction(function () use ($log, $sourceKey, &$imported): void {
activity('admin')
->causedBy($log->user)
->performedOn($log->timesheet)
->withProperties([
'backfill_source' => $sourceKey,
'action' => $log->action ?? null,
'comment' => $log->comment,
])
->tap(function (Activity $activity) use ($log): void {
$activity->created_at = $log->created_at;
$activity->updated_at = $log->updated_at;
})
->log($this->descriptionForAction($log->action));
});
$imported++;
}
});
$this->info(($dryRun ? 'Would import' : 'Imported') . " {$imported} audit entries ({$skipped} already present).");
return self::SUCCESS;
}
protected function descriptionForAction(string $action): string
{
return match ($action) {
'submitted' => 'Timesheet submitted for approval',
'approved_pm' => 'Timesheet approved by PM',
'approved_pd' => 'Timesheet approved by PD',
'rejected' => 'Timesheet rejected',
'reverted' => 'Timesheet reverted to draft',
default => 'Timesheet workflow action: ' . str_replace('_', ' ', $action),
};
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
class SignalUptimeHeartbeat extends Command
{
protected $signature = 'uptime:signal-heartbeat';
protected $description = 'Record a scheduler heartbeat for Better Uptime monitoring';
public function handle(): int
{
if (! config('observability.uptime.enabled')) {
$this->components->warn('Uptime monitoring is disabled (BETTER_UPTIME_ENABLED=false).');
return self::SUCCESS;
}
Cache::put(
config('observability.uptime.cache_key_scheduler'),
now()->timestamp,
now()->addMinutes(config('observability.uptime.scheduler_stale_minutes') * 2),
);
$this->components->info('Scheduler heartbeat recorded.');
return self::SUCCESS;
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Filament\Actions;
use App\Models\User;
use Filament\Actions\Action;
use Filament\Forms;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
class ResetUserPasswordAction
{
/**
* @return array<int, Forms\Components\Component>
*/
public static function formSchema(): array
{
return [
Forms\Components\TextInput::make('password')
->label('New password')
->password()
->revealable()
->required()
->rule(Password::defaults())
->confirmed(),
Forms\Components\TextInput::make('password_confirmation')
->label('Confirm password')
->password()
->revealable()
->required(),
];
}
public static function make(string $name = 'resetPassword'): Action
{
return Action::make($name)
->label('Reset password')
->icon('heroicon-o-key')
->color('warning')
->modalHeading('Reset user password')
->modalDescription('Set a temporary password for this user. They should change it after signing in.')
->form(static::formSchema())
->action(function (User $record, array $data): void {
$record->update([
'password' => Hash::make($data['password']),
]);
Notification::make()
->success()
->title('Password updated')
->body("A new password has been set for {$record->name}.")
->send();
});
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Filament\Concerns;
use Filament\Actions\Action;
use Filament\Tables\Enums\ColumnManagerLayout;
use Filament\Tables\Enums\FiltersLayout;
use Filament\Tables\Table;
trait ConfiguresTableToolbar
{
protected static function configureTableToolbar(Table $table): Table
{
return $table
->filtersLayout(FiltersLayout::Modal)
->filtersApplyAction(fn (Action $action) => $action->close())
->columnManagerLayout(ColumnManagerLayout::Modal)
->columnManagerApplyAction(
fn (Action $action) => $action->alpineClickHandler('applyTableColumnManager(); close()'),
);
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\Filament\Concerns;
trait RestoresHeaderInteractivity
{
protected function restoreHeaderInteractivity(): void
{
if (! config('ui.consistent_buttons')) {
return;
}
$this->dispatch('restore-header-interactivity');
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace App\Filament\Concerns;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Validation\ValidationException;
trait SyncsProjectMembers
{
/**
* @param list<array{user_id?: int|string|null, assigned_role?: string|null}>|mixed $assignments
* @return array<int, array{assigned_role: string}>
*/
protected function resolveMemberAssignments(mixed $assignments): array
{
$rows = array_values(array_filter(
Arr::wrap($assignments),
fn (mixed $row): bool => is_array($row) && filled($row['user_id'] ?? null),
));
if ($rows === []) {
throw ValidationException::withMessages([
'data.member_assignments' => 'Add at least one project member.',
]);
}
$sync = [];
$userIds = [];
foreach ($rows as $index => $row) {
$userId = (int) $row['user_id'];
$role = trim((string) ($row['assigned_role'] ?? ''));
if ($role === '') {
throw ValidationException::withMessages([
"data.member_assignments.{$index}.assigned_role" => 'Enter a role for this member.',
]);
}
if (in_array($userId, $userIds, true)) {
throw ValidationException::withMessages([
"data.member_assignments.{$index}.user_id" => 'Each member can only be assigned once.',
]);
}
$userIds[] = $userId;
$sync[$userId] = ['assigned_role' => $role];
}
$existingIds = User::query()
->whereIn('id', $userIds)
->pluck('id')
->all();
$missingIds = array_values(array_diff($userIds, $existingIds));
if ($missingIds !== []) {
throw ValidationException::withMessages([
'data.member_assignments' => 'These user IDs do not exist in the system: ' . implode(', ', $missingIds) . '.',
]);
}
return $sync;
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Filament\Forms\Components;
use App\Rules\ValidDailyHours;
use Filament\Forms\Components\Field;
class DailyHoursGrid extends Field
{
protected string $view = 'filament.forms.components.daily-hours-grid';
protected function setUp(): void
{
parent::setUp();
$this->default([0, 0, 0, 0, 0, 0, 0]);
$this->dehydrateStateUsing(function (mixed $state): array {
if (! is_array($state)) {
return [0, 0, 0, 0, 0, 0, 0];
}
return array_map(
fn (mixed $value): float => (float) (filled($value) ? $value : 0),
array_replace([0, 0, 0, 0, 0, 0, 0], array_values($state))
);
});
$this->rule(new ValidDailyHours());
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Filament\Forms\Components;
use Filament\Forms\Components\Field;
class DailyTasksGrid extends Field
{
protected string $view = 'filament.forms.components.daily-tasks-grid';
protected function setUp(): void
{
parent::setUp();
$this->default(['', '', '', '', '', '', '']);
$this->dehydrateStateUsing(function (mixed $state): array {
if (! is_array($state)) {
return ['', '', '', '', '', '', ''];
}
return array_map(
fn (mixed $value): string => trim((string) ($value ?? '')),
array_replace(['', '', '', '', '', '', ''], array_values($state))
);
});
}
}

View File

@ -0,0 +1,8 @@
<?php
namespace App\Filament\Forms\Components;
class WeeklyTimesheetPlanner extends DailyHoursGrid
{
protected string $view = 'filament.forms.components.weekly-timesheet-planner';
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Filament\Pages\Auth;
use Filament\Auth\Pages\Login as BaseLogin;
use Illuminate\Contracts\Support\Htmlable;
class Login extends BaseLogin
{
public function getSubheading(): string | Htmlable | null
{
if (filled($this->userUndertakingMultiFactorAuthentication)) {
return parent::getSubheading();
}
return 'Enter your Quatriz credentials to continue';
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Filament\Pages;
use Filament\Pages\Dashboard as BaseDashboard;
class Dashboard extends BaseDashboard
{
protected static ?string $navigationLabel = 'Dashboard';
protected static ?string $title = 'Dashboard';
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
protected static ?int $navigationSort = -2;
protected static string|\UnitEnum|null $navigationGroup = 'Overview';
/**
* @return array<string>
*/
public function getPageClasses(): array
{
return ['corp-dashboard'];
}
public function getColumns(): int | array
{
return 1;
}
}

View File

@ -0,0 +1,75 @@
<?php
namespace App\Filament\Pages;
use App\Models\Project;
use Filament\Pages\Page;
use Illuminate\Support\Collection;
class MyProjects extends Page
{
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-briefcase';
protected static string|\UnitEnum|null $navigationGroup = 'Time Tracking';
protected static ?int $navigationSort = 1;
protected static ?string $navigationLabel = 'My projects';
protected static ?string $title = 'My projects';
protected static ?string $slug = 'my-projects';
protected string $view = 'filament.pages.my-projects';
public static function canAccess(): bool
{
return auth()->user()?->isEmployee() ?? false;
}
public static function shouldRegisterNavigation(): bool
{
return static::canAccess();
}
/**
* @return Collection<int, array{
* project: Project,
* role: string,
* hours_logged: float,
* days_remaining: ?int,
* schedule_label: string,
* schedule_color: string,
* }>
*/
public function getAssignedProjects(): Collection
{
$user = auth()->user();
if (! $user) {
return collect();
}
return $user->projects()
->where('status', 'active')
->with(['members'])
->orderBy('end_date')
->get()
->map(function (Project $project) use ($user): array {
$health = $project->scheduleHealth();
$hoursLogged = round($project->timesheets()
->where('user_id', $user->id)
->get()
->sum(fn ($timesheet): float => $timesheet->totalHours()), 1);
return [
'project' => $project,
'role' => $project->members->firstWhere('id', $user->id)?->pivot?->assigned_role ?? '—',
'hours_logged' => $hoursLogged,
'days_remaining' => $health->daysRemaining(),
'schedule_label' => $health->label(),
'schedule_color' => $health->color(),
];
});
}
}

View File

@ -0,0 +1,132 @@
<?php
namespace App\Filament\Pages;
use App\Models\Project;
use App\Support\TimesheetAccess;
use App\Support\TimesheetSummaryBuilder;
use Filament\Actions\Action;
use Filament\Pages\Page;
use Symfony\Component\HttpFoundation\StreamedResponse;
class Reports extends Page
{
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-chart-bar';
protected static string|\UnitEnum|null $navigationGroup = 'Reports';
protected static ?int $navigationSort = 1;
protected static ?string $title = 'Analytics & Reports';
protected static ?string $slug = 'reports';
protected string $view = 'filament.pages.reports';
public string $reportType = 'project';
public ?string $dateFrom = null;
public ?string $dateTo = null;
public ?int $projectId = null;
protected function getHeaderActions(): array
{
return [
Action::make('exportSummary')
->label('Export PDF')
->icon('heroicon-o-arrow-down-tray')
->color('gray')
->url(fn (): string => $this->getExportUrl())
->openUrlInNewTab(),
Action::make('exportCsv')
->label('Export CSV')
->icon('heroicon-o-table-cells')
->color('gray')
->action('exportCsv'),
];
}
public function mount(): void
{
$this->dateFrom = now()->startOfYear()->format('Y-m-d');
$this->dateTo = now()->endOfMonth()->format('Y-m-d');
}
public function exportCsv(): StreamedResponse
{
$data = $this->getReportData();
$total = $this->getTotalHours();
$filename = 'timesheet-report-' . now()->format('Y-m-d-His') . '.csv';
return response()->streamDownload(function () use ($data, $total): void {
$handle = fopen('php://output', 'w');
fputcsv($handle, [$this->summaryBuilder()->dataColumnLabel(), 'Hours', 'Share %']);
foreach ($data as $row) {
$share = $total > 0 ? round(($row['hours'] / $total) * 100, 1) : 0;
fputcsv($handle, [$row['label'], number_format($row['hours'], 1, '.', ''), $share]);
}
fputcsv($handle, ['Total', number_format($total, 1, '.', ''), $total > 0 ? 100 : 0]);
fclose($handle);
}, $filename, [
'Content-Type' => 'text/csv',
]);
}
public function getProjects(): array
{
$user = auth()->user();
$query = Project::query()->where('status', 'active');
if ($user) {
TimesheetAccess::scopeProjectsForUser($query, $user);
}
return $query->orderBy('name')->pluck('name', 'id')->toArray();
}
public static function getNavigationLabel(): string
{
$user = auth()->user();
if ($user?->isApprover() && ! $user->isAdmin()) {
return 'Project Analytics';
}
return 'Analytics & Reports';
}
public function summaryBuilder(): TimesheetSummaryBuilder
{
return TimesheetSummaryBuilder::fromReports(
$this->reportType,
$this->dateFrom,
$this->dateTo,
$this->projectId,
);
}
public function getExportUrl(): string
{
return $this->summaryBuilder()->exportUrl();
}
public function getReportData(): array
{
return $this->summaryBuilder()->groupedData();
}
public function getTotalHours(): float
{
return $this->summaryBuilder()->totalHours();
}
public function getReportCount(): int
{
return count($this->getReportData());
}
}

View File

@ -0,0 +1,166 @@
<?php
namespace App\Filament\Pages;
use App\Filament\Concerns\RestoresHeaderInteractivity;
use App\Models\Setting;
use App\Support\AuditLogger;
use Filament\Actions\Action;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Pages\Concerns\CanUseDatabaseTransactions;
use Filament\Pages\Page;
use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\EmbeddedSchema;
use Filament\Schemas\Components\Form;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Support\Exceptions\Halt;
use Throwable;
/**
* @property-read Schema $form
*/
class Settings extends Page
{
use CanUseDatabaseTransactions;
use RestoresHeaderInteractivity;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-cog-6-tooth';
protected static string|\UnitEnum|null $navigationGroup = 'Administration';
protected static ?int $navigationSort = 10;
protected static ?string $title = 'Settings';
protected static ?string $slug = 'settings';
/**
* @var array<string, mixed>|null
*/
public ?array $data = [];
public static function canAccess(): bool
{
return auth()->user()?->isAdmin() ?? false;
}
public function mount(): void
{
$this->fillForm();
}
protected function fillForm(): void
{
$this->form->fill([
'standardWeeklyHours' => Setting::standardWeeklyHours(),
'requireDirectorApproval' => Setting::getValue('requireDirectorApproval', true),
'emailNotifications' => Setting::getValue('emailNotifications', true),
]);
}
public function save(): void
{
try {
$this->beginDatabaseTransaction();
$data = $this->form->getState();
Setting::setValue('standardWeeklyHours', (float) $data['standardWeeklyHours']);
Setting::setValue('requireDirectorApproval', (bool) $data['requireDirectorApproval']);
Setting::setValue('emailNotifications', (bool) $data['emailNotifications']);
} catch (Halt $exception) {
$exception->shouldRollbackDatabaseTransaction() ?
$this->rollBackDatabaseTransaction() :
$this->commitDatabaseTransaction();
return;
} catch (Throwable $exception) {
$this->rollBackDatabaseTransaction();
throw $exception;
}
$this->commitDatabaseTransaction();
AuditLogger::log('Application settings updated', null, [
'standardWeeklyHours' => $data['standardWeeklyHours'],
'requireDirectorApproval' => $data['requireDirectorApproval'],
'emailNotifications' => $data['emailNotifications'],
]);
Notification::make()
->success()
->title('Settings saved')
->send();
$this->restoreHeaderInteractivity();
}
public function defaultForm(Schema $schema): Schema
{
return $schema
->statePath('data');
}
public function form(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Time & Overtime')
->description('Configure how weekly overtime is calculated across dashboards and reports.')
->schema([
Forms\Components\TextInput::make('standardWeeklyHours')
->label('Standard weekly hours')
->helperText('Weeks exceeding this total are counted as overtime. Common values: 40 (US), 37.5 (UK), 35 (France/Malaysia public sector).')
->numeric()
->required()
->minValue(1)
->maxValue(168)
->step(0.5)
->suffix('hours'),
]),
Section::make('Approvals')
->schema([
Forms\Components\Toggle::make('requireDirectorApproval')
->label('Require Project Director approval')
->helperText('When enabled, timesheets need both PM and PD sign-off before they are fully approved.'),
]),
Section::make('Notifications')
->schema([
Forms\Components\Toggle::make('emailNotifications')
->label('Email notifications')
->helperText('Sends email on submit, approval, and rejection. Requires Resend domain verification and the queue worker on the server.'),
]),
]);
}
/**
* @return array<Action>
*/
protected function getFormActions(): array
{
return [
Action::make('save')
->label('Save settings')
->submit('save')
->keyBindings(['mod+s']),
];
}
public function content(Schema $schema): Schema
{
return $schema
->components([
Form::make([EmbeddedSchema::make('form')])
->id('settings-form')
->livewireSubmitHandler('save')
->footer([
Actions::make($this->getFormActions())
->alignment($this->getFormActionsAlignment())
->key('settings-form-actions'),
]),
]);
}
}

View File

@ -0,0 +1,365 @@
<?php
namespace App\Filament\Pages;
use App\Models\User;
use App\Support\TimesheetAccess;
use App\Support\WeeklyHoursAccess;
use App\Support\WeeklyHoursFormatter;
use App\Support\WeeklyHoursSheet;
use Carbon\Carbon;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Auth\Access\AuthorizationException;
class WeeklyHours extends Page
{
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-calendar-days';
protected static string|\UnitEnum|null $navigationGroup = 'Time Tracking';
protected static ?int $navigationSort = 0;
protected static ?string $navigationLabel = 'Weekly hours';
protected static ?string $title = 'Weekly hours';
protected static ?string $slug = 'weekly-hours';
protected string $view = 'filament.pages.weekly-hours';
public ?int $selectedUserId = null;
public string $weekStart = '';
/** @var list<array<string, mixed>> */
public array $rows = [];
public function mount(): void
{
$this->weekStart = now()->startOfWeek(Carbon::MONDAY)->format('Y-m-d');
$this->selectedUserId = auth()->id();
$this->loadSheet();
}
protected function getHeaderActions(): array
{
return [
Action::make('printWeeklyHours')
->label('Print')
->icon('heroicon-o-printer')
->color('gray')
->url(fn (): string => $this->getPrintUrl())
->openUrlInNewTab(),
Action::make('downloadWeeklyHoursPdf')
->label('Download PDF')
->icon('heroicon-o-arrow-down-tray')
->color('gray')
->url(fn (): string => $this->getPdfDownloadUrl())
->openUrlInNewTab(),
];
}
public static function getNavigationLabel(): string
{
return 'Weekly hours';
}
/**
* @return array<string>
*/
public function getPageClasses(): array
{
return ['corp-weekly-hours-page'];
}
public function getWeekLabel(): string
{
$start = Carbon::parse($this->weekStart);
return $start->format('F Y') . ' Week ' . $start->isoWeek();
}
/**
* @return array<int, string>
*/
public function getDayHeaders(): array
{
$start = Carbon::parse($this->weekStart)->startOfDay();
return collect(range(0, 6))
->mapWithKeys(function (int $offset) use ($start): array {
$date = $start->copy()->addDays($offset);
return [$offset => $date->format('D j')];
})
->all();
}
/**
* @return array<int, string>
*/
public function getUserOptions(): array
{
$viewer = auth()->user();
if (! $viewer || $viewer->isEmployee()) {
return [];
}
$query = User::query()->orderBy('name');
if ($viewer->isAdmin()) {
return $query->pluck('name', 'id')->all();
}
return $query
->where(function ($userQuery) use ($viewer): void {
$userQuery
->whereHas('timesheets.project', fn ($projectQuery) => TimesheetAccess::scopeAssignedProjectsForUser($projectQuery, $viewer))
->orWhere('id', $viewer->id);
})
->pluck('name', 'id')
->all();
}
/**
* @return array<int, string>
*/
public function getProjectOptions(): array
{
return $this->sheet()->projectOptions();
}
public function canEditSheet(): bool
{
$viewer = auth()->user();
$target = $this->selectedUser();
if (! $viewer || ! $target) {
return false;
}
if ($viewer->isAdmin()) {
return true;
}
return $viewer->isEmployee() && $viewer->id === $target->id;
}
public function previousWeek(): void
{
$this->weekStart = Carbon::parse($this->weekStart)
->subWeek()
->startOfWeek(Carbon::MONDAY)
->format('Y-m-d');
$this->loadSheet();
}
public function nextWeek(): void
{
$this->weekStart = Carbon::parse($this->weekStart)
->addWeek()
->startOfWeek(Carbon::MONDAY)
->format('Y-m-d');
$this->loadSheet();
}
public function updatedSelectedUserId(): void
{
$this->authorizeSelectedUser();
$this->loadSheet();
}
public function addRow(): void
{
if (! $this->canEditSheet()) {
return;
}
$this->rows[] = [
'id' => null,
'project_id' => null,
'project_name' => '',
'activity' => '',
'hours' => [0, 0, 0, 0, 0, 0, 0],
'status' => 'draft',
'editable' => true,
];
}
public function removeRow(int $index): void
{
if (! $this->canEditSheet()) {
return;
}
if (! isset($this->rows[$index]) || ! ($this->rows[$index]['editable'] ?? false)) {
return;
}
unset($this->rows[$index]);
$this->rows = array_values($this->rows);
if ($this->rows === []) {
$this->addRow();
}
}
public function save(): void
{
if (! $this->canEditSheet()) {
Notification::make()
->title('You cannot edit this weekly sheet.')
->danger()
->send();
return;
}
$this->authorizeSelectedUser();
try {
$this->sheet()->saveRows($this->rows, auth()->user());
} catch (\Illuminate\Validation\ValidationException $exception) {
Notification::make()
->title('Could not save weekly hours')
->body($this->formatValidationError($exception))
->danger()
->send();
throw $exception;
}
$this->loadSheet();
Notification::make()
->title('Weekly hours saved')
->body('Your timesheet rows for this week were updated.')
->success()
->send();
}
public function formatHours(float | int | string | null $hours): string
{
return WeeklyHoursFormatter::display($hours);
}
public function rowDuration(array $row): string
{
return WeeklyHoursFormatter::display(WeeklyHoursFormatter::rowTotal($row['hours'] ?? []));
}
/**
* @return list<string>
*/
public function columnTotals(): array
{
return array_map(
fn (float $total): string => WeeklyHoursFormatter::display($total),
WeeklyHoursFormatter::columnTotals($this->rows),
);
}
public function grandTotal(): string
{
$total = array_sum(WeeklyHoursFormatter::columnTotals($this->rows));
return WeeklyHoursFormatter::display($total);
}
public function getPdfDownloadUrl(): string
{
return route('pdf.weekly-hours', [
'user' => $this->selectedUserId,
'weekStart' => $this->weekStart,
]);
}
public function getPrintUrl(): string
{
return route('weekly-hours.print', [
'user' => $this->selectedUserId,
'weekStart' => $this->weekStart,
]);
}
protected function loadSheet(): void
{
$this->rows = $this->sheet()->loadRows();
}
protected function sheet(): WeeklyHoursSheet
{
return new WeeklyHoursSheet(
$this->selectedUser(),
Carbon::parse($this->weekStart)->startOfWeek(Carbon::MONDAY),
);
}
public function selectedUser(): User
{
$user = User::query()->find($this->selectedUserId);
if (! $user) {
abort(404);
}
return $user;
}
protected function authorizeSelectedUser(): void
{
$viewer = auth()->user();
$target = $this->selectedUser();
if (! $viewer) {
throw new AuthorizationException();
}
if ($viewer->isEmployee() && $viewer->id !== $target->id) {
$this->selectedUserId = $viewer->id;
$this->loadSheet();
Notification::make()
->title('You can only view your own weekly hours.')
->danger()
->send();
return;
}
if ($viewer->isAdmin()) {
return;
}
if ($viewer->isEmployee()) {
return;
}
if (! WeeklyHoursAccess::userCanView($viewer, $target)) {
throw new AuthorizationException('You cannot view weekly hours for this user.');
}
}
protected function formatValidationError(\Illuminate\Validation\ValidationException $exception): string
{
return collect($exception->errors())
->flatMap(function (array $messages, string $key): array {
if (preg_match('/^rows\.(\d+)\./', $key, $matches) !== 1) {
return $messages;
}
$rowNumber = ((int) $matches[1]) + 1;
return array_map(
fn (string $message): string => "Row {$rowNumber}: {$message}",
$messages,
);
})
->first() ?? 'Please review the highlighted rows and try again.';
}
}

View File

@ -0,0 +1,183 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\ActivityLogResource\Pages;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ActivityLogResource extends Resource
{
protected static ?string $model = Activity::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-list';
protected static string|\UnitEnum|null $navigationGroup = 'Administration';
protected static ?int $navigationSort = 20;
protected static ?string $navigationLabel = 'Audit Log';
protected static ?string $modelLabel = 'Audit entry';
protected static ?string $pluralModelLabel = 'Audit log';
public static function canAccess(): bool
{
return static::canViewAny();
}
public static function canViewAny(): bool
{
return auth()->user()?->isAdmin() ?? false;
}
public static function canView(Model $record): bool
{
return static::canViewAny();
}
public static function form(Schema $schema): Schema
{
return $schema->schema([]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('created_at')
->label('When')
->dateTime('M j, Y H:i')
->sortable(),
Tables\Columns\TextColumn::make('description')
->searchable()
->wrap(),
Tables\Columns\TextColumn::make('log_name')
->label('Log')
->badge()
->sortable(),
Tables\Columns\TextColumn::make('event')
->badge()
->toggleable(),
Tables\Columns\TextColumn::make('causer.name')
->label('User')
->placeholder('System')
->searchable(),
Tables\Columns\TextColumn::make('subject_type')
->label('Subject')
->formatStateUsing(fn (?string $state, Activity $record): string => $state
? Str::afterLast($state, '\\') . ' #' . ($record->subject_id ?? '—')
: '—')
->toggleable(),
Tables\Columns\TextColumn::make('properties')
->label('Details')
->formatStateUsing(function ($state): string {
if (blank($state)) {
return '—';
}
$json = is_string($state) ? $state : json_encode($state);
return Str::limit((string) $json, 80);
})
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('created_at', 'desc')
->filters([
Tables\Filters\SelectFilter::make('log_name')
->options(fn (): array => Activity::query()
->select('log_name')
->distinct()
->orderBy('log_name')
->pluck('log_name', 'log_name')
->filter()
->all()),
Tables\Filters\SelectFilter::make('subject_type')
->label('Subject type')
->options([
'App\Models\Timesheet' => 'Timesheet',
'App\Models\User' => 'User',
'App\Models\Project' => 'Project',
]),
Tables\Filters\Filter::make('created_at')
->schema([
Forms\Components\DatePicker::make('from')
->label('From'),
Forms\Components\DatePicker::make('until')
->label('Until'),
])
->query(function (Builder $query, array $data): Builder {
return $query
->when($data['from'] ?? null, fn (Builder $q, $date) => $q->whereDate('created_at', '>=', $date))
->when($data['until'] ?? null, fn (Builder $q, $date) => $q->whereDate('created_at', '<=', $date));
}),
])
->actions([
\Filament\Actions\ViewAction::make()
->modalHeading('Audit entry')
->modalContent(fn (Activity $record) => view('filament.resources.activity-log-entry', ['record' => $record])),
])
->headerActions([
\Filament\Actions\Action::make('exportCsv')
->label('Export CSV')
->icon('heroicon-o-arrow-down-tray')
->action(fn (): StreamedResponse => static::exportCsv()),
])
->bulkActions([])
->emptyStateHeading('No audit entries yet')
->emptyStateDescription('Workflow actions and admin changes appear here after they occur. Historical approval activity can be imported with `php artisan activitylog:backfill-approval-logs`.')
->emptyStateIcon('heroicon-o-clipboard-document-list');
}
public static function exportCsv(): StreamedResponse
{
$filename = 'audit-log-' . now()->format('Y-m-d-His') . '.csv';
return response()->streamDownload(function (): void {
$handle = fopen('php://output', 'w');
fputcsv($handle, ['created_at', 'description', 'log_name', 'event', 'causer', 'subject_type', 'subject_id']);
Activity::query()
->with('causer')
->orderByDesc('created_at')
->chunk(500, function ($activities) use ($handle): void {
foreach ($activities as $activity) {
fputcsv($handle, [
$activity->created_at?->toDateTimeString(),
$activity->description,
$activity->log_name,
$activity->event,
$activity->causer?->name,
$activity->subject_type,
$activity->subject_id,
]);
}
});
fclose($handle);
}, $filename, [
'Content-Type' => 'text/csv',
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListActivityLogs::route('/'),
];
}
public static function canCreate(): bool
{
return false;
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\ActivityLogResource\Pages;
use App\Filament\Resources\ActivityLogResource;
use Filament\Resources\Pages\ListRecords;
class ListActivityLogs extends ListRecords
{
protected static string $resource = ActivityLogResource::class;
}

View File

@ -0,0 +1,273 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Concerns\ConfiguresTableToolbar;
use App\Filament\Resources\ProjectResource\Pages;
use App\Models\Project;
use App\Models\User;
use App\Support\TimesheetAccess;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
class ProjectResource extends Resource
{
use ConfiguresTableToolbar;
protected static ?string $model = Project::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-folder-open';
protected static string|\UnitEnum|null $navigationGroup = 'Management';
protected static ?int $navigationSort = 2;
public static function form(Schema $schema): Schema
{
return $schema
->schema([
\Filament\Schemas\Components\Section::make('Project details')
->description('Step 1 — Create the project record.')
->schema([
Forms\Components\TextInput::make('code')
->required()
->maxLength(50)
->unique(ignoreRecord: true),
Forms\Components\TextInput::make('name')
->required()
->maxLength(255)
->unique(ignoreRecord: true),
Forms\Components\Textarea::make('description')
->label('Description')
->rows(3)
->maxLength(2000)
->columnSpanFull(),
Forms\Components\Select::make('status')
->options([
'active' => 'Active',
'inactive' => 'Inactive',
'archived' => 'Archived',
])
->default('active'),
])->columns(2),
\Filament\Schemas\Components\Section::make('Timeline')
->description('Step 2 — Define the project start and end dates.')
->schema([
Forms\Components\DatePicker::make('start_date')
->label('Start date')
->required()
->native(false)
->displayFormat('d/m/Y')
->format('Y-m-d')
->closeOnDateSelection()
->live(),
Forms\Components\DatePicker::make('end_date')
->label('End date')
->required()
->native(false)
->displayFormat('d/m/Y')
->format('Y-m-d')
->closeOnDateSelection()
->minDate(fn (Get $get) => $get('start_date'))
->rule('after_or_equal:start_date'),
])->columns(2),
\Filament\Schemas\Components\Section::make('Approvers')
->description('Assign who receives approval emails and can sign off timesheets for this project.')
->schema([
Forms\Components\Select::make('project_manager_id')
->label('Project Manager')
->relationship(
'projectManager',
'name',
fn ($query) => $query->whereIn('role', ['project_manager', 'admin'])
)
->default(fn () => auth()->user()?->isProjectManager() ? auth()->id() : null)
->searchable()
->preload()
->required(),
Forms\Components\Select::make('project_director_id')
->label('Project Director')
->relationship(
'projectDirector',
'name',
fn ($query) => $query->whereIn('role', ['project_director', 'admin'])
)
->default(fn () => auth()->user()?->isProjectDirector() ? auth()->id() : null)
->searchable()
->preload()
->required(),
])->columns(2),
\Filament\Schemas\Components\Section::make('Team members')
->description('Step 3 — Assign project members and their role on this project.')
->schema([
Forms\Components\Repeater::make('member_assignments')
->label('Project members')
->schema([
Forms\Components\Select::make('user_id')
->label('Member')
->options(fn (): array => User::query()
->where('role', 'employee')
->orderBy('name')
->get()
->mapWithKeys(fn (User $user): array => [
$user->id => "{$user->name} ({$user->email})",
])
->all())
->required()
->searchable()
->distinct(),
Forms\Components\TextInput::make('assigned_role')
->label('Role')
->required()
->maxLength(100)
->placeholder('e.g. Designer, Developer'),
])
->minItems(1)
->required()
->columns(2)
->addActionLabel('Add member')
->dehydrated(),
]),
\Filament\Schemas\Components\Section::make('Record')
->schema([
Forms\Components\Placeholder::make('creator_display')
->label('Created by')
->content(fn (?Project $record): string => $record?->creator?->name ?? '—'),
Forms\Components\Placeholder::make('created_at_display')
->label('Created at')
->content(fn (?Project $record): string => $record?->created_at?->format('d/m/Y H:i') ?? '—'),
])
->columns(2)
->visible(fn (?Project $record): bool => $record !== null),
]);
}
public static function table(Table $table): Table
{
return static::configureTableToolbar($table
->columns([
Tables\Columns\TextColumn::make('code')
->badge()
->color('primary')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('start_date')
->label('Start')
->date('d/m/Y')
->sortable()
->placeholder('—'),
Tables\Columns\TextColumn::make('end_date')
->label('End')
->date('d/m/Y')
->sortable()
->placeholder('—'),
Tables\Columns\TextColumn::make('members_count')
->label('Members')
->counts('members')
->sortable(),
Tables\Columns\TextColumn::make('projectManager.name')
->label('PM')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('projectDirector.name')
->label('PD')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('schedule_status')
->label('Schedule')
->badge()
->state(fn (Project $record): string => $record->scheduleHealth()->label())
->color(fn (Project $record): string => $record->scheduleHealth()->color()),
Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(fn (string $state) => ucfirst($state))
->color(fn (string $state) => match ($state) {
'active' => 'success',
'archived' => 'gray',
default => 'warning',
}),
Tables\Columns\TextColumn::make('creator.name')
->label('Created by')
->searchable()
->sortable()
->placeholder('—'),
Tables\Columns\TextColumn::make('created_at')
->label('Created')
->dateTime('d/m/Y H:i')
->sortable(),
])
->filters([
Tables\Filters\SelectFilter::make('status')
->options([
'active' => 'Active',
'inactive' => 'Inactive',
'archived' => 'Archived',
]),
])
->actions([
\Filament\Actions\ViewAction::make(),
\Filament\Actions\EditAction::make()
->visible(fn (Project $record) => static::canEdit($record)),
\Filament\Actions\DeleteAction::make()
->visible(fn () => auth()->user()?->isAdmin() ?? false),
])
->bulkActions([
\Filament\Actions\BulkActionGroup::make([
\Filament\Actions\DeleteBulkAction::make()
->visible(fn () => auth()->user()?->isAdmin() ?? false),
]),
]));
}
public static function canEdit(Model $record): bool
{
return TimesheetAccess::userCanEditProject(
auth()->user(),
$record instanceof Project ? $record : null,
);
}
public static function canView(Model $record): bool
{
return TimesheetAccess::userCanViewProject(
auth()->user(),
$record instanceof Project ? $record : null,
);
}
public static function canCreate(): bool
{
return static::canViewAny();
}
public static function canDelete(Model $record): bool
{
return auth()->user()?->isAdmin() ?? false;
}
public static function getRelations(): array
{
return [];
}
public static function getPages(): array
{
return [
'index' => Pages\ListProjects::route('/'),
'create' => Pages\CreateProject::route('/create'),
'view' => Pages\ViewProject::route('/{record}'),
'edit' => Pages\EditProject::route('/{record}/edit'),
];
}
public static function canViewAny(): bool
{
$user = auth()->user();
return $user && in_array($user->role, ['admin', 'project_manager', 'project_director']);
}
}

View File

@ -0,0 +1,98 @@
<?php
namespace App\Filament\Resources\ProjectResource\Pages;
use App\Filament\Concerns\RestoresHeaderInteractivity;
use App\Filament\Concerns\SyncsProjectMembers;
use App\Filament\Resources\ProjectResource;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
class CreateProject extends CreateRecord
{
use RestoresHeaderInteractivity;
use SyncsProjectMembers;
protected static string $resource = ProjectResource::class;
protected function getRedirectUrl(): string
{
return static::getResource()::getUrl('view', ['record' => $this->getRecord()]);
}
protected function getCreatedNotification(): ?Notification
{
$memberCount = $this->record->members()->count();
return Notification::make()
->success()
->title('Project created')
->body(sprintf(
'%s saved with timeline %s to %s. %d team member(s) assigned.',
$this->record->name,
$this->record->start_date?->format('d/m/Y'),
$this->record->end_date?->format('d/m/Y'),
$memberCount,
));
}
protected function getCreateFormAction(): Action
{
return parent::getCreateFormAction()
->label('Create project')
->requiresConfirmation()
->modalHeading('Confirm project creation')
->modalDescription(function (): string {
$data = $this->form->getState();
$memberCount = count($data['member_assignments'] ?? []);
return sprintf(
'Create "%s" (%s) from %s to %s with %d team member(s)?',
$data['name'] ?? '—',
$data['code'] ?? '—',
$data['start_date'] ?? '—',
$data['end_date'] ?? '—',
$memberCount,
);
})
->modalSubmitActionLabel('Yes, create project');
}
protected function beforeCreate(): void
{
$this->resolveMemberAssignments($this->form->getState()['member_assignments'] ?? []);
}
protected function afterCreate(): void
{
$this->record->members()->sync(
$this->resolveMemberAssignments($this->form->getState()['member_assignments'] ?? []),
);
$this->restoreHeaderInteractivity();
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeCreate(array $data): array
{
unset($data['member_assignments']);
$user = auth()->user();
$data['created_by'] = $user?->id;
if ($user?->isProjectManager() && empty($data['project_manager_id'])) {
$data['project_manager_id'] = $user->id;
}
if ($user?->isProjectDirector() && empty($data['project_director_id'])) {
$data['project_director_id'] = $user->id;
}
return $data;
}
}

View File

@ -0,0 +1,77 @@
<?php
namespace App\Filament\Resources\ProjectResource\Pages;
use App\Filament\Concerns\SyncsProjectMembers;
use App\Filament\Resources\ProjectResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditProject extends EditRecord
{
use SyncsProjectMembers;
protected static string $resource = ProjectResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
->visible(fn () => auth()->user()?->isAdmin() ?? false),
];
}
protected function getRedirectUrl(): ?string
{
$this->record->refresh();
return static::getResource()::getUrl('view', ['record' => $this->record]);
}
protected function getSavedNotificationTitle(): ?string
{
return 'Project saved';
}
protected function beforeSave(): void
{
$this->resolveMemberAssignments($this->form->getState()['member_assignments'] ?? []);
}
protected function afterSave(): void
{
$this->record->members()->sync(
$this->resolveMemberAssignments($this->form->getState()['member_assignments'] ?? []),
);
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeFill(array $data): array
{
$this->record->load('members');
$data['member_assignments'] = $this->record->members
->map(fn ($member): array => [
'user_id' => $member->id,
'assigned_role' => $member->pivot->assigned_role,
])
->values()
->all();
return $data;
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeSave(array $data): array
{
unset($data['member_assignments']);
return $data;
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\ProjectResource\Pages;
use App\Filament\Resources\ProjectResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListProjects extends ListRecords
{
protected static string $resource = ProjectResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@ -0,0 +1,146 @@
<?php
namespace App\Filament\Resources\ProjectResource\Pages;
use App\Filament\Resources\ProjectResource;
use Filament\Actions;
use Filament\Infolists\Components\TextEntry;
use Filament\Resources\Pages\ViewRecord;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class ViewProject extends ViewRecord
{
protected static string $resource = ProjectResource::class;
public function mount(int | string $record): void
{
parent::mount($record);
$this->record->load(['members', 'timesheets.user']);
}
protected function getHeaderActions(): array
{
return [
Actions\EditAction::make()
->visible(fn () => ProjectResource::canEdit($this->record)),
];
}
public function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Project details')
->schema([
TextEntry::make('code')
->badge()
->color('primary'),
TextEntry::make('name'),
TextEntry::make('status')
->badge()
->formatStateUsing(fn (string $state) => ucfirst($state))
->color(fn (string $state) => match ($state) {
'active' => 'success',
'archived' => 'gray',
default => 'warning',
}),
TextEntry::make('description')
->placeholder('—')
->columnSpanFull(),
])
->columns(3),
Section::make('Timeline & schedule')
->schema([
TextEntry::make('start_date')
->label('Start date')
->date('d/m/Y')
->placeholder('—'),
TextEntry::make('end_date')
->label('End date')
->date('d/m/Y')
->placeholder('—'),
TextEntry::make('schedule_status')
->label('Schedule status')
->badge()
->state(fn ($record) => $record->scheduleHealth()->label())
->color(fn ($record) => $record->scheduleHealth()->color()),
TextEntry::make('duration_days')
->label('Duration')
->state(fn ($record) => ($days = $record->scheduleHealth()->durationDays()) !== null
? "{$days} days"
: '—'),
TextEntry::make('days_remaining')
->label('Days remaining')
->state(fn ($record) => ($days = $record->scheduleHealth()->daysRemaining()) !== null
? "{$days} days"
: '—'),
TextEntry::make('time_progress')
->label('Timeline progress')
->state(fn ($record) => ($progress = $record->scheduleHealth()->timeProgressPercent()) !== null
? number_format($progress, 1) . '%'
: '—'),
TextEntry::make('hours_logged')
->label('Hours logged')
->state(fn ($record) => number_format($record->scheduleHealth()->hoursLogged(), 1) . 'h'),
TextEntry::make('expected_hours')
->label('Expected hours to date')
->state(fn ($record) => number_format($record->scheduleHealth()->expectedHoursToDate(), 1) . 'h'),
TextEntry::make('hours_progress')
->label('Hours vs expected')
->state(fn ($record) => ($progress = $record->scheduleHealth()->hoursProgressPercent()) !== null
? number_format($progress, 1) . '%'
: '—'),
])
->columns(3),
Section::make('Approvers')
->schema([
TextEntry::make('projectManager.name')
->label('Project Manager'),
TextEntry::make('projectDirector.name')
->label('Project Director'),
])
->columns(2),
Section::make('Team members')
->schema([
TextEntry::make('members_list')
->label('Assigned members')
->state(fn ($record) => $record->members
->sortBy('name')
->map(fn ($member) => "{$member->name}{$member->pivot->assigned_role}")
->join("\n") ?: '—')
->markdown()
->columnSpanFull(),
]),
Section::make('Member contributions')
->schema([
TextEntry::make('member_contributions')
->label('Hours by member')
->state(function ($record): string {
$rows = $record->memberContributions();
if ($rows === []) {
return '—';
}
return collect($rows)
->map(fn (array $row): string => "{$row['name']} ({$row['role']}): {$row['hours']}h")
->join("\n");
})
->markdown()
->columnSpanFull(),
]),
Section::make('Record')
->schema([
TextEntry::make('creator.name')
->label('Created by')
->placeholder('—'),
TextEntry::make('created_at')
->label('Created at')
->dateTime('d/m/Y H:i'),
])
->columns(2),
]);
}
}

View File

@ -0,0 +1,546 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Concerns\ConfiguresTableToolbar;
use App\Filament\Forms\Components\DailyTasksGrid;
use App\Filament\Forms\Components\WeeklyTimesheetPlanner;
use App\Filament\Resources\TimesheetResource\Pages;
use App\Models\Setting;
use App\Models\Timesheet;
use App\Models\User;
use App\Rules\ValidDailyHours;
use App\Rules\WeekStartsOnMonday;
use App\Support\AuditLogger;
use App\Support\TimesheetAccess;
use App\Support\TimesheetNotifier;
use Carbon\Carbon;
use Filament\Forms;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
class TimesheetResource extends Resource
{
use ConfiguresTableToolbar;
protected static ?string $model = Timesheet::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-clock';
protected static string|\UnitEnum|null $navigationGroup = 'Time Tracking';
protected static ?int $navigationSort = 1;
public static function getNavigationLabel(): string
{
$user = auth()->user();
return ($user && $user->isEmployee()) ? 'My Timesheets' : 'All Timesheets';
}
public static function form(Schema $schema): Schema
{
return $schema
->schema([
\Filament\Schemas\Components\Section::make('Project')
->description('Choose where you worked and your role on the project.')
->icon('heroicon-o-briefcase')
->schema([
Forms\Components\Select::make('user_id')
->relationship('user', 'name')
->required()
->searchable()
->visible(fn () => auth()->user()->isApprover()),
Forms\Components\Select::make('project_id')
->relationship(
'project',
'name',
function (Builder $query, $livewire): void {
$query->where('status', 'active');
if (! auth()->user()?->isEmployee()) {
return;
}
$query->where(function (Builder $employeeProjects) use ($livewire): void {
$employeeProjects->whereHas(
'members',
fn (Builder $members) => $members->whereKey(auth()->id()),
);
$record = method_exists($livewire, 'getRecord')
? $livewire->getRecord()
: null;
if ($record?->project_id) {
$employeeProjects->orWhere('projects.id', $record->project_id);
}
});
},
)
->required()
->searchable()
->preload()
->live(),
Forms\Components\TextInput::make('project_role')
->label('Project role')
->placeholder('e.g. Site Engineer, Developer')
->maxLength(100)
->required()
->visible(fn (Get $get): bool => filled($get('project_id'))),
])
->columns(2),
\Filament\Schemas\Components\Section::make('Date')
->description('Pick the day you want to log. The form opens on that day automatically.')
->icon('heroicon-o-calendar')
->schema([
Forms\Components\DatePicker::make('work_date')
->label('Work date')
->required()
->native(false)
->displayFormat('d/m/Y')
->format('Y-m-d')
->default(Carbon::now()->format('Y-m-d'))
->closeOnDateSelection()
->live()
->dehydrated(false)
->afterStateHydrated(function ($state, Set $set): void {
$date = filled($state)
? Carbon::parse($state)
: Carbon::now();
if (blank($state)) {
$set('work_date', $date->format('Y-m-d'));
}
$set('week_start', $date->copy()->startOfWeek(Carbon::MONDAY)->format('Y-m-d'));
})
->afterStateUpdated(function (?string $state, Set $set, $livewire): void {
if (blank($state)) {
return;
}
$date = Carbon::parse($state);
$set('week_start', $date->copy()->startOfWeek(Carbon::MONDAY)->format('Y-m-d'));
$livewire->dispatch(
'timesheet-date-chosen',
dayIndex: max(0, min(6, $date->dayOfWeekIso - 1)),
);
}),
Forms\Components\Hidden::make('week_start')
->default(Carbon::now()->startOfWeek(Carbon::MONDAY)->format('Y-m-d'))
->dehydrated()
->required(),
])
->visible(fn (Get $get): bool => filled($get('project_id')) && filled($get('project_role')))
->columns(2),
\Filament\Schemas\Components\Section::make('Time entry')
->description('Fill in hours and activity for the selected day, then move through the week.')
->icon('heroicon-o-clock')
->schema([
WeeklyTimesheetPlanner::make('hours')
->hiddenLabel()
->columnSpanFull(),
DailyTasksGrid::make('tasks')
->hiddenLabel()
->view('filament.forms.components.empty-field')
->dehydrated(),
Forms\Components\Textarea::make('notes')
->label('Additional notes')
->placeholder('Optional notes for the whole week')
->rows(2),
])
->visible(fn (Get $get): bool => filled($get('project_id')) && filled($get('project_role'))),
]);
}
public static function table(Table $table): Table
{
$user = auth()->user();
return static::configureTableToolbar($table
->columns([
Tables\Columns\TextColumn::make('user.name')
->searchable()
->sortable()
->visible(fn() => $user && !$user->isEmployee()),
Tables\Columns\TextColumn::make('project.code')
->badge()
->color('primary')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('project.name')
->searchable()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('week_start')
->label('Week start')
->date('d/m/Y')
->sortable(),
Tables\Columns\TextColumn::make('week_number')
->label('Week')
->getStateUsing(fn(Timesheet $record) => $record->week_start->isoWeek()),
Tables\Columns\TextColumn::make('total_hours')
->label('Total Hours')
->getStateUsing(fn(Timesheet $record) => $record->totalHours() . 'h')
->sortable(),
Tables\Columns\TextColumn::make('status')
->badge()
->color(fn(string $state) => match ($state) {
'approved' => 'success',
'pending_pd', 'pending_pm' => 'warning',
'rejected' => 'danger',
default => 'gray',
})
->formatStateUsing(fn(string $state) => ucwords(str_replace('_', ' ', $state)))
->sortable(),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\SelectFilter::make('status')
->options([
'draft' => 'Draft',
'pending_pm' => 'Pending PM',
'pending_pd' => 'Pending Director',
'approved' => 'Approved',
'rejected' => 'Rejected',
]),
Tables\Filters\SelectFilter::make('project_id')
->relationship(
'project',
'name',
fn (Builder $query) => TimesheetAccess::scopeProjectsForUser($query, auth()->user()),
)
->searchable()
->preload(),
Tables\Filters\Filter::make('approved_by_me')
->label('Approved by me')
->visible(fn () => $user?->isApprover() ?? false)
->query(fn (Builder $query) => $query->whereHas(
'approvalLogs',
fn (Builder $logQuery) => $logQuery
->where('user_id', auth()->id())
->whereIn('action', ['approved_pm', 'approved_pd']),
)),
Tables\Filters\SelectFilter::make('user_id')
->relationship('user', 'name')
->searchable()
->preload()
->visible(fn() => $user && !$user->isEmployee()),
])
->actions([
\Filament\Actions\ViewAction::make(),
\Filament\Actions\EditAction::make()
->visible(fn (Timesheet $record) => auth()->user() && TimesheetAccess::userCanEditTimesheet(auth()->user(), $record)),
\Filament\Actions\Action::make('printPdf')
->label('Print')
->icon('heroicon-o-printer')
->color('gray')
->url(fn (Timesheet $record) => route('pdf.weekly', $record))
->openUrlInNewTab(),
\Filament\Actions\Action::make('submit')
->label('Submit')
->icon('heroicon-o-paper-airplane')
->color('primary')
->visible(fn (Timesheet $record) => static::canUserSubmitTimesheet($user, $record))
->requiresConfirmation()
->modalHeading('Submit timesheet')
->modalDescription('Submit this timesheet for project manager approval? You will not be able to edit it until it is rejected or reverted to draft.')
->action(function (Timesheet $record): void {
static::submitTimesheet($record);
\Filament\Notifications\Notification::make()
->title('Timesheet submitted')
->body('Your timesheet has been sent for approval.')
->success()
->send();
}),
\Filament\Actions\Action::make('approve')
->label('Approve')
->icon('heroicon-o-check')
->color('success')
->visible(fn(Timesheet $record) => static::canApprove($record))
->form([
Forms\Components\Textarea::make('comment')
->label('Comment (optional)')
->rows(2),
])
->action(function (Timesheet $record, array $data) {
static::handleApprove($record, $data['comment'] ?? '');
}),
\Filament\Actions\Action::make('reject')
->label('Reject')
->icon('heroicon-o-x-mark')
->color('danger')
->visible(fn (Timesheet $record) => static::canReject($record))
->form([
Forms\Components\Textarea::make('comment')
->label('Reason for rejection')
->required()
->rows(2),
])
->action(function (Timesheet $record, array $data) {
static::handleReject($record, $data['comment']);
}),
\Filament\Actions\Action::make('revertToDraft')
->label('Revert to Draft')
->icon('heroicon-o-arrow-uturn-left')
->color('warning')
->visible(fn (Timesheet $record) => auth()->user() && TimesheetAccess::userCanRevertToDraft(auth()->user(), $record))
->requiresConfirmation()
->modalHeading('Revert approved timesheet to draft')
->modalDescription('This will unlock the timesheet so the employee can edit and resubmit it. The action is recorded in the approval history.')
->form([
Forms\Components\Textarea::make('reason')
->label('Reason for revert')
->required()
->rows(3)
->maxLength(1000),
])
->action(function (Timesheet $record, array $data) {
static::handleRevertToDraft($record, $data['reason']);
}),
])
->bulkActions([])
->defaultSort('week_start', 'desc'));
}
public static function canApprove(Timesheet $record): bool
{
$user = auth()->user();
return $user ? $record->canBeApprovedBy($user) : false;
}
public static function canReject(Timesheet $record): bool
{
$user = auth()->user();
return $user ? $record->canBeRejectedBy($user) : false;
}
public static function canUserSubmitTimesheet(?User $user, Timesheet $record): bool
{
return $user instanceof User
&& $record->isSubmittable()
&& $record->user_id === $user->id;
}
/**
* @throws ValidationException
*/
public static function validateForSubmission(Timesheet $record): void
{
$validator = Validator::make(
[
'project_id' => $record->project_id,
'project_role' => $record->project_role,
'week_start' => $record->week_start?->format('Y-m-d'),
'hours' => $record->hours ?? [],
],
[
'project_id' => ['required'],
'project_role' => ['required', 'string', 'max:100'],
'week_start' => ['required', new WeekStartsOnMonday],
'hours' => ['required', new ValidDailyHours],
],
);
$validator->after(function ($validator) use ($record): void {
if ($record->totalHours() <= 0) {
$validator->errors()->add('hours', 'Enter at least some hours before submitting.');
}
});
if ($validator->fails()) {
throw ValidationException::withMessages($validator->errors()->toArray());
}
}
/**
* @throws ValidationException
*/
public static function submitTimesheet(Timesheet $record): void
{
$user = auth()->user();
if (! static::canUserSubmitTimesheet($user, $record)) {
abort(403, 'You are not allowed to submit this timesheet.');
}
static::validateForSubmission($record);
$record->update(['status' => 'pending_pm']);
$record->approvalLogs()->create([
'user_id' => $user->id,
'action' => 'submitted',
]);
AuditLogger::log('Timesheet submitted for approval', $record, [
'status' => 'pending_pm',
]);
TimesheetNotifier::notifySubmitted($record->fresh(['user', 'project']));
}
public static function handleApprove(Timesheet $record, string $comment): void
{
$user = auth()->user();
if ($record->isPendingPm() && $record->canBeApprovedBy($user)) {
$requireDirector = Setting::getValue('requireDirectorApproval', true);
if ($requireDirector) {
$record->update(['status' => 'pending_pd']);
$record->approvalLogs()->create([
'user_id' => $user->id,
'action' => 'approved_pm',
'comment' => $comment,
]);
TimesheetNotifier::notifyPendingDirector($record->fresh(['user', 'project']), $comment);
AuditLogger::log('Timesheet approved by PM, pending PD', $record, [
'action' => 'approved_pm',
'status' => 'pending_pd',
]);
return;
}
$record->update(['status' => 'approved']);
$record->approvalLogs()->create([
'user_id' => $user->id,
'action' => 'approved_pm',
'comment' => $comment,
]);
TimesheetNotifier::notifyApproved($record->fresh(['user', 'project']), $user, $comment);
AuditLogger::log('Timesheet approved (PM, final)', $record, [
'action' => 'approved_pm',
'status' => 'approved',
]);
return;
}
if ($record->isPendingPd() && $record->canBeApprovedBy($user)) {
$record->update(['status' => 'approved']);
$record->approvalLogs()->create([
'user_id' => $user->id,
'action' => 'approved_pd',
'comment' => $comment,
]);
TimesheetNotifier::notifyApproved($record->fresh(['user', 'project']), $user, $comment);
AuditLogger::log('Timesheet approved (PD)', $record, [
'action' => 'approved_pd',
'status' => 'approved',
]);
}
}
public static function handleReject(Timesheet $record, string $comment): void
{
$user = auth()->user();
$action = $record->isPendingPm() ? 'rejected_pm' : 'rejected_pd';
$record->update(['status' => 'rejected']);
$record->approvalLogs()->create([
'user_id' => $user->id,
'action' => $action,
'comment' => $comment,
]);
TimesheetNotifier::notifyRejected($record->fresh(['user', 'project']), $user, $comment);
AuditLogger::log('Timesheet rejected', $record, [
'action' => $action,
'status' => 'rejected',
]);
}
public static function handleRevertToDraft(Timesheet $record, string $reason): void
{
$user = auth()->user();
if (! $user || ! TimesheetAccess::userCanRevertToDraft($user, $record)) {
abort(403, 'You are not allowed to revert this timesheet.');
}
$record->update(['status' => 'draft']);
$record->approvalLogs()->create([
'user_id' => $user->id,
'action' => 'reverted_to_draft',
'comment' => $reason,
]);
AuditLogger::log('Timesheet reverted to draft', $record, [
'action' => 'reverted_to_draft',
'status' => 'draft',
]);
}
public static function canEdit(Model $record): bool
{
$user = auth()->user();
return $user instanceof \App\Models\User
&& $record instanceof Timesheet
&& TimesheetAccess::userCanEditTimesheet($user, $record);
}
public static function canView(Model $record): bool
{
$user = auth()->user();
return $user instanceof \App\Models\User
&& $record instanceof Timesheet
&& TimesheetAccess::userCanViewTimesheet($user, $record);
}
public static function canDelete(Model $record): bool
{
return static::canEdit($record) && $record instanceof Timesheet && $record->isDraft();
}
public static function getEloquentQuery(): Builder
{
$query = parent::getEloquentQuery();
$user = auth()->user();
if ($user) {
TimesheetAccess::scopeTimesheetsForUser($query, $user);
}
return $query;
}
public static function getRelations(): array
{
return [];
}
public static function getPages(): array
{
return [
'index' => Pages\ListTimesheets::route('/'),
'create' => Pages\CreateTimesheet::route('/create'),
'edit' => Pages\EditTimesheet::route('/{record}/edit'),
'view' => Pages\ViewTimesheet::route('/{record}'),
];
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Filament\Resources\TimesheetResource\Concerns;
use App\Filament\Resources\TimesheetResource;
use Filament\Actions;
use Filament\Notifications\Notification;
use Illuminate\Validation\ValidationException;
trait CanSubmitTimesheet
{
protected function makeSubmitTimesheetAction(bool $persistFormFirst = false): Actions\Action
{
return Actions\Action::make('submit')
->label('Submit')
->icon('heroicon-o-paper-airplane')
->color('primary')
->visible(fn (): bool => TimesheetResource::canUserSubmitTimesheet(auth()->user(), $this->record))
->requiresConfirmation()
->modalHeading('Submit timesheet')
->modalDescription('Submit this timesheet for project manager approval? You will not be able to edit it until it is rejected or reverted to draft.')
->action(function () use ($persistFormFirst): void {
if ($persistFormFirst) {
$this->form->validate();
$this->save(shouldRedirect: false, shouldSendSavedNotification: false);
$this->record->refresh();
}
try {
TimesheetResource::submitTimesheet($this->record);
} catch (ValidationException $exception) {
Notification::make()
->title('Cannot submit timesheet')
->body(collect($exception->errors())->flatten()->first() ?? 'Please complete all required fields.')
->danger()
->send();
return;
}
Notification::make()
->title('Timesheet submitted')
->body('Your timesheet has been sent for approval.')
->success()
->send();
$this->record->refresh();
if ($persistFormFirst) {
$this->redirect(TimesheetResource::getUrl('view', ['record' => $this->record]));
}
});
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Filament\Resources\TimesheetResource\Pages;
use App\Filament\Resources\TimesheetResource;
use Carbon\Carbon;
use Filament\Resources\Pages\CreateRecord;
class CreateTimesheet extends CreateRecord
{
protected static string $resource = TimesheetResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
if (! isset($data['user_id'])) {
$data['user_id'] = auth()->id();
}
if (blank($data['week_start'] ?? null)) {
$data['week_start'] = Carbon::now()->startOfWeek(Carbon::MONDAY)->format('Y-m-d');
} else {
$data['week_start'] = Carbon::parse($data['week_start'])
->startOfWeek(Carbon::MONDAY)
->format('Y-m-d');
}
$data['status'] ??= 'draft';
$data['tasks'] ??= ['', '', '', '', '', '', ''];
$data['hours'] ??= [0, 0, 0, 0, 0, 0, 0];
unset($data['work_date']);
return $data;
}
}

View File

@ -0,0 +1,81 @@
<?php
namespace App\Filament\Resources\TimesheetResource\Pages;
use App\Filament\Resources\TimesheetResource;
use App\Filament\Resources\TimesheetResource\Concerns\CanSubmitTimesheet;
use App\Support\TimesheetAccess;
use Carbon\Carbon;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Auth\Access\AuthorizationException;
class EditTimesheet extends EditRecord
{
use CanSubmitTimesheet;
protected static string $resource = TimesheetResource::class;
protected function getHeaderActions(): array
{
return [
$this->makeSubmitTimesheetAction(persistFormFirst: true),
Actions\ViewAction::make(),
Actions\DeleteAction::make()
->visible(fn () => TimesheetResource::canDelete($this->record)),
];
}
protected function beforeSave(): void
{
$this->record->refresh();
if (! TimesheetAccess::userCanEditTimesheet(auth()->user(), $this->record)) {
throw new AuthorizationException('This timesheet cannot be edited in its current status.');
}
}
protected function mutateFormDataBeforeFill(array $data): array
{
$hours = $this->record->hours ?? [0, 0, 0, 0, 0, 0, 0];
$tasks = $this->record->tasks ?? ['', '', '', '', '', '', ''];
$weekStart = $this->record->week_start->copy()->startOfDay();
$today = now()->startOfDay();
$data['hours'] = array_replace([0, 0, 0, 0, 0, 0, 0], $hours);
$data['tasks'] = array_replace(['', '', '', '', '', '', ''], $tasks);
if ($today->between($weekStart, $weekStart->copy()->addDays(6))) {
$data['work_date'] = $today->format('Y-m-d');
} else {
$workDate = $weekStart;
foreach ($hours as $index => $value) {
if ((float) $value > 0) {
$workDate = $weekStart->copy()->addDays($index);
break;
}
}
$data['work_date'] = $workDate->format('Y-m-d');
}
return $data;
}
protected function mutateFormDataBeforeSave(array $data): array
{
if (filled($data['week_start'] ?? null)) {
$data['week_start'] = Carbon::parse($data['week_start'])
->startOfWeek(Carbon::MONDAY)
->format('Y-m-d');
}
$data['tasks'] ??= ['', '', '', '', '', '', ''];
$data['hours'] ??= [0, 0, 0, 0, 0, 0, 0];
unset($data['work_date']);
return $data;
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Filament\Resources\TimesheetResource\Pages;
use App\Filament\Resources\TimesheetResource;
use App\Support\TimesheetSummaryBuilder;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListTimesheets extends ListRecords
{
protected static string $resource = TimesheetResource::class;
protected function getHeaderActions(): array
{
return [
Actions\Action::make('exportSummary')
->label('Export Summary')
->icon('heroicon-o-document-chart-bar')
->color('gray')
->url(fn (): string => TimesheetSummaryBuilder::fromTableFilters($this->tableFilters)->exportUrl())
->openUrlInNewTab(),
Actions\CreateAction::make(),
];
}
}

View File

@ -0,0 +1,142 @@
<?php
namespace App\Filament\Resources\TimesheetResource\Pages;
use App\Filament\Resources\TimesheetResource;
use App\Filament\Resources\TimesheetResource\Concerns\CanSubmitTimesheet;
use App\Support\TimesheetAccess;
use Filament\Actions;
use Filament\Forms;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Pages\ViewRecord;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Illuminate\Database\Eloquent\Model;
class ViewTimesheet extends ViewRecord
{
use CanSubmitTimesheet;
protected static string $resource = TimesheetResource::class;
protected function getHeaderActions(): array
{
$record = $this->record;
$user = auth()->user();
return [
$this->makeSubmitTimesheetAction(),
Actions\EditAction::make()
->visible(fn () => $user && TimesheetAccess::userCanEditTimesheet($user, $record)),
Actions\Action::make('printPdf')
->label('Print')
->icon('heroicon-o-printer')
->color('gray')
->url(fn () => route('pdf.weekly', $record))
->openUrlInNewTab(),
Actions\Action::make('revertToDraft')
->label('Revert to Draft')
->icon('heroicon-o-arrow-uturn-left')
->color('warning')
->visible(fn () => $user && TimesheetAccess::userCanRevertToDraft($user, $record))
->requiresConfirmation()
->modalHeading('Revert approved timesheet to draft')
->modalDescription('This will unlock the timesheet so the employee can edit and resubmit it. The action is recorded in the approval history.')
->form([
Forms\Components\Textarea::make('reason')
->label('Reason for revert')
->required()
->rows(3)
->maxLength(1000),
])
->action(function (array $data): void {
TimesheetResource::handleRevertToDraft($this->record, $data['reason']);
$this->record->refresh();
}),
];
}
public function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Timesheet Details')
->icon('heroicon-o-document-text')
->schema([
TextEntry::make('user.name')
->label('Employee')
->visible(fn () => ! auth()->user()->isEmployee()),
TextEntry::make('project.code')
->label('Project Code')
->badge()
->color('primary'),
TextEntry::make('project.name')
->label('Project Name'),
TextEntry::make('project_role')
->label('Project Role')
->placeholder('—'),
TextEntry::make('week_start')
->label('Week Starting')
->date('d/m/Y'),
TextEntry::make('week_number')
->label('Week Number')
->getStateUsing(fn (Model $record) => 'Week ' . $record->week_start->isoWeek() . ', ' . $record->week_start->year),
TextEntry::make('status')
->badge()
->color(fn (string $state) => match ($state) {
'approved' => 'success',
'pending_pd', 'pending_pm' => 'warning',
'rejected' => 'danger',
default => 'gray',
})
->formatStateUsing(fn (string $state) => ucwords(str_replace('_', ' ', $state))),
])->columns(3),
Section::make('Weekly Breakdown')
->icon('heroicon-o-calendar-days')
->schema([
ViewEntry::make('hours_grid')
->view('filament.infolists.daily-hours-grid'),
ViewEntry::make('tasks_grid')
->view('filament.infolists.daily-tasks-grid')
->visible(fn (Model $record) => collect($record->tasks ?? [])->contains(fn ($task) => filled($task))),
]),
Section::make('Approval History')
->icon('heroicon-o-clipboard-document-check')
->schema([
\Filament\Infolists\Components\RepeatableEntry::make('approvalLogs')
->schema([
TextEntry::make('user.name')
->label('By'),
TextEntry::make('action')
->label('Action')
->badge()
->color(fn (string $state) => match ($state) {
'submitted' => 'info',
'approved_pm', 'approved_pd' => 'success',
'rejected_pm', 'rejected_pd' => 'danger',
'reverted_to_draft' => 'warning',
default => 'gray',
})
->formatStateUsing(fn (string $state) => ucwords(str_replace('_', ' ', $state))),
TextEntry::make('comment')
->visible(fn ($state) => filled($state)),
TextEntry::make('created_at')
->label('Date')
->dateTime('M j, Y g:i A'),
])
->columns(4),
]),
Section::make('Notes')
->icon('heroicon-o-chat-bubble-left-ellipsis')
->schema([
TextEntry::make('notes')
->hiddenLabel(),
])
->visible(fn (Model $record) => filled($record->notes)),
]);
}
}

View File

@ -0,0 +1,123 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Actions\ResetUserPasswordAction;
use App\Filament\Concerns\ConfiguresTableToolbar;
use App\Filament\Resources\UserResource\Pages;
use App\Models\User;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\Facades\Hash;
class UserResource extends Resource
{
use ConfiguresTableToolbar;
protected static ?string $model = User::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-users';
protected static string|\UnitEnum|null $navigationGroup = 'Administration';
protected static ?int $navigationSort = 1;
public static function form(Schema $schema): Schema
{
return $schema
->schema([
\Filament\Schemas\Components\Section::make()->schema([
Forms\Components\TextInput::make('name')
->required()
->maxLength(255),
Forms\Components\TextInput::make('email')
->email()
->required()
->maxLength(255)
->unique(ignoreRecord: true),
Forms\Components\Select::make('role')
->required()
->options([
'employee' => 'Employee',
'project_manager' => 'Project Manager',
'project_director' => 'Project Director',
'admin' => 'Admin',
]),
Forms\Components\TextInput::make('password')
->password()
->dehydrateStateUsing(fn($state) => filled($state) ? Hash::make($state) : null)
->dehydrated(fn($state) => filled($state))
->required(fn(string $operation) => $operation === 'create')
->maxLength(255),
Forms\Components\ColorPicker::make('color')
->default('#0891b2'),
])->columns(2),
]);
}
public static function table(Table $table): Table
{
return static::configureTableToolbar($table
->columns([
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('email')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('role')
->badge()
->formatStateUsing(fn(string $state) => ucwords(str_replace('_', ' ', $state)))
->color(fn(string $state) => match ($state) {
'admin' => 'danger',
'project_director' => 'purple',
'project_manager' => 'warning',
default => 'gray',
})
->sortable(),
Tables\Columns\ColorColumn::make('color'),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\SelectFilter::make('role')
->options([
'employee' => 'Employee',
'project_manager' => 'Project Manager',
'project_director' => 'Project Director',
'admin' => 'Admin',
]),
])
->actions([
ResetUserPasswordAction::make(),
\Filament\Actions\EditAction::make(),
\Filament\Actions\DeleteAction::make(),
])
->bulkActions([
\Filament\Actions\BulkActionGroup::make([
\Filament\Actions\DeleteBulkAction::make(),
]),
]));
}
public static function getRelations(): array
{
return [];
}
public static function getPages(): array
{
return [
'index' => Pages\ListUsers::route('/'),
'create' => Pages\CreateUser::route('/create'),
'edit' => Pages\EditUser::route('/{record}/edit'),
];
}
public static function canViewAny(): bool
{
return auth()->user()?->isAdmin() ?? false;
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Resources\Pages\CreateRecord;
class CreateUser extends CreateRecord
{
protected static string $resource = UserResource::class;
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Actions\ResetUserPasswordAction;
use App\Filament\Resources\UserResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditUser extends EditRecord
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
ResetUserPasswordAction::make(),
Actions\DeleteAction::make(),
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListUsers extends ListRecords
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Timesheet;
use App\Support\TimesheetAccess;
use Filament\Widgets\ChartWidget;
class HoursByDayChart extends ChartWidget
{
protected static ?int $sort = 2;
protected int | string | array $columnSpan = 'full';
protected ?string $maxHeight = '240px';
protected ?string $heading = 'Hours by Day of Week';
protected ?string $description = 'Average daily hours logged across all timesheets';
protected function getData(): array
{
$user = auth()->user();
$query = Timesheet::query();
TimesheetAccess::scopeTimesheetsForUser($query, $user);
$timesheets = $query->get(['hours']);
$daily = [0, 0, 0, 0, 0, 0, 0];
foreach ($timesheets as $t) {
$hours = $t->hours ?? [0, 0, 0, 0, 0, 0, 0];
foreach ($hours as $i => $h) {
$daily[$i] += (float) $h;
}
}
return [
'datasets' => [
[
'label' => 'Hours',
'data' => $daily,
'backgroundColor' => [
'rgba(37, 99, 235, 0.7)',
'rgba(37, 99, 235, 0.7)',
'rgba(37, 99, 235, 0.7)',
'rgba(37, 99, 235, 0.7)',
'rgba(37, 99, 235, 0.7)',
'rgba(148, 163, 184, 0.5)',
'rgba(148, 163, 184, 0.5)',
],
'borderColor' => 'rgba(37, 99, 235, 1)',
'borderWidth' => 1,
'borderRadius' => 6,
],
],
'labels' => ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
];
}
protected function getType(): string
{
return 'bar';
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Timesheet;
use App\Support\TimesheetAccess;
use Filament\Widgets\ChartWidget;
class HoursByProjectChart extends ChartWidget
{
protected static ?int $sort = 3;
protected int | string | array $columnSpan = 'full';
protected ?string $maxHeight = '260px';
protected ?string $heading = 'Hours by Project';
protected ?string $description = 'Distribution of logged hours across active projects';
protected ?string $pollingInterval = null;
protected function getData(): array
{
$user = auth()->user();
$query = Timesheet::with('project')
->select('project_id', 'hours');
TimesheetAccess::scopeTimesheetsForUser($query, $user);
$timesheets = $query->get();
$projHours = [];
foreach ($timesheets as $t) {
$name = $t->project?->name ?? 'Unknown';
$projHours[$name] = ($projHours[$name] ?? 0) + $t->totalHours();
}
arsort($projHours);
$colors = [
'rgba(37, 99, 235, 0.75)',
'rgba(14, 165, 233, 0.75)',
'rgba(16, 185, 129, 0.75)',
'rgba(245, 158, 11, 0.75)',
'rgba(99, 102, 241, 0.75)',
'rgba(236, 72, 153, 0.75)',
'rgba(100, 116, 139, 0.75)',
];
return [
'datasets' => [
[
'label' => 'Hours',
'data' => array_values($projHours),
'backgroundColor' => array_slice($colors, 0, count($projHours)),
'borderWidth' => 0,
],
],
'labels' => array_keys($projHours),
];
}
protected function getType(): string
{
return 'doughnut';
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Filament\Widgets;
use App\Support\SiteTrafficRecorder;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class SiteTrafficOverview extends BaseWidget
{
protected static ?int $sort = 2;
protected int | string | array $columnSpan = 'full';
public static function canView(): bool
{
return auth()->user()?->isAdmin() ?? false;
}
/**
* @return int | array<string, ?int> | null
*/
protected function getColumns(): int | array | null
{
return 2;
}
protected function getStats(): array
{
/** @var SiteTrafficRecorder $recorder */
$recorder = app(SiteTrafficRecorder::class);
$todayViews = $recorder->todayPageViews();
$weekViews = $recorder->totalPageViewsForDays(7);
$weekSessions = $recorder->totalUniqueSessionsForDays(7);
return [
Stat::make('Site Traffic Today', number_format($todayViews))
->icon('heroicon-o-globe-alt')
->description('Page views across the admin app today')
->descriptionIcon('heroicon-o-arrow-trending-up')
->descriptionColor('info')
->color('info'),
Stat::make('Traffic (7 days)', number_format($weekViews))
->icon('heroicon-o-chart-bar')
->description(number_format($weekSessions) . ' unique sessions this week')
->descriptionIcon('heroicon-o-users')
->descriptionColor('primary')
->color('primary'),
];
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Setting;
use App\Models\Timesheet;
use App\Support\TimesheetAccess;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class TimesheetStatsOverview extends BaseWidget
{
protected static ?int $sort = 1;
protected int | string | array $columnSpan = 'full';
/**
* @return int | array<string, ?int> | null
*/
protected function getColumns(): int | array | null
{
return 2;
}
protected function getStats(): array
{
$user = auth()->user();
$query = Timesheet::query();
TimesheetAccess::scopeTimesheetsForUser($query, $user);
$all = (clone $query)->get();
$thisMonth = (clone $query)
->where('week_start', '>=', now()->startOfMonth())
->get();
$total = $all->sum(fn ($t) => $t->totalHours());
$monthHours = $thisMonth->sum(fn ($t) => $t->totalHours());
$approved = $all->where('status', 'approved')->count();
$pending = $all->whereIn('status', ['pending_pm', 'pending_pd'])->count();
$std = Setting::standardWeeklyHours();
$overtime = $all->filter(fn ($t) => $t->totalHours() > $std)->count();
return [
Stat::make('Total Hours', number_format($total, 1) . 'h')
->icon('heroicon-o-clock')
->description(number_format($monthHours, 1) . 'h this month')
->descriptionIcon('heroicon-o-clock')
->descriptionColor('primary')
->color('primary'),
Stat::make('Approved', $approved)
->icon('heroicon-o-check-circle')
->description('Fully approved')
->descriptionIcon('heroicon-o-check-circle')
->descriptionColor('success')
->color('success'),
Stat::make('Pending Review', $pending)
->icon('heroicon-o-arrow-path')
->description('Awaiting approval')
->descriptionIcon('heroicon-o-arrow-path')
->descriptionColor('warning')
->color('warning'),
Stat::make('Overtime Weeks', $overtime)
->icon('heroicon-o-exclamation-triangle')
->description('Exceeding ' . $std . 'h standard')
->descriptionIcon('heroicon-o-exclamation-triangle')
->descriptionColor('danger')
->color('danger'),
];
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Filament\Widgets;
use App\Filament\Resources\TimesheetResource;
use App\Models\Timesheet;
use Filament\Widgets\Widget;
class WelcomeBanner extends Widget
{
protected static ?int $sort = 0;
protected int | string | array $columnSpan = 'full';
protected string $view = 'filament.widgets.welcome-banner';
protected const TIMEZONE = 'Asia/Kuala_Lumpur';
public function getGreeting(): string
{
$hour = (int) now(self::TIMEZONE)->format('H');
return match (true) {
$hour < 12 => 'Good morning',
$hour < 17 => 'Good afternoon',
default => 'Good evening',
};
}
public function getTodayLabel(): string
{
return now(self::TIMEZONE)->format('l, F j, Y');
}
public function getPendingCount(): int
{
$user = auth()->user();
$query = Timesheet::query()->whereIn('status', ['pending_pm', 'pending_pd']);
if ($user->isEmployee()) {
return $query->where('user_id', $user->id)->count();
}
if ($user->canApproveAsPm()) {
return (clone $query)->where('status', 'pending_pm')->count();
}
if ($user->canApproveAsPd()) {
return (clone $query)->where('status', 'pending_pd')->count();
}
return $query->count();
}
public function getCreateUrl(): string
{
return TimesheetResource::getUrl('create');
}
public function getTimesheetsUrl(): string
{
return TimesheetResource::getUrl('index');
}
}

View File

@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@ -0,0 +1,79 @@
<?php
namespace App\Http\Controllers;
use App\Support\FaviconAssets;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class FaviconController extends Controller
{
public function branding(string $file): BinaryFileResponse
{
$path = FaviconAssets::assertPresent($file);
return $this->fileResponse($path, FaviconAssets::mimeType($file));
}
public function legacyIco(): BinaryFileResponse
{
$rootPath = public_path('favicon.ico');
if (is_file($rootPath) && filesize($rootPath) > 0) {
return $this->fileResponse($rootPath, 'image/x-icon');
}
$path = FaviconAssets::assertPresent('favicon.ico');
return $this->fileResponse($path, 'image/x-icon');
}
public function legacyAppleTouch(): BinaryFileResponse
{
$path = FaviconAssets::assertPresent('apple-touch-icon.png');
return $this->fileResponse($path, 'image/png');
}
private function fileResponse(string $path, string $contentType): BinaryFileResponse
{
$response = response()->file($path, [
'Content-Type' => $contentType,
'Cache-Control' => 'public, max-age=604800, stale-while-revalidate=86400',
]);
$response->setLastModified(new \DateTimeImmutable('@' . filemtime($path)));
$response->setEtag(sha1_file($path) ?: (string) filemtime($path));
return $response;
}
public function manifest(): Response
{
$icons = [
[
'src' => FaviconAssets::url('favicon-32x32.png'),
'sizes' => '32x32',
'type' => 'image/png',
],
[
'src' => FaviconAssets::url('apple-touch-icon.png'),
'sizes' => '180x180',
'type' => 'image/png',
],
];
$body = json_encode([
'name' => config('app.name', 'Quatriz TimeSheet'),
'short_name' => 'TimeSheet',
'icons' => $icons,
'display' => 'standalone',
'start_url' => '/admin',
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
return response($body, 200, [
'Content-Type' => 'application/manifest+json',
'Cache-Control' => 'public, max-age=86400',
]);
}
}

View File

@ -0,0 +1,111 @@
<?php
namespace App\Http\Controllers;
use App\Models\Project;
use App\Models\Timesheet;
use App\Models\User;
use App\Support\AuditLogger;
use App\Support\TimesheetAccess;
use App\Support\TimesheetSummaryBuilder;
use App\Support\WeeklyHoursExport;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class PdfController extends Controller
{
public function weekly(Timesheet $timesheet)
{
$user = auth()->user();
if (! TimesheetAccess::userCanViewTimesheet($user, $timesheet)) {
abort(403);
}
$timesheet->loadMissing(['user', 'project', 'approvalLogs.user']);
$pdf = Pdf::loadView('pdf.weekly', compact('timesheet'));
$filename = sprintf(
'timesheet_%s_%s.pdf',
$timesheet->user->name,
$timesheet->week_start->format('Y-m-d')
);
AuditLogger::log('Weekly timesheet PDF exported', $timesheet, [
'export' => 'weekly_pdf',
'filename' => $filename,
]);
return $pdf->download($filename);
}
public function weeklyHours(User $user, string $weekStart)
{
$export = WeeklyHoursExport::for($user, $weekStart, auth()->user());
$pdf = Pdf::loadView('pdf.weekly-hours', [
'export' => $export,
]);
AuditLogger::log('Weekly hours PDF exported', null, [
'export' => 'weekly_hours_pdf',
'user_id' => $export->user->id,
'week_start' => $export->weekStart->toDateString(),
'filename' => $export->filename(),
]);
return $pdf->download($export->filename());
}
public function weeklyHoursPrint(User $user, string $weekStart)
{
$export = WeeklyHoursExport::for($user, $weekStart, auth()->user());
return view('print.weekly-hours', [
'export' => $export,
]);
}
public function summary(Request $request)
{
$validated = $request->validate([
'groupBy' => ['nullable', 'string', Rule::in(TimesheetAccess::SUMMARY_GROUP_BY)],
'status' => ['nullable', 'string', Rule::in(TimesheetAccess::TIMESHEET_STATUSES)],
'dateFrom' => ['nullable', 'date'],
'dateTo' => ['nullable', 'date', 'after_or_equal:dateFrom'],
'projectId' => ['nullable', 'integer', 'exists:projects,id'],
'userId' => ['nullable', 'integer', 'exists:users,id'],
]);
if (filled($validated['projectId'] ?? null)) {
$project = Project::query()->find($validated['projectId']);
if (! TimesheetAccess::userCanAccessProject(auth()->user(), $project)) {
abort(403);
}
}
$builder = TimesheetSummaryBuilder::fromValidated($validated);
$data = $builder->groupedData();
$totalHours = $builder->totalHours();
$project = $builder->resolvedProject();
$pdf = Pdf::loadView('pdf.summary', [
'data' => $data,
'totalHours' => $totalHours,
'builder' => $builder,
'project' => $project,
'userName' => auth()->user()->name,
]);
$filename = 'timesheet_summary_' . now()->format('Y-m-d') . '.pdf';
AuditLogger::log('Timesheet summary PDF exported', null, [
'export' => 'summary_pdf',
'filters' => AuditLogger::redactProperties($validated),
]);
return $pdf->download($filename);
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
class UptimeHeartbeatController extends Controller
{
public function scheduler(Request $request): Response
{
if (! $this->tokenIsValid($request)) {
return response('Forbidden', 403);
}
if (! config('observability.uptime.enabled')) {
return response('Uptime monitoring disabled', 503);
}
if ($this->cacheIsFresh(
config('observability.uptime.cache_key_scheduler'),
config('observability.uptime.scheduler_stale_minutes'),
)) {
return response('OK', 200);
}
return response('Scheduler heartbeat stale', 503);
}
public function queue(Request $request): Response
{
if (! $this->tokenIsValid($request)) {
return response('Forbidden', 403);
}
if (! config('observability.uptime.enabled')) {
return response('Uptime monitoring disabled', 503);
}
if ($this->cacheIsFresh(
config('observability.uptime.cache_key_queue'),
config('observability.uptime.queue_stale_minutes'),
)) {
return response('OK', 200);
}
return response('Queue heartbeat stale', 503);
}
protected function tokenIsValid(Request $request): bool
{
$configured = (string) config('observability.uptime.heartbeat_token');
if ($configured === '') {
return false;
}
return hash_equals($configured, (string) $request->query('token', ''));
}
protected function cacheIsFresh(string $key, int $staleMinutes): bool
{
$lastSuccess = Cache::get($key);
if (! is_int($lastSuccess)) {
return false;
}
return $lastSuccess >= now()->subMinutes($staleMinutes)->timestamp;
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Spatie\LaravelFlare\Facades\Flare;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
class AttachFlareContext
{
public function handle(Request $request, Closure $next): Response
{
if (filled(config('flare.key')) && config('flare.report')) {
try {
Flare::context([
'tenant' => config('observability.flare.tenant'),
'application' => config('observability.flare.application'),
'environment' => config('app.env'),
'request_id' => $request->header('X-Request-Id') ?? $request->fingerprint(),
]);
$user = $request->user();
if ($user !== null) {
Flare::context([
'user_id' => $user->id,
'user_role' => $user->role,
]);
}
} catch (Throwable) {
// Flare may not be ready during early requests.
}
}
return $next($request);
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Http\Middleware;
use App\Support\SiteTrafficRecorder;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RecordSiteTraffic
{
public function __construct(
protected SiteTrafficRecorder $recorder,
) {}
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if ($response->getStatusCode() < 400) {
$this->recorder->record($request);
}
return $response;
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RestrictHealthCheck
{
public function handle(Request $request, Closure $next): Response
{
if ($this->isAllowed($request)) {
return $next($request);
}
abort(404);
}
private function isAllowed(Request $request): bool
{
if (app()->environment('local', 'testing')) {
return true;
}
$ip = $request->ip();
if (in_array($ip, ['127.0.0.1', '::1'], true)) {
return true;
}
$allowed = array_filter(array_map(
trim(...),
explode(',', (string) config('security.health_check_allowed_ips', '')),
));
return in_array($ip, $allowed, true);
}
}

View File

@ -0,0 +1,87 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class SecurityHeaders
{
public function handle(Request $request, Closure $next): Response
{
/** @var Response $response */
$response = $next($request);
if ($request->is('admin/*') || $request->is('admin')) {
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
if ($this->shouldPreventAdminPageCache($request, $response)) {
$response->headers->set('Cache-Control', 'no-store, no-cache, must-revalidate, private');
$response->headers->set('Pragma', 'no-cache');
}
} else {
$response->headers->set('X-Frame-Options', 'DENY');
}
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
$response->headers->set('X-Permitted-Cross-Domain-Policies', 'none');
if ($request->secure()) {
$response->headers->set(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains',
);
}
$csp = $this->contentSecurityPolicy();
if (config('security.csp_report_only')) {
$response->headers->set('Content-Security-Policy-Report-Only', $csp);
}
if (config('security.csp_enforce')) {
$response->headers->set('Content-Security-Policy', $csp);
}
if ($coop = config('security.coop')) {
$response->headers->set('Cross-Origin-Opener-Policy', $coop);
}
if ($corp = config('security.corp')) {
$response->headers->set('Cross-Origin-Resource-Policy', $corp);
}
return $response;
}
private function shouldPreventAdminPageCache(Request $request, Response $response): bool
{
if (! $request->isMethod('GET')) {
return false;
}
$contentType = (string) $response->headers->get('Content-Type', '');
return str_contains($contentType, 'text/html');
}
private function contentSecurityPolicy(): string
{
$directives = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net",
"style-src 'self' 'unsafe-inline' https://fonts.bunny.net",
"img-src 'self' data: blob:",
"font-src 'self' data: https://fonts.bunny.net",
"connect-src 'self'",
"frame-ancestors 'self'",
"base-uri 'self'",
"form-action 'self'",
];
return implode('; ', $directives);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Cache;
class RecordQueueHeartbeat implements ShouldQueue
{
use Queueable;
public function handle(): void
{
if (! config('observability.uptime.enabled')) {
return;
}
Cache::put(
config('observability.uptime.cache_key_queue'),
now()->timestamp,
now()->addMinutes(config('observability.uptime.queue_stale_minutes') * 2),
);
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ApprovalLog extends Model
{
protected $fillable = [
'timesheet_id', 'user_id', 'action', 'comment', 'created_at', 'updated_at',
];
public function timesheet()
{
return $this->belongsTo(Timesheet::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Models\Concerns;
use Spatie\Activitylog\Models\Concerns\LogsActivity;
use Spatie\Activitylog\Support\LogOptions;
trait LogsAuditableChanges
{
use LogsActivity;
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly($this->auditableAttributes())
->logOnlyDirty()
->dontLogEmptyChanges()
->useLogName('admin');
}
/**
* @return list<string>
*/
abstract protected function auditableAttributes(): array;
}

144
app/Models/Project.php Normal file
View File

@ -0,0 +1,144 @@
<?php
namespace App\Models;
use App\Models\Concerns\LogsAuditableChanges;
use App\Support\ProjectScheduleHealth;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Project extends Model
{
use LogsAuditableChanges;
protected $fillable = [
'code',
'name',
'description',
'status',
'start_date',
'end_date',
'project_manager_id',
'project_director_id',
'created_by',
];
protected function casts(): array
{
return [
'start_date' => 'date',
'end_date' => 'date',
];
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function timesheets()
{
return $this->hasMany(Timesheet::class);
}
public function projectManager(): BelongsTo
{
return $this->belongsTo(User::class, 'project_manager_id');
}
public function projectDirector(): BelongsTo
{
return $this->belongsTo(User::class, 'project_director_id');
}
public function members(): BelongsToMany
{
return $this->belongsToMany(User::class)
->withPivot('assigned_role')
->withTimestamps();
}
public function hasMember(User $user): bool
{
return $this->members()->whereKey($user->id)->exists();
}
public function isManagedBy(User $user): bool
{
if ($user->isAdmin()) {
return true;
}
return $user->canApproveAsPm() && $this->project_manager_id === $user->id;
}
public function isDirectedBy(User $user): bool
{
if ($user->isAdmin()) {
return true;
}
return $user->canApproveAsPd() && $this->project_director_id === $user->id;
}
public function totalHours(): float
{
return (float) $this->timesheets()
->get()
->sum(fn ($timesheet): float => $timesheet->totalHours());
}
public function scheduleHealth(): ProjectScheduleHealth
{
return new ProjectScheduleHealth($this);
}
/**
* @return list<array{name: string, role: string, hours: float}>
*/
public function memberContributions(): array
{
$this->loadMissing('members');
return $this->timesheets()
->with('user')
->get()
->groupBy('user_id')
->map(function ($timesheets, int $userId): array {
$member = $this->members->firstWhere('id', $userId);
return [
'name' => $timesheets->first()?->user?->name ?? 'Unknown',
'role' => $member?->pivot?->assigned_role ?? '—',
'hours' => round($timesheets->sum(fn ($timesheet): float => $timesheet->totalHours()), 1),
];
})
->sortByDesc('hours')
->values()
->all();
}
public function employeeCount(): int
{
return $this->timesheets()->distinct('user_id')->count('user_id');
}
/**
* @return list<string>
*/
protected function auditableAttributes(): array
{
return [
'code',
'name',
'description',
'status',
'start_date',
'end_date',
'project_manager_id',
'project_director_id',
'created_by',
];
}
}

43
app/Models/Setting.php Normal file
View File

@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Setting extends Model
{
public $timestamps = false;
protected $fillable = ['key', 'value'];
protected function casts(): array
{
return [
'value' => 'json',
];
}
public static function getValue(string $key, mixed $default = null): mixed
{
$setting = static::where('key', $key)->first();
return $setting ? $setting->value : $default;
}
public static function setValue(string $key, mixed $value): void
{
static::updateOrCreate(
['key' => $key],
['value' => $value]
);
}
public static function standardWeeklyHours(): float
{
return (float) static::getValue('standardWeeklyHours', 40);
}
public static function emailNotificationsEnabled(): bool
{
return (bool) static::getValue('emailNotifications', true);
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SiteTrafficDaily extends Model
{
protected $table = 'site_traffic_daily';
protected $fillable = [
'date',
'page_views',
'unique_sessions',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'date' => 'date',
'page_views' => 'integer',
'unique_sessions' => 'integer',
];
}
}

184
app/Models/Timesheet.php Normal file
View File

@ -0,0 +1,184 @@
<?php
namespace App\Models;
use App\Models\Concerns\LogsAuditableChanges;
use Illuminate\Database\Eloquent\Model;
class Timesheet extends Model
{
use LogsAuditableChanges;
protected $fillable = [
'user_id',
'project_id',
'project_role',
'week_start',
'hours',
'tasks',
'status',
'notes',
];
protected function casts(): array
{
return [
'week_start' => 'date',
'hours' => 'array',
'tasks' => 'array',
];
}
public function user()
{
return $this->belongsTo(User::class);
}
public function project()
{
return $this->belongsTo(Project::class);
}
public function approvalLogs()
{
return $this->hasMany(ApprovalLog::class);
}
public function latestApprovalLog(string $action): ?ApprovalLog
{
if ($this->relationLoaded('approvalLogs')) {
return $this->approvalLogs
->where('action', $action)
->sortByDesc('created_at')
->first();
}
return $this->approvalLogs()
->where('action', $action)
->with('user')
->latest()
->first();
}
public function preparedByName(): string
{
return $this->latestApprovalLog('submitted')?->user?->name
?? $this->user?->name
?? '';
}
public function preparedByDate(): string
{
$date = $this->latestApprovalLog('submitted')?->created_at
?? $this->week_start;
return $date?->format('d/m/Y') ?? '';
}
public function pmApproverName(): string
{
return $this->latestApprovalLog('approved_pm')?->user?->name ?? '';
}
public function pmApproverDate(): string
{
return $this->latestApprovalLog('approved_pm')?->created_at?->format('d/m/Y') ?? '';
}
public function pdApproverName(): string
{
return $this->latestApprovalLog('approved_pd')?->user?->name ?? '';
}
public function pdApproverDate(): string
{
return $this->latestApprovalLog('approved_pd')?->created_at?->format('d/m/Y') ?? '';
}
public function totalHours(): float
{
return array_sum($this->hours ?? [0, 0, 0, 0, 0, 0, 0]);
}
public function taskForDay(int $index): string
{
$tasks = $this->tasks ?? [];
$task = trim((string) ($tasks[$index] ?? ''));
if ($task !== '') {
return $task;
}
$hours = (float) ($this->hours[$index] ?? 0);
if ($hours > 0 && filled($this->notes)) {
return (string) $this->notes;
}
return '';
}
public function isDraft(): bool
{
return $this->status === 'draft';
}
public function isPendingPm(): bool
{
return $this->status === 'pending_pm';
}
public function isPendingPd(): bool
{
return $this->status === 'pending_pd';
}
public function isApproved(): bool
{
return $this->status === 'approved';
}
public function isRejected(): bool
{
return $this->status === 'rejected';
}
public function isEditable(): bool
{
return in_array($this->status, ['draft', 'rejected']);
}
public function isSubmittable(): bool
{
return in_array($this->status, ['draft', 'rejected']);
}
public function canBeApprovedBy(User $user): bool
{
$this->loadMissing('project');
$project = $this->project;
if ($this->isPendingPm()) {
return $project?->isManagedBy($user) ?? false;
}
if ($this->isPendingPd()) {
return $project?->isDirectedBy($user) ?? false;
}
return false;
}
public function canBeRejectedBy(User $user): bool
{
return $this->canBeApprovedBy($user);
}
/**
* @return list<string>
*/
protected function auditableAttributes(): array
{
return ['status', 'project_id', 'week_start', 'project_role'];
}
}

110
app/Models/User.php Normal file
View File

@ -0,0 +1,110 @@
<?php
namespace App\Models;
use App\Models\Concerns\LogsAuditableChanges;
use Database\Factories\UserFactory;
use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthentication;
use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthenticationRecovery;
use Filament\Auth\MultiFactor\App\Concerns\InteractsWithAppAuthentication;
use Filament\Auth\MultiFactor\App\Concerns\InteractsWithAppAuthenticationRecovery;
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable implements FilamentUser, HasAppAuthentication, HasAppAuthenticationRecovery
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
use InteractsWithAppAuthentication;
use InteractsWithAppAuthenticationRecovery;
use LogsAuditableChanges;
protected $fillable = [
'name', 'email', 'password', 'role', 'color',
];
protected $hidden = [
'password', 'remember_token',
];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
public function timesheets()
{
return $this->hasMany(Timesheet::class);
}
public function projects()
{
return $this->belongsToMany(Project::class)
->withPivot('assigned_role')
->withTimestamps();
}
public function approvalLogs()
{
return $this->hasMany(ApprovalLog::class);
}
public function isAdmin(): bool
{
return $this->role === 'admin';
}
public function isProjectManager(): bool
{
return $this->role === 'project_manager';
}
public function isProjectDirector(): bool
{
return $this->role === 'project_director';
}
public function isEmployee(): bool
{
return $this->role === 'employee';
}
public function isApprover(): bool
{
return in_array($this->role, ['admin', 'project_manager', 'project_director']);
}
public function canApproveAsPm(): bool
{
return in_array($this->role, ['admin', 'project_manager']);
}
public function canApproveAsPd(): bool
{
return in_array($this->role, ['admin', 'project_director']);
}
public function canAccessPanel(Panel $panel): bool
{
return in_array($this->role, [
'employee',
'project_manager',
'project_director',
'admin',
], true);
}
/**
* @return list<string>
*/
protected function auditableAttributes(): array
{
return ['name', 'email', 'role', 'color'];
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Notifications;
use App\Models\Timesheet;
use App\Models\User;
use App\Support\Concerns\BuildsTimesheetMail;
use App\Support\Concerns\QueuesTimesheetNotification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class TimesheetApprovedNotification extends Notification implements ShouldQueue, ShouldQueueAfterCommit
{
use BuildsTimesheetMail;
use QueuesTimesheetNotification;
public function __construct(
public Timesheet $timesheet,
public User $approver,
public ?string $comment = null,
) {}
/**
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$message = (new MailMessage)
->subject('Your timesheet has been approved')
->greeting('Hello '.$notifiable->name.',')
->line('Your timesheet has been fully approved by '.$this->approver->name.'.');
foreach ($this->timesheetSummary($this->timesheet) as $label => $value) {
$message->line("**{$label}:** {$value}");
}
if (filled($this->comment)) {
$message->line('**Approver comment:** '.$this->comment);
}
return $message
->action('View timesheet', $this->timesheetViewUrl($this->timesheet))
->line('No further action is required.');
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Notifications;
use App\Models\Timesheet;
use App\Support\Concerns\BuildsTimesheetMail;
use App\Support\Concerns\QueuesTimesheetNotification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class TimesheetPendingDirectorNotification extends Notification implements ShouldQueue, ShouldQueueAfterCommit
{
use BuildsTimesheetMail;
use QueuesTimesheetNotification;
public function __construct(
public Timesheet $timesheet,
public ?string $pmComment = null,
) {}
/**
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$this->timesheet->loadMissing(['user', 'project']);
$message = (new MailMessage)
->subject('Timesheet awaiting Project Director approval')
->greeting('Hello '.$notifiable->name.',')
->line('A timesheet has been approved by the Project Manager and now requires your sign-off.');
foreach ($this->timesheetSummary($this->timesheet) as $label => $value) {
$message->line("**{$label}:** {$value}");
}
if (filled($this->pmComment)) {
$message->line('**PM comment:** '.$this->pmComment);
}
return $message
->action('Review timesheet', $this->timesheetViewUrl($this->timesheet))
->line('Please review and approve or reject this submission in Quatriz TimeSheet.');
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Notifications;
use App\Models\Timesheet;
use App\Models\User;
use App\Support\Concerns\BuildsTimesheetMail;
use App\Support\Concerns\QueuesTimesheetNotification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class TimesheetRejectedNotification extends Notification implements ShouldQueue, ShouldQueueAfterCommit
{
use BuildsTimesheetMail;
use QueuesTimesheetNotification;
public function __construct(
public Timesheet $timesheet,
public User $rejector,
public string $comment,
) {}
/**
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$message = (new MailMessage)
->subject('Your timesheet was rejected')
->greeting('Hello '.$notifiable->name.',')
->line('Your timesheet was rejected by '.$this->rejector->name.'. Please update and resubmit.');
foreach ($this->timesheetSummary($this->timesheet) as $label => $value) {
$message->line("**{$label}:** {$value}");
}
return $message
->line('**Reason:** '.$this->comment)
->action('Edit timesheet', route('filament.admin.resources.timesheets.edit', ['record' => $this->timesheet]))
->line('Make the requested changes and submit again when ready.');
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Notifications;
use App\Models\Timesheet;
use App\Support\Concerns\BuildsTimesheetMail;
use App\Support\Concerns\QueuesTimesheetNotification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class TimesheetSubmittedNotification extends Notification implements ShouldQueue, ShouldQueueAfterCommit
{
use BuildsTimesheetMail;
use QueuesTimesheetNotification;
public function __construct(public Timesheet $timesheet) {}
/**
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$this->timesheet->loadMissing(['user', 'project']);
$message = (new MailMessage)
->subject('Timesheet submitted for your review')
->greeting('Hello '.$notifiable->name.',')
->line('A timesheet has been submitted and is waiting for Project Manager approval.');
if ($this->timesheet->user) {
$message->line('**Submitted by:** '.$this->timesheet->user->name);
}
foreach ($this->timesheetSummary($this->timesheet) as $label => $value) {
$message->line("**{$label}:** {$value}");
}
return $message
->action('Review timesheet', $this->timesheetViewUrl($this->timesheet))
->line('Please review and approve or reject this submission in Quatriz TimeSheet.');
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Providers;
use App\Auth\Http\Responses\LoginResponse as AppLoginResponse;
use Filament\Auth\Http\Responses\Contracts\LoginResponse;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
$this->app->bind(LoginResponse::class, AppLoginResponse::class);
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Queue::failing(function (JobFailed $event): void {
if (! str_contains($event->job->getName(), 'SendQueuedNotifications')) {
return;
}
Log::error('Queued timesheet notification job failed', [
'job' => $event->job->getName(),
'connection' => $event->connectionName,
'queue' => $event->job->getQueue(),
'exception' => $event->exception->getMessage(),
]);
});
}
}

View File

@ -0,0 +1,104 @@
<?php
namespace App\Providers\Filament;
use App\Filament\Pages\Auth\Login;
use App\Http\Middleware\RecordSiteTraffic;
use App\Support\LocalAvatarProvider;
use Filament\Auth\MultiFactor\App\AppAuthentication;
use Filament\Enums\ThemeMode;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Navigation\NavigationGroup;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Filament\View\PanelsRenderHook;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
class AdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->default()
->id('admin')
->path('admin')
->login(Login::class)
->profile()
->defaultAvatarProvider(LocalAvatarProvider::class)
->multiFactorAuthentication(
providers: [
AppAuthentication::make()
->recoverable(),
],
isRequired: fn (): bool => config('security.mfa_required_for_admin')
&& ! app()->environment('testing')
&& auth()->check()
&& auth()->user()->isAdmin(),
)
->brandName('')
->brandLogo(asset('logo.webp'))
->brandLogoHeight('2rem')
->favicon(null)
->font('Inter')
->viteTheme('resources/css/filament/admin/theme.css')
->defaultThemeMode(ThemeMode::Light)
->sidebarCollapsibleOnDesktop()
->spa()
->spaUrlExceptions([
'/pdf/*',
'/weekly-hours/*',
'/admin/projects*',
])
->colors([
'primary' => Color::hex('#1B3860'),
'gray' => Color::Slate,
'danger' => Color::hex('#BE123C'),
'warning' => Color::hex('#B45309'),
'success' => Color::hex('#047857'),
'info' => Color::hex('#1D4ED8'),
])
->navigationGroups([
NavigationGroup::make('Overview'),
NavigationGroup::make('Time Tracking'),
NavigationGroup::make('Reports'),
NavigationGroup::make('Management'),
NavigationGroup::make('Administration'),
])
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
->widgets([])
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
PreventRequestForgery::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
RecordSiteTraffic::class,
])
->authMiddleware([
Authenticate::class,
])
->renderHook(
PanelsRenderHook::HEAD_START,
fn () => view('filament.hooks.favicons'),
)
->renderHook(
PanelsRenderHook::SCRIPTS_AFTER,
fn () => view('filament.hooks.ui-interactivity'),
);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class ValidDailyHours implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_array($value)) {
return;
}
foreach ($value as $hours) {
if (! is_numeric($hours)) {
$fail('Each day must be a valid number of hours.');
return;
}
if ((float) $hours < 0 || (float) $hours > 24) {
$fail('Each day must be between 0 and 24 hours.');
return;
}
}
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Rules;
use Carbon\Carbon;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class WeekStartsOnMonday implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (blank($value)) {
return;
}
try {
$date = Carbon::parse($value);
} catch (\Throwable) {
$fail('Please enter a valid date.');
return;
}
if ($date->dayOfWeek !== Carbon::MONDAY) {
$fail('Week start must be a Monday.');
}
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace App\Support;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
class AuditLogger
{
/**
* @param array<string, mixed> $properties
*/
public static function log(
string $description,
?Model $subject = null,
array $properties = [],
string $logName = 'admin',
): void {
if (! config('activitylog.enabled')) {
return;
}
$activity = activity($logName)
->causedBy(Auth::user())
->withProperties(static::redactProperties($properties));
if ($subject !== null) {
$activity->performedOn($subject);
}
$activity->log($description);
}
/**
* @param array<string, mixed> $properties
* @return array<string, mixed>
*/
public static function redactProperties(array $properties): array
{
$redactKeys = [
'password',
'password_confirmation',
'remember_token',
'hours',
'tasks',
'notes',
'app_authentication_secret',
'app_authentication_recovery_codes',
];
foreach ($redactKeys as $key) {
if (array_key_exists($key, $properties)) {
$properties[$key] = '[redacted]';
}
}
return $properties;
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Support\Concerns;
use App\Models\Timesheet;
trait BuildsTimesheetMail
{
protected function timesheetSummary(Timesheet $timesheet): array
{
$timesheet->loadMissing(['user', 'project']);
return [
'Employee' => $timesheet->user?->name ?? '—',
'Project' => $timesheet->project?->name ?? '—',
'Week starting' => $timesheet->week_start?->format('d M Y') ?? '—',
'Total hours' => number_format($timesheet->totalHours(), 1).'h',
];
}
protected function timesheetViewUrl(Timesheet $timesheet): string
{
return route('filament.admin.resources.timesheets.view', ['record' => $timesheet]);
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Support\Concerns;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Support\Facades\Log;
/**
* Queued timesheet mail with retries and structured failure logging.
*/
trait QueuesTimesheetNotification
{
use Queueable;
public int $tries = 3;
/** @var list<int> */
public array $backoff = [30, 120, 300];
public function failed(\Throwable $exception): void
{
$timesheetId = property_exists($this, 'timesheet') ? ($this->timesheet->id ?? null) : null;
Log::error('Timesheet notification delivery failed after retries', [
'notification' => static::class,
'timesheet_id' => $timesheetId,
'error' => $exception->getMessage(),
]);
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Support;
class FaviconAssets
{
public const VERSION = '8';
/** @var list<string> */
public const FILES = [
'favicon.ico',
'favicon-16x16.png',
'favicon-32x32.png',
'apple-touch-icon.png',
];
public static function directory(): string
{
return public_path('branding');
}
public static function path(string $filename): string
{
return self::directory() . '/' . $filename;
}
public static function url(string $filename): string
{
return asset('branding/' . $filename . '?v=' . self::VERSION);
}
public static function assertPresent(string $filename): string
{
abort_unless(in_array($filename, self::FILES, true), 404);
$path = self::path($filename);
abort_unless(is_file($path) && filesize($path) > 0, 404);
return $path;
}
public static function mimeType(string $filename): string
{
return match ($filename) {
'favicon.ico' => 'image/x-icon',
'apple-touch-icon.png' => 'image/png',
default => 'image/png',
};
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Support;
use App\Models\User;
use Filament\AvatarProviders\Contracts\AvatarProvider;
use Filament\Facades\Filament;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class LocalAvatarProvider implements AvatarProvider
{
public function get(Model | Authenticatable $record): string
{
$name = Filament::getNameForDefaultAvatar($record);
$initials = Str::of($name)
->trim()
->explode(' ')
->filter()
->map(fn (string $segment): string => mb_strtoupper(mb_substr($segment, 0, 1)))
->take(2)
->join('');
if ($initials === '') {
$initials = '?';
}
$background = $record instanceof User && filled($record->color)
? ltrim((string) $record->color, '#')
: '1B3860';
if (! preg_match('/^[0-9A-Fa-f]{6}$/', $background)) {
$background = '1B3860';
}
$initials = htmlspecialchars($initials, ENT_XML1 | ENT_QUOTES, 'UTF-8');
$svg = <<<SVG
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<rect width="128" height="128" rx="64" fill="#{$background}"/>
<text x="50%" y="50%" dy="0.35em" text-anchor="middle" fill="#FFFFFF" font-family="Inter, ui-sans-serif, system-ui, sans-serif" font-size="48" font-weight="600">{$initials}</text>
</svg>
SVG;
return 'data:image/svg+xml;base64,' . base64_encode($svg);
}
}

View File

@ -0,0 +1,168 @@
<?php
namespace App\Support;
use App\Models\Project;
use App\Models\Setting;
use Carbon\Carbon;
class ProjectScheduleHealth
{
public const STATUS_NOT_STARTED = 'not_started';
public const STATUS_ON_TRACK = 'on_track';
public const STATUS_AT_RISK = 'at_risk';
public const STATUS_DELAYED = 'delayed';
public const STATUS_COMPLETED = 'completed';
public function __construct(public Project $project) {}
public function durationDays(): ?int
{
if (! $this->project->start_date || ! $this->project->end_date) {
return null;
}
return $this->project->start_date->diffInDays($this->project->end_date) + 1;
}
public function daysElapsed(): ?int
{
if (! $this->project->start_date) {
return null;
}
$today = now()->startOfDay();
if ($today->lt($this->project->start_date)) {
return 0;
}
if ($this->project->end_date && $today->gt($this->project->end_date)) {
return $this->durationDays();
}
return $this->project->start_date->diffInDays($today) + 1;
}
public function daysRemaining(): ?int
{
if (! $this->project->end_date) {
return null;
}
$today = now()->startOfDay();
if ($today->gt($this->project->end_date)) {
return 0;
}
return $today->diffInDays($this->project->end_date);
}
public function hoursLogged(): float
{
return (float) $this->project->timesheets()
->get()
->sum(fn ($timesheet): float => $timesheet->totalHours());
}
public function expectedHoursToDate(): float
{
$daysElapsed = $this->daysElapsed();
if ($daysElapsed === null || $daysElapsed <= 0) {
return 0.0;
}
$memberCount = max(1, $this->project->members()->count());
$weeklyHours = Setting::standardWeeklyHours();
$weeksElapsed = $daysElapsed / 7;
return round($memberCount * $weeklyHours * $weeksElapsed, 1);
}
public function timeProgressPercent(): ?float
{
$duration = $this->durationDays();
$elapsed = $this->daysElapsed();
if ($duration === null || $elapsed === null || $duration <= 0) {
return null;
}
return min(100, round(($elapsed / $duration) * 100, 1));
}
public function hoursProgressPercent(): ?float
{
$expected = $this->expectedHoursToDate();
if ($expected <= 0) {
return null;
}
return min(100, round(($this->hoursLogged() / $expected) * 100, 1));
}
public function status(): string
{
if (! $this->project->start_date || ! $this->project->end_date) {
return self::STATUS_NOT_STARTED;
}
$today = now()->startOfDay();
if ($today->lt($this->project->start_date)) {
return self::STATUS_NOT_STARTED;
}
if ($this->project->status === 'archived') {
return self::STATUS_COMPLETED;
}
if ($today->gt($this->project->end_date)) {
return self::STATUS_DELAYED;
}
$timeProgress = ($this->timeProgressPercent() ?? 0) / 100;
$hoursProgress = ($this->hoursProgressPercent() ?? 0) / 100;
$daysRemaining = $this->daysRemaining() ?? 0;
if ($timeProgress >= 0.5 && $hoursProgress < 0.5) {
return self::STATUS_AT_RISK;
}
if ($daysRemaining <= 7 && $hoursProgress < 0.85) {
return self::STATUS_AT_RISK;
}
return self::STATUS_ON_TRACK;
}
public function label(): string
{
return match ($this->status()) {
self::STATUS_NOT_STARTED => 'Not started',
self::STATUS_ON_TRACK => 'On track',
self::STATUS_AT_RISK => 'At risk',
self::STATUS_DELAYED => 'Delayed',
self::STATUS_COMPLETED => 'Completed',
default => 'Unknown',
};
}
public function color(): string
{
return match ($this->status()) {
self::STATUS_ON_TRACK => 'success',
self::STATUS_AT_RISK => 'warning',
self::STATUS_DELAYED => 'danger',
self::STATUS_COMPLETED => 'gray',
default => 'info',
};
}
}

View File

@ -0,0 +1,96 @@
<?php
namespace App\Support;
use App\Models\SiteTrafficDaily;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class SiteTrafficRecorder
{
public function record(Request $request): void
{
if (! config('observability.traffic.enabled')) {
return;
}
if (! $this->shouldRecord($request)) {
return;
}
$date = now()->toDateString();
$sessionId = $request->hasSession() ? $request->session()->getId() : null;
$sessionKey = $sessionId
? 'site_traffic:session:' . $date . ':' . $sessionId
: null;
$isNewSession = $sessionKey === null
|| Cache::add($sessionKey, true, now()->endOfDay());
$row = SiteTrafficDaily::query()->firstOrCreate(
['date' => $date],
['page_views' => 0, 'unique_sessions' => 0],
);
$row->increment('page_views');
if ($isNewSession) {
$row->increment('unique_sessions');
}
}
public function totalPageViewsForDays(int $days): int
{
$from = now()->subDays(max($days - 1, 0))->toDateString();
return (int) SiteTrafficDaily::query()
->whereDate('date', '>=', $from)
->sum('page_views');
}
public function totalUniqueSessionsForDays(int $days): int
{
$from = now()->subDays(max($days - 1, 0))->toDateString();
return (int) SiteTrafficDaily::query()
->whereDate('date', '>=', $from)
->sum('unique_sessions');
}
public function todayPageViews(): int
{
return (int) SiteTrafficDaily::query()
->whereDate('date', now()->toDateString())
->value('page_views');
}
protected function shouldRecord(Request $request): bool
{
if (! $request->isMethod('GET')) {
return false;
}
if ($request->ajax() || $request->is('livewire/*')) {
return false;
}
if ($request->is(
'up',
'uptime/*',
'.well-known/*',
'build/*',
'css/*',
'js/*',
'fonts/*',
'storage/*',
'favicon.ico',
'branding/*',
'site.webmanifest',
'logo.webp',
)) {
return false;
}
return $request->is('admin', 'admin/*');
}
}

View File

@ -0,0 +1,217 @@
<?php
namespace App\Support;
use App\Models\Project;
use App\Models\Timesheet;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
class TimesheetAccess
{
public const SUMMARY_GROUP_BY = ['project', 'week', 'month', 'member'];
public const TIMESHEET_STATUSES = [
'draft',
'pending_pm',
'pending_pd',
'approved',
'rejected',
];
public static function userCanEditTimesheet(User $user, Timesheet $timesheet): bool
{
if (! $timesheet->isEditable()) {
return false;
}
if ($user->isAdmin()) {
return true;
}
if ($user->isEmployee()) {
return $timesheet->user_id === $user->id;
}
return false;
}
public static function userCanRevertToDraft(User $user, Timesheet $timesheet): bool
{
return $user->isAdmin() && $timesheet->isApproved();
}
public static function userCanViewTimesheet(User $user, Timesheet $timesheet): bool
{
if ($user->isAdmin()) {
return true;
}
if ($user->isEmployee()) {
return $timesheet->user_id === $user->id;
}
if (self::userHasApprovalHistoryOnTimesheet($user, $timesheet)) {
return true;
}
$timesheet->loadMissing('project');
$project = $timesheet->project;
if (! $project) {
return false;
}
if ($user->isProjectManager()) {
return $project->project_manager_id === $user->id;
}
if ($user->isProjectDirector()) {
return $project->project_director_id === $user->id;
}
return false;
}
public static function userHasApprovalHistoryOnTimesheet(User $user, Timesheet $timesheet): bool
{
if ($timesheet->relationLoaded('approvalLogs')) {
return $timesheet->approvalLogs->contains(
fn ($log) => $log->user_id === $user->id,
);
}
return $timesheet->approvalLogs()
->where('user_id', $user->id)
->exists();
}
public static function userCanAccessProject(User $user, ?Project $project): bool
{
if (! $project) {
return false;
}
if ($user->isAdmin()) {
return true;
}
if ($user->isEmployee()) {
return true;
}
return self::userCanManageProject($user, $project);
}
public static function userCanViewProject(User $user, ?Project $project): bool
{
if (! $project) {
return false;
}
if ($user->isAdmin()) {
return true;
}
return $user->isProjectManager() || $user->isProjectDirector();
}
public static function userCanEditProject(User $user, ?Project $project): bool
{
if (! $project) {
return false;
}
if ($user->isAdmin()) {
return true;
}
if (! $user->isApprover()) {
return false;
}
return $project->created_by === $user->id;
}
public static function userCanManageProject(User $user, ?Project $project): bool
{
if (! $project) {
return false;
}
if ($user->isAdmin()) {
return true;
}
if ($user->isProjectManager()) {
return $project->project_manager_id === $user->id;
}
if ($user->isProjectDirector()) {
return $project->project_director_id === $user->id;
}
return false;
}
public static function scopeTimesheetsForUser(Builder $query, User $user): Builder
{
if ($user->isAdmin()) {
return $query;
}
if ($user->isEmployee()) {
return $query->where('user_id', $user->id);
}
if ($user->isProjectManager() || $user->isProjectDirector()) {
return $query->where(function (Builder $scopedQuery) use ($user): void {
$scopedQuery
->whereHas(
'project',
fn (Builder $projectQuery) => self::scopeAssignedProjectsForUser($projectQuery, $user),
)
->orWhereHas(
'approvalLogs',
fn (Builder $logQuery) => $logQuery->where('user_id', $user->id),
);
});
}
return $query->whereRaw('0 = 1');
}
public static function scopeProjectsForUser(Builder $query, User $user): Builder
{
if ($user->isAdmin() || $user->isProjectManager() || $user->isProjectDirector()) {
return $query;
}
if ($user->isEmployee()) {
return $query->where('status', 'active');
}
return $query->whereRaw('0 = 1');
}
public static function scopeAssignedProjectsForUser(Builder $query, User $user): Builder
{
if ($user->isAdmin()) {
return $query;
}
if ($user->isEmployee()) {
return $query->where('status', 'active');
}
if ($user->isProjectManager()) {
return $query->where('project_manager_id', $user->id);
}
if ($user->isProjectDirector()) {
return $query->where('project_director_id', $user->id);
}
return $query->whereRaw('0 = 1');
}
}

View File

@ -0,0 +1,194 @@
<?php
namespace App\Support;
use App\Models\Setting;
use App\Models\Timesheet;
use App\Models\User;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification as NotificationFacade;
class TimesheetNotifier
{
public static function enabled(): bool
{
return Setting::emailNotificationsEnabled();
}
public static function notifySubmitted(Timesheet $timesheet): void
{
if (! static::enabled()) {
static::logSkipped('submitted', $timesheet, 'email_notifications_disabled');
return;
}
$timesheet->loadMissing(['user', 'project.projectManager']);
static::notifyRecipients(
'submitted',
$timesheet,
static::submissionRecipients($timesheet),
new \App\Notifications\TimesheetSubmittedNotification($timesheet),
);
}
public static function notifyPendingDirector(Timesheet $timesheet, ?string $pmComment = null): void
{
if (! static::enabled()) {
static::logSkipped('pending_director', $timesheet, 'email_notifications_disabled');
return;
}
$timesheet->loadMissing(['user', 'project.projectDirector']);
static::notifyRecipients(
'pending_director',
$timesheet,
static::directorRecipients($timesheet),
new \App\Notifications\TimesheetPendingDirectorNotification($timesheet, $pmComment),
);
}
public static function notifyApproved(Timesheet $timesheet, User $approver, ?string $comment = null): void
{
if (! static::enabled()) {
static::logSkipped('approved', $timesheet, 'email_notifications_disabled');
return;
}
$employee = $timesheet->user;
if (! $employee) {
static::logSkipped('approved', $timesheet, 'missing_employee');
return;
}
static::notifyRecipients(
'approved',
$timesheet,
collect([$employee]),
new \App\Notifications\TimesheetApprovedNotification($timesheet, $approver, $comment),
);
}
public static function notifyRejected(Timesheet $timesheet, User $rejector, string $comment): void
{
if (! static::enabled()) {
static::logSkipped('rejected', $timesheet, 'email_notifications_disabled');
return;
}
$employee = $timesheet->user;
if (! $employee) {
static::logSkipped('rejected', $timesheet, 'missing_employee');
return;
}
static::notifyRecipients(
'rejected',
$timesheet,
collect([$employee]),
new \App\Notifications\TimesheetRejectedNotification($timesheet, $rejector, $comment),
);
}
/**
* @return Collection<int, User>
*/
protected static function submissionRecipients(Timesheet $timesheet): Collection
{
$manager = $timesheet->project?->projectManager;
if ($manager) {
return collect([$manager]);
}
return static::admins();
}
/**
* @return Collection<int, User>
*/
protected static function directorRecipients(Timesheet $timesheet): Collection
{
$director = $timesheet->project?->projectDirector;
if ($director) {
return collect([$director]);
}
return static::admins();
}
/**
* @return Collection<int, User>
*/
protected static function admins(): Collection
{
return User::query()->where('role', 'admin')->get();
}
/**
* @param Collection<int, User> $recipients
*/
protected static function notifyRecipients(
string $event,
Timesheet $timesheet,
Collection $recipients,
Notification $notification,
): void {
$recipients = $recipients->filter()->unique('id');
if ($recipients->isEmpty()) {
static::logSkipped($event, $timesheet, 'no_recipients');
return;
}
foreach ($recipients as $user) {
try {
if (config('timesheet.notifications.queue', true)) {
$user->notify($notification);
} else {
NotificationFacade::sendNow($user, $notification);
}
Log::info('Timesheet notification dispatched', [
'event' => $event,
'timesheet_id' => $timesheet->id,
'recipient_id' => $user->id,
'recipient_email' => $user->email,
'queued' => config('timesheet.notifications.queue', true),
]);
} catch (\Throwable $exception) {
Log::error('Timesheet notification dispatch failed', [
'event' => $event,
'timesheet_id' => $timesheet->id,
'recipient_id' => $user->id,
'recipient_email' => $user->email,
'error' => $exception->getMessage(),
]);
throw $exception;
}
}
}
protected static function logSkipped(string $event, Timesheet $timesheet, string $reason): void
{
Log::warning('Timesheet notification skipped', [
'event' => $event,
'timesheet_id' => $timesheet->id,
'reason' => $reason,
]);
}
}

View File

@ -0,0 +1,310 @@
<?php
namespace App\Support;
use App\Models\Project;
use App\Models\Timesheet;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
class TimesheetSummaryBuilder
{
public function __construct(
public string $groupBy = 'project',
public ?string $dateFrom = null,
public ?string $dateTo = null,
public ?int $projectId = null,
public ?int $userId = null,
public ?string $status = null,
) {}
public static function fromRequest(Request $request): self
{
return self::fromValidated($request->all());
}
/**
* @param array<string, mixed> $validated
*/
public static function fromValidated(array $validated): self
{
return new self(
groupBy: $validated['groupBy'] ?? 'project',
dateFrom: $validated['dateFrom'] ?? null,
dateTo: $validated['dateTo'] ?? null,
projectId: isset($validated['projectId']) ? (int) $validated['projectId'] : null,
userId: isset($validated['userId']) ? (int) $validated['userId'] : null,
status: $validated['status'] ?? null,
);
}
public static function fromTableFilters(?array $filters): self
{
return new self(
groupBy: 'project',
projectId: self::filterIntValue($filters, 'project_id'),
userId: self::filterIntValue($filters, 'user_id'),
status: self::filterStringValue($filters, 'status'),
);
}
public static function fromReports(
string $reportType,
?string $dateFrom,
?string $dateTo,
?int $projectId,
): self {
$groupBy = in_array($reportType, TimesheetAccess::SUMMARY_GROUP_BY, true)
? $reportType
: 'project';
return new self(
groupBy: $groupBy,
dateFrom: $dateFrom,
dateTo: $dateTo,
projectId: $projectId,
);
}
public function toQueryParams(): array
{
return array_filter([
'groupBy' => $this->groupBy !== 'project' ? $this->groupBy : null,
'dateFrom' => $this->dateFrom,
'dateTo' => $this->dateTo,
'projectId' => $this->projectId,
'userId' => $this->userId,
'status' => $this->status,
], fn ($value) => filled($value));
}
public function exportUrl(): string
{
return route('pdf.summary', $this->toQueryParams());
}
public function query(): Builder
{
$user = auth()->user();
$query = Timesheet::query()->with('project', 'user');
TimesheetAccess::scopeTimesheetsForUser($query, $user);
if ($this->userId && ! $user->isEmployee()) {
$query->where('user_id', $this->userId);
}
if ($this->status) {
$query->where('status', $this->status);
}
if ($this->dateFrom) {
$query->where('week_start', '>=', $this->dateFrom);
}
if ($this->dateTo) {
$query->where('week_start', '<=', $this->dateTo);
}
if ($this->projectId) {
$query->where('project_id', $this->projectId);
}
return $query;
}
/**
* @return array<int, array{label: string, hours: float|int}>
*/
public function groupedData(): array
{
$timesheets = $this->query()->get();
return match ($this->groupBy) {
'week' => $this->groupByWeek($timesheets),
'month' => $this->groupByMonth($timesheets),
'member' => $this->groupByMember($timesheets),
default => $this->groupByProject($timesheets),
};
}
public function totalHours(): float
{
return array_sum(array_column($this->groupedData(), 'hours'));
}
public function resolvedProject(): ?Project
{
return $this->projectId ? Project::find($this->projectId) : null;
}
public function periodLabel(): string
{
if ($this->dateFrom && $this->dateTo) {
$from = Carbon::parse($this->dateFrom);
$to = Carbon::parse($this->dateTo);
if ($from->isSameMonth($to) && $from->isSameYear($to)) {
return $from->format('F Y');
}
return $from->format('d M Y') . ' ' . $to->format('d M Y');
}
return now()->format('F Y');
}
public function dataColumnLabel(): string
{
return match ($this->groupBy) {
'week' => 'Week',
'month' => 'Month',
'member' => 'Member',
default => 'Project',
};
}
public function title(): string
{
return match ($this->groupBy) {
'week' => 'WEEKLY SUMMARY',
'month' => 'MONTHLY SUMMARY',
'member' => 'MEMBER SUMMARY',
default => 'PROJECT SUMMARY',
};
}
public function statusLabel(): ?string
{
if (! $this->status) {
return null;
}
return ucwords(str_replace('_', ' ', $this->status));
}
/**
* @param \Illuminate\Support\Collection<int, Timesheet> $timesheets
* @return array<int, array{label: string, hours: float|int}>
*/
private function groupByMember($timesheets): array
{
$results = [];
foreach ($timesheets as $timesheet) {
$key = $timesheet->user?->name ?? 'Unknown';
$results[$key] = ($results[$key] ?? 0) + $timesheet->totalHours();
}
arsort($results);
return array_values(array_map(
fn ($hours, $label) => ['label' => $label, 'hours' => $hours],
$results,
array_keys($results),
));
}
/**
* @param \Illuminate\Support\Collection<int, Timesheet> $timesheets
* @return array<int, array{label: string, hours: float|int}>
*/
private function groupByProject($timesheets): array
{
$results = [];
foreach ($timesheets as $timesheet) {
$key = $timesheet->project?->name ?? 'Unknown';
$results[$key] = ($results[$key] ?? 0) + $timesheet->totalHours();
}
arsort($results);
return array_values(array_map(
fn ($hours, $label) => ['label' => $label, 'hours' => $hours],
$results,
array_keys($results),
));
}
/**
* @param \Illuminate\Support\Collection<int, Timesheet> $timesheets
* @return array<int, array{label: string, hours: float|int}>
*/
private function groupByWeek($timesheets): array
{
$results = [];
foreach ($timesheets as $timesheet) {
$weekStart = $timesheet->week_start instanceof Carbon
? $timesheet->week_start
: Carbon::parse($timesheet->week_start);
$weekEnd = $weekStart->copy()->addDays(6)->format('d/m/Y');
$key = $weekStart->format('Y-\WW') . ' (ending ' . $weekEnd . ')';
$results[$key] = ($results[$key] ?? 0) + $timesheet->totalHours();
}
ksort($results);
return array_values(array_map(
fn ($hours, $label) => ['label' => $label, 'hours' => $hours],
$results,
array_keys($results),
));
}
/**
* @param \Illuminate\Support\Collection<int, Timesheet> $timesheets
* @return array<int, array{label: string, hours: float|int}>
*/
private function groupByMonth($timesheets): array
{
$results = [];
foreach ($timesheets as $timesheet) {
$weekStart = $timesheet->week_start instanceof Carbon
? $timesheet->week_start
: Carbon::parse($timesheet->week_start);
$key = $weekStart->format('M Y');
$results[$key] = ($results[$key] ?? 0) + $timesheet->totalHours();
}
ksort($results);
return array_values(array_map(
fn ($hours, $label) => ['label' => $label, 'hours' => $hours],
$results,
array_keys($results),
));
}
private static function filterStringValue(?array $filters, string $key): ?string
{
$value = self::filterRawValue($filters, $key);
return filled($value) ? (string) $value : null;
}
private static function filterIntValue(?array $filters, string $key): ?int
{
$value = self::filterRawValue($filters, $key);
return filled($value) ? (int) $value : null;
}
private static function filterRawValue(?array $filters, string $key): mixed
{
if (empty($filters[$key])) {
return null;
}
$state = $filters[$key];
if (! is_array($state)) {
return $state;
}
return $state['value'] ?? $state['values'] ?? null;
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Support;
use App\Models\User;
class WeeklyHoursAccess
{
public static function userCanView(User $viewer, User $target): bool
{
if ($viewer->isAdmin()) {
return true;
}
if ($viewer->isEmployee()) {
return $viewer->id === $target->id;
}
return User::query()
->whereKey($target->id)
->where(function ($userQuery) use ($viewer): void {
$userQuery
->whereHas(
'timesheets.project',
fn ($projectQuery) => TimesheetAccess::scopeAssignedProjectsForUser($projectQuery, $viewer),
)
->orWhere('id', $viewer->id);
})
->exists();
}
}

View File

@ -0,0 +1,115 @@
<?php
namespace App\Support;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Http\Request;
class WeeklyHoursExport
{
public function __construct(
public User $user,
public Carbon $weekStart,
) {}
public static function fromRequest(Request $request, User $viewer): self
{
$validated = $request->validate([
'userId' => ['required', 'integer', 'exists:users,id'],
'weekStart' => ['required', 'date'],
]);
$user = User::query()->findOrFail($validated['userId']);
return self::for($user, $validated['weekStart'], $viewer);
}
public static function for(User $user, Carbon | string $weekStart, User $viewer): self
{
$weekStart = Carbon::parse($weekStart)->startOfWeek(Carbon::MONDAY);
if (! WeeklyHoursAccess::userCanView($viewer, $user)) {
abort(403);
}
return new self($user, $weekStart);
}
/**
* @return list<array{
* id: ?int,
* project_id: ?int,
* project_name: string,
* activity: string,
* hours: list<float>,
* status: string,
* editable: bool,
* }>
*/
public function rows(): array
{
return $this->sheet()->loadExportRows();
}
/**
* @return array<int, string>
*/
public function dayHeaders(): array
{
return collect(range(0, 6))
->mapWithKeys(function (int $offset): array {
$date = $this->weekStart->copy()->addDays($offset);
return [$offset => strtoupper($date->format('D j'))];
})
->all();
}
public function weekLabel(): string
{
return $this->weekStart->format('F Y') . ' Week ' . $this->weekStart->isoWeek();
}
/**
* @return list<string>
*/
public function columnTotals(): array
{
return array_map(
fn (float $total): string => WeeklyHoursFormatter::display($total),
WeeklyHoursFormatter::columnTotals($this->rows()),
);
}
public function grandTotal(): string
{
$total = array_sum(WeeklyHoursFormatter::columnTotals($this->rows()));
return WeeklyHoursFormatter::display($total);
}
public function formatHours(float | int | string | null $hours): string
{
return WeeklyHoursFormatter::display($hours);
}
public function rowDuration(array $row): string
{
return WeeklyHoursFormatter::display(WeeklyHoursFormatter::rowTotal($row['hours'] ?? []));
}
public function filename(): string
{
return sprintf(
'weekly_hours_%s_%s.pdf',
str($this->user->name)->slug('_'),
$this->weekStart->format('Y-m-d'),
);
}
private function sheet(): WeeklyHoursSheet
{
return new WeeklyHoursSheet($this->user, $this->weekStart);
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Support;
class WeeklyHoursFormatter
{
public static function display(float | int | string | null $hours): string
{
$hours = max(0, (float) ($hours ?: 0));
$totalMinutes = (int) round($hours * 60);
$wholeHours = intdiv($totalMinutes, 60);
$minutes = $totalMinutes % 60;
return sprintf('%d:%02d', $wholeHours, $minutes);
}
public static function parse(mixed $value): float
{
if (is_numeric($value)) {
return max(0, min(24, (float) $value));
}
$value = trim((string) $value);
if ($value === '') {
return 0;
}
if (str_contains($value, ':')) {
[$hoursPart, $minutesPart] = array_pad(explode(':', $value, 2), 2, '0');
$hours = (float) $hoursPart + ((float) $minutesPart / 60);
return max(0, min(24, $hours));
}
$parsed = (float) $value;
return max(0, min(24, $parsed));
}
/**
* @param list<float|int|string|null> $hours
*/
public static function rowTotal(array $hours): float
{
return array_sum(array_map(
fn (mixed $value): float => self::parse($value),
array_replace([0, 0, 0, 0, 0, 0, 0], array_values($hours)),
));
}
/**
* @param list<array{hours: list<float|int|string|null>}> $rows
* @return list<float>
*/
public static function columnTotals(array $rows): array
{
$totals = array_fill(0, 7, 0.0);
foreach ($rows as $row) {
foreach (array_values($row['hours'] ?? []) as $index => $value) {
if ($index > 6) {
break;
}
$totals[$index] += self::parse($value);
}
}
return $totals;
}
}

View File

@ -0,0 +1,470 @@
<?php
namespace App\Support;
use App\Models\Project;
use App\Models\Timesheet;
use App\Models\User;
use App\Rules\ValidDailyHours;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
class WeeklyHoursSheet
{
public function __construct(
public User $user,
public Carbon $weekStart,
) {}
/**
* @return list<array{
* id: ?int,
* project_id: ?int,
* project_name: string,
* activity: string,
* hours: list<float>,
* status: string,
* editable: bool,
* }>
*/
public function loadRows(): array
{
$timesheets = Timesheet::query()
->with('project')
->where('user_id', $this->user->id)
->whereDate('week_start', $this->weekStart->toDateString())
->orderBy('id')
->get();
$rows = $timesheets->map(fn (Timesheet $timesheet): array => $this->rowFromTimesheet($timesheet))->all();
if ($rows === []) {
$rows[] = $this->emptyRow();
}
return $rows;
}
/**
* @return list<array{
* id: ?int,
* project_id: ?int,
* project_name: string,
* activity: string,
* hours: list<float>,
* status: string,
* editable: bool,
* }>
*/
public function loadExportRows(): array
{
$timesheets = Timesheet::query()
->with('project')
->where('user_id', $this->user->id)
->whereDate('week_start', $this->weekStart->toDateString())
->orderBy('id')
->get();
return $timesheets
->map(function (Timesheet $timesheet): array {
$row = $this->rowFromTimesheet($timesheet);
$row['project_name'] = $timesheet->project?->name ?? '—';
return $row;
})
->reject(fn (array $row): bool => $this->rowIsBlank($row))
->values()
->all();
}
/**
* @return array<int, string>
*/
public function projectOptions(): array
{
$viewer = auth()->user();
if ($viewer?->isAdmin()) {
return Project::query()
->where('status', 'active')
->orderBy('name')
->pluck('name', 'id')
->all();
}
$query = Project::query()
->where('status', 'active')
->orderBy('name');
if ($this->user->isEmployee()) {
$query->whereHas(
'members',
fn ($members) => $members->whereKey($this->user->id),
);
} else {
TimesheetAccess::scopeAssignedProjectsForUser($query, $viewer);
}
$options = $query->pluck('name', 'id')->all();
$existingProjectIds = Timesheet::query()
->where('user_id', $this->user->id)
->whereDate('week_start', $this->weekStart->toDateString())
->whereNotNull('project_id')
->pluck('project_id');
if ($existingProjectIds->isNotEmpty()) {
$existingOptions = Project::query()
->whereIn('id', $existingProjectIds)
->orderBy('name')
->pluck('name', 'id')
->all();
$options = $existingOptions + $options;
}
return $options;
}
/**
* @param list<array{
* id?: int|null,
* project_id?: int|null,
* activity?: string|null,
* hours?: list<float|int|string|null>,
* status?: string|null,
* editable?: bool|null,
* }> $rows
*/
public function saveRows(array $rows, User $actor): void
{
$normalized = $this->normalizeIncomingRows($rows);
$this->validateRows($normalized, $actor);
DB::transaction(function () use ($normalized, $actor): void {
$keptIds = [];
foreach ($normalized as $row) {
if ($this->rowIsBlank($row)) {
continue;
}
$timesheet = $this->persistRow($row, $actor);
$keptIds[] = $timesheet->id;
}
$this->deleteRemovedDraftRows($keptIds, $actor);
});
}
/**
* @param list<array{
* id?: int|null,
* project_id?: int|null,
* activity?: string|null,
* hours?: list<float|int|string|null>,
* status?: string|null,
* editable?: bool|null,
* }> $rows
* @return list<array{
* id: ?int,
* project_id: ?int,
* activity: string,
* hours: list<float>,
* status: string,
* editable: bool,
* }>
*/
private function normalizeIncomingRows(array $rows): array
{
return array_values(array_map(function (array $row): array {
$hours = array_map(
fn (mixed $value): float => WeeklyHoursFormatter::parse($value),
array_replace([0, 0, 0, 0, 0, 0, 0], array_values($row['hours'] ?? [])),
);
return [
'id' => filled($row['id'] ?? null) ? (int) $row['id'] : null,
'project_id' => filled($row['project_id'] ?? null) ? (int) $row['project_id'] : null,
'activity' => trim((string) ($row['activity'] ?? '')),
'hours' => $hours,
'status' => (string) ($row['status'] ?? 'draft'),
'editable' => (bool) ($row['editable'] ?? true),
];
}, $rows));
}
/**
* @param list<array{
* id: ?int,
* project_id: ?int,
* activity: string,
* hours: list<float>,
* status: string,
* editable: bool,
* }> $rows
*/
private function validateRows(array $rows, User $actor): void
{
$errors = [];
$projectIds = [];
foreach ($rows as $index => $row) {
if ($this->rowIsBlank($row)) {
continue;
}
if (! $row['project_id']) {
if ($row['editable']) {
$errors["rows.{$index}.project_id"] = 'Select a project for this row.';
}
continue;
}
if (in_array($row['project_id'], $projectIds, true)) {
$errors["rows.{$index}.project_id"] = 'Each project can only appear once in the same week.';
}
$projectIds[] = $row['project_id'];
$conflictingTimesheet = Timesheet::query()
->where('user_id', $this->user->id)
->where('project_id', $row['project_id'])
->whereDate('week_start', $this->weekStart->toDateString())
->when($row['id'], fn ($query) => $query->where('id', '!=', $row['id']))
->first();
if ($conflictingTimesheet && $row['id'] !== $conflictingTimesheet->id) {
$canUpsertExisting = ! $row['id']
&& $conflictingTimesheet->isEditable()
&& TimesheetAccess::userCanEditTimesheet($actor, $conflictingTimesheet);
if (! $canUpsertExisting) {
$errors["rows.{$index}.project_id"] = 'A timesheet row for this project already exists in this week.';
continue;
}
}
if (! $row['editable']) {
continue;
}
$validator = Validator::make(
['hours' => $row['hours']],
['hours' => [new ValidDailyHours()]],
);
if ($validator->fails()) {
$errors["rows.{$index}.hours"] = $validator->errors()->first('hours');
}
if ($row['id']) {
$timesheet = Timesheet::query()->find($row['id']);
if (! $timesheet || $timesheet->user_id !== $this->user->id) {
$errors["rows.{$index}.id"] = 'This timesheet row could not be found.';
continue;
}
if (! TimesheetAccess::userCanEditTimesheet($actor, $timesheet)) {
$errors["rows.{$index}.id"] = 'This row cannot be edited in its current status.';
}
}
}
if ($errors !== []) {
throw ValidationException::withMessages($errors);
}
}
/**
* @param array{
* id: ?int,
* project_id: ?int,
* activity: string,
* hours: list<float>,
* status: string,
* editable: bool,
* } $row
*/
private function persistRow(array $row, User $actor): Timesheet
{
if ($row['id']) {
$timesheet = Timesheet::query()->findOrFail($row['id']);
$timesheet->update([
'project_id' => $row['project_id'],
'project_role' => $this->resolveProjectRole($timesheet, $row['project_id']),
'hours' => $row['hours'],
'tasks' => $this->tasksFromActivity($row['activity'], $row['hours'], $timesheet->tasks),
]);
return $timesheet->fresh();
}
$existing = Timesheet::query()
->where('user_id', $this->user->id)
->where('project_id', $row['project_id'])
->whereDate('week_start', $this->weekStart->toDateString())
->first();
if ($existing) {
$existing->update([
'project_role' => $this->resolveProjectRole($existing, $row['project_id']),
'hours' => $row['hours'],
'tasks' => $this->tasksFromActivity($row['activity'], $row['hours'], $existing->tasks),
]);
return $existing->fresh();
}
return Timesheet::query()->create([
'user_id' => $this->user->id,
'project_id' => $row['project_id'],
'project_role' => $this->assignedProjectRole($row['project_id']),
'week_start' => $this->weekStart->toDateString(),
'hours' => $row['hours'],
'tasks' => $this->tasksFromActivity($row['activity'], $row['hours'], null),
'status' => 'draft',
]);
}
/**
* @param list<int> $keptIds
*/
private function deleteRemovedDraftRows(array $keptIds, User $actor): void
{
$query = Timesheet::query()
->where('user_id', $this->user->id)
->whereDate('week_start', $this->weekStart->toDateString())
->whereIn('status', ['draft', 'rejected']);
if ($keptIds !== []) {
$query->whereNotIn('id', $keptIds);
}
$query->get()->each(function (Timesheet $timesheet) use ($actor): void {
if (TimesheetAccess::userCanEditTimesheet($actor, $timesheet)) {
$timesheet->delete();
}
});
}
private function rowFromTimesheet(Timesheet $timesheet): array
{
return [
'id' => $timesheet->id,
'project_id' => $timesheet->project_id,
'project_name' => $timesheet->project?->name ?? '',
'activity' => $this->activityFromTasks($timesheet->tasks),
'hours' => array_map(
fn (mixed $value): float => (float) $value,
array_replace([0, 0, 0, 0, 0, 0, 0], $timesheet->hours ?? []),
),
'status' => $timesheet->status,
'editable' => $timesheet->isEditable(),
];
}
/**
* @return array{
* id: null,
* project_id: null,
* project_name: string,
* activity: string,
* hours: list<float>,
* status: string,
* editable: bool,
* }
*/
private function emptyRow(): array
{
return [
'id' => null,
'project_id' => null,
'project_name' => '',
'activity' => '',
'hours' => [0, 0, 0, 0, 0, 0, 0],
'status' => 'draft',
'editable' => true,
];
}
/**
* @param array{
* id: ?int,
* project_id: ?int,
* activity: string,
* hours: list<float>,
* } $row
*/
private function rowIsBlank(array $row): bool
{
if ($row['project_id']) {
return false;
}
if ($row['activity'] !== '') {
return false;
}
return WeeklyHoursFormatter::rowTotal($row['hours']) <= 0;
}
private function activityFromTasks(?array $tasks): string
{
$parts = array_values(array_unique(array_filter(array_map(
fn (mixed $value): string => trim((string) $value),
array_replace(['', '', '', '', '', '', ''], $tasks ?? []),
))));
return implode(', ', $parts);
}
/**
* @param list<float> $hours
* @param list<string>|null $existingTasks
* @return list<string>
*/
private function tasksFromActivity(string $activity, array $hours, ?array $existingTasks): array
{
$tasks = array_replace(['', '', '', '', '', '', ''], $existingTasks ?? []);
$activity = trim($activity);
for ($i = 0; $i < 7; $i++) {
if ($hours[$i] > 0) {
$tasks[$i] = $activity;
} elseif ($activity === '' && ($existingTasks[$i] ?? '') !== '') {
$tasks[$i] = '';
}
}
return $tasks;
}
private function resolveProjectRole(Timesheet $timesheet, ?int $projectId): ?string
{
if (filled($timesheet->project_role)) {
return $timesheet->project_role;
}
return $this->assignedProjectRole($projectId);
}
private function assignedProjectRole(?int $projectId): ?string
{
if (! $projectId) {
return null;
}
$assignedRole = $this->user->projects()
->whereKey($projectId)
->value('project_user.assigned_role');
return filled($assignedRole) ? (string) $assignedRole : null;
}
}

18
artisan Executable file
View File

@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);

57
bootstrap/app.php Normal file
View File

@ -0,0 +1,57 @@
<?php
use App\Http\Middleware\AttachFlareContext;
use App\Http\Middleware\RestrictHealthCheck;
use App\Http\Middleware\SecurityHeaders;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Spatie\LaravelFlare\Facades\Flare;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
then: function (): void {
Route::middleware([RestrictHealthCheck::class])
->get('/up', function (Request $request) {
if ($request->wantsJson()) {
return response()->json(['status' => 'up']);
}
return response('OK', 200);
});
},
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->redirectGuestsTo('/admin/login');
$middleware->append(SecurityHeaders::class);
$middleware->web(append: [
AttachFlareContext::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
if (filled(config('flare.key')) && config('flare.report')) {
Flare::handles($exceptions);
Flare::filterExceptionsUsing(function (\Throwable $throwable): bool {
if ($throwable instanceof NotFoundHttpException) {
return false;
}
if ($throwable instanceof HttpExceptionInterface && $throwable->getStatusCode() < 500) {
return false;
}
return true;
});
}
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*'),
);
})->create();

2
bootstrap/cache/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

6
bootstrap/providers.php Normal file
View File

@ -0,0 +1,6 @@
<?php
return [
App\Providers\AppServiceProvider::class,
App\Providers\Filament\AdminPanelProvider::class,
];

91
composer.json Normal file
View File

@ -0,0 +1,91 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.3",
"barryvdh/laravel-dompdf": "^3.1",
"filament/filament": "^5.6",
"laravel/framework": "^13.8",
"laravel/tinker": "^3.0",
"spatie/laravel-activitylog": "^5.0",
"spatie/laravel-flare": "^3.0"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.5",
"laravel/pao": "^1.0.6",
"laravel/pint": "^1.27",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^12.5.12"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install --ignore-scripts",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
],
"test": [
"@php artisan config:clear --ansi @no_additional_args",
"@php artisan test"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi",
"@php artisan filament:upgrade"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

11338
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

73
config/activitylog.php Normal file
View File

@ -0,0 +1,73 @@
<?php
use Spatie\Activitylog\Actions\CleanActivityLogAction;
use Spatie\Activitylog\Actions\LogActivityAction;
use Spatie\Activitylog\Models\Activity;
return [
/*
* If set to false, no activities will be saved to the database.
*/
'enabled' => env('ACTIVITYLOG_ENABLED', true),
/*
* When the clean command is executed, all recording activities older than
* the number of days specified here will be deleted.
*/
'clean_after_days' => (int) env('ACTIVITYLOG_RETENTION_DAYS', 90),
/*
* If no log name is passed to the activity() helper
* we use this default log name.
*/
'default_log_name' => 'default',
/*
* You can specify an auth driver here that gets user models.
* If this is null we'll use the current Laravel auth driver.
*/
'default_auth_driver' => null,
/*
* If set to true, the subject relationship on activities
* will include soft deleted models.
*/
'include_soft_deleted_subjects' => false,
/*
* This model will be used to log activity.
* It should implement the Spatie\Activitylog\Contracts\Activity interface
* and extend Illuminate\Database\Eloquent\Model.
*/
'activity_model' => Activity::class,
/*
* These attributes will be excluded from logging for all models.
* Model-specific exclusions via logExcept() are merged with these.
*/
'default_except_attributes' => [],
/*
* When enabled, activities are buffered in memory and inserted in a
* single bulk query after the response has been sent to the client.
* This can significantly reduce the number of database queries when
* many activities are logged during a single request.
*
* Only enable this if your application logs a high volume of activities
* per request. Buffered activities will not have an ID until the
* buffer is flushed.
*/
'buffer' => [
'enabled' => env('ACTIVITYLOG_BUFFER_ENABLED', false),
],
/*
* These action classes can be overridden to customize how activities
* are logged and cleaned. Your custom classes must extend the originals.
*/
'actions' => [
'log_activity' => LogActivityAction::class,
'clean_log' => CleanActivityLogAction::class,
],
];

126
config/app.php Normal file
View File

@ -0,0 +1,126 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => env('APP_TIMEZONE', 'Asia/Kuala_Lumpur'),
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];

117
config/auth.php Normal file
View File

@ -0,0 +1,117 @@
<?php
use App\Models\User;
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];

136
config/cache.php Normal file
View File

@ -0,0 +1,136 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => env('CACHE_STORE', 'database'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "storage", "octane",
| "session", "failover", "null"
|
*/
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'storage' => [
'driver' => 'storage',
'disk' => env('CACHE_STORAGE_DISK'),
'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
/*
|--------------------------------------------------------------------------
| Serializable Classes
|--------------------------------------------------------------------------
|
| This value determines the classes that can be unserialized from cache
| storage. By default, no PHP classes will be unserialized from your
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
*/
'serializable_classes' => false,
];

184
config/database.php Normal file
View File

@ -0,0 +1,184 @@
<?php
use Illuminate\Support\Str;
use Pdo\Mysql;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => env('DB_CONNECTION', 'sqlite'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
],
];

80
config/filesystems.php Normal file
View File

@ -0,0 +1,80 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],
];

206
config/flare.php Normal file
View File

@ -0,0 +1,206 @@
<?php
return [
/*
|
|--------------------------------------------------------------------------
| Flare API key
|--------------------------------------------------------------------------
|
| Specify Flare's API key below to enable error reporting to the service.
|
| More info: https://flareapp.io/docs/flare/general/getting-started
|
*/
'key' => env('FLARE_KEY'),
'report' => env('FLARE_REPORT', false) && filled(env('FLARE_KEY')),
/*
|--------------------------------------------------------------------------
| Collects
|--------------------------------------------------------------------------
|
| Flare will collect a lot of information about your application. You can
| disable some of the collectors here, configure them or add your own.
|
*/
'collects' => \Spatie\LaravelFlare\FlareConfig::defaultCollects(
ignore: [],
extra: []
),
/*
|--------------------------------------------------------------------------
| Censor data
|--------------------------------------------------------------------------
|
| It is possible to censor sensitive data from the reports and sent to
| Flare. Below you can specify which fields and header should be
| censored. It is also possible to hide the client's IP address.
|
*/
'censor' => [
'body_fields' => [
'password',
'password_confirmation',
'remember_token',
'hours',
'tasks',
'notes',
'salary',
'rate',
'app_authentication_secret',
'app_authentication_recovery_codes',
],
'headers' => [
'API-KEY',
'Authorization',
'Cookie',
'Set-Cookie',
'X-CSRF-TOKEN',
'X-XSRF-TOKEN',
],
'client_ips' => false,
'cookies' => false,
'session' => false,
],
/*
|--------------------------------------------------------------------------
| Sender
|--------------------------------------------------------------------------
|
| The sender is responsible for sending the error reports and traces to
| Flare. By default, Laravel Flare sends them over HTTP. To use the local
| Flare daemon, switch the sender class to
| `Spatie\FlareClient\Senders\DaemonSender::class` and set `daemon_url`.
| The daemon sender defaults to localhost on port 8787 and uses its own
| default timeouts and fallback sender config unless you override them.
|
*/
'sender' => [
'class' => \Spatie\LaravelFlare\Senders\LaravelHttpSender::class,
'config' => [
'timeout' => 10,
],
],
// Daemon sender example
// 'sender' => [
// 'class' => \Spatie\FlareClient\Senders\DaemonSender::class,
// 'config' => [
// 'daemon_url' => env('FLARE_DAEMON_URL', 'http://127.0.0.1:8787'),
// ],
// ],
/*
|--------------------------------------------------------------------------
| Report
|--------------------------------------------------------------------------
|
| Flare reports errors and exceptions happening within your application.
| Reporting is gated by FLARE_REPORT and requires FLARE_KEY (see top of file).
|
*/
/*
|--------------------------------------------------------------------------
| Report error levels
|--------------------------------------------------------------------------
| When reporting errors, you can specify which error levels should be
| reported. By default, all error levels are reported by setting
| this value to `null`.
*/
'report_error_levels' => null,
/*
|--------------------------------------------------------------------------
| Override grouping
|--------------------------------------------------------------------------
|
| Flare will try to group errors and exceptions as best as possible, that
| being said, sometimes you might want to override the grouping. You can
| do this by adding exception classes to this array which should always
| be grouped by exception class, exception message or exception class
| and message.
|
*/
'overridden_groupings' => [
// Illuminate\Http\Client\ConnectionException::class => Spatie\FlareClient\Enums\OverriddenGrouping::ExceptionMessageAndClass,
],
/*
|--------------------------------------------------------------------------
| Share button
|--------------------------------------------------------------------------
|
| Flare automatically adds a Share button to the laravel error page. This
| button allows you to easily share errors with colleagues or friends. It
| is enabled by default, but you can disable it here.
|
*/
'enable_share_button' => true,
/*
|--------------------------------------------------------------------------
| Trace
|--------------------------------------------------------------------------
|
| Tracing allows you to see the flow of your application. It shows you
| which parts of your application are slow and which parts are fast.
|
*/
'trace' => env('FLARE_TRACE', true),
/*
|--------------------------------------------------------------------------
| Sampler
|--------------------------------------------------------------------------
|
| The sampler is used to determine which traces should be recorded and
| which traces should be dropped. It is possible to set the rate
| at which traces should be recorded. The default rate is 0.1
| which means that 10% of the traces will be recorded.
|
*/
'sampler' => [
'class' => \Spatie\FlareClient\Sampling\RateSampler::class,
'config' => [
'rate' => env('FLARE_SAMPLER_RATE', 0.1),
],
],
/*
|--------------------------------------------------------------------------
| Log
|--------------------------------------------------------------------------
|
| Logging show you an overview of log entries within your application.
|
*/
'log' => env('FLARE_LOG', false),
/*
|--------------------------------------------------------------------------
| Minimal log level
|--------------------------------------------------------------------------
|
| You can specify the minimal (Monolog) log level that should be sent to Flare.
| Log levels lower than the specified level will be ignored.
| If null all log levels will be sent to Flare.
|
*/
'minimal_log_level' => null,
];

132
config/logging.php Normal file
View File

@ -0,0 +1,132 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
|
| This option controls the log channel that should be used to log warnings
| regarding deprecated PHP and library features. This allows you to get
| your application ready for upcoming major versions of dependencies.
|
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
],
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Laravel
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
];

118
config/mail.php Normal file
View File

@ -0,0 +1,118 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => env('MAIL_MAILER', 'log'),
/*
|--------------------------------------------------------------------------
| Mailer Configurations
|--------------------------------------------------------------------------
|
| Here you may configure all of the mailers used by your application plus
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers that can be used
| when delivering an email. You may specify which one you're using for
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
|
*/
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 2525),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
],
'ses' => [
'transport' => 'ses',
],
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'client' => [
// 'timeout' => 5,
// ],
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
'retry_after' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all emails sent by your application to be sent from
| the same address. Here you may specify a name and address that is
| used globally for all emails that are sent by your application.
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
],
];

53
config/observability.php Normal file
View File

@ -0,0 +1,53 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Better Uptime
|--------------------------------------------------------------------------
|
| External uptime monitors (Better Uptime / Better Stack) ping signed
| heartbeat URLs. See docs/OBSERVABILITY.md for monitor setup.
|
*/
'uptime' => [
'enabled' => env('BETTER_UPTIME_ENABLED', false),
'heartbeat_token' => env('UPTIME_HEARTBEAT_TOKEN'),
'scheduler_stale_minutes' => (int) env('UPTIME_SCHEDULER_STALE_MINUTES', 5),
'queue_stale_minutes' => (int) env('UPTIME_QUEUE_STALE_MINUTES', 5),
'cache_key_scheduler' => 'observability.uptime.scheduler',
'cache_key_queue' => 'observability.uptime.queue',
],
/*
|--------------------------------------------------------------------------
| Activity log retention
|--------------------------------------------------------------------------
*/
'activitylog_retention_days' => (int) env('ACTIVITYLOG_RETENTION_DAYS', 90),
/*
|--------------------------------------------------------------------------
| Flare tags (applied to every report when enabled)
|--------------------------------------------------------------------------
*/
'flare' => [
'application' => env('APP_NAME', 'Quatriz TimeSheet'),
'tenant' => env('FLARE_TENANT_TAG', 'quatriz'),
],
/*
|--------------------------------------------------------------------------
| Site traffic (admin dashboard metric)
|--------------------------------------------------------------------------
*/
'traffic' => [
'enabled' => env('SITE_TRAFFIC_ENABLED', true),
],
];

129
config/queue.php Normal file
View File

@ -0,0 +1,129 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each backend using identical
| syntax for each. The default queue connection is defined below.
|
*/
'default' => env('QUEUE_CONNECTION', 'database'),
/*
|--------------------------------------------------------------------------
| Queue Connections
|--------------------------------------------------------------------------
|
| Here you may configure the connection options for every queue backend
| used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
| "deferred", "background", "failover", "null"
|
*/
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'after_commit' => false,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
'queue' => env('BEANSTALKD_QUEUE', 'default'),
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
'block_for' => 0,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],
'deferred' => [
'driver' => 'deferred',
],
'background' => [
'driver' => 'background',
],
'failover' => [
'driver' => 'failover',
'connections' => [
'database',
'deferred',
],
],
],
/*
|--------------------------------------------------------------------------
| Job Batching
|--------------------------------------------------------------------------
|
| The following options configure the database and table that store job
| batching information. These options can be updated to any database
| connection and table which has been defined by your application.
|
*/
'batching' => [
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'job_batches',
],
/*
|--------------------------------------------------------------------------
| Failed Queue Jobs
|--------------------------------------------------------------------------
|
| These options configure the behavior of failed queue job logging so you
| can control how and where failed jobs are stored. Laravel ships with
| support for storing failed jobs in a simple file or in a database.
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
*/
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'failed_jobs',
],
];

Some files were not shown because too many files have changed in this diff Show More