complete story 6.4 with qa tests + fixed the problem with the navigation button to the export users page

This commit is contained in:
Naser Mansour
2025-12-27 20:15:37 +02:00
parent 07fc38de8d
commit b69b4c8be2
15 changed files with 1616 additions and 46 deletions
@@ -57,9 +57,14 @@ new class extends Component {
<flux:heading size="xl">{{ __('clients.company_clients') }}</flux:heading>
<flux:text class="mt-1 text-zinc-500 dark:text-zinc-400">{{ __('clients.clients') }}</flux:text>
</div>
<flux:button variant="primary" :href="route('admin.clients.company.create')" wire:navigate icon="plus">
{{ __('clients.create_company') }}
</flux:button>
<div class="flex gap-3">
<flux:button :href="route('admin.users.export')" wire:navigate icon="arrow-down-tray">
{{ __('export.export_users') }}
</flux:button>
<flux:button variant="primary" :href="route('admin.clients.company.create')" wire:navigate icon="plus">
{{ __('clients.create_company') }}
</flux:button>
</div>
</div>
<div class="mb-6 rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-800">
@@ -57,9 +57,14 @@ new class extends Component {
<flux:heading size="xl">{{ __('clients.individual_clients') }}</flux:heading>
<flux:text class="mt-1 text-zinc-500 dark:text-zinc-400">{{ __('clients.clients') }}</flux:text>
</div>
<flux:button variant="primary" :href="route('admin.clients.individual.create')" wire:navigate icon="plus">
{{ __('clients.create_client') }}
</flux:button>
<div class="flex gap-3">
<flux:button :href="route('admin.users.export')" wire:navigate icon="arrow-down-tray">
{{ __('export.export_users') }}
</flux:button>
<flux:button variant="primary" :href="route('admin.clients.individual.create')" wire:navigate icon="plus">
{{ __('clients.create_client') }}
</flux:button>
</div>
</div>
<div class="mb-6 rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-800">
@@ -0,0 +1,253 @@
<?php
use App\Enums\UserStatus;
use App\Enums\UserType;
use App\Models\User;
use Barryvdh\DomPDF\Facade\Pdf;
use League\Csv\Writer;
use Livewire\Volt\Component;
use Symfony\Component\HttpFoundation\StreamedResponse;
new class extends Component {
public string $userType = 'all';
public string $status = 'all';
public string $dateFrom = '';
public string $dateTo = '';
public function exportCsv(): ?StreamedResponse
{
$count = $this->getFilteredUsers()->count();
if ($count === 0) {
$this->dispatch('notify', type: 'info', message: __('export.no_users_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.name', [], $locale),
__('export.email', [], $locale),
__('export.phone', [], $locale),
__('export.user_type', [], $locale),
__('export.id_number', [], $locale),
__('export.status', [], $locale),
__('export.created_at', [], $locale),
]);
$this->getFilteredUsers()->cursor()->each(function ($user) use ($csv, $locale) {
$csv->insertOne([
$user->user_type === UserType::Company ? $user->company_name : $user->full_name,
$user->email,
$user->phone,
__('export.type_'.$user->user_type->value, [], $locale),
$user->user_type === UserType::Company ? $user->company_cert_number : $user->national_id,
__('export.status_'.$user->status->value, [], $locale),
$user->created_at->format($locale === 'ar' ? 'd/m/Y' : 'm/d/Y'),
]);
});
echo $csv->toString();
}, 'users-export-'.now()->format('Y-m-d').'.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
public function exportPdf(): ?StreamedResponse
{
$users = $this->getFilteredUsers()->get();
if ($users->isEmpty()) {
$this->dispatch('notify', type: 'info', message: __('export.no_users_match'));
return null;
}
if ($users->count() > 500) {
$this->dispatch('notify', type: 'warning', message: __('export.large_export_warning'));
}
$locale = auth()->user()->preferred_language ?? 'ar';
$pdf = Pdf::loadView('pdf.users-export', [
'users' => $users,
'locale' => $locale,
'generatedAt' => now(),
'filters' => $this->getActiveFilters(),
'totalCount' => $users->count(),
]);
$pdf->setOption('isHtml5ParserEnabled', true);
$pdf->setOption('defaultFont', 'DejaVu Sans');
return response()->streamDownload(
fn () => print($pdf->output()),
'users-export-'.now()->format('Y-m-d').'.pdf'
);
}
public function clearFilters(): void
{
$this->userType = 'all';
$this->status = 'all';
$this->dateFrom = '';
$this->dateTo = '';
}
public function with(): array
{
return [
'userTypes' => [
'all' => __('export.all_types'),
'individual' => __('export.type_individual'),
'company' => __('export.type_company'),
],
'statuses' => [
'all' => __('export.all_statuses'),
'active' => __('export.status_active'),
'deactivated' => __('export.status_deactivated'),
],
'previewCount' => $this->getFilteredUsers()->count(),
];
}
private function getFilteredUsers()
{
return User::query()
->when($this->userType !== 'all', fn ($q) => $q->where('user_type', $this->userType))
->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))
->whereIn('user_type', [UserType::Individual, UserType::Company])
->orderBy('created_at', 'desc');
}
private function getActiveFilters(): array
{
$filters = [];
if ($this->userType !== 'all') {
$filters['user_type'] = __('export.type_'.$this->userType);
}
if ($this->status !== 'all') {
$filters['status'] = __('export.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_users') }}</flux:heading>
<flux:text class="mt-1 text-zinc-500 dark:text-zinc-400">{{ __('export.export_users_description') }}</flux:text>
</div>
</div>
<div class="mb-6 rounded-lg border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-800">
<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">
<div>
<flux:select wire:model.live="userType" :label="__('export.user_type')">
@foreach ($userTypes as $value => $label)
<flux:select.option value="{{ $value }}">{{ $label }}</flux:select.option>
@endforeach
</flux:select>
</div>
<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>
<div>
<flux:input
wire:model.live="dateFrom"
type="date"
:label="__('export.date_from')"
/>
</div>
<div>
<flux:input
wire:model.live="dateTo"
type="date"
:label="__('export.date_to')"
/>
</div>
</div>
@if ($userType !== 'all' || $status !== 'all' || $dateFrom || $dateTo)
<div class="mt-4">
<flux:button wire:click="clearFilters" variant="ghost" 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 dark:border-zinc-700 dark:bg-zinc-800">
<div class="flex flex-col items-center justify-between gap-4 sm:flex-row">
<div>
<flux:text class="text-zinc-600 dark:text-zinc-400">
{{ __('export.total_records') }}: <span class="font-semibold text-zinc-900 dark:text-zinc-100">{{ $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="filled"
icon="document-text"
: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 dark:bg-zinc-900">
<flux:icon name="users" class="mx-auto mb-4 h-12 w-12 text-zinc-400" />
<flux:text class="text-zinc-500 dark:text-zinc-400">{{ __('export.no_users_match') }}</flux:text>
</div>
@endif
</div>
</div>
@@ -60,6 +60,9 @@ new class extends Component
<flux:button href="{{ route('admin.posts.create') }}" icon="document-plus" wire:navigate>
{{ __('widgets.create_post') }}
</flux:button>
<flux:button href="{{ route('admin.users.export') }}" icon="arrow-down-tray" wire:navigate>
{{ __('widgets.export_users') }}
</flux:button>
<flux:button wire:click="openBlockModal" icon="clock">
{{ __('widgets.block_time_slot') }}
</flux:button>
+267
View File
@@ -0,0 +1,267 @@
<!DOCTYPE html>
<html lang="{{ $locale }}" dir="{{ $locale === 'ar' ? 'rtl' : 'ltr' }}">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<title>{{ __('export.users_export_title', [], $locale) }}</title>
<style>
@page {
margin: 100px 50px 80px 50px;
}
body {
font-family: 'DejaVu Sans', sans-serif;
font-size: 10px;
color: #333;
direction: {{ $locale === 'ar' ? 'rtl' : 'ltr' }};
}
header {
position: fixed;
top: -80px;
left: 0;
right: 0;
height: 70px;
border-bottom: 3px solid #D4AF37;
padding-bottom: 10px;
}
.header-content {
display: table;
width: 100%;
}
.header-left, .header-right {
display: table-cell;
vertical-align: middle;
}
.header-left {
text-align: {{ $locale === 'ar' ? 'right' : 'left' }};
}
.header-right {
text-align: {{ $locale === 'ar' ? 'left' : 'right' }};
}
.brand-name {
font-size: 22px;
font-weight: bold;
color: #0A1F44;
}
.brand-subtitle {
font-size: 11px;
color: #666;
margin-top: 2px;
}
.report-title {
font-size: 14px;
font-weight: bold;
color: #0A1F44;
}
footer {
position: fixed;
bottom: -60px;
left: 0;
right: 0;
height: 50px;
border-top: 2px solid #D4AF37;
padding-top: 10px;
font-size: 9px;
color: #666;
}
.footer-content {
display: table;
width: 100%;
}
.footer-left, .footer-right {
display: table-cell;
vertical-align: middle;
}
.footer-left {
text-align: {{ $locale === 'ar' ? 'right' : 'left' }};
}
.footer-right {
text-align: {{ $locale === 'ar' ? 'left' : 'right' }};
}
.page-number:after {
content: counter(page);
}
.filters-section {
background-color: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 4px;
padding: 10px 15px;
margin-bottom: 20px;
}
.filters-title {
font-weight: bold;
color: #0A1F44;
margin-bottom: 5px;
}
.filter-item {
display: inline-block;
margin-{{ $locale === 'ar' ? 'left' : 'right' }}: 15px;
color: #666;
}
.summary {
margin-bottom: 15px;
color: #0A1F44;
}
.summary strong {
color: #D4AF37;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
th {
background-color: #0A1F44;
color: #fff;
padding: 10px 8px;
text-align: {{ $locale === 'ar' ? 'right' : 'left' }};
font-weight: bold;
font-size: 9px;
text-transform: uppercase;
}
td {
padding: 8px;
border-bottom: 1px solid #e9ecef;
text-align: {{ $locale === 'ar' ? 'right' : 'left' }};
font-size: 9px;
}
tr:nth-child(even) {
background-color: #f8f9fa;
}
tr:hover {
background-color: #fff3cd;
}
.status-active {
color: #28a745;
font-weight: bold;
}
.status-deactivated {
color: #dc3545;
font-weight: bold;
}
.user-type {
background-color: #e9ecef;
padding: 2px 6px;
border-radius: 3px;
font-size: 8px;
}
.no-data {
text-align: center;
padding: 40px;
color: #666;
}
</style>
</head>
<body>
<header>
<div class="header-content">
<div class="header-left">
<div class="brand-name">Libra</div>
<div class="brand-subtitle">{{ __('export.libra_law_firm', [], $locale) }}</div>
</div>
<div class="header-right">
<div class="report-title">{{ __('export.users_export_title', [], $locale) }}</div>
</div>
</div>
</header>
<footer>
<div class="footer-content">
<div class="footer-left">
{{ __('export.generated_at', [], $locale) }}: {{ $generatedAt->format($locale === 'ar' ? 'd/m/Y H:i' : 'm/d/Y H:i') }}
</div>
<div class="footer-right">
{{ __('export.page', [], $locale) }} <span class="page-number"></span>
</div>
</div>
</footer>
<main>
@if(count($filters) > 0)
<div class="filters-section">
<div class="filters-title">{{ __('export.filters_applied', [], $locale) }}:</div>
@foreach($filters as $key => $value)
<span class="filter-item">
@if($key === 'user_type')
{{ __('export.user_type', [], $locale) }}: {{ $value }}
@elseif($key === 'status')
{{ __('export.status', [], $locale) }}: {{ $value }}
@elseif($key === 'date_from')
{{ __('export.date_from', [], $locale) }}: {{ $value }}
@elseif($key === 'date_to')
{{ __('export.date_to', [], $locale) }}: {{ $value }}
@endif
</span>
@endforeach
</div>
@endif
<div class="summary">
{{ __('export.total_records', [], $locale) }}: <strong>{{ $totalCount }}</strong>
</div>
@if($users->count() > 0)
<table>
<thead>
<tr>
<th>{{ __('export.name', [], $locale) }}</th>
<th>{{ __('export.email', [], $locale) }}</th>
<th>{{ __('export.phone', [], $locale) }}</th>
<th>{{ __('export.user_type', [], $locale) }}</th>
<th>{{ __('export.id_number', [], $locale) }}</th>
<th>{{ __('export.status', [], $locale) }}</th>
<th>{{ __('export.created_at', [], $locale) }}</th>
</tr>
</thead>
<tbody>
@foreach($users as $user)
<tr>
<td>{{ $user->user_type->value === 'company' ? $user->company_name : $user->full_name }}</td>
<td>{{ $user->email }}</td>
<td>{{ $user->phone }}</td>
<td><span class="user-type">{{ __('export.type_' . $user->user_type->value, [], $locale) }}</span></td>
<td>{{ $user->user_type->value === 'company' ? $user->company_cert_number : $user->national_id }}</td>
<td class="{{ $user->status->value === 'active' ? 'status-active' : 'status-deactivated' }}">
{{ __('export.status_' . $user->status->value, [], $locale) }}
</td>
<td>{{ $user->created_at->format($locale === 'ar' ? 'd/m/Y' : 'm/d/Y') }}</td>
</tr>
@endforeach
</tbody>
</table>
@else
<div class="no-data">
{{ __('export.no_users_match', [], $locale) }}
</div>
@endif
</main>
</body>
</html>