#!/usr/bin/env bash
# =====================================================================
#  PM SYSTEM INSTALLER  (Laravel + Inertia + Vue 3 + Tailwind)
#  Usage:  cd ~/Projects/nl/pm && bash pm-install.sh
#  Requires the Laravel **Vue starter kit** (auth + 2FA + layout).
# =====================================================================
set -euo pipefail
cd "${1:-.}"

say()  { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
warn() { printf '\033[1;33m !! %s\033[0m\n' "$*"; }
die()  { printf '\033[1;31mERROR: %s\033[0m\n' "$*"; exit 1; }
w()    { mkdir -p "$(dirname "$1")"; cat > "$1"; echo "  wrote $1"; }

# ---------------------------------------------------------------- checks
[ -f artisan ] || die "Run this from the Laravel project root (the folder containing 'artisan')."
command -v php >/dev/null      || die "php not found"
command -v composer >/dev/null || die "composer not found"
command -v npm >/dev/null      || die "npm not found"

if [ ! -f resources/js/layouts/AppLayout.vue ] || [ ! -f app/Providers/FortifyServiceProvider.php ]; then
  cat <<'MSG'

This project was created WITHOUT the Vue starter kit (no login/2FA/layout found).
The system builds on the kit's authentication, so recreate the project once:

    cd ..
    rm -rf pm
    laravel new pm --vue          # Vue starter kit, Laravel's built-in auth
    cd pm
    cp /path/to/pm-install.sh .
    bash pm-install.sh

MSG
  exit 1
fi

say "Installing packages"
composer require spatie/laravel-activitylog --no-interaction
php artisan vendor:publish --provider="Spatie\Activitylog\ActivitylogServiceProvider" --tag="activitylog-migrations" --no-interaction || warn "activitylog migration publish failed"
[ -f routes/api.php ] || php artisan install:api --without-migration-prompt --no-interaction || php artisan install:api --no-interaction || true
ls database/migrations | grep -q personal_access_tokens || warn "Sanctum migration not found - run: php artisan install:api"
ls database/migrations | grep -q create_notifications_table || php artisan make:notifications-table --no-interaction
npm install vuedraggable@4.1.0

# =====================================================================
say "Writing enums"
# =====================================================================
w app/Enums/Role.php <<'__EOF__'
<?php

namespace App\Enums;

enum Role: string
{
    case Admin = 'admin';
    case Manager = 'manager';
    case Member = 'member';
    case Client = 'client';

    public function isInternal(): bool
    {
        return $this !== self::Client;
    }
}
__EOF__

w app/Enums/TaskStatus.php <<'__EOF__'
<?php

namespace App\Enums;

enum TaskStatus: string
{
    case Todo = 'todo';
    case InProgress = 'in_progress';
    case Review = 'review';
    case Done = 'done';
}
__EOF__

w app/Enums/TaskPriority.php <<'__EOF__'
<?php

namespace App\Enums;

enum TaskPriority: string
{
    case Low = 'low';
    case Medium = 'medium';
    case High = 'high';
    case Urgent = 'urgent';
}
__EOF__

# =====================================================================
say "Writing migrations"
# =====================================================================
w database/migrations/2026_11_14_000001_create_clients_table.php <<'__EOF__'
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('clients', function (Blueprint $table) {
            $table->id();
            $table->ulid('ulid')->unique();
            $table->string('name');
            $table->string('email')->nullable();
            $table->string('phone')->nullable();
            $table->text('notes')->nullable();
            $table->timestamps();
            $table->softDeletes();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('clients');
    }
};
__EOF__

w database/migrations/2026_11_14_000002_add_role_and_client_to_users_table.php <<'__EOF__'
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->string('role')->default('member')->index();
            $table->foreignId('client_id')->nullable()->constrained()->nullOnDelete();
            $table->boolean('is_active')->default(true);
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropConstrainedForeignId('client_id');
            $table->dropColumn(['role', 'is_active']);
        });
    }
};
__EOF__

w database/migrations/2026_11_14_000003_create_projects_table.php <<'__EOF__'
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('projects', function (Blueprint $table) {
            $table->id();
            $table->ulid('ulid')->unique();
            $table->foreignId('client_id')->nullable()->constrained()->restrictOnDelete();
            $table->foreignId('owner_id')->constrained('users');
            $table->string('name');
            $table->text('description')->nullable();
            $table->string('status')->default('planning')->index();
            $table->date('starts_on')->nullable();
            $table->date('due_on')->nullable();
            $table->decimal('budget', 12, 2)->nullable();
            $table->decimal('hourly_rate', 8, 2)->nullable();
            $table->timestamps();
            $table->softDeletes();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('projects');
    }
};
__EOF__

w database/migrations/2026_11_14_000004_create_project_user_table.php <<'__EOF__'
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('project_user', function (Blueprint $table) {
            $table->foreignId('project_id')->constrained()->cascadeOnDelete();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->string('role')->default('member');
            $table->primary(['project_id', 'user_id']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('project_user');
    }
};
__EOF__

w database/migrations/2026_11_14_000005_create_invitations_table.php <<'__EOF__'
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('invitations', function (Blueprint $table) {
            $table->id();
            $table->string('email')->index();
            $table->string('role');
            $table->foreignId('client_id')->nullable()->constrained()->cascadeOnDelete();
            $table->string('token_hash', 64)->unique();
            $table->foreignId('invited_by')->constrained('users');
            $table->timestamp('expires_at');
            $table->timestamp('accepted_at')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('invitations');
    }
};
__EOF__

w database/migrations/2026_11_14_000006_create_task_tables.php <<'__EOF__'
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('tasks', function (Blueprint $table) {
            $table->id();
            $table->ulid('ulid')->unique();
            $table->foreignId('project_id')->constrained()->cascadeOnDelete();
            $table->foreignId('parent_id')->nullable()->constrained('tasks')->cascadeOnDelete();
            $table->foreignId('created_by')->constrained('users');
            $table->foreignId('assignee_id')->nullable()->constrained('users')->nullOnDelete();
            $table->string('title', 200);
            $table->text('description')->nullable();
            $table->string('status')->default('todo');
            $table->string('priority')->default('medium');
            $table->boolean('is_internal')->default(true);
            $table->unsignedInteger('position')->default(0);
            $table->date('due_on')->nullable();
            $table->timestamp('completed_at')->nullable();
            $table->timestamps();
            $table->softDeletes();
            $table->index(['project_id', 'status', 'position']);
            $table->index(['project_id', 'is_internal']);
        });

        Schema::create('labels', function (Blueprint $table) {
            $table->id();
            $table->foreignId('project_id')->constrained()->cascadeOnDelete();
            $table->string('name', 50);
            $table->string('color', 7)->default('#6366f1');
            $table->timestamps();
            $table->unique(['project_id', 'name']);
        });

        Schema::create('label_task', function (Blueprint $table) {
            $table->foreignId('label_id')->constrained()->cascadeOnDelete();
            $table->foreignId('task_id')->constrained()->cascadeOnDelete();
            $table->primary(['label_id', 'task_id']);
        });

        Schema::create('comments', function (Blueprint $table) {
            $table->id();
            $table->ulid('ulid')->unique();
            $table->foreignId('task_id')->constrained()->cascadeOnDelete();
            $table->foreignId('user_id')->constrained('users');
            $table->text('body');
            $table->boolean('is_internal')->default(true);
            $table->timestamps();
            $table->softDeletes();
            $table->index(['task_id', 'is_internal']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('comments');
        Schema::dropIfExists('label_task');
        Schema::dropIfExists('labels');
        Schema::dropIfExists('tasks');
    }
};
__EOF__

w database/migrations/2026_11_14_000007_create_milestone_file_time_invoice_tables.php <<'__EOF__'
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('milestones', function (Blueprint $t) {
            $t->id();
            $t->ulid('ulid')->unique();
            $t->foreignId('project_id')->constrained()->cascadeOnDelete();
            $t->string('title', 150);
            $t->text('description')->nullable();
            $t->date('due_on')->nullable();
            $t->timestamp('completed_at')->nullable();
            $t->boolean('is_internal')->default(true);
            $t->timestamps();
            $t->index(['project_id', 'is_internal']);
        });

        Schema::create('attachments', function (Blueprint $t) {
            $t->id();
            $t->ulid('ulid')->unique();
            $t->foreignId('project_id')->constrained()->cascadeOnDelete();
            $t->foreignId('task_id')->nullable()->constrained()->cascadeOnDelete();
            $t->foreignId('uploaded_by')->constrained('users');
            $t->string('original_name', 255);
            $t->string('path', 500);
            $t->string('mime', 100);
            $t->unsignedBigInteger('size');
            $t->boolean('is_internal')->default(true);
            $t->timestamps();
            $t->softDeletes();
        });

        Schema::create('invoices', function (Blueprint $t) {
            $t->id();
            $t->ulid('ulid')->unique();
            $t->foreignId('project_id')->constrained()->restrictOnDelete();
            $t->foreignId('created_by')->constrained('users');
            $t->string('number')->unique();
            $t->string('status')->default('draft')->index();
            $t->date('issued_on')->nullable();
            $t->date('due_on')->nullable();
            $t->text('notes')->nullable();
            $t->decimal('total', 12, 2)->default(0);
            $t->timestamps();
            $t->softDeletes();
        });

        Schema::create('invoice_items', function (Blueprint $t) {
            $t->id();
            $t->foreignId('invoice_id')->constrained()->cascadeOnDelete();
            $t->string('description', 255);
            $t->decimal('quantity', 10, 2);
            $t->decimal('unit_price', 10, 2);
        });

        Schema::create('time_entries', function (Blueprint $t) {
            $t->id();
            $t->ulid('ulid')->unique();
            $t->foreignId('project_id')->constrained()->cascadeOnDelete();
            $t->foreignId('task_id')->nullable()->constrained()->nullOnDelete();
            $t->foreignId('user_id')->constrained()->cascadeOnDelete();
            $t->foreignId('invoice_id')->nullable()->constrained()->nullOnDelete();
            $t->date('entry_date');
            $t->unsignedSmallInteger('minutes');
            $t->string('description', 500)->nullable();
            $t->boolean('is_billable')->default(true);
            $t->timestamps();
            $t->index(['project_id', 'entry_date']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('time_entries');
        Schema::dropIfExists('invoice_items');
        Schema::dropIfExists('invoices');
        Schema::dropIfExists('attachments');
        Schema::dropIfExists('milestones');
    }
};
__EOF__

# =====================================================================
say "Writing models"
# =====================================================================
w app/Models/User.php <<'__EOF__'
<?php

namespace App\Models;

use App\Enums\Role;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable implements MustVerifyEmail
{
    use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable;

    // role, client_id and is_active are NOT fillable: they are set explicitly in code only.
    protected $fillable = ['name', 'email', 'password'];

    protected $hidden = ['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'];

    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
            'two_factor_confirmed_at' => 'datetime',
            'role' => Role::class,
            'is_active' => 'boolean',
        ];
    }

    public function client() { return $this->belongsTo(Client::class); }
    public function projects() { return $this->belongsToMany(Project::class)->withPivot('role'); }

    public function isInternal(): bool { return $this->role->isInternal(); }
    public function isAdmin(): bool { return $this->role === Role::Admin; }
}
__EOF__

w app/Models/Client.php <<'__EOF__'
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;

class Client extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = ['name', 'email', 'phone', 'notes'];

    public function getRouteKeyName(): string { return 'ulid'; }

    protected static function booted(): void
    {
        static::creating(fn ($c) => $c->ulid ??= (string) Str::ulid());
    }

    public function projects() { return $this->hasMany(Project::class); }
    public function users() { return $this->hasMany(User::class); }
}
__EOF__

w app/Models/Project.php <<'__EOF__'
<?php

namespace App\Models;

use App\Enums\Role;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;

class Project extends Model
{
    use HasFactory, SoftDeletes, LogsActivity;

    protected $fillable = ['client_id', 'name', 'description', 'status', 'starts_on', 'due_on', 'budget', 'hourly_rate'];

    protected $casts = [
        'starts_on' => 'date:Y-m-d',
        'due_on' => 'date:Y-m-d',
        'budget' => 'decimal:2',
        'hourly_rate' => 'decimal:2',
    ];

    public function getRouteKeyName(): string { return 'ulid'; }

    protected static function booted(): void
    {
        static::creating(fn ($p) => $p->ulid ??= (string) Str::ulid());
    }

    public function client() { return $this->belongsTo(Client::class); }
    public function owner() { return $this->belongsTo(User::class, 'owner_id'); }
    public function members() { return $this->belongsToMany(User::class)->withPivot('role'); }
    public function tasks() { return $this->hasMany(Task::class); }
    public function labels() { return $this->hasMany(Label::class); }
    public function milestones() { return $this->hasMany(Milestone::class); }
    public function attachments() { return $this->hasMany(Attachment::class); }
    public function timeEntries() { return $this->hasMany(TimeEntry::class); }
    public function invoices() { return $this->hasMany(Invoice::class); }

    /** Single source of truth for "which projects can this user see". */
    public function scopeVisibleTo(Builder $query, User $user): Builder
    {
        return match (true) {
            $user->role === Role::Admin => $query,
            $user->role === Role::Client => $query->where('client_id', $user->client_id),
            default => $query->where(fn ($q) => $q
                ->where('owner_id', $user->id)
                ->orWhereHas('members', fn ($m) => $m->whereKey($user->id))),
        };
    }

    /** Budget and rate are visible only to admins, managers and the owner. */
    public function hideFinancialsFrom(User $user): static
    {
        $allowed = in_array($user->role, [Role::Admin, Role::Manager], true) || $this->owner_id === $user->id;

        return $allowed ? $this : $this->makeHidden(['budget', 'hourly_rate']);
    }

    public function getActivitylogOptions(): LogOptions
    {
        return LogOptions::defaults()->logFillable()->logOnlyDirty()->dontSubmitEmptyLogs();
    }
}
__EOF__

w app/Models/Invitation.php <<'__EOF__'
<?php

namespace App\Models;

use App\Enums\Role;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;

class Invitation extends Model
{
    protected $fillable = ['email', 'role', 'client_id', 'token_hash', 'invited_by', 'expires_at'];

    protected $hidden = ['token_hash'];

    protected function casts(): array
    {
        return ['role' => Role::class, 'expires_at' => 'datetime', 'accepted_at' => 'datetime'];
    }

    public function client() { return $this->belongsTo(Client::class); }
    public function inviter() { return $this->belongsTo(User::class, 'invited_by'); }

    public function scopePending(Builder $q): Builder
    {
        return $q->whereNull('accepted_at')->where('expires_at', '>', now());
    }

    public function scopeForToken(Builder $q, string $token): Builder
    {
        return $q->where('token_hash', hash('sha256', $token));
    }

    /** Returns [Invitation, plainToken]. Only the hash is stored. */
    public static function issue(string $email, Role $role, ?int $clientId, User $by): array
    {
        static::pending()->where('email', $email)->delete();

        $token = Str::random(64);

        $invitation = static::create([
            'email' => $email,
            'role' => $role,
            'client_id' => $role === Role::Client ? $clientId : null,
            'token_hash' => hash('sha256', $token),
            'invited_by' => $by->id,
            'expires_at' => now()->addDays(7),
        ]);

        return [$invitation, $token];
    }
}
__EOF__

w app/Models/Task.php <<'__EOF__'
<?php

namespace App\Models;

use App\Enums\TaskPriority;
use App\Enums\TaskStatus;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;

class Task extends Model
{
    use HasFactory, SoftDeletes, LogsActivity;

    protected $fillable = ['title', 'description', 'assignee_id', 'priority', 'due_on'];

    protected function casts(): array
    {
        return [
            'status' => TaskStatus::class,
            'priority' => TaskPriority::class,
            'is_internal' => 'boolean',
            'due_on' => 'date:Y-m-d',
            'completed_at' => 'datetime',
        ];
    }

    public function getRouteKeyName(): string { return 'ulid'; }

    protected static function booted(): void
    {
        static::creating(fn ($t) => $t->ulid ??= (string) Str::ulid());
    }

    public function project() { return $this->belongsTo(Project::class); }
    public function parent() { return $this->belongsTo(Task::class, 'parent_id'); }
    public function subtasks() { return $this->hasMany(Task::class, 'parent_id'); }
    public function creator() { return $this->belongsTo(User::class, 'created_by'); }
    public function assignee() { return $this->belongsTo(User::class, 'assignee_id'); }
    public function labels() { return $this->belongsToMany(Label::class); }
    public function comments() { return $this->hasMany(Comment::class); }

    /** Clients never see internal tasks. Always use this when listing tasks. */
    public function scopeVisibleTo(Builder $query, User $user): Builder
    {
        return $user->isInternal() ? $query : $query->where('is_internal', false);
    }

    public function moveTo(TaskStatus $status, ?int $index = null): void
    {
        DB::transaction(function () use ($status, $index) {
            $this->status = $status;
            $this->completed_at = $status === TaskStatus::Done ? ($this->completed_at ?? now()) : null;

            if ($this->parent_id) {
                $this->save();
                return;
            }

            $ids = static::query()
                ->where('project_id', $this->project_id)
                ->whereNull('parent_id')
                ->where('status', $status->value)
                ->whereKeyNot($this->id)
                ->orderBy('position')
                ->lockForUpdate()
                ->pluck('id')
                ->all();

            $at = $index === null ? count($ids) : min($index, count($ids));
            array_splice($ids, $at, 0, [$this->id]);

            $this->position = $at;
            $this->save();

            foreach ($ids as $i => $id) {
                static::whereKey($id)->update(['position' => $i]);
            }
        });
    }

    public function getActivitylogOptions(): LogOptions
    {
        return LogOptions::defaults()
            ->logOnly(['title', 'status', 'priority', 'assignee_id', 'due_on', 'is_internal'])
            ->logOnlyDirty()
            ->dontSubmitEmptyLogs();
    }
}
__EOF__

w app/Models/Comment.php <<'__EOF__'
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;

class Comment extends Model
{
    use SoftDeletes;

    protected $fillable = ['body'];

    protected function casts(): array { return ['is_internal' => 'boolean']; }

    public function getRouteKeyName(): string { return 'ulid'; }

    protected static function booted(): void
    {
        static::creating(fn ($c) => $c->ulid ??= (string) Str::ulid());
    }

    public function task() { return $this->belongsTo(Task::class); }
    public function user() { return $this->belongsTo(User::class); }

    public function scopeVisibleTo(Builder $query, User $user): Builder
    {
        return $user->isInternal() ? $query : $query->where('is_internal', false);
    }
}
__EOF__

w app/Models/Label.php <<'__EOF__'
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Label extends Model
{
    protected $fillable = ['name', 'color'];
}
__EOF__

w app/Models/Milestone.php <<'__EOF__'
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;

class Milestone extends Model
{
    protected $fillable = ['title', 'description', 'due_on'];

    protected function casts(): array
    {
        return ['due_on' => 'date:Y-m-d', 'completed_at' => 'datetime', 'is_internal' => 'boolean'];
    }

    public function getRouteKeyName(): string { return 'ulid'; }

    protected static function booted(): void
    {
        static::creating(fn ($m) => $m->ulid ??= (string) Str::ulid());
    }

    public function project() { return $this->belongsTo(Project::class); }

    public function scopeVisibleTo(Builder $q, User $user): Builder
    {
        return $user->isInternal() ? $q : $q->where('is_internal', false);
    }
}
__EOF__

w app/Models/Attachment.php <<'__EOF__'
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;

class Attachment extends Model
{
    use SoftDeletes;

    protected $guarded = ['*'];
    protected $hidden = ['path'];

    protected function casts(): array { return ['is_internal' => 'boolean']; }

    public function getRouteKeyName(): string { return 'ulid'; }

    protected static function booted(): void
    {
        static::creating(fn ($a) => $a->ulid ??= (string) Str::ulid());
    }

    public function project() { return $this->belongsTo(Project::class); }
    public function task() { return $this->belongsTo(Task::class); }
    public function uploader() { return $this->belongsTo(User::class, 'uploaded_by'); }

    /** Clients see a file only if it AND its task (if any) are client-visible. */
    public function scopeVisibleTo(Builder $q, User $user): Builder
    {
        return $user->isInternal()
            ? $q
            : $q->where('is_internal', false)
                ->where(fn ($w) => $w->whereNull('task_id')
                    ->orWhereHas('task', fn ($t) => $t->where('is_internal', false)));
    }
}
__EOF__

w app/Models/TimeEntry.php <<'__EOF__'
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;

class TimeEntry extends Model
{
    protected $fillable = ['entry_date', 'minutes', 'description', 'is_billable'];

    protected function casts(): array
    {
        return ['entry_date' => 'date:Y-m-d', 'is_billable' => 'boolean'];
    }

    public function getRouteKeyName(): string { return 'ulid'; }

    protected static function booted(): void
    {
        static::creating(fn ($e) => $e->ulid ??= (string) Str::ulid());
    }

    public function project() { return $this->belongsTo(Project::class); }
    public function task() { return $this->belongsTo(Task::class); }
    public function user() { return $this->belongsTo(User::class); }
    public function invoice() { return $this->belongsTo(Invoice::class); }
}
__EOF__

w app/Models/Invoice.php <<'__EOF__'
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;

class Invoice extends Model
{
    use SoftDeletes;

    protected $fillable = ['due_on', 'notes'];

    protected function casts(): array
    {
        return ['issued_on' => 'date:Y-m-d', 'due_on' => 'date:Y-m-d', 'total' => 'decimal:2'];
    }

    public function getRouteKeyName(): string { return 'ulid'; }

    protected static function booted(): void
    {
        static::creating(fn ($i) => $i->ulid ??= (string) Str::ulid());
    }

    public function project() { return $this->belongsTo(Project::class); }
    public function items() { return $this->hasMany(InvoiceItem::class); }
    public function timeEntries() { return $this->hasMany(TimeEntry::class); }
}
__EOF__

w app/Models/InvoiceItem.php <<'__EOF__'
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class InvoiceItem extends Model
{
    public $timestamps = false;

    protected $fillable = ['description', 'quantity', 'unit_price'];
}
__EOF__

# =====================================================================
say "Writing policies"
# =====================================================================
w app/Policies/ProjectPolicy.php <<'__EOF__'
<?php

namespace App\Policies;

use App\Enums\Role;
use App\Models\{Project, User};

class ProjectPolicy
{
    public function viewAny(User $user): bool { return $user->is_active; }

