diff --git a/app/Filament/Exports/EmployeeMasterExporter.php b/app/Filament/Exports/EmployeeMasterExporter.php new file mode 100644 index 0000000..a3492cd --- /dev/null +++ b/app/Filament/Exports/EmployeeMasterExporter.php @@ -0,0 +1,44 @@ +label('ID'), + ExportColumn::make('plant.name'), + ExportColumn::make('name'), + ExportColumn::make('code'), + ExportColumn::make('department'), + ExportColumn::make('designation'), + ExportColumn::make('email'), + ExportColumn::make('mobile_number'), + ExportColumn::make('created_at'), + ExportColumn::make('updated_at'), + ExportColumn::make('created_by'), + ExportColumn::make('updated_by'), + ExportColumn::make('deleted_at'), + ]; + } + + public static function getCompletedNotificationBody(Export $export): string + { + $body = 'Your employee master export has completed and ' . number_format($export->successful_rows) . ' ' . str('row')->plural($export->successful_rows) . ' exported.'; + + if ($failedRowsCount = $export->getFailedRowsCount()) { + $body .= ' ' . number_format($failedRowsCount) . ' ' . str('row')->plural($failedRowsCount) . ' failed to export.'; + } + + return $body; + } +} diff --git a/app/Filament/Imports/EmployeeMasterImporter.php b/app/Filament/Imports/EmployeeMasterImporter.php new file mode 100644 index 0000000..64bc34f --- /dev/null +++ b/app/Filament/Imports/EmployeeMasterImporter.php @@ -0,0 +1,53 @@ +requiredMapping() + ->relationship() + ->rules(['required']), + ImportColumn::make('name'), + ImportColumn::make('code'), + ImportColumn::make('department'), + ImportColumn::make('designation'), + ImportColumn::make('email') + ->rules(['email']), + ImportColumn::make('mobile_number'), + ImportColumn::make('created_by'), + ImportColumn::make('updated_by'), + ]; + } + + public function resolveRecord(): ?EmployeeMaster + { + // return EmployeeMaster::firstOrNew([ + // // Update existing records, matching them by `$this->data['column_name']` + // 'email' => $this->data['email'], + // ]); + + return new EmployeeMaster(); + } + + public static function getCompletedNotificationBody(Import $import): string + { + $body = 'Your employee master import has completed and ' . number_format($import->successful_rows) . ' ' . str('row')->plural($import->successful_rows) . ' imported.'; + + if ($failedRowsCount = $import->getFailedRowsCount()) { + $body .= ' ' . number_format($failedRowsCount) . ' ' . str('row')->plural($failedRowsCount) . ' failed to import.'; + } + + return $body; + } +} diff --git a/app/Filament/Resources/EmployeeMasterResource.php b/app/Filament/Resources/EmployeeMasterResource.php new file mode 100644 index 0000000..882011e --- /dev/null +++ b/app/Filament/Resources/EmployeeMasterResource.php @@ -0,0 +1,217 @@ +schema([ + Forms\Components\Select::make('plant_id') + ->label('Plant') + ->relationship('plant', 'name') + ->required(), + Forms\Components\TextInput::make('name') + ->label('Name') + ->required() + ->reactive() + ->extraInputAttributes([ + 'oninput' => 'this.value = this.value.replace(/[^a-zA-Z\s]/g, "")', + ]), + Forms\Components\TextInput::make('code') + ->label('ID') + ->extraInputAttributes([ + 'oninput' => 'this.value = this.value.replace(/[^a-zA-Z0-9]/g, "")',]) + ->required() + ->unique( + table: 'employee_masters', + column: 'code', + ignoreRecord: true + ) + ->validationMessages([ + 'unique' => 'Duplicate employee code already exists.', + ]), + Forms\Components\TextInput::make('department') + ->label('Department') + ->required(), + Forms\Components\TextInput::make('designation') + ->label('Designation') + ->extraInputAttributes([ + 'oninput' => 'this.value = this.value.replace(/[^a-zA-Z\s]/g, "")',]) + ->required(), + Forms\Components\TextInput::make('email') + ->label('Email') + ->email() + ->required(), + 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() + ->unique( + table: 'employee_masters', + column: 'mobile_number', + ignoreRecord: true + ) + ->validationMessages([ + 'unique' => 'Duplicate mobile number already exists.', + ]), + 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.') + ->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; + }) + ->alignCenter(), + Tables\Columns\TextColumn::make('plant.name') + ->numeric() + ->sortable() + ->alignCenter(), + Tables\Columns\TextColumn::make('name') + ->label('Name') + ->sortable() + ->alignCenter() + ->searchable(), + Tables\Columns\TextColumn::make('code') + ->label('Employee ID') + ->sortable() + ->alignCenter() + ->searchable(), + Tables\Columns\TextColumn::make('department') + ->label('Department') + ->sortable() + ->alignCenter() + ->searchable(), + Tables\Columns\TextColumn::make('designation') + ->label('Designation') + ->sortable() + ->alignCenter() + ->searchable(), + Tables\Columns\TextColumn::make('email') + ->label('Email') + ->sortable() + ->alignCenter() + ->searchable(), + Tables\Columns\TextColumn::make('mobile_number') + ->label('Mobile Number') + ->sortable() + ->alignCenter() + ->searchable(), + Tables\Columns\TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->alignCenter(), + Tables\Columns\TextColumn::make('updated_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true) + ->alignCenter(), + Tables\Columns\TextColumn::make('created_by') + ->label('Created by') + ->sortable() + ->alignCenter(), + Tables\Columns\TextColumn::make('updated_by') + ->label('Updated by') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true) + ->alignCenter(), + Tables\Columns\TextColumn::make('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(), + ]), + ]) + ->headerActions([ + ImportAction::make() + ->importer(EmployeeMasterImporter::class) + ->visible(function() { + return Filament::auth()->user()->can('view import employee master'); + }), + ExportAction::make() + ->exporter(EmployeeMasterExporter::class) + ->visible(function() { + return Filament::auth()->user()->can('view export employee master'); + }), + ]); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListEmployeeMasters::route('/'), + 'create' => Pages\CreateEmployeeMaster::route('/create'), + 'view' => Pages\ViewEmployeeMaster::route('/{record}'), + 'edit' => Pages\EditEmployeeMaster::route('/{record}/edit'), + ]; + } + + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery() + ->withoutGlobalScopes([ + SoftDeletingScope::class, + ]); + } +} diff --git a/app/Filament/Resources/EmployeeMasterResource/Pages/CreateEmployeeMaster.php b/app/Filament/Resources/EmployeeMasterResource/Pages/CreateEmployeeMaster.php new file mode 100644 index 0000000..d1f36eb --- /dev/null +++ b/app/Filament/Resources/EmployeeMasterResource/Pages/CreateEmployeeMaster.php @@ -0,0 +1,12 @@ +serNo, $matches)) { + else { $itemCode = $matches[1]; $serialNumber = $matches[2]; @@ -195,58 +197,6 @@ class CreateStickerValidation extends CreateRecord return; } - $urls = []; - - foreach ($stickers as $sticker) { - $urls[] = route('stickers1.pdf', [ - 'stickerId' => $sticker['sticker_id'], - 'plant_id' => $this->plantId, - 'item_characteristic_id' => $sticker['item_characteristic'], - 'serial_number' => $serialNumber, - ]); - } - - $this->dispatch('open-stickers-sequence', urls: $urls); - - // $pdfPath = storage_path('app/private/uploads/StickerTemplateOcr/multi.pdf'); - - // if (! file_exists($pdfPath)) { - // Notification::make() - // ->danger() - // ->title('Pdf Not Found') - // ->body("pdf file not exist.") - // ->send(); - // return; - // } - - // if (! $printerName) { - // Notification::make() - // ->danger() - // ->title('Printer Not Found') - // ->body("No CUPS printer configured for IP: $iotsPrintIp") - // ->send(); - // return; - // } - - // putenv('CUPS_SERVER=printer.iotsignin.com'); - - // $cmd = "lp -d " . escapeshellarg($printerName) - // . " -o fit-to-page " - // . escapeshellarg($pdfPath); - - // exec($cmd, $out, $status); - - // if ($status != 0) { - // Notification::make() - // ->danger() - // ->title('Print Failed') - // ->body('CUPS print command failed.') - // ->send(); - // return; - // } - - //dd($iotsPrintIp, $matchedSticker); - StickerValidation::create([ 'plant_id' => $this->plantId, 'machine_id' => $this->workCenter, @@ -275,24 +225,192 @@ class CreateStickerValidation extends CreateRecord $this->dispatch('refreshEmptySticker', $plantId, $this->ref_number); - } + // foreach ($stickers as $sticker) { + + // // $printerName = $this->getCupsPrinterNameByIp($sticker['print_ip']); + + // \Log::info("Looking up printer for IP: " . $sticker['print_ip']); + // $printerName = $this->getCupsPrinterNameByIp($sticker['print_ip']); + // \Log::info("Found printer: " . ($printerName ?? 'NULL')); + + // if (! $printerName) { + // Notification::make() + // ->danger() + // ->title('Printer Not Found') + // ->body("No CUPS printer configured for IP: {$sticker['print_ip']}") + // ->send(); + // return; + // } + + // $structure = StickerStructureDetail::findOrFail($sticker['sticker_id']); + // $itemCharacteristic = ItemCharacteristic::where('plant_id', $this->plantId) + // ->where('id', $sticker['item_characteristic']) + // ->firstOrFail(); + + // $dynamicElements = StickerDetail::where( + // 'sticker_structure_detail_id', + // $structure->id + // )->where('element_type', 'Dynamic')->get(); + + + + // /** STEP 3: Stream PDF to CUPS (STDIN) */ + // $process = proc_open( + // 'lp -d ' . escapeshellarg($printerName) . ' -o fit-to-page -', + // [ + // ['pipe', 'r'], // STDIN + // ['pipe', 'w'], // STDOUT + // ['pipe', 'w'], // STDERR + // ], + // $pipes + // ); + + + // if (! is_resource($process)) { + // Notification::make() + // ->danger() + // ->title('Print Failed') + // ->body('Unable to start CUPS print process.') + // ->send(); + // return; + // // continue; + // } + + // $pdfContent = (new StickerPdfService())->generatePdf1( + // $structure->sticker_id, + // $dynamicElements, + // $itemCharacteristic, + // $serialNumber, + // $serNo + // ); + + // fwrite($pipes[0], $pdfContent); + // fclose($pipes[0]); + + // $stderr = stream_get_contents($pipes[2]); + // fclose($pipes[1]); + // fclose($pipes[2]); + + // $status = proc_close($process); + + // if ($status != 0) { + // Notification::make() + // ->danger() + // ->title('Print Failed') + // ->body("CUPS error: {$stderr}") + // ->send(); + // return; + // } + // } + + + foreach ($stickers as $sticker) + { + + \Log::info("Looking up printer for IP: " . $sticker['print_ip']); + + $printerName = $this->getCupsPrinterNameByIp($sticker['print_ip']); + + \Log::info("Found printer: " . ($printerName ?? 'NULL')); + + if (! $printerName) { + Notification::make() + ->danger() + ->title('Printer Not Found') + ->body("No CUPS printer configured for IP: {$sticker['print_ip']}") + ->send(); + return; + } + + $structure = StickerStructureDetail::findOrFail($sticker['sticker_id']); + + $itemCharacteristic = ItemCharacteristic::where('plant_id', $this->plantId) + ->where('id', $sticker['item_characteristic']) + ->firstOrFail(); + + $dynamicElements = StickerDetail::where( + 'sticker_structure_detail_id', + $structure->id + )->where('element_type', 'Dynamic')->get(); + + $pdfContent = (new StickerPdfService())->generatePdf1( + $structure->sticker_id, + $dynamicElements, + $itemCharacteristic, + $serialNumber, + $serNo + ); + + $tempPdfPath = storage_path('app/temp_sticker_' . uniqid() . '.pdf'); + file_put_contents($tempPdfPath, $pdfContent); + + exec( + "lp -d " . escapeshellarg($printerName) . " " . escapeshellarg($tempPdfPath), + $output, + $status + ); + + \Log::info("LP Output:", $output); + \Log::info("LP Status: " . $status); + + if ($status != 0) { + Notification::make() + ->danger() + ->title('Print Failed') + ->body("CUPS error while printing.") + ->send(); + + if (file_exists($tempPdfPath)) { + unlink($tempPdfPath); + } + + return; + } + + if (file_exists($tempPdfPath)) { + unlink($tempPdfPath); + } + } + + Notification::make() + ->success() + ->title('Sticker Printed') + ->body("Sticker for Serial Number: $serialNumber printed successfully!") + ->seconds(3) + ->send(); + + // [$itemCode, $serialNumber] = explode('|', $serNo); + + // $this->dispatch('open-sticker-pdf', [ + // 'url' => url("/sticker/pdf/{$itemCode}/{$serialNumber}/$this->plantId/$this->ref_number") + // ]); + + } } - private function getPrinterNameByIp(string $ip): ?string + protected function getCupsPrinterNameByIp(string $ip): ?string { - exec('lpstat -v', $output); - foreach ($output as $line) { - // Example: - // device for TSC_WC_01: socket://192.168.1.50:9100 - if (str_contains($line, $ip)) { - preg_match('/device for (.+?):/', $line, $matches); - return $matches[1] ?? null; + // exec('lpstat -v 2>&1', $output, $status); + + exec('lpstat -h cups:631 -v 2>&1', $output, $status); + + if ($status != 0 || empty($output)) { + return null; + } + + foreach ($output as $line){ + $parts = explode(':', $line, 2); + if (count($parts) < 2) continue; + + $printerName = trim(str_replace('device for', '', $parts[0])); + $deviceUri = trim($parts[1]); + + if (str_contains($deviceUri, $ip)) { + return $printerName; } } - return null; } - } diff --git a/app/Filament/Resources/UserResource.php b/app/Filament/Resources/UserResource.php index dbc5c35..1b0d8c5 100644 --- a/app/Filament/Resources/UserResource.php +++ b/app/Filament/Resources/UserResource.php @@ -52,7 +52,6 @@ class UserResource extends Resource // ->email() ->unique(ignoreRecord: true) ->required() - ->readOnly() // ->rule(function (callable $get) { // return Rule::unique('users', 'email') // ->ignore($get('id')); // Ignore current record during updates diff --git a/app/Filament/Resources/VisitorEntryResource.php b/app/Filament/Resources/VisitorEntryResource.php new file mode 100644 index 0000000..bb0bd14 --- /dev/null +++ b/app/Filament/Resources/VisitorEntryResource.php @@ -0,0 +1,276 @@ +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 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..5095d4f --- /dev/null +++ b/app/Filament/Resources/VisitorEntryResource/Pages/CreateVisitorEntry.php @@ -0,0 +1,150 @@ +data['photo'] = $photo; + // } + + public function processMobile($mobile) + { + $visitor = VisitorEntry::where('mobile_number', $mobile)->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 ?? '', + ]); + } + } + + // protected function mutateFormDataBeforeCreate(array $data): array + // { + // if ( + // !empty($data['photo']) && + // str_starts_with($data['photo'], 'data:image') + // ) { + // // Step A: Strip the "data:image/jpeg;base64," prefix + // $imageData = explode(',', $data['photo'])[1]; + + // // Step B: Generate a unique filename + // $filename = 'visitor_' . time() . '_' . uniqid() . '.jpg'; + + // // Step C: Decode Base64 and save as a real .jpg file + // $path = 'visitor-photos/' . $filename; + // Storage::disk('public')->put($path, base64_decode($imageData)); + + // // Step D: Replace the Base64 string with just the file path + // $data['photo'] = $path; + // } + + // return $data; + // } + + #[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 + // { + // \Log::info('WEBCAM: mutateFormDataBeforeCreate called, photo value: ' . substr($data['photo'] ?? 'NULL', 0, 50)); + + // if ( + // !empty($data['photo']) && + // str_starts_with($data['photo'], 'data:image') + // ) { + // $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; + + // \Log::info('WEBCAM: photo saved to ' . $path); + // } + + // return $data; + // } + + 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); + + \Log::info('PHOTO UPLOAD (PUBLIC):', [ + 'filename' => $filename, + 'path' => $path, + 'size_bytes' => strlen($decoded), + 'saved' => $saved ? 'SUCCESS' : 'FAILED', + ]); + + $data['photo'] = $path; + + } catch (\Exception $e) { + \Log::error('PHOTO UPLOAD ERROR: ' . $e->getMessage()); + } + } + + return $data; + } + + + public function setPhoto(string $photo): void + { + $this->capturedPhoto = $photo; + + // Change this ↓ to dispatch to parent explicitly + $this->dispatch('photo-captured', photo: $photo)->to(\App\Filament\Resources\VisitorEntryResource\Pages\CreateVisitorEntry::class); + } +} diff --git a/app/Filament/Resources/VisitorEntryResource/Pages/EditVisitorEntry.php b/app/Filament/Resources/VisitorEntryResource/Pages/EditVisitorEntry.php new file mode 100644 index 0000000..5126830 --- /dev/null +++ b/app/Filament/Resources/VisitorEntryResource/Pages/EditVisitorEntry.php @@ -0,0 +1,54 @@ +data['photo'] = $photo; + } + + 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 @@ + 'user'|'assistant', 'content' => '…'] + * + * The full history is passed to GeminiChatbotService on every turn so + * Gemini can resolve follow-up messages (e.g. answering a clarification + * question) in context. + */ + public array $chatHistory = []; + + // ───────────────────────────────────────────────────────────────────────── + + public function mount(): void + { + $this->plants = DB::table('plants') + ->whereNull('deleted_at') + ->orderBy('name') + ->get(['id', 'name']) + ->toArray(); + + $this->dateFrom = now()->startOfMonth()->format('Y-m-d'); + $this->dateTo = now()->format('Y-m-d'); + } + + // ── Mode switching ──────────────────────────────────────────────────────── + + public function setMode(string $mode): void + { + $this->mode = $mode; + } + + public function setReportType(string $type): void + { + $this->reportType = $type; + + // Clear previous results when switching report type + $this->result = ''; + $this->hasResult = false; + $this->invoiceResult = ''; + $this->hasInvoiceResult = false; + $this->invoiceStatusResult = ''; + $this->hasInvoiceStatusResult = false; + $this->invoiceStatusData = []; + $this->showAllUnscanned = false; + } + + // ── Basic mode — Production helpers ────────────────────────────────────── + + public function updatedSelectedPlantId(): void + { + $this->selectedLineId = null; + $this->lines = []; + $this->result = ''; + $this->hasResult = false; + + if ($this->selectedPlantId) { + $this->lines = DB::table('lines') + ->whereNull('deleted_at') + ->where('plant_id', $this->selectedPlantId) + ->orderBy('name') + ->get(['id', 'name']) + ->toArray(); + } + } + + public function updatedSelectedLineId(): void + { + $this->result = ''; + $this->hasResult = false; + } + + public function fetchProduction(): void + { + if (! $this->selectedPlantId) { + $this->result = 'Please select a plant.'; + $this->hasResult = true; + return; + } + + $query = DB::table('production_quantities') + ->whereNull('deleted_at') + ->where('plant_id', $this->selectedPlantId) + ->whereDate('created_at', '>=', $this->dateFrom) + ->whereDate('created_at', '<=', $this->dateTo); + + if ($this->selectedLineId) { + $query->where('line_id', $this->selectedLineId); + } + + $count = $query->count(); + + $plantName = collect($this->plants) + ->firstWhere('id', $this->selectedPlantId)?->name ?? 'Unknown Plant'; + + $lineName = $this->selectedLineId + ? (collect($this->lines)->firstWhere('id', $this->selectedLineId)?->name ?? 'Unknown Line') + : 'All Lines'; + + $from = \Carbon\Carbon::parse($this->dateFrom)->format('d M Y'); + $to = \Carbon\Carbon::parse($this->dateTo)->format('d M Y'); + + $this->result = "Production count for {$plantName} / {$lineName} from {$from} to {$to}: {$count} records."; + $this->hasResult = true; + } + + // ── Basic mode — Invoice report (type lookup) ───────────────────────────── + + public function updatedInvoicePlantId(): void + { + $this->invoiceResult = ''; + $this->hasInvoiceResult = false; + } + + public function updatedInvoiceItemCode(): void + { + $this->invoiceResult = ''; + $this->hasInvoiceResult = false; + } + + public function fetchInvoiceReport(): void + { + if (! $this->invoicePlantId) { + $this->invoiceResult = 'Please select a plant.'; + $this->hasInvoiceResult = true; + return; + } + + $itemCode = trim($this->invoiceItemCode); + + if ($itemCode === '') { + $this->invoiceResult = 'Please enter an item code.'; + $this->hasInvoiceResult = true; + return; + } + + $plantName = collect($this->plants) + ->firstWhere('id', $this->invoicePlantId)?->name ?? 'Unknown Plant'; + + try { + $rows = DB::select(" + WITH plant_item AS ( + SELECT ? AS user_plant, + ? AS user_item_code + ), + t1 AS ( + SELECT + plants.id AS plant_id, + plants.name AS plant_name, + ARRAY_AGG(items.code) AS item_codes + FROM plants + LEFT JOIN items ON plants.id = items.plant_id + GROUP BY plants.id, plants.name + ), + t2 AS ( + SELECT + t1.plant_id, + t1.plant_name, + CASE + WHEN plant_item.user_item_code = ANY(t1.item_codes) THEN 1 + ELSE 0 + END AS exists_flag + FROM t1 + CROSS JOIN plant_item + WHERE t1.plant_name = plant_item.user_plant + ), + t3 AS ( + SELECT t2.plant_id, t2.plant_name, t2.exists_flag, + plant_item.user_item_code + FROM t2 + LEFT JOIN plant_item ON plant_item.user_plant = t2.plant_name + ), + t4 AS ( + SELECT items.id AS item_id, + t3.plant_id, t3.plant_name, t3.exists_flag, t3.user_item_code + FROM t3 + LEFT JOIN items + ON t3.plant_id = items.plant_id + AND t3.user_item_code = items.code + ) + SELECT + t4.item_id, + t4.plant_id, + t4.plant_name, + t4.exists_flag, + t4.user_item_code, + COALESCE(sticker_masters.material_type, 0) AS material_type, + CASE + WHEN sticker_masters.item_id IS NULL + THEN 'no match found' + WHEN COALESCE(sticker_masters.material_type, 0) = 0 + THEN 'serial invoice' + ELSE 'material invoice' + END AS invoice_description + FROM t4 + LEFT JOIN sticker_masters + ON sticker_masters.plant_id = t4.plant_id + AND sticker_masters.item_id = t4.item_id + ", [$plantName, $itemCode]); + + } catch (\Exception $e) { + Log::error('ChatBot: invoice report query failed', [ + 'plant' => $plantName, + 'item_code' => $itemCode, + 'error' => $e->getMessage(), + ]); + + $this->invoiceResult = "Sorry, I couldn't fetch data. Please try again or contact support."; + $this->hasInvoiceResult = true; + return; + } + + if (empty($rows)) { + $this->invoiceResult = "No data found for plant \"{$plantName}\". Please verify the plant selection."; + $this->hasInvoiceResult = true; + return; + } + + $row = $rows[0]; + + if ((int) $row->exists_flag === 0) { + $this->invoiceResult = 'Provided item code does not exist in the item table.'; + } else { + switch ($row->invoice_description) { + case 'no match found': + $this->invoiceResult = "Item not found in sticker master for the plant {$row->plant_name}."; + break; + case 'serial invoice': + $this->invoiceResult = 'It is a serial invoice item.'; + break; + case 'material invoice': + $this->invoiceResult = 'It is a material invoice item.'; + break; + default: + $this->invoiceResult = 'Unexpected result. Please contact support.'; + } + } + + $this->hasInvoiceResult = true; + } + + // ── Basic mode — Invoice status (scan status) ───────────────────────────── + + public function updatedInvoiceNumber(): void + { + $this->invoiceStatusResult = ''; + $this->hasInvoiceStatusResult = false; + $this->invoiceStatusData = []; + $this->showAllUnscanned = false; + } + + /** + * Looks up how many serials within an invoice have been scanned / not scanned. + * Stores structured data in $invoiceStatusData so the blade can render + * a "show more" serial-number list without dumping 70+ serials in one blob. + */ + public function fetchInvoiceStatus(): void + { + $invoiceNumber = trim(preg_replace('/\s+/', '', $this->invoiceNumber)); + + if (empty($invoiceNumber)) { + $this->invoiceStatusResult = 'Please enter a valid invoice number.'; + $this->invoiceStatusData = []; + $this->showAllUnscanned = false; + $this->hasInvoiceStatusResult = true; + return; + } + + try { + /** @var \App\Services\ChatbotService $service */ + $service = app(\App\Services\ChatbotService::class); + $data = $service->getInvoiceData($invoiceNumber); + } catch (\Throwable $e) { + Log::error('ChatBot: invoice status fetch failed', [ + 'invoice' => $invoiceNumber, + 'error' => $e->getMessage(), + ]); + $data = [ + 'type' => 'error', + 'message' => "Sorry, I couldn't fetch data for invoice {$invoiceNumber}. " + . 'Please try again or contact support.', + 'invoice_number' => $invoiceNumber, + 'total' => 0, + 'scanned' => 0, + 'not_scanned' => 0, + 'unscanned_serials' => [], + ]; + } + + $this->invoiceStatusData = $data; + $this->invoiceStatusResult = $data['message']; // fallback plain-text copy + $this->showAllUnscanned = false; + $this->hasInvoiceStatusResult = true; + } + + /** + * Toggles the "show all / show less" state for unscanned serial numbers + * in the Basic → Invoice Status result card. + */ + public function toggleShowAllUnscanned(): void + { + $this->showAllUnscanned = ! $this->showAllUnscanned; + } + + // ── Advanced mode (Gemini-powered) ──────────────────────────────────────── + + /** + * Handles a free-text user message in advanced mode. + * + * Steps: + * 1. Appends the user message to chatHistory immediately (UI feedback). + * 2. Calls GeminiChatbotService with the full prior history for context. + * 3. Gemini classifies the intent, extracts params, and either: + * a) runs the appropriate DB query and returns the result, or + * b) returns a clarification question if intent is ambiguous. + * 4. Appends the assistant reply to chatHistory. + */ + public function askAdvanced(): void + { + $question = trim($this->advancedQuestion); + + if (empty($question)) { + return; + } + + // Show the user's message in the chat bubble immediately + $this->chatHistory[] = [ + 'role' => 'user', + 'content' => $question, + ]; + + $this->advancedQuestion = ''; + $this->isAdvancedLoading = true; + + try { + /** @var GeminiChatbotService $gemini */ + $gemini = app(GeminiChatbotService::class); + + // Pass history *without* the turn we just appended — the new user + // message is passed separately so Gemini sees it as the latest turn. + $priorHistory = array_slice($this->chatHistory, 0, -1); + $answer = $gemini->processMessage($priorHistory, $question); + + } catch (\Throwable $e) { + Log::error('ChatBot: advanced ask failed', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + $answer = 'Sorry, something went wrong. Please try again.'; + } + + $this->chatHistory[] = [ + 'role' => 'assistant', + 'content' => $answer, + ]; + + $this->isAdvancedLoading = false; + } + + public function clearAdvancedChat(): void + { + $this->chatHistory = []; + $this->advancedQuestion = ''; + $this->isAdvancedLoading = false; + } + + // ── Panel controls ──────────────────────────────────────────────────────── + + public function toggleChat(): void + { + $this->isOpen = ! $this->isOpen; + } + + public function resetForm(): void + { + // Basic mode — shared + $this->reportType = ''; + + // Basic mode — production + $this->selectedPlantId = null; + $this->selectedLineId = null; + $this->lines = []; + $this->result = ''; + $this->hasResult = false; + $this->dateFrom = now()->startOfMonth()->format('Y-m-d'); + $this->dateTo = now()->format('Y-m-d'); + + // Basic mode — invoice type lookup + $this->invoicePlantId = null; + $this->invoiceItemCode = ''; + $this->invoiceResult = ''; + $this->hasInvoiceResult = false; + + // Basic mode — invoice scan status + $this->invoiceNumber = ''; + $this->invoiceStatusResult = ''; + $this->hasInvoiceStatusResult = false; + $this->invoiceStatusData = []; + $this->showAllUnscanned = false; + + // Advanced mode + $this->clearAdvancedChat(); + + // Go back to mode selector + $this->mode = 'select'; + } + + public function render() + { + return view('livewire.chat-bot'); + } +} diff --git a/app/Livewire/Webcam.php b/app/Livewire/Webcam.php new file mode 100644 index 0000000..4a75467 --- /dev/null +++ b/app/Livewire/Webcam.php @@ -0,0 +1,32 @@ +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/EmployeeMaster.php b/app/Models/EmployeeMaster.php new file mode 100644 index 0000000..cc6b748 --- /dev/null +++ b/app/Models/EmployeeMaster.php @@ -0,0 +1,32 @@ +belongsTo(Plant::class); + } +} 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/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index de279b8..c060d82 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -29,6 +29,9 @@ use App\Filament\Auth\CustomLogin as AuthCustomLogin; use App\Filament\Pages\CustomLogin; use Filament\View\PanelsRenderHook; use Filament\Support\Facades\FilamentView; +use Illuminate\Support\Facades\Blade; +use Filament\Pages\Auth\PasswordReset\RequestPasswordReset; +use Filament\Pages\Auth\PasswordReset\ResetPassword; class AdminPanelProvider extends PanelProvider @@ -41,6 +44,10 @@ class AdminPanelProvider extends PanelProvider ->id('admin') ->path('admin') ->login() + ->passwordReset( + RequestPasswordReset::class, + ResetPassword::class, + ) //->maxContentWidth(MaxWidth::Small) //->simplePageMaxContentWidth(MaxWidth::Medium) @@ -160,5 +167,10 @@ class AdminPanelProvider extends PanelProvider } return ''; }); + + FilamentView::registerRenderHook( + PanelsRenderHook::BODY_END, + fn (): string => Blade::render("@livewire('chat-bot')"), + ); } } diff --git a/app/Services/ChatbotService.php b/app/Services/ChatbotService.php new file mode 100644 index 0000000..e35b043 --- /dev/null +++ b/app/Services/ChatbotService.php @@ -0,0 +1,403 @@ + '/inv(?:oice)?(?:\s*(?:number|num|no\.?))?\s*(?:=|is| |equal\s+to)\s*([^\s,\.]+)/i', + 'handler' => 'handleInvoice', + ], + + // ── Invoice report: item type lookup ────────────────────────────────── + // Accepts patterns like: + // item = 674071 plant = Vahinie Unit 2 + // item code = 674071 plant = Vahinie Unit 2 + // check item 674071 for plant Vahinie Unit 2 + [ + 'pattern' => '/item(?:\s*code)?\s*(?:=|is|:)?\s*([^\s,]+)\s+(?:for\s+)?plant\s*(?:=|is|:)?\s*(.+)/i', + 'handler' => 'handleInvoiceReport', + ], + + // ── Add more commands here ──────────────────────────────────────────── + // Example: + // [ + // 'pattern' => '/^\s*serial\s*=\s*(.+)/i', + // 'handler' => 'handleSerial', + // ], + ]; + + // ───────────────────────────────────────────────────────────────────────── + // Public entry point + // ───────────────────────────────────────────────────────────────────────── + + /** + * Dispatch the user's input to the matching handler. + */ + public function ask(string $input): string + { + $input = trim($input); + + foreach ($this->handlers as $entry) { + if (preg_match($entry['pattern'], $input, $matches)) { + $value = trim($matches[1] ?? ''); + $value2 = trim($matches[2] ?? ''); + return $this->{$entry['handler']}($value, $value2); + } + } + + return $this->unknownCommand($input); + } + + // ───────────────────────────────────────────────────────────────────────── + // Handler: invoice = + // ───────────────────────────────────────────────────────────────────────── + + /** + * Looks up scan status for an invoice number in invoice_validations. + * Returns a plain-English string (used by the Advanced / free-text path). + * Structured callers should use getInvoiceData() directly. + */ + private function handleInvoice(string $invoiceNumber, string $_unused = ''): string + { + $data = $this->getInvoiceData($invoiceNumber); + + // For the plain-text path (advanced mode / ChatbotService::ask()), + // reassemble a human-readable sentence from the structured data. + if (in_array($data['type'], ['invalid', 'error', 'not_found'], true)) { + return $data['message']; + } + + if ($data['type'] === 'all_scanned') { + $n = $data['total']; + $itemWord = $n === 1 ? 'serial number' : 'serial numbers'; + return "For invoice number {$data['invoice_number']}, all {$n} {$itemWord} " + . ($n === 1 ? 'has' : 'have') . ' been scanned. ✅'; + } + + // partial or none_scanned + $total = $data['total']; + $scanned = $data['scanned']; + $notScan = $data['not_scanned']; + $inv = $data['invoice_number']; + $itemWord = $total === 1 ? 'serial number' : 'serial numbers'; + + if ($scanned === 0) { + $msg = "For invoice number {$inv}, there " + . ($total === 1 ? 'is' : 'are') . " {$total} {$itemWord} " + . 'and none have been scanned.'; + } else { + $msg = "For invoice number {$inv}, there " + . ($total === 1 ? 'is' : 'are') . " {$total} {$itemWord} in total. " + . "Out of which {$scanned} " + . ($scanned === 1 ? 'has' : 'have') . ' been scanned and ' + . "{$notScan} " + . ($notScan === 1 ? 'has' : 'have') . ' not been scanned.'; + } + + if (! empty($data['unscanned_serials'])) { + $msg .= ' Unscanned serial numbers are: ' + . implode(', ', $data['unscanned_serials']) . '.'; + } + + return $msg; + } + + // ───────────────────────────────────────────────────────────────────────── + // Public structured accessor — used by ChatBot (Basic mode) + // ───────────────────────────────────────────────────────────────────────── + + /** + * Returns structured scan-status data for an invoice number. + * + * Return shape: + * [ + * 'type' => 'all_scanned' | 'partial' | 'none_scanned' + * | 'not_found' | 'error' | 'invalid', + * 'message' => string, // one-line human summary (no serial list) + * 'invoice_number' => string, + * 'total' => int, + * 'scanned' => int, + * 'not_scanned' => int, + * 'unscanned_serials' => string[], // full list — may be large + * ] + */ + public function getInvoiceData(string $invoiceNumber): array + { + $invoiceNumber = preg_replace('/\s+/', '', $invoiceNumber); + + if (empty($invoiceNumber)) { + return [ + 'type' => 'invalid', + 'message' => 'Please provide a valid invoice number. Example: invoice = 3RA0013333', + 'invoice_number' => '', + 'total' => 0, + 'scanned' => 0, + 'not_scanned' => 0, + 'unscanned_serials' => [], + ]; + } + + try { + $rows = DB::select(" + SELECT + COALESCE(scanned_status, 'not scanned') AS status, + COUNT(*) AS total_count, + STRING_AGG( + CASE + WHEN scanned_status IS NULL THEN serial_number::text + END, + ', ' + ) AS serial_numbers_not_scanned + FROM invoice_validations + WHERE invoice_number = ? + GROUP BY scanned_status + ", [$invoiceNumber]); + } catch (\Exception $e) { + Log::error('ChatbotService: invoice query failed', [ + 'invoice' => $invoiceNumber, + 'error' => $e->getMessage(), + ]); + + return [ + 'type' => 'error', + 'message' => "Sorry, I couldn't fetch data for invoice {$invoiceNumber}. " + . 'Please try again or contact support if this keeps happening.', + 'invoice_number' => $invoiceNumber, + 'total' => 0, + 'scanned' => 0, + 'not_scanned' => 0, + 'unscanned_serials' => [], + ]; + } + + if (empty($rows)) { + return [ + 'type' => 'not_found', + 'message' => "No records found for invoice number {$invoiceNumber}. " + . 'Please double-check the invoice number and try again.', + 'invoice_number' => $invoiceNumber, + 'total' => 0, + 'scanned' => 0, + 'not_scanned' => 0, + 'unscanned_serials' => [], + ]; + } + + // ── Aggregate rows ──────────────────────────────────────────────────── + $totalScanned = 0; + $totalNotScanned = 0; + $unscannedSerials = []; + + foreach ($rows as $row) { + if ($row->status === 'not scanned') { + $totalNotScanned = (int) $row->total_count; + if (! empty($row->serial_numbers_not_scanned)) { + $unscannedSerials = array_values( + array_filter( + array_map('trim', explode(',', $row->serial_numbers_not_scanned)) + ) + ); + } + } else { + $totalScanned += (int) $row->total_count; + } + } + + $grandTotal = $totalScanned + $totalNotScanned; + + // ── All scanned ─────────────────────────────────────────────────────── + if ($totalNotScanned === 0) { + $n = $grandTotal; + $itemWord = $n === 1 ? 'serial number' : 'serial numbers'; + return [ + 'type' => 'all_scanned', + 'message' => "All {$n} {$itemWord} scanned for invoice {$invoiceNumber}. ✅", + 'invoice_number' => $invoiceNumber, + 'total' => $grandTotal, + 'scanned' => $totalScanned, + 'not_scanned' => 0, + 'unscanned_serials' => [], + ]; + } + + // ── None / partial scanned ──────────────────────────────────────────── + $type = $totalScanned === 0 ? 'none_scanned' : 'partial'; + $itemWord = $grandTotal === 1 ? 'serial number' : 'serial numbers'; + + if ($totalScanned === 0) { + $summary = "Invoice {$invoiceNumber} — {$grandTotal} {$itemWord}, none scanned yet."; + } else { + $summary = "Invoice {$invoiceNumber} — {$grandTotal} {$itemWord} total: " + . "{$totalScanned} scanned, {$totalNotScanned} not scanned."; + } + + return [ + 'type' => $type, + 'message' => $summary, + 'invoice_number' => $invoiceNumber, + 'total' => $grandTotal, + 'scanned' => $totalScanned, + 'not_scanned' => $totalNotScanned, + 'unscanned_serials' => $unscannedSerials, + ]; + } + + // ───────────────────────────────────────────────────────────────────────── + // Handler: item = plant = + // ───────────────────────────────────────────────────────────────────────── + + /** + * Determines whether an item is a serial invoice or material invoice + * for a given plant, using the sticker_masters table. + * + * @param string $itemCode Extracted item code (capture group 1) + * @param string $plantName Extracted plant name (capture group 2) + */ + private function handleInvoiceReport(string $itemCode, string $plantName): string + { + $itemCode = trim($itemCode); + $plantName = trim($plantName); + + if (empty($itemCode)) { + return 'Please provide an item code. Example: item = 674071 plant = Vahinie Unit 2'; + } + + if (empty($plantName)) { + return 'Please provide a plant name. Example: item = 674071 plant = Vahinie Unit 2'; + } + + try { + $rows = DB::select(" + WITH plant_item AS ( + SELECT ? AS user_plant, + ? AS user_item_code + ), + t1 AS ( + SELECT + plants.id AS plant_id, + plants.name AS plant_name, + ARRAY_AGG(items.code) AS item_codes + FROM plants + LEFT JOIN items ON plants.id = items.plant_id + GROUP BY plants.id, plants.name + ), + t2 AS ( + SELECT + t1.plant_id, + t1.plant_name, + CASE + WHEN plant_item.user_item_code = ANY(t1.item_codes) THEN 1 + ELSE 0 + END AS exists_flag + FROM t1 + CROSS JOIN plant_item + WHERE t1.plant_name = plant_item.user_plant + ), + t3 AS ( + SELECT t2.plant_id, t2.plant_name, t2.exists_flag, + plant_item.user_item_code + FROM t2 + LEFT JOIN plant_item ON plant_item.user_plant = t2.plant_name + ), + t4 AS ( + SELECT items.id AS item_id, + t3.plant_id, t3.plant_name, t3.exists_flag, t3.user_item_code + FROM t3 + LEFT JOIN items + ON t3.plant_id = items.plant_id + AND t3.user_item_code = items.code + ) + SELECT + t4.item_id, + t4.plant_id, + t4.plant_name, + t4.exists_flag, + t4.user_item_code, + COALESCE(sticker_masters.material_type, 0) AS material_type, + CASE + WHEN sticker_masters.item_id IS NULL + THEN 'no match found' + WHEN COALESCE(sticker_masters.material_type, 0) = 0 + THEN 'serial invoice' + ELSE 'material invoice' + END AS invoice_description + FROM t4 + LEFT JOIN sticker_masters + ON sticker_masters.plant_id = t4.plant_id + AND sticker_masters.item_id = t4.item_id + ", [$plantName, $itemCode]); + + } catch (\Exception $e) { + Log::error('ChatbotService: invoice report query failed', [ + 'plant' => $plantName, + 'item_code' => $itemCode, + 'error' => $e->getMessage(), + ]); + + return "Sorry, I couldn't fetch data for item {$itemCode} in plant {$plantName}. " + . 'Please try again or contact support.'; + } + + if (empty($rows)) { + return "No data found for plant \"{$plantName}\". Please check the plant name and try again."; + } + + $row = $rows[0]; + + if ((int) $row->exists_flag === 0) { + return 'Provided item code does not exist in the item table.'; + } + + return match ($row->invoice_description) { + 'no match found' => "Item not found in sticker master for the plant {$row->plant_name}.", + 'serial invoice' => 'It is a serial invoice item.', + 'material invoice' => 'It is a material invoice item.', + default => 'Unexpected result. Please contact support.', + }; + } + + // ───────────────────────────────────────────────────────────────────────── + // Fallback for unrecognised input + // ───────────────────────────────────────────────────────────────────────── + + private function unknownCommand(string $input): string + { + return "I didn't recognise that command. Please include a supported keyword with a value after '='.\n\n" + . "• Invoice scan status:\n" + . " invoice = 3RA0013333\n" + . " what is the status of invoice = 3RA0013333\n\n" + . "• Invoice type lookup:\n" + . " item = 674071 plant = Vahinie Unit 2\n" + . " item code = 674071 plant = Vahinie Unit 2\n\n" + . 'Any sentence containing keyword = value will work.'; + } +} diff --git a/app/Services/GeminiChatbotService.php b/app/Services/GeminiChatbotService.php new file mode 100644 index 0000000..b8ec342 --- /dev/null +++ b/app/Services/GeminiChatbotService.php @@ -0,0 +1,819 @@ +apiKey = config('services.gemini.api_key', ''); + + $model = config('services.gemini.model', 'gemini-3-flash-preview'); + + $this->apiUrl = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent"; + } + + // ───────────────────────────────────────────────────────────────────────── + // Public entry point + // ───────────────────────────────────────────────────────────────────────── + + /** + * Process a user message in context of the prior conversation. + * + * @param array $chatHistory Previous turns: [['role'=>'user'|'assistant','content'=>'…'], …] + * @param string $userInput The new message just typed. + * @return string Plain-text reply to show in the chat bubble. + */ + public function processMessage(array $chatHistory, string $userInput): string + { + if (empty($this->apiKey)) { + return '⚠️ AI features are not configured. Please set GEMINI_API_KEY in your .env file.'; + } + + // ── Step 1: Classify the intent ─────────────────────────────────────── + try { + $classification = $this->classifyWithGemini($chatHistory, $userInput); + } catch (\RuntimeException $e) { + // Surface the specific error directly in the chat bubble + return $e->getMessage(); + } + + $task = $classification['task'] ?? 'unknown'; + $params = $classification['params'] ?? []; + $missing = $classification['missing'] ?? []; + $clarification = $classification['clarification'] ?? null; + + // ── Step 2: Missing required params → ask user for them ─────────────── + if (! empty($missing) && ! empty($clarification)) { + return $clarification; + } + + // ── Step 3: Dispatch to the appropriate handler ─────────────────────── + return match ($task) { + 'invoice_status' => $this->handleInvoiceStatus($params), + 'invoice_report' => $this->handleInvoiceReport($params), + 'production_report' => $this->handleProductionReport($params), + + // ── Unknown intent: hand off to Gemini as a free-form assistant ── + 'unknown' => $this->handleUnknown($chatHistory, $userInput, $clarification), + + default => $this->handleUnknown($chatHistory, $userInput, null), + }; + } + + // ───────────────────────────────────────────────────────────────────────── + // Gemini API calls + // ───────────────────────────────────────────────────────────────────────── + + /** + * Phase 1 — Classify the user's intent and extract structured params. + * + * @return array|null Parsed JSON array, or null on failure. + */ + /** + * @throws \RuntimeException with a user-facing message describing exactly what failed. + */ + private function classifyWithGemini(array $history, string $userInput): array + { + $requestBody = [ + 'system_instruction' => [ + 'parts' => [['text' => $this->buildSystemPrompt()]], + ], + 'contents' => $this->buildGeminiContents($history, $userInput), + 'generationConfig' => [ + 'temperature' => 0.1, + 'responseMimeType' => 'application/json', + ], + ]; + + // ── HTTP call ───────────────────────────────────────────────────────── + try { + $response = Http::withHeaders(['Content-Type' => 'application/json']) + ->timeout(15) + ->post($this->apiUrl . '?key=' . $this->apiKey, $requestBody); + } catch (\Exception $e) { + Log::error('GeminiChatbotService: HTTP exception', ['error' => $e->getMessage()]); + throw new \RuntimeException( + '⚠️ Could not reach the Gemini API. Check your network or firewall. (' + . $e->getMessage() . ')' + ); + } + + // ── HTTP-level error ────────────────────────────────────────────────── + if (! $response->successful()) { + $status = $response->status(); + $body = $response->body(); + + Log::error('GeminiChatbotService: API HTTP error', [ + 'status' => $status, + 'body' => $body, + ]); + + // Parse Google's error message when available + $googleMsg = $response->json('error.message') ?? $body; + + throw new \RuntimeException( + "⚠️ Gemini API returned HTTP {$status}: {$googleMsg}" + ); + } + + // ── Extract text from response ──────────────────────────────────────── + $text = $response->json('candidates.0.content.parts.0.text'); + + if (empty($text)) { + // Check for prompt-blocking + $blockReason = $response->json('promptFeedback.blockReason'); + $finishReason = $response->json('candidates.0.finishReason'); + + Log::error('GeminiChatbotService: empty response text', [ + 'blockReason' => $blockReason, + 'finishReason' => $finishReason, + 'full' => $response->json(), + ]); + + $hint = $blockReason + ? "prompt was blocked (reason: {$blockReason})" + : ($finishReason ? "finish reason: {$finishReason}" : 'no text returned'); + + throw new \RuntimeException("⚠️ Gemini returned no content — {$hint}"); + } + + // ── JSON decode ─────────────────────────────────────────────────────── + $clean = preg_replace('/^```json\s*/i', '', trim($text)); + $clean = preg_replace('/\s*```$/i', '', $clean); + $parsed = json_decode($clean, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + Log::error('GeminiChatbotService: JSON decode failed', ['raw' => $text]); + throw new \RuntimeException( + '⚠️ Gemini returned a non-JSON response: ' . mb_substr($text, 0, 200) + ); + } + + return $parsed; + } + + /** + * Phase 2 (unknown task only) — Ask Gemini to respond conversationally. + * + * Sends the full chat history + new user message to Gemini as a friendly + * factory-operations assistant (no JSON constraint). Gemini can ask for + * clarification, answer general questions, or guide the user to one of the + * supported tasks. + * + * @param string|null $hintFromClassification Optional clarification from the + * classification step — prepended as assistant context if present. + * @return string Plain-text reply from Gemini. + */ + private function callGeminiConversational( + array $history, + string $userInput, + ?string $hintFromClassification = null + ): ?string { + // If the classifier already produced a good clarification question, use it + // directly and skip the second API call to save latency + quota. + if (! empty($hintFromClassification)) { + return $hintFromClassification; + } + + $requestBody = [ + 'system_instruction' => [ + 'parts' => [['text' => $this->buildConversationalSystemPrompt()]], + ], + 'contents' => $this->buildGeminiContents($history, $userInput), + 'generationConfig' => [ + 'temperature' => 0.7, // more natural conversational tone + 'maxOutputTokens' => 400, + ], + ]; + + try { + $response = Http::withHeaders(['Content-Type' => 'application/json']) + ->timeout(20) + ->post($this->apiUrl . '?key=' . $this->apiKey, $requestBody); + + if (! $response->successful()) { + Log::error('GeminiChatbotService: API error (conversational)', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + return null; + } + + return $response->json('candidates.0.content.parts.0.text'); + + } catch (\Exception $e) { + Log::error('GeminiChatbotService: exception (conversational)', ['error' => $e->getMessage()]); + return null; + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Prompt builders + // ───────────────────────────────────────────────────────────────────────── + + /** + * System prompt for Phase 1 (classification) — forces JSON output. + */ + private function buildSystemPrompt(): string + { + $today = now()->format('Y-m-d'); + $startOfMonth = now()->startOfMonth()->format('Y-m-d'); + + return << $msg['role'] === 'user' ? 'user' : 'model', + 'parts' => [['text' => $msg['content']]], + ]; + } + + $contents[] = [ + 'role' => 'user', + 'parts' => [['text' => $userInput]], + ]; + + return $contents; + } + + // ───────────────────────────────────────────────────────────────────────── + // Task handlers + // ───────────────────────────────────────────────────────────────────────── + + /** + * Invoice Status — delegate to ChatbotService (regex-based, same as basic mode). + */ + private function handleInvoiceStatus(array $params): string + { + $invoiceNumber = trim(preg_replace('/\s+/', '', $params['invoice_number'] ?? '')); + + if (empty($invoiceNumber)) { + return 'I need the invoice number to check the scan status. What is the invoice number?'; + } + + /** @var ChatbotService $svc */ + $svc = app(ChatbotService::class); + + return $svc->ask("invoice = {$invoiceNumber}"); + } + + /** + * Invoice Report — resolves plant name with fuzzy matching, then runs the + * same CTE query as ChatBot::fetchInvoiceReport() directly. + * + * We bypass ChatbotService::ask() here so that resolvePlant()'s multi-strategy + * fuzzy logic is applied rather than the simpler LIKE inside ChatbotService. + */ + private function handleInvoiceReport(array $params): string + { + $itemCode = trim($params['item_code'] ?? ''); + $plantName = trim($params['plant_name'] ?? ''); + + if (empty($itemCode)) { + return 'I need the item code to look up the invoice type. What is the item code?'; + } + + if (empty($plantName)) { + return 'I need the plant name to look up the invoice type. Which plant are you asking about?'; + } + + // ── Fuzzy-resolve the plant name ────────────────────────────────────── + $plant = $this->resolvePlant($plantName); + + if ($plant === null) { + return "I couldn't find a plant matching \"{$plantName}\". " + . 'Please check the plant name and try again.'; + } + + // ── Run the same CTE as ChatBot::fetchInvoiceReport() ───────────────── + try { + $rows = DB::select(" + WITH plant_item AS ( + SELECT ? AS user_plant, + ? AS user_item_code + ), + t1 AS ( + SELECT + plants.id AS plant_id, + plants.name AS plant_name, + ARRAY_AGG(items.code) AS item_codes + FROM plants + LEFT JOIN items ON plants.id = items.plant_id + GROUP BY plants.id, plants.name + ), + t2 AS ( + SELECT + t1.plant_id, + t1.plant_name, + CASE + WHEN plant_item.user_item_code = ANY(t1.item_codes) THEN 1 + ELSE 0 + END AS exists_flag + FROM t1 + CROSS JOIN plant_item + WHERE t1.plant_name = plant_item.user_plant + ), + t3 AS ( + SELECT t2.plant_id, t2.plant_name, t2.exists_flag, + plant_item.user_item_code + FROM t2 + LEFT JOIN plant_item ON plant_item.user_plant = t2.plant_name + ), + t4 AS ( + SELECT items.id AS item_id, + t3.plant_id, t3.plant_name, t3.exists_flag, t3.user_item_code + FROM t3 + LEFT JOIN items + ON t3.plant_id = items.plant_id + AND t3.user_item_code = items.code + ) + SELECT + t4.item_id, + t4.plant_id, + t4.plant_name, + t4.exists_flag, + t4.user_item_code, + COALESCE(sticker_masters.material_type, 0) AS material_type, + CASE + WHEN sticker_masters.item_id IS NULL + THEN 'no match found' + WHEN COALESCE(sticker_masters.material_type, 0) = 0 + THEN 'serial invoice' + ELSE 'material invoice' + END AS invoice_description + FROM t4 + LEFT JOIN sticker_masters + ON sticker_masters.plant_id = t4.plant_id + AND sticker_masters.item_id = t4.item_id + ", [$plant->name, $itemCode]); + + } catch (\Exception $e) { + Log::error('GeminiChatbotService: invoice report query failed', [ + 'plant' => $plant->name, + 'item_code' => $itemCode, + 'error' => $e->getMessage(), + ]); + return "Sorry, I couldn't fetch data. Please try again or contact support."; + } + + if (empty($rows)) { + return "No data found for plant \"{$plant->name}\". Please verify the plant name."; + } + + $row = $rows[0]; + + if ((int) $row->exists_flag === 0) { + return 'The provided item code does not exist in the item table.'; + } + + return match ($row->invoice_description) { + 'serial invoice' => 'It is a serial invoice item.', + 'material invoice' => 'It is a material invoice item.', + 'no match found' => "Item not found in sticker master for plant {$plant->name}.", + default => 'Unexpected result. Please contact support.', + }; + } + + /** + * Production Report — resolves plant/line names to IDs and runs the count query. + * Mirrors ChatBot::fetchProduction() but works with plain names instead of IDs, + * using resolvePlant() for robust fuzzy matching. + */ + private function handleProductionReport(array $params): string + { + $plantName = trim($params['plant_name'] ?? ''); + $lineName = trim($params['line_name'] ?? ''); + $dateFrom = $params['date_from'] ?? now()->startOfMonth()->format('Y-m-d'); + $dateTo = $params['date_to'] ?? now()->format('Y-m-d'); + + if (empty($plantName)) { + return 'I need a plant name to fetch the production report. Which plant are you asking about?'; + } + + // ── Fuzzy-resolve the plant name ────────────────────────────────────── + $plant = $this->resolvePlant($plantName); + + if ($plant === null) { + return "I couldn't find a plant matching \"{$plantName}\". " + . 'Please check the plant name and try again.'; + } + + // ── Base query ──────────────────────────────────────────────────────── + $query = DB::table('production_quantities') + ->whereNull('deleted_at') + ->where('plant_id', $plant->id) + ->whereDate('created_at', '>=', $dateFrom) + ->whereDate('created_at', '<=', $dateTo); + + $lineLabel = 'All Lines'; + + // ── Optionally filter by line (fuzzy LIKE match) ────────────────────── + if (! empty($lineName)) { + $line = $this->resolveLine($lineName, $plant->id); + + if ($line === null) { + return "I couldn't find a line matching \"{$lineName}\" " + . "in plant \"{$plant->name}\". Please check the line name."; + } + + $query->where('line_id', $line->id); + $lineLabel = $line->name; + } + + try { + $count = $query->count(); + } catch (\Exception $e) { + Log::error('GeminiChatbotService: production query failed', [ + 'plant' => $plant->name, + 'line' => $lineLabel, + 'error' => $e->getMessage(), + ]); + + return "Sorry, I couldn't fetch production data for {$plant->name}. " + . 'Please try again or contact support.'; + } + + $from = \Carbon\Carbon::parse($dateFrom)->format('d M Y'); + $to = \Carbon\Carbon::parse($dateTo)->format('d M Y'); + + return "📊 Production count for {$plant->name} / {$lineLabel} " + . "from {$from} to {$to}: {$count} records."; + } + + // ───────────────────────────────────────────────────────────────────────── + // Fuzzy name resolvers + // ───────────────────────────────────────────────────────────────────────── + + /** + * Resolve a user-supplied plant name to the best matching DB row. + * + * Strategy cascade (stops at first hit): + * 1. Exact case-insensitive match → "ransar industries-i" == "Ransar Industries-I" + * 2. Normalised LIKE match → strips hyphens/spaces, swaps I↔1 + * 3. Every significant word present (LIKE) → "ransar unit 2" matches "Ransar Industries Unit 2" + * 4. Best token-overlap score → picks the DB row sharing the most words + * + * @return object|null stdClass with {id, name} or null if no match. + */ + private function resolvePlant(string $userInput): ?object + { + $allPlants = DB::table('plants') + ->whereNull('deleted_at') + ->get(['id', 'name']); + + $norm = $this->normaliseForMatching($userInput); + + // ── Strategy 1: exact normalised match ──────────────────────────────── + foreach ($allPlants as $plant) { + if ($this->normaliseForMatching($plant->name) === $norm) { + return $plant; + } + } + + // ── Strategy 2: normalised LIKE (user input contained in plant name or vice-versa) ── + foreach ($allPlants as $plant) { + $dbNorm = $this->normaliseForMatching($plant->name); + if (str_contains($dbNorm, $norm) || str_contains($norm, $dbNorm)) { + return $plant; + } + } + + // ── Strategy 3: all significant user words appear in the plant name ─── + $userTokens = $this->significantTokens($norm); + + if (count($userTokens) >= 1) { + foreach ($allPlants as $plant) { + $dbNorm = $this->normaliseForMatching($plant->name); + $allFound = true; + foreach ($userTokens as $token) { + if (! str_contains($dbNorm, $token)) { + $allFound = false; + break; + } + } + if ($allFound) { + return $plant; + } + } + } + + // ── Strategy 4: best token-overlap score ───────────────────────────── + $bestPlant = null; + $bestScore = 0; + + foreach ($allPlants as $plant) { + $dbTokens = $this->significantTokens($this->normaliseForMatching($plant->name)); + $shared = count(array_intersect($userTokens, $dbTokens)); + + // Require at least half the user tokens to match to avoid false positives + $threshold = max(1, (int) ceil(count($userTokens) / 2)); + + if ($shared >= $threshold && $shared > $bestScore) { + $bestScore = $shared; + $bestPlant = $plant; + } + } + + return $bestPlant; + } + + /** + * Resolve a user-supplied line name within a specific plant. + * Uses the same normalisation + token strategies as resolvePlant(). + * + * @return object|null stdClass with {id, name} or null if no match. + */ + private function resolveLine(string $userInput, int $plantId): ?object + { + $allLines = DB::table('lines') + ->whereNull('deleted_at') + ->where('plant_id', $plantId) + ->get(['id', 'name']); + + $norm = $this->normaliseForMatching($userInput); + + // Strategy 1: exact normalised + foreach ($allLines as $line) { + if ($this->normaliseForMatching($line->name) === $norm) { + return $line; + } + } + + // Strategy 2: normalised LIKE + foreach ($allLines as $line) { + $dbNorm = $this->normaliseForMatching($line->name); + if (str_contains($dbNorm, $norm) || str_contains($norm, $dbNorm)) { + return $line; + } + } + + // Strategy 3: all user tokens found in line name + $userTokens = $this->significantTokens($norm); + + foreach ($allLines as $line) { + $dbNorm = $this->normaliseForMatching($line->name); + $allFound = true; + foreach ($userTokens as $token) { + if (! str_contains($dbNorm, $token)) { + $allFound = false; + break; + } + } + if ($allFound) { + return $line; + } + } + + // Strategy 4: best token-overlap + $bestLine = null; + $bestScore = 0; + + foreach ($allLines as $line) { + $dbTokens = $this->significantTokens($this->normaliseForMatching($line->name)); + $shared = count(array_intersect($userTokens, $dbTokens)); + $threshold = max(1, (int) ceil(count($userTokens) / 2)); + + if ($shared >= $threshold && $shared > $bestScore) { + $bestScore = $shared; + $bestLine = $line; + } + } + + return $bestLine; + } + + /** + * Normalise a plant/line name for fuzzy comparison: + * - lowercase + * - replace Roman numeral suffixes I/II/III/IV → 1/2/3/4 (and vice-versa digits → numerals as a canonical form) + * - collapse hyphens, underscores, extra spaces into a single space + * - strip leading/trailing whitespace + * + * Both the user input AND the DB value are passed through this before comparing, + * so the comparison is always apples-to-apples. + */ + private function normaliseForMatching(string $value): string + { + $v = strtolower($value); + + // 1. Punctuation/separators → space + $v = str_replace(['-', '_', '.', ','], ' ', $v); + + // 2. Split any letter→digit or digit→letter boundary with a space. + // e.g. "industries1" → "industries 1", "unit2" → "unit 2", "2unit" → "2 unit" + // This must happen BEFORE Roman numeral conversion so isolated digits are + // already separated from words. + $v = preg_replace('/([a-z])(\d)/', '$1 $2', $v); + $v = preg_replace('/(\d)([a-z])/', '$1 $2', $v); + + // 3. Convert standalone Roman numerals to digits. + // Applied AFTER splitting so "industries" is never touched — + // the \b boundary ensures only whole tokens are matched. + // Order matters: longer patterns first (iii before ii before i). + $romanMap = [ + '/\bviii\b/' => '8', + '/\bvii\b/' => '7', + '/\bvi\b/' => '6', + '/\biv\b/' => '4', + '/\biii\b/' => '3', + '/\bii\b/' => '2', + '/\bv\b/' => '5', + '/\bi\b/' => '1', // last — single i only after all others consumed + ]; + foreach ($romanMap as $pattern => $digit) { + $v = preg_replace($pattern, $digit, $v); + } + + // 4. Collapse multiple spaces + $v = preg_replace('/\s+/', ' ', $v); + + return trim($v); + } + + /** + * Split a normalised string into significant tokens (drops noise words). + * + * @return array + */ + private function significantTokens(string $normalised): array + { + $stopWords = ['and', 'the', 'of', 'for', 'at', 'in', 'a']; + $tokens = explode(' ', $normalised); + + return array_values(array_filter($tokens, function (string $t) use ($stopWords) { + return strlen($t) >= 2 && ! in_array($t, $stopWords, true); + })); + } + + // ───────────────────────────────────────────────────────────────────────── + + /** + * Unknown task — let Gemini respond conversationally. + * + * If the classification step already produced a useful clarification string + * (e.g. "Could you clarify — are you asking about scan status or invoice type?"), + * we return that directly without a second API call. + * Otherwise we hit Gemini again with a conversational system prompt. + */ + private function handleUnknown( + array $chatHistory, + string $userInput, + ?string $clarificationFromClassifier + ): string { + $reply = $this->callGeminiConversational( + $chatHistory, + $userInput, + $clarificationFromClassifier + ); + + return $reply + ?? "I'm not sure I understood that. I can help you with:\n\n" + . "• Invoice Status — scan progress of an invoice\n" + . "• Invoice Report — serial vs material type for an item\n" + . "• Production Report — unit count for a plant / line\n\n" + . "What would you like to check?"; + } +} diff --git a/app/Services/StickerPdfService.php b/app/Services/StickerPdfService.php index 94d82bc..eaacf68 100644 --- a/app/Services/StickerPdfService.php +++ b/app/Services/StickerPdfService.php @@ -2,11 +2,16 @@ namespace App\Services; +use App\Models\Item; use App\Models\ItemCharacteristic; +use App\Models\ProductionQuantity; use App\Models\StickerDetail; +use App\Models\StickerMappingMaster; use App\Models\StickerStructureDetail; +use App\Models\StickerValidation; use Illuminate\Http\Response; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Schema; use TCPDF; @@ -447,11 +452,10 @@ class StickerPdfService } // return $pdf->Output('sticker.pdf', 'S'); - $pdfContent = $pdf->Output('sticker.pdf', 'S'); + $pdfContent = $pdf->Output('', 'S'); // 'S' returns string - return (new Response($pdfContent, 200)) - ->header('Content-Type', 'application/pdf') - ->header('Content-Disposition', 'inline; filename="sticker.pdf"'); + // Encode as base64 + return base64_encode($pdfContent); } @@ -788,10 +792,11 @@ class StickerPdfService // ->header('Content-Disposition', 'inline; filename="sticker.pdf"'); } - public function generatePdf1(string $stickerId, Collection $dynamicElements, ?ItemCharacteristic $itemCharacteristic, ?string $serialNumber) + public function generatePdf1(string $stickerId, Collection $dynamicElements, ?ItemCharacteristic $itemCharacteristic, ?string $serialNumber, $serNo) { $dynamicValueMap = []; + $itemCode = $itemCharacteristic?->item?->code ?? ''; foreach ($dynamicElements as $element) { @@ -827,42 +832,29 @@ class StickerPdfService $pdf = new TCPDF('P', 'mm', [$width, $height], true, 'UTF-8', false); - // $pdf->SetMargins( - // (float) $structure->sticker_lmargin, - // (float) $structure->sticker_tmargin, - // (float) $structure->sticker_rmargin, - // ); - - // //$pdf->SetAutoPageBreak(false, (float) $structure->sticker_bmargin); - // $pdf->SetAutoPageBreak(false, (float) $structure->sticker_bmargin); - $pdf->setPrintHeader(false); $pdf->setPrintFooter(false); - // $pdf->setCellPaddings(0, 0, 0, 0); - // $pdf->setCellMargins(5, 5, 5, 5); - - // Set margins - // $pdf->SetMargins(5, 5, 5); // left, top, right $pdf->SetMargins( (float) $structure->sticker_lmargin, (float) $structure->sticker_tmargin, (float) $structure->sticker_rmargin, + (float) $structure->sticker_bmargin, ); $pdf->SetAutoPageBreak(false, 0); $pdf->AddPage(); - if (!empty($serialNumber)) { - $pdf->SetFont('helvetica', 'B', 10); - $pdf->SetTextColor(0, 0, 0); + // if (!empty($serialNumber)) { + // $pdf->SetFont('helvetica', 'B', 10); + // $pdf->SetTextColor(0, 0, 0); - // HARD-CODED POSITION (mm) - $x = 40; // change as needed - $y = 60; // change as needed + // // HARD-CODED POSITION (mm) + // $x = 40; // change as needed + // $y = 60; // change as needed - $pdf->Text($x, $y, (string) $serialNumber); - } + // $pdf->Text($x, $y, (string) $serialNumber); + // } $pdf->SetFont('helvetica', 'B', 10); @@ -900,15 +892,32 @@ class StickerPdfService break; case 'QR': - $pdf->write2DBarcode( + if ( + ($row->element_type) == 'Dynamic' + ) { + $qrContent = $serNo ?? ''; + $pdf->write2DBarcode( + $qrContent, + 'QRCODE,H', + (float) ($row->qr_x_value ?? 0), + (float) ($row->qr_y_value ?? 0), + (float) ($row->qr_size ?? 10), + (float) ($row->qr_size ?? 10) + ); + break; + } + else{ + $pdf->write2DBarcode( $row->qr_value ?? '', - 'QRCODE,H', - (float) ($row->qr_x_value ?? 0), - (float) ($row->qr_y_value ?? 0), - (float) ($row->qr_size ?? 10), - (float) ($row->qr_size ?? 10) - ); - break; + 'QRCODE,H', + (float) ($row->qr_x_value ?? 0), + (float) ($row->qr_y_value ?? 0), + (float) ($row->qr_size ?? 10), + (float) ($row->qr_size ?? 10) + ); + break; + } + case 'Image': @@ -1009,22 +1018,123 @@ class StickerPdfService } } - // return $pdf->Output('sticker.pdf', 'S'); - $pdfContent = $pdf->Output('sticker1.pdf', 'S'); - $filename = "sticker_{$stickerId}_" . time() . ".pdf"; + // $pdfContent = $pdf->Output('', 'S'); // 'S' returns string - // return (new Response($pdfContent, 200)) - // ->header('Content-Type', 'application/pdf'); - // ->header('Content-Disposition', 'inline; filename="sticker.pdf"'); - return response($pdfContent, 200) - ->header('Content-Type', 'application/pdf') - ->header('Content-Disposition', 'inline; filename="'.$filename.'"') - ->header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') - ->header('Pragma', 'no-cache') - ->header('Expires', '0'); + // // Encode as base64 + // return base64_encode($pdfContent); + + // $pdfContent = $pdf->Output('', 'S'); + + // return response($pdfContent) + // ->header('Content-Type', 'application/pdf') + // ->header('Content-Disposition', 'inline; filename="sticker.pdf"'); + $pdfContent = $pdf->Output('', 'S'); // 'S' returns the PDF as a string + + // Return the PDF as a response + try { + $pdfContent = $pdf->Output('', 'S'); // 'S' returns the PDF as a string + return response($pdfContent) + ->header('Content-Type', 'application/pdf') + ->header('Content-Disposition', 'inline; filename="sticker.pdf"'); + } catch (\Exception $e) { + Log::error('PDF generation failed: '.$e->getMessage()); + abort(500, 'Failed to generate PDF'); + } } + public function generatePdfBySerial($item, $serNo, $plantId, $refNumber) + { + $recFound = ProductionQuantity::where('plant_id', $plantId) + ->where('production_order', $refNumber) + ->where('serial_number', $serNo) + ->first(); + + if (!$recFound) { + abort(404, 'Serial not found'); + } + + $duplicate = StickerValidation::where('plant_id', $plantId) + ->where('production_order', $refNumber) + ->where('serial_number', $serNo) + ->first(); + + $itemC = Item::where('code', $item) + ->where('plant_id', $plantId) + ->first(); + + if (!$itemC) { + abort(404, 'Item not found'); + } + + $item = ItemCharacteristic::where('item_id', $itemC->id) + ->where('plant_id', $plantId) + ->first(); + + if (!$item) { + abort(404, 'Item characteristic not found'); + } + + $mapping = StickerMappingMaster::where('plant_id', $plantId) + ->where('item_characteristic_id', $item->id) + ->first(); + + if (!$mapping) { + abort(404, 'Sticker mapping not found'); + } + + $structure = StickerStructureDetail::findOrFail($mapping->sticker_structure1_id); + + $dynamicElements = StickerDetail::where( + 'sticker_structure_detail_id', + $structure->id + )->where('element_type', 'Dynamic')->get(); + + return $this->generatePdf1( + $structure->sticker_id, + $dynamicElements, + $item, + $serNo, + $serNo + ); + + // return response($pdf) + // ->header('Content-Type', 'application/pdf') + // ->header('Content-Disposition', 'inline; filename="sticker.pdf"'); + } + + // public function printStickersToUSB(array $stickers, int $plantId, ?string $serialNumber) + // { + // $printerPort = 'USB001'; + + // foreach ($stickers as $sticker) { + + // $dynamicElements = StickerDetail::where('sticker_structure_detail_id', $sticker['sticker_id']) + // ->get(); + + // $itemCharacteristic = ItemCharacteristic::find( + // $sticker['item_characteristic'] + // ); + + // $pdfContent = $this->generatePdf1( + // $sticker['sticker_id'], + // $dynamicElements, + // $itemCharacteristic, + // $serialNumber + // ); + + // $handle = fopen("{$printerPort}:", "wb"); + + // if (! $handle) { + // throw new \Exception("Cannot open printer port {$printerPort}"); + // } + + // fwrite($handle, $pdfContent); + // fclose($handle); + // } + // } + + // private function hexToRgb($hex) // { // $hex = ltrim($hex, '#'); @@ -1034,6 +1144,7 @@ class StickerPdfService // hexdec(substr($hex, 4, 2)), // ]; // } + private function hexToRgb($hex) { if (! is_string($hex) || ($hex = trim($hex)) === '') { diff --git a/config/services.php b/config/services.php index 27a3617..5ec9271 100644 --- a/config/services.php +++ b/config/services.php @@ -18,6 +18,11 @@ return [ 'token' => env('POSTMARK_TOKEN'), ], + 'gemini' => [ + 'api_key' => env('GEMINI_API_KEY'), + 'model' => env('GEMINI_MODEL', 'gemini-3-flash-preview'), + ], + 'ses' => [ 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), diff --git a/database/migrations/2026_05_25_110441_create_employee_masters_table.php b/database/migrations/2026_05_25_110441_create_employee_masters_table.php new file mode 100644 index 0000000..df4e2c6 --- /dev/null +++ b/database/migrations/2026_05_25_110441_create_employee_masters_table.php @@ -0,0 +1,45 @@ + + + + Visitor Photo + + + + + + diff --git a/resources/views/livewire/chat-bot.blade.php b/resources/views/livewire/chat-bot.blade.php new file mode 100644 index 0000000..8bc9014 --- /dev/null +++ b/resources/views/livewire/chat-bot.blade.php @@ -0,0 +1,734 @@ +
+ + + {{-- ── Chat Panel ──────────────────────────────────────────────────────── --}} + @if($isOpen) +
+ + {{-- ── Header ── --}} +
+
+ + + + Report Assistant ⒶⓇ + + {{-- Mode badge --}} + @if($mode !== 'select') + + {{ $mode }} + + @endif +
+ +
+ {{-- Back to mode select (only when in a mode) --}} + @if($mode !== 'select') + + @endif + + {{-- Reset --}} + + + {{-- Close --}} + +
+
+ + {{-- ══════════════════════════════════════════════════════════════════ --}} + {{-- ── MODE SELECT SCREEN ── --}} + {{-- ══════════════════════════════════════════════════════════════════ --}} + @if($mode === 'select') +
+ +
+

How would you like to query?

+

Choose a mode to get started

+
+ +
+ + {{-- Basic card --}} + + + {{-- Advanced card --}} + + +
+ + {{-- Hint --}} +
+

+ 💡 Tip: Use Advanced to just describe what you need in plain English — Gemini will figure out the rest +

+
+ +
+ @endif + + {{-- ══════════════════════════════════════════════════════════════════ --}} + {{-- ── BASIC MODE ── --}} + {{-- ══════════════════════════════════════════════════════════════════ --}} + @if($mode === 'basic') +
+ + {{-- ── Report Type Selector (dropdown) ──────────────────────────── --}} +
+ + {{-- Custom wrapper gives us the chevron icon and accent border on selection --}} +
+ + {{-- Chevron icon --}} +
+ + + +
+
+
+ + {{-- ── Placeholder when no report type selected ─────────────────── --}} + @if($reportType === '') +
+ + + +

Select a report type from the dropdown to get started

+
+ @endif + + {{-- ══════════════════════════════════════════════════════════════ --}} + {{-- ── PRODUCTION REPORT FORM ── --}} + {{-- ══════════════════════════════════════════════════════════════ --}} + @if($reportType === 'production') + + {{-- Plant --}} +
+ + +
+ + {{-- Line --}} +
+ + + @if(!$selectedPlantId) +

Select a plant first

+ @endif +
+ + {{-- Date Range --}} +
+
+ + +
+
+ + +
+
+ + {{-- Fetch Button --}} + + + {{-- Production Result --}} + @if($hasResult) +
+ + + +

{{ $result }}

+
+ @endif + + @endif {{-- end production --}} + + {{-- ══════════════════════════════════════════════════════════════ --}} + {{-- ── INVOICE REPORT FORM (type lookup) ── --}} + {{-- ══════════════════════════════════════════════════════════════ --}} + @if($reportType === 'invoice') + + {{-- Plant --}} +
+ + +
+ + {{-- Item Code --}} +
+ + +

Press Enter or click the button below

+
+ + {{-- Fetch Button --}} + + + {{-- Invoice Report Result --}} + @if($hasInvoiceResult) +
+ @if(str_contains($invoiceResult, 'serial invoice')) + + + + + @elseif(str_contains($invoiceResult, 'material invoice')) + + + + @elseif(str_contains($invoiceResult, 'does not exist') || str_contains($invoiceResult, 'not found')) + + + + @else + + + + @endif +

{{ $invoiceResult }}

+
+ @endif + + @endif {{-- end invoice report --}} + + {{-- ══════════════════════════════════════════════════════════════ --}} + {{-- ── INVOICE STATUS FORM (scan status) ── NEW ── --}} + {{-- ══════════════════════════════════════════════════════════════ --}} + @if($reportType === 'invoice_status') + + {{-- Description banner --}} +
+ + + +

+ Enter an invoice number to see how many serial numbers have been scanned, how many are pending, and the list of unscanned serials. +

+
+ + {{-- Invoice Number input --}} +
+ + +

Press Enter or click the button below

+
+ + {{-- Fetch Button --}} + + + {{-- Invoice Status Result --}} + @if($hasInvoiceStatusResult) + + @php + $sd = $invoiceStatusData; + $sdType = $sd['type'] ?? 'error'; + $sdTotal = $sd['total'] ?? 0; + $sdScanned = $sd['scanned'] ?? 0; + $sdNot = $sd['not_scanned'] ?? 0; + $sdSerials = $sd['unscanned_serials'] ?? []; + $sdCount = count($sdSerials); + $sdInv = $sd['invoice_number'] ?? ''; + $isGood = $sdType === 'all_scanned'; + $isWarn = in_array($sdType, ['error', 'not_found', 'invalid']); + $borderCol = $isGood ? '#10b981' : ($isWarn ? '#f59e0b' : '#3b82f6'); + @endphp + +
+ + {{-- ── Icon + summary row ── --}} +
+ @if($isGood) + + + + @elseif($isWarn) + + + + @else + + + + + @endif + +
+

+ {{ $sd['message'] ?? $invoiceStatusResult }} +

+ + {{-- ── Count pills (only for real invoice data) ── --}} + @if($sdTotal > 0) +
+ + Total: {{ $sdTotal }} + + + ✔ Scanned: {{ $sdScanned }} + + @if($sdNot > 0) + + ✘ Not scanned: {{ $sdNot }} + + @endif +
+ @endif +
+
+ + {{-- ── Unscanned serial numbers section ── --}} + @if($sdCount > 0) +
+ +

+ Unscanned serial numbers + + {{ $sdCount }} + +

+ + {{-- Serial chips — first 10, or all when expanded --}} + @php + $visibleSerials = $showAllUnscanned + ? $sdSerials + : array_slice($sdSerials, 0, 10); + $hiddenCount = $sdCount - 10; + @endphp + +
+ @foreach($visibleSerials as $serial) + {{ $serial }} + @endforeach +
+ + {{-- Show more / Show less button --}} + @if($sdCount > 10) + + @endif + +
+ @endif + +
+ @endif + + @endif {{-- end invoice_status --}} + +
+ @endif + + {{-- ══════════════════════════════════════════════════════════════════ --}} + {{-- ── ADVANCED MODE ── --}} + {{-- ══════════════════════════════════════════════════════════════════ --}} + @if($mode === 'advanced') +
+ + {{-- ── Conversation area ── --}} +
+ + @if(empty($chatHistory)) + {{-- ── Empty state ── --}} +
+
+ + + +
+
+

AI-Powered Assistant

+

Ask anything in plain English — tap an example below to get started

+
+ + {{-- Example prompt cards --}} +
+ + {{-- Card 1 — Invoice scan status --}} + + + {{-- Card 2 — Invoice type lookup --}} + + + {{-- Card 3 — Production report --}} + + +
+
+ @endif + + {{-- ── Chat bubbles ── --}} + @foreach($chatHistory as $message) + @if($message['role'] === 'user') +
+
{{ $message['content'] }}
+
+ @else +
+
+ + + +
+
{{ $message['content'] }}
+
+ @endif + @endforeach + + {{-- ── Typing indicator ── --}} + @if($isAdvancedLoading) +
+
+ + + +
+
+ + + +
+
+ @endif + +
+ + {{-- ── Input bar ── --}} +
+
+ + +
+

+ Press Enter to send · Ask anything in plain English +

+
+ +
+ @endif + +
+ @endif + + {{-- ── FAB Toggle Button ───────────────────────────────────────────────── --}} + +
diff --git a/resources/views/livewire/webcam.blade.php b/resources/views/livewire/webcam.blade.php new file mode 100644 index 0000000..1d5407b --- /dev/null +++ b/resources/views/livewire/webcam.blade.php @@ -0,0 +1,193 @@ +
+ {{-- ── 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/routes/web.php b/routes/web.php index 842d85f..2507d61 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@