mirror of
https://github.com/skysyaz/timesheet-staging.git
synced 2026-07-14 08:40:51 +00:00
Add file attachments to weekly hours and timesheet view
Employees can now attach supporting files (PDFs, images, office docs) to a project row on the Weekly hours page and view/download them when viewing a timesheet. - New timesheet_attachments table + TimesheetAttachment model with automatic file cleanup on delete and on parent timesheet deletion - Weekly hours grid: per-row upload/remove controls for editable saved rows, with existing attachments shown as downloadable chips - ViewTimesheet infolist: new Attachments section with download links - Authenticated download route authorized via TimesheetAccess so only users who can view a timesheet can fetch its files - Uploads validated for type (pdf/images/doc/xls/csv/txt) and size (10MB) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cwd7t8ySm9Kegxxfefmm69
This commit is contained in:
parent
da5f5d5770
commit
02e36d7a9b
@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\Timesheet;
|
||||
use App\Models\TimesheetAttachment;
|
||||
use App\Models\User;
|
||||
use App\Support\TimesheetAccess;
|
||||
use App\Support\WeeklyHoursAccess;
|
||||
@ -12,9 +14,14 @@ use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
class WeeklyHours extends Page
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-calendar-days';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Time Tracking';
|
||||
@ -36,6 +43,14 @@ class WeeklyHours extends Page
|
||||
/** @var list<array<string, mixed>> */
|
||||
public array $rows = [];
|
||||
|
||||
/**
|
||||
* Pending file uploads keyed by row index. Livewire streams the selected
|
||||
* file here before {@see uploadAttachment()} persists it.
|
||||
*
|
||||
* @var array<int, TemporaryUploadedFile>
|
||||
*/
|
||||
public array $rowUploads = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->weekStart = now()->startOfWeek(Carbon::MONDAY)->format('Y-m-d');
|
||||
@ -78,7 +93,7 @@ class WeeklyHours extends Page
|
||||
{
|
||||
$start = Carbon::parse($this->weekStart);
|
||||
|
||||
return $start->format('F Y') . ' – Week ' . $start->isoWeek();
|
||||
return $start->format('F Y').' – Week '.$start->isoWeek();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -189,6 +204,7 @@ class WeeklyHours extends Page
|
||||
'overtime_hours' => [0, 0, 0, 0, 0, 0, 0],
|
||||
'status' => 'draft',
|
||||
'editable' => true,
|
||||
'attachments' => [],
|
||||
];
|
||||
}
|
||||
|
||||
@ -210,6 +226,105 @@ class WeeklyHours extends Page
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the file staged in {@see $rowUploads} for the given row. The row
|
||||
* must already be saved (have an id) and be editable by the current user.
|
||||
*/
|
||||
public function uploadAttachment(int $rowIndex): void
|
||||
{
|
||||
if (! $this->canEditSheet()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$file = $this->rowUploads[$rowIndex] ?? null;
|
||||
$row = $this->rows[$rowIndex] ?? null;
|
||||
|
||||
if (! $file || ! $row || ! ($row['editable'] ?? false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$timesheetId = $row['id'] ?? null;
|
||||
|
||||
if (! $timesheetId) {
|
||||
Notification::make()
|
||||
->title('Save the row before attaching files.')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validate([
|
||||
"rowUploads.{$rowIndex}" => ['file', 'max:10240', 'mimes:pdf,jpg,jpeg,png,gif,webp,doc,docx,xls,xlsx,csv,txt'],
|
||||
], attributes: ["rowUploads.{$rowIndex}" => 'attachment']);
|
||||
|
||||
$this->authorizeSelectedUser();
|
||||
|
||||
$timesheet = Timesheet::query()
|
||||
->where('user_id', $this->selectedUserId)
|
||||
->find($timesheetId);
|
||||
|
||||
if (! $timesheet || ! TimesheetAccess::userCanEditTimesheet(auth()->user(), $timesheet)) {
|
||||
Notification::make()
|
||||
->title('This row can no longer be edited.')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$path = $file->store("timesheet-attachments/{$timesheet->id}", 'local');
|
||||
|
||||
$timesheet->attachments()->create([
|
||||
'uploaded_by' => auth()->id(),
|
||||
'disk' => 'local',
|
||||
'path' => $path,
|
||||
'original_name' => $file->getClientOriginalName(),
|
||||
'mime_type' => $file->getMimeType(),
|
||||
'size' => $file->getSize(),
|
||||
]);
|
||||
|
||||
unset($this->rowUploads[$rowIndex]);
|
||||
$this->loadSheet();
|
||||
|
||||
Notification::make()
|
||||
->title('Attachment uploaded')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
public function removeAttachment(int $attachmentId): void
|
||||
{
|
||||
if (! $this->canEditSheet()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->authorizeSelectedUser();
|
||||
|
||||
$attachment = TimesheetAttachment::query()
|
||||
->with('timesheet')
|
||||
->whereHas('timesheet', fn ($query) => $query->where('user_id', $this->selectedUserId))
|
||||
->find($attachmentId);
|
||||
|
||||
if (! $attachment || ! $attachment->timesheet
|
||||
|| ! TimesheetAccess::userCanEditTimesheet(auth()->user(), $attachment->timesheet)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$attachment->delete();
|
||||
$this->loadSheet();
|
||||
|
||||
Notification::make()
|
||||
->title('Attachment removed')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
public function attachmentDownloadUrl(int $attachmentId): string
|
||||
{
|
||||
return route('timesheet-attachments.download', ['attachment' => $attachmentId]);
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
if (! $this->canEditSheet()) {
|
||||
@ -225,7 +340,7 @@ class WeeklyHours extends Page
|
||||
|
||||
try {
|
||||
$this->sheet()->saveRows($this->rows, auth()->user());
|
||||
} catch (\Illuminate\Validation\ValidationException $exception) {
|
||||
} catch (ValidationException $exception) {
|
||||
Notification::make()
|
||||
->title('Could not save weekly hours')
|
||||
->body($this->formatValidationError($exception))
|
||||
@ -244,7 +359,7 @@ class WeeklyHours extends Page
|
||||
->send();
|
||||
}
|
||||
|
||||
public function formatHours(float | int | string | null $hours): string
|
||||
public function formatHours(float|int|string|null $hours): string
|
||||
{
|
||||
return WeeklyHoursFormatter::display($hours);
|
||||
}
|
||||
@ -341,7 +456,7 @@ class WeeklyHours extends Page
|
||||
$target = $this->selectedUser();
|
||||
|
||||
if (! $viewer) {
|
||||
throw new AuthorizationException();
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
if ($viewer->isEmployee() && $viewer->id !== $target->id) {
|
||||
@ -369,7 +484,7 @@ class WeeklyHours extends Page
|
||||
}
|
||||
}
|
||||
|
||||
protected function formatValidationError(\Illuminate\Validation\ValidationException $exception): string
|
||||
protected function formatValidationError(ValidationException $exception): string
|
||||
{
|
||||
return collect($exception->errors())
|
||||
->flatMap(function (array $messages, string $key): array {
|
||||
|
||||
@ -8,6 +8,7 @@ use App\Support\ProjectDisplay;
|
||||
use App\Support\TimesheetAccess;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Infolists\Components\RepeatableEntry;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
@ -87,7 +88,7 @@ class ViewTimesheet extends ViewRecord
|
||||
->date('d/m/Y'),
|
||||
TextEntry::make('week_number')
|
||||
->label('Week Number')
|
||||
->getStateUsing(fn (Model $record) => 'Week ' . $record->week_start->isoWeek() . ', ' . $record->week_start->year),
|
||||
->getStateUsing(fn (Model $record) => 'Week '.$record->week_start->isoWeek().', '.$record->week_start->year),
|
||||
TextEntry::make('status')
|
||||
->badge()
|
||||
->color(fn (string $state) => match ($state) {
|
||||
@ -102,7 +103,7 @@ class ViewTimesheet extends ViewRecord
|
||||
Section::make('Approval History')
|
||||
->icon('heroicon-o-clipboard-document-check')
|
||||
->schema([
|
||||
\Filament\Infolists\Components\RepeatableEntry::make('approvalLogs')
|
||||
RepeatableEntry::make('approvalLogs')
|
||||
->schema([
|
||||
TextEntry::make('approver_name')
|
||||
->label('By')
|
||||
@ -134,6 +135,15 @@ class ViewTimesheet extends ViewRecord
|
||||
->hiddenLabel(),
|
||||
])
|
||||
->visible(fn (Model $record) => filled($record->notes)),
|
||||
|
||||
Section::make('Attachments')
|
||||
->icon('heroicon-o-paper-clip')
|
||||
->schema([
|
||||
ViewEntry::make('attachments')
|
||||
->hiddenLabel()
|
||||
->view('filament.infolists.timesheet-attachments'),
|
||||
])
|
||||
->visible(fn (Model $record) => $record->attachments()->exists()),
|
||||
])->grow(),
|
||||
|
||||
Group::make([
|
||||
|
||||
29
app/Http/Controllers/TimesheetAttachmentController.php
Normal file
29
app/Http/Controllers/TimesheetAttachmentController.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\TimesheetAttachment;
|
||||
use App\Support\TimesheetAccess;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class TimesheetAttachmentController extends Controller
|
||||
{
|
||||
public function download(TimesheetAttachment $attachment)
|
||||
{
|
||||
$user = auth()->user();
|
||||
$attachment->loadMissing('timesheet.project');
|
||||
|
||||
if (! $user || ! $attachment->timesheet
|
||||
|| ! TimesheetAccess::userCanViewTimesheet($user, $attachment->timesheet)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$disk = $attachment->disk ?: 'local';
|
||||
|
||||
if (! $attachment->path || ! Storage::disk($disk)->exists($attachment->path)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return Storage::disk($disk)->download($attachment->path, $attachment->original_name);
|
||||
}
|
||||
}
|
||||
@ -31,6 +31,16 @@ class Timesheet extends Model
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
// Ensure attachment files are removed with the timesheet. Deleting each
|
||||
// attachment through the model (rather than relying on the database
|
||||
// cascade) fires TimesheetAttachment's own cleanup that unlinks files.
|
||||
static::deleting(function (Timesheet $timesheet): void {
|
||||
$timesheet->attachments()->get()->each->delete();
|
||||
});
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
@ -46,6 +56,11 @@ class Timesheet extends Model
|
||||
return $this->hasMany(ApprovalLog::class);
|
||||
}
|
||||
|
||||
public function attachments()
|
||||
{
|
||||
return $this->hasMany(TimesheetAttachment::class)->latest('id');
|
||||
}
|
||||
|
||||
public function latestApprovalLog(string $action): ?ApprovalLog
|
||||
{
|
||||
if ($this->relationLoaded('approvalLogs')) {
|
||||
@ -100,22 +115,22 @@ class Timesheet extends Model
|
||||
public function totalRegularHours(): float
|
||||
{
|
||||
$hours = $this->hours;
|
||||
|
||||
|
||||
if (! is_array($hours)) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
|
||||
return array_sum($hours);
|
||||
}
|
||||
|
||||
public function totalOvertimeHours(): float
|
||||
{
|
||||
$overtimeHours = $this->overtime_hours;
|
||||
|
||||
|
||||
if (! is_array($overtimeHours)) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
|
||||
return array_sum($overtimeHours);
|
||||
}
|
||||
|
||||
@ -211,7 +226,7 @@ class Timesheet extends Model
|
||||
if (! $this->relationLoaded('project')) {
|
||||
$this->load('project');
|
||||
}
|
||||
|
||||
|
||||
$project = $this->project;
|
||||
|
||||
if ($this->isPendingPm()) {
|
||||
|
||||
69
app/Models/TimesheetAttachment.php
Normal file
69
app/Models/TimesheetAttachment.php
Normal file
@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class TimesheetAttachment extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'timesheet_id',
|
||||
'uploaded_by',
|
||||
'disk',
|
||||
'path',
|
||||
'original_name',
|
||||
'mime_type',
|
||||
'size',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'size' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
// Remove the underlying file whenever the record is deleted, whether
|
||||
// that happens directly or as part of a timesheet being removed.
|
||||
static::deleting(function (TimesheetAttachment $attachment): void {
|
||||
$disk = $attachment->disk ?: 'local';
|
||||
|
||||
if ($attachment->path && Storage::disk($disk)->exists($attachment->path)) {
|
||||
Storage::disk($disk)->delete($attachment->path);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function timesheet()
|
||||
{
|
||||
return $this->belongsTo(Timesheet::class);
|
||||
}
|
||||
|
||||
public function uploader()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'uploaded_by');
|
||||
}
|
||||
|
||||
public function humanSize(): string
|
||||
{
|
||||
$size = (int) $this->size;
|
||||
|
||||
if ($size <= 0) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
$units = ['B', 'KB', 'MB', 'GB'];
|
||||
$power = min((int) floor(log($size, 1024)), count($units) - 1);
|
||||
$value = $size / (1024 ** $power);
|
||||
|
||||
return ($power === 0 ? $size : number_format($value, 1)).' '.$units[$power];
|
||||
}
|
||||
|
||||
public function isImage(): bool
|
||||
{
|
||||
return str_starts_with((string) $this->mime_type, 'image/');
|
||||
}
|
||||
}
|
||||
@ -34,7 +34,7 @@ class WeeklyHoursSheet
|
||||
public function loadRows(): array
|
||||
{
|
||||
$timesheets = Timesheet::query()
|
||||
->with('project')
|
||||
->with(['project', 'attachments'])
|
||||
->where('user_id', $this->user->id)
|
||||
->whereDate('week_start', $this->weekStart->toDateString())
|
||||
->orderBy('id')
|
||||
@ -263,8 +263,8 @@ class WeeklyHoursSheet
|
||||
],
|
||||
[
|
||||
'project_id' => [new ProjectMembershipForEmployee($this->user, $existing)],
|
||||
'hours' => [new ValidDailyHours()],
|
||||
'overtime_hours' => [new ValidDailyHours()],
|
||||
'hours' => [new ValidDailyHours],
|
||||
'overtime_hours' => [new ValidDailyHours],
|
||||
],
|
||||
);
|
||||
|
||||
@ -375,6 +375,11 @@ class WeeklyHoursSheet
|
||||
),
|
||||
'status' => $timesheet->status,
|
||||
'editable' => $timesheet->isEditable(),
|
||||
'attachments' => $timesheet->attachments->map(fn ($attachment): array => [
|
||||
'id' => $attachment->id,
|
||||
'name' => $attachment->original_name,
|
||||
'size' => $attachment->humanSize(),
|
||||
])->all(),
|
||||
];
|
||||
}
|
||||
|
||||
@ -401,6 +406,7 @@ class WeeklyHoursSheet
|
||||
'overtime_hours' => [0, 0, 0, 0, 0, 0, 0],
|
||||
'status' => 'draft',
|
||||
'editable' => true,
|
||||
'attachments' => [],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,30 @@
|
||||
<?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('timesheet_attachments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('timesheet_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('uploaded_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->string('disk', 40)->default('local');
|
||||
$table->string('path');
|
||||
$table->string('original_name');
|
||||
$table->string('mime_type')->nullable();
|
||||
$table->unsignedBigInteger('size')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('timesheet_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('timesheet_attachments');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,22 @@
|
||||
@php
|
||||
$attachments = $entry->getRecord()->attachments;
|
||||
@endphp
|
||||
|
||||
<div class="space-y-2">
|
||||
@forelse ($attachments as $attachment)
|
||||
<a
|
||||
href="{{ route('timesheet-attachments.download', ['attachment' => $attachment->id]) }}"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 px-3 py-2 text-sm transition hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<x-filament::icon icon="heroicon-o-paper-clip" class="h-4 w-4 shrink-0 text-gray-400" />
|
||||
<span class="truncate font-medium text-primary-600 dark:text-primary-400">{{ $attachment->original_name }}</span>
|
||||
</span>
|
||||
<span class="shrink-0 text-xs text-gray-500 dark:text-gray-400">{{ $attachment->humanSize() }}</span>
|
||||
</a>
|
||||
@empty
|
||||
<p class="text-sm italic text-gray-400">No attachments.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
@ -7,6 +7,25 @@
|
||||
.corp-weekly-hours-ot-row .corp-weekly-hours-hour-input { border-color: rgb(253 230 138 / 0.8); background: rgb(255 251 235 / 0.5); }
|
||||
.corp-weekly-hours-total-row-ot td { background: rgb(255 251 235 / 0.8); font-weight: 600; }
|
||||
.dark .corp-weekly-hours-total-row-ot td { background: rgb(69 26 3 / 0.3); }
|
||||
.corp-weekly-hours-attach-row td { border-top: 0; background: rgb(248 250 252 / 0.7); }
|
||||
.dark .corp-weekly-hours-attach-row td { background: rgb(30 41 59 / 0.3); }
|
||||
.corp-weekly-hours-attach-label { font-size: 0.75rem; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; color: rgb(100 116 139); }
|
||||
.dark .corp-weekly-hours-attach-label { color: rgb(148 163 184); }
|
||||
.corp-weekly-hours-attachments { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
|
||||
.corp-weekly-hours-attach-chip { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.2rem 0.5rem; border-radius: 9999px; background: rgb(226 232 240 / 0.7); font-size: 0.8rem; }
|
||||
.dark .corp-weekly-hours-attach-chip { background: rgb(51 65 85 / 0.6); }
|
||||
.corp-weekly-hours-attach-chip a { color: rgb(37 99 235); text-decoration: none; font-weight: 500; }
|
||||
.corp-weekly-hours-attach-chip a:hover { text-decoration: underline; }
|
||||
.dark .corp-weekly-hours-attach-chip a { color: rgb(96 165 250); }
|
||||
.corp-weekly-hours-attach-size { color: rgb(100 116 139); font-size: 0.7rem; }
|
||||
.corp-weekly-hours-attach-remove { display: inline-flex; color: rgb(148 163 184); }
|
||||
.corp-weekly-hours-attach-remove:hover { color: rgb(220 38 38); }
|
||||
.corp-weekly-hours-attach-empty, .corp-weekly-hours-attach-hint { color: rgb(148 163 184); font-size: 0.8rem; font-style: italic; }
|
||||
.corp-weekly-hours-attach-upload { display: inline-flex; align-items: center; gap: 0.5rem; }
|
||||
.corp-weekly-hours-attach-input { font-size: 0.75rem; max-width: 15rem; }
|
||||
.corp-weekly-hours-attach-btn { padding: 0.2rem 0.6rem; border-radius: 0.375rem; background: rgb(37 99 235); color: #fff; font-size: 0.75rem; font-weight: 600; }
|
||||
.corp-weekly-hours-attach-btn:disabled { opacity: 0.5; }
|
||||
.corp-weekly-hours-attach-error { margin-top: 0.35rem; color: rgb(220 38 38); font-size: 0.75rem; }
|
||||
</style>
|
||||
<div class="corp-weekly-hours">
|
||||
<div class="corp-weekly-hours-toolbar">
|
||||
@ -112,7 +131,7 @@
|
||||
{{ $this->rowDuration($row) }}
|
||||
</td>
|
||||
@if ($this->canEditSheet())
|
||||
<td class="corp-weekly-hours-actions" rowspan="2">
|
||||
<td class="corp-weekly-hours-actions" rowspan="3">
|
||||
@if ($editable)
|
||||
<button
|
||||
type="button"
|
||||
@ -153,6 +172,68 @@
|
||||
{{ $this->rowOvertimeDuration($row) }}
|
||||
</td>
|
||||
</tr>
|
||||
@php
|
||||
$attachments = $row['attachments'] ?? [];
|
||||
@endphp
|
||||
<tr
|
||||
wire:key="weekly-hours-attach-row-{{ $row['id'] ?? 'new-' . $rowIndex }}"
|
||||
class="corp-weekly-hours-attach-row"
|
||||
>
|
||||
<td class="corp-weekly-hours-attach-label">Files</td>
|
||||
<td colspan="9">
|
||||
<div class="corp-weekly-hours-attachments">
|
||||
@forelse ($attachments as $attachment)
|
||||
<span class="corp-weekly-hours-attach-chip">
|
||||
<x-filament::icon icon="heroicon-m-paper-clip" class="h-3.5 w-3.5" />
|
||||
<a href="{{ $this->attachmentDownloadUrl($attachment['id']) }}" target="_blank" rel="noopener">
|
||||
{{ $attachment['name'] }}
|
||||
</a>
|
||||
<span class="corp-weekly-hours-attach-size">{{ $attachment['size'] }}</span>
|
||||
@if ($editable)
|
||||
<button
|
||||
type="button"
|
||||
wire:click="removeAttachment({{ $attachment['id'] }})"
|
||||
wire:confirm="Remove this attachment?"
|
||||
class="corp-weekly-hours-attach-remove"
|
||||
aria-label="Remove attachment"
|
||||
>
|
||||
<x-filament::icon icon="heroicon-m-x-mark" class="h-3 w-3" />
|
||||
</button>
|
||||
@endif
|
||||
</span>
|
||||
@empty
|
||||
<span class="corp-weekly-hours-attach-empty">No files attached</span>
|
||||
@endforelse
|
||||
|
||||
@if ($editable)
|
||||
@if ($row['id'] ?? null)
|
||||
<span class="corp-weekly-hours-attach-upload">
|
||||
<input
|
||||
type="file"
|
||||
wire:model="rowUploads.{{ $rowIndex }}"
|
||||
class="corp-weekly-hours-attach-input"
|
||||
/>
|
||||
<span wire:loading wire:target="rowUploads.{{ $rowIndex }}" class="corp-weekly-hours-attach-hint">Uploading…</span>
|
||||
<button
|
||||
type="button"
|
||||
wire:click="uploadAttachment({{ $rowIndex }})"
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="rowUploads.{{ $rowIndex }},uploadAttachment"
|
||||
class="corp-weekly-hours-attach-btn"
|
||||
>
|
||||
Attach
|
||||
</button>
|
||||
</span>
|
||||
@else
|
||||
<span class="corp-weekly-hours-attach-hint">Save the row to attach files.</span>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
@error('rowUploads.' . $rowIndex)
|
||||
<div class="corp-weekly-hours-attach-error">{{ $message }}</div>
|
||||
@enderror
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
<tfoot>
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
use App\Http\Controllers\Auth\SetPasswordController;
|
||||
use App\Http\Controllers\FaviconController;
|
||||
use App\Http\Controllers\PdfController;
|
||||
use App\Http\Controllers\TimesheetAttachmentController;
|
||||
use App\Http\Controllers\UptimeHeartbeatController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
@ -56,6 +57,8 @@ Route::middleware(['auth'])->group(function () {
|
||||
->where('weekStart', '[0-9]{4}-[0-9]{2}-[0-9]{2}')
|
||||
->name('weekly-hours.print');
|
||||
Route::get('/pdf/summary', [PdfController::class, 'summary'])->name('pdf.summary');
|
||||
Route::get('/timesheet-attachments/{attachment}/download', [TimesheetAttachmentController::class, 'download'])
|
||||
->name('timesheet-attachments.download');
|
||||
});
|
||||
|
||||
Route::middleware('throttle:60,1')->group(function () {
|
||||
|
||||
178
tests/Feature/TimesheetAttachmentTest.php
Normal file
178
tests/Feature/TimesheetAttachmentTest.php
Normal file
@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Filament\Pages\WeeklyHours;
|
||||
use App\Models\Project;
|
||||
use App\Models\Timesheet;
|
||||
use App\Models\TimesheetAttachment;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TimesheetAttachmentTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $employee;
|
||||
|
||||
private Project $project;
|
||||
|
||||
private Carbon $monday;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Carbon::setTestNow(Carbon::parse('2026-06-24 09:00:00'));
|
||||
Storage::fake('local');
|
||||
|
||||
$this->monday = Carbon::now()->startOfWeek(Carbon::MONDAY);
|
||||
$this->employee = User::factory()->create(['role' => 'employee']);
|
||||
|
||||
$this->project = Project::create([
|
||||
'code' => 'PRJ-A',
|
||||
'name' => 'Alpha Project',
|
||||
'status' => 'active',
|
||||
]);
|
||||
$this->project->members()->attach($this->employee->id, ['assigned_role' => 'Developer']);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Carbon::setTestNow();
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function draftTimesheet(): Timesheet
|
||||
{
|
||||
return Timesheet::create([
|
||||
'user_id' => $this->employee->id,
|
||||
'project_id' => $this->project->id,
|
||||
'project_role' => 'Developer',
|
||||
'week_start' => $this->monday->toDateString(),
|
||||
'hours' => [8, 0, 0, 0, 0, 0, 0],
|
||||
'overtime_hours' => [0, 0, 0, 0, 0, 0, 0],
|
||||
'tasks' => ['Dev', '', '', '', '', '', ''],
|
||||
'status' => 'draft',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_employee_can_upload_attachment_to_a_saved_row(): void
|
||||
{
|
||||
$timesheet = $this->draftTimesheet();
|
||||
|
||||
Livewire::actingAs($this->employee)
|
||||
->test(WeeklyHours::class)
|
||||
->set('weekStart', $this->monday->toDateString())
|
||||
->set('rowUploads.0', UploadedFile::fake()->create('receipt.pdf', 120, 'application/pdf'))
|
||||
->call('uploadAttachment', 0)
|
||||
->assertHasNoErrors();
|
||||
|
||||
$attachment = $timesheet->attachments()->first();
|
||||
|
||||
$this->assertNotNull($attachment);
|
||||
$this->assertSame('receipt.pdf', $attachment->original_name);
|
||||
$this->assertSame($this->employee->id, $attachment->uploaded_by);
|
||||
Storage::disk('local')->assertExists($attachment->path);
|
||||
}
|
||||
|
||||
public function test_upload_rejects_disallowed_file_type(): void
|
||||
{
|
||||
$this->draftTimesheet();
|
||||
|
||||
Livewire::actingAs($this->employee)
|
||||
->test(WeeklyHours::class)
|
||||
->set('weekStart', $this->monday->toDateString())
|
||||
->set('rowUploads.0', UploadedFile::fake()->create('malware.exe', 10, 'application/octet-stream'))
|
||||
->call('uploadAttachment', 0)
|
||||
->assertHasErrors('rowUploads.0');
|
||||
|
||||
$this->assertSame(0, TimesheetAttachment::count());
|
||||
}
|
||||
|
||||
public function test_attachment_cannot_be_added_to_locked_timesheet(): void
|
||||
{
|
||||
$timesheet = $this->draftTimesheet();
|
||||
$timesheet->update(['status' => 'approved']);
|
||||
|
||||
Livewire::actingAs($this->employee)
|
||||
->test(WeeklyHours::class)
|
||||
->set('weekStart', $this->monday->toDateString())
|
||||
->set('rowUploads.0', UploadedFile::fake()->create('receipt.pdf', 50, 'application/pdf'))
|
||||
->call('uploadAttachment', 0);
|
||||
|
||||
$this->assertSame(0, TimesheetAttachment::count());
|
||||
}
|
||||
|
||||
public function test_employee_can_remove_own_attachment(): void
|
||||
{
|
||||
$timesheet = $this->draftTimesheet();
|
||||
$path = UploadedFile::fake()->create('note.txt', 5)->store('timesheet-attachments/'.$timesheet->id, 'local');
|
||||
|
||||
$attachment = $timesheet->attachments()->create([
|
||||
'uploaded_by' => $this->employee->id,
|
||||
'disk' => 'local',
|
||||
'path' => $path,
|
||||
'original_name' => 'note.txt',
|
||||
'mime_type' => 'text/plain',
|
||||
'size' => 5,
|
||||
]);
|
||||
|
||||
Livewire::actingAs($this->employee)
|
||||
->test(WeeklyHours::class)
|
||||
->set('weekStart', $this->monday->toDateString())
|
||||
->call('removeAttachment', $attachment->id);
|
||||
|
||||
$this->assertSame(0, TimesheetAttachment::count());
|
||||
Storage::disk('local')->assertMissing($path);
|
||||
}
|
||||
|
||||
public function test_deleting_timesheet_removes_attachment_file(): void
|
||||
{
|
||||
$timesheet = $this->draftTimesheet();
|
||||
$path = UploadedFile::fake()->create('note.txt', 5)->store('timesheet-attachments/'.$timesheet->id, 'local');
|
||||
|
||||
$timesheet->attachments()->create([
|
||||
'uploaded_by' => $this->employee->id,
|
||||
'disk' => 'local',
|
||||
'path' => $path,
|
||||
'original_name' => 'note.txt',
|
||||
'size' => 5,
|
||||
]);
|
||||
|
||||
$timesheet->delete();
|
||||
|
||||
$this->assertSame(0, TimesheetAttachment::count());
|
||||
Storage::disk('local')->assertMissing($path);
|
||||
}
|
||||
|
||||
public function test_owner_can_download_attachment_but_stranger_cannot(): void
|
||||
{
|
||||
$timesheet = $this->draftTimesheet();
|
||||
$path = UploadedFile::fake()->create('note.txt', 5)->store('timesheet-attachments/'.$timesheet->id, 'local');
|
||||
|
||||
$attachment = $timesheet->attachments()->create([
|
||||
'uploaded_by' => $this->employee->id,
|
||||
'disk' => 'local',
|
||||
'path' => $path,
|
||||
'original_name' => 'note.txt',
|
||||
'size' => 5,
|
||||
]);
|
||||
|
||||
$stranger = User::factory()->create(['role' => 'employee']);
|
||||
|
||||
$this->actingAs($stranger)
|
||||
->get(route('timesheet-attachments.download', $attachment))
|
||||
->assertForbidden();
|
||||
|
||||
$this->actingAs($this->employee)
|
||||
->get(route('timesheet-attachments.download', $attachment))
|
||||
->assertOk();
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user