    public function view(User $user, Project $project): bool
    {
        return $user->is_active
            && Project::query()->visibleTo($user)->whereKey($project->id)->exists();
    }

    public function create(User $user): bool
    {
        return $user->is_active && in_array($user->role, [Role::Admin, Role::Manager], true);
    }

    public function update(User $user, Project $project): bool
    {
        return $this->view($user, $project)
            && ($user->role === Role::Admin || $project->owner_id === $user->id);
    }

    public function delete(User $user, Project $project): bool
    {
        return $user->is_active && $user->role === Role::Admin;
    }
}
__EOF__

w app/Policies/TaskPolicy.php <<'__EOF__'
<?php

namespace App\Policies;

use App\Enums\Role;
use App\Models\Project;
use App\Models\Task;
use App\Models\User;
use Illuminate\Auth\Access\Response;

class TaskPolicy
{
    public function view(User $user, Task $task): Response|bool
    {
        if (! $user->is_active || ! $user->can('view', $task->project)) {
            return false;
        }

        return $user->isInternal() || ! $task->is_internal
            ? true
            : Response::denyAsNotFound();
    }

    public function create(User $user, Project $project): bool
    {
        return $this->canWork($user, $project);
    }

    public function update(User $user, Task $task): bool
    {
        return $this->sees($user, $task) && $this->canWork($user, $task->project);
    }

    public function delete(User $user, Task $task): bool
    {
        return $this->update($user, $task)
            && ($user->role === Role::Admin
                || $task->project->owner_id === $user->id
                || $task->created_by === $user->id);
    }

    public function comment(User $user, Task $task): bool
    {
        return $this->sees($user, $task);
    }

    private function sees(User $user, Task $task): bool
    {
        return $this->view($user, $task) === true;
    }

    private function canWork(User $user, Project $project): bool
    {
        if (! $user->is_active || ! $user->isInternal() || ! $user->can('view', $project)) {
            return false;
        }

        return ! $project->members()->whereKey($user->id)->wherePivot('role', 'viewer')->exists();
    }
}
__EOF__

w app/Policies/CommentPolicy.php <<'__EOF__'
<?php

namespace App\Policies;

use App\Enums\Role;
use App\Models\Comment;
use App\Models\User;

class CommentPolicy
{
    public function delete(User $user, Comment $comment): bool
    {
        return $user->is_active
            && $user->can('view', $comment->task)
            && ($comment->user_id === $user->id || $user->role === Role::Admin);
    }
}
__EOF__

w app/Policies/InvoicePolicy.php <<'__EOF__'
<?php

namespace App\Policies;

use App\Enums\Role;
use App\Models\{Invoice, User};
use Illuminate\Auth\Access\Response;

class InvoicePolicy
{
    public function view(User $user, Invoice $invoice): Response|bool
    {
        if (! $user->is_active || ! $user->can('view', $invoice->project)) {
            return false;
        }
        if ($user->can('manage-finance')) {
            return true;
        }

        return $user->role === Role::Client && in_array($invoice->status, ['sent', 'paid'], true)
            ? true
            : Response::denyAsNotFound();
    }

    public function manage(User $user, Invoice $invoice): bool
    {
        return $user->can('manage-finance') && $user->can('view', $invoice->project);
    }
}
__EOF__

# =====================================================================
say "Writing middleware, support, mail, notifications, console command"
# =====================================================================
w app/Http/Middleware/SecurityHeaders.php <<'__EOF__'
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Vite;
use Symfony\Component\HttpFoundation\Response;

class SecurityHeaders
{
    public function handle(Request $request, Closure $next): Response
    {
        $nonce = Vite::useCspNonce();
        $response = $next($request);

        $headers = [
            'X-Frame-Options' => 'DENY',
            'X-Content-Type-Options' => 'nosniff',
            'Referrer-Policy' => 'strict-origin-when-cross-origin',
            'Permissions-Policy' => 'camera=(), microphone=(), geolocation=()',
        ];

        if (app()->isProduction()) {
            $headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains';
            $headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self' 'nonce-{$nonce}'; "
                ."style-src 'self' 'unsafe-inline' https://fonts.bunny.net; font-src 'self' data: https://fonts.bunny.net; "
                ."img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'";
        }

        foreach ($headers as $k => $v) {
            $response->headers->set($k, $v);
        }

        return $response;
    }
}
__EOF__

w app/Http/Middleware/BlockRegistration.php <<'__EOF__'
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

/** Public sign-up is disabled: accounts are created by invitation only. */
class BlockRegistration
{
    public function handle(Request $request, Closure $next): Response
    {
        abort_if($request->is('register'), 404);

        return $next($request);
    }
}
__EOF__

w app/Http/Middleware/EnsureUserIsActive.php <<'__EOF__'
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class EnsureUserIsActive
{
    public function handle(Request $request, Closure $next): Response
    {
        $user = $request->user();

        if ($user && ! $user->is_active) {
            auth()->guard('web')->logout();
            $request->session()->invalidate();
            $request->session()->regenerateToken();

            return redirect()->route('login')->withErrors(['email' => 'Your account has been deactivated.']);
        }

        return $next($request);
    }
}
__EOF__

w app/Http/Middleware/RequireTwoFactor.php <<'__EOF__'
<?php

namespace App\Http\Middleware;

use App\Enums\Role;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Symfony\Component\HttpFoundation\Response;

/** Admins and managers must enable 2FA before using the app. */
class RequireTwoFactor
{
    public function handle(Request $request, Closure $next): Response
    {
        $user = $request->user();

        $needs2fa = $user
            && in_array($user->role, [Role::Admin, Role::Manager], true)
            && is_null($user->two_factor_confirmed_at);

        if ($needs2fa && ! $request->routeIs(
            'two-factor.*', 'logout', 'login*', 'verification.*', 'password.*', 'user-password.*',
            'profile.*', 'settings.*', 'appearance*', 'security.*',
        ) && ! $request->is('settings/*', 'user/*', 'two-factor-challenge', 'email/*')) {
            $target = Route::has('two-factor.show') ? route('two-factor.show') : url('/settings/two-factor');

            return redirect($target)->with('warning', 'Two-factor authentication is required for your role.');
        }

        return $next($request);
    }
}
__EOF__

w app/Http/Middleware/ApiStaffOnly.php <<'__EOF__'
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class ApiStaffOnly
{
    public function handle(Request $request, Closure $next): Response
    {
        $u = $request->user();
        abort_unless($u && $u->is_active && $u->isInternal(), 403);

        return $next($request);
    }
}
__EOF__

w app/Support/Csv.php <<'__EOF__'
<?php

namespace App\Support;

class Csv
{
    /** Writes a CSV row, neutralising spreadsheet formula injection. */
    public static function row($out, array $cells): void
    {
        $safe = array_map(function ($v) {
            $s = (string) $v;
            return preg_match('/^[=+\-@\t\r]/', $s) ? "'".$s : $s;
        }, $cells);

        fputcsv($out, $safe, ',', '"', '\\');
    }
}
__EOF__

w app/Mail/InvitationMail.php <<'__EOF__'
<?php

namespace App\Mail;

use App\Models\Invitation;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;

class InvitationMail extends Mailable
{
    public function __construct(public Invitation $invitation, public string $token) {}

    public function envelope(): Envelope
    {
        return new Envelope(subject: 'You have been invited to '.config('app.name'));
    }

    public function content(): Content
    {
        return new Content(markdown: 'mail.invitation', with: [
            'url' => route('invitations.accept', $this->token),
            'expires' => $this->invitation->expires_at->toDayDateTimeString(),
        ]);
    }
}
__EOF__

w resources/views/mail/invitation.blade.php <<'__EOF__'
<x-mail::message>
# You're invited

You have been invited to join {{ config('app.name') }}.

<x-mail::button :url="$url">Accept invitation</x-mail::button>

This link expires on {{ $expires }} and can be used once.
If you weren't expecting this, ignore this email.
</x-mail::message>
__EOF__

w app/Notifications/TaskAssigned.php <<'__EOF__'
<?php

namespace App\Notifications;

use App\Models\{Task, User};
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class TaskAssigned extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(public Task $task, public User $by) {}

    public function via(object $notifiable): array { return ['database', 'mail']; }

    private function path(): string
    {
        return "/projects/{$this->task->project->ulid}/tasks/{$this->task->ulid}";
    }

    public function toMail(object $notifiable): MailMessage
    {
        return (new MailMessage)
            ->subject('Task assigned: '.$this->task->title)
            ->line("{$this->by->name} assigned you a task.")
            ->action('Open task', url($this->path()));
    }

    public function toArray(object $notifiable): array
    {
        return ['text' => "{$this->by->name} assigned you “{$this->task->title}”", 'url' => $this->path()];
    }
}
__EOF__

w app/Notifications/CommentPosted.php <<'__EOF__'
<?php

namespace App\Notifications;

use App\Models\Comment;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;

class CommentPosted extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(public Comment $comment) {}

    public function via(object $notifiable): array { return ['database']; }

    /** Never includes the comment body, so internal notes cannot leak. */
    public function toArray(object $notifiable): array
    {
        $task = $this->comment->task()->with('project:id,ulid')->first();

        return [
            'text' => "{$this->comment->user->name} commented on “{$task->title}”",
            'url' => "/projects/{$task->project->ulid}/tasks/{$task->ulid}",
        ];
    }
}
__EOF__

w app/Console/Commands/CreateAdmin.php <<'__EOF__'
<?php

namespace App\Console\Commands;

use App\Enums\Role;
use App\Models\User;
use Illuminate\Console\Command;

class CreateAdmin extends Command
{
    protected $signature = 'pm:create-admin {--name=} {--email=} {--password=}';

    protected $description = 'Create an administrator account (registration is invite-only)';

    public function handle(): int
    {
        $name = $this->option('name') ?: $this->ask('Full name');
        $email = strtolower($this->option('email') ?: $this->ask('Email'));
        $password = $this->option('password') ?: $this->secret('Password (min 12 characters)');

        if (strlen((string) $password) < 12) {
            $this->error('Password must be at least 12 characters.');
            return self::FAILURE;
        }
        if (User::where('email', $email)->exists()) {
            $this->error('A user with that email already exists.');
            return self::FAILURE;
        }

        $u = new User(['name' => $name, 'email' => $email, 'password' => $password]);
        $u->role = Role::Admin;
        $u->is_active = true;
        $u->email_verified_at = now();
        $u->save();

        $this->info("Admin {$email} created. Log in and enable two-factor authentication.");

        return self::SUCCESS;
    }
}
__EOF__

# =====================================================================
say "Writing requests and controllers"
# =====================================================================
w app/Http/Controllers/Controller.php <<'__EOF__'
<?php

namespace App\Http\Controllers;

use Illuminate\Foundation\Auth\Access\AuthorizesRequests;

abstract class Controller
{
    use AuthorizesRequests;
}
__EOF__

w app/Http/Requests/StoreProjectRequest.php <<'__EOF__'
<?php

namespace App\Http\Requests;

use App\Models\Project;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class StoreProjectRequest extends FormRequest
{
    public function authorize(): bool
    {
        $project = $this->route('project');

        return $project
            ? $this->user()->can('update', $project)
            : $this->user()->can('create', Project::class);
    }

    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:150'],
            'description' => ['nullable', 'string', 'max:5000'],
            'client_id' => ['nullable', Rule::exists('clients', 'id')->whereNull('deleted_at')],
            'status' => ['required', Rule::in(['planning', 'active', 'on_hold', 'completed', 'archived'])],
            'starts_on' => ['nullable', 'date'],
            'due_on' => ['nullable', 'date', 'after_or_equal:starts_on'],
            'budget' => ['nullable', 'numeric', 'min:0', 'max:999999999'],
            'hourly_rate' => ['nullable', 'numeric', 'min:0', 'max:99999'],
        ];
    }
}
__EOF__

w app/Http/Requests/Admin/StoreInvitationRequest.php <<'__EOF__'
<?php

namespace App\Http\Requests\Admin;

use App\Enums\Role;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class StoreInvitationRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('manage-users');
    }

    public function rules(): array
    {
        return [
            'email' => ['required', 'email:rfc', 'max:255', Rule::unique('users', 'email')],
            'role' => ['required', Rule::enum(Role::class)],
            'client_id' => [
                Rule::requiredIf(fn () => $this->input('role') === Role::Client->value),
                'nullable',
                Rule::exists('clients', 'id')->whereNull('deleted_at'),
            ],
        ];
    }
}
__EOF__

w app/Http/Requests/TaskRequest.php <<'__EOF__'
<?php

namespace App\Http\Requests;

use App\Enums\TaskPriority;
use App\Enums\TaskStatus;
use App\Models\Task;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class TaskRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->isMethod('POST')
            ? $this->user()->can('create', [Task::class, $this->route('project')])
            : $this->user()->can('update', $this->route('task'));
    }

    public function rules(): array
    {
        $projectId = $this->route('project')->id;

        return [
            'title' => ['required', 'string', 'max:200'],
            'description' => ['nullable', 'string', 'max:10000'],
            'status' => ['sometimes', Rule::enum(TaskStatus::class)],
            'priority' => ['required', Rule::enum(TaskPriority::class)],
            'assignee_id' => ['nullable', Rule::exists('project_user', 'user_id')->where('project_id', $projectId)],
            'due_on' => ['nullable', 'date'],
            'is_internal' => ['required', 'boolean'],
            'label_ids' => ['sometimes', 'array', 'max:10'],
            'label_ids.*' => ['integer', Rule::exists('labels', 'id')->where('project_id', $projectId)],
            'parent' => [
                'nullable',
                Rule::prohibitedIf(! $this->isMethod('POST')),
                Rule::exists('tasks', 'ulid')
                    ->where('project_id', $projectId)
                    ->whereNull('parent_id')
                    ->whereNull('deleted_at'),
            ],
        ];
    }
}
__EOF__

w app/Http/Requests/MoveTaskRequest.php <<'__EOF__'
<?php

namespace App\Http\Requests;

use App\Enums\TaskStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class MoveTaskRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('update', $this->route('task'));
    }

    public function rules(): array
    {
        return [
            'status' => ['required', Rule::enum(TaskStatus::class)],
            'position' => ['nullable', 'integer', 'min:0', 'max:10000'],
        ];
    }
}
__EOF__

w app/Http/Controllers/ProjectController.php <<'__EOF__'
<?php

namespace App\Http\Controllers;

use App\Http\Requests\StoreProjectRequest;
use App\Models\{Client, Project};
use Illuminate\Http\Request;
use Inertia\Inertia;

class ProjectController extends Controller
{
    public function index(Request $request)
    {
        $this->authorize('viewAny', Project::class);
        $user = $request->user();

        $projects = Project::query()
            ->visibleTo($user)
            ->with('client:id,name', 'owner:id,name')
            ->withCount([
                'tasks as tasks_total' => fn ($q) => $q->whereNull('parent_id')->visibleTo($user),
                'tasks as tasks_done' => fn ($q) => $q->whereNull('parent_id')->visibleTo($user)->where('status', 'done'),
            ])
            ->when($request->string('status')->toString(), fn ($q, $s) => $q->where('status', $s))
            ->when($request->string('q')->toString(), fn ($q, $s) => $q->where('name', 'like', '%'.addcslashes($s, '%_\\').'%'))
            ->latest()
            ->paginate(15)
            ->withQueryString();

        $projects->getCollection()->each->hideFinancialsFrom($user);

        return Inertia::render('Projects/Index', [
            'projects' => $projects,
            'filters' => $request->only('status', 'q'),
            'can' => ['create' => $user->can('create', Project::class)],
        ]);
    }

    public function create()
    {
        $this->authorize('create', Project::class);

        return Inertia::render('Projects/Form', [
            'project' => null,
            'clients' => Client::orderBy('name')->get(['id', 'name']),
        ]);
    }

    public function store(StoreProjectRequest $request)
    {
        $project = new Project($request->validated());
        $project->owner_id = $request->user()->id;
        $project->save();
        $project->members()->attach($request->user()->id, ['role' => 'lead']);

        return to_route('projects.show', $project)->with('success', 'Project created.');
    }

    public function show(Request $request, Project $project)
    {
        $this->authorize('view', $project);
        $user = $request->user();

        $project->load('client:id,name', 'owner:id,name');
        if ($user->isInternal()) {
            $project->load('members:id,name');
        }
        $project->hideFinancialsFrom($user);

        $tasks = fn () => $project->tasks()->whereNull('parent_id')->visibleTo($user);

        return Inertia::render('Projects/Show', [
            'project' => $project,
            'stats' => [
                'total' => $tasks()->count(),
                'done' => $tasks()->where('status', 'done')->count(),
                'overdue' => $tasks()->where('status', '!=', 'done')->whereNotNull('due_on')->where('due_on', '<', today())->count(),
            ],
            'milestones' => $project->milestones()->visibleTo($user)->orderBy('due_on')->limit(5)
                ->get(['ulid', 'title', 'due_on', 'completed_at']),
            'can' => ['update' => $user->can('update', $project), 'delete' => $user->can('delete', $project)],
        ]);
    }

    public function edit(Project $project)
    {
        $this->authorize('update', $project);

        return Inertia::render('Projects/Form', [
            'project' => $project,
            'clients' => Client::orderBy('name')->get(['id', 'name']),
        ]);
    }

    public function update(StoreProjectRequest $request, Project $project)
    {
        $project->update($request->validated());

        return to_route('projects.show', $project)->with('success', 'Project updated.');
    }

    public function destroy(Project $project)
    {
        $this->authorize('delete', $project);
        $project->delete();

        return to_route('projects.index')->with('success', 'Project deleted.');
    }
}
__EOF__

w app/Http/Controllers/Admin/UserController.php <<'__EOF__'
<?php

namespace App\Http\Controllers\Admin;

use App\Enums\Role;
use App\Http\Controllers\Controller;
use App\Models\{Client, Invitation, User};
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;

class UserController extends Controller
{
    public function index()
    {
        $this->authorize('manage-users');

        return Inertia::render('Admin/Users/Index', [
            'users' => User::with('client:id,name')
                ->orderBy('name')
                ->get(['id', 'name', 'email', 'role', 'is_active', 'client_id', 'two_factor_confirmed_at']),
            'invitations' => Invitation::pending()->with('client:id,name')->latest()->get(),
            'clients' => Client::orderBy('name')->get(['id', 'name']),
            'roles' => array_column(Role::cases(), 'value'),
        ]);
    }

    public function update(Request $request, User $user)
    {
        $this->authorize('manage-users');

        $data = $request->validate([
            'role' => ['required', Rule::enum(Role::class)],
            'is_active' => ['required', 'boolean'],
            'client_id' => [
                Rule::requiredIf(fn () => $request->input('role') === Role::Client->value),
                'nullable',
                Rule::exists('clients', 'id')->whereNull('deleted_at'),
            ],
        ]);

        $newRole = Role::from($data['role']);
        $active = $request->boolean('is_active');

        if ($user->is($request->user())) {
            throw ValidationException::withMessages(['role' => 'You cannot change your own role or status.']);
        }

        $removingAdmin = $user->role === Role::Admin && ($newRole !== Role::Admin || ! $active);
        if ($removingAdmin && User::where('role', Role::Admin)->where('is_active', true)->count() <= 1) {
            throw ValidationException::withMessages(['role' => 'At least one active admin is required.']);
        }

        $securityChange = $user->role !== $newRole || (bool) $user->is_active !== $active;

        $user->role = $newRole;
        $user->is_active = $active;
        $user->client_id = $newRole === Role::Client ? $data['client_id'] : null;
        $user->save();

        if ($securityChange) {
            DB::table('sessions')->where('user_id', $user->id)->delete();
        }

        return back()->with('success', 'User updated.');
    }
}
__EOF__

w app/Http/Controllers/Admin/InvitationController.php <<'__EOF__'
<?php

namespace App\Http\Controllers\Admin;

use App\Enums\Role;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\StoreInvitationRequest;
use App\Mail\InvitationMail;
use App\Models\Invitation;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;

class InvitationController extends Controller
{
    public function store(StoreInvitationRequest $request)
    {
        [$invitation, $token] = Invitation::issue(
            $request->string('email')->lower()->toString(),
            Role::from($request->input('role')),
            $request->integer('client_id') ?: null,
            $request->user(),
        );

        Mail::to($invitation->email)->send(new InvitationMail($invitation, $token));

        return back()->with('success', "Invitation sent to {$invitation->email}.");
    }

    public function destroy(Request $request, Invitation $invitation)
    {
        $this->authorize('manage-users');
        $invitation->delete();

        return back()->with('success', 'Invitation revoked.');
    }
}
__EOF__

w app/Http/Controllers/Admin/AuditController.php <<'__EOF__'
<?php

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Support\Csv;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Inertia\Inertia;
use Spatie\Activitylog\Models\Activity;

class AuditController extends Controller
{
    public function index()
    {
        $this->authorize('manage-users');

        return Inertia::render('Admin/Audit');
    }

    public function export(Request $request)
    {
        $this->authorize('manage-users');
        $d = $request->validate(['from' => ['required', 'date'], 'to' => ['required', 'date', 'after_or_equal:from']]);

        $from = Carbon::parse($d['from'])->startOfDay();
        $to = Carbon::parse($d['to'])->endOfDay();
        abort_if(abs($from->diffInDays($to)) > 366, 422, 'Range too large (max 1 year).');

        activity()->causedBy($request->user())->log('audit log exported');

        return response()->streamDownload(function () use ($from, $to) {
            $out = fopen('php://output', 'w');
            Csv::row($out, ['id', 'time', 'user', 'event', 'subject_type', 'subject_id', 'description', 'properties']);

            Activity::query()->with('causer:id,email')
                ->whereBetween('created_at', [$from, $to])
                ->orderBy('id')
                ->lazyById(1000)
                ->each(fn ($a) => Csv::row($out, [
                    $a->id, $a->created_at->toIso8601String(), $a->causer?->email, $a->event,
                    $a->subject_type, $a->subject_id, $a->description, json_encode($a->properties),
                ]));

            fclose($out);
        }, "audit-{$from->toDateString()}-to-{$to->toDateString()}.csv", ['Content-Type' => 'text/csv']);
    }
}
__EOF__

w app/Http/Controllers/InvitationAcceptController.php <<'__EOF__'
<?php

namespace App\Http\Controllers;

use App\Models\Invitation;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;

class InvitationAcceptController extends Controller
{
    public function show(string $token)
    {
        $invitation = Invitation::pending()->forToken($token)->first() ?? abort(404);

        return Inertia::render('Invitations/Accept', [
            'email' => $invitation->email,
            'token' => $token,
        ]);
    }

