From efa97b6c7142ff81cf90d170051cdf3465e14958 Mon Sep 17 00:00:00 2001 From: phuongtc Date: Sat, 9 May 2026 11:00:59 +0000 Subject: [PATCH] them log, chinh upload --- AGENTS.md | 2 + PROGRESS.md | 13 + app/Console/Commands/ExportBackup.php | 12 + app/Console/Commands/ImportCskh.php | 26 ++ app/Enums/ContractStatus.php | 27 +- app/Enums/ContractType.php | 25 +- app/Enums/HandoverStatus.php | 29 ++- app/Enums/ServiceType.php | 27 +- .../ActivityLogs/ActivityLogResource.php | 132 ++++++++++ .../ActivityLogs/Pages/ListActivityLogs.php | 11 + .../FeedbacksRelationManager.php | 5 +- .../Feedback/Pages/CreateFeedback.php | 26 +- .../Resources/Feedback/Pages/EditFeedback.php | 34 ++- .../InteractionsRelationManager.php | 31 ++- .../ContractsRelationManager.php | 6 +- .../HandoversRelationManager.php | 5 +- .../ProductServicesRelationManager.php | 63 ++++- app/Models/ActivityLog.php | 32 +++ app/Policies/ActivityLogPolicy.php | 34 +++ app/Services/ActivityLogger.php | 27 ++ app/Services/ClosingService.php | 12 + app/Services/FileService.php | 2 +- app/Services/TransferService.php | 18 ++ ...5_09_104519_create_activity_logs_table.php | 26 ++ database/seeders/PermissionSeeder.php | 4 + docs/ENUM_GUIDE.md | 233 ++++++++++++++++++ docs/I18N_GUIDE.md | 162 ++++++++++++ lang/en/app.php | 14 ++ lang/en/enums.php | 19 ++ lang/en/feedback.php | 6 + lang/vi/app.php | 14 ++ lang/vi/enums.php | 19 ++ lang/vi/feedback.php | 6 + 33 files changed, 1055 insertions(+), 47 deletions(-) create mode 100644 app/Filament/Resources/ActivityLogs/ActivityLogResource.php create mode 100644 app/Filament/Resources/ActivityLogs/Pages/ListActivityLogs.php create mode 100644 app/Models/ActivityLog.php create mode 100644 app/Policies/ActivityLogPolicy.php create mode 100644 app/Services/ActivityLogger.php create mode 100644 database/migrations/2026_05_09_104519_create_activity_logs_table.php create mode 100644 docs/ENUM_GUIDE.md create mode 100644 docs/I18N_GUIDE.md diff --git a/AGENTS.md b/AGENTS.md index 4da94f1b..b3a83dc4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,8 @@ > - `CODEBASE_SNAPSHOT.md` — Toàn bộ cấu trúc codebase (thay vì scan lại code) > - `TASKS_ROADMAP.md` — Roadmap: đã làm, đang làm, sẽ làm > - `PROGRESS.md` — Lịch sử tiến độ hoàn thành +> - `docs/I18N_GUIDE.md` — Hướng dẫn đa ngôn ngữ +> - `docs/ENUM_GUIDE.md` — Hướng dẫn thêm Enum mới ## Overview AfterSales CRM is a real-estate after-sales customer care system built with **Laravel 13.6** + **Filament 5.6**. diff --git a/PROGRESS.md b/PROGRESS.md index 320ebbb5..506b6d05 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -152,6 +152,17 @@ Tham chiếu: `SOP_des.md` — Bản đặc tả hệ thống After-Sale CRM - [x] Filament vendor translations (57 Vietnamese files) auto-activated for UI chrome - [x] Tests: 8/8 pass (21 assertions) +### UI Polish & Bug Fixes +- [x] File upload: Fixed disk('local') → disk('public') across all components +- [x] File upload: Added exists() check before getting metadata +- [x] File upload: Fixed dehydrated(false) issue - use property to store paths +- [x] Tailwind CSS: Added ~200 utility classes to filament-custom.css +- [x] SVG icons: Use inline width/height attributes instead of Tailwind classes +- [x] GlobalSearch: Customized for all 6 Resources with multi-field search +- [x] RelationManager titles: Override getTitle() for proper translation +- [x] Enum refactoring: Added color() method, static options(), developer comments +- [x] Documentation: Created docs/I18N_GUIDE.md and docs/ENUM_GUIDE.md + --- ## Database Schema (final — 23 migrations) @@ -187,3 +198,5 @@ php artisan test # 8 tests (21 assertions) - `CODEBASE_SNAPSHOT.md` — Toàn bộ cấu trúc codebase (đọc thay vì scan code) - `TASKS_ROADMAP.md` — Roadmap: đã làm, đang làm, sẽ làm - `AGENTS.md` — Hướng dẫn chạy, workflow, RBAC, gotchas +- `docs/I18N_GUIDE.md` — Hướng dẫn đa ngôn ngữ chi tiết +- `docs/ENUM_GUIDE.md` — Hướng dẫn thêm Enum mới diff --git a/app/Console/Commands/ExportBackup.php b/app/Console/Commands/ExportBackup.php index 9d923aed..427fb513 100644 --- a/app/Console/Commands/ExportBackup.php +++ b/app/Console/Commands/ExportBackup.php @@ -2,6 +2,7 @@ namespace App\Console\Commands; +use App\Services\ActivityLogger; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; @@ -71,6 +72,17 @@ class ExportBackup extends Command $this->printSummary($backupDir, $totalRows); + ActivityLogger::log( + 'export', + __('feedback.log_export_done', ['tables' => count($tables), 'rows' => $totalRows]), + null, + [ + 'tables' => $this->stats, + 'total_rows' => $totalRows, + 'path' => $backupDir, + ], + ); + return self::SUCCESS; } diff --git a/app/Console/Commands/ImportCskh.php b/app/Console/Commands/ImportCskh.php index f19cf498..67fbf73d 100644 --- a/app/Console/Commands/ImportCskh.php +++ b/app/Console/Commands/ImportCskh.php @@ -2,6 +2,7 @@ namespace App\Console\Commands; +use App\Services\ActivityLogger; use App\Models\Contract; use App\Models\Customer; use App\Models\CustomerProduct; @@ -52,6 +53,8 @@ class ImportCskh extends Command $this->warn('=== DRY RUN MODE — No data will be written ==='); } + $startTime = now(); + $rows = SimpleExcelReader::create($filePath)->getRows(); $total = iterator_count($rows); @@ -94,6 +97,29 @@ class ImportCskh extends Command $this->newLine(2); $this->printSummary($rowCount, $dryRun); + $duration = now()->diffInSeconds($startTime); + $modeLabel = $dryRun ? '[DRY RUN] ' : ''; + + ActivityLogger::log( + 'import', + $modeLabel . __('feedback.log_import_done', ['file' => basename($filePath), 'rows' => $rowCount]), + null, + [ + 'file' => $filePath, + 'total_rows' => $rowCount, + 'skipped' => $this->statsSkipped, + 'products' => $this->statsProducts, + 'customers' => $this->statsCustomers, + 'contracts' => $this->statsContracts, + 'handovers' => $this->statsHandovers, + 'services' => $this->statsServices, + 'feedbacks' => $this->statsFeedbacks, + 'interactions' => $this->statsInteractions, + 'duration_seconds' => $duration, + 'dry_run' => $dryRun, + ], + ); + return self::SUCCESS; } diff --git a/app/Enums/ContractStatus.php b/app/Enums/ContractStatus.php index b1676460..96a077d7 100644 --- a/app/Enums/ContractStatus.php +++ b/app/Enums/ContractStatus.php @@ -2,6 +2,14 @@ namespace App\Enums; +/** + * Contract Status Enum + * + * Để thêm trạng thái mới: + * 1. Thêm case mới vào enum + * 2. Thêm label trong lang/vi/enums.php và lang/en/enums.php + * 3. Reload browser + */ enum ContractStatus: string { case ACTIVE = 'active'; @@ -17,12 +25,19 @@ enum ContractStatus: string }; } - public function options(): array + public function color(): string { - return [ - self::ACTIVE->value => self::ACTIVE->label(), - self::EXPIRED->value => self::EXPIRED->label(), - self::LIQUIDATED->value => self::LIQUIDATED->label(), - ]; + return match ($this) { + self::ACTIVE => 'success', + self::EXPIRED => 'warning', + self::LIQUIDATED => 'danger', + }; + } + + public static function options(): array + { + return collect(self::cases())->mapWithKeys(fn ($case) => [ + $case->value => $case->label(), + ])->toArray(); } } diff --git a/app/Enums/ContractType.php b/app/Enums/ContractType.php index 95a0368e..be4fd58f 100644 --- a/app/Enums/ContractType.php +++ b/app/Enums/ContractType.php @@ -2,6 +2,14 @@ namespace App\Enums; +/** + * Contract Type Enum + * + * Để thêm loại hợp đồng mới: + * 1. Thêm case mới vào enum + * 2. Thêm label trong lang/vi/enums.php và lang/en/enums.php + * 3. Reload browser + */ enum ContractType: string { case SALE = 'sale'; @@ -17,12 +25,19 @@ enum ContractType: string }; } + public function color(): string + { + return match ($this) { + self::SALE => 'success', + self::LEASE => 'info', + self::BCC => 'warning', + }; + } + public static function options(): array { - return [ - self::SALE->value => self::SALE->label(), - self::LEASE->value => self::LEASE->label(), - self::BCC->value => self::BCC->label(), - ]; + return collect(self::cases())->mapWithKeys(fn ($case) => [ + $case->value => $case->label(), + ])->toArray(); } } diff --git a/app/Enums/HandoverStatus.php b/app/Enums/HandoverStatus.php index e6679b0e..b6b1f1a3 100644 --- a/app/Enums/HandoverStatus.php +++ b/app/Enums/HandoverStatus.php @@ -2,6 +2,14 @@ namespace App\Enums; +/** + * Handover Status Enum + * + * Để thêm trạng thái mới: + * 1. Thêm case mới vào enum + * 2. Thêm label trong lang/vi/enums.php và lang/en/enums.php + * 3. Reload browser + */ enum HandoverStatus: string { case NOT_READY = 'chua_du_dieu_kien'; @@ -19,13 +27,20 @@ enum HandoverStatus: string }; } - public function options(): array + public function color(): string { - return [ - self::NOT_READY->value => self::NOT_READY->label(), - self::READY->value => self::READY->label(), - self::SCHEDULED->value => self::SCHEDULED->label(), - self::HANDED_OVER->value => self::HANDED_OVER->label(), - ]; + return match ($this) { + self::NOT_READY => 'danger', + self::READY => 'warning', + self::HANDED_OVER => 'success', + self::SCHEDULED => 'info', + }; + } + + public static function options(): array + { + return collect(self::cases())->mapWithKeys(fn ($case) => [ + $case->value => $case->label(), + ])->toArray(); } } diff --git a/app/Enums/ServiceType.php b/app/Enums/ServiceType.php index 62c52ff0..b071a5aa 100644 --- a/app/Enums/ServiceType.php +++ b/app/Enums/ServiceType.php @@ -2,6 +2,14 @@ namespace App\Enums; +/** + * Service Type Enum + * + * Để thêm loại dịch vụ mới: + * 1. Thêm case mới vào enum + * 2. Thêm label trong lang/vi/enums.php và lang/en/enums.php + * 3. Reload browser + */ enum ServiceType: string { case MANAGEMENT_FEE = 'management_fee'; @@ -17,12 +25,19 @@ enum ServiceType: string }; } - public function options(): array + public function color(): string { - return [ - self::MANAGEMENT_FEE->value => self::MANAGEMENT_FEE->label(), - self::GRATITUDE->value => self::GRATITUDE->label(), - self::SELF_BUSINESS->value => self::SELF_BUSINESS->label(), - ]; + return match ($this) { + self::MANAGEMENT_FEE => 'info', + self::GRATITUDE => 'success', + self::SELF_BUSINESS => 'warning', + }; + } + + public static function options(): array + { + return collect(self::cases())->mapWithKeys(fn ($case) => [ + $case->value => $case->label(), + ])->toArray(); } } diff --git a/app/Filament/Resources/ActivityLogs/ActivityLogResource.php b/app/Filament/Resources/ActivityLogs/ActivityLogResource.php new file mode 100644 index 00000000..9680187d --- /dev/null +++ b/app/Filament/Resources/ActivityLogs/ActivityLogResource.php @@ -0,0 +1,132 @@ +recordTitleAttribute('description') + ->defaultSort('created_at', 'desc') + ->columns([ + TextColumn::make('created_at') + ->label(__('app.time')) + ->dateTime('d/m/Y H:i:s') + ->sortable(), + TextColumn::make('user.name') + ->label(__('app.user')) + ->placeholder(__('app.system')), + TextColumn::make('action') + ->label(__('app.action')) + ->badge() + ->formatStateUsing(fn (string $state): string => match ($state) { + 'import' => __('app.action_import'), + 'export' => __('app.action_export'), + 'transfer' => __('app.action_transfer'), + 'close' => __('app.action_close'), + 'create' => __('app.action_create'), + 'update' => __('app.action_update'), + 'delete' => __('app.action_delete'), + default => $state, + }) + ->color(fn (string $state): string => match ($state) { + 'import' => 'info', + 'export' => 'success', + 'transfer' => 'warning', + 'close' => 'success', + 'create' => 'info', + 'update' => 'gray', + 'delete' => 'danger', + default => 'gray', + }), + TextColumn::make('description') + ->label(__('app.description')) + ->wrap() + ->searchable(), + TextColumn::make('metadata') + ->label(__('app.details')) + ->formatStateUsing(function (?array $state): string { + if (empty($state)) return ''; + $lines = []; + foreach ($state as $key => $val) { + if (is_scalar($val)) { + $lines[] = "{$key}: {$val}"; + } elseif (is_array($val)) { + $nested = []; + foreach ($val as $k => $v) { + if (is_scalar($v)) { + $nested[] = "{$k}: {$v}"; + } + } + $lines[] = "{$key}: " . implode(', ', $nested); + } + } + return implode('
', $lines); + }) + ->html() + ->wrap() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + SelectFilter::make('action') + ->label(__('app.action')) + ->options([ + 'import' => __('app.action_import'), + 'export' => __('app.action_export'), + 'transfer' => __('app.action_transfer'), + 'close' => __('app.action_close'), + 'create' => __('app.action_create'), + 'update' => __('app.action_update'), + 'delete' => __('app.action_delete'), + ]), + SelectFilter::make('user_id') + ->label(__('app.user')) + ->relationship('user', 'name'), + ]) + ->actions([]) + ->bulkActions([]); + } + + public static function getPages(): array + { + return [ + 'index' => ListActivityLogs::route('/'), + ]; + } + + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery() + ->with('user') + ->orderBy('created_at', 'desc'); + } +} diff --git a/app/Filament/Resources/ActivityLogs/Pages/ListActivityLogs.php b/app/Filament/Resources/ActivityLogs/Pages/ListActivityLogs.php new file mode 100644 index 00000000..ebecade9 --- /dev/null +++ b/app/Filament/Resources/ActivityLogs/Pages/ListActivityLogs.php @@ -0,0 +1,11 @@ +attachments()->where('path', $path)->exists()) { + continue; + } + $feedback->attachments()->create([ 'uuid' => (string) \Illuminate\Support\Str::uuid(), 'user_id' => auth()->id(), @@ -56,9 +60,27 @@ class CreateFeedback extends CreateRecord 'path' => $path, 'disk' => 'public', 'collection' => $this->pendingCollection, - 'mime_type' => $disk->mimeType($path), - 'size' => $disk->size($path), + 'mime_type' => $this->safeMimeType($disk, $path), + 'size' => $this->safeFileSize($disk, $path), ]); } } + + protected function safeMimeType($disk, string $path): ?string + { + try { + return $disk->mimeType($path); + } catch (\Exception $e) { + return null; + } + } + + protected function safeFileSize($disk, string $path): ?int + { + try { + return $disk->size($path); + } catch (\Exception $e) { + return null; + } + } } diff --git a/app/Filament/Resources/Feedback/Pages/EditFeedback.php b/app/Filament/Resources/Feedback/Pages/EditFeedback.php index e2526666..02a0c882 100644 --- a/app/Filament/Resources/Feedback/Pages/EditFeedback.php +++ b/app/Filament/Resources/Feedback/Pages/EditFeedback.php @@ -90,6 +90,10 @@ class EditFeedback extends EditRecord continue; } + if ($feedback->attachments()->where('path', $path)->exists()) { + continue; + } + $feedback->attachments()->create([ 'uuid' => (string) \Illuminate\Support\Str::uuid(), 'user_id' => $sender->id, @@ -97,8 +101,8 @@ class EditFeedback extends EditRecord 'path' => $path, 'disk' => 'public', 'collection' => 'general', - 'mime_type' => $disk->mimeType($path), - 'size' => $disk->size($path), + 'mime_type' => $this->safeMimeType($disk, $path), + 'size' => $this->safeFileSize($disk, $path), ]); } } @@ -194,6 +198,10 @@ class EditFeedback extends EditRecord continue; } + if ($feedback->attachments()->where('path', $path)->exists()) { + continue; + } + $feedback->attachments()->create([ 'uuid' => (string) \Illuminate\Support\Str::uuid(), 'user_id' => auth()->id(), @@ -201,12 +209,30 @@ class EditFeedback extends EditRecord 'path' => $path, 'disk' => 'public', 'collection' => $this->pendingCollection, - 'mime_type' => $disk->mimeType($path), - 'size' => $disk->size($path), + 'mime_type' => $this->safeMimeType($disk, $path), + 'size' => $this->safeFileSize($disk, $path), ]); } } + protected function safeMimeType($disk, string $path): ?string + { + try { + return $disk->mimeType($path); + } catch (\Exception $e) { + return null; + } + } + + protected function safeFileSize($disk, string $path): ?int + { + try { + return $disk->size($path); + } catch (\Exception $e) { + return null; + } + } + protected function mutateFormDataBeforeFill(array $data): array { $data['is_general'] = $data['customer_product_id'] === null; diff --git a/app/Filament/Resources/Feedback/RelationManagers/InteractionsRelationManager.php b/app/Filament/Resources/Feedback/RelationManagers/InteractionsRelationManager.php index 0726824e..eb81d16e 100644 --- a/app/Filament/Resources/Feedback/RelationManagers/InteractionsRelationManager.php +++ b/app/Filament/Resources/Feedback/RelationManagers/InteractionsRelationManager.php @@ -27,7 +27,10 @@ class InteractionsRelationManager extends RelationManager { protected static string $relationship = 'interactions'; - protected static ?string $title = 'feedback.interaction_history'; + public static function getTitle(\Illuminate\Database\Eloquent\Model $ownerRecord, string $pageClass): string + { + return __('feedback.interaction_history'); + } public function form(Schema $schema): Schema { @@ -211,6 +214,10 @@ class InteractionsRelationManager extends RelationManager continue; } + if ($record->attachments()->where('path', $path)->exists()) { + continue; + } + $record->attachments()->create([ 'uuid' => (string) \Illuminate\Support\Str::uuid(), 'feedback_id' => $record->feedback_id, @@ -219,8 +226,8 @@ class InteractionsRelationManager extends RelationManager 'path' => $path, 'disk' => 'public', 'collection' => 'general', - 'mime_type' => $disk->mimeType($path), - 'size' => $disk->size($path), + 'mime_type' => $this->safeMimeType($disk, $path), + 'size' => $this->safeFileSize($disk, $path), ]); } } @@ -262,4 +269,22 @@ class InteractionsRelationManager extends RelationManager throw new \Filament\Exceptions\SilentActionException; } } + + protected function safeMimeType($disk, string $path): ?string + { + try { + return $disk->mimeType($path); + } catch (\Exception $e) { + return null; + } + } + + protected function safeFileSize($disk, string $path): ?int + { + try { + return $disk->size($path); + } catch (\Exception $e) { + return null; + } + } } diff --git a/app/Filament/Resources/Products/RelationManagers/ContractsRelationManager.php b/app/Filament/Resources/Products/RelationManagers/ContractsRelationManager.php index 6fa41466..892011db 100644 --- a/app/Filament/Resources/Products/RelationManagers/ContractsRelationManager.php +++ b/app/Filament/Resources/Products/RelationManagers/ContractsRelationManager.php @@ -5,6 +5,7 @@ namespace App\Filament\Resources\Products\RelationManagers; use App\Enums\ContractStatus; use App\Enums\ContractType; use App\Filament\Resources\Contracts\ContractResource; +use App\Models\Feedback; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filter; @@ -15,7 +16,10 @@ class ContractsRelationManager extends RelationManager { protected static string $relationship = 'contracts'; - protected static ?string $title = 'app.contracts'; + public static function getTitle(\Illuminate\Database\Eloquent\Model $ownerRecord, string $pageClass): string + { + return __('app.contracts'); + } public function table(Table $table): Table { diff --git a/app/Filament/Resources/Products/RelationManagers/HandoversRelationManager.php b/app/Filament/Resources/Products/RelationManagers/HandoversRelationManager.php index 958a6c39..6e8e9370 100644 --- a/app/Filament/Resources/Products/RelationManagers/HandoversRelationManager.php +++ b/app/Filament/Resources/Products/RelationManagers/HandoversRelationManager.php @@ -12,7 +12,10 @@ class HandoversRelationManager extends RelationManager { protected static string $relationship = 'handovers'; - protected static ?string $title = 'app.handovers'; + public static function getTitle(\Illuminate\Database\Eloquent\Model $ownerRecord, string $pageClass): string + { + return __('app.handovers'); + } public function table(Table $table): Table { diff --git a/app/Filament/Resources/Products/RelationManagers/ProductServicesRelationManager.php b/app/Filament/Resources/Products/RelationManagers/ProductServicesRelationManager.php index 7e095cbf..708db41a 100644 --- a/app/Filament/Resources/Products/RelationManagers/ProductServicesRelationManager.php +++ b/app/Filament/Resources/Products/RelationManagers/ProductServicesRelationManager.php @@ -3,7 +3,14 @@ namespace App\Filament\Resources\Products\RelationManagers; use App\Enums\ServiceType; +use Filament\Actions\CreateAction; +use Filament\Actions\DeleteAction; +use Filament\Actions\EditAction; +use Filament\Forms\Components\DatePicker; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\Textarea; use Filament\Resources\RelationManagers\RelationManager; +use Filament\Schemas\Schema; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; @@ -12,7 +19,39 @@ class ProductServicesRelationManager extends RelationManager { protected static string $relationship = 'productServices'; - protected static ?string $title = 'app.services'; + public static function getTitle(\Illuminate\Database\Eloquent\Model $ownerRecord, string $pageClass): string + { + return __('app.services'); + } + + public function form(Schema $schema): Schema + { + return $schema + ->components([ + Select::make('service_type') + ->label(__('enums.service_type')) + ->options(ServiceType::options()) + ->required(), + + Select::make('status') + ->label(__('enums.status')) + ->options([ + 'active' => __('app.status_active'), + 'pending' => __('app.status_pending'), + 'completed' => __('app.status_completed'), + 'cancelled' => __('app.status_cancelled'), + ]) + ->default('active') + ->required(), + + DatePicker::make('actual_collection_date') + ->label(__('app.collection_date')), + + Textarea::make('remark') + ->label(__('app.remark')) + ->rows(3), + ]); + } public function table(Table $table): Table { @@ -20,15 +59,12 @@ class ProductServicesRelationManager extends RelationManager ->recordTitleAttribute('service_type') ->columns([ TextColumn::make('service_type') + ->label(__('enums.service_type')) ->badge() - ->color(fn (string $state): string => match ($state) { - 'management_fee' => 'info', - 'gratitude' => 'success', - 'self_business' => 'warning', - default => 'gray', - }) + ->color(fn (string $state): string => ServiceType::from($state)->color()) ->formatStateUsing(fn (string $state): string => ServiceType::from($state)->label()), TextColumn::make('status') + ->label(__('enums.status')) ->badge() ->color(fn (string $state): string => match ($state) { 'active' => 'success', @@ -52,8 +88,10 @@ class ProductServicesRelationManager extends RelationManager ]) ->filters([ SelectFilter::make('service_type') - ->options(ServiceType::MANAGEMENT_FEE->options()), + ->label(__('enums.service_type')) + ->options(ServiceType::options()), SelectFilter::make('status') + ->label(__('enums.status')) ->options([ 'active' => __('app.status_active'), 'pending' => __('app.status_pending'), @@ -62,7 +100,12 @@ class ProductServicesRelationManager extends RelationManager ]), ]) ->defaultSort('created_at', 'desc') - ->headerActions([]) - ->recordActions([]); + ->headerActions([ + CreateAction::make(), + ]) + ->recordActions([ + EditAction::make(), + DeleteAction::make(), + ]); } } diff --git a/app/Models/ActivityLog.php b/app/Models/ActivityLog.php new file mode 100644 index 00000000..e923a1d7 --- /dev/null +++ b/app/Models/ActivityLog.php @@ -0,0 +1,32 @@ + 'array', + 'created_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function subject(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/app/Policies/ActivityLogPolicy.php b/app/Policies/ActivityLogPolicy.php new file mode 100644 index 00000000..8aa05f28 --- /dev/null +++ b/app/Policies/ActivityLogPolicy.php @@ -0,0 +1,34 @@ +hasPermissionTo('view-activity-logs'); + } + + public function view(User $user, ActivityLog $activityLog): bool + { + return $user->hasPermissionTo('view-activity-logs'); + } + + public function create(User $user): bool + { + return false; + } + + public function update(User $user, ActivityLog $activityLog): bool + { + return false; + } + + public function delete(User $user, ActivityLog $activityLog): bool + { + return false; + } +} diff --git a/app/Services/ActivityLogger.php b/app/Services/ActivityLogger.php new file mode 100644 index 00000000..32ef1505 --- /dev/null +++ b/app/Services/ActivityLogger.php @@ -0,0 +1,27 @@ + $userId ?? auth()->id(), + 'action' => $action, + 'description' => $description, + 'subject_type' => $subject ? get_class($subject) : null, + 'subject_id' => $subject?->id, + 'metadata' => $metadata, + 'created_at' => now(), + ]); + } +} diff --git a/app/Services/ClosingService.php b/app/Services/ClosingService.php index 46bb78e8..7e1ba335 100644 --- a/app/Services/ClosingService.php +++ b/app/Services/ClosingService.php @@ -38,8 +38,20 @@ class ClosingService ]); } + $previousStatus = $feedback->status; + $feedback->update(['status' => 'closed']); + ActivityLogger::log( + 'close', + __('feedback.log_close', ['title' => $feedback->title]), + $feedback, + [ + 'feedback_id' => $feedback->id, + 'previous_status' => $previousStatus, + ], + ); + $this->notifyStakeholders($feedback, $actor); } diff --git a/app/Services/FileService.php b/app/Services/FileService.php index 95e53830..a7756236 100644 --- a/app/Services/FileService.php +++ b/app/Services/FileService.php @@ -22,7 +22,7 @@ class FileService 'video/mov', ]; - protected int $maxSize = 20480; + protected int $maxSize = 51200; protected string $defaultDisk = 'public'; diff --git a/app/Services/TransferService.php b/app/Services/TransferService.php index add0b215..6fb12c50 100644 --- a/app/Services/TransferService.php +++ b/app/Services/TransferService.php @@ -6,6 +6,7 @@ use App\Models\Department; use App\Models\Feedback; use App\Models\TicketTransferLog; use App\Models\User; +use App\Services\ActivityLogger; use Illuminate\Validation\ValidationException; class TransferService @@ -24,6 +25,7 @@ class TransferService } $fromDepartmentId = $feedback->current_department_id; + $fromDepartmentName = $feedback->currentDepartment?->name ?? 'N/A'; $log = TicketTransferLog::create([ 'feedback_id' => $feedback->id, @@ -40,6 +42,22 @@ class TransferService $this->notifyDepartmentManager($toDepartment, $feedback, $log, $sender); + ActivityLogger::log( + 'transfer', + __('feedback.log_transfer', [ + 'title' => $feedback->title, + 'from' => $fromDepartmentName, + 'to' => $toDepartment->name, + ]), + $feedback, + [ + 'feedback_id' => $feedback->id, + 'from_department' => $fromDepartmentName, + 'to_department' => $toDepartment->name, + 'reason' => $reason, + ], + ); + return $log; } diff --git a/database/migrations/2026_05_09_104519_create_activity_logs_table.php b/database/migrations/2026_05_09_104519_create_activity_logs_table.php new file mode 100644 index 00000000..7dc3442e --- /dev/null +++ b/database/migrations/2026_05_09_104519_create_activity_logs_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('action'); + $table->text('description'); + $table->nullableMorphs('subject'); + $table->json('metadata')->nullable(); + $table->timestamp('created_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('activity_logs'); + } +}; diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php index d0f85e12..d61bf254 100644 --- a/database/seeders/PermissionSeeder.php +++ b/database/seeders/PermissionSeeder.php @@ -56,6 +56,9 @@ class PermissionSeeder extends Seeder // Dashboard & Export 'view-dashboard-widgets', 'export-data', + + // Activity Logs + 'view-activity-logs', ]; foreach ($permissions as $permission) { @@ -92,6 +95,7 @@ class PermissionSeeder extends Seeder 'create-tag', 'update-tag', 'view-dashboard-widgets', + 'view-activity-logs', ]); // Staff: read + limited write diff --git a/docs/ENUM_GUIDE.md b/docs/ENUM_GUIDE.md new file mode 100644 index 00000000..2f624371 --- /dev/null +++ b/docs/ENUM_GUIDE.md @@ -0,0 +1,233 @@ +# Hướng dẫn thêm Enum mới + +## Tổng quan + +Hệ thống sử dụng PHP Enums với i18n support. Khi cần thêm giá trị enum mới (Service Type, Contract Type, v.v.), chỉ cần cập nhật 3 chỗ. + +## Cấu trúc Enum hiện có + +``` +app/Enums/ +├── ContractStatus.php # Trạng thái hợp đồng +├── ContractType.php # Loại hợp đồng +├── HandoverStatus.php # Trạng thái bàn giao +└── ServiceType.php # Loại dịch vụ +``` + +## Ví dụ: Thêm Service Type mới + +Giả sử cần thêm loại dịch vụ "Bảo trì định kỳ" (maintenance). + +### Bước 1: Thêm case vào Enum + +**File:** `app/Enums/ServiceType.php` + +```php +enum ServiceType: string +{ + case MANAGEMENT_FEE = 'management_fee'; + case GRATITUDE = 'gratitude'; + case SELF_BUSINESS = 'self_business'; + case MAINTENANCE = 'maintenance'; // ← THÊM MỚI + + public function label(): string + { + return match ($this) { + self::MANAGEMENT_FEE => __('enums.service.management_fee'), + self::GRATITUDE => __('enums.service.gratitude'), + self::SELF_BUSINESS => __('enums.service.self_business'), + self::MAINTENANCE => __('enums.service.maintenance'), // ← THÊM MỚI + }; + } + + public function color(): string + { + return match ($this) { + self::MANAGEMENT_FEE => 'info', + self::GRATITUDE => 'success', + self::SELF_BUSINESS => 'warning', + self::MAINTENANCE => 'primary', // ← THÊM MỚI + }; + } + + // options() tự động lấy tất cả cases - KHÔNG cần sửa +} +``` + +### Bước 2: Thêm translation tiếng Việt + +**File:** `lang/vi/enums.php` + +```php +return [ + // ... existing keys ... + + 'service' => [ + 'management_fee' => 'Phí quản lý', + 'gratitude' => 'Tri ân', + 'self_business' => 'Tự doanh', + 'maintenance' => 'Bảo trì định kỳ', // ← THÊM MỚI + ], + + // ... existing keys ... +]; +``` + +### Bước 3: Thêm translation tiếng Anh + +**File:** `lang/en/enums.php` + +```php +return [ + // ... existing keys ... + + 'service' => [ + 'management_fee' => 'Management fee', + 'gratitude' => 'Gratitude', + 'self_business' => 'Self business', + 'maintenance' => 'Periodic maintenance', // ← THÊM MỚI + ], + + // ... existing keys ... +]; +``` + +### Bước 4: Reload browser + +Không cần chạy lệnh nào, chỉ cần reload trang. + +## Ví dụ: Thêm Contract Type mới + +### Bước 1: Thêm case + +**File:** `app/Enums/ContractType.php` + +```php +enum ContractType: string +{ + case SALE = 'sale'; + case LEASE = 'lease'; + case BCC = 'bcc'; + case CONSORTIUM = 'consortium'; // ← THÊM MỚI + + public function label(): string + { + return match ($this) { + self::SALE => __('enums.contract_type.sale'), + self::LEASE => __('enums.contract_type.lease'), + self::BCC => __('enums.contract_type.bcc'), + self::CONSORTIUM => __('enums.contract_type.consortium'), // ← THÊM MỚI + }; + } + + public function color(): string + { + return match ($this) { + self::SALE => 'success', + self::LEASE => 'info', + self::BCC => 'warning', + self::CONSORTIUM => 'primary', // ← THÊM MỚI + }; + } +} +``` + +### Bước 2-3: Thêm translations + +```php +// lang/vi/enums.php +'contract_type' => [ + 'sale' => 'HĐ Mua bán', + 'lease' => 'HĐ Thuê', + 'bcc' => 'HĐ Hợp tác kinh doanh (BCC)', + 'consortium' => 'HĐ Liên doanh', // ← THÊM MỚI +], + +// lang/en/enums.php +'contract_type' => [ + 'sale' => 'Sale contract', + 'lease' => 'Lease contract', + 'bcc' => 'Business cooperation contract (BCC)', + 'consortium' => 'Consortium contract', // ← THÊM MỚI +], +``` + +## Template Enum (Copy-paste) + +Khi tạo enum mới, copy template sau: + +```php + __('enums.[enum_name].[value_1]'), + self::[CASE_2] => __('enums.[enum_name].[value_2]'), + }; + } + + public function color(): string + { + return match ($this) { + self::[CASE_1] => 'success', + self::[CASE_2] => 'info', + }; + } + + public static function options(): array + { + return collect(self::cases())->mapWithKeys(fn ($case) => [ + $case->value => $case->label(), + ])->toArray(); + } +} +``` + +## Cách sử dụng Enum trong code + +### Trong Filament Form +```php +Select::make('service_type') + ->label(__('enums.service_type')) + ->options(ServiceType::options()) + ->required(), +``` + +### Trong Filament Table +```php +TextColumn::make('service_type') + ->badge() + ->color(fn (string $state): string => ServiceType::from($state)->color()) + ->formatStateUsing(fn (string $state): string => ServiceType::from($state)->label()), +``` + +### Trong PHP code +```php +$type = ServiceType::MANAGEMENT_FEE; +echo $type->label(); // "Phí quản lý" +echo $type->value; // "management_fee" +echo $type->color(); // "info" +``` + +## Lưu ý + +1. **`options()` method** - PHẢI là `static` (không phải instance method) +2. **Translation keys** - Phải có trong CẢ `lang/vi/enums.php` VÀ `lang/en/enums.php` +3. **Color values** - Sử dụng Filament colors: `primary`, `success`, `warning`, `danger`, `info`, `gray` +4. **Case values** - Nên dùng snake_case (ví dụ: `management_fee`, không phải `ManagementFee`) diff --git a/docs/I18N_GUIDE.md b/docs/I18N_GUIDE.md new file mode 100644 index 00000000..b28f56fb --- /dev/null +++ b/docs/I18N_GUIDE.md @@ -0,0 +1,162 @@ +# Hướng dẫn Đa ngôn ngữ (i18n) + +## Tổng quan + +Hệ thống hỗ trợ 2 ngôn ngữ: +- **Tiếng Việt (vi)** - Ngôn ngữ chính +- **Tiếng Anh (en)** - Ngôn ngữ dự phòng + +Cấu hình trong `.env`: +``` +APP_LOCALE=vi +APP_FALLBACK_LOCALE=en +``` + +## Cấu trúc thư mục + +``` +lang/ +├── en/ +│ ├── app.php # UI chung: navigation, labels, status, widgets +│ ├── feedback.php # Domain feedback: services, notifications, interactions +│ └── enums.php # Labels cho enums +└── vi/ + ├── app.php + ├── feedback.php + └── enums.php +``` + +## Quy tắc đặt tên Translation Keys + +### 1. Resource Labels +```php +// Pattern: app.resource_{name} +'resource_feedbacks' => 'Phản ánh', +'resource_products' => 'Sản phẩm', +'resource_customers' => 'Khách hàng', +``` + +### 2. Status Labels +```php +// Pattern: app.status_{name} +'status_pending' => 'Chờ xử lý', +'status_processing' => 'Đang xử lý', +'status_resolved' => 'Đã xử lý', +'status_closed' => 'Đã đóng', +``` + +### 3. Form Labels +```php +// Pattern: app.{field_name} +'name' => 'Tên', +'email' => 'Email', +'phone' => 'Điện thoại', +``` + +### 4. Enum Labels +```php +// Pattern: enums.{enum_name}.{case_value} +'service.management_fee' => 'Phí quản lý', +'contract_status.active' => 'Đang hiệu lực', +``` + +### 5. Feedback Domain +```php +// Pattern: feedback.{action}_{context} +'already_closed' => 'Phiếu này đã được đóng.', +'close_permission_denied' => 'Chỉ lãnh đạo cấp phòng mới có quyền đóng phiếu.', +``` + +## Cách sử dụng trong code + +### Resource Labels +```php +// Phải override getPluralLabel() - KHÔNG tự dịch +public static function getPluralLabel(): ?string +{ + return __('app.resource_feedbacks'); +} + +// Phải override getNavigationGroup() - KHÔNG tự dịch +public static function getNavigationGroup(): ?string +{ + return __('app.nav_customer_care'); +} +``` + +### Form Fields +```php +// PHẢI thêm ->label() explicit +TextInput::make('name')->label(__('app.name')) +Select::make('status')->label(__('app.status')) +``` + +### RelationManager Title +```php +// Phải override getTitle() - KHÔNG dùng $title property +public static function getTitle(Model $ownerRecord, string $pageClass): string +{ + return __('app.services'); +} +``` + +### Widget Heading/Description +```php +// Phải override getHeading()/getDescription() - KHÔNG dùng property +public function getHeading(): string | Htmlable | null +{ + return __('app.widget_avg_time_by_handler'); +} +``` + +### Enum Labels +```php +public function label(): string +{ + return match ($this) { + self::ACTIVE => __('enums.contract_status.active'), + }; +} +``` + +## Cách thêm Translation Key mới + +### Bước 1: Thêm vào `lang/vi/app.php` +```php +return [ + // ... existing keys ... + 'new_key' => 'Giá trị tiếng Việt', +]; +``` + +### Bước 2: Thêm vào `lang/en/app.php` +```php +return [ + // ... existing keys ... + 'new_key' => 'English value', +]; +``` + +### Bước 3: Sử dụng trong code +```php +echo __('app.new_key'); +``` + +## Lưu ý quan trọng + +1. **`$pluralLabel` property** - KHÔNG tự dịch, phải override `getPluralLabel()` +2. **`getNavigationGroup()`** - KHÔNG tự dịch, phải gọi `__()` +3. **`$heading`/`$description`** trong ChartWidget - KHÔNG tự dịch, phải override methods +4. **Form fields** - PHẢI thêm `->label()` explicit +5. **`$title`** trong RelationManager - KHÔNG tự dịch, phải override `getTitle()` +6. **Filament vendor** - Có sẵn 57 file dịch tiếng Việt, tự kích hoạt khi `APP_LOCALE=vi` + +## Kiểm tra Translation + +```bash +# Kiểm tra key có tồn tại không +php artisan tinker --execute="echo __('app.new_key');" + +# Kiểm tra locale hiện tại +php artisan tinker --execute="echo app()->getLocale();" +``` diff --git a/lang/en/app.php b/lang/en/app.php index d1953109..cbb41bbf 100644 --- a/lang/en/app.php +++ b/lang/en/app.php @@ -14,6 +14,7 @@ return [ 'resource_tags' => 'Tags', 'resource_roles' => 'Roles', 'resource_users' => 'Users', + 'resource_activity_logs' => 'Activity Logs', // Status 'status_pending' => 'Pending', @@ -89,6 +90,7 @@ return [ 'signed_status' => 'Signed Status', 'contract_info' => 'Contract Information', 'feedback_history' => 'Feedback History', + 'service_type' => 'Service Type', 'tab_all' => 'All', 'min' => 'min', 'na' => 'N/A', @@ -130,4 +132,16 @@ return [ 'blade_file_count' => ':count file(s)', 'blade_proof' => 'Proof', 'blade_evidence' => 'Evidence', + + // Activity Logs + 'system' => 'System', + 'time' => 'Time', + 'action' => 'Action', + 'action_import' => 'Import', + 'action_export' => 'Export', + 'action_transfer' => 'Transfer', + 'action_close' => 'Close', + 'action_create' => 'Create', + 'action_update' => 'Update', + 'action_delete' => 'Delete', ]; diff --git a/lang/en/enums.php b/lang/en/enums.php index a9222934..4d1f6078 100644 --- a/lang/en/enums.php +++ b/lang/en/enums.php @@ -1,6 +1,16 @@ [...]) + * 2. Add corresponding translation in lang/vi/enums.php + * 3. Reload browser + */ + return [ + // Handover Status 'handover' => [ 'not_ready' => 'Not ready', 'ready' => 'Ready', @@ -8,21 +18,30 @@ return [ 'scheduled' => 'Scheduled', ], + // Service Type 'service' => [ 'management_fee' => 'Management fee', 'gratitude' => 'Gratitude', 'self_business' => 'Self business', ], + // Contract Status 'contract_status' => [ 'active' => 'Active', 'expired' => 'Expired', 'liquidated' => 'Liquidated', ], + // Contract Type 'contract_type' => [ 'sale' => 'Sale contract', 'lease' => 'Lease contract', 'bcc' => 'Business cooperation contract (BCC)', ], + + // Labels for Select filters + 'service_type' => 'Service Type', + 'status' => 'Status', + 'handover_status' => 'Handover Status', + 'contract_type_label' => 'Contract Type', ]; diff --git a/lang/en/feedback.php b/lang/en/feedback.php index 149ecebb..ba1df163 100644 --- a/lang/en/feedback.php +++ b/lang/en/feedback.php @@ -54,4 +54,10 @@ return [ // CreateFeedback 'created_via' => 'Feedback created via :channel', 'unknown_channel' => 'unknown channel', + + // Activity Log + 'log_import_done' => 'Import completed: :file - :rows rows', + 'log_export_done' => 'Exported :tables tables, :rows rows', + 'log_transfer' => ':title transferred from :from to :to', + 'log_close' => 'Ticket closed: :title', ]; diff --git a/lang/vi/app.php b/lang/vi/app.php index 29d5390a..ecd09895 100644 --- a/lang/vi/app.php +++ b/lang/vi/app.php @@ -14,6 +14,7 @@ return [ 'resource_tags' => 'Thẻ', 'resource_roles' => 'Vai trò', 'resource_users' => 'Người dùng', + 'resource_activity_logs' => 'Nhật ký hoạt động', // Status 'status_pending' => 'Chờ xử lý', @@ -89,6 +90,7 @@ return [ 'signed_status' => 'Tình trạng ký', 'contract_info' => 'Thông tin hợp đồng', 'feedback_history' => 'Lịch sử phản ánh', + 'service_type' => 'Loại dịch vụ', 'tab_all' => 'Tất cả', 'min' => 'phút', 'na' => 'N/A', @@ -130,4 +132,16 @@ return [ 'blade_file_count' => ':count tệp', 'blade_proof' => 'Bằng chứng', 'blade_evidence' => 'Chứng cứ', + + // Activity Logs + 'system' => 'Hệ thống', + 'time' => 'Thời gian', + 'action' => 'Hành động', + 'action_import' => 'Nhập dữ liệu', + 'action_export' => 'Xuất dữ liệu', + 'action_transfer' => 'Chuyển phòng ban', + 'action_close' => 'Đóng ticket', + 'action_create' => 'Tạo mới', + 'action_update' => 'Cập nhật', + 'action_delete' => 'Xóa', ]; diff --git a/lang/vi/enums.php b/lang/vi/enums.php index 6bd35b50..4ee6c840 100644 --- a/lang/vi/enums.php +++ b/lang/vi/enums.php @@ -1,6 +1,16 @@ [...]) + * 2. Thêm bản dịch tương ứng trong lang/en/enums.php + * 3. Reload browser + */ + return [ + // Handover Status 'handover' => [ 'not_ready' => 'Chưa đủ điều kiện', 'ready' => 'Đủ điều kiện', @@ -8,21 +18,30 @@ return [ 'scheduled' => 'Đã lên lịch', ], + // Service Type 'service' => [ 'management_fee' => 'Phí quản lý', 'gratitude' => 'Tri ân', 'self_business' => 'Tự doanh', ], + // Contract Status 'contract_status' => [ 'active' => 'Đang hiệu lực', 'expired' => 'Hết hạn', 'liquidated' => 'Đã thanh lý', ], + // Contract Type 'contract_type' => [ 'sale' => 'HĐ Mua bán', 'lease' => 'HĐ Thuê', 'bcc' => 'HĐ Hợp tác kinh doanh (BCC)', ], + + // Labels cho Select filters + 'service_type' => 'Loại dịch vụ', + 'status' => 'Trạng thái', + 'handover_status' => 'Trạng thái bàn giao', + 'contract_type_label' => 'Loại hợp đồng', ]; diff --git a/lang/vi/feedback.php b/lang/vi/feedback.php index 979ddc5a..8ad13a38 100644 --- a/lang/vi/feedback.php +++ b/lang/vi/feedback.php @@ -54,4 +54,10 @@ return [ // CreateFeedback 'created_via' => 'Phản ánh được tạo qua :channel', 'unknown_channel' => 'kênh không xác định', + + // Activity Log + 'log_import_done' => 'Nhập xong :file - :rows dòng', + 'log_export_done' => 'Xuất :tables bảng, :rows dòng', + 'log_transfer' => ':title: chuyển từ :from sang :to', + 'log_close' => 'Đóng ticket: :title', ];