Certified/resources/views/pages/certificates/⚡create.blade.php
2026-08-04 10:18:10 +02:00

207 lines
8.2 KiB
PHP

<?php
use App\Models\Category;
use App\Ocr\OcrManager;
use Flux\Flux;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Title;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
new #[Title('Add certificate')] class extends Component {
use WithFileUploads;
public ?TemporaryUploadedFile $file = null;
public string $title = '';
public string $category_id = '';
public string $issuer = '';
public string $certificate_number = '';
public string $holder_last_name = '';
public string $issued_at = '';
public string $expires_at = '';
public string $notes = '';
public bool $scanning = false;
/**
* Runs when a file is selected. If OCR is enabled for the user, analyze
* the uploaded document and pre-fill empty fields with suggestions.
*/
public function updatedFile(): void
{
$this->validate([
'file' => ['required', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:10240'],
]);
$user = Auth::user();
if ($user->ocr_provider === 'none') {
return;
}
$this->scanning = true;
$suggestions = app(OcrManager::class)
->driver($user->ocr_provider)
->analyze($this->file->getRealPath(), $user->ocr_api_key);
if (blank($this->title) && filled($suggestions['title'] ?? null)) {
$this->title = $suggestions['title'];
}
if (blank($this->expires_at) && filled($suggestions['expires_at'] ?? null)) {
$this->expires_at = $suggestions['expires_at'];
}
$this->scanning = false;
}
public function save(): void
{
$validated = $this->validate([
'file' => ['required', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:10240'],
'title' => ['required', 'string', 'max:255'],
'category_id' => ['nullable', 'integer', 'exists:categories,id'],
'issuer' => ['nullable', 'string', 'max:255'],
'certificate_number' => ['nullable', 'string', 'max:255'],
'holder_last_name' => ['nullable', 'string', 'max:255'],
'issued_at' => ['nullable', 'date', 'before_or_equal:expires_at'],
'expires_at' => ['required', 'date'],
'notes' => ['nullable', 'string', 'max:2000'],
]);
$path = $this->file->storeAs(
'certificates/'.Auth::id(),
Str::uuid().'.'.$this->file->getClientOriginalExtension(),
'local'
);
Auth::user()->certificates()->create([
'title' => $validated['title'],
'category_id' => $validated['category_id'] ?: null,
'issuer' => $validated['issuer'] ?: null,
'certificate_number' => $validated['certificate_number'] ?: null,
'holder_last_name' => $validated['holder_last_name'] ?: null,
'issued_at' => $validated['issued_at'] ?: null,
'file_path' => $path,
'expires_at' => $validated['expires_at'],
'notes' => $validated['notes'] ?: null,
]);
Flux::toast(variant: 'success', text: __('Certificate added.'));
$this->redirectRoute('certificates.index', navigate: true);
}
/**
* @return Collection<int, Category>
*/
#[Computed]
public function categories(): Collection
{
return Category::orderBy('name')->get();
}
}; ?>
<section
x-data="{
previewUrl: null,
isImage: false,
isPdf: false,
setPreview(event) {
if (event.target?.type !== 'file') return;
if (this.previewUrl) URL.revokeObjectURL(this.previewUrl);
const file = event.target.files?.[0];
if (! file) { this.previewUrl = null; return; }
this.previewUrl = URL.createObjectURL(file);
this.isImage = file.type.startsWith('image/');
this.isPdf = file.type === 'application/pdf';
},
}"
@change="setPreview($event)"
class="flex w-full flex-col gap-6"
>
<div>
<flux:heading size="xl">{{ __('Add certificate') }}</flux:heading>
<flux:text class="mt-1">{{ __('Upload a document to preview it, then confirm the details — pre-filled automatically if scanning is enabled.') }}</flux:text>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2 lg:items-start">
{{-- Left: live document preview (client-side, before upload completes) --}}
<div class="lg:sticky lg:top-6">
<div
class="relative flex h-[60vh] items-center justify-center overflow-hidden rounded-2xl border bg-zinc-50 transition-colors duration-200 lg:h-[calc(100vh-9rem)] dark:bg-zinc-800/40"
:class="previewUrl ? 'border-zinc-200 dark:border-zinc-700' : 'border-2 border-dashed border-emerald-400/60 dark:border-emerald-500/40'"
>
<div x-show="! previewUrl" class="flex flex-col items-center gap-3 px-6 text-center text-zinc-400">
<span class="flex size-12 items-center justify-center rounded-2xl bg-emerald-100 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-400">
<flux:icon.document-arrow-up class="size-6" />
</span>
<flux:text>{{ __('Select a document to preview it here.') }}</flux:text>
</div>
<img x-show="previewUrl && isImage" :src="previewUrl" alt="" class="max-h-full max-w-full object-contain" />
<iframe x-show="previewUrl && isPdf" :src="isPdf ? previewUrl + '#toolbar=0&navpanes=0&view=FitH' : ''" class="h-full w-full" title="{{ __('Document preview') }}"></iframe>
<div wire:loading wire:target="file" class="absolute inset-0 flex items-center justify-center gap-2 bg-white/70 dark:bg-zinc-900/70">
<flux:icon.loading variant="mini" />
<flux:text>{{ __('Uploading…') }}</flux:text>
</div>
</div>
</div>
{{-- Right: form fields --}}
<form wire:submit="save" class="space-y-6">
<flux:input
type="file"
wire:model="file"
:label="__('Document')"
accept=".pdf,.jpg,.jpeg,.png"
:description="__('PDF or image, up to 10 MB.')"
/>
@if ($scanning)
<flux:callout icon="sparkles" variant="secondary">
{{ __('Scanning document for suggestions…') }}
</flux:callout>
@endif
<flux:input wire:model="title" :label="__('Title')" required />
<flux:select wire:model="category_id" :label="__('Category')" :placeholder="__('Uncategorized')">
<flux:select.option value="">{{ __('Uncategorized') }}</flux:select.option>
@foreach ($this->categories as $category)
<flux:select.option value="{{ $category->id }}">{{ $category->name }}</flux:select.option>
@endforeach
</flux:select>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<flux:input wire:model="issuer" :label="__('Issuing authority')" :placeholder="__('e.g. VCA, Red Cross')" />
<flux:input wire:model="certificate_number" :label="__('Certificate number')" />
<flux:input wire:model="holder_last_name" :label="__('Holder last name')" :description="__('Used by the public verification page.')" />
<flux:input type="date" wire:model="issued_at" :label="__('Issue date')" />
<flux:input type="date" wire:model="expires_at" :label="__('Expiry date')" required />
</div>
<flux:textarea wire:model="notes" :label="__('Notes')" rows="3" :placeholder="__('Optional notes about this certificate')" />
<div class="flex items-center gap-3">
<flux:button type="submit" variant="primary">{{ __('Save certificate') }}</flux:button>
<flux:button :href="route('certificates.index')" wire:navigate variant="ghost">{{ __('Cancel') }}</flux:button>
</div>
</form>
</div>
</section>