    public function store(Request $request, string $token)
    {
        $data = $request->validate([
            'name' => ['required', 'string', 'max:255'],
            'password' => ['required', 'confirmed', Password::defaults()],
        ]);

        $user = DB::transaction(function () use ($token, $data) {
            $invitation = Invitation::pending()->forToken($token)->lockForUpdate()->first() ?? abort(404);

            abort_if(User::where('email', $invitation->email)->exists(), 409);

            $user = new User(['name' => $data['name'], 'email' => $invitation->email, 'password' => $data['password']]);
            $user->role = $invitation->role;
            $user->client_id = $invitation->client_id;
            $user->is_active = true;
            $user->email_verified_at = now();
            $user->save();

            $invitation->update(['accepted_at' => now()]);

            return $user;
        });

        Auth::login($user);
        $request->session()->regenerate();

        return redirect()->route('dashboard');
    }
}
__EOF__

w app/Http/Controllers/TaskController.php <<'__EOF__'
<?php

namespace App\Http\Controllers;

use App\Enums\Role;
use App\Enums\TaskPriority;
use App\Enums\TaskStatus;
use App\Http\Requests\MoveTaskRequest;
use App\Http\Requests\TaskRequest;
use App\Models\Project;
use App\Models\Task;
use App\Models\User;
use App\Notifications\TaskAssigned;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;

class TaskController extends Controller
{
    public function index(Request $request, Project $project)
    {
        $this->authorize('view', $project);
        $user = $request->user();

        $tasks = $project->tasks()
            ->visibleTo($user)
            ->whereNull('parent_id')
            ->with('assignee:id,name', 'labels:id,name,color')
            ->withCount(['subtasks' => fn ($q) => $q->visibleTo($user)])
            ->orderBy('position')
            ->get();

        $canManage = $user->can('update', $project);
        $canWork = $user->can('create', [Task::class, $project]);

        return Inertia::render('Tasks/Board', [
            'project' => $project->only('ulid', 'name'),
            'columns' => collect(TaskStatus::cases())->mapWithKeys(fn ($s) => [
                $s->value => $tasks->filter(fn ($t) => $t->status === $s)->values(),
            ]),
            'candidates' => $canManage
                ? User::whereIn('role', [Role::Admin, Role::Manager, Role::Member])
                    ->where('is_active', true)
                    ->whereNotIn('id', $project->members()->pluck('users.id'))
                    ->orderBy('name')->get(['id', 'name'])
                : [],
            'can' => ['create' => $canWork, 'update' => $canWork, 'manageMembers' => $canManage],
            ...$this->shared($project, $user),
        ]);
    }

    public function store(TaskRequest $request, Project $project)
    {
        $data = $request->validated();

        $task = DB::transaction(function () use ($request, $project, $data) {
            $parent = ! empty($data['parent'])
                ? $project->tasks()->where('ulid', $data['parent'])->firstOrFail()
                : null;

            $status = TaskStatus::from($data['status'] ?? 'todo');

            $task = new Task(Arr::only($data, ['title', 'description', 'assignee_id', 'priority', 'due_on']));
            $task->project_id = $project->id;
            $task->parent_id = $parent?->id;
            $task->created_by = $request->user()->id;
            $task->status = $status;
            $task->completed_at = $status === TaskStatus::Done ? now() : null;
            $task->is_internal = (bool) ($parent?->is_internal || $data['is_internal']);
            $task->position = ($project->tasks()->whereNull('parent_id')->where('status', $status->value)->max('position') ?? -1) + 1;
            $task->save();

            $task->labels()->sync($data['label_ids'] ?? []);

            return $task;
        });

        if ($task->assignee_id && $task->assignee_id !== $request->user()->id) {
            $task->assignee->notify(new TaskAssigned($task->load('project:id,ulid'), $request->user()));
        }

        return back()->with('success', 'Task created.');
    }

    public function show(Request $request, Project $project, Task $task)
    {
        $this->authorize('view', $task);
        $user = $request->user();

        $task->load('assignee:id,name', 'creator:id,name', 'labels:id,name,color', 'parent:id,ulid,title');

        return Inertia::render('Tasks/Show', [
            'project' => $project->only('ulid', 'name'),
            'task' => $task,
            'subtasks' => $task->subtasks()->visibleTo($user)->with('assignee:id,name')->orderBy('id')->get(),
            'comments' => $task->comments()->visibleTo($user)->with('user:id,name')->oldest()->get(),
            'can' => [
                'update' => $user->can('update', $task),
                'delete' => $user->can('delete', $task),
                'comment' => $user->can('comment', $task),
            ],
            'isInternalUser' => $user->isInternal(),
            ...$this->shared($project, $user),
        ]);
    }

    public function update(TaskRequest $request, Project $project, Task $task)
    {
        $data = $request->validated();
        $before = $task->assignee_id;

        DB::transaction(function () use ($task, $data) {
            $task->fill(Arr::only($data, ['title', 'description', 'assignee_id', 'priority', 'due_on']));
            $task->is_internal = (bool) ($data['is_internal'] || $task->parent?->is_internal);
            $task->save();

            if ($task->is_internal) {
                $task->subtasks()->update(['is_internal' => true]);
            }

            $task->labels()->sync($data['label_ids'] ?? []);

            if (isset($data['status']) && $data['status'] !== $task->status->value) {
                $task->moveTo(TaskStatus::from($data['status']));
            }
        });

        if ($task->assignee_id && $task->assignee_id !== $before && $task->assignee_id !== $request->user()->id) {
            $task->assignee->notify(new TaskAssigned($task->load('project:id,ulid'), $request->user()));
        }

        return back()->with('success', 'Task updated.');
    }

    public function move(MoveTaskRequest $request, Project $project, Task $task)
    {
        $task->moveTo(TaskStatus::from($request->validated('status')), $request->validated('position'));

        return back();
    }

    public function destroy(Project $project, Task $task)
    {
        $this->authorize('delete', $task);

        DB::transaction(function () use ($task) {
            $task->subtasks()->delete();
            $task->delete();
        });

        return to_route('projects.tasks.index', $project)->with('success', 'Task deleted.');
    }

    private function shared(Project $project, User $user): array
    {
        return [
            'members' => $user->isInternal() ? $project->members()->orderBy('name')->get(['users.id', 'users.name']) : [],
            'labels' => $user->isInternal() ? $project->labels()->orderBy('name')->get(['id', 'name', 'color']) : [],
            'statuses' => collect(TaskStatus::cases())->map(fn ($s) => [
                'value' => $s->value,
                'label' => str($s->name)->headline()->toString(),
            ]),
            'priorities' => array_column(TaskPriority::cases(), 'value'),
        ];
    }
}
__EOF__

w app/Http/Controllers/CommentController.php <<'__EOF__'
<?php

namespace App\Http\Controllers;

use App\Enums\Role;
use App\Models\Comment;
use App\Models\Project;
use App\Models\Task;
use App\Models\User;
use App\Notifications\CommentPosted;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Notification;

class CommentController extends Controller
{
    public function store(Request $request, Project $project, Task $task)
    {
        $this->authorize('comment', $task);

        $data = $request->validate([
            'body' => ['required', 'string', 'max:5000'],
            'is_internal' => ['sometimes', 'boolean'],
        ]);

        $user = $request->user();

        // Clients: always client-visible. Internal task: always internal. Staff otherwise choose (default internal).
        $internal = $user->isInternal() && ($task->is_internal || $request->boolean('is_internal', true));

        $comment = new Comment(['body' => $data['body']]);
        $comment->task_id = $task->id;
        $comment->user_id = $user->id;
        $comment->is_internal = $internal;
        $comment->save();

        $recipients = User::query()
            ->where('is_active', true)
            ->whereKeyNot($user->id)
            ->where(function ($q) use ($project, $internal) {
                $q->whereIn('id', $project->members()->pluck('users.id'));
                // Clients are only ever notified about client-visible comments.
                if (! $internal && $project->client_id) {
                    $q->orWhere(fn ($c) => $c->where('role', Role::Client->value)->where('client_id', $project->client_id));
                }
            })->get();

        Notification::send($recipients, new CommentPosted($comment));

        return back()->with('success', 'Comment added.');
    }

    public function destroy(Project $project, Task $task, Comment $comment)
    {
        $this->authorize('delete', $comment);
        $comment->delete();

        return back()->with('success', 'Comment deleted.');
    }
}
__EOF__

w app/Http/Controllers/LabelController.php <<'__EOF__'
<?php

namespace App\Http\Controllers;

use App\Models\Label;
use App\Models\Project;
use App\Models\Task;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;

class LabelController extends Controller
{
    public function store(Request $request, Project $project)
    {
        $this->authorize('create', [Task::class, $project]);

        $data = $request->validate([
            'name' => ['required', 'string', 'max:50', Rule::unique('labels', 'name')->where('project_id', $project->id)],
            'color' => ['required', 'regex:/^#[0-9a-fA-F]{6}$/'],
        ]);

        $project->labels()->create($data);

        return back()->with('success', 'Label added.');
    }

    public function destroy(Project $project, Label $label)
    {
        $this->authorize('create', [Task::class, $project]);
        $label->delete();

        return back()->with('success', 'Label removed.');
    }
}
__EOF__

w app/Http/Controllers/ProjectMemberController.php <<'__EOF__'
<?php

namespace App\Http\Controllers;

use App\Enums\Role;
use App\Models\Project;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;

class ProjectMemberController extends Controller
{
    public function store(Request $request, Project $project)
    {
        $this->authorize('update', $project);

        $data = $request->validate([
            'user_id' => ['required', Rule::exists('users', 'id')
                ->whereIn('role', [Role::Admin->value, Role::Manager->value, Role::Member->value])
                ->where('is_active', true)],
            'role' => ['required', Rule::in(['lead', 'member', 'viewer'])],
        ]);

        $project->members()->syncWithoutDetaching([$data['user_id'] => ['role' => $data['role']]]);

        return back()->with('success', 'Member added.');
    }

    public function destroy(Project $project, User $user)
    {
        $this->authorize('update', $project);
        abort_if($user->id === $project->owner_id, 422, 'The project owner cannot be removed.');

        $project->members()->detach($user->id);
        $project->tasks()->where('assignee_id', $user->id)->update(['assignee_id' => null]);

        return back()->with('success', 'Member removed.');
    }
}
__EOF__

w app/Http/Controllers/ClientController.php <<'__EOF__'
<?php

namespace App\Http\Controllers;

use App\Models\Client;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;

class ClientController extends Controller
{
    public function index()
    {
        $this->authorize('manage-clients');

        return Inertia::render('Clients/Index', [
            'clients' => Client::withCount(['projects', 'users'])->orderBy('name')->get(),
        ]);
    }

    public function store(Request $request)
    {
        $this->authorize('manage-clients');
        Client::create($this->data($request));

        return back()->with('success', 'Client created.');
    }

    public function update(Request $request, Client $client)
    {
        $this->authorize('manage-clients');
        $client->update($this->data($request));

        return back()->with('success', 'Client updated.');
    }

    public function destroy(Client $client)
    {
        $this->authorize('manage-clients');

        if ($client->projects()->exists() || $client->users()->exists()) {
            throw ValidationException::withMessages(['client' => 'Remove or reassign this client’s projects and users first.']);
        }
        $client->delete();

        return back()->with('success', 'Client deleted.');
    }

    private function data(Request $request): array
    {
        return $request->validate([
            'name' => ['required', 'string', 'max:150'],
            'email' => ['nullable', 'email:rfc', 'max:255'],
            'phone' => ['nullable', 'string', 'max:50'],
            'notes' => ['nullable', 'string', 'max:5000'],
        ]);
    }
}
__EOF__

w app/Http/Controllers/MilestoneController.php <<'__EOF__'
<?php

namespace App\Http\Controllers;

use App\Models\{Milestone, Project, Task};
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Inertia\Inertia;

class MilestoneController extends Controller
{
    public function index(Request $request, Project $project)
    {
        $this->authorize('view', $project);
        $user = $request->user();

        return Inertia::render('Milestones/Index', [
            'project' => $project->only('ulid', 'name'),
            'milestones' => $project->milestones()->visibleTo($user)->orderBy('due_on')->get(),
            'can' => ['manage' => $user->can('create', [Task::class, $project])],
        ]);
    }

    public function store(Request $request, Project $project)
    {
        $this->authorize('create', [Task::class, $project]);
        $data = $this->validated($request);

        $m = new Milestone(Arr::only($data, ['title', 'description', 'due_on']));
        $m->project_id = $project->id;
        $m->is_internal = $data['is_internal'];
        $m->save();

        return back()->with('success', 'Milestone added.');
    }

    public function update(Request $request, Project $project, Milestone $milestone)
    {
        $this->authorize('create', [Task::class, $project]);
        $data = $this->validated($request);

        $milestone->fill(Arr::only($data, ['title', 'description', 'due_on']));
        $milestone->is_internal = $data['is_internal'];
        if (array_key_exists('completed', $data)) {
            $milestone->completed_at = $data['completed'] ? ($milestone->completed_at ?? now()) : null;
        }
        $milestone->save();

        return back()->with('success', 'Milestone updated.');
    }

    public function destroy(Project $project, Milestone $milestone)
    {
        $this->authorize('create', [Task::class, $project]);
        $milestone->delete();

        return back()->with('success', 'Milestone deleted.');
    }

    private function validated(Request $request): array
    {
        return $request->validate([
            'title' => ['required', 'string', 'max:150'],
            'description' => ['nullable', 'string', 'max:2000'],
            'due_on' => ['nullable', 'date'],
            'is_internal' => ['required', 'boolean'],
            'completed' => ['sometimes', 'boolean'],
        ]);
    }
}
__EOF__

w app/Http/Controllers/AttachmentController.php <<'__EOF__'
<?php

namespace App\Http\Controllers;

use App\Enums\Role;
use App\Models\{Attachment, Project, Task};
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Inertia\Inertia;

class AttachmentController extends Controller
{
    private const ALLOWED = 'pdf,png,jpg,jpeg,gif,webp,doc,docx,xls,xlsx,ppt,pptx,txt,csv,zip';

    public function index(Request $request, Project $project)
    {
        $this->authorize('view', $project);
        $user = $request->user();

        return Inertia::render('Files/Index', [
            'project' => $project->only('ulid', 'name'),
            'files' => $project->attachments()->visibleTo($user)
                ->with('task:id,ulid,title', 'uploader:id,name')
                ->latest()
                ->get(['id', 'ulid', 'task_id', 'uploaded_by', 'original_name', 'mime', 'size', 'is_internal', 'created_at']),
            'tasks' => $project->tasks()->visibleTo($user)->whereNull('parent_id')->orderBy('title')->get(['ulid', 'title']),
            'can' => [
                'upload' => $user->can('create', [Task::class, $project]) || $user->role === Role::Client,
                'internal' => $user->isInternal(),
                'clientOnly' => $user->role === Role::Client,
            ],
        ]);
    }

    public function store(Request $request, Project $project)
    {
        $this->authorize('view', $project);
        $user = $request->user();

        $data = $request->validate([
            'file' => ['required', 'file', 'max:20480', 'mimes:'.self::ALLOWED],
            'task' => ['nullable', Rule::exists('tasks', 'ulid')->where('project_id', $project->id)->whereNull('deleted_at')],
            'is_internal' => ['sometimes', 'boolean'],
        ]);

        $task = ! empty($data['task']) ? $project->tasks()->where('ulid', $data['task'])->firstOrFail() : null;

        if ($task) {
            $this->authorize('comment', $task);
        } else {
            $this->authorize('create', [Task::class, $project]);
        }

        $internal = $user->isInternal()
            ? (bool) (($task?->is_internal) || $request->boolean('is_internal', true))
            : false;

        $file = $request->file('file');
        $ext = strtolower($file->guessExtension() ?: $file->getClientOriginalExtension());

        $attachment = new Attachment;
        $attachment->project_id = $project->id;
        $attachment->task_id = $task?->id;
        $attachment->uploaded_by = $user->id;
        $attachment->original_name = Str::limit(preg_replace('/[^\w.\- ]/u', '_', basename($file->getClientOriginalName())), 150, '');
        $attachment->path = $file->storeAs("attachments/{$project->ulid}", Str::ulid().'.'.$ext, 'local');
        $attachment->mime = $file->getMimeType() ?: 'application/octet-stream';
        $attachment->size = $file->getSize();
        $attachment->is_internal = $internal;
        $attachment->save();

        return back()->with('success', 'File uploaded.');
    }

    public function download(Request $request, Project $project, Attachment $attachment)
    {
        $this->authorize('view', $project);
        abort_unless(Attachment::visibleTo($request->user())->whereKey($attachment->id)->exists(), 404);
        abort_unless(Storage::disk('local')->exists($attachment->path), 404);

        return Storage::disk('local')->download($attachment->path, $attachment->original_name, [
            'X-Content-Type-Options' => 'nosniff',
            'Cache-Control' => 'private, no-store',
        ]);
    }

    public function destroy(Request $request, Project $project, Attachment $attachment)
    {
        $this->authorize('view', $project);
        $user = $request->user();

        $mayDelete = $user->role === Role::Admin
            || $attachment->uploaded_by === $user->id
            || $project->owner_id === $user->id;

        abort_unless($mayDelete && Attachment::visibleTo($user)->whereKey($attachment->id)->exists(), 403);
        $attachment->delete();

        return back()->with('success', 'File removed.');
    }
}
__EOF__

w app/Http/Controllers/TimeEntryController.php <<'__EOF__'
<?php

namespace App\Http\Controllers;

use App\Enums\Role;
use App\Models\{Project, Task, TimeEntry};
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Validation\Rule;
use Inertia\Inertia;

class TimeEntryController extends Controller
{
    public function index(Request $request, Project $project)
    {
        $this->authorize('view', $project);
        $user = $request->user();
        abort_unless($user->isInternal(), 404);

        $seesAll = $user->can('manage-finance') || $project->owner_id === $user->id;

        $entries = $project->timeEntries()
            ->when(! $seesAll, fn ($q) => $q->where('user_id', $user->id))
            ->with('user:id,name', 'task:id,ulid,title')
            ->orderByDesc('entry_date')->orderByDesc('id')
            ->paginate(25);

        return Inertia::render('Time/Index', [
            'project' => $project->only('ulid', 'name'),
            'entries' => $entries,
            'tasks' => $project->tasks()->orderBy('title')->get(['ulid', 'title']),
            'totals' => [
                'minutes' => (int) $project->timeEntries()->when(! $seesAll, fn ($q) => $q->where('user_id', $user->id))->sum('minutes'),
                'scope' => $seesAll ? 'Everyone' : 'You',
            ],
            'can' => ['log' => $user->can('create', [Task::class, $project])],
            'me' => $user->id,
            'isAdmin' => $user->role === Role::Admin,
        ]);
    }

    public function store(Request $request, Project $project)
    {
        $this->authorize('create', [Task::class, $project]);

        $data = $request->validate([
            'entry_date' => ['required', 'date', 'before_or_equal:today'],
            'minutes' => ['required', 'integer', 'min:1', 'max:1440'],
            'description' => ['nullable', 'string', 'max:500'],
            'is_billable' => ['required', 'boolean'],
            'task' => ['nullable', Rule::exists('tasks', 'ulid')->where('project_id', $project->id)->whereNull('deleted_at')],
        ]);

        $entry = new TimeEntry(Arr::only($data, ['entry_date', 'minutes', 'description', 'is_billable']));
        $entry->project_id = $project->id;
        $entry->user_id = $request->user()->id;
        $entry->task_id = ! empty($data['task']) ? $project->tasks()->where('ulid', $data['task'])->value('id') : null;
        $entry->save();

        return back()->with('success', 'Time logged.');
    }

    public function destroy(Request $request, Project $project, TimeEntry $timeEntry)
    {
        $this->authorize('create', [Task::class, $project]);
        $user = $request->user();

        abort_unless($timeEntry->user_id === $user->id || $user->role === Role::Admin, 403);
        abort_if($timeEntry->invoice_id, 422, 'This entry is on an invoice and is locked.');

        $timeEntry->delete();

        return back()->with('success', 'Entry deleted.');
    }
}
__EOF__

w app/Http/Controllers/InvoiceController.php <<'__EOF__'
<?php

namespace App\Http\Controllers;

use App\Enums\Role;
use App\Models\{Invoice, Project, TimeEntry};
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;

class InvoiceController extends Controller
{
    public function index(Request $request, Project $project)
    {
        $this->authorize('view', $project);
        $user = $request->user();
        $manage = $user->can('manage-finance');
        abort_unless($manage || $user->role === Role::Client, 404);

        return Inertia::render('Invoices/Index', [
            'project' => $project->only('ulid', 'name'),
            'invoices' => $project->invoices()
                ->when(! $manage, fn ($q) => $q->whereIn('status', ['sent', 'paid']))
                ->latest('id')
                ->get(['ulid', 'number', 'status', 'issued_on', 'due_on', 'total']),
            'can' => ['manage' => $manage],
            'unbilledMinutes' => $manage
                ? (int) $project->timeEntries()->where('is_billable', true)->whereNull('invoice_id')->sum('minutes')
                : 0,
            'hourlyRate' => $manage ? $project->hourly_rate : null,
        ]);
    }

