diff --git a/app/Filament/Resources/VisitorEntryResource.php b/app/Filament/Resources/VisitorEntryResource.php new file mode 100644 index 0000000..8cb6650 --- /dev/null +++ b/app/Filament/Resources/VisitorEntryResource.php @@ -0,0 +1,351 @@ +schema([ + Forms\Components\TextInput::make('mobile_number') + ->label('Mobile Number') + ->length(10) + ->reactive() + ->extraInputAttributes([ + 'oninput' => 'this.value = this.value.replace(/[^0-9]/g, "").slice(0, 10)', // blocks non-numbers + limits to 10 chars + 'maxlength' => 10, + ]) + ->required() + ->extraAttributes([ + 'id' => 'mobile_number_input', + 'x-data' => '{ value: "" }', + 'x-model' => 'value', + 'wire:keydown.enter.prevent' => 'processMobile(value)', + ]), + Forms\Components\TextInput::make('name') + ->label('Name') + ->required() + ->reactive() + ->extraInputAttributes([ + 'oninput' => 'this.value = this.value.replace(/[^a-zA-Z\s]/g, "")', + ]), + Forms\Components\Select::make('type') + ->label('Type') + ->reactive() + ->options([ + 'Student' => 'Student', + 'Consultant' => 'Consultant', + 'Vendor' => 'Vendor', + 'Other' => 'Other', + ]) + ->required() + ->dehydrateStateUsing(function ($state, callable $get) { + return $state == 'Other' + ? $get('other_type') + : $state; + }), + Forms\Components\TextInput::make('other_type') + ->label('Specify Type') + ->reactive() + ->visible(fn (callable $get) => $get('type') == 'Other') + ->required(fn (callable $get) => $get('type') == 'Other') + ->dehydrated(false), + Forms\Components\TextInput::make('company') + ->label('Company') + ->required(), + Forms\Components\Select::make('department') + ->label('Employee Department') + ->options( + \App\Models\EmployeeMaster::distinct() + ->pluck('department', 'department') + ) + ->required() + ->reactive() + ->afterStateUpdated(function (callable $set) { + $set('employee_master_id', null); + $set('code', null); + }), + // Forms\Components\Select::make('employee_master_id') + // ->label('Recipient Employee') + // ->required() + // ->options(function (callable $get) { + // $department = $get('department'); + + // if (!$department) { + // return []; + // } + + // return \App\Models\EmployeeMaster::where('department', $department) + // ->pluck('name', 'id'); + // }) + // ->reactive() + // ->afterStateUpdated(function (callable $set, callable $get, ?string $state) { + // $department = $get('department'); + + // $employee = \App\Models\EmployeeMaster::where('id', $state) + // ->where('department', $department) + // ->first(); + + // $set('code', $employee ? $employee->code : ''); + // }), + + Forms\Components\Select::make('employee_master_id') + ->label('Recipient Employee') + ->required() + ->options(function (callable $get) { + $department = $get('department'); + // Always load ALL employees, filter by department if set + if ($department) { + return \App\Models\EmployeeMaster::where('department', $department) + ->pluck('name', 'id'); + } + // Fallback: load all so fill() can always match the ID + return \App\Models\EmployeeMaster::pluck('name', 'id'); + }) + ->reactive() + ->afterStateUpdated(function (callable $set, ?string $state) { + $employee = \App\Models\EmployeeMaster::find($state); + $set('code', $employee?->code ?? ''); + }), + + Forms\Components\TextInput::make('code') + ->label('Employee Code') + ->readOnly(), + Forms\Components\Textarea::make('purpose_of_visit') + ->label('Purpose of Visit') + ->required(), + Forms\Components\TextInput::make('number_of_person') + ->numeric() + ->default(1) + ->required(), + Forms\Components\DateTimePicker::make('in_time') + ->label('In Time'), + Forms\Components\DateTimePicker::make('out_time') + ->label('Out Time'), + Forms\Components\View::make('components.webcam-field') + ->columnSpanFull(), + Forms\Components\Hidden::make('photo'), + Forms\Components\Hidden::make('created_by') + ->label('created_by') + ->default(Filament::auth()->user()?->name), + Forms\Components\Hidden::make('updated_by') + ->label('updated_by') + ->default(Filament::auth()->user()?->name), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('No.') + ->label('NO') + ->alignCenter() + ->getStateUsing(function ($record, $livewire, $column, $rowLoop) { + $paginator = $livewire->getTableRecords(); + $perPage = method_exists($paginator, 'perPage') ? $paginator->perPage() : 10; + $currentPage = method_exists($paginator, 'currentPage') ? $paginator->currentPage() : 1; + + return ($currentPage - 1) * $perPage + $rowLoop->iteration; + }), + Tables\Columns\ImageColumn::make('photo') + ->label('Photo') + ->disk('public') + ->height(50) + ->width(50) + // ->defaultImageUrl('https://ui-avatars.com/api/?name=Visitor&background=555&color=fff') + ->defaultImageUrl(asset('images/profile.png')) + ->alignCenter() + ->extraImgAttributes(['style' => 'border-radius: 6px; object-fit: cover;']), + Tables\Columns\TextColumn::make('type') + ->label('Visitor Type') + ->alignCenter() + ->sortable(), + Tables\Columns\TextColumn::make('name') + ->label('Visitor Name') + ->sortable() + ->alignCenter() + ->searchable(), + Tables\Columns\TextColumn::make('mobile_number') + ->label('Visitor Mobile Number') + ->alignCenter() + ->sortable(), + Tables\Columns\TextColumn::make('employeeMaster.name') + ->label('Recipient Name') + ->alignCenter() + ->sortable(), + Tables\Columns\TextColumn::make('employeeMaster.code') + ->label('Receipient ID') + ->alignCenter() + ->sortable(), + Tables\Columns\TextColumn::make('employeeMaster.department') + ->label('Receipient Department') + ->alignCenter() + ->sortable(), + Tables\Columns\TextColumn::make('number_of_person') + ->label('Number of Person') + ->numeric() + ->alignCenter() + ->sortable(), + Tables\Columns\TextColumn::make('in_time') + ->label('In Time') + ->dateTime() + ->sortable() + ->alignCenter(), + Tables\Columns\TextColumn::make('out_time') + ->label('Out Time') + ->dateTime() + ->sortable() + ->alignCenter(), + Tables\Columns\TextColumn::make('created_at') + ->label('Created At') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true) + ->alignCenter(), + Tables\Columns\TextColumn::make('updated_at') + ->label('Updated At') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true) + ->alignCenter(), + Tables\Columns\TextColumn::make('deleted_at') + ->label('Deleted At') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true) + ->alignCenter(), + ]) + ->filters([ + Tables\Filters\TrashedFilter::make(), + ]) + ->actions([ + Tables\Actions\ViewAction::make(), + Tables\Actions\EditAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + Tables\Actions\ForceDeleteBulkAction::make(), + Tables\Actions\RestoreBulkAction::make(), + ]), + ]); + } + + public static function infolist(Infolist $infolist): Infolist + { + return $infolist + ->schema([ + + // ── Visitor Photo (full width at the top) ── + Section::make('Visitor Photo') + ->schema([ + ImageEntry::make('photo') + ->label('') + ->disk('public') + ->height(220) + ->defaultImageUrl(asset('images/profile.png')) + ->extraImgAttributes([ + 'style' => 'border-radius: 10px; object-fit: cover;' + ]), + ]) + ->columnSpanFull(), + + // ── Visitor Details ── + Section::make('Visitor Details') + ->schema([ + TextEntry::make('name') + ->label('Visitor Name'), + TextEntry::make('mobile_number') + ->label('Mobile Number'), + TextEntry::make('type') + ->label('Visitor Type') + ->badge() + ->color(fn (string $state): string => match ($state) { + 'internal' => 'success', + 'external' => 'warning', + default => 'gray', + }), + TextEntry::make('company') + ->label('Company'), + TextEntry::make('purpose_of_visit') + ->label('Purpose of Visit') + ->columnSpanFull(), + ]) + ->columns(2), + + // ── Employee Details ── + Section::make('Recipient Details') + ->schema([ + TextEntry::make('employeeMaster.name') + ->label('Recipient Employee'), + TextEntry::make('code') + ->label('Employee Code'), + TextEntry::make('employeeMaster.department') + ->label('Department'), + TextEntry::make('number_of_person') + ->label('Number of Persons'), + ]) + ->columns(2), + + // ── Visit Timing ── + Section::make('Visit Timing') + ->schema([ + TextEntry::make('in_time') + ->label('In Time') + ->dateTime(), + TextEntry::make('out_time') + ->label('Out Time') + ->dateTime(), + ]) + ->columns(2), + + ]); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListVisitorEntries::route('/'), + 'create' => Pages\CreateVisitorEntry::route('/create'), + 'view' => Pages\ViewVisitorEntry::route('/{record}'), + 'edit' => Pages\EditVisitorEntry::route('/{record}/edit'), + ]; + } + + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery() + ->withoutGlobalScopes([ + SoftDeletingScope::class, + ]); + } +} diff --git a/app/Filament/Resources/VisitorEntryResource/Pages/CreateVisitorEntry.php b/app/Filament/Resources/VisitorEntryResource/Pages/CreateVisitorEntry.php new file mode 100644 index 0000000..2a81930 --- /dev/null +++ b/app/Filament/Resources/VisitorEntryResource/Pages/CreateVisitorEntry.php @@ -0,0 +1,121 @@ +latest()->first(); + + if ($visitor) { + + $employee = EmployeeMaster::where('id', $visitor->employee_master_id)->first(); + + $this->form->fill([ + 'mobile_number' => $mobile ?? '', + 'name' => $visitor->name ?? '', + 'company' => $visitor->company ?? '', + 'type' => $visitor->type ?? '', + 'department' => $employee->department ?? '', + 'employee_master_id' => $visitor->employee_master_id->name ?? '', + 'code' => $employee->code ?? '', + ]); + } + else { + + $this->form->fill([ + 'mobile_number' => $mobile ?? '', + 'name' => $visitor->name ?? '', + 'company' => $visitor->company ?? '', + 'type' => $visitor->type ?? '', + 'department' => $employee->department ?? '', + 'employee_master_id' => $visitor->employee_master_id->name ?? '', + 'code' => $employee->code ?? '', + ]); + } + } + + #[On('photo-captured')] + public function handlePhotoCapture(string $photo): void + { + $this->data['photo'] = $photo; + \Log::info('WEBCAM: photo-captured event received, length: ' . strlen($photo)); + } + + protected function mutateFormDataBeforeCreate(array $data): array + { + if ( + !empty($data['photo']) && + str_starts_with($data['photo'], 'data:image') + ) { + try { + $imageData = explode(',', $data['photo'])[1]; + + $filename = 'visitor_' . time() . '_' . uniqid() . '.jpg'; + + $path = 'visitor-photos/' . $filename; + + $decoded = base64_decode($imageData); + + $saved = Storage::disk('public')->put($path, $decoded); + + $data['photo'] = $path; + + } catch (\Exception $e) { + \Log::error('PHOTO UPLOAD ERROR: ' . $e->getMessage()); + } + } + + return $data; + } + + + public function setPhoto(string $photo): void + { + $this->capturedPhoto = $photo; + + $this->dispatch('photo-captured', photo: $photo)->to(\App\Filament\Resources\VisitorEntryResource\Pages\CreateVisitorEntry::class); + } + + // ── Custom form action buttons ── + protected function getFormActions(): array + { + return [ + $this->getCreateFormAction(), + $this->getCreateAnotherFormAction(), + + Action::make('createAndPrint') + ->label('Create & Print') + ->color('success') + ->icon('heroicon-o-printer') + ->action(function () { + // Save the record using existing logic (calls mutateFormDataBeforeCreate internally) + $this->create(); + + // Open the badge print page in a new tab + $this->js("window.open('" . route('visitor.badge', $this->record->id) . "', '_blank')"); + }), + + $this->getCancelFormAction(), + ]; + } + + protected function getRedirectUrl(): string + { + return $this->getResource()::getUrl('view', ['record' => $this->record]); + } +} diff --git a/app/Filament/Resources/VisitorEntryResource/Pages/EditVisitorEntry.php b/app/Filament/Resources/VisitorEntryResource/Pages/EditVisitorEntry.php new file mode 100644 index 0000000..ba793bf --- /dev/null +++ b/app/Filament/Resources/VisitorEntryResource/Pages/EditVisitorEntry.php @@ -0,0 +1,56 @@ +data['photo'] = $photo; + } + + // Runs automatically before the record is updated in the database + protected function mutateFormDataBeforeSave(array $data): array + { + if ( + !empty($data['photo']) && + str_starts_with($data['photo'], 'data:image') + ) { + // Delete the old photo file if one exists + $oldPhoto = $this->record->photo; + if ($oldPhoto && Storage::disk('public')->exists($oldPhoto)) { + Storage::disk('public')->delete($oldPhoto); + } + + // Save the new photo + $imageData = explode(',', $data['photo'])[1]; + $filename = 'visitor_' . time() . '_' . uniqid() . '.jpg'; + $path = 'visitor-photos/' . $filename; + Storage::disk('public')->put($path, base64_decode($imageData)); + + $data['photo'] = $path; + } + + return $data; + } +} diff --git a/app/Filament/Resources/VisitorEntryResource/Pages/ListVisitorEntries.php b/app/Filament/Resources/VisitorEntryResource/Pages/ListVisitorEntries.php new file mode 100644 index 0000000..05e6266 --- /dev/null +++ b/app/Filament/Resources/VisitorEntryResource/Pages/ListVisitorEntries.php @@ -0,0 +1,19 @@ +label('Print Badge') + ->icon('heroicon-o-printer') + ->color('success') + ->url(fn () => route('visitor.badge', $this->record->id)) + ->openUrlInNewTab(), + ]; + } +} diff --git a/app/Http/Controllers/VisitorBadgeController.php b/app/Http/Controllers/VisitorBadgeController.php new file mode 100644 index 0000000..2b712ce --- /dev/null +++ b/app/Http/Controllers/VisitorBadgeController.php @@ -0,0 +1,21 @@ +findOrFail($id); + + $photoUrl = $visitor->photo + ? Storage::disk('public')->url($visitor->photo) + : null; + + return view('visitor.badge', compact('visitor', 'photoUrl')); + } +} diff --git a/app/Livewire/Webcam.php b/app/Livewire/Webcam.php new file mode 100644 index 0000000..e53672f --- /dev/null +++ b/app/Livewire/Webcam.php @@ -0,0 +1,33 @@ +capturedPhoto = $photo; + + // Fires a browser event that the Filament form will listen to + $this->dispatch('photo-captured', photo: $photo); + } + + // Called from JavaScript when user clicks "Retake" + public function clearPhoto(): void + { + $this->capturedPhoto = ''; + + $this->dispatch('photo-captured', photo: ''); + } + public function render() + { + return view('livewire.webcam'); + } +} + diff --git a/app/Models/VisitorEntry.php b/app/Models/VisitorEntry.php new file mode 100644 index 0000000..706e787 --- /dev/null +++ b/app/Models/VisitorEntry.php @@ -0,0 +1,30 @@ +belongsTo(EmployeeMaster::class); + } +} diff --git a/app/Policies/VisitorEntryPolicy.php b/app/Policies/VisitorEntryPolicy.php new file mode 100644 index 0000000..47e8c59 --- /dev/null +++ b/app/Policies/VisitorEntryPolicy.php @@ -0,0 +1,106 @@ +checkPermissionTo('view-any VisitorEntry'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, VisitorEntry $visitorentry): bool + { + return $user->checkPermissionTo('view VisitorEntry'); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->checkPermissionTo('create VisitorEntry'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, VisitorEntry $visitorentry): bool + { + return $user->checkPermissionTo('update VisitorEntry'); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, VisitorEntry $visitorentry): bool + { + return $user->checkPermissionTo('delete VisitorEntry'); + } + + /** + * Determine whether the user can delete any models. + */ + public function deleteAny(User $user): bool + { + return $user->checkPermissionTo('delete-any VisitorEntry'); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, VisitorEntry $visitorentry): bool + { + return $user->checkPermissionTo('restore VisitorEntry'); + } + + /** + * Determine whether the user can restore any models. + */ + public function restoreAny(User $user): bool + { + return $user->checkPermissionTo('restore-any VisitorEntry'); + } + + /** + * Determine whether the user can replicate the model. + */ + public function replicate(User $user, VisitorEntry $visitorentry): bool + { + return $user->checkPermissionTo('replicate VisitorEntry'); + } + + /** + * Determine whether the user can reorder the models. + */ + public function reorder(User $user): bool + { + return $user->checkPermissionTo('reorder VisitorEntry'); + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, VisitorEntry $visitorentry): bool + { + return $user->checkPermissionTo('force-delete VisitorEntry'); + } + + /** + * Determine whether the user can permanently delete any models. + */ + public function forceDeleteAny(User $user): bool + { + return $user->checkPermissionTo('force-delete-any VisitorEntry'); + } +} diff --git a/database/migrations/2026_05_23_104243_create_visitor_entries_table.php b/database/migrations/2026_05_23_104243_create_visitor_entries_table.php new file mode 100644 index 0000000..120ac4d --- /dev/null +++ b/database/migrations/2026_05_23_104243_create_visitor_entries_table.php @@ -0,0 +1,47 @@ + track.stop()); + } + this.cameraActive = false; + this.captured = true; + this.photoData = photoData; + + // Send photo data to PHP via Livewire + $wire.setPhoto(photoData); + }, + + retake() { + this.captured = false; + this.photoData = ''; + $wire.clearPhoto(); + this.$nextTick(() => this.startCamera()); + } + }" + style="font-family: inherit;" +> + {{-- ── Error message ── --}} + + + {{-- ── Live video feed (shown while camera is active) ── --}} +
+ +
+ + {{-- ── Captured photo preview (shown after capture) ── --}} +
+ Captured visitor photo +
✓ Photo captured
+
+ + {{-- ── Placeholder (before camera starts) ── --}} +
+ 📷 Camera not started yet +
+ + {{-- ── Hidden canvas used for capturing the frame ── --}} + + + {{-- ── Buttons ── --}} +
+ + {{-- Start Camera button --}} + + + {{-- Capture button --}} + + + {{-- Retake button --}} + + +
+ diff --git a/resources/views/visitor/badge.blade.php b/resources/views/visitor/badge.blade.php new file mode 100644 index 0000000..e3585c0 --- /dev/null +++ b/resources/views/visitor/badge.blade.php @@ -0,0 +1,222 @@ + + + + + Visitor Badge + + + + + {{-- ── Print button (visible on screen only, hidden when printing) ── --}} +
+ + +
+ +
+ + {{-- Header --}} +
+ {{ strtoupper($visitor->type ?? 'VISITOR') }} + #{{ str_pad($visitor->id, 5, '0', STR_PAD_LEFT) }} +
+ + {{-- Body --}} +
+ + {{-- Left: fields --}} +
+
+ Name: + {{ strtoupper($visitor->name) }} +
+
+ Company: + {{ $visitor->company }} +
+
+ To Meet: + {{ strtoupper($visitor->employeeMaster?->name ?? '—') }} +
+
+ Dept: + + {{ strtoupper($visitor->employeeMaster?->department ?? $visitor->department ?? '—') }} + +
+
+ Valid upto: + + {{ $visitor->out_time ? \Carbon\Carbon::parse($visitor->out_time)->format('d/m/Y H:i') : '—' }} + +
+
+ Date&Time: + + {{ $visitor->in_time ? \Carbon\Carbon::parse($visitor->in_time)->format('d/m/Y H:i') : '—' }} + +
+
+ No of Visitor: + {{ $visitor->number_of_person ?? 1 }} +
+
+ + {{-- Right: photo + host sign --}} +
+ @if($photoUrl) + Visitor Photo + @else +
No Photo
+ @endif +
Host Sign
+
+ +
+ + {{-- Footer --}} + + +
+ + + diff --git a/routes/web.php b/routes/web.php index 32ecd52..7bc53b6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,6 +3,7 @@ use App\Http\Controllers\CharacteristicApprovalController; use App\Http\Controllers\ProductionOrderController; // use App\Http\Controllers\FileUploadController; +use App\Http\Controllers\VisitorBadgeController; use App\Models\EquipmentMaster; use App\Models\User; use Filament\Facades\Filament; @@ -65,6 +66,8 @@ Route::get('production-orders/{production_order}/{plant_code}/printItemSerial', [ProductionOrderController::class, 'printItemSerial'] )->name('production-orders.printItemSerial'); +Route::get('/visitor-badge/{id}', [VisitorBadgeController::class, 'show']) + ->name('visitor.badge'); // Route::get('/characteristic/approve', [CharacteristicApprovalController::class, 'approve']) // ->name('characteristic.approve') // ->middleware('signed');