mirror of
https://github.com/skysyaz/timesheet.git
synced 2026-07-14 08:40:50 +00:00
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:
commit
e90c84970f
18
.editorconfig
Normal file
18
.editorconfig
Normal 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
102
.env.example
Normal 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
11
.gitattributes
vendored
Normal 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
27
.gitignore
vendored
Normal 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
|
||||
482
BUILD_WORKFLOW.md
Normal file
482
BUILD_WORKFLOW.md
Normal 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 (Mon–Sun) 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
73
README.md
Normal 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
|
||||
18
app/Auth/Http/Responses/LoginResponse.php
Normal file
18
app/Auth/Http/Responses/LoginResponse.php
Normal 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());
|
||||
}
|
||||
}
|
||||
82
app/Console/Commands/BackfillActivityLogFromApprovalLogs.php
Normal file
82
app/Console/Commands/BackfillActivityLogFromApprovalLogs.php
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
32
app/Console/Commands/SignalUptimeHeartbeat.php
Normal file
32
app/Console/Commands/SignalUptimeHeartbeat.php
Normal 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;
|
||||
}
|
||||
}
|
||||
56
app/Filament/Actions/ResetUserPasswordAction.php
Normal file
56
app/Filament/Actions/ResetUserPasswordAction.php
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
22
app/Filament/Concerns/ConfiguresTableToolbar.php
Normal file
22
app/Filament/Concerns/ConfiguresTableToolbar.php
Normal 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()'),
|
||||
);
|
||||
}
|
||||
}
|
||||
15
app/Filament/Concerns/RestoresHeaderInteractivity.php
Normal file
15
app/Filament/Concerns/RestoresHeaderInteractivity.php
Normal 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');
|
||||
}
|
||||
}
|
||||
66
app/Filament/Concerns/SyncsProjectMembers.php
Normal file
66
app/Filament/Concerns/SyncsProjectMembers.php
Normal 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;
|
||||
}
|
||||
}
|
||||
31
app/Filament/Forms/Components/DailyHoursGrid.php
Normal file
31
app/Filament/Forms/Components/DailyHoursGrid.php
Normal 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());
|
||||
}
|
||||
}
|
||||
28
app/Filament/Forms/Components/DailyTasksGrid.php
Normal file
28
app/Filament/Forms/Components/DailyTasksGrid.php
Normal 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))
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
8
app/Filament/Forms/Components/WeeklyTimesheetPlanner.php
Normal file
8
app/Filament/Forms/Components/WeeklyTimesheetPlanner.php
Normal file
@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Forms\Components;
|
||||
|
||||
class WeeklyTimesheetPlanner extends DailyHoursGrid
|
||||
{
|
||||
protected string $view = 'filament.forms.components.weekly-timesheet-planner';
|
||||
}
|
||||
18
app/Filament/Pages/Auth/Login.php
Normal file
18
app/Filament/Pages/Auth/Login.php
Normal 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';
|
||||
}
|
||||
}
|
||||
31
app/Filament/Pages/Dashboard.php
Normal file
31
app/Filament/Pages/Dashboard.php
Normal 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;
|
||||
}
|
||||
}
|
||||
75
app/Filament/Pages/MyProjects.php
Normal file
75
app/Filament/Pages/MyProjects.php
Normal 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(),
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
132
app/Filament/Pages/Reports.php
Normal file
132
app/Filament/Pages/Reports.php
Normal 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());
|
||||
}
|
||||
}
|
||||
166
app/Filament/Pages/Settings.php
Normal file
166
app/Filament/Pages/Settings.php
Normal 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'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
365
app/Filament/Pages/WeeklyHours.php
Normal file
365
app/Filament/Pages/WeeklyHours.php
Normal 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.';
|
||||
}
|
||||
}
|
||||
183
app/Filament/Resources/ActivityLogResource.php
Normal file
183
app/Filament/Resources/ActivityLogResource.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
273
app/Filament/Resources/ProjectResource.php
Normal file
273
app/Filament/Resources/ProjectResource.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
77
app/Filament/Resources/ProjectResource/Pages/EditProject.php
Normal file
77
app/Filament/Resources/ProjectResource/Pages/EditProject.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
146
app/Filament/Resources/ProjectResource/Pages/ViewProject.php
Normal file
146
app/Filament/Resources/ProjectResource/Pages/ViewProject.php
Normal 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
546
app/Filament/Resources/TimesheetResource.php
Normal file
546
app/Filament/Resources/TimesheetResource.php
Normal 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}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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]));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
142
app/Filament/Resources/TimesheetResource/Pages/ViewTimesheet.php
Normal file
142
app/Filament/Resources/TimesheetResource/Pages/ViewTimesheet.php
Normal 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)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
123
app/Filament/Resources/UserResource.php
Normal file
123
app/Filament/Resources/UserResource.php
Normal 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;
|
||||
}
|
||||
}
|
||||
11
app/Filament/Resources/UserResource/Pages/CreateUser.php
Normal file
11
app/Filament/Resources/UserResource/Pages/CreateUser.php
Normal 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;
|
||||
}
|
||||
21
app/Filament/Resources/UserResource/Pages/EditUser.php
Normal file
21
app/Filament/Resources/UserResource/Pages/EditUser.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/UserResource/Pages/ListUsers.php
Normal file
19
app/Filament/Resources/UserResource/Pages/ListUsers.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
65
app/Filament/Widgets/HoursByDayChart.php
Normal file
65
app/Filament/Widgets/HoursByDayChart.php
Normal 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';
|
||||
}
|
||||
}
|
||||
68
app/Filament/Widgets/HoursByProjectChart.php
Normal file
68
app/Filament/Widgets/HoursByProjectChart.php
Normal 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';
|
||||
}
|
||||
}
|
||||
52
app/Filament/Widgets/SiteTrafficOverview.php
Normal file
52
app/Filament/Widgets/SiteTrafficOverview.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
71
app/Filament/Widgets/TimesheetStatsOverview.php
Normal file
71
app/Filament/Widgets/TimesheetStatsOverview.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
64
app/Filament/Widgets/WelcomeBanner.php
Normal file
64
app/Filament/Widgets/WelcomeBanner.php
Normal 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');
|
||||
}
|
||||
}
|
||||
8
app/Http/Controllers/Controller.php
Normal file
8
app/Http/Controllers/Controller.php
Normal file
@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
79
app/Http/Controllers/FaviconController.php
Normal file
79
app/Http/Controllers/FaviconController.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
111
app/Http/Controllers/PdfController.php
Normal file
111
app/Http/Controllers/PdfController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
72
app/Http/Controllers/UptimeHeartbeatController.php
Normal file
72
app/Http/Controllers/UptimeHeartbeatController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
39
app/Http/Middleware/AttachFlareContext.php
Normal file
39
app/Http/Middleware/AttachFlareContext.php
Normal 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);
|
||||
}
|
||||
}
|
||||
26
app/Http/Middleware/RecordSiteTraffic.php
Normal file
26
app/Http/Middleware/RecordSiteTraffic.php
Normal 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;
|
||||
}
|
||||
}
|
||||
39
app/Http/Middleware/RestrictHealthCheck.php
Normal file
39
app/Http/Middleware/RestrictHealthCheck.php
Normal 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);
|
||||
}
|
||||
}
|
||||
87
app/Http/Middleware/SecurityHeaders.php
Normal file
87
app/Http/Middleware/SecurityHeaders.php
Normal 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);
|
||||
}
|
||||
}
|
||||
25
app/Jobs/RecordQueueHeartbeat.php
Normal file
25
app/Jobs/RecordQueueHeartbeat.php
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
22
app/Models/ApprovalLog.php
Normal file
22
app/Models/ApprovalLog.php
Normal 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);
|
||||
}
|
||||
}
|
||||
25
app/Models/Concerns/LogsAuditableChanges.php
Normal file
25
app/Models/Concerns/LogsAuditableChanges.php
Normal 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
144
app/Models/Project.php
Normal 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
43
app/Models/Setting.php
Normal 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);
|
||||
}
|
||||
}
|
||||
28
app/Models/SiteTrafficDaily.php
Normal file
28
app/Models/SiteTrafficDaily.php
Normal 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
184
app/Models/Timesheet.php
Normal 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
110
app/Models/User.php
Normal 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'];
|
||||
}
|
||||
}
|
||||
52
app/Notifications/TimesheetApprovedNotification.php
Normal file
52
app/Notifications/TimesheetApprovedNotification.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
52
app/Notifications/TimesheetPendingDirectorNotification.php
Normal file
52
app/Notifications/TimesheetPendingDirectorNotification.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
49
app/Notifications/TimesheetRejectedNotification.php
Normal file
49
app/Notifications/TimesheetRejectedNotification.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
49
app/Notifications/TimesheetSubmittedNotification.php
Normal file
49
app/Notifications/TimesheetSubmittedNotification.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
40
app/Providers/AppServiceProvider.php
Normal file
40
app/Providers/AppServiceProvider.php
Normal 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(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
104
app/Providers/Filament/AdminPanelProvider.php
Normal file
104
app/Providers/Filament/AdminPanelProvider.php
Normal 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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
30
app/Rules/ValidDailyHours.php
Normal file
30
app/Rules/ValidDailyHours.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
app/Rules/WeekStartsOnMonday.php
Normal file
29
app/Rules/WeekStartsOnMonday.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
59
app/Support/AuditLogger.php
Normal file
59
app/Support/AuditLogger.php
Normal 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;
|
||||
}
|
||||
}
|
||||
25
app/Support/Concerns/BuildsTimesheetMail.php
Normal file
25
app/Support/Concerns/BuildsTimesheetMail.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
31
app/Support/Concerns/QueuesTimesheetNotification.php
Normal file
31
app/Support/Concerns/QueuesTimesheetNotification.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
51
app/Support/FaviconAssets.php
Normal file
51
app/Support/FaviconAssets.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
49
app/Support/LocalAvatarProvider.php
Normal file
49
app/Support/LocalAvatarProvider.php
Normal 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);
|
||||
}
|
||||
}
|
||||
168
app/Support/ProjectScheduleHealth.php
Normal file
168
app/Support/ProjectScheduleHealth.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
96
app/Support/SiteTrafficRecorder.php
Normal file
96
app/Support/SiteTrafficRecorder.php
Normal 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/*');
|
||||
}
|
||||
}
|
||||
217
app/Support/TimesheetAccess.php
Normal file
217
app/Support/TimesheetAccess.php
Normal 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');
|
||||
}
|
||||
}
|
||||
194
app/Support/TimesheetNotifier.php
Normal file
194
app/Support/TimesheetNotifier.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
310
app/Support/TimesheetSummaryBuilder.php
Normal file
310
app/Support/TimesheetSummaryBuilder.php
Normal 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;
|
||||
}
|
||||
}
|
||||
31
app/Support/WeeklyHoursAccess.php
Normal file
31
app/Support/WeeklyHoursAccess.php
Normal 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();
|
||||
}
|
||||
}
|
||||
115
app/Support/WeeklyHoursExport.php
Normal file
115
app/Support/WeeklyHoursExport.php
Normal 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);
|
||||
}
|
||||
}
|
||||
72
app/Support/WeeklyHoursFormatter.php
Normal file
72
app/Support/WeeklyHoursFormatter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
470
app/Support/WeeklyHoursSheet.php
Normal file
470
app/Support/WeeklyHoursSheet.php
Normal 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
18
artisan
Executable 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
57
bootstrap/app.php
Normal 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
2
bootstrap/cache/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
6
bootstrap/providers.php
Normal file
6
bootstrap/providers.php
Normal file
@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\Filament\AdminPanelProvider::class,
|
||||
];
|
||||
91
composer.json
Normal file
91
composer.json
Normal 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
11338
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
73
config/activitylog.php
Normal file
73
config/activitylog.php
Normal 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
126
config/app.php
Normal 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
117
config/auth.php
Normal 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
136
config/cache.php
Normal 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
184
config/database.php
Normal 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
80
config/filesystems.php
Normal 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
206
config/flare.php
Normal 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
132
config/logging.php
Normal 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
118
config/mail.php
Normal 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
53
config/observability.php
Normal 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
129
config/queue.php
Normal 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
Loading…
x
Reference in New Issue
Block a user