    public function store(Request $request, Project $project)
    {
        $this->authorize('manage-finance');
        $this->authorize('view', $project);

        $data = $request->validate([
            'due_on' => ['nullable', 'date', 'after_or_equal:today'],
            'notes' => ['nullable', 'string', 'max:2000'],
            'include_time' => ['required', 'boolean'],
            'items' => ['array', 'max:50'],
            'items.*.description' => ['required', 'string', 'max:255'],
            'items.*.quantity' => ['required', 'numeric', 'gt:0', 'max:100000'],
            'items.*.unit_price' => ['required', 'numeric', 'min:0', 'max:10000000'],
        ]);

        $invoice = DB::transaction(function () use ($request, $project, $data) {
            $lines = $data['items'] ?? [];
            $entryIds = [];

            if ($data['include_time']) {
                if (! $project->hourly_rate) {
                    throw ValidationException::withMessages(['include_time' => 'Set an hourly rate on the project first.']);
                }
                $entries = $project->timeEntries()
                    ->where('is_billable', true)->whereNull('invoice_id')
                    ->lockForUpdate()->with('user:id,name')->get();

                foreach ($entries->groupBy('user_id') as $group) {
                    $lines[] = [
                        'description' => $group->first()->user->name.' – billable time (hours)',
                        'quantity' => round($group->sum('minutes') / 60, 2),
                        'unit_price' => $project->hourly_rate,
                    ];
                }
                $entryIds = $entries->pluck('id')->all();
            }

            if (! $lines) {
                throw ValidationException::withMessages(['items' => 'Add at least one line, or include unbilled time.']);
            }

            $invoice = new Invoice(Arr::only($data, ['due_on', 'notes']));
            $invoice->project_id = $project->id;
            $invoice->created_by = $request->user()->id;
            $invoice->status = 'draft';
            $invoice->number = (string) Str::ulid();
            $invoice->save();

            $invoice->items()->createMany(array_map(fn ($l) => Arr::only($l, ['description', 'quantity', 'unit_price']), $lines));

            $invoice->number = sprintf('INV-%d-%05d', now()->year, $invoice->id);
            $invoice->total = round(collect($lines)->sum(fn ($l) => (float) $l['quantity'] * (float) $l['unit_price']), 2);
            $invoice->save();

            if ($entryIds) {
                TimeEntry::whereIn('id', $entryIds)->update(['invoice_id' => $invoice->id]);
            }

            return $invoice;
        });

        return to_route('projects.invoices.show', [$project, $invoice])->with('success', 'Draft invoice created.');
    }

    public function show(Request $request, Project $project, Invoice $invoice)
    {
        $this->authorize('view', $invoice);
        $user = $request->user();
        $invoice->load('items');

        return Inertia::render('Invoices/Show', [
            'project' => $project->only('ulid', 'name'),
            'client' => $project->client?->only('name', 'email'),
            'invoice' => $invoice->only('ulid', 'number', 'status', 'issued_on', 'due_on', 'notes', 'total'),
            'items' => $invoice->items->map->only('description', 'quantity', 'unit_price')->values(),
            'can' => ['manage' => $user->can('manage', $invoice)],
        ]);
    }

    public function status(Request $request, Project $project, Invoice $invoice)
    {
        $this->authorize('manage', $invoice);
        $new = $request->validate(['status' => ['required', 'in:sent,paid,void']])['status'];

        $allowed = ['draft' => ['sent', 'void'], 'sent' => ['paid', 'void'], 'paid' => [], 'void' => []];
        abort_unless(in_array($new, $allowed[$invoice->status], true), 422, 'That status change is not allowed.');

        DB::transaction(function () use ($invoice, $new) {
            if ($new === 'sent') {
                $invoice->issued_on = today();
                $invoice->due_on ??= today()->addDays(14);
            }
            if ($new === 'void') {
                $invoice->timeEntries()->update(['invoice_id' => null]);
            }
            $invoice->status = $new;
            $invoice->save();
        });

        return back()->with('success', "Invoice marked {$new}.");
    }

    public function destroy(Project $project, Invoice $invoice)
    {
        $this->authorize('manage', $invoice);
        abort_unless($invoice->status === 'draft', 422, 'Only drafts can be deleted. Void it instead.');

        DB::transaction(function () use ($invoice) {
            $invoice->timeEntries()->update(['invoice_id' => null]);
            $invoice->delete();
        });

        return to_route('projects.invoices.index', $project)->with('success', 'Draft deleted.');
    }
}
__EOF__

w app/Http/Controllers/ReportController.php <<'__EOF__'
<?php

namespace App\Http\Controllers;

use App\Models\Project;
use App\Support\Csv;
use Illuminate\Http\Request;
use Inertia\Inertia;

class ReportController extends Controller
{
    public function index(Request $request)
    {
        $this->authorize('manage-finance');

        $rows = Project::query()
            ->visibleTo($request->user())
            ->with('client:id,name')
            ->withSum('timeEntries as total_minutes', 'minutes')
            ->withSum(['timeEntries as billable_minutes' => fn ($q) => $q->where('is_billable', true)], 'minutes')
            ->withSum(['invoices as invoiced_total' => fn ($q) => $q->whereIn('status', ['sent', 'paid'])], 'total')
            ->withSum(['invoices as paid_total' => fn ($q) => $q->where('status', 'paid')], 'total')
            ->orderBy('name')
            ->get()
            ->map(function ($p) {
                $billableHours = ($p->billable_minutes ?? 0) / 60;
                $cost = $p->hourly_rate ? round($billableHours * (float) $p->hourly_rate, 2) : null;
                $budget = $p->budget !== null ? (float) $p->budget : null;

                return [
                    'ulid' => $p->ulid,
                    'name' => $p->name,
                    'client' => $p->client?->name,
                    'status' => $p->status,
                    'hours' => round(($p->total_minutes ?? 0) / 60, 1),
                    'billable_hours' => round($billableHours, 1),
                    'budget' => $budget,
                    'cost' => $cost,
                    'burn' => ($budget && $cost !== null) ? round($cost / $budget * 100) : null,
                    'invoiced' => (float) ($p->invoiced_total ?? 0),
                    'paid' => (float) ($p->paid_total ?? 0),
                ];
            });

        return Inertia::render('Reports/Index', ['rows' => $rows]);
    }

    public function timeCsv(Project $project)
    {
        $this->authorize('manage-finance');
        $this->authorize('view', $project);

        return response()->streamDownload(function () use ($project) {
            $out = fopen('php://output', 'w');
            Csv::row($out, ['Date', 'User', 'Task', 'Description', 'Minutes', 'Billable', 'Invoice']);

            $project->timeEntries()
                ->with('user:id,name', 'task:id,title', 'invoice:id,number')
                ->orderBy('entry_date')
                ->lazy()
                ->each(fn ($e) => Csv::row($out, [
                    $e->entry_date->toDateString(), $e->user->name, $e->task?->title, $e->description,
                    $e->minutes, $e->is_billable ? 'yes' : 'no', $e->invoice?->number,
                ]));

            fclose($out);
        }, "time-{$project->ulid}.csv", ['Content-Type' => 'text/csv']);
    }
}
__EOF__

w app/Http/Controllers/ActivityController.php <<'__EOF__'
<?php

namespace App\Http\Controllers;

use App\Enums\TaskStatus;
use App\Models\{Project, Task};
use Illuminate\Http\Request;
use Inertia\Inertia;
use Spatie\Activitylog\Models\Activity;

class ActivityController extends Controller
{
    public function index(Request $request, Project $project)
    {
        $this->authorize('view', $project);
        $user = $request->user();
        $staff = $user->isInternal();
        $seesMoney = $user->can('manage-finance') || $project->owner_id === $user->id;

        $query = Activity::query()->with('causer:id,name');

        if ($staff) {
            $query->where(function ($q) use ($project) {
                $q->where(fn ($w) => $w->where('subject_type', $project->getMorphClass())->where('subject_id', $project->id))
                  ->orWhereHasMorph('subject', [Task::class], fn ($t) => $t->withTrashed()->where('project_id', $project->id));
            });
        } else {
            // Clients: only status moves on tasks that are currently client-visible.
            $query->where('event', 'updated')
                ->whereNotNull('properties->attributes->status')
                ->whereHasMorph('subject', [Task::class], fn ($t) => $t->where('project_id', $project->id)->where('is_internal', false));
        }

        $entries = $query->latest('id')->paginate(30)->through(function (Activity $a) use ($staff, $seesMoney) {
            $attrs = $a->properties['attributes'] ?? [];
            $old = $a->properties['old'] ?? [];

            if (! $staff) {
                $status = TaskStatus::tryFrom($attrs['status'] ?? '');

                return [
                    'id' => $a->id,
                    'at' => $a->created_at->toIso8601String(),
                    'who' => 'Team',
                    'text' => '“'.($a->subject?->title ?? 'A task').'” moved to '.str($status?->name ?? 'a new status')->headline(),
                    'changes' => [],
                ];
            }

            if (! $seesMoney) {
                unset($attrs['budget'], $attrs['hourly_rate']);
            }

            $changes = collect($attrs)->mapWithKeys(fn ($v, $k) => [$k => ['from' => $old[$k] ?? null, 'to' => $v]]);
            $title = $attrs['title'] ?? $a->subject?->title ?? $a->subject?->name ?? '';

            return [
                'id' => $a->id,
                'at' => $a->created_at->toIso8601String(),
                'who' => $a->causer?->name ?? 'System',
                'text' => trim($a->event.' '.strtolower(class_basename($a->subject_type)).' '.$title),
                'changes' => $changes,
            ];
        });

        return Inertia::render('Activity/Index', [
            'project' => $project->only('ulid', 'name'),
            'entries' => $entries,
        ]);
    }
}
__EOF__

w app/Http/Controllers/NotificationController.php <<'__EOF__'
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Inertia\Inertia;

class NotificationController extends Controller
{
    public function index(Request $request)
    {
        return Inertia::render('Notifications/Index', [
            'notifications' => $request->user()->notifications()->latest()->paginate(20)->through(fn ($n) => [
                'id' => $n->id,
                'text' => $n->data['text'] ?? '',
                'read' => $n->read_at !== null,
                'at' => $n->created_at->toIso8601String(),
            ]),
        ]);
    }

    public function read(Request $request, string $id)
    {
        $n = $request->user()->notifications()->findOrFail($id);
        $n->markAsRead();
        $url = $n->data['url'] ?? '/notifications';

        return redirect(str_starts_with($url, '/') && ! str_starts_with($url, '//') ? $url : '/notifications');
    }

    public function readAll(Request $request)
    {
        $request->user()->unreadNotifications->markAsRead();

        return back();
    }
}
__EOF__

w app/Http/Controllers/ApiTokenController.php <<'__EOF__'
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Inertia\Inertia;

class ApiTokenController extends Controller
{
    public function index(Request $request)
    {
        abort_unless($request->user()->isInternal(), 404);

        return Inertia::render('Settings/ApiTokens', [
            'tokens' => $request->user()->tokens()->latest()->get(['id', 'name', 'last_used_at', 'expires_at', 'created_at']),
            'newToken' => session('newToken'),
        ]);
    }

    public function store(Request $request)
    {
        abort_unless($request->user()->isInternal(), 403);
        $data = $request->validate(['name' => ['required', 'string', 'max:60']]);

        $token = $request->user()->createToken($data['name'], ['read'], now()->addDays(90));

        return back()->with('newToken', $token->plainTextToken);
    }

    public function destroy(Request $request, int $tokenId)
    {
        $request->user()->tokens()->whereKey($tokenId)->delete();

        return back()->with('success', 'Token revoked.');
    }
}
__EOF__

w app/Http/Controllers/DashboardController.php <<'__EOF__'
<?php

namespace App\Http\Controllers;

use App\Models\{Invoice, Milestone, Project, Task, TimeEntry, User};
use Illuminate\Http\Request;
use Inertia\Inertia;

class DashboardController extends Controller
{
    public function __invoke(Request $request)
    {
        $user = $request->user();

        return $user->isInternal() ? $this->staff($user) : $this->client($user);
    }

    private function staff(User $user)
    {
        $ids = Project::visibleTo($user)->pluck('id');

        $open = Task::whereIn('project_id', $ids)->where('assignee_id', $user->id)->where('status', '!=', 'done');

        return Inertia::render('Dashboard', [
            'mode' => 'staff',
            'stats' => [
                'open' => (clone $open)->count(),
                'overdue' => (clone $open)->whereNotNull('due_on')->where('due_on', '<', today())->count(),
                'hours' => round(TimeEntry::where('user_id', $user->id)
                    ->whereBetween('entry_date', [now()->startOfWeek()->toDateString(), now()->endOfWeek()->toDateString()])
                    ->sum('minutes') / 60, 1),
                'active' => Project::visibleTo($user)->where('status', 'active')->count(),
            ],
            'tasks' => (clone $open)->with('project:id,ulid,name')
                ->orderByRaw('due_on is null')->orderBy('due_on')->limit(10)->get()
                ->map(fn ($t) => [
                    'ulid' => $t->ulid, 'title' => $t->title, 'priority' => $t->priority->value, 'due_on' => $t->due_on?->toDateString(),
                    'project' => $t->project->only('ulid', 'name'),
                ]),
            'milestones' => $this->milestones($ids, $user),
            'projects' => [],
        ]);
    }

    private function client(User $user)
    {
        $projects = Project::visibleTo($user)
            ->withCount([
                'tasks as total' => fn ($q) => $q->whereNull('parent_id')->visibleTo($user),
                'tasks as done' => fn ($q) => $q->whereNull('parent_id')->visibleTo($user)->where('status', 'done'),
            ])->orderBy('name')->get();

        $ids = $projects->pluck('id');
        $outstanding = Invoice::whereIn('project_id', $ids)->where('status', 'sent');

        return Inertia::render('Dashboard', [
            'mode' => 'client',
            'stats' => ['invoices' => (clone $outstanding)->count(), 'outstanding' => (float) (clone $outstanding)->sum('total')],
            'tasks' => [],
            'milestones' => $this->milestones($ids, $user),
            'projects' => $projects->map(fn ($p) => [
                'ulid' => $p->ulid, 'name' => $p->name, 'status' => $p->status,
                'progress' => $p->total ? (int) round($p->done / $p->total * 100) : 0,
            ]),
        ]);
    }

    private function milestones($projectIds, User $user)
    {
        return Milestone::whereIn('project_id', $projectIds)->visibleTo($user)
            ->whereNull('completed_at')->whereNotNull('due_on')
            ->with('project:id,ulid,name')->orderBy('due_on')->limit(5)->get()
            ->map(fn ($m) => ['ulid' => $m->ulid, 'title' => $m->title, 'due_on' => $m->due_on->toDateString(), 'project' => $m->project->only('ulid', 'name')]);
    }
}
__EOF__

w app/Http/Controllers/Api/ProjectApiController.php <<'__EOF__'
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\Project;
use Illuminate\Http\Request;

class ProjectApiController extends Controller
{
    public function index(Request $request)
    {
        return Project::visibleTo($request->user())
            ->orderBy('name')
            ->paginate(50, ['ulid', 'name', 'status', 'starts_on', 'due_on']);
    }

    public function tasks(Request $request, Project $project)
    {
        $this->authorize('view', $project);
        $user = $request->user();

        return $project->tasks()->visibleTo($user)->whereNull('parent_id')->with('assignee:id,name')
            ->orderBy('position')->get()
            ->map(fn ($t) => [
                'id' => $t->ulid, 'title' => $t->title, 'status' => $t->status->value, 'priority' => $t->priority->value,
                'due_on' => $t->due_on?->toDateString(), 'assignee' => $t->assignee?->name,
            ]);
    }
}
__EOF__

# =====================================================================
say "Writing routes"
# =====================================================================
w routes/pm.php <<'__EOF__'
<?php

use App\Http\Controllers\Admin;
use App\Http\Controllers\{
    ActivityController, ApiTokenController, AttachmentController, ClientController, CommentController,
    DashboardController, InvitationAcceptController, InvoiceController, LabelController, MilestoneController,
    NotificationController, ProjectController, ProjectMemberController, ReportController, TaskController,
    TimeEntryController,
};
use Illuminate\Support\Facades\Route;

// Invitation acceptance (guests only)
Route::middleware(['guest', 'throttle:6,1'])->group(function () {
    Route::get('invite/{token}', [InvitationAcceptController::class, 'show'])->name('invitations.accept');
    Route::post('invite/{token}', [InvitationAcceptController::class, 'store']);
});

Route::middleware(['auth', 'verified'])->group(function () {
    Route::get('dashboard', DashboardController::class)->name('dashboard');

    Route::resource('projects', ProjectController::class);

    Route::scopeBindings()->prefix('projects/{project}')->name('projects.')->group(function () {
        Route::get('tasks', [TaskController::class, 'index'])->name('tasks.index');
        Route::post('tasks', [TaskController::class, 'store'])->name('tasks.store');
        Route::get('tasks/{task}', [TaskController::class, 'show'])->name('tasks.show');
        Route::patch('tasks/{task}', [TaskController::class, 'update'])->name('tasks.update');
        Route::patch('tasks/{task}/move', [TaskController::class, 'move'])->name('tasks.move');
        Route::delete('tasks/{task}', [TaskController::class, 'destroy'])->name('tasks.destroy');

        Route::post('tasks/{task}/comments', [CommentController::class, 'store'])
            ->middleware('throttle:30,1')->name('comments.store');
        Route::delete('tasks/{task}/comments/{comment}', [CommentController::class, 'destroy'])->name('comments.destroy');

        Route::post('labels', [LabelController::class, 'store'])->name('labels.store');
        Route::delete('labels/{label}', [LabelController::class, 'destroy'])->name('labels.destroy');

        Route::get('milestones', [MilestoneController::class, 'index'])->name('milestones.index');
        Route::post('milestones', [MilestoneController::class, 'store'])->name('milestones.store');
        Route::patch('milestones/{milestone}', [MilestoneController::class, 'update'])->name('milestones.update');
        Route::delete('milestones/{milestone}', [MilestoneController::class, 'destroy'])->name('milestones.destroy');

        Route::get('files', [AttachmentController::class, 'index'])->name('files.index');
        Route::post('files', [AttachmentController::class, 'store'])->middleware('throttle:20,1')->name('files.store');
        Route::get('files/{attachment}', [AttachmentController::class, 'download'])->name('files.download');
        Route::delete('files/{attachment}', [AttachmentController::class, 'destroy'])->name('files.destroy');

        Route::get('time', [TimeEntryController::class, 'index'])->name('time.index');
        Route::post('time', [TimeEntryController::class, 'store'])->name('time.store');
        Route::delete('time/{timeEntry}', [TimeEntryController::class, 'destroy'])->name('time.destroy');

        Route::get('invoices', [InvoiceController::class, 'index'])->name('invoices.index');
        Route::post('invoices', [InvoiceController::class, 'store'])->name('invoices.store');
        Route::get('invoices/{invoice}', [InvoiceController::class, 'show'])->name('invoices.show');
        Route::patch('invoices/{invoice}/status', [InvoiceController::class, 'status'])->name('invoices.status');
        Route::delete('invoices/{invoice}', [InvoiceController::class, 'destroy'])->name('invoices.destroy');

        Route::get('activity', [ActivityController::class, 'index'])->name('activity.index');
    });

    // {user} is not a child relation of the project, so no scoped bindings here.
    Route::post('projects/{project}/members', [ProjectMemberController::class, 'store'])->name('projects.members.store');
    Route::delete('projects/{project}/members/{user}', [ProjectMemberController::class, 'destroy'])->name('projects.members.destroy');

    Route::resource('clients', ClientController::class)->only(['index', 'store', 'update', 'destroy']);

    Route::get('reports', [ReportController::class, 'index'])->name('reports.index');
    Route::get('reports/projects/{project}/time.csv', [ReportController::class, 'timeCsv'])->name('reports.time');

    Route::get('notifications', [NotificationController::class, 'index'])->name('notifications.index');
    Route::post('notifications/read-all', [NotificationController::class, 'readAll'])->name('notifications.readAll');
    Route::post('notifications/{id}/read', [NotificationController::class, 'read'])->name('notifications.read');

    Route::get('settings/api-tokens', [ApiTokenController::class, 'index'])->name('api-tokens.index');
    Route::post('settings/api-tokens', [ApiTokenController::class, 'store'])->middleware('throttle:10,1')->name('api-tokens.store');
    Route::delete('settings/api-tokens/{tokenId}', [ApiTokenController::class, 'destroy'])->name('api-tokens.destroy');

    Route::prefix('admin')->name('admin.')->group(function () {
        Route::get('users', [Admin\UserController::class, 'index'])->name('users.index');
        Route::patch('users/{user}', [Admin\UserController::class, 'update'])->name('users.update');
        Route::post('invitations', [Admin\InvitationController::class, 'store'])->name('invitations.store');
        Route::delete('invitations/{invitation}', [Admin\InvitationController::class, 'destroy'])->name('invitations.destroy');
        Route::get('audit', [Admin\AuditController::class, 'index'])->name('audit.index');
        Route::get('audit/export', [Admin\AuditController::class, 'export'])->middleware('throttle:5,1')->name('audit.export');
    });
});
__EOF__

w routes/api.php <<'__EOF__'
<?php

use App\Http\Controllers\Api\ProjectApiController;
use Illuminate\Support\Facades\Route;

Route::prefix('v1')
    ->middleware(['auth:sanctum', 'abilities:read', 'api.staff', 'throttle:60,1'])
    ->group(function () {
        Route::get('projects', [ProjectApiController::class, 'index']);
        Route::get('projects/{project}/tasks', [ProjectApiController::class, 'tasks']);
    });
__EOF__

# =====================================================================
say "Writing frontend"
# =====================================================================
w resources/js/types/vuedraggable.d.ts <<'__EOF__'
declare module 'vuedraggable' {
  import type { DefineComponent } from 'vue'
  const draggable: DefineComponent<any, any, any>
  export default draggable
}
__EOF__

w resources/js/composables/usePmNav.ts <<'__EOF__'
import { usePage } from '@inertiajs/vue3'
import { Bell, Building2, FileText, FolderKanban, KeyRound, ShieldCheck, Users } from 'lucide-vue-next'
import { computed } from 'vue'
import type { NavItem } from '@/types'

/** Extends the starter kit's sidebar items with the project-management links the user may see. */
export function usePmNav(base: NavItem[]) {
  const page = usePage()

  return computed<NavItem[]>(() => {
    const a = (page.props.abilities ?? {}) as Record<string, boolean>
    const unread = (page.props.unreadNotifications as number) || 0

    return [
      ...base,
      { title: 'Projects', href: '/projects', icon: FolderKanban },
      ...(a.manageClients ? [{ title: 'Clients', href: '/clients', icon: Building2 }] : []),
      ...(a.finance ? [{ title: 'Reports', href: '/reports', icon: FileText }] : []),
      { title: unread ? `Notifications (${unread})` : 'Notifications', href: '/notifications', icon: Bell },
      ...(a.internal ? [{ title: 'API tokens', href: '/settings/api-tokens', icon: KeyRound }] : []),
      ...(a.manageUsers
        ? [
            { title: 'Users', href: '/admin/users', icon: Users },
            { title: 'Audit export', href: '/admin/audit', icon: ShieldCheck },
          ]
        : []),
    ] as NavItem[]
  })
}
__EOF__

w resources/js/components/ProjectTabs.vue <<'__EOF__'
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3'
import { computed } from 'vue'

const props = defineProps<{ ulid: string }>()
const page = usePage()
const a = computed(() => (page.props.abilities ?? {}) as Record<string, boolean>)
const base = computed(() => `/projects/${props.ulid}`)

