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',
];