235 lines
9.3 KiB
PHP
235 lines
9.3 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;
|
|
|
|
public bool $limitReached = false;
|
|
|
|
public function mount(): void
|
|
{
|
|
$this->limitReached = Auth::user()->certificateLimitReached();
|
|
}
|
|
|
|
/**
|
|
* 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' || ! $user->plan()->includesOcr()) {
|
|
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
|
|
{
|
|
// Re-check server-side even though the form is hidden once the
|
|
// limit is reached, in case the plan's limit was hit between
|
|
// page load and submit (e.g. another tab, or a downgrade).
|
|
if (Auth::user()->certificateLimitReached()) {
|
|
$this->limitReached = true;
|
|
|
|
return;
|
|
}
|
|
|
|
$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>
|
|
|
|
@if ($limitReached)
|
|
<flux:callout icon="lock-closed" variant="warning" :heading="__('Certificate limit reached')">
|
|
<flux:callout.text>
|
|
{{ __('Your plan is full — upgrade to add more certificates.') }}
|
|
</flux:callout.text>
|
|
<x-slot name="actions">
|
|
<flux:button :href="route('marketing.pricing')" wire:navigate variant="primary">{{ __('View plans') }}</flux:button>
|
|
<flux:button :href="route('certificates.index')" wire:navigate variant="ghost">{{ __('Back to certificates') }}</flux:button>
|
|
</x-slot>
|
|
</flux:callout>
|
|
@else
|
|
<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>
|
|
@endif
|
|
</section>
|