const tabs = computed(() => [
  { label: 'Overview', href: base.value, show: true, exact: true },
  { label: 'Tasks', href: `${base.value}/tasks`, show: true },
  { label: 'Milestones', href: `${base.value}/milestones`, show: true },
  { label: 'Files', href: `${base.value}/files`, show: true },
  { label: 'Time', href: `${base.value}/time`, show: a.value.internal },
  { label: 'Invoices', href: `${base.value}/invoices`, show: a.value.finance || a.value.client },
  { label: 'Activity', href: `${base.value}/activity`, show: true },
].filter(t => t.show))

const active = (t: { href: string; exact?: boolean }) =>
  t.exact ? page.url.split('?')[0] === t.href : page.url.startsWith(t.href)
</script>

<template>
  <nav class="flex gap-1 overflow-x-auto border-b text-sm">
    <Link v-for="t in tabs" :key="t.href" :href="t.href"
          :class="active(t) ? 'border-b-2 border-indigo-600 font-medium text-indigo-600' : 'text-gray-500 hover:text-gray-800 dark:hover:text-gray-200'"
          class="whitespace-nowrap px-3 py-2">{{ t.label }}</Link>
  </nav>
</template>
__EOF__

w resources/js/pages/Invitations/Accept.vue <<'__EOF__'
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3'

const props = defineProps<{ email: string; token: string }>()
const form = useForm({ name: '', password: '', password_confirmation: '' })
const submit = () => form.post(`/invite/${props.token}`, { onFinish: () => form.reset('password', 'password_confirmation') })
</script>

<template>
  <Head title="Accept invitation" />
  <div class="flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-gray-950">
    <form @submit.prevent="submit" class="w-full max-w-sm space-y-4 rounded-xl bg-white p-6 shadow dark:bg-gray-900">
      <h1 class="text-xl font-semibold">Create your account</h1>
      <p class="text-sm text-gray-500">{{ props.email }}</p>
      <div>
        <label class="mb-1 block text-sm font-medium">Full name</label>
        <input v-model="form.name" type="text" autocomplete="name" required class="input w-full" />
        <p v-if="form.errors.name" class="mt-1 text-sm text-red-600">{{ form.errors.name }}</p>
      </div>
      <div>
        <label class="mb-1 block text-sm font-medium">Password</label>
        <input v-model="form.password" type="password" autocomplete="new-password" required class="input w-full" />
        <p v-if="form.errors.password" class="mt-1 text-sm text-red-600">{{ form.errors.password }}</p>
      </div>
      <div>
        <label class="mb-1 block text-sm font-medium">Confirm password</label>
        <input v-model="form.password_confirmation" type="password" autocomplete="new-password" required class="input w-full" />
      </div>
      <button :disabled="form.processing" class="btn-primary w-full">Create account</button>
    </form>
  </div>
</template>
__EOF__

w resources/js/pages/Admin/Users/Index.vue <<'__EOF__'
<script setup lang="ts">
import { Head, router, useForm } from '@inertiajs/vue3'
import AppLayout from '@/layouts/AppLayout.vue'

type U = { id: number; name: string; email: string; role: string; is_active: boolean; client_id: number | null; two_factor_confirmed_at: string | null; client?: { name: string } }
type Inv = { id: number; email: string; role: string; expires_at: string; client?: { name: string } }

defineProps<{ users: U[]; invitations: Inv[]; clients: { id: number; name: string }[]; roles: string[] }>()

const invite = useForm({ email: '', role: 'member', client_id: null as number | null })
const sendInvite = () => invite.post('/admin/invitations', { onSuccess: () => invite.reset() })

const save = (u: U, patch: Partial<U>) =>
  router.patch(`/admin/users/${u.id}`, { role: u.role, is_active: u.is_active, client_id: u.client_id, ...patch }, { preserveScroll: true })

const revoke = (i: Inv) => confirm(`Revoke invitation for ${i.email}?`) && router.delete(`/admin/invitations/${i.id}`, { preserveScroll: true })
</script>

<template>
  <Head title="Users" />
  <AppLayout>
    <div class="mx-auto max-w-5xl space-y-8 p-6">
      <section class="card">
        <h2 class="mb-3 font-semibold">Invite a user</h2>
        <form @submit.prevent="sendInvite" class="flex flex-wrap items-start gap-3">
          <div>
            <input v-model="invite.email" type="email" placeholder="email@company.com" required class="input" />
            <p v-if="invite.errors.email" class="mt-1 text-sm text-red-600">{{ invite.errors.email }}</p>
          </div>
          <select v-model="invite.role" class="input">
            <option v-for="r in roles" :key="r" :value="r">{{ r }}</option>
          </select>
          <div v-if="invite.role === 'client'">
            <select v-model="invite.client_id" required class="input">
              <option :value="null" disabled>Select client…</option>
              <option v-for="c in clients" :key="c.id" :value="c.id">{{ c.name }}</option>
            </select>
            <p v-if="invite.errors.client_id" class="mt-1 text-sm text-red-600">{{ invite.errors.client_id }}</p>
          </div>
          <button :disabled="invite.processing" class="btn-primary">Send invite</button>
        </form>
      </section>

      <section v-if="invitations.length">
        <h2 class="mb-2 font-semibold">Pending invitations</h2>
        <ul class="divide-y rounded-xl border">
          <li v-for="i in invitations" :key="i.id" class="flex items-center justify-between p-3 text-sm">
            <span>{{ i.email }} · {{ i.role }}<span v-if="i.client"> · {{ i.client.name }}</span></span>
            <button @click="revoke(i)" class="text-red-600 hover:underline">Revoke</button>
          </li>
        </ul>
      </section>

      <section>
        <h2 class="mb-2 font-semibold">Users</h2>
        <p v-if="($page.props.errors as any)?.role" class="mb-2 text-sm text-red-600">{{ ($page.props.errors as any).role }}</p>
        <div class="overflow-x-auto rounded-xl border">
          <table class="w-full text-left text-sm">
            <thead class="bg-gray-50 text-gray-500 dark:bg-gray-900">
              <tr><th class="p-3">Name</th><th class="p-3">Role</th><th class="p-3">2FA</th><th class="p-3">Active</th></tr>
            </thead>
            <tbody class="divide-y">
              <tr v-for="u in users" :key="u.id">
                <td class="p-3"><div class="font-medium">{{ u.name }}</div><div class="text-gray-500">{{ u.email }}</div></td>
                <td class="p-3">
                  <select :value="u.role" @change="save(u, { role: ($event.target as HTMLSelectElement).value })" class="input !w-auto !py-1">
                    <option v-for="r in roles" :key="r" :value="r">{{ r }}</option>
                  </select>
                  <span v-if="u.client" class="ml-2 text-gray-500">{{ u.client.name }}</span>
                </td>
                <td class="p-3">{{ u.two_factor_confirmed_at ? 'On' : 'Off' }}</td>
                <td class="p-3"><input type="checkbox" :checked="u.is_active" @change="save(u, { is_active: ($event.target as HTMLInputElement).checked })" /></td>
              </tr>
            </tbody>
          </table>
        </div>
        <p class="mt-2 text-xs text-gray-500">To switch someone to the client role, remove and re-invite them as a client.</p>
      </section>
    </div>
  </AppLayout>
</template>
__EOF__

w resources/js/pages/Admin/Audit.vue <<'__EOF__'
<script setup lang="ts">
import { Head } from '@inertiajs/vue3'
import AppLayout from '@/layouts/AppLayout.vue'

const today = new Date().toISOString().slice(0, 10)
const monthAgo = new Date(Date.now() - 30 * 864e5).toISOString().slice(0, 10)
</script>

<template>
  <Head title="Audit export" />
  <AppLayout>
    <div class="mx-auto max-w-md space-y-4 p-6">
      <h1 class="text-xl font-semibold">Audit log export</h1>
      <p class="text-sm text-gray-500">Downloads a CSV of every recorded change. The export itself is logged.</p>
      <form method="get" action="/admin/audit/export" class="card space-y-3">
        <label class="block text-sm">From <input name="from" type="date" :value="monthAgo" required class="input mt-1 w-full" /></label>
        <label class="block text-sm">To <input name="to" type="date" :value="today" required class="input mt-1 w-full" /></label>
        <button class="btn-primary">Download CSV</button>
      </form>
    </div>
  </AppLayout>
</template>
__EOF__

w resources/js/pages/Dashboard.vue <<'__EOF__'
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3'
import AppLayout from '@/layouts/AppLayout.vue'

defineProps<{
  mode: 'staff' | 'client'
  stats: Record<string, number>
  tasks: { ulid: string; title: string; priority: string; due_on: string | null; project: { ulid: string; name: string } }[]
  milestones: { ulid: string; title: string; due_on: string; project: { ulid: string; name: string } }[]
  projects: { ulid: string; name: string; status: string; progress: number }[]
}>()
</script>

<template>
  <Head title="Dashboard" />
  <AppLayout>
    <div class="space-y-6 p-6">
      <h1 class="text-xl font-semibold">Dashboard</h1>

      <div v-if="mode === 'staff'" class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
        <div class="card"><div class="text-2xl font-semibold">{{ stats.open }}</div><div class="text-sm text-gray-500">My open tasks</div></div>
        <div class="card"><div class="text-2xl font-semibold" :class="stats.overdue ? 'text-red-600' : ''">{{ stats.overdue }}</div><div class="text-sm text-gray-500">Overdue</div></div>
        <div class="card"><div class="text-2xl font-semibold">{{ stats.hours }}</div><div class="text-sm text-gray-500">Hours this week</div></div>
        <div class="card"><div class="text-2xl font-semibold">{{ stats.active }}</div><div class="text-sm text-gray-500">Active projects</div></div>
      </div>
      <div v-else class="grid gap-4 sm:grid-cols-2">
        <div class="card"><div class="text-2xl font-semibold">{{ stats.invoices }}</div><div class="text-sm text-gray-500">Invoices awaiting payment</div></div>
        <div class="card"><div class="text-2xl font-semibold">{{ stats.outstanding?.toFixed(2) }}</div><div class="text-sm text-gray-500">Outstanding</div></div>
      </div>

      <div class="grid gap-6 lg:grid-cols-2">
        <section v-if="mode === 'staff'" class="card">
          <h2 class="mb-2 font-semibold">My tasks</h2>
          <ul class="divide-y text-sm">
            <li v-for="t in tasks" :key="t.ulid" class="flex justify-between gap-2 py-2">
              <Link :href="`/projects/${t.project.ulid}/tasks/${t.ulid}`" class="hover:underline">{{ t.title }}<span class="block text-xs text-gray-500">{{ t.project.name }}</span></Link>
              <span class="text-gray-500">{{ t.due_on ?? '' }}</span>
            </li>
            <li v-if="!tasks.length" class="py-2 text-gray-500">Nothing assigned to you.</li>
          </ul>
        </section>

        <section v-else class="card">
          <h2 class="mb-2 font-semibold">Your projects</h2>
          <ul class="space-y-3 text-sm">
            <li v-for="p in projects" :key="p.ulid">
              <Link :href="`/projects/${p.ulid}`" class="font-medium hover:underline">{{ p.name }}</Link>
              <div class="mt-1 h-2 overflow-hidden rounded bg-gray-100 dark:bg-gray-800"><div class="h-full bg-indigo-600" :style="{ width: p.progress + '%' }" /></div>
              <span class="text-xs text-gray-500">{{ p.progress }}% complete</span>
            </li>
            <li v-if="!projects.length" class="text-gray-500">No projects yet.</li>
          </ul>
        </section>

        <section class="card">
          <h2 class="mb-2 font-semibold">Upcoming milestones</h2>
          <ul class="divide-y text-sm">
            <li v-for="m in milestones" :key="m.ulid" class="flex justify-between py-2">
              <Link :href="`/projects/${m.project.ulid}/milestones`" class="hover:underline">{{ m.title }}<span class="block text-xs text-gray-500">{{ m.project.name }}</span></Link>
              <span class="text-gray-500">{{ m.due_on }}</span>
            </li>
            <li v-if="!milestones.length" class="py-2 text-gray-500">None scheduled.</li>
          </ul>
        </section>
      </div>
    </div>
  </AppLayout>
</template>
__EOF__

w resources/js/pages/Projects/Index.vue <<'__EOF__'
<script setup lang="ts">
import { Head, Link, router } from '@inertiajs/vue3'
import { ref } from 'vue'
import AppLayout from '@/layouts/AppLayout.vue'

type P = {
  ulid: string; name: string; status: string; due_on: string | null
  tasks_total: number; tasks_done: number; client?: { name: string }
}
const props = defineProps<{
  projects: { data: P[]; prev_page_url: string | null; next_page_url: string | null }
  filters: { status?: string; q?: string }
  can: { create: boolean }
}>()

const q = ref(props.filters.q ?? '')
const status = ref(props.filters.status ?? '')
const apply = () => router.get('/projects', { q: q.value || undefined, status: status.value || undefined }, { preserveState: true })
const pct = (p: P) => (p.tasks_total ? Math.round((p.tasks_done / p.tasks_total) * 100) : 0)
</script>

<template>
  <Head title="Projects" />
  <AppLayout>
    <div class="space-y-4 p-6">
      <div class="flex flex-wrap items-center justify-between gap-3">
        <h1 class="text-xl font-semibold">Projects</h1>
        <Link v-if="can.create" href="/projects/create" class="btn-primary">New project</Link>
      </div>

      <form @submit.prevent="apply" class="flex flex-wrap gap-2">
        <input v-model="q" placeholder="Search…" class="input" />
        <select v-model="status" @change="apply" class="input">
          <option value="">All statuses</option>
          <option v-for="s in ['planning', 'active', 'on_hold', 'completed', 'archived']" :key="s" :value="s">{{ s.replace('_', ' ') }}</option>
        </select>
      </form>

      <div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
        <Link v-for="p in projects.data" :key="p.ulid" :href="`/projects/${p.ulid}`" class="card hover:shadow">
          <div class="flex items-start justify-between gap-2">
            <h2 class="font-semibold">{{ p.name }}</h2>
            <span class="rounded bg-gray-100 px-2 py-0.5 text-xs capitalize dark:bg-gray-800">{{ p.status.replace('_', ' ') }}</span>
          </div>
          <p class="mt-1 text-sm text-gray-500">{{ p.client?.name ?? 'Internal' }}<span v-if="p.due_on"> · due {{ p.due_on }}</span></p>
          <div class="mt-3 h-2 overflow-hidden rounded bg-gray-100 dark:bg-gray-800"><div class="h-full bg-indigo-600" :style="{ width: pct(p) + '%' }" /></div>
          <p class="mt-1 text-xs text-gray-500">{{ p.tasks_done }}/{{ p.tasks_total }} tasks · {{ pct(p) }}%</p>
        </Link>
      </div>
      <p v-if="!projects.data.length" class="text-gray-500">No projects found.</p>

      <div class="flex gap-3 text-sm">
        <Link v-if="projects.prev_page_url" :href="projects.prev_page_url" class="underline">← Previous</Link>
        <Link v-if="projects.next_page_url" :href="projects.next_page_url" class="underline">Next →</Link>
      </div>
    </div>
  </AppLayout>
</template>
__EOF__

w resources/js/pages/Projects/Form.vue <<'__EOF__'
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3'
import AppLayout from '@/layouts/AppLayout.vue'

const props = defineProps<{ project: any | null; clients: { id: number; name: string }[] }>()
const p = props.project

const form = useForm({
  name: p?.name ?? '',
  description: p?.description ?? '',
  client_id: p?.client_id ?? null,
  status: p?.status ?? 'planning',
  starts_on: p?.starts_on ?? '',
  due_on: p?.due_on ?? '',
  budget: p?.budget ?? '',
  hourly_rate: p?.hourly_rate ?? '',
})
const submit = () => (p ? form.patch(`/projects/${p.ulid}`) : form.post('/projects'))
</script>

<template>
  <Head :title="p ? 'Edit project' : 'New project'" />
  <AppLayout>
    <form @submit.prevent="submit" class="mx-auto max-w-2xl space-y-4 p-6">
      <h1 class="text-xl font-semibold">{{ p ? 'Edit project' : 'New project' }}</h1>

      <div>
        <label class="mb-1 block text-sm font-medium">Name</label>
        <input v-model="form.name" required maxlength="150" class="input w-full" />
        <p v-if="form.errors.name" class="mt-1 text-sm text-red-600">{{ form.errors.name }}</p>
      </div>
      <div>
        <label class="mb-1 block text-sm font-medium">Description</label>
        <textarea v-model="form.description" rows="4" class="input w-full" />
      </div>
      <div class="grid gap-3 sm:grid-cols-2">
        <div>
          <label class="mb-1 block text-sm font-medium">Client</label>
          <select v-model="form.client_id" class="input w-full">
            <option :value="null">Internal project</option>
            <option v-for="c in clients" :key="c.id" :value="c.id">{{ c.name }}</option>
          </select>
          <p v-if="form.errors.client_id" class="mt-1 text-sm text-red-600">{{ form.errors.client_id }}</p>
        </div>
        <div>
          <label class="mb-1 block text-sm font-medium">Status</label>
          <select v-model="form.status" class="input w-full">
            <option v-for="s in ['planning', 'active', 'on_hold', 'completed', 'archived']" :key="s" :value="s">{{ s.replace('_', ' ') }}</option>
          </select>
        </div>
        <div>
          <label class="mb-1 block text-sm font-medium">Starts</label>
          <input v-model="form.starts_on" type="date" class="input w-full" />
        </div>
        <div>
          <label class="mb-1 block text-sm font-medium">Due</label>
          <input v-model="form.due_on" type="date" class="input w-full" />
          <p v-if="form.errors.due_on" class="mt-1 text-sm text-red-600">{{ form.errors.due_on }}</p>
        </div>
        <div>
          <label class="mb-1 block text-sm font-medium">Budget</label>
          <input v-model="form.budget" type="number" step="0.01" min="0" class="input w-full" />
        </div>
        <div>
          <label class="mb-1 block text-sm font-medium">Hourly rate</label>
          <input v-model="form.hourly_rate" type="number" step="0.01" min="0" class="input w-full" />
        </div>
      </div>
      <button :disabled="form.processing" class="btn-primary">Save</button>
    </form>
  </AppLayout>
</template>
__EOF__

w resources/js/pages/Projects/Show.vue <<'__EOF__'
<script setup lang="ts">
import { Head, Link, router } from '@inertiajs/vue3'
import AppLayout from '@/layouts/AppLayout.vue'
import ProjectTabs from '@/components/ProjectTabs.vue'

const props = defineProps<{
  project: any
  stats: { total: number; done: number; overdue: number }
  milestones: { ulid: string; title: string; due_on: string | null; completed_at: string | null }[]
  can: { update: boolean; delete: boolean }
}>()

const pct = props.stats.total ? Math.round((props.stats.done / props.stats.total) * 100) : 0
const destroy = () => confirm('Delete this project?') && router.delete(`/projects/${props.project.ulid}`)
</script>

<template>
  <Head :title="project.name" />
  <AppLayout>
    <div class="space-y-6 p-6">
      <header class="flex flex-wrap items-start justify-between gap-3">
        <div>
          <h1 class="text-2xl font-semibold">{{ project.name }}</h1>
          <p class="text-sm text-gray-500">
            {{ project.client?.name ?? 'Internal' }} · <span class="capitalize">{{ project.status.replace('_', ' ') }}</span>
            <span v-if="project.due_on"> · due {{ project.due_on }}</span>
          </p>
        </div>
        <div v-if="can.update" class="flex gap-2">
          <Link :href="`/projects/${project.ulid}/edit`" class="btn">Edit</Link>
          <button v-if="can.delete" @click="destroy" class="btn-danger">Delete</button>
        </div>
      </header>

      <ProjectTabs :ulid="project.ulid" />

      <div class="grid gap-4 sm:grid-cols-3">
        <div class="card"><div class="text-2xl font-semibold">{{ pct }}%</div><div class="text-sm text-gray-500">{{ stats.done }}/{{ stats.total }} tasks done</div></div>
        <div class="card"><div class="text-2xl font-semibold" :class="stats.overdue ? 'text-red-600' : ''">{{ stats.overdue }}</div><div class="text-sm text-gray-500">Overdue</div></div>
        <div v-if="project.budget != null" class="card"><div class="text-2xl font-semibold">{{ project.budget }}</div><div class="text-sm text-gray-500">Budget</div></div>
      </div>

      <p v-if="project.description" class="whitespace-pre-wrap text-sm">{{ project.description }}</p>

      <section v-if="milestones.length" class="card">
        <h2 class="mb-2 font-semibold">Milestones</h2>
        <ul class="space-y-1 text-sm">
          <li v-for="m in milestones" :key="m.ulid" class="flex justify-between">
            <span :class="m.completed_at ? 'text-gray-400 line-through' : ''">{{ m.title }}</span>
            <span class="text-gray-500">{{ m.due_on ?? '' }}</span>
          </li>
        </ul>
      </section>

      <section v-if="project.members" class="card">
        <h2 class="mb-2 font-semibold">Team</h2>
        <p class="text-sm text-gray-600 dark:text-gray-400">{{ project.members.map((m: any) => m.name).join(', ') }}</p>
      </section>
    </div>
  </AppLayout>
</template>
__EOF__

w resources/js/pages/Tasks/Board.vue <<'__EOF__'
<script setup lang="ts">
import { Head, Link, router, useForm } from '@inertiajs/vue3'
import draggable from 'vuedraggable'
import { computed, reactive, ref, watch } from 'vue'
import AppLayout from '@/layouts/AppLayout.vue'
import ProjectTabs from '@/components/ProjectTabs.vue'

type Label = { id: number; name: string; color: string }
type Task = {
  ulid: string; title: string; priority: string; status: string; is_internal: boolean
  due_on: string | null; subtasks_count: number
  assignee: { id: number; name: string } | null; labels: Label[]
}

const props = defineProps<{
  project: { ulid: string; name: string }
  columns: Record<string, Task[]>
  statuses: { value: string; label: string }[]
  priorities: string[]
  members: { id: number; name: string }[]
  candidates: { id: number; name: string }[]
  labels: Label[]
  can: { create: boolean; update: boolean; manageMembers: boolean }
}>()

const base = `/projects/${props.project.ulid}`
const clone = () => JSON.parse(JSON.stringify(props.columns)) as Record<string, Task[]>
const cols = reactive<Record<string, Task[]>>(clone())
watch(() => props.columns, () => Object.assign(cols, clone()), { deep: true })

