diff --git a/Modules/ApprovalWorkflow/app/Enums/ApprovalChainStatus.php b/Modules/ApprovalWorkflow/app/Enums/ApprovalChainStatus.php new file mode 100644 index 0000000..2150c3f --- /dev/null +++ b/Modules/ApprovalWorkflow/app/Enums/ApprovalChainStatus.php @@ -0,0 +1,31 @@ + 'Pending', + self::InReview => 'In Review', + self::Approved => 'Approved', + self::Rejected => 'Rejected', + }; + } + + public function allowedTransitions(): array + { + return match ($this) { + self::Pending => [self::InReview, self::Rejected], + self::InReview => [self::Approved, self::Rejected], + self::Approved => [], + self::Rejected => [self::Pending], + }; + } +} diff --git a/Modules/ApprovalWorkflow/app/Enums/ApprovalStepStatus.php b/Modules/ApprovalWorkflow/app/Enums/ApprovalStepStatus.php new file mode 100644 index 0000000..6aec377 --- /dev/null +++ b/Modules/ApprovalWorkflow/app/Enums/ApprovalStepStatus.php @@ -0,0 +1,21 @@ + 'Pending', + self::Approved => 'Approved', + self::Rejected => 'Rejected', + self::Skipped => 'Skipped', + }; + } +} diff --git a/Modules/ApprovalWorkflow/app/Events/ApprovalCompleted.php b/Modules/ApprovalWorkflow/app/Events/ApprovalCompleted.php new file mode 100644 index 0000000..9c63682 --- /dev/null +++ b/Modules/ApprovalWorkflow/app/Events/ApprovalCompleted.php @@ -0,0 +1,16 @@ +user(); + $tab = $request->get('tab', 'pending'); + + $query = ApprovalChain::query(); + + if ($tab === 'history') { + $query->whereHas('steps', function ($q) use ($user) { + $q->where('approver_id', $user->id)->where('status', '!=', 'pending'); + }); + } else { + $query->whereHas('steps', function ($q) use ($user) { + $q->where('approver_id', $user->id)->where('status', 'pending'); + })->whereIn('status', ['pending', 'in_review']); + } + + $chains = $query->with(['steps.approver:id,name', 'initiator:id,name']) + ->latest() + ->paginate(15) + ->withQueryString(); + + return Inertia::render('ApprovalWorkflow::Approvals/Index', [ + 'approvals' => $chains, + 'tab' => $tab, + ]); + } + + /** + * Show a specific approval chain. + */ + public function show(ApprovalChain $approvalChain) + { + $approvalChain->load(['steps.approver:id,name,email', 'initiator:id,name,email', 'approvable']); + + $breakdownData = null; + + if ($approvalChain->approvable) { + if (method_exists($approvalChain->approvable, 'items')) { + $approvalChain->approvable->load('items'); + } + + $breakdownData = [ + 'document_number' => $approvalChain->approvable->document_number ?? $approvalChain->approvable->po_number ?? null, + 'total_cost' => $approvalChain->approvable->total_cost ?? 0, + 'notes' => $approvalChain->approvable->notes ?? null, + ]; + } + + return Inertia::render('ApprovalWorkflow::Approvals/Show', [ + 'chain' => $approvalChain, + 'breakdownData' => $breakdownData, + ]); + } + + /** + * Approve the current step. + */ + public function approve(Request $request, ApprovalChain $approvalChain) + { + $request->validate(['notes' => 'nullable|string|max:1000']); + + try { + $this->approvalService->approve($approvalChain, $request->user(), $request->notes); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return back()->with('success', 'Step approved successfully.'); + } + + /** + * Reject the current step (rejects entire chain). + */ + public function reject(Request $request, ApprovalChain $approvalChain) + { + $request->validate(['notes' => 'required|string|max:1000']); + + try { + $this->approvalService->reject($approvalChain, $request->user(), $request->notes); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return back()->with('success', 'Approval rejected.'); + } +} diff --git a/Modules/ApprovalWorkflow/app/Http/Controllers/ApprovalWorkflowController.php b/Modules/ApprovalWorkflow/app/Http/Controllers/ApprovalWorkflowController.php new file mode 100644 index 0000000..faeadc1 --- /dev/null +++ b/Modules/ApprovalWorkflow/app/Http/Controllers/ApprovalWorkflowController.php @@ -0,0 +1,56 @@ +chain; + $approvable = $chain->approvable; + + if (!$approvable) { + return; + } + + // If the model defines a custom hook, use it + if (method_exists($approvable, 'onApprovalCompleted')) { + $approvable->onApprovalCompleted($chain); + return; + } + + // Otherwise, attempt a generic status sync + $fillable = $approvable->getFillable(); + $updates = []; + + if (in_array('status', $fillable)) { + $updates['status'] = $chain->status->value; + } + + if ($chain->status->value === 'approved') { + if (in_array('approved_by', $fillable)) { + $lastStep = $chain->steps()->where('status', 'approved')->orderByDesc('acted_at')->first(); + if ($lastStep) { + $updates['approved_by'] = $lastStep->approver_id; + } + } + if (in_array('approved_at', $fillable)) { + $lastStep = $chain->steps()->where('status', 'approved')->orderByDesc('acted_at')->first(); + if ($lastStep) { + $updates['approved_at'] = $lastStep->acted_at; + } else { + $updates['approved_at'] = now(); + } + } + } + + if (!empty($updates)) { + $approvable->update($updates); + } + } +} diff --git a/Modules/ApprovalWorkflow/app/Models/ApprovalChain.php b/Modules/ApprovalWorkflow/app/Models/ApprovalChain.php new file mode 100644 index 0000000..e1427ba --- /dev/null +++ b/Modules/ApprovalWorkflow/app/Models/ApprovalChain.php @@ -0,0 +1,76 @@ + ApprovalChainStatus::class, + 'completed_at' => 'datetime', + ]; + } + + public function approvable(): MorphTo + { + return $this->morphTo(); + } + + public function initiator(): BelongsTo + { + return $this->belongsTo(User::class, 'initiated_by'); + } + + public function steps(): HasMany + { + return $this->hasMany(ApprovalStep::class)->orderBy('order'); + } + + public function currentStep(): ?ApprovalStep + { + return $this->steps()->where('status', 'pending')->first(); + } + + public function isFullyApproved(): bool + { + return $this->steps()->where('status', '!=', 'approved')->doesntExist(); + } + + public function transitionTo(ApprovalChainStatus $newStatus): void + { + $allowed = $this->status->allowedTransitions(); + if (!in_array($newStatus, $allowed)) { + throw new \InvalidArgumentException( + "Cannot transition chain from {$this->status->label()} to {$newStatus->label()}" + ); + } + + $updates = ['status' => $newStatus]; + if (in_array($newStatus, [ApprovalChainStatus::Approved, ApprovalChainStatus::Rejected])) { + $updates['completed_at'] = now(); + } + + $this->update($updates); + } +} diff --git a/Modules/ApprovalWorkflow/app/Models/ApprovalStep.php b/Modules/ApprovalWorkflow/app/Models/ApprovalStep.php new file mode 100644 index 0000000..2370710 --- /dev/null +++ b/Modules/ApprovalWorkflow/app/Models/ApprovalStep.php @@ -0,0 +1,46 @@ + ApprovalStepStatus::class, + 'acted_at' => 'datetime', + ]; + } + + public function chain(): BelongsTo + { + return $this->belongsTo(ApprovalChain::class, 'approval_chain_id'); + } + + public function approver(): BelongsTo + { + return $this->belongsTo(User::class, 'approver_id'); + } + + public function isPending(): bool + { + return $this->status === ApprovalStepStatus::Pending; + } +} diff --git a/Modules/ApprovalWorkflow/app/Providers/.gitkeep b/Modules/ApprovalWorkflow/app/Providers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Modules/ApprovalWorkflow/app/Providers/ApprovalWorkflowServiceProvider.php b/Modules/ApprovalWorkflow/app/Providers/ApprovalWorkflowServiceProvider.php new file mode 100644 index 0000000..a772690 --- /dev/null +++ b/Modules/ApprovalWorkflow/app/Providers/ApprovalWorkflowServiceProvider.php @@ -0,0 +1,46 @@ +command('inspire')->hourly(); + // } +} diff --git a/Modules/ApprovalWorkflow/app/Providers/EventServiceProvider.php b/Modules/ApprovalWorkflow/app/Providers/EventServiceProvider.php new file mode 100644 index 0000000..d57f8d7 --- /dev/null +++ b/Modules/ApprovalWorkflow/app/Providers/EventServiceProvider.php @@ -0,0 +1,31 @@ +> + */ + protected $listen = [ + \Modules\ApprovalWorkflow\Events\ApprovalCompleted::class => [ + \Modules\ApprovalWorkflow\Listeners\SyncApprovableStatus::class, + ], + ]; + + /** + * Indicates if events should be discovered. + * + * @var bool + */ + protected static $shouldDiscoverEvents = true; + + /** + * Configure the proper event listeners for email verification. + */ + protected function configureEmailVerification(): void {} +} diff --git a/Modules/ApprovalWorkflow/app/Providers/RouteServiceProvider.php b/Modules/ApprovalWorkflow/app/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..2b5161a --- /dev/null +++ b/Modules/ApprovalWorkflow/app/Providers/RouteServiceProvider.php @@ -0,0 +1,50 @@ +mapApiRoutes(); + $this->mapWebRoutes(); + } + + /** + * Define the "web" routes for the application. + * + * These routes all receive session state, CSRF protection, etc. + */ + protected function mapWebRoutes(): void + { + Route::middleware('web')->group(module_path($this->name, '/routes/web.php')); + } + + /** + * Define the "api" routes for the application. + * + * These routes are typically stateless. + */ + protected function mapApiRoutes(): void + { + Route::middleware('api')->prefix('api')->name('api.')->group(module_path($this->name, '/routes/api.php')); + } +} diff --git a/Modules/ApprovalWorkflow/app/Services/ApprovalService.php b/Modules/ApprovalWorkflow/app/Services/ApprovalService.php new file mode 100644 index 0000000..d3e81a0 --- /dev/null +++ b/Modules/ApprovalWorkflow/app/Services/ApprovalService.php @@ -0,0 +1,135 @@ + $approverIds — ordered list of user IDs + */ + public function createChain( + Model $approvable, + array $approverIds, + string $type = 'general', + ?int $initiatedBy = null, + ?string $notes = null, + ): ApprovalChain { + $chain = ApprovalChain::create([ + 'approvable_type' => $approvable->getMorphClass(), + 'approvable_id' => $approvable->getKey(), + 'type' => $type, + 'status' => ApprovalChainStatus::Pending, + 'initiated_by' => $initiatedBy, + 'notes' => $notes, + ]); + + foreach ($approverIds as $order => $userId) { + ApprovalStep::create([ + 'approval_chain_id' => $chain->id, + 'approver_id' => $userId, + 'order' => $order, + 'status' => ApprovalStepStatus::Pending, + ]); + } + + // Auto-transition to InReview and notify first approver + $chain->transitionTo(ApprovalChainStatus::InReview); + $firstStep = $chain->currentStep(); + if ($firstStep) { + ApprovalRequired::dispatch($chain, $firstStep); + } + + return $chain->load('steps.approver'); + } + + /** + * Approve the current pending step. + */ + public function approve(ApprovalChain $chain, User $approver, ?string $notes = null): ApprovalChain + { + $step = $this->getCurrentStepForApprover($chain, $approver); + + $step->update([ + 'status' => ApprovalStepStatus::Approved, + 'notes' => $notes, + 'acted_at' => now(), + ]); + + StepApproved::dispatch($chain, $step); + + // Check if all steps are approved + if ($chain->isFullyApproved()) { + $chain->transitionTo(ApprovalChainStatus::Approved); + ApprovalCompleted::dispatch($chain); + } else { + // Notify next approver + $nextStep = $chain->currentStep(); + if ($nextStep) { + ApprovalRequired::dispatch($chain, $nextStep); + } + } + + return $chain->fresh('steps.approver'); + } + + /** + * Reject the current pending step (rejects the entire chain). + */ + public function reject(ApprovalChain $chain, User $approver, ?string $notes = null): ApprovalChain + { + $step = $this->getCurrentStepForApprover($chain, $approver); + + $step->update([ + 'status' => ApprovalStepStatus::Rejected, + 'notes' => $notes, + 'acted_at' => now(), + ]); + + // Skip remaining steps + $chain->steps() + ->where('order', '>', $step->order) + ->where('status', 'pending') + ->update(['status' => ApprovalStepStatus::Skipped->value]); + + $chain->transitionTo(ApprovalChainStatus::Rejected); + + StepRejected::dispatch($chain, $step); + ApprovalCompleted::dispatch($chain); + + return $chain->fresh('steps.approver'); + } + + public function getNextApprover(ApprovalChain $chain): ?User + { + $step = $chain->currentStep(); + return $step?->approver; + } + + private function getCurrentStepForApprover(ApprovalChain $chain, User $approver): ApprovalStep + { + $step = $chain->currentStep(); + + if (!$step) { + throw new \InvalidArgumentException('No pending step found in this approval chain.'); + } + + if ($step->approver_id !== $approver->id) { + throw new \InvalidArgumentException('You are not the current approver for this step.'); + } + + return $step; + } +} diff --git a/Modules/ApprovalWorkflow/app/Traits/HasApprovable.php b/Modules/ApprovalWorkflow/app/Traits/HasApprovable.php new file mode 100644 index 0000000..427ae9e --- /dev/null +++ b/Modules/ApprovalWorkflow/app/Traits/HasApprovable.php @@ -0,0 +1,30 @@ +morphMany(ApprovalChain::class, 'approvable'); + } + + public function latestApprovalChain(): ?ApprovalChain + { + return $this->approvalChains()->latest()->first(); + } + + public function isApproved(): bool + { + $chain = $this->latestApprovalChain(); + return $chain && $chain->status->value === 'approved'; + } + + public function isPendingApproval(): bool + { + $chain = $this->latestApprovalChain(); + return $chain && in_array($chain->status->value, ['pending', 'in_review']); + } +} diff --git a/Modules/ApprovalWorkflow/composer.json b/Modules/ApprovalWorkflow/composer.json new file mode 100644 index 0000000..baa1a3c --- /dev/null +++ b/Modules/ApprovalWorkflow/composer.json @@ -0,0 +1,30 @@ +{ + "name": "nwidart/approvalworkflow", + "description": "", + "authors": [ + { + "name": "Nicolas Widart", + "email": "n.widart@gmail.com" + } + ], + "extra": { + "laravel": { + "providers": [], + "aliases": { + + } + } + }, + "autoload": { + "psr-4": { + "Modules\\ApprovalWorkflow\\": "app/", + "Modules\\ApprovalWorkflow\\Database\\Factories\\": "database/factories/", + "Modules\\ApprovalWorkflow\\Database\\Seeders\\": "database/seeders/" + } + }, + "autoload-dev": { + "psr-4": { + "Modules\\ApprovalWorkflow\\Tests\\": "tests/" + } + } +} diff --git a/Modules/ApprovalWorkflow/config/.gitkeep b/Modules/ApprovalWorkflow/config/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Modules/ApprovalWorkflow/config/config.php b/Modules/ApprovalWorkflow/config/config.php new file mode 100644 index 0000000..8895c25 --- /dev/null +++ b/Modules/ApprovalWorkflow/config/config.php @@ -0,0 +1,5 @@ + 'ApprovalWorkflow', +]; diff --git a/Modules/ApprovalWorkflow/database/factories/.gitkeep b/Modules/ApprovalWorkflow/database/factories/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Modules/ApprovalWorkflow/database/migrations/.gitkeep b/Modules/ApprovalWorkflow/database/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Modules/ApprovalWorkflow/database/migrations/2026_03_24_210001_create_approval_tables.php b/Modules/ApprovalWorkflow/database/migrations/2026_03_24_210001_create_approval_tables.php new file mode 100644 index 0000000..67e2f1a --- /dev/null +++ b/Modules/ApprovalWorkflow/database/migrations/2026_03_24_210001_create_approval_tables.php @@ -0,0 +1,39 @@ +id(); + $table->morphs('approvable'); // approvable_type + approvable_id + $table->string('type')->default('general'); // e.g., material_request, invoice, purchase_order + $table->string('status')->default('pending'); + $table->text('notes')->nullable(); + $table->foreignId('initiated_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('approval_steps', function (Blueprint $table) { + $table->id(); + $table->foreignId('approval_chain_id')->constrained()->cascadeOnDelete(); + $table->foreignId('approver_id')->constrained('users')->cascadeOnDelete(); + $table->integer('order')->default(0); + $table->string('status')->default('pending'); + $table->text('notes')->nullable(); + $table->timestamp('acted_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('approval_steps'); + Schema::dropIfExists('approval_chains'); + } +}; diff --git a/Modules/ApprovalWorkflow/database/seeders/.gitkeep b/Modules/ApprovalWorkflow/database/seeders/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Modules/ApprovalWorkflow/database/seeders/ApprovalWorkflowDatabaseSeeder.php b/Modules/ApprovalWorkflow/database/seeders/ApprovalWorkflowDatabaseSeeder.php new file mode 100644 index 0000000..fea89b0 --- /dev/null +++ b/Modules/ApprovalWorkflow/database/seeders/ApprovalWorkflowDatabaseSeeder.php @@ -0,0 +1,16 @@ +call([]); + } +} diff --git a/Modules/ApprovalWorkflow/module.json b/Modules/ApprovalWorkflow/module.json new file mode 100644 index 0000000..44d5248 --- /dev/null +++ b/Modules/ApprovalWorkflow/module.json @@ -0,0 +1,11 @@ +{ + "name": "ApprovalWorkflow", + "alias": "approvalworkflow", + "description": "", + "keywords": [], + "priority": 0, + "providers": [ + "Modules\\ApprovalWorkflow\\Providers\\ApprovalWorkflowServiceProvider" + ], + "files": [] +} diff --git a/Modules/ApprovalWorkflow/package.json b/Modules/ApprovalWorkflow/package.json new file mode 100644 index 0000000..d6fbfc8 --- /dev/null +++ b/Modules/ApprovalWorkflow/package.json @@ -0,0 +1,15 @@ +{ + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "devDependencies": { + "axios": "^1.1.2", + "laravel-vite-plugin": "^0.7.5", + "sass": "^1.69.5", + "postcss": "^8.3.7", + "vite": "^4.0.0" + } +} diff --git a/Modules/ApprovalWorkflow/resources/assets/.gitkeep b/Modules/ApprovalWorkflow/resources/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Modules/ApprovalWorkflow/resources/assets/js/app.js b/Modules/ApprovalWorkflow/resources/assets/js/app.js new file mode 100644 index 0000000..e69de29 diff --git a/Modules/ApprovalWorkflow/resources/assets/sass/app.scss b/Modules/ApprovalWorkflow/resources/assets/sass/app.scss new file mode 100644 index 0000000..e69de29 diff --git a/Modules/ApprovalWorkflow/resources/js/Components/ApprovableBreakdown.tsx b/Modules/ApprovalWorkflow/resources/js/Components/ApprovableBreakdown.tsx new file mode 100644 index 0000000..067b410 --- /dev/null +++ b/Modules/ApprovalWorkflow/resources/js/Components/ApprovableBreakdown.tsx @@ -0,0 +1,83 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card'; +import { Button } from '@/Components/ui/button'; +import { ExternalLink, FileText, ShoppingCart } from 'lucide-react'; + +interface Props { + chain: any; + breakdownData?: any; +} + +export default function ApprovableBreakdown({ chain, breakdownData }: Props) { + const approvable = chain.approvable; + if (!approvable || !breakdownData) return null; + + let title = 'Document Breakdown'; + let icon = ; + let details: { label: string; value: React.ReactNode }[] = []; + let linkUrl = '#'; + + switch (chain.approvable_type) { + case 'Modules\\MaterialLogistics\\Models\\MaterialRequisition': + title = 'Material Requisition Details'; + icon = ; + linkUrl = route('requisitions.show', approvable.ulid || approvable.id); + details = [ + { label: 'Document Number', value: breakdownData.document_number || `REQ-${approvable.id}` }, + { label: 'Total Cost', value: breakdownData.total_cost != null ? `₱${parseFloat(breakdownData.total_cost).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : 'N/A' }, + { label: 'Notes', value: breakdownData.notes || 'None' }, + ]; + break; + case 'Modules\\MaterialLogistics\\Models\\PurchaseOrder': + title = 'Purchase Order Details'; + icon = ; + linkUrl = route('purchase-orders.show', approvable.ulid || approvable.id); + details = [ + { label: 'PO Number', value: breakdownData.document_number || `PO-${approvable.id}` }, + { label: 'Total Amount', value: breakdownData.total_cost != null ? `₱${parseFloat(breakdownData.total_cost).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : 'N/A' }, + { label: 'Notes', value: breakdownData.notes || 'None' }, + ]; + break; + default: + title = 'Generic Document'; + break; + } + + return ( + + + + {icon} {title} + + + + + + + {details.length > 0 ? ( +
+ {details.map((detail, idx) => ( +
+

{detail.label}

+

{detail.value}

+
+ ))} +
+ ) : ( +

Details not found or unsupported document type.

+ )} + + +
+
+ ); +} diff --git a/Modules/ApprovalWorkflow/resources/js/Components/ApprovalTimeline.tsx b/Modules/ApprovalWorkflow/resources/js/Components/ApprovalTimeline.tsx new file mode 100644 index 0000000..15ef6e7 --- /dev/null +++ b/Modules/ApprovalWorkflow/resources/js/Components/ApprovalTimeline.tsx @@ -0,0 +1,66 @@ +import { Badge } from '@/Components/ui/badge'; +import { CheckCircle2, Circle, XCircle, SkipForward } from 'lucide-react'; + +interface Step { + id: number; ulid: string; + order: number; + status: string; + notes?: string; + acted_at?: string; + approver: { id: number; ulid: string; name: string }; +} + +interface Props { + steps: Step[]; +} + +const statusConfig: Record; color: string; bgColor: string }> = { + pending: { icon: Circle, color: 'text-gray-400', bgColor: 'bg-gray-100' }, + approved: { icon: CheckCircle2, color: 'text-green-600', bgColor: 'bg-green-50' }, + rejected: { icon: XCircle, color: 'text-red-600', bgColor: 'bg-red-50' }, + skipped: { icon: SkipForward, color: 'text-gray-400', bgColor: 'bg-gray-50' }, +}; + +export default function ApprovalTimeline({ steps }: Props) { + return ( +
+ {steps.map((step, idx) => { + const config = statusConfig[step.status] || statusConfig.pending; + const Icon = config.icon; + const isLast = idx === steps.length - 1; + + return ( +
+ {/* Timeline line + icon */} +
+
+ +
+ {!isLast && ( +
+ )} +
+ + {/* Content */} +
+
+ {step.approver.name} + + {step.status.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())} + +
+ {step.notes && ( +

"{step.notes}"

+ )} + {step.acted_at && ( +

+ {new Date(step.acted_at).toLocaleString()} +

+ )} +
+
+ ); + })} +
+ ); +} diff --git a/Modules/ApprovalWorkflow/resources/js/Pages/Approvals/Index.tsx b/Modules/ApprovalWorkflow/resources/js/Pages/Approvals/Index.tsx new file mode 100644 index 0000000..1af8ebf --- /dev/null +++ b/Modules/ApprovalWorkflow/resources/js/Pages/Approvals/Index.tsx @@ -0,0 +1,147 @@ +import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; +import { Head, Link, usePage } from '@inertiajs/react'; +import { Badge } from '@/Components/ui/badge'; +import { Button } from '@/Components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card'; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from '@/Components/ui/table'; +import { PaginatedData, PageProps } from '@/types'; +import { ClipboardCheck, Eye } from 'lucide-react'; + +interface ChainItem { + id: number; ulid: string; + approvable_type: string; + type: string; + status: string; + notes?: string; + created_at: string; + initiator?: { id: number; ulid: string; name: string }; + steps: { + id: number; ulid: string; order: number; status: string; + approver: { id: number; ulid: string; name: string }; + }[]; +} + +interface Props extends PageProps { + approvals: PaginatedData; + tab: string; +} + +const typeLabel = (type: string) => type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); +const statusVariant = (s: string) => { + switch (s) { + case 'approved': return 'default'; + case 'rejected': return 'destructive'; + case 'in_review': return 'secondary'; + default: return 'outline'; + } +}; + +export default function Index({ approvals, tab }: Props) { + const { flash } = usePage().props; + const isHistory = tab === 'history'; + + return ( + + +

+ {isHistory ? 'Approval History' : 'Pending Approvals'} +

+
+ } + > + + +
+
+ {flash?.success &&
{flash.success}
} + {flash?.error &&
{flash.error}
} + +
+ + Pending Approvals + + + Approval History + +
+ + + {isHistory ? 'Your Past Approvals' : 'Items Awaiting Your Approval'} + + + + + Type + Initiated By + Status + Steps + Submitted + Actions + + + + {approvals.data.length === 0 ? ( + + + {isHistory ? 'No past approvals found.' : "No pending approvals. You're all caught up! 🎉"} + + + ) : ( + approvals.data.map((chain) => ( + + {typeLabel(chain.type)} + {chain.initiator?.name || '-'} + + + {typeLabel(chain.status)} + + + + {chain.steps.filter(s => s.status === 'approved').length}/{chain.steps.length} + + + {new Date(chain.created_at).toLocaleDateString()} + + + + + + + + )) + )} + +
+ + {approvals.last_page > 1 && ( +
+

+ Showing {approvals.from} to {approvals.to} of {approvals.total} +

+
+ {approvals.prev_page_url && ( + + + + )} + {approvals.next_page_url && ( + + + + )} +
+
+ )} +
+
+
+
+ + ); +} diff --git a/Modules/ApprovalWorkflow/resources/js/Pages/Approvals/Show.tsx b/Modules/ApprovalWorkflow/resources/js/Pages/Approvals/Show.tsx new file mode 100644 index 0000000..3b1f2c6 --- /dev/null +++ b/Modules/ApprovalWorkflow/resources/js/Pages/Approvals/Show.tsx @@ -0,0 +1,157 @@ +import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; +import { Head, Link, router, usePage } from '@inertiajs/react'; +import { Badge } from '@/Components/ui/badge'; +import { Button } from '@/Components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card'; +import { Input } from '@/Components/ui/input'; +import { Label } from '@/Components/ui/label'; +import ApprovalTimeline from '../../Components/ApprovalTimeline'; +import ApprovableBreakdown from '../../Components/ApprovableBreakdown'; +import { ArrowLeft, CheckCircle2, XCircle } from 'lucide-react'; +import { PageProps } from '@/types'; +import { useState } from 'react'; + +interface Step { + id: number; ulid: string; order: number; status: string; notes?: string; + acted_at?: string; approver: { id: number; ulid: string; name: string; email: string }; +} + +interface ChainData { + id: number; ulid: string; approvable_type: string; type: string; status: string; + notes?: string; created_at: string; completed_at?: string; + initiator?: { id: number; ulid: string; name: string; email: string }; + steps: Step[]; + approvable?: any; +} + +interface Props extends PageProps { + chain: ChainData; + breakdownData: any; +} + +const typeLabel = (s: string) => s.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); +const statusVariant = (s: string) => { + switch (s) { case 'approved': return 'default'; case 'rejected': return 'destructive'; case 'in_review': return 'secondary'; default: return 'outline'; } +}; + +export default function Show({ chain, breakdownData }: Props) { + const { flash, auth } = usePage().props; + const [approveNotes, setApproveNotes] = useState(''); + const [rejectNotes, setRejectNotes] = useState(''); + + const currentStep = chain.steps.find(s => s.status === 'pending'); + const isCurrentApprover = currentStep && currentStep.approver.id === auth.user.id; + + const handleApprove = () => { + router.patch(route('approvals.approve', chain.ulid), { notes: approveNotes || undefined }); + }; + + const handleReject = () => { + if (!rejectNotes.trim()) { + alert('Please provide a reason for rejection.'); + return; + } + router.patch(route('approvals.reject', chain.ulid), { notes: rejectNotes }); + }; + + return ( + + + + +
+

+ Approval: {typeLabel(chain.type)} #{chain.id} +

+
+ {typeLabel(chain.status)} + + } + > + + +
+
+ {flash?.success &&
{flash.success}
} + {flash?.error &&
{flash.error}
} + + {/* Chain Info */} + + Details + +
+
+

Type

+

{typeLabel(chain.type)}

+
+
+

Initiated By

+

{chain.initiator?.name || 'System'}

+
+
+

Submitted

+

{new Date(chain.created_at).toLocaleString()}

+
+ {chain.completed_at && ( +
+

Completed

+

{new Date(chain.completed_at).toLocaleString()}

+
+ )} +
+ {chain.notes && ( +
+

Notes

+

{chain.notes}

+
+ )} +
+
+ + {/* Approvable Breakdown */} + + + {/* Timeline */} + + Approval Progress + + + + + + {/* Action Buttons */} + {isCurrentApprover && chain.status === 'in_review' && ( + + Your Action Required + +
+ +