libra/resources/views/livewire/admin/timelines/export-timelines.blade.php

334 lines
12 KiB
PHP

<?php
use App\Enums\TimelineStatus;
use App\Models\Timeline;
use App\Models\User;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Support\Carbon;
use League\Csv\Writer;
use Livewire\Volt\Component;
use Symfony\Component\HttpFoundation\StreamedResponse;
new class extends Component {
public string $clientSearch = '';
public ?int $clientId = null;
public string $status = 'all';
public string $dateFrom = '';
public string $dateTo = '';
public bool $includeUpdates = false;
public function getClientsProperty()
{
if (strlen($this->clientSearch) < 2) {
return collect();
}
return User::query()
->whereIn('user_type', ['individual', 'company'])
->where(fn ($q) => $q
->where('full_name', 'like', "%{$this->clientSearch}%")
->orWhere('email', 'like', "%{$this->clientSearch}%"))
->limit(10)
->get();
}
public function selectClient(int $id): void
{
$this->clientId = $id;
$this->clientSearch = User::find($id)?->full_name ?? '';
}
public function clearClient(): void
{
$this->clientId = null;
$this->clientSearch = '';
}
public function clearFilters(): void
{
$this->clientId = null;
$this->clientSearch = '';
$this->status = 'all';
$this->dateFrom = '';
$this->dateTo = '';
$this->includeUpdates = false;
}
public function exportCsv(): ?StreamedResponse
{
$count = $this->getFilteredTimelines()->count();
if ($count === 0) {
$this->dispatch('notify', type: 'info', message: __('export.no_timelines_match'));
return null;
}
$locale = auth()->user()->preferred_language ?? 'ar';
return response()->streamDownload(function () use ($locale) {
// UTF-8 BOM for Excel Arabic support
echo "\xEF\xBB\xBF";
$csv = Writer::createFromString();
// Headers based on admin language
$csv->insertOne([
__('export.case_name', [], $locale),
__('export.case_reference', [], $locale),
__('export.client_name', [], $locale),
__('export.status', [], $locale),
__('export.created_date', [], $locale),
__('export.updates_count', [], $locale),
__('export.last_update', [], $locale),
]);
$this->getFilteredTimelines()->cursor()->each(function ($timeline) use ($csv, $locale) {
$csv->insertOne([
$timeline->case_name,
$timeline->case_reference ?? '-',
$timeline->user->full_name,
__('export.timeline_status_'.$timeline->status->value, [], $locale),
$timeline->created_at->format('Y-m-d'),
$timeline->updates_count,
$timeline->updates_max_created_at
? Carbon::parse($timeline->updates_max_created_at)->format('Y-m-d H:i')
: '-',
]);
});
echo $csv->toString();
}, 'timelines-export-'.now()->format('Y-m-d').'.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
public function exportPdf(): ?StreamedResponse
{
$query = $this->getFilteredTimelines();
if ($this->includeUpdates) {
$query->with(['updates' => fn ($q) => $q->orderBy('created_at', 'desc')]);
}
$timelines = $query->get();
if ($timelines->isEmpty()) {
$this->dispatch('notify', type: 'info', message: __('export.no_timelines_match'));
return null;
}
if ($timelines->count() > 500) {
$this->dispatch('notify', type: 'warning', message: __('export.large_export_warning'));
}
$locale = auth()->user()->preferred_language ?? 'ar';
$pdf = Pdf::loadView('pdf.timelines-export', [
'timelines' => $timelines,
'includeUpdates' => $this->includeUpdates,
'locale' => $locale,
'generatedAt' => now(),
'filters' => $this->getActiveFilters(),
'totalCount' => $timelines->count(),
]);
$pdf->setOption('isHtml5ParserEnabled', true);
$pdf->setOption('defaultFont', 'DejaVu Sans');
return response()->streamDownload(
fn () => print($pdf->output()),
'timelines-export-'.now()->format('Y-m-d').'.pdf'
);
}
public function with(): array
{
return [
'statuses' => [
'all' => __('export.all_statuses'),
'active' => __('export.timeline_status_active'),
'archived' => __('export.timeline_status_archived'),
],
'previewCount' => $this->getFilteredTimelines()->count(),
];
}
private function getFilteredTimelines()
{
return Timeline::query()
->with('user:id,full_name')
->withCount('updates')
->withMax('updates', 'created_at')
->when($this->clientId, fn ($q) => $q->where('user_id', $this->clientId))
->when($this->status !== 'all', fn ($q) => $q->where('status', $this->status))
->when($this->dateFrom, fn ($q) => $q->whereDate('created_at', '>=', $this->dateFrom))
->when($this->dateTo, fn ($q) => $q->whereDate('created_at', '<=', $this->dateTo))
->orderBy('created_at', 'desc');
}
private function getActiveFilters(): array
{
$filters = [];
if ($this->clientId) {
$filters['client'] = User::find($this->clientId)?->full_name;
}
if ($this->status !== 'all') {
$filters['status'] = __('export.timeline_status_'.$this->status);
}
if ($this->dateFrom) {
$filters['date_from'] = $this->dateFrom;
}
if ($this->dateTo) {
$filters['date_to'] = $this->dateTo;
}
return $filters;
}
}; ?>
<div>
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<flux:heading size="xl">{{ __('export.export_timelines') }}</flux:heading>
<flux:text class="mt-1 text-zinc-500">{{ __('export.export_timelines_description') }}</flux:text>
</div>
</div>
<div class="mb-6 rounded-lg border border-zinc-200 bg-white p-6">
<flux:heading size="lg" class="mb-4">{{ __('export.filters_applied') }}</flux:heading>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{{-- Client Search --}}
<div class="relative">
<flux:input
wire:model.live.debounce.300ms="clientSearch"
:label="__('export.client_name')"
:placeholder="__('export.search_client_placeholder')"
icon="magnifying-glass"
/>
@if ($clientId)
<button
wire:click="clearClient"
type="button"
class="absolute end-2 top-8 text-zinc-400 hover:text-zinc-600"
>
<flux:icon name="x-mark" class="h-4 w-4" />
</button>
@endif
@if (strlen($clientSearch) >= 2 && ! $clientId && $this->clients->count() > 0)
<div class="absolute z-10 mt-1 w-full rounded-lg border border-zinc-200 bg-white shadow-lg">
@foreach ($this->clients as $client)
<button
wire:click="selectClient({{ $client->id }})"
type="button"
class="block w-full px-4 py-2 text-start hover:bg-zinc-100"
>
<span class="font-medium">{{ $client->full_name }}</span>
<span class="text-sm text-zinc-500">{{ $client->email }}</span>
</button>
@endforeach
</div>
@endif
</div>
{{-- Status Filter --}}
<div>
<flux:select wire:model.live="status" :label="__('export.status')">
@foreach ($statuses as $value => $label)
<flux:select.option value="{{ $value }}">{{ $label }}</flux:select.option>
@endforeach
</flux:select>
</div>
{{-- Date From --}}
<div>
<flux:input
wire:model.live="dateFrom"
type="date"
:label="__('export.date_from')"
/>
</div>
{{-- Date To --}}
<div>
<flux:input
wire:model.live="dateTo"
type="date"
:label="__('export.date_to')"
/>
</div>
</div>
{{-- Include Updates Toggle --}}
<div class="mt-4">
<flux:checkbox
wire:model.live="includeUpdates"
:label="__('export.include_updates')"
/>
<flux:text class="ms-6 text-sm text-zinc-500">
{{ __('export.include_updates_description') }}
</flux:text>
</div>
@if ($clientId || $status !== 'all' || $dateFrom || $dateTo || $includeUpdates)
<div class="mt-4">
<flux:button wire:click="clearFilters" variant="outline" icon="x-mark" size="sm">
{{ __('export.clear_filters') }}
</flux:button>
</div>
@endif
</div>
<div class="rounded-lg border border-zinc-200 bg-white p-6">
<div class="flex flex-col items-center justify-between gap-4 sm:flex-row">
<div>
<flux:text class="text-zinc-600">
{{ __('export.total_records') }}: <span class="font-semibold text-zinc-900">{{ $previewCount }}</span>
</flux:text>
</div>
<div class="flex gap-3">
<flux:button
wire:click="exportCsv"
wire:loading.attr="disabled"
wire:target="exportCsv,exportPdf"
variant="primary"
icon="document-arrow-down"
:disabled="$previewCount === 0"
>
<span wire:loading.remove wire:target="exportCsv">{{ __('export.export_csv') }}</span>
<span wire:loading wire:target="exportCsv">{{ __('export.exporting') }}</span>
</flux:button>
<flux:button
wire:click="exportPdf"
wire:loading.attr="disabled"
wire:target="exportCsv,exportPdf"
variant="outline"
icon="document-text"
class="!bg-zinc-700 !text-white hover:!bg-zinc-800"
:disabled="$previewCount === 0"
>
<span wire:loading.remove wire:target="exportPdf">{{ __('export.export_pdf') }}</span>
<span wire:loading wire:target="exportPdf">{{ __('export.exporting') }}</span>
</flux:button>
</div>
</div>
@if ($previewCount === 0)
<div class="mt-6 rounded-lg bg-zinc-50 p-8 text-center">
<flux:icon name="clock" class="mx-auto mb-4 h-12 w-12 text-zinc-400" />
<flux:text class="text-zinc-500">{{ __('export.no_timelines_match') }}</flux:text>
</div>
@endif
</div>
</div>