const view = ref<'board' | 'list'>('board')
const flat = computed(() => props.statuses.flatMap(s => cols[s.value] ?? []))

const priorityClass: Record<string, string> = {
  low: 'bg-gray-100 text-gray-700',
  medium: 'bg-blue-100 text-blue-700',
  high: 'bg-orange-100 text-orange-700',
  urgent: 'bg-red-100 text-red-700',
}
const overdue = (t: Task) => !!t.due_on && t.status !== 'done' && new Date(t.due_on) < new Date(new Date().toDateString())

const onChange = (status: string, e: any) => {
  const ev = e.added ?? e.moved
  if (!ev) return
  router.patch(`${base}/tasks/${ev.element.ulid}/move`, { status, position: ev.newIndex }, {
    preserveScroll: true,
    preserveState: true,
    onError: () => Object.assign(cols, clone()),
  })
}

const showForm = ref(false)
const form = useForm({
  title: '', description: '', status: 'todo', priority: 'medium',
  assignee_id: null as number | null, due_on: '', is_internal: true, label_ids: [] as number[],
})
const createTask = () => form.post(`${base}/tasks`, {
  preserveScroll: true,
  onSuccess: () => { form.reset(); showForm.value = false },
})

const labelForm = useForm({ name: '', color: '#6366f1' })
const addLabel = () => labelForm.post(`${base}/labels`, { preserveScroll: true, onSuccess: () => labelForm.reset('name') })
const removeLabel = (l: Label) => confirm(`Delete label "${l.name}"?`) && router.delete(`${base}/labels/${l.id}`, { preserveScroll: true })

const memberForm = useForm({ user_id: null as number | null, role: 'member' })
const addMember = () => memberForm.post(`${base}/members`, { preserveScroll: true, onSuccess: () => memberForm.reset() })
const removeMember = (m: { id: number; name: string }) => confirm(`Remove ${m.name} from this project?`) && router.delete(`${base}/members/${m.id}`, { preserveScroll: true })
</script>

<template>
  <Head :title="`${project.name} · Tasks`" />
  <AppLayout>
    <div class="space-y-6 p-6">
      <header class="flex flex-wrap items-center justify-between gap-3">
        <h1 class="text-xl font-semibold">{{ project.name }}</h1>
        <div class="flex items-center gap-2">
          <div class="inline-flex overflow-hidden rounded-lg border text-sm">
            <button v-for="v in (['board', 'list'] as const)" :key="v" @click="view = v"
                    :class="view === v ? 'bg-indigo-600 text-white' : ''" class="px-3 py-2 capitalize">{{ v }}</button>
          </div>
          <button v-if="can.create" @click="showForm = !showForm" class="btn-primary">New task</button>
        </div>
      </header>

      <ProjectTabs :ulid="project.ulid" />

      <form v-if="showForm" @submit.prevent="createTask" class="card grid gap-3 md:grid-cols-3">
        <div class="md:col-span-3">
          <input v-model="form.title" required maxlength="200" placeholder="Task title" class="input w-full" />
          <p v-if="form.errors.title" class="mt-1 text-sm text-red-600">{{ form.errors.title }}</p>
        </div>
        <textarea v-model="form.description" rows="3" placeholder="Description (optional)" class="input md:col-span-3" />
        <select v-model="form.status" class="input"><option v-for="s in statuses" :key="s.value" :value="s.value">{{ s.label }}</option></select>
        <select v-model="form.priority" class="input capitalize"><option v-for="p in priorities" :key="p" :value="p">{{ p }}</option></select>
        <input v-model="form.due_on" type="date" class="input" />
        <div>
          <select v-model="form.assignee_id" class="input w-full">
            <option :value="null">Unassigned</option>
            <option v-for="m in members" :key="m.id" :value="m.id">{{ m.name }}</option>
          </select>
          <p v-if="form.errors.assignee_id" class="mt-1 text-sm text-red-600">{{ form.errors.assignee_id }}</p>
        </div>
        <div class="flex flex-wrap items-center gap-2 md:col-span-2">
          <label v-for="l in labels" :key="l.id" class="flex items-center gap-1 text-sm"><input type="checkbox" :value="l.id" v-model="form.label_ids" /> {{ l.name }}</label>
        </div>
        <label class="flex items-center gap-2 text-sm md:col-span-2"><input type="checkbox" v-model="form.is_internal" /> Internal only (hidden from clients)</label>
        <button :disabled="form.processing" class="btn-primary">Create</button>
      </form>

      <div v-if="view === 'board'" class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
        <section v-for="s in statuses" :key="s.value" class="rounded-xl bg-gray-50 p-3 dark:bg-gray-900">
          <h2 class="mb-3 flex items-center justify-between text-sm font-semibold">{{ s.label }} <span class="text-gray-400">{{ cols[s.value]?.length ?? 0 }}</span></h2>
          <draggable v-model="cols[s.value]" group="tasks" item-key="ulid" :disabled="!can.update"
                     class="min-h-24 space-y-2" ghost-class="opacity-40" @change="onChange(s.value, $event)">
            <template #item="{ element: t }">
              <div class="cursor-grab rounded-lg border bg-white p-3 text-sm shadow-sm dark:bg-gray-800">
                <div class="flex items-start justify-between gap-2">
                  <Link :href="`${base}/tasks/${t.ulid}`" class="font-medium hover:underline">{{ t.title }}</Link>
                  <span v-if="t.is_internal" title="Internal only">🔒</span>
                </div>
                <div class="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
                  <span :class="priorityClass[t.priority]" class="rounded px-1.5 py-0.5 capitalize">{{ t.priority }}</span>
                  <span v-for="l in t.labels" :key="l.id" class="rounded px-1.5 py-0.5 text-white" :style="{ backgroundColor: l.color }">{{ l.name }}</span>
                  <span v-if="t.due_on" :class="overdue(t) ? 'font-semibold text-red-600' : 'text-gray-500'">{{ t.due_on }}</span>
                  <span v-if="t.subtasks_count" class="text-gray-500">☰ {{ t.subtasks_count }}</span>
                  <span v-if="t.assignee" class="ml-auto text-gray-500">{{ t.assignee.name }}</span>
                </div>
              </div>
            </template>
          </draggable>
        </section>
      </div>

      <div v-else class="overflow-x-auto rounded-xl border">
        <table class="w-full text-left text-sm">
          <thead class="bg-gray-50 text-gray-500 dark:bg-gray-900"><tr><th class="p-3">Task</th><th class="p-3">Status</th><th class="p-3">Priority</th><th class="p-3">Assignee</th><th class="p-3">Due</th></tr></thead>
          <tbody class="divide-y">
            <tr v-for="t in flat" :key="t.ulid">
              <td class="p-3"><Link :href="`${base}/tasks/${t.ulid}`" class="font-medium hover:underline">{{ t.title }}</Link> <span v-if="t.is_internal">🔒</span></td>
              <td class="p-3">{{ statuses.find(s => s.value === t.status)?.label }}</td>
              <td class="p-3 capitalize">{{ t.priority }}</td>
              <td class="p-3">{{ t.assignee?.name ?? '—' }}</td>
              <td class="p-3" :class="overdue(t) ? 'text-red-600' : ''">{{ t.due_on ?? '—' }}</td>
            </tr>
            <tr v-if="!flat.length"><td colspan="5" class="p-6 text-center text-gray-500">No tasks yet.</td></tr>
          </tbody>
        </table>
      </div>

      <div v-if="can.create || can.manageMembers" class="grid gap-4 md:grid-cols-2">
        <section v-if="can.create" class="card">
          <h2 class="mb-3 font-semibold">Labels</h2>
          <div class="mb-3 flex flex-wrap gap-2">
            <span v-for="l in labels" :key="l.id" class="inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs text-white" :style="{ backgroundColor: l.color }">
              {{ l.name }} <button @click="removeLabel(l)" aria-label="Delete label">×</button>
            </span>
          </div>
          <form @submit.prevent="addLabel" class="flex gap-2">
            <input v-model="labelForm.name" required maxlength="50" placeholder="New label" class="input flex-1" />
            <input v-model="labelForm.color" type="color" class="h-9 w-10 rounded border" />
            <button :disabled="labelForm.processing" class="btn-primary">Add</button>
          </form>
          <p v-if="labelForm.errors.name" class="mt-1 text-sm text-red-600">{{ labelForm.errors.name }}</p>
        </section>

        <section class="card">
          <h2 class="mb-3 font-semibold">Team</h2>
          <ul class="mb-3 space-y-1 text-sm">
            <li v-for="m in members" :key="m.id" class="flex justify-between">
              {{ m.name }}
              <button v-if="can.manageMembers" @click="removeMember(m)" class="text-red-600 hover:underline">Remove</button>
            </li>
          </ul>
          <form v-if="can.manageMembers" @submit.prevent="addMember" class="flex gap-2">
            <select v-model="memberForm.user_id" required class="input flex-1">
              <option :value="null" disabled>Add member…</option>
              <option v-for="c in candidates" :key="c.id" :value="c.id">{{ c.name }}</option>
            </select>
            <select v-model="memberForm.role" class="input"><option>lead</option><option>member</option><option>viewer</option></select>
            <button :disabled="memberForm.processing" class="btn-primary">Add</button>
          </form>
        </section>
      </div>
    </div>
  </AppLayout>
</template>
__EOF__

w resources/js/pages/Tasks/Show.vue <<'__EOF__'
<script setup lang="ts">
import { Head, Link, router, useForm } from '@inertiajs/vue3'
import AppLayout from '@/layouts/AppLayout.vue'

type Label = { id: number; name: string; color: string }
type Sub = { ulid: string; title: string; status: string; is_internal: boolean; assignee: { name: string } | null }
type Comment = { ulid: string; body: string; is_internal: boolean; created_at: string; user: { id: number; name: string } }

const props = defineProps<{
  project: { ulid: string; name: string }
  task: {
    ulid: string; title: string; description: string | null; status: string; priority: string
    is_internal: boolean; due_on: string | null; assignee_id: number | null; parent_id: number | null
    assignee: { name: string } | null; creator: { name: string }; labels: Label[]
    parent: { ulid: string; title: string } | null
  }
  subtasks: Sub[]
  comments: Comment[]
  members: { id: number; name: string }[]
  labels: Label[]
  statuses: { value: string; label: string }[]
  priorities: string[]
  can: { update: boolean; delete: boolean; comment: boolean }
  isInternalUser: boolean
}>()

const base = `/projects/${props.project.ulid}`
const taskUrl = `${base}/tasks/${props.task.ulid}`

const form = useForm({
  title: props.task.title,
  description: props.task.description ?? '',
  status: props.task.status,
  priority: props.task.priority,
  assignee_id: props.task.assignee_id,
  due_on: props.task.due_on ?? '',
  is_internal: props.task.is_internal,
  label_ids: props.task.labels.map(l => l.id),
})
const save = () => form.patch(taskUrl, { preserveScroll: true })
const destroy = () => confirm('Delete this task and its subtasks?') && router.delete(taskUrl)

const sub = useForm({ title: '', priority: 'medium', is_internal: props.task.is_internal, parent: props.task.ulid })
const addSub = () => sub.post(`${base}/tasks`, { preserveScroll: true, onSuccess: () => sub.reset('title') })
const toggleSub = (s: Sub) =>
  router.patch(`${base}/tasks/${s.ulid}/move`, { status: s.status === 'done' ? 'todo' : 'done' }, { preserveScroll: true })

const comment = useForm({ body: '', is_internal: true })
const addComment = () => comment.post(`${taskUrl}/comments`, { preserveScroll: true, onSuccess: () => comment.reset('body') })
const removeComment = (c: Comment) => confirm('Delete this comment?') && router.delete(`${taskUrl}/comments/${c.ulid}`, { preserveScroll: true })
</script>

<template>
  <Head :title="task.title" />
  <AppLayout>
    <div class="mx-auto max-w-4xl space-y-6 p-6">
      <div class="text-sm text-gray-500">
        <Link :href="`${base}/tasks`" class="hover:underline">← {{ project.name }} / Tasks</Link>
        <template v-if="task.parent"> / <Link :href="`${base}/tasks/${task.parent.ulid}`" class="hover:underline">{{ task.parent.title }}</Link></template>
      </div>

      <form v-if="can.update" @submit.prevent="save" class="card grid gap-3 md:grid-cols-3">
        <div class="md:col-span-3">
          <input v-model="form.title" required maxlength="200" class="input w-full text-lg font-semibold" />
          <p v-if="form.errors.title" class="mt-1 text-sm text-red-600">{{ form.errors.title }}</p>
        </div>
        <textarea v-model="form.description" rows="5" class="input md:col-span-3" />
        <select v-model="form.status" class="input"><option v-for="s in statuses" :key="s.value" :value="s.value">{{ s.label }}</option></select>
        <select v-model="form.priority" class="input capitalize"><option v-for="p in priorities" :key="p" :value="p">{{ p }}</option></select>
        <input v-model="form.due_on" type="date" class="input" />
        <div>
          <select v-model="form.assignee_id" class="input w-full">
            <option :value="null">Unassigned</option>
            <option v-for="m in members" :key="m.id" :value="m.id">{{ m.name }}</option>
          </select>
          <p v-if="form.errors.assignee_id" class="mt-1 text-sm text-red-600">{{ form.errors.assignee_id }}</p>
        </div>
        <div class="flex flex-wrap items-center gap-2 md:col-span-2">
          <label v-for="l in labels" :key="l.id" class="flex items-center gap-1 text-sm"><input type="checkbox" :value="l.id" v-model="form.label_ids" /> {{ l.name }}</label>
        </div>
        <label class="flex items-center gap-2 text-sm md:col-span-2"><input type="checkbox" v-model="form.is_internal" /> Internal only (hidden from clients)</label>
        <div class="flex gap-2">
          <button :disabled="form.processing" class="btn-primary">Save</button>
          <button v-if="can.delete" type="button" @click="destroy" class="btn-danger">Delete</button>
        </div>
      </form>

      <div v-else class="card space-y-2">
        <h1 class="text-xl font-semibold">{{ task.title }}</h1>
        <p class="whitespace-pre-wrap text-sm">{{ task.description }}</p>
        <p class="text-sm text-gray-500">
          {{ statuses.find(s => s.value === task.status)?.label }} · <span class="capitalize">{{ task.priority }}</span>
          <span v-if="task.due_on"> · due {{ task.due_on }}</span>
          <span v-if="task.assignee"> · {{ task.assignee.name }}</span>
        </p>
      </div>

      <section v-if="!task.parent_id" class="card">
        <h2 class="mb-3 font-semibold">Subtasks</h2>
        <ul class="mb-3 space-y-1 text-sm">
          <li v-for="s in subtasks" :key="s.ulid" class="flex items-center gap-2">
            <input type="checkbox" :checked="s.status === 'done'" :disabled="!can.update" @change="toggleSub(s)" />
            <Link :href="`${base}/tasks/${s.ulid}`" :class="s.status === 'done' ? 'text-gray-400 line-through' : ''" class="hover:underline">{{ s.title }}</Link>
            <span v-if="s.is_internal">🔒</span>
            <span v-if="s.assignee" class="ml-auto text-gray-500">{{ s.assignee.name }}</span>
          </li>
          <li v-if="!subtasks.length" class="text-gray-500">None yet.</li>
        </ul>
        <form v-if="can.update" @submit.prevent="addSub" class="flex gap-2">
          <input v-model="sub.title" required maxlength="200" placeholder="Add a subtask" class="input flex-1" />
          <button :disabled="sub.processing" class="btn-primary">Add</button>
        </form>
      </section>

      <section class="card">
        <h2 class="mb-3 font-semibold">Comments</h2>
        <ul class="mb-4 space-y-3">
          <li v-for="c in comments" :key="c.ulid"
              :class="c.is_internal ? 'border-amber-300 bg-amber-50 dark:bg-amber-950' : ''" class="rounded-lg border p-3 text-sm">
            <div class="mb-1 flex items-center justify-between text-xs text-gray-500">
              <span>{{ c.user.name }} · {{ new Date(c.created_at).toLocaleString() }}
                <span v-if="c.is_internal" class="ml-1 font-semibold text-amber-700">Internal</span></span>
              <button @click="removeComment(c)" class="text-red-600 hover:underline">Delete</button>
            </div>
            <p class="whitespace-pre-wrap">{{ c.body }}</p>
          </li>
          <li v-if="!comments.length" class="text-sm text-gray-500">No comments yet.</li>
        </ul>

        <form v-if="can.comment" @submit.prevent="addComment" class="space-y-2">
          <textarea v-model="comment.body" required maxlength="5000" rows="3" placeholder="Write a comment…" class="input w-full" />
          <p v-if="comment.errors.body" class="text-sm text-red-600">{{ comment.errors.body }}</p>
          <div class="flex items-center justify-between">
            <label v-if="isInternalUser && !task.is_internal" class="flex items-center gap-2 text-sm"><input type="checkbox" v-model="comment.is_internal" /> Internal note (hidden from client)</label>
            <span v-else-if="isInternalUser" class="text-sm text-gray-500">This task is internal, so comments are internal.</span>
            <span v-else />
            <button :disabled="comment.processing" class="btn-primary">Post</button>
          </div>
        </form>
      </section>
    </div>
  </AppLayout>
</template>
__EOF__

w resources/js/pages/Clients/Index.vue <<'__EOF__'
<script setup lang="ts">
import { Head, router, useForm } from '@inertiajs/vue3'
import { ref } from 'vue'
import AppLayout from '@/layouts/AppLayout.vue'

type C = { ulid: string; name: string; email: string | null; phone: string | null; notes: string | null; projects_count: number; users_count: number }
defineProps<{ clients: C[] }>()

const editing = ref<string | null>(null)
const form = useForm({ name: '', email: '', phone: '', notes: '' })

const edit = (c: C) => { editing.value = c.ulid; form.name = c.name; form.email = c.email ?? ''; form.phone = c.phone ?? ''; form.notes = c.notes ?? '' }
const cancel = () => { editing.value = null; form.reset(); form.clearErrors() }
const submit = () => {
  const opts = { preserveScroll: true, onSuccess: cancel }
  editing.value ? form.patch(`/clients/${editing.value}`, opts) : form.post('/clients', opts)
}
const remove = (c: C) => confirm(`Delete ${c.name}?`) && router.delete(`/clients/${c.ulid}`, { preserveScroll: true })
</script>

<template>
  <Head title="Clients" />
  <AppLayout>
    <div class="mx-auto max-w-5xl space-y-6 p-6">
      <h1 class="text-xl font-semibold">Clients</h1>

      <form @submit.prevent="submit" class="card grid gap-3 md:grid-cols-3">
        <div class="font-medium md:col-span-3">{{ editing ? 'Edit client' : 'Add client' }}</div>
        <div>
          <input v-model="form.name" required placeholder="Name" class="input w-full" />
          <p v-if="form.errors.name" class="mt-1 text-sm text-red-600">{{ form.errors.name }}</p>
        </div>
        <input v-model="form.email" type="email" placeholder="Email" class="input" />
        <input v-model="form.phone" placeholder="Phone" class="input" />
        <textarea v-model="form.notes" rows="2" placeholder="Internal notes" class="input md:col-span-3" />
        <div class="flex gap-2">
          <button :disabled="form.processing" class="btn-primary">Save</button>
          <button v-if="editing" type="button" @click="cancel" class="btn">Cancel</button>
        </div>
      </form>
      <p v-if="($page.props.errors as any)?.client" class="text-sm text-red-600">{{ ($page.props.errors as any).client }}</p>

      <div class="overflow-x-auto rounded-xl border">
        <table class="w-full text-left text-sm">
          <thead class="bg-gray-50 text-gray-500 dark:bg-gray-900"><tr><th class="p-3">Client</th><th class="p-3">Projects</th><th class="p-3">Users</th><th class="p-3" /></tr></thead>
          <tbody class="divide-y">
            <tr v-for="c in clients" :key="c.ulid">
              <td class="p-3"><div class="font-medium">{{ c.name }}</div><div class="text-gray-500">{{ c.email }}</div></td>
              <td class="p-3">{{ c.projects_count }}</td>
              <td class="p-3">{{ c.users_count }}</td>
              <td class="space-x-3 p-3 text-right">
                <button @click="edit(c)" class="text-indigo-600 hover:underline">Edit</button>
                <button @click="remove(c)" class="text-red-600 hover:underline">Delete</button>
              </td>
            </tr>
            <tr v-if="!clients.length"><td colspan="4" class="p-6 text-center text-gray-500">No clients yet. Create one, then invite client users from Users.</td></tr>
          </tbody>
        </table>
      </div>
    </div>
  </AppLayout>
</template>
__EOF__

w resources/js/pages/Milestones/Index.vue <<'__EOF__'
<script setup lang="ts">
import { Head, router, useForm } from '@inertiajs/vue3'
import AppLayout from '@/layouts/AppLayout.vue'
import ProjectTabs from '@/components/ProjectTabs.vue'

type M = { ulid: string; title: string; description: string | null; due_on: string | null; completed_at: string | null; is_internal: boolean }
const props = defineProps<{ project: { ulid: string; name: string }; milestones: M[]; can: { manage: boolean } }>()
const base = `/projects/${props.project.ulid}/milestones`

const form = useForm({ title: '', description: '', due_on: '', is_internal: true })
const add = () => form.post(base, { preserveScroll: true, onSuccess: () => form.reset() })
const toggle = (m: M) =>
  router.patch(`${base}/${m.ulid}`, { title: m.title, description: m.description, due_on: m.due_on, is_internal: m.is_internal, completed: !m.completed_at }, { preserveScroll: true })
const remove = (m: M) => confirm(`Delete "${m.title}"?`) && router.delete(`${base}/${m.ulid}`, { preserveScroll: true })
const late = (m: M) => !m.completed_at && !!m.due_on && new Date(m.due_on) < new Date(new Date().toDateString())
</script>

<template>
  <Head :title="`${project.name} · Milestones`" />
  <AppLayout>
    <div class="space-y-6 p-6">
      <h1 class="text-xl font-semibold">{{ project.name }}</h1>
      <ProjectTabs :ulid="project.ulid" />

      <form v-if="can.manage" @submit.prevent="add" class="card flex flex-wrap items-start gap-3">
        <div>
          <input v-model="form.title" required maxlength="150" placeholder="Milestone" class="input" />
          <p v-if="form.errors.title" class="mt-1 text-sm text-red-600">{{ form.errors.title }}</p>
        </div>
        <input v-model="form.due_on" type="date" class="input" />
        <label class="flex items-center gap-2 py-2 text-sm"><input type="checkbox" v-model="form.is_internal" /> Internal only</label>
        <button :disabled="form.processing" class="btn-primary">Add</button>
      </form>

      <ul class="divide-y rounded-xl border">
        <li v-for="m in milestones" :key="m.ulid" class="flex items-center gap-3 p-3 text-sm">
          <input type="checkbox" :checked="!!m.completed_at" :disabled="!can.manage" @change="toggle(m)" />
          <span :class="m.completed_at ? 'text-gray-400 line-through' : ''" class="font-medium">{{ m.title }}</span>
          <span v-if="m.is_internal" title="Internal only">🔒</span>
          <span class="ml-auto" :class="late(m) ? 'font-semibold text-red-600' : 'text-gray-500'">{{ m.due_on ?? '' }}</span>
          <button v-if="can.manage" @click="remove(m)" class="text-red-600 hover:underline">Delete</button>
        </li>
        <li v-if="!milestones.length" class="p-6 text-center text-gray-500">No milestones yet.</li>
      </ul>
    </div>
  </AppLayout>
</template>
__EOF__

w resources/js/pages/Files/Index.vue <<'__EOF__'
<script setup lang="ts">
import { Head, router, useForm } from '@inertiajs/vue3'
import AppLayout from '@/layouts/AppLayout.vue'
import ProjectTabs from '@/components/ProjectTabs.vue'

type F = { ulid: string; original_name: string; size: number; is_internal: boolean; created_at: string; task: { title: string } | null; uploader: { name: string } }
const props = defineProps<{
  project: { ulid: string; name: string }
  files: F[]
  tasks: { ulid: string; title: string }[]
  can: { upload: boolean; internal: boolean; clientOnly: boolean }
}>()
const base = `/projects/${props.project.ulid}/files`

const form = useForm({ file: null as File | null, task: '', is_internal: true })
const upload = () => form.post(base, { forceFormData: true, preserveScroll: true, onSuccess: () => form.reset() })
const remove = (f: F) => confirm(`Remove ${f.original_name}?`) && router.delete(`${base}/${f.ulid}`, { preserveScroll: true })
const size = (b: number) => (b > 1048576 ? (b / 1048576).toFixed(1) + ' MB' : Math.max(1, Math.round(b / 1024)) + ' KB')
</script>

<template>
  <Head :title="`${project.name} · Files`" />
  <AppLayout>
    <div class="space-y-6 p-6">
      <h1 class="text-xl font-semibold">{{ project.name }}</h1>
      <ProjectTabs :ulid="project.ulid" />

      <form v-if="can.upload" @submit.prevent="upload" class="card flex flex-wrap items-start gap-3">
        <div>
          <input type="file" required @input="form.file = ($event.target as HTMLInputElement).files?.[0] ?? null" />
          <p v-if="form.errors.file" class="mt-1 text-sm text-red-600">{{ form.errors.file }}</p>
          <p class="mt-1 text-xs text-gray-500">PDF, images, Office files, CSV, TXT, ZIP · max 20 MB</p>
        </div>
        <select v-model="form.task" :required="can.clientOnly" class="input">
          <option v-if="!can.clientOnly" value="">Project-level</option>
          <option v-else value="" disabled>Choose a task…</option>
          <option v-for="t in tasks" :key="t.ulid" :value="t.ulid">{{ t.title }}</option>
        </select>
        <label v-if="can.internal" class="flex items-center gap-2 py-2 text-sm"><input type="checkbox" v-model="form.is_internal" /> Internal only</label>
        <button :disabled="form.processing" class="btn-primary">Upload</button>
      </form>

      <ul class="divide-y rounded-xl border">
        <li v-for="f in files" :key="f.ulid" class="flex flex-wrap items-center gap-3 p-3 text-sm">
          <a :href="`${base}/${f.ulid}`" class="font-medium text-indigo-600 hover:underline">{{ f.original_name }}</a>
          <span v-if="f.is_internal" title="Internal only">🔒</span>
          <span class="text-gray-500">{{ size(f.size) }} · {{ f.uploader.name }}<span v-if="f.task"> · {{ f.task.title }}</span></span>
          <button @click="remove(f)" class="ml-auto text-red-600 hover:underline">Remove</button>
        </li>
        <li v-if="!files.length" class="p-6 text-center text-gray-500">No files yet.</li>
      </ul>
    </div>
  </AppLayout>
</template>
__EOF__

w resources/js/pages/Time/Index.vue <<'__EOF__'
<script setup lang="ts">
import { Head, Link, router, useForm } from '@inertiajs/vue3'
import AppLayout from '@/layouts/AppLayout.vue'
import ProjectTabs from '@/components/ProjectTabs.vue'

type E = { ulid: string; entry_date: string; minutes: number; description: string | null; is_billable: boolean; invoice_id: number | null; user: { id: number; name: string }; task: { title: string } | null }
const props = defineProps<{
  project: { ulid: string; name: string }
  entries: { data: E[]; prev_page_url: string | null; next_page_url: string | null }
  tasks: { ulid: string; title: string }[]
  totals: { minutes: number; scope: string }
  can: { log: boolean }
  me: number
  isAdmin: boolean
}>()
const base = `/projects/${props.project.ulid}/time`

const form = useForm({ entry_date: new Date().toISOString().slice(0, 10), hours: 1, task: '', description: '', is_billable: true })
const add = () =>
  form.transform(d => ({ ...d, minutes: Math.round(Number(d.hours) * 60) }))
    .post(base, { preserveScroll: true, onSuccess: () => form.reset('description') })
const remove = (e: E) => confirm('Delete this entry?') && router.delete(`${base}/${e.ulid}`, { preserveScroll: true })
const fmt = (m: number) => `${Math.floor(m / 60)}h ${m % 60}m`
</script>

<template>
  <Head :title="`${project.name} · Time`" />
  <AppLayout>
    <div class="space-y-6 p-6">
      <h1 class="text-xl font-semibold">{{ project.name }}</h1>
      <ProjectTabs :ulid="project.ulid" />

      <form v-if="can.log" @submit.prevent="add" class="card grid gap-3 md:grid-cols-4">
        <input v-model="form.entry_date" type="date" required class="input" />
        <div>
          <input v-model="form.hours" type="number" step="0.25" min="0.25" max="24" required class="input w-full" />
          <p v-if="form.errors.minutes" class="mt-1 text-sm text-red-600">{{ form.errors.minutes }}</p>
        </div>
        <select v-model="form.task" class="input">
          <option value="">No task</option>
          <option v-for="t in tasks" :key="t.ulid" :value="t.ulid">{{ t.title }}</option>
        </select>
        <label class="flex items-center gap-2 text-sm"><input type="checkbox" v-model="form.is_billable" /> Billable</label>
        <input v-model="form.description" maxlength="500" placeholder="What did you work on?" class="input md:col-span-3" />
        <button :disabled="form.processing" class="btn-primary">Log time</button>
      </form>

      <p class="text-sm text-gray-500">{{ totals.scope }}: <strong>{{ fmt(totals.minutes) }}</strong> logged on this project</p>

      <div class="overflow-x-auto rounded-xl border">
        <table class="w-full text-left text-sm">
          <thead class="bg-gray-50 text-gray-500 dark:bg-gray-900"><tr><th class="p-3">Date</th><th class="p-3">Who</th><th class="p-3">Task / note</th><th class="p-3">Time</th><th class="p-3" /></tr></thead>
          <tbody class="divide-y">
            <tr v-for="e in entries.data" :key="e.ulid">
              <td class="p-3">{{ e.entry_date }}</td>
              <td class="p-3">{{ e.user.name }}</td>
              <td class="p-3">{{ e.task?.title }} <span class="text-gray-500">{{ e.description }}</span></td>
              <td class="p-3">{{ fmt(e.minutes) }} <span v-if="!e.is_billable" class="text-xs text-gray-400">non-billable</span><span v-if="e.invoice_id" class="text-xs text-gray-400"> · invoiced</span></td>
              <td class="p-3 text-right"><button v-if="!e.invoice_id && (e.user.id === me || isAdmin)" @click="remove(e)" class="text-red-600 hover:underline">Delete</button></td>
            </tr>
            <tr v-if="!entries.data.length"><td colspan="5" class="p-6 text-center text-gray-500">No time logged.</td></tr>
          </tbody>
        </table>
      </div>
      <div class="flex gap-3 text-sm">
        <Link v-if="entries.prev_page_url" :href="entries.prev_page_url" class="underline">← Newer</Link>
        <Link v-if="entries.next_page_url" :href="entries.next_page_url" class="underline">Older →</Link>
      </div>
    </div>
  </AppLayout>
</template>
__EOF__

w resources/js/pages/Invoices/Index.vue <<'__EOF__'
<script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3'
import AppLayout from '@/layouts/AppLayout.vue'
import ProjectTabs from '@/components/ProjectTabs.vue'

type I = { ulid: string; number: string; status: string; issued_on: string | null; due_on: string | null; total: string }
const props = defineProps<{
  project: { ulid: string; name: string }
  invoices: I[]
  can: { manage: boolean }
  unbilledMinutes: number
  hourlyRate: string | null
}>()
const base = `/projects/${props.project.ulid}/invoices`

const form = useForm({
  due_on: '', notes: '', include_time: false,
  items: [] as { description: string; quantity: number; unit_price: number }[],
})
const addLine = () => form.items.push({ description: '', quantity: 1, unit_price: 0 })
const create = () => form.post(base, { preserveScroll: true })

const badge: Record<string, string> = {
  draft: 'bg-gray-100 text-gray-700', sent: 'bg-blue-100 text-blue-700',
  paid: 'bg-green-100 text-green-700', void: 'bg-red-100 text-red-700',
}
</script>

<template>
  <Head :title="`${project.name} · Invoices`" />
  <AppLayout>
    <div class="space-y-6 p-6">
      <h1 class="text-xl font-semibold">{{ project.name }}</h1>
      <ProjectTabs :ulid="project.ulid" />

      <form v-if="can.manage" @submit.prevent="create" class="card space-y-3">
        <h2 class="font-semibold">New invoice</h2>
        <label class="flex items-center gap-2 text-sm">
          <input type="checkbox" v-model="form.include_time" />
          Include unbilled time ({{ (unbilledMinutes / 60).toFixed(2) }} h<span v-if="hourlyRate"> @ {{ hourlyRate }}/h</span>)
        </label>
        <p v-if="form.errors.include_time" class="text-sm text-red-600">{{ form.errors.include_time }}</p>

        <div v-for="(l, i) in form.items" :key="i" class="grid gap-2 sm:grid-cols-[1fr_6rem_8rem_auto]">
          <input v-model="l.description" required maxlength="255" placeholder="Description" class="input" />
          <input v-model.number="l.quantity" type="number" step="0.01" min="0.01" class="input" />
          <input v-model.number="l.unit_price" type="number" step="0.01" min="0" class="input" />
          <button type="button" @click="form.items.splice(i, 1)" class="text-red-600">×</button>
        </div>
        <p v-if="form.errors.items" class="text-sm text-red-600">{{ form.errors.items }}</p>

        <div class="flex flex-wrap gap-2">
          <button type="button" @click="addLine" class="btn">+ Line</button>
          <input v-model="form.due_on" type="date" class="input" />
        </div>
        <textarea v-model="form.notes" rows="2" placeholder="Notes (shown on the invoice)" class="input w-full" />
        <button :disabled="form.processing" class="btn-primary">Create draft</button>
      </form>

      <div class="overflow-x-auto rounded-xl border">
        <table class="w-full text-left text-sm">
          <thead class="bg-gray-50 text-gray-500 dark:bg-gray-900"><tr><th class="p-3">Number</th><th class="p-3">Status</th><th class="p-3">Issued</th><th class="p-3">Due</th><th class="p-3 text-right">Total</th></tr></thead>
          <tbody class="divide-y">
            <tr v-for="i in invoices" :key="i.ulid">
              <td class="p-3"><Link :href="`${base}/${i.ulid}`" class="font-medium text-indigo-600 hover:underline">{{ i.number }}</Link></td>
              <td class="p-3"><span :class="badge[i.status]" class="rounded px-2 py-0.5 text-xs capitalize">{{ i.status }}</span></td>
              <td class="p-3">{{ i.issued_on ?? '—' }}</td>
              <td class="p-3">{{ i.due_on ?? '—' }}</td>
              <td class="p-3 text-right">{{ i.total }}</td>
            </tr>
            <tr v-if="!invoices.length"><td colspan="5" class="p-6 text-center text-gray-500">No invoices.</td></tr>
          </tbody>
        </table>
      </div>
    </div>
  </AppLayout>
</template>
__EOF__

w resources/js/pages/Invoices/Show.vue <<'__EOF__'
<script setup lang="ts">
import { Head, Link, router } from '@inertiajs/vue3'
import AppLayout from '@/layouts/AppLayout.vue'

const props = defineProps<{
  project: { ulid: string; name: string }
  client: { name: string; email: string | null } | null
  invoice: { ulid: string; number: string; status: string; issued_on: string | null; due_on: string | null; notes: string | null; total: string }
  items: { description: string; quantity: string; unit_price: string }[]
  can: { manage: boolean }
}>()
const url = `/projects/${props.project.ulid}/invoices/${props.invoice.ulid}`
const setStatus = (s: string) => confirm(`Mark invoice as ${s}?`) && router.patch(`${url}/status`, { status: s }, { preserveScroll: true })
const destroy = () => confirm('Delete this draft?') && router.delete(url)
const print = () => window.print()
const line = (i: { quantity: string; unit_price: string }) => (Number(i.quantity) * Number(i.unit_price)).toFixed(2)
</script>

<template>
  <Head :title="invoice.number" />
  <AppLayout>
    <div class="mx-auto max-w-3xl space-y-6 p-6">
      <div class="flex flex-wrap items-center justify-between gap-2 print:hidden">
        <Link :href="`/projects/${project.ulid}/invoices`" class="text-sm text-gray-500 hover:underline">← Invoices</Link>
        <div class="flex gap-2">
          <button @click="print" class="btn">Print / PDF</button>
          <template v-if="can.manage">
            <button v-if="invoice.status === 'draft'" @click="setStatus('sent')" class="btn-primary">Mark sent</button>
            <button v-if="invoice.status === 'sent'" @click="setStatus('paid')" class="rounded-lg bg-green-600 px-3 py-2 text-sm text-white">Mark paid</button>
            <button v-if="['draft', 'sent'].includes(invoice.status)" @click="setStatus('void')" class="btn-danger">Void</button>
            <button v-if="invoice.status === 'draft'" @click="destroy" class="btn">Delete</button>
          </template>
        </div>
      </div>

      <div class="space-y-6 rounded-xl border p-8">
        <div class="flex justify-between">
          <div><h1 class="text-2xl font-semibold">Invoice {{ invoice.number }}</h1><p class="text-sm capitalize text-gray-500">{{ invoice.status }}</p></div>
          <div class="text-right text-sm"><p>Issued: {{ invoice.issued_on ?? '—' }}</p><p>Due: {{ invoice.due_on ?? '—' }}</p></div>
        </div>
        <div class="text-sm"><p class="text-gray-500">Project</p><p>{{ project.name }}</p><p v-if="client">{{ client.name }}<span v-if="client.email"> · {{ client.email }}</span></p></div>

        <table class="w-full text-left text-sm">
          <thead class="border-b text-gray-500"><tr><th class="py-2">Description</th><th class="py-2 text-right">Qty</th><th class="py-2 text-right">Price</th><th class="py-2 text-right">Amount</th></tr></thead>
          <tbody class="divide-y">
            <tr v-for="(i, n) in items" :key="n"><td class="py-2">{{ i.description }}</td><td class="py-2 text-right">{{ i.quantity }}</td><td class="py-2 text-right">{{ i.unit_price }}</td><td class="py-2 text-right">{{ line(i) }}</td></tr>
          </tbody>
          <tfoot><tr><td colspan="3" class="pt-3 text-right font-semibold">Total</td><td class="pt-3 text-right font-semibold">{{ invoice.total }}</td></tr></tfoot>
        </table>
        <p v-if="invoice.notes" class="whitespace-pre-wrap text-sm text-gray-600">{{ invoice.notes }}</p>
      </div>
    </div>
  </AppLayout>
</template>
__EOF__

w resources/js/pages/Reports/Index.vue <<'__EOF__'
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3'
import AppLayout from '@/layouts/AppLayout.vue'

type R = { ulid: string; name: string; client: string | null; status: string; hours: number; billable_hours: number; budget: number | null; cost: number | null; burn: number | null; invoiced: number; paid: number }
defineProps<{ rows: R[] }>()
const burnClass = (b: number | null) => (b === null ? '' : b >= 100 ? 'text-red-600 font-semibold' : b >= 80 ? 'text-orange-600' : '')
</script>

<template>
  <Head title="Reports" />
  <AppLayout>
    <div class="space-y-4 p-6">
      <h1 class="text-xl font-semibold">Project reports</h1>
      <div class="overflow-x-auto rounded-xl border">
        <table class="w-full text-left text-sm">
          <thead class="bg-gray-50 text-gray-500 dark:bg-gray-900">
            <tr><th class="p-3">Project</th><th class="p-3">Hours</th><th class="p-3">Billable</th><th class="p-3">Budget</th><th class="p-3">Cost</th><th class="p-3">Burn</th><th class="p-3">Invoiced</th><th class="p-3">Paid</th><th class="p-3" /></tr>
          </thead>
          <tbody class="divide-y">
            <tr v-for="r in rows" :key="r.ulid">
              <td class="p-3"><Link :href="`/projects/${r.ulid}`" class="font-medium hover:underline">{{ r.name }}</Link><div class="text-gray-500">{{ r.client ?? 'Internal' }}</div></td>
              <td class="p-3">{{ r.hours }}</td>
              <td class="p-3">{{ r.billable_hours }}</td>
              <td class="p-3">{{ r.budget ?? '—' }}</td>
              <td class="p-3">{{ r.cost ?? '—' }}</td>
              <td class="p-3" :class="burnClass(r.burn)">{{ r.burn === null ? '—' : r.burn + '%' }}</td>
              <td class="p-3">{{ r.invoiced }}</td>
              <td class="p-3">{{ r.paid }}</td>
              <td class="p-3"><a :href="`/reports/projects/${r.ulid}/time.csv`" class="text-indigo-600 hover:underline">CSV</a></td>
            </tr>
            <tr v-if="!rows.length"><td colspan="9" class="p-6 text-center text-gray-500">No projects.</td></tr>
          </tbody>
        </table>
      </div>
    </div>
  </AppLayout>
</template>
__EOF__

w resources/js/pages/Activity/Index.vue <<'__EOF__'
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3'
import AppLayout from '@/layouts/AppLayout.vue'
import ProjectTabs from '@/components/ProjectTabs.vue'

type E = { id: number; at: string; who: string; text: string; changes: Record<string, { from: unknown; to: unknown }> }
defineProps<{
  project: { ulid: string; name: string }
  entries: { data: E[]; prev_page_url: string | null; next_page_url: string | null }
}>()
const show = (v: unknown) => (v === null || v === undefined || v === '' ? '—' : String(v))
</script>

<template>
  <Head :title="`${project.name} · Activity`" />
  <AppLayout>
    <div class="space-y-6 p-6">
      <h1 class="text-xl font-semibold">{{ project.name }}</h1>
      <ProjectTabs :ulid="project.ulid" />

      <ul class="divide-y rounded-xl border">
        <li v-for="e in entries.data" :key="e.id" class="p-3 text-sm">
          <div><strong>{{ e.who }}</strong> {{ e.text }}</div>
          <ul v-if="Object.keys(e.changes).length" class="mt-1 space-y-0.5 text-xs text-gray-500">
            <li v-for="(c, k) in e.changes" :key="k">{{ String(k).replace('_', ' ') }}: {{ show(c.from) }} → {{ show(c.to) }}</li>
          </ul>
          <div class="mt-1 text-xs text-gray-400">{{ new Date(e.at).toLocaleString() }}</div>
        </li>
        <li v-if="!entries.data.length" class="p-6 text-center text-gray-500">No activity yet.</li>
      </ul>
      <div class="flex gap-3 text-sm">
        <Link v-if="entries.prev_page_url" :href="entries.prev_page_url" class="underline">← Newer</Link>
        <Link v-if="entries.next_page_url" :href="entries.next_page_url" class="underline">Older →</Link>
      </div>
    </div>
  </AppLayout>
</template>
__EOF__

w resources/js/pages/Notifications/Index.vue <<'__EOF__'
<script setup lang="ts">
import { Head, Link, router } from '@inertiajs/vue3'
import AppLayout from '@/layouts/AppLayout.vue'

type N = { id: string; text: string; read: boolean; at: string }
defineProps<{ notifications: { data: N[]; prev_page_url: string | null; next_page_url: string | null } }>()
const open = (n: N) => router.post(`/notifications/${n.id}/read`)
</script>

<template>
  <Head title="Notifications" />
  <AppLayout>
    <div class="mx-auto max-w-2xl space-y-4 p-6">
      <div class="flex items-center justify-between">
        <h1 class="text-xl font-semibold">Notifications</h1>
        <button @click="router.post('/notifications/read-all')" class="text-sm text-indigo-600 hover:underline">Mark all read</button>
      </div>
      <ul class="divide-y rounded-xl border">
        <li v-for="n in notifications.data" :key="n.id">
          <button @click="open(n)" class="flex w-full items-start gap-3 p-3 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-900">
            <span :class="n.read ? 'bg-transparent' : 'bg-indigo-600'" class="mt-1.5 h-2 w-2 shrink-0 rounded-full" />
            <span><span :class="n.read ? 'text-gray-500' : 'font-medium'">{{ n.text }}</span>
              <span class="block text-xs text-gray-400">{{ new Date(n.at).toLocaleString() }}</span></span>
          </button>
        </li>
        <li v-if="!notifications.data.length" class="p-6 text-center text-gray-500">Nothing yet.</li>
      </ul>
      <div class="flex gap-3 text-sm">
        <Link v-if="notifications.prev_page_url" :href="notifications.prev_page_url" class="underline">← Newer</Link>
        <Link v-if="notifications.next_page_url" :href="notifications.next_page_url" class="underline">Older →</Link>
      </div>
    </div>
  </AppLayout>
</template>
__EOF__

w resources/js/pages/Settings/ApiTokens.vue <<'__EOF__'
<script setup lang="ts">
import { Head, router, useForm } from '@inertiajs/vue3'
import AppLayout from '@/layouts/AppLayout.vue'

type T = { id: number; name: string; last_used_at: string | null; expires_at: string | null }
defineProps<{ tokens: T[]; newToken: string | null }>()
const form = useForm({ name: '' })
const create = () => form.post('/settings/api-tokens', { preserveScroll: true, onSuccess: () => form.reset() })
const revoke = (t: T) => confirm(`Revoke "${t.name}"?`) && router.delete(`/settings/api-tokens/${t.id}`, { preserveScroll: true })
</script>

<template>
  <Head title="API tokens" />
  <AppLayout>
    <div class="mx-auto max-w-2xl space-y-6 p-6">
      <h1 class="text-xl font-semibold">API tokens</h1>
      <p class="text-sm text-gray-500">Read-only access to the projects and tasks you can see. Tokens expire after 90 days.</p>

      <div v-if="newToken" class="rounded-xl border border-amber-300 bg-amber-50 p-4 text-sm dark:bg-amber-950">
        <p class="mb-2 font-medium">Copy this token now. It won’t be shown again.</p>
        <code class="block break-all rounded bg-white p-2 dark:bg-gray-900">{{ newToken }}</code>
      </div>

      <form @submit.prevent="create" class="flex gap-2">
        <input v-model="form.name" required maxlength="60" placeholder="Token name" class="input flex-1" />
        <button :disabled="form.processing" class="btn-primary">Create</button>
      </form>

      <ul class="divide-y rounded-xl border">
        <li v-for="t in tokens" :key="t.id" class="flex items-center justify-between p-3 text-sm">
          <span>{{ t.name }}<span class="block text-xs text-gray-500">Last used: {{ t.last_used_at ? new Date(t.last_used_at).toLocaleString() : 'never' }} · expires {{ t.expires_at ? new Date(t.expires_at).toLocaleDateString() : '—' }}</span></span>
          <button @click="revoke(t)" class="text-red-600 hover:underline">Revoke</button>
        </li>
        <li v-if="!tokens.length" class="p-6 text-center text-gray-500">No tokens.</li>
      </ul>
    </div>
  </AppLayout>
</template>
__EOF__

# =====================================================================
say "Writing tests"
# =====================================================================
w tests/Helpers.php <<'__EOF__'
<?php

use App\Enums\Role;
use App\Enums\TaskStatus;
use App\Models\{Client, Project, Task, User};

pest()->beforeEach(fn () => $this->withoutVite())->in('Feature');

function userWith(Role $role, ?Client $client = null): User
{
    $u = User::factory()->create();
    $u->role = $role;
    $u->client_id = $client?->id;
    $u->is_active = true;
    if (in_array($role, [Role::Admin, Role::Manager], true)) {
        $u->two_factor_confirmed_at = now(); // satisfy RequireTwoFactor
    }
    $u->save();

    return $u;
}

function project(?Client $client = null, ?User $owner = null): Project
{
    $p = new Project(['name' => 'P', 'status' => 'active', 'client_id' => $client?->id]);
    $p->owner_id = ($owner ?? userWith(Role::Admin))->id;
    $p->save();

    return $p;
}

function taskIn(Project $project, bool $internal = true): Task
{
    $t = new Task(['title' => 'T', 'priority' => 'medium']);
    $t->project_id = $project->id;
    $t->created_by = $project->owner_id;
    $t->status = TaskStatus::Todo;
    $t->is_internal = $internal;
    $t->save();

    return $t;
}
__EOF__

w tests/Feature/AccessControlTest.php <<'__EOF__'
<?php

use App\Enums\Role;
use App\Models\{Client, Invitation, User};

it('hides other clients projects from a client user', function () {
    $a = Client::create(['name' => 'A']);
    $b = Client::create(['name' => 'B']);
    $mine = project($a);
    $theirs = project($b);
    $client = userWith(Role::Client, $a);

    $this->actingAs($client)->get("/projects/{$mine->ulid}")->assertOk();
    $this->actingAs($client)->get("/projects/{$theirs->ulid}")->assertForbidden();
});

it('stops members from seeing projects they are not on', function () {
    $p = project();
    $this->actingAs(userWith(Role::Member))->get("/projects/{$p->ulid}")->assertForbidden();
});

it('only lets admins invite', function () {
    $this->actingAs(userWith(Role::Manager))
        ->post('/admin/invitations', ['email' => 'x@y.com', 'role' => 'member'])
        ->assertForbidden();
});

it('rejects expired invitation tokens', function () {
    [$inv, $token] = Invitation::issue('new@y.com', Role::Member, null, userWith(Role::Admin));
    $inv->update(['expires_at' => now()->subMinute()]);

    $this->get("/invite/{$token}")->assertNotFound();
});

it('lets an invited user register once only', function () {
    [, $token] = Invitation::issue('new@y.com', Role::Member, null, userWith(Role::Admin));
    $payload = ['name' => 'New', 'password' => 'Str0ng-Passw0rd!x', 'password_confirmation' => 'Str0ng-Passw0rd!x'];

    $this->post("/invite/{$token}", $payload)->assertRedirect();
    expect(User::where('email', 'new@y.com')->exists())->toBeTrue();

    $this->app['auth']->forgetGuards();
    $this->flushSession();
    $this->post("/invite/{$token}", $payload)->assertNotFound();
});

it('blocks public registration', function () {
    $this->get('/register')->assertNotFound();
});

it('logs out deactivated users', function () {
    $u = userWith(Role::Member);
    $u->forceFill(['is_active' => false])->save();

    $this->actingAs($u)->get('/projects')->assertRedirect(route('login'));
});
__EOF__

w tests/Feature/TaskVisibilityTest.php <<'__EOF__'
<?php

use App\Enums\Role;
use App\Models\{Client, Comment, Task};
use Inertia\Testing\AssertableInertia as Assert;

it('hides internal tasks from clients', function () {
    $acme = Client::create(['name' => 'Acme']);
    $project = project($acme);
    $internal = taskIn($project, true);
    $public = taskIn($project, false);
    $client = userWith(Role::Client, $acme);

    $this->actingAs($client)->get("/projects/{$project->ulid}/tasks/{$internal->ulid}")->assertNotFound();
    $this->actingAs($client)->get("/projects/{$project->ulid}/tasks/{$public->ulid}")->assertOk();

    $this->actingAs($client)->get("/projects/{$project->ulid}/tasks")
        ->assertInertia(fn (Assert $page) => $page->has('columns.todo', 1));
});

it('forces client comments to be client-visible', function () {
    $acme = Client::create(['name' => 'Acme']);
    $project = project($acme);
    $task = taskIn($project, false);

    $this->actingAs(userWith(Role::Client, $acme))
        ->post("/projects/{$project->ulid}/tasks/{$task->ulid}/comments", ['body' => 'Hi', 'is_internal' => true])
        ->assertRedirect();

    expect(Comment::first()->is_internal)->toBeFalse();
});

it('never returns internal comments to clients', function () {
    $acme = Client::create(['name' => 'Acme']);
    $project = project($acme);
    $task = taskIn($project, false);
    foreach ([true, false] as $flag) {
        $c = new Comment(['body' => 'x']);
        $c->task_id = $task->id;
        $c->user_id = $project->owner_id;
        $c->is_internal = $flag;
        $c->save();
    }

    $this->actingAs(userWith(Role::Client, $acme))
        ->get("/projects/{$project->ulid}/tasks/{$task->ulid}")
        ->assertInertia(fn (Assert $page) => $page->has('comments', 1));
});

it('stops clients from creating or moving tasks', function () {
    $acme = Client::create(['name' => 'Acme']);
    $project = project($acme);
    $task = taskIn($project, false);
    $client = userWith(Role::Client, $acme);

    $this->actingAs($client)->post("/projects/{$project->ulid}/tasks", ['title' => 'x', 'priority' => 'low', 'is_internal' => false])->assertForbidden();
    $this->actingAs($client)->patch("/projects/{$project->ulid}/tasks/{$task->ulid}/move", ['status' => 'done'])->assertForbidden();
});

it('rejects assignees who are not project members', function () {
    $admin = userWith(Role::Admin);
    $project = project(null, $admin);
    $outsider = userWith(Role::Member);

    $this->actingAs($admin)
        ->post("/projects/{$project->ulid}/tasks", ['title' => 'x', 'priority' => 'low', 'is_internal' => true, 'assignee_id' => $outsider->id])
        ->assertSessionHasErrors('assignee_id');
});

it('makes subtasks of internal tasks internal', function () {
    $admin = userWith(Role::Admin);
    $project = project(null, $admin);
    $parent = taskIn($project, true);

    $this->actingAs($admin)
        ->post("/projects/{$project->ulid}/tasks", ['title' => 'sub', 'priority' => 'low', 'is_internal' => false, 'parent' => $parent->ulid]);

    expect(Task::where('parent_id', $parent->id)->first()->is_internal)->toBeTrue();
});

it('returns 404 for a task under the wrong project', function () {
    $admin = userWith(Role::Admin);
    $a = project(null, $admin);
    $b = project(null, $admin);
    $task = taskIn($a);

    $this->actingAs($admin)->get("/projects/{$b->ulid}/tasks/{$task->ulid}")->assertNotFound();
});
__EOF__

w tests/Feature/FinanceFilesTimeTest.php <<'__EOF__'
<?php

use App\Enums\Role;
use App\Models\{Attachment, Client, Invoice};
use App\Notifications\CommentPosted;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\{Notification, Storage};
use Inertia\Testing\AssertableInertia as Assert;

it('hides budget and rate from clients', function () {
    $acme = Client::create(['name' => 'Acme']);
    $p = project($acme);
    $p->forceFill(['budget' => 5000, 'hourly_rate' => 90])->save();

    $this->actingAs(userWith(Role::Client, $acme))->get("/projects/{$p->ulid}")
        ->assertInertia(fn (Assert $page) => $page->missing('project.budget')->missing('project.hourly_rate'));
});

it('does not let clients download internal files', function () {
    Storage::fake('local');
    $acme = Client::create(['name' => 'Acme']);
    $p = project($acme);
    $admin = userWith(Role::Admin);

    $this->actingAs($admin)->post("/projects/{$p->ulid}/files", [
        'file' => UploadedFile::fake()->create('plan.pdf', 20, 'application/pdf'),
        'is_internal' => true,
    ])->assertSessionHasNoErrors();

    $a = Attachment::firstOrFail();
    $client = userWith(Role::Client, $acme);

    $this->actingAs($client)->get("/projects/{$p->ulid}/files/{$a->ulid}")->assertNotFound();

    $a->forceFill(['is_internal' => false])->save();
    $this->actingAs($client)->get("/projects/{$p->ulid}/files/{$a->ulid}")->assertOk();
});

it('rejects disallowed upload types', function () {
    Storage::fake('local');
    $p = project();

    $this->actingAs(userWith(Role::Admin))->post("/projects/{$p->ulid}/files", [
        'file' => UploadedFile::fake()->create('evil.php', 5, 'application/x-php'),
    ])->assertSessionHasErrors('file');
});

it('keeps time tracking away from clients', function () {
    $acme = Client::create(['name' => 'Acme']);
    $p = project($acme);
    $client = userWith(Role::Client, $acme);

    $this->actingAs($client)->get("/projects/{$p->ulid}/time")->assertNotFound();
    $this->actingAs($client)->post("/projects/{$p->ulid}/time", [
        'entry_date' => today()->toDateString(), 'minutes' => 30, 'is_billable' => true,
    ])->assertForbidden();
});

it('shows clients only issued invoices', function () {
    $acme = Client::create(['name' => 'Acme']);
    $p = project($acme);

    $inv = new Invoice;
    $inv->project_id = $p->id;
    $inv->created_by = $p->owner_id;
    $inv->number = 'INV-T-1';
    $inv->status = 'draft';
    $inv->save();

    $client = userWith(Role::Client, $acme);
    $this->actingAs($client)->get("/projects/{$p->ulid}/invoices/{$inv->ulid}")->assertNotFound();

    $inv->forceFill(['status' => 'sent'])->save();
    $this->actingAs($client)->get("/projects/{$p->ulid}/invoices/{$inv->ulid}")->assertOk();
});

it('stops members from creating invoices', function () {
    $p = project();

    $this->actingAs(userWith(Role::Member))
        ->post("/projects/{$p->ulid}/invoices", ['include_time' => false, 'items' => [['description' => 'x', 'quantity' => 1, 'unit_price' => 5]]])
        ->assertForbidden();
});

it('creates an invoice with a server-computed total', function () {
    $admin = userWith(Role::Admin);
    $p = project(null, $admin);

    $this->actingAs($admin)->post("/projects/{$p->ulid}/invoices", [
        'include_time' => false,
        'items' => [['description' => 'Design', 'quantity' => 2, 'unit_price' => 50.25]],
    ])->assertRedirect();

    expect((float) Invoice::firstOrFail()->total)->toBe(100.5);
});

it('never notifies clients about internal comments', function () {
    Notification::fake();
    $acme = Client::create(['name' => 'Acme']);
    $p = project($acme);
    $task = taskIn($p, false);
    $client = userWith(Role::Client, $acme);
    $admin = $p->owner;

    $this->actingAs($admin)->post("/projects/{$p->ulid}/tasks/{$task->ulid}/comments", ['body' => 'secret', 'is_internal' => true]);
    Notification::assertNotSentTo($client, CommentPosted::class);

    $this->actingAs($admin)->post("/projects/{$p->ulid}/tasks/{$task->ulid}/comments", ['body' => 'hello', 'is_internal' => false]);
    Notification::assertSentTo($client, CommentPosted::class);
});
__EOF__

# =====================================================================
say "Patching starter-kit files"
# =====================================================================
cat > /tmp/pm_patch.php <<'__EOF__'
<?php
$manual = [];

function patch(string $file, string $marker, callable $fn, string $label): void {
    global $manual;
    if (! file_exists($file)) { echo "  SKIP (missing) $file\n"; $manual[] = $label; return; }
    $c = file_get_contents($file);
    if (str_contains($c, $marker)) { echo "  already patched: $label\n"; return; }
    $n = $fn($c);
    if ($n === null || $n === $c) { echo "  !! could not patch: $label\n"; $manual[] = $label; return; }
    file_put_contents($file, $n);
    echo "  patched: $label\n";
}

// 1. bootstrap/app.php : middleware
patch('bootstrap/app.php', 'SecurityHeaders', function ($c) {
    $alias = <<<'TXT'
$middleware->alias([
            'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class,
            'api.staff' => \App\Http\Middleware\ApiStaffOnly::class,
        ]);

TXT;
    $list = <<<'TXT'
\App\Http\Middleware\BlockRegistration::class,
            \App\Http\Middleware\SecurityHeaders::class,
            \App\Http\Middleware\EnsureUserIsActive::class,
            \App\Http\Middleware\RequireTwoFactor::class,
TXT;
    if (str_contains($c, '$middleware->web(append: [')) {
        return str_replace('$middleware->web(append: [', $alias."        \$middleware->web(append: [\n            ".$list, $c);
    }
    if (preg_match('/->withMiddleware\(function \(Middleware \$middleware\)[^{]*\{/', $c, $m)) {
        $block = "\n        ".$alias."        \$middleware->web(append: [\n            ".$list."\n        ]);\n";
        return str_replace($m[0], $m[0].$block, $c);
    }
    return null;
}, 'bootstrap/app.php (middleware)');

// 2. AppServiceProvider : gates
patch('app/Providers/AppServiceProvider.php', 'manage-users', function ($c) {
    $gates = <<<'TXT'

        \Illuminate\Support\Facades\Gate::define('manage-users', fn (\App\Models\User $u) => $u->is_active && $u->role === \App\Enums\Role::Admin);
        \Illuminate\Support\Facades\Gate::define('manage-clients', fn (\App\Models\User $u) => $u->is_active && in_array($u->role, [\App\Enums\Role::Admin, \App\Enums\Role::Manager], true));
        \Illuminate\Support\Facades\Gate::define('manage-finance', fn (\App\Models\User $u) => $u->is_active && in_array($u->role, [\App\Enums\Role::Admin, \App\Enums\Role::Manager], true));
TXT;
    return preg_replace_callback('/(public function boot\(\)(?:\s*:\s*void)?\s*\{)/', fn ($m) => $m[1].$gates, $c, 1);
}, 'AppServiceProvider (gates)');

// 3. HandleInertiaRequests : shared props
patch('app/Http/Middleware/HandleInertiaRequests.php', 'unreadNotifications', function ($c) {
    $extra = <<<'TXT'
...parent::share($request),
            'unreadNotifications' => fn () => $request->user()?->unreadNotifications()->count() ?? 0,
            'abilities' => fn () => ($u = $request->user()) ? [
                'internal' => $u->isInternal(),
                'client' => ! $u->isInternal(),
                'manageUsers' => $u->can('manage-users'),
                'manageClients' => $u->can('manage-clients'),
                'finance' => $u->can('manage-finance'),
            ] : null,
TXT;
    return str_contains($c, '...parent::share($request),') ? str_replace('...parent::share($request),', $extra, $c) : null;
}, 'HandleInertiaRequests (shared props)');

// 4. app.blade.php : CSP nonce on inline scripts
patch('resources/views/app.blade.php', 'cspNonce', function ($c) {
    return str_contains($c, '<script>') ? str_replace('<script>', '<script nonce="{{ Vite::cspNonce() }}">', $c) : $c.'';
}, 'app.blade.php (CSP nonce)');

// 5. routes/web.php : disable kit dashboard route, include pm.php
patch('routes/web.php', 'pm.php', function ($c) {
    $c = preg_replace('/^(\s*)(Route::(?:inertia|get)\(\s*[\'"]dashboard[\'"].*)$/m', '$1// $2  // replaced by DashboardController in routes/pm.php', $c);
    return rtrim($c)."\n\nrequire __DIR__.'/pm.php';\n";
}, 'routes/web.php');

// 6. routes/console.php : scheduler
patch('routes/console.php', 'sanctum:prune-expired', function ($c) {
    return rtrim($c)."\n\n\\Illuminate\\Support\\Facades\\Schedule::command('sanctum:prune-expired --hours=24')->daily();\n"
        ."\\Illuminate\\Support\\Facades\\Schedule::call(fn () => \\App\\Models\\Invitation::where('expires_at', '<', now()->subDays(30))->delete())->daily();\n"
        ."\\Illuminate\\Support\\Facades\\Schedule::command('queue:prune-failed --hours=168')->daily();\n";
}, 'routes/console.php (schedule)');

// 7. tests/Pest.php : helpers
patch('tests/Pest.php', 'Helpers.php', function ($c) {
    return rtrim($c)."\n\nrequire_once __DIR__.'/Helpers.php';\n";
}, 'tests/Pest.php');

// 8. app.css : shared component classes
patch('resources/css/app.css', '.btn-primary', function ($c) {
    return rtrim($c)."\n\n".<<<'TXT'
/* --- PM system shared components --- */
@layer components {
    .input { @apply rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100; }
    .btn { @apply inline-block rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700; }
    .btn-primary { @apply inline-block rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50; }
    .btn-danger { @apply inline-block rounded-lg border border-red-300 px-3 py-2 text-sm text-red-600; }
    .card { @apply rounded-xl border border-gray-200 p-4 dark:border-gray-800; }
}

TXT;
}, 'resources/css/app.css');

// 9. AppSidebar.vue : add PM links through the composable
patch('resources/js/components/AppSidebar.vue', 'usePmNav', function ($c) {
    if (! preg_match('/const mainNavItems: NavItem\[\] = \[(.*?)\n\];/s', $c)) return null;
    $c = preg_replace('/const mainNavItems: NavItem\[\] = \[(.*?)\n\];/s', "const baseNavItems: NavItem[] = [\$1\n];\nconst mainNavItems = usePmNav(baseNavItems);", $c, 1);
    return preg_replace('/(<script setup lang="ts">\s*\n)/', "\$1import { usePmNav } from '@/composables/usePmNav';\n", $c, 1);
}, 'AppSidebar.vue (navigation)');

if ($manual) {
    echo "\nMANUAL_STEPS:" . implode('|', $manual) . "\n";
}
__EOF__
PATCH_OUT=$(php /tmp/pm_patch.php); echo "$PATCH_OUT"
rm -f /tmp/pm_patch.php

# =====================================================================
say "Migrating database, building frontend, running tests"
# =====================================================================
php artisan migrate --force
php artisan route:list >/dev/null && echo "  routes OK" || warn "route:list failed - check the error above"

npm run build || warn "npm run build failed - see output above"

php artisan test || warn "some tests failed - paste the first failure to get a fix"

# =====================================================================
echo
read -r -p "Create your first admin account now? [Y/n] " ans || ans="n"
if [[ "${ans:-Y}" =~ ^[Yy]?$ ]]; then
  php artisan pm:create-admin
fi

say "DONE"
cat <<'MSG'

  Start developing:   composer run dev
  Open:               the URL shown (usually http://localhost:8000)
  Log in as admin  -> you will be sent to enable two-factor authentication first.

  First steps in the app:
    1. Clients      -> create a client
    2. Users        -> invite staff and client users (invite emails go to storage/logs/laravel.log
                       while MAIL_MAILER=log; copy the link from there when testing)
    3. Projects     -> create a project, add members, then try Tasks / Files / Time / Invoices

MSG

if echo "$PATCH_OUT" | grep -q MANUAL_STEPS; then
  warn "Some starter-kit files could not be patched automatically:"
  echo "$PATCH_OUT" | grep MANUAL_STEPS | sed 's/MANUAL_STEPS://; s/|/\n   - /g; s/^/   - /'
  cat <<'MSG'

  Manual fixes for any that failed:
   * bootstrap/app.php        : add BlockRegistration, SecurityHeaders, EnsureUserIsActive, RequireTwoFactor
                                to $middleware->web(append: [...]) and alias 'abilities' (Sanctum CheckAbilities)
                                and 'api.staff' (App\Http\Middleware\ApiStaffOnly).
   * AppServiceProvider::boot : define Gates manage-users / manage-clients / manage-finance (Admin only; Admin+Manager).
   * HandleInertiaRequests    : share 'abilities' and 'unreadNotifications' (see app code in this installer).
   * AppSidebar.vue           : import { usePmNav } from '@/composables/usePmNav' and wrap your nav array:
                                const mainNavItems = usePmNav(baseNavItems)
MSG
fi
