diff --git a/AGENTS.md b/AGENTS.md
index 5c4bf0ec..f19de2c0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -68,11 +68,19 @@ minicrm/
│ │ │ ├── Customers/
│ │ │ │ └── RelationManagers/FeedbacksRelationManager.php
│ │ │ ├── FeedbackChannels/
-│ │ │ └── FeedbackTags/
+│ │ │ ├── FeedbackTags/
+│ │ │ ├── Contracts/
+│ │ │ ├── Users/
+│ │ │ └── Roles/
│ │ └── Widgets/
│ │ ├── ResolvedTicketsWidget.php # Dashboard: resolved tickets table
│ │ ├── AvgProcessingTimeWidget.php # Dashboard: stats overview
│ │ └── HandlerPerformanceWidget.php # Dashboard: bar chart per handler
+│ ├── Enums/
+│ │ ├── ContractType.php
+│ │ ├── ContractStatus.php
+│ │ ├── HandoverStatus.php
+│ │ └── ServiceType.php
│ ├── Models/
│ │ ├── User.php # HasRoles (Spatie), isAdmin(), isManager()
│ │ ├── Department.php # NEW: name, manager_id → User
@@ -101,6 +109,15 @@ minicrm/
│ │ └── TicketClosed.php # DB + Mail for ticket closure
│ └── Providers/Filament/
│ └── AdminPanelProvider.php
+├── lang/ # i18n translation files
+│ ├── en/
+│ │ ├── app.php # General UI labels, navigation, status, widgets
+│ │ ├── feedback.php # Feedback domain: services, notifications, interactions
+│ │ └── enums.php # Enum labels
+│ └── vi/
+│ ├── app.php
+│ ├── feedback.php
+│ └── enums.php
├── resources/views/filament/resources/feedback/
│ ├── similar-cases.blade.php
│ └── attachment-links.blade.php # Modal listing attachments with signed download URLs
@@ -217,19 +234,84 @@ feedback_interactions, feedback_tags, feedback_feedback_tag, products, customers
- AvgProcessingTimeWidget: 4 stats (resolved count, avg time, pending count, escalated count)
- HandlerPerformanceWidget: bar chart of avg processing time per handler
+## Internationalization (i18n)
+
+### Configuration
+- Default locale: `vi` (Vietnamese), fallback: `en` (English)
+- Set in `.env`: `APP_LOCALE=vi`, `APP_FALLBACK_LOCALE=en`
+- Filament uses `app()->getLocale()` — no extra panel config needed
+
+### Translation Files
+```
+lang/
+├── en/
+│ ├── app.php # General UI: navigation, labels, status, widgets, blade strings
+│ ├── feedback.php # Feedback domain: services, notifications, interactions
+│ └── enums.php # Enum labels (HandoverStatus, ServiceType, ContractStatus, ContractType)
+└── vi/
+ ├── app.php
+ ├── feedback.php
+ └── enums.php
+```
+
+### Translation Patterns
+```php
+// Resource labels — MUST override getPluralLabel() (not auto-translated)
+public static function getPluralLabel(): ?string
+{
+ return __('app.resource_feedbacks');
+}
+
+// Navigation group — MUST override getNavigationGroup() (not auto-translated)
+public static function getNavigationGroup(): ?string
+{
+ return __('app.nav_customer_care');
+}
+
+// Form fields — MUST add explicit ->label()
+TextInput::make('name')->label(__('app.name'))
+
+// Status options
+'options' => [
+ 'pending' => __('app.status_pending'),
+ 'processing' => __('app.status_processing'),
+]
+
+// Enum labels
+public function label(): string
+{
+ return match ($this) {
+ self::ACTIVE => __('enums.contract_status.active'),
+ };
+}
+
+// Widget heading/description — override getHeading()/getDescription()
+public function getHeading(): string | Htmlable | null
+{
+ return __('app.widget_avg_time_by_handler');
+}
+```
+
+### Key Gotchas
+- `$pluralLabel` property does NOT auto-translate — must override `getPluralLabel()`
+- `getNavigationGroup()` does NOT auto-translate — must call `__()` explicitly
+- `$heading`/`$description` in ChartWidget are raw properties — must override `getHeading()`/`getDescription()`
+- Form fields without `->label()` use Filament's `filament-panels::` namespace (may not resolve correctly)
+- Always add `->label(__('key'))` to TextInput, Select, Textarea, etc.
+
## Navigation Structure
```
Dashboard
-├── Customer Care (group)
-│ └── Feedbacks (chat-bubble-left-right, sort=1)
-└── Management (group)
- ├── Products (building-storefront, sort=1)
- ├── Customers (user-group, sort=2)
- ├── Channels (inbox-stack, sort=3)
- ├── Tags (tag, sort=default)
- ├── Users (users, sort=5) — admin only
- └── Roles (shield-check, sort=6) — admin only
+├── Customer Care / Chăm sóc khách hàng (group)
+│ └── Feedbacks / Phản ánh (chat-bubble-left-right, sort=1)
+└── Management / Quản lý (group)
+ ├── Products / Sản phẩm (building-storefront, sort=1)
+ ├── Customers / Khách hàng (user-group, sort=2)
+ ├── Channels / Kênh phản ánh (inbox-stack, sort=3)
+ ├── Tags / Thẻ (tag, sort=default)
+ ├── Users / Người dùng (users, sort=5) — admin only
+ └── Roles / Vai trò (shield-check, sort=6) — admin only
```
Dashboard widgets: ResolvedTicketsWidget, AvgProcessingTimeWidget, HandlerPerformanceWidget (admin/manager only)
@@ -337,3 +419,8 @@ $user->assignRole('manager');
8. **ChartWidget properties**: `$heading` and `$columnSpan` are non-static in Filament's ChartWidget.
9. **Manager closing permission**: Manager must be department head (`Department.manager_id`) to close tickets in that department.
10. **Docker**: Entrypoint auto-generates APP_KEY and runs migrations. SQLite persists via volume mount.
+11. **i18n - pluralLabel**: `$pluralLabel` property does NOT auto-translate — must override `getPluralLabel()` and call `__()`.
+12. **i18n - navigationGroup**: `getNavigationGroup()` does NOT auto-translate — must return `__('key')` explicitly.
+13. **i18n - ChartWidget heading**: `$heading`/`$description` are raw properties — must override `getHeading()`/`getDescription()`.
+14. **i18n - form labels**: Form fields without `->label()` use Filament's `filament-panels::` namespace — always add explicit `->label(__('key'))`.
+15. **i18n - Filament vendor**: Filament ships 57 Vietnamese translation files — auto-activated when `APP_LOCALE=vi`.
diff --git a/PROGRESS.md b/PROGRESS.md
index 769825db..320ebbb5 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -137,6 +137,21 @@ Tham chiếu: `SOP_des.md` — Bản đặc tả hệ thống After-Sale CRM
- [x] UserPolicy + RolePolicy (admin-only)
- [x] Tests updated: 8 tests, 21 assertions pass
+### Internationalization (i18n) — Vietnamese + English
+- [x] Config: `APP_LOCALE=vi`, `APP_FALLBACK_LOCALE=en` in `.env`
+- [x] Created `lang/en/` and `lang/vi/` with 3 files each: `app.php`, `feedback.php`, `enums.php`
+- [x] ~120 translation keys covering: navigation, labels, status, widgets, services, notifications, enums
+- [x] All Resources: override `getPluralLabel()` + `getNavigationGroup()` with `__()` calls
+- [x] All Form fields: explicit `->label(__('key'))` on TextInput, Select, Textarea, etc.
+- [x] All Table columns: explicit `->label(__('key'))` on TextColumn, IconColumn
+- [x] All RelationManagers: `$title` property uses translation key
+- [x] All Widgets: `getHeading()`/`getDescription()` methods return translated strings
+- [x] Enums: `ContractType`, `ContractStatus`, `HandoverStatus`, `ServiceType` use `__()` in `label()`
+- [x] Services/Notifications: all user-facing strings use translation keys
+- [x] Blade views: all hardcoded strings replaced with `__()` calls
+- [x] Filament vendor translations (57 Vietnamese files) auto-activated for UI chrome
+- [x] Tests: 8/8 pass (21 assertions)
+
---
## Database Schema (final — 23 migrations)
diff --git a/SYSTEM_ASSESSMENT_PROPOSALS.md b/SYSTEM_ASSESSMENT_PROPOSALS.md
new file mode 100644
index 00000000..06660328
--- /dev/null
+++ b/SYSTEM_ASSESSMENT_PROPOSALS.md
@@ -0,0 +1,56 @@
+# AfterSales CRM - System Assessment & Proposals (Draft)
+
+> **Ngày ghi nhận:** Monday, May 4, 2026
+> **Người thực hiện:** Gemini CLI Agent
+> **Mục đích:** Ghi lại các nhận định về hệ thống, rủi ro bảo mật và đề xuất nâng cấp để thảo luận sau.
+
+---
+
+## 1. Nhận định Hệ thống (Current Understanding)
+- **Kiến trúc:** Laravel 13.6 + Filament 5.6, PHP 8.3. Sử dụng Spatie Permissions cho RBAC.
+- **Dữ liệu:** Đã import ~3600 records từ Excel thành công. Sử dụng SQLite làm database.
+- **Workflow:** Quy trình Feedback -> Transfer -> Resolve -> Close đã được thiết lập chặt chẽ với các Service chuyên biệt (`ClosingService`, `TransferService`).
+- **Tài liệu:** Đã có `AGENTS.md`, `PROGRESS.md`, `TASKS_ROADMAP.md` và `CODEBASE_SNAPSHOT.md` giúp nắm bắt nhanh trạng thái dự án.
+
+## 2. Rủi ro Bảo mật & An toàn (Security & Safety Risks)
+
+### 2.1. Phạm vi truy cập dữ liệu (Data Visibility)
+- **Vấn đề:** `FeedbackPolicy` hiện tại cho phép mọi User có quyền `view-feedback` xem được toàn bộ các phản hồi của tất cả phòng ban.
+- **Rủi ro:** Lộ thông tin nhạy cảm giữa các phòng ban.
+- **Đề xuất:** Áp dụng Global Scope hoặc Scoping trong Resource để nhân viên chỉ thấy phiếu thuộc phòng ban mình hoặc được giao cho mình.
+
+### 2.2. Bảo mật Backup
+- **Vấn đề:** Command `app:export-backup` lưu file JSON (bao gồm hash mật khẩu và thông tin khách hàng) trực tiếp trong `storage/app/backups`.
+- **Rủi ro:** Dễ bị đánh cắp nếu folder này bị lộ hoặc server bị chiếm quyền user.
+- **Đề xuất:** Mã hóa file backup hoặc đẩy thẳng lên S3 với chính sách bảo mật nghiêm ngặt.
+
+### 2.3. Signed URL trên Local Disk
+- **Vấn đề:** `FileService` dùng `temporaryUrl` trên driver `local`.
+- **Rủi ro:** Laravel mặc định không hỗ trợ URL tạm thời cho local driver trừ khi được cấu hình thêm. Có thể gây lỗi không xem được ảnh/file trong thực tế.
+
+### 2.4. Tính nhất quán của Logic Đóng phiếu
+- **Vấn đề:** Logic kiểm tra `proof_of_resolution` đang nằm rải rác ở Service và một số chỗ trong Filament UI.
+- **Rủi ro:** Khó bảo trì và dễ xảy ra sai sót khi cập nhật quy trình.
+
+## 3. Đề xuất Nâng cấp (Proposed Upgrades)
+
+### 3.1. Audit Trail (Lịch sử chi tiết)
+- **Mô tả:** Ghi lại chi tiết ai đã sửa cái gì, từ giá trị cũ sang giá trị mới cho tất cả các Model quan trọng (Contract, Feedback, Customer).
+- **Công cụ gợi ý:** `spatie/laravel-activitylog`.
+
+### 3.2. Mã hóa dữ liệu (PII Encryption)
+- **Mô tả:** Mã hóa các trường thông tin cá nhân (Số điện thoại, Email) trong Database để đảm bảo an toàn nếu database bị leak.
+
+### 3.3. Theo dõi SLA (SLA Tracking)
+- **Mô tả:** Thiết lập thời hạn xử lý cho từng loại phản hồi. Cảnh báo (Email/Notification) khi sắp đến hạn hoặc quá hạn.
+
+### 3.4. Gợi ý chuyển phòng thông minh (Smart Suggestion)
+- **Mô tả:** Tự động phân tích loại Hợp đồng hoặc Tag để gợi ý "Nên chuyển cho phòng Pháp lý/Kế toán".
+
+### 3.5. Cải tiến Bulk Actions
+- **Mô tả:** Cho phép xử lý hàng loạt (Chuyển phòng, Phân công nhân viên) để tối ưu năng suất cho Manager.
+
+## 4. Các câu hỏi thảo luận sau
+1. Quy tắc phân quyền xem chéo giữa các phòng ban cụ thể là gì?
+2. Hệ thống sẽ chạy Production trên SQLite hay chuyển sang MySQL/PostgreSQL?
+3. Có cần tích hợp Chat trực tuyến (Zalo/Messenger) vào hệ thống không?
diff --git a/app/Enums/ContractStatus.php b/app/Enums/ContractStatus.php
index 2e22f886..b1676460 100644
--- a/app/Enums/ContractStatus.php
+++ b/app/Enums/ContractStatus.php
@@ -11,9 +11,9 @@ enum ContractStatus: string
public function label(): string
{
return match ($this) {
- self::ACTIVE => 'Đang hiệu lực',
- self::EXPIRED => 'Hết hạn',
- self::LIQUIDATED => 'Đã thanh lý',
+ self::ACTIVE => __('enums.contract_status.active'),
+ self::EXPIRED => __('enums.contract_status.expired'),
+ self::LIQUIDATED => __('enums.contract_status.liquidated'),
};
}
diff --git a/app/Enums/ContractType.php b/app/Enums/ContractType.php
index 5fbc5e9f..95a0368e 100644
--- a/app/Enums/ContractType.php
+++ b/app/Enums/ContractType.php
@@ -11,9 +11,9 @@ enum ContractType: string
public function label(): string
{
return match ($this) {
- self::SALE => 'HĐ Mua bán',
- self::LEASE => 'HĐ Thuê',
- self::BCC => 'HĐ Hợp tác kinh doanh (BCC)',
+ self::SALE => __('enums.contract_type.sale'),
+ self::LEASE => __('enums.contract_type.lease'),
+ self::BCC => __('enums.contract_type.bcc'),
};
}
diff --git a/app/Enums/HandoverStatus.php b/app/Enums/HandoverStatus.php
index 03884d3b..e6679b0e 100644
--- a/app/Enums/HandoverStatus.php
+++ b/app/Enums/HandoverStatus.php
@@ -12,10 +12,10 @@ enum HandoverStatus: string
public function label(): string
{
return match ($this) {
- self::NOT_READY => 'Chưa đủ điều kiện',
- self::READY => 'Đủ điều kiện',
- self::HANDED_OVER => 'Đã bàn giao',
- self::SCHEDULED => 'Đã lên lịch',
+ self::NOT_READY => __('enums.handover.not_ready'),
+ self::READY => __('enums.handover.ready'),
+ self::HANDED_OVER => __('enums.handover.handed_over'),
+ self::SCHEDULED => __('enums.handover.scheduled'),
};
}
diff --git a/app/Enums/ServiceType.php b/app/Enums/ServiceType.php
index d6f4add0..62c52ff0 100644
--- a/app/Enums/ServiceType.php
+++ b/app/Enums/ServiceType.php
@@ -11,9 +11,9 @@ enum ServiceType: string
public function label(): string
{
return match ($this) {
- self::MANAGEMENT_FEE => 'Phí quản lý',
- self::GRATITUDE => 'Tri ân',
- self::SELF_BUSINESS => 'Tự doanh',
+ self::MANAGEMENT_FEE => __('enums.service.management_fee'),
+ self::GRATITUDE => __('enums.service.gratitude'),
+ self::SELF_BUSINESS => __('enums.service.self_business'),
};
}
diff --git a/app/Filament/Resources/Contracts/ContractResource.php b/app/Filament/Resources/Contracts/ContractResource.php
index a63bb254..3ee5b9d1 100644
--- a/app/Filament/Resources/Contracts/ContractResource.php
+++ b/app/Filament/Resources/Contracts/ContractResource.php
@@ -20,13 +20,18 @@ class ContractResource extends Resource
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedDocumentText;
- protected static ?string $pluralLabel = 'Contracts';
+ protected static ?string $pluralLabel = 'app.resource_contracts';
protected static ?int $navigationSort = 4;
+ public static function getPluralLabel(): ?string
+ {
+ return __('app.resource_contracts');
+ }
+
public static function getNavigationGroup(): ?string
{
- return 'Management';
+ return __('app.nav_management');
}
public static function form(Schema $schema): Schema
diff --git a/app/Filament/Resources/Contracts/Schemas/ContractForm.php b/app/Filament/Resources/Contracts/Schemas/ContractForm.php
index 99286f58..dfe248c4 100644
--- a/app/Filament/Resources/Contracts/Schemas/ContractForm.php
+++ b/app/Filament/Resources/Contracts/Schemas/ContractForm.php
@@ -16,33 +16,37 @@ class ContractForm
{
return $schema
->components([
- Section::make('Thông tin hợp đồng')
+ Section::make(__('app.contract_info'))
->schema([
Select::make('product_id')
+ ->label(__('app.product'))
->relationship('product', 'name')
->searchable()
->required(),
Select::make('customer_id')
+ ->label(__('app.widget_customer'))
->relationship('customer', 'name')
->searchable()
->required(),
Select::make('type')
+ ->label(__('app.contract'))
->options(ContractType::options())
->required()
->native(false),
Select::make('status')
+ ->label(__('app.status'))
->options(ContractStatus::ACTIVE->options())
->default('active')
->required()
->native(false),
DatePicker::make('start_date')
- ->label('Ngày bắt đầu'),
+ ->label(__('app.start_date')),
DatePicker::make('end_date')
- ->label('Ngày kết thúc'),
+ ->label(__('app.end_date')),
DatePicker::make('printed_at')
- ->label('Ngày in HĐ'),
+ ->label(__('app.printed_at')),
TextInput::make('signed_status')
- ->label('Tình trạng ký')
+ ->label(__('app.signed_status'))
->maxLength(255),
])
->columns(2),
diff --git a/app/Filament/Resources/Contracts/Tables/ContractsTable.php b/app/Filament/Resources/Contracts/Tables/ContractsTable.php
index a260975b..4c8a1eb3 100644
--- a/app/Filament/Resources/Contracts/Tables/ContractsTable.php
+++ b/app/Filament/Resources/Contracts/Tables/ContractsTable.php
@@ -46,7 +46,7 @@ class ContractsTable
->toggleable(),
TextColumn::make('feedbacks_count')
->counts('feedbacks')
- ->label('Tickets'),
+ ->label(__('app.tickets')),
TextColumn::make('created_at')
->dateTime()
->sortable()
diff --git a/app/Filament/Resources/Customers/CustomerResource.php b/app/Filament/Resources/Customers/CustomerResource.php
index 7ab6ddee..4b5c8e00 100644
--- a/app/Filament/Resources/Customers/CustomerResource.php
+++ b/app/Filament/Resources/Customers/CustomerResource.php
@@ -21,13 +21,18 @@ class CustomerResource extends Resource
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedUserGroup;
- protected static ?string $pluralLabel = 'Customers';
+ protected static ?string $pluralLabel = 'app.resource_customers';
protected static ?int $navigationSort = 2;
+ public static function getPluralLabel(): ?string
+ {
+ return __('app.resource_customers');
+ }
+
public static function getNavigationGroup(): ?string
{
- return 'Management';
+ return __('app.nav_management');
}
public static function form(Schema $schema): Schema
diff --git a/app/Filament/Resources/Customers/RelationManagers/FeedbacksRelationManager.php b/app/Filament/Resources/Customers/RelationManagers/FeedbacksRelationManager.php
index 0e912062..b8e2a9e7 100644
--- a/app/Filament/Resources/Customers/RelationManagers/FeedbacksRelationManager.php
+++ b/app/Filament/Resources/Customers/RelationManagers/FeedbacksRelationManager.php
@@ -12,7 +12,7 @@ class FeedbacksRelationManager extends RelationManager
{
protected static string $relationship = 'feedbacks';
- protected static ?string $title = 'Feedback History';
+ protected static ?string $title = 'app.feedback_history';
public function table(Table $table): Table
{
@@ -23,8 +23,8 @@ class FeedbacksRelationManager extends RelationManager
->searchable()
->url(fn ($record) => FeedbackResource::getUrl('edit', ['record' => $record])),
TextColumn::make('customerProduct.product.name')
- ->label('Product')
- ->placeholder('General'),
+ ->label(__('app.product'))
+ ->placeholder(__('app.general')),
TextColumn::make('status')
->badge()
->color(fn (string $state): string => match ($state) {
@@ -37,10 +37,10 @@ class FeedbacksRelationManager extends RelationManager
TextColumn::make('tags.name')
->badge(),
TextColumn::make('feedbackChannel.name')
- ->label('Channel'),
+ ->label(__('app.channel')),
TextColumn::make('interactions_count')
->counts('interactions')
- ->label('Interactions'),
+ ->label(__('app.interactions')),
TextColumn::make('created_at')
->dateTime('d/m/Y')
->sortable(),
@@ -48,10 +48,10 @@ class FeedbacksRelationManager extends RelationManager
->filters([
SelectFilter::make('status')
->options([
- 'pending' => 'Pending',
- 'processing' => 'Processing',
- 'resolved' => 'Resolved',
- 'closed' => 'Closed',
+ 'pending' => __('app.status_pending'),
+ 'processing' => __('app.status_processing'),
+ 'resolved' => __('app.status_resolved'),
+ 'closed' => __('app.status_closed'),
]),
SelectFilter::make('tags')
->relationship('tags', 'name'),
diff --git a/app/Filament/Resources/Customers/Schemas/CustomerForm.php b/app/Filament/Resources/Customers/Schemas/CustomerForm.php
index 0d83054e..3cef8083 100644
--- a/app/Filament/Resources/Customers/Schemas/CustomerForm.php
+++ b/app/Filament/Resources/Customers/Schemas/CustomerForm.php
@@ -14,32 +14,37 @@ class CustomerForm
{
return $schema
->components([
- Section::make('Customer Information')
+ Section::make(__('app.customer_information'))
->schema([
TextInput::make('name')
+ ->label(__('app.name'))
->required()
->maxLength(255),
TextInput::make('email')
+ ->label(__('app.email'))
->email()
->maxLength(255),
TextInput::make('phone')
+ ->label(__('app.phone'))
->tel()
->maxLength(255),
Textarea::make('address')
+ ->label(__('app.address'))
->columnSpanFull(),
Select::make('status')
->options([
- 'active' => 'Active',
- 'inactive' => 'Inactive',
+ 'active' => __('app.status_active'),
+ 'inactive' => __('app.status_inactive'),
])
->default('active')
->required(),
])
->columns(2),
- Section::make('Owned Products')
+ Section::make(__('app.owned_products'))
->schema([
Select::make('products')
+ ->label(__('app.resource_products'))
->relationship('products', 'name')
->multiple()
->preload()
diff --git a/app/Filament/Resources/Customers/Tables/CustomersTable.php b/app/Filament/Resources/Customers/Tables/CustomersTable.php
index ba5f0d47..3263f72f 100644
--- a/app/Filament/Resources/Customers/Tables/CustomersTable.php
+++ b/app/Filament/Resources/Customers/Tables/CustomersTable.php
@@ -33,10 +33,10 @@ class CustomersTable
}),
TextColumn::make('products_count')
->counts('products')
- ->label('Products'),
+ ->label(__('app.resource_products')),
TextColumn::make('feedbacks_count')
->counts('feedbacks')
- ->label('Feedbacks'),
+ ->label(__('app.feedbacks')),
TextColumn::make('created_at')
->dateTime()
->sortable()
@@ -45,8 +45,8 @@ class CustomersTable
->filters([
SelectFilter::make('status')
->options([
- 'active' => 'Active',
- 'inactive' => 'Inactive',
+ 'active' => __('app.status_active'),
+ 'inactive' => __('app.status_inactive'),
]),
])
->recordActions([
diff --git a/app/Filament/Resources/Feedback/FeedbackResource.php b/app/Filament/Resources/Feedback/FeedbackResource.php
index 7e6f506c..11e39ae7 100644
--- a/app/Filament/Resources/Feedback/FeedbackResource.php
+++ b/app/Filament/Resources/Feedback/FeedbackResource.php
@@ -21,13 +21,18 @@ class FeedbackResource extends Resource
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChatBubbleLeftRight;
- protected static ?string $pluralLabel = 'Feedbacks';
+ protected static ?string $pluralLabel = 'app.resource_feedbacks';
protected static ?int $navigationSort = 1;
+ public static function getPluralLabel(): ?string
+ {
+ return __('app.resource_feedbacks');
+ }
+
public static function getNavigationGroup(): ?string
{
- return 'Customer Care';
+ return __('app.nav_customer_care');
}
public static function form(Schema $schema): Schema
diff --git a/app/Filament/Resources/Feedback/Pages/CreateFeedback.php b/app/Filament/Resources/Feedback/Pages/CreateFeedback.php
index f72837aa..795516a8 100644
--- a/app/Filament/Resources/Feedback/Pages/CreateFeedback.php
+++ b/app/Filament/Resources/Feedback/Pages/CreateFeedback.php
@@ -29,7 +29,7 @@ class CreateFeedback extends CreateRecord
$feedback->interactions()->create([
'user_id' => auth()->id(),
'type' => 'note',
- 'content' => 'Feedback created via ' . ($feedback->feedbackChannel?->name ?? 'unknown channel'),
+ 'content' => __('feedback.created_via', ['channel' => $feedback->feedbackChannel?->name ?? __('feedback.unknown_channel')]),
'changes' => ['status' => ['old' => null, 'new' => $feedback->status]],
]);
diff --git a/app/Filament/Resources/Feedback/Pages/EditFeedback.php b/app/Filament/Resources/Feedback/Pages/EditFeedback.php
index b15b5e56..68ee9d99 100644
--- a/app/Filament/Resources/Feedback/Pages/EditFeedback.php
+++ b/app/Filament/Resources/Feedback/Pages/EditFeedback.php
@@ -33,18 +33,18 @@ class EditFeedback extends EditRecord
protected function transferDepartmentAction(): Action
{
return Action::make('transferDepartment')
- ->label('Transfer Department')
+ ->label(__('feedback.transfer_department'))
->icon('heroicon-o-arrows-right-left')
->color('warning')
->visible(fn (): bool => auth()->user()->hasPermissionTo('transfer-department'))
->form([
Select::make('to_department_id')
- ->label('Target Department')
+ ->label(__('feedback.target_department'))
->options(Department::pluck('name', 'id')->toArray())
->required()
->searchable(),
Select::make('new_assignee_id')
- ->label('Assign To (optional)')
+ ->label(__('feedback.assign_to_optional'))
->options(function (\Filament\Forms\Get $get) {
$deptId = $get('to_department_id');
if (! $deptId) {
@@ -57,11 +57,11 @@ class EditFeedback extends EditRecord
->searchable()
->nullable(),
Textarea::make('reason')
- ->label('Reason')
+ ->label(__('feedback.reason'))
->required()
->rows(3),
FileUpload::make('transfer_attachments')
- ->label('Attachments')
+ ->label(__('app.attachments'))
->multiple()
->disk('local')
->directory('feedback-attachments')
@@ -92,7 +92,7 @@ class EditFeedback extends EditRecord
}
Notification::make()
- ->title('Transferred successfully')
+ ->title(__('feedback.transferred_successfully'))
->success()
->send();
@@ -103,14 +103,14 @@ class EditFeedback extends EditRecord
protected function closeTicketAction(): Action
{
return Action::make('closeTicket')
- ->label('Close Ticket')
+ ->label(__('feedback.close_ticket'))
->icon('heroicon-o-check-circle')
->color('success')
->visible(fn (): bool => $this->getRecord()->status !== 'closed' && auth()->user()->hasPermissionTo('close-ticket'))
->requiresConfirmation()
- ->modalHeading('Close Ticket')
- ->modalDescription('Bạn có chắc muốn đóng phiếu này? Hành động này KHÔNG thể hoàn tác.')
- ->modalSubmitActionLabel('Close')
+ ->modalHeading(__('feedback.close_ticket'))
+ ->modalDescription(__('feedback.close_confirm'))
+ ->modalSubmitActionLabel(__('feedback.close_submit'))
->action(function (): void {
$feedback = $this->getRecord();
$actor = auth()->user();
@@ -121,7 +121,7 @@ class EditFeedback extends EditRecord
$closingService->close($feedback, $actor);
Notification::make()
- ->title('Ticket closed successfully')
+ ->title(__('feedback.closed_successfully'))
->success()
->send();
diff --git a/app/Filament/Resources/Feedback/RelationManagers/InteractionsRelationManager.php b/app/Filament/Resources/Feedback/RelationManagers/InteractionsRelationManager.php
index 6161acd2..dfe76316 100644
--- a/app/Filament/Resources/Feedback/RelationManagers/InteractionsRelationManager.php
+++ b/app/Filament/Resources/Feedback/RelationManagers/InteractionsRelationManager.php
@@ -27,7 +27,7 @@ class InteractionsRelationManager extends RelationManager
{
protected static string $relationship = 'interactions';
- protected static ?string $title = 'Interaction History';
+ protected static ?string $title = 'feedback.interaction_history';
public function form(Schema $schema): Schema
{
@@ -35,9 +35,9 @@ class InteractionsRelationManager extends RelationManager
->components([
Select::make('type')
->options([
- 'note' => 'Note',
- 'status_change' => 'Status Change',
- 'assignment' => 'Assignment / Forward',
+ 'note' => __('feedback.interaction_type_note'),
+ 'status_change' => __('feedback.interaction_type_status_change'),
+ 'assignment' => __('feedback.interaction_type_assignment'),
])
->default('note')
->required()
@@ -52,28 +52,28 @@ class InteractionsRelationManager extends RelationManager
->columnSpanFull(),
Select::make('new_status')
- ->label('New Status')
+ ->label(__('app.new_status'))
->options(fn (): array => auth()->user()->hasPermissionTo('close-ticket')
- ? ['pending' => 'Pending', 'processing' => 'Processing', 'resolved' => 'Resolved', 'closed' => 'Closed']
- : ['pending' => 'Pending', 'processing' => 'Processing', 'resolved' => 'Resolved'],
+ ? ['pending' => __('app.status_pending'), 'processing' => __('app.status_processing'), 'resolved' => __('app.status_resolved'), 'closed' => __('app.status_closed')]
+ : ['pending' => __('app.status_pending'), 'processing' => __('app.status_processing'), 'resolved' => __('app.status_resolved')],
)
->visible(fn ($get): bool => $get('type') === 'status_change'),
Select::make('new_assignee')
- ->label('Assign To')
+ ->label(__('app.assign_to'))
->options(User::pluck('name', 'id')->toArray())
->searchable()
->visible(fn ($get): bool => $get('type') === 'assignment'),
Select::make('department_id')
- ->label('Target Department')
+ ->label(__('app.target_department'))
->options(Department::pluck('name', 'id')->toArray())
->searchable()
->nullable()
->visible(fn ($get): bool => $get('type') === 'assignment'),
FileUpload::make('attachments')
- ->label('Attachments')
+ ->label(__('app.attachments'))
->multiple()
->disk('local')
->directory('feedback-attachments')
@@ -90,13 +90,13 @@ class InteractionsRelationManager extends RelationManager
->dateTime('d/m/Y H:i')
->sortable(),
TextColumn::make('user.name')
- ->label('By'),
+ ->label(__('app.by')),
TextColumn::make('type')
->badge()
->formatStateUsing(fn (string $state): string => match ($state) {
- 'note' => 'Note',
- 'status_change' => 'Status Change',
- 'assignment' => 'Assignment',
+ 'note' => __('feedback.interaction_type_note'),
+ 'status_change' => __('feedback.interaction_type_status_change'),
+ 'assignment' => __('feedback.interaction_type_assignment'),
default => $state,
})
->color(fn (string $state): string => match ($state) {
@@ -110,7 +110,7 @@ class InteractionsRelationManager extends RelationManager
->limit(80)
->wrap(),
TextColumn::make('changes')
- ->label('Details')
+ ->label(__('app.details'))
->formatStateUsing(function (?array $state): string {
if (! $state) return '';
$lines = [];
@@ -123,7 +123,7 @@ class InteractionsRelationManager extends RelationManager
})
->html(),
TextColumn::make('attachments_count')
- ->label('Files')
+ ->label(__('app.files'))
->counts('attachments')
->badge()
->color(fn (int $state): string => $state > 0 ? 'primary' : 'gray'),
@@ -131,9 +131,9 @@ class InteractionsRelationManager extends RelationManager
->defaultSort('created_at', 'desc')
->recordActions([
Action::make('viewAttachments')
- ->label('View Attachments')
+ ->label(__('feedback.view_attachments'))
->icon('heroicon-o-paper-clip')
- ->modalHeading('Attachments')
+ ->modalHeading(__('app.attachments'))
->modalContent(function (FeedbackInteraction $record): \Illuminate\Contracts\View\View {
$fileService = App::make(FileService::class);
$attachments = $record->attachments;
@@ -155,7 +155,7 @@ class InteractionsRelationManager extends RelationManager
])
->headerActions([
CreateAction::make()
- ->label('Add Interaction')
+ ->label(__('feedback.add_interaction'))
->icon('heroicon-o-plus')
->mutateFormDataUsing(function (array $data, $livewire): array {
$data['user_id'] = auth()->id();
@@ -177,8 +177,8 @@ class InteractionsRelationManager extends RelationManager
if ($data['type'] === 'assignment' && isset($data['new_assignee'])) {
$feedback = $livewire->getOwnerRecord();
- $oldAssignee = $feedback->assignedTo?->name ?? 'Unassigned';
- $newAssignee = User::find($data['new_assignee'])?->name ?? 'Unknown';
+ $oldAssignee = $feedback->assignedTo?->name ?? __('app.unassigned');
+ $newAssignee = User::find($data['new_assignee'])?->name ?? __('app.widget_unknown');
$data['changes'] = [
'assignment' => ['old' => $oldAssignee, 'new' => $newAssignee],
];
@@ -221,13 +221,13 @@ class InteractionsRelationManager extends RelationManager
if ($record->type === 'assignment') {
Notification::make()
- ->title('Feedback assigned successfully')
+ ->title(__('feedback.assigned_successfully'))
->success()
->send();
}
if ($record->type === 'status_change') {
Notification::make()
- ->title('Status updated successfully')
+ ->title(__('feedback.status_updated'))
->success()
->send();
}
diff --git a/app/Filament/Resources/Feedback/Schemas/FeedbackForm.php b/app/Filament/Resources/Feedback/Schemas/FeedbackForm.php
index 6be258e3..7c1bb1a6 100644
--- a/app/Filament/Resources/Feedback/Schemas/FeedbackForm.php
+++ b/app/Filament/Resources/Feedback/Schemas/FeedbackForm.php
@@ -20,8 +20,8 @@ class FeedbackForm
{
return $schema
->components([
- Section::make('Customer & Product')
- ->description('Select the customer and their associated product')
+ Section::make(__('app.customer_product'))
+ ->description(__('app.select_customer_product'))
->icon('heroicon-o-user-group')
->schema([
Select::make('customer_id')
@@ -32,13 +32,13 @@ class FeedbackForm
->columnSpan(1),
Toggle::make('is_general')
- ->label('General feedback (not related to a product)')
+ ->label(__('app.general_feedback'))
->default(false)
->live()
->columnSpan(1),
Select::make('customer_product_id')
- ->label('Product')
+ ->label(__('app.product'))
->searchable()
->getSearchResultsUsing(function (string $search, $get): array {
$customerId = $get('customer_id');
@@ -62,7 +62,7 @@ class FeedbackForm
->live(),
Select::make('contract_id')
- ->label('Contract')
+ ->label(__('app.contract'))
->visible(fn ($get): bool => ! $get('is_general') && filled($get('customer_product_id')))
->options(function ($get): array {
$customerProductId = $get('customer_product_id');
@@ -110,8 +110,8 @@ class FeedbackForm
])
->columns(2),
- Section::make('Feedback Details')
- ->description('Describe the customer feedback')
+ Section::make(__('app.feedback_details'))
+ ->description(__('app.describe_feedback'))
->icon('heroicon-o-chat-bubble-left-right')
->schema([
Select::make('feedback_channel_id')
@@ -143,16 +143,16 @@ class FeedbackForm
])
->columns(2),
- Section::make('Assignment & Status')
- ->description('Assign handler and set initial status')
+ Section::make(__('app.assignment_status'))
+ ->description(__('app.assign_handler'))
->icon('heroicon-o-arrow-path-rounded-square')
->schema([
Select::make('status')
->options([
- 'pending' => 'Pending',
- 'processing' => 'Processing',
- 'resolved' => 'Resolved',
- 'closed' => 'Closed',
+ 'pending' => __('app.status_pending'),
+ 'processing' => __('app.status_processing'),
+ 'resolved' => __('app.status_resolved'),
+ 'closed' => __('app.status_closed'),
])
->default('pending')
->required()
@@ -165,35 +165,35 @@ class FeedbackForm
->columnSpan(1),
Select::make('current_department_id')
- ->label('Department')
+ ->label(__('app.department'))
->options(Department::pluck('name', 'id')->toArray())
->searchable()
->nullable()
->columnSpan(1),
Toggle::make('is_escalated')
- ->label('Escalated')
+ ->label(__('app.escalated'))
->visible(fn (): bool => auth()->user()?->hasPermissionTo('close-ticket') ?? false)
->columnSpan(1),
])
->columns(2),
- Section::make('Attachments')
- ->description('Upload files related to this feedback')
+ Section::make(__('app.attachments'))
+ ->description(__('app.upload_files'))
->icon('heroicon-o-paper-clip')
->schema([
Select::make('attachment_collection')
- ->label('Collection')
+ ->label(__('app.collection'))
->options([
- 'general' => 'General',
- 'proof_of_resolution' => 'Proof of Resolution',
- 'customer_evidence' => 'Customer Evidence',
+ 'general' => __('app.collection_general'),
+ 'proof_of_resolution' => __('app.collection_proof_of_resolution'),
+ 'customer_evidence' => __('app.collection_customer_evidence'),
])
->default('general')
->columnSpan(1),
FileUpload::make('attachments')
- ->label('Files')
+ ->label(__('app.files'))
->multiple()
->disk('local')
->directory('feedback-attachments')
diff --git a/app/Filament/Resources/Feedback/Tables/FeedbackTable.php b/app/Filament/Resources/Feedback/Tables/FeedbackTable.php
index 870d4cd8..b7bbe19c 100644
--- a/app/Filament/Resources/Feedback/Tables/FeedbackTable.php
+++ b/app/Filament/Resources/Feedback/Tables/FeedbackTable.php
@@ -33,13 +33,13 @@ class FeedbackTable
->sortable(),
TextColumn::make('customerProduct.product.name')
- ->label('Product')
- ->placeholder('General')
+ ->label(__('app.product'))
+ ->placeholder(__('app.general'))
->sortable()
->toggleable(),
TextColumn::make('contract.type')
- ->label('Contract')
+ ->label(__('app.contract'))
->badge()
->formatStateUsing(fn (?string $state): string => $state ? \App\Enums\ContractType::from($state)->label() : '-')
->color(fn (?string $state): string => match ($state) {
@@ -52,7 +52,7 @@ class FeedbackTable
->toggleable(),
TextColumn::make('feedbackChannel.name')
- ->label('Channel')
+ ->label(__('app.channel'))
->badge()
->color('gray'),
@@ -72,22 +72,22 @@ class FeedbackTable
}),
TextColumn::make('currentDepartment.name')
- ->label('Department')
+ ->label(__('app.department'))
->badge()
->color('info')
->toggleable(),
TextColumn::make('assignedTo.name')
- ->label('Assigned To')
+ ->label(__('app.assigned_to'))
->toggleable(),
IconColumn::make('is_escalated')
- ->label('Escalated')
+ ->label(__('app.escalated'))
->boolean()
->toggleable(),
TextColumn::make('created_at')
- ->label('Created')
+ ->label(__('app.created'))
->since()
->sortable()
->toggleable()
@@ -96,20 +96,20 @@ class FeedbackTable
->filters([
SelectFilter::make('status')
->options([
- 'pending' => 'Pending',
- 'processing' => 'Processing',
- 'resolved' => 'Resolved',
- 'closed' => 'Closed',
+ 'pending' => __('app.status_pending'),
+ 'processing' => __('app.status_processing'),
+ 'resolved' => __('app.status_resolved'),
+ 'closed' => __('app.status_closed'),
]),
SelectFilter::make('feedback_channel_id')
->relationship('feedbackChannel', 'name')
- ->label('Channel'),
+ ->label(__('app.channel')),
SelectFilter::make('current_department_id')
->relationship('currentDepartment', 'name')
- ->label('Department'),
+ ->label(__('app.department')),
SelectFilter::make('assigned_to')
->relationship('assignedTo', 'name')
- ->label('Assigned To'),
+ ->label(__('app.assigned_to')),
])
->recordActions([
EditAction::make(),
diff --git a/app/Filament/Resources/FeedbackChannels/FeedbackChannelResource.php b/app/Filament/Resources/FeedbackChannels/FeedbackChannelResource.php
index edf20b0c..1a15019e 100644
--- a/app/Filament/Resources/FeedbackChannels/FeedbackChannelResource.php
+++ b/app/Filament/Resources/FeedbackChannels/FeedbackChannelResource.php
@@ -20,13 +20,18 @@ class FeedbackChannelResource extends Resource
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedInboxStack;
- protected static ?string $pluralLabel = 'Channels';
+ protected static ?string $pluralLabel = 'app.resource_channels';
protected static ?int $navigationSort = 3;
+ public static function getPluralLabel(): ?string
+ {
+ return __('app.resource_channels');
+ }
+
public static function getNavigationGroup(): ?string
{
- return 'Management';
+ return __('app.nav_management');
}
public static function form(Schema $schema): Schema
diff --git a/app/Filament/Resources/FeedbackChannels/Schemas/FeedbackChannelForm.php b/app/Filament/Resources/FeedbackChannels/Schemas/FeedbackChannelForm.php
index af8d4a3c..08d814d0 100644
--- a/app/Filament/Resources/FeedbackChannels/Schemas/FeedbackChannelForm.php
+++ b/app/Filament/Resources/FeedbackChannels/Schemas/FeedbackChannelForm.php
@@ -17,18 +17,22 @@ class FeedbackChannelForm
Section::make()
->schema([
TextInput::make('name')
+ ->label(__('app.name'))
->required()
->maxLength(255),
TextInput::make('slug')
+ ->label(__('app.slug'))
->required()
->maxLength(255)
->unique(ignoreRecord: true),
TextInput::make('icon')
+ ->label(__('app.icon'))
->maxLength(255)
- ->hint('Heroicon name or emoji'),
+ ->hint(__('app.icon_hint')),
ColorPicker::make('color')
- ->label('Color'),
+ ->label(__('app.color')),
Toggle::make('is_active')
+ ->label(__('app.status_active'))
->default(true),
])
->columns(2),
diff --git a/app/Filament/Resources/FeedbackChannels/Tables/FeedbackChannelsTable.php b/app/Filament/Resources/FeedbackChannels/Tables/FeedbackChannelsTable.php
index c1988d36..c393c3aa 100644
--- a/app/Filament/Resources/FeedbackChannels/Tables/FeedbackChannelsTable.php
+++ b/app/Filament/Resources/FeedbackChannels/Tables/FeedbackChannelsTable.php
@@ -27,7 +27,7 @@ class FeedbackChannelsTable
TextColumn::make('slug')
->searchable(),
TextColumn::make('color')
- ->label('Color')
+ ->label(__('app.color'))
->formatStateUsing(fn ($state) => sprintf(
' ',
$state ?: '#6b7280',
@@ -36,10 +36,10 @@ class FeedbackChannelsTable
->html(),
IconColumn::make('is_active')
->boolean()
- ->label('Active'),
+ ->label(__('app.status_active')),
TextColumn::make('feedbacks_count')
->counts('feedbacks')
- ->label('Feedbacks'),
+ ->label(__('app.feedbacks')),
TextColumn::make('created_at')
->dateTime()
->sortable()
diff --git a/app/Filament/Resources/FeedbackTags/FeedbackTagResource.php b/app/Filament/Resources/FeedbackTags/FeedbackTagResource.php
index 443489c5..72c1ca27 100644
--- a/app/Filament/Resources/FeedbackTags/FeedbackTagResource.php
+++ b/app/Filament/Resources/FeedbackTags/FeedbackTagResource.php
@@ -20,11 +20,14 @@ class FeedbackTagResource extends Resource
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedTag;
- protected static ?string $pluralLabel = 'Tags';
+ public static function getPluralLabel(): ?string
+ {
+ return __('app.resource_tags');
+ }
public static function getNavigationGroup(): ?string
{
- return 'Management';
+ return __('app.nav_management');
}
public static function form(Schema $schema): Schema
diff --git a/app/Filament/Resources/FeedbackTags/Schemas/FeedbackTagForm.php b/app/Filament/Resources/FeedbackTags/Schemas/FeedbackTagForm.php
index c0a831cb..a4ddf207 100644
--- a/app/Filament/Resources/FeedbackTags/Schemas/FeedbackTagForm.php
+++ b/app/Filament/Resources/FeedbackTags/Schemas/FeedbackTagForm.php
@@ -16,13 +16,15 @@ class FeedbackTagForm
Section::make()
->schema([
TextInput::make('name')
+ ->label(__('app.name'))
->required()
->maxLength(255),
TextInput::make('slug')
+ ->label(__('app.slug'))
->required()
->maxLength(255),
ColorPicker::make('color')
- ->label('Color')
+ ->label(__('app.color'))
->default('#3b82f6'),
])
->columns(2),
diff --git a/app/Filament/Resources/FeedbackTags/Tables/FeedbackTagsTable.php b/app/Filament/Resources/FeedbackTags/Tables/FeedbackTagsTable.php
index ab5db0f3..506187f9 100644
--- a/app/Filament/Resources/FeedbackTags/Tables/FeedbackTagsTable.php
+++ b/app/Filament/Resources/FeedbackTags/Tables/FeedbackTagsTable.php
@@ -26,7 +26,7 @@ class FeedbackTagsTable
TextColumn::make('slug')
->searchable(),
TextColumn::make('color')
- ->label('Color')
+ ->label(__('app.color'))
->formatStateUsing(fn ($state) => sprintf(
' ',
$state ?: '#6b7280',
@@ -35,7 +35,7 @@ class FeedbackTagsTable
->html(),
TextColumn::make('feedbacks_count')
->counts('feedbacks')
- ->label('Used'),
+ ->label(__('app.used')),
TextColumn::make('created_at')
->dateTime()
->sortable()
diff --git a/app/Filament/Resources/Products/ProductResource.php b/app/Filament/Resources/Products/ProductResource.php
index 37e52c45..d566a3fb 100644
--- a/app/Filament/Resources/Products/ProductResource.php
+++ b/app/Filament/Resources/Products/ProductResource.php
@@ -23,13 +23,18 @@ class ProductResource extends Resource
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedBuildingStorefront;
- protected static ?string $pluralLabel = 'Products';
+ protected static ?string $pluralLabel = 'app.resource_products';
protected static ?int $navigationSort = 1;
+ public static function getPluralLabel(): ?string
+ {
+ return __('app.resource_products');
+ }
+
public static function getNavigationGroup(): ?string
{
- return 'Management';
+ return __('app.nav_management');
}
public static function form(Schema $schema): Schema
diff --git a/app/Filament/Resources/Products/RelationManagers/ContractsRelationManager.php b/app/Filament/Resources/Products/RelationManagers/ContractsRelationManager.php
index 96d6fc80..6fa41466 100644
--- a/app/Filament/Resources/Products/RelationManagers/ContractsRelationManager.php
+++ b/app/Filament/Resources/Products/RelationManagers/ContractsRelationManager.php
@@ -15,7 +15,7 @@ class ContractsRelationManager extends RelationManager
{
protected static string $relationship = 'contracts';
- protected static ?string $title = 'Contracts';
+ protected static ?string $title = 'app.contracts';
public function table(Table $table): Table
{
@@ -26,7 +26,7 @@ class ContractsRelationManager extends RelationManager
->badge()
->formatStateUsing(fn (string $state): string => ContractType::from($state)->label()),
TextColumn::make('customer.name')
- ->label('Customer')
+ ->label(__('app.widget_customer'))
->searchable(),
TextColumn::make('status')
->badge()
@@ -44,10 +44,10 @@ class ContractsRelationManager extends RelationManager
->date('d/m/Y')
->sortable(),
TextColumn::make('signed_status')
- ->label('Signed'),
+ ->label(__('app.signed')),
TextColumn::make('feedbacks_count')
->counts('feedbacks')
- ->label('Tickets'),
+ ->label(__('app.tickets')),
])
->filters([
SelectFilter::make('type')
diff --git a/app/Filament/Resources/Products/RelationManagers/HandoversRelationManager.php b/app/Filament/Resources/Products/RelationManagers/HandoversRelationManager.php
index cd6aad9b..958a6c39 100644
--- a/app/Filament/Resources/Products/RelationManagers/HandoversRelationManager.php
+++ b/app/Filament/Resources/Products/RelationManagers/HandoversRelationManager.php
@@ -12,7 +12,7 @@ class HandoversRelationManager extends RelationManager
{
protected static string $relationship = 'handovers';
- protected static ?string $title = 'Handovers';
+ protected static ?string $title = 'app.handovers';
public function table(Table $table): Table
{
@@ -20,11 +20,11 @@ class HandoversRelationManager extends RelationManager
->recordTitleAttribute('status')
->columns([
TextColumn::make('customer.name')
- ->label('Customer')
+ ->label(__('app.widget_customer'))
->searchable()
->sortable(),
TextColumn::make('handover_date')
- ->label('Ngày BG')
+ ->label(__('app.handover_date'))
->date('d/m/Y')
->sortable(),
TextColumn::make('status')
@@ -38,7 +38,7 @@ class HandoversRelationManager extends RelationManager
})
->formatStateUsing(fn (string $state): string => HandoverStatus::from($state)->label()),
TextColumn::make('remark')
- ->label('Ghi chú')
+ ->label(__('app.remark'))
->limit(50)
->toggleable(),
TextColumn::make('created_at')
diff --git a/app/Filament/Resources/Products/RelationManagers/ProductServicesRelationManager.php b/app/Filament/Resources/Products/RelationManagers/ProductServicesRelationManager.php
index 4a25d7ea..7e095cbf 100644
--- a/app/Filament/Resources/Products/RelationManagers/ProductServicesRelationManager.php
+++ b/app/Filament/Resources/Products/RelationManagers/ProductServicesRelationManager.php
@@ -12,7 +12,7 @@ class ProductServicesRelationManager extends RelationManager
{
protected static string $relationship = 'productServices';
- protected static ?string $title = 'Services';
+ protected static ?string $title = 'app.services';
public function table(Table $table): Table
{
@@ -38,11 +38,11 @@ class ProductServicesRelationManager extends RelationManager
default => 'gray',
}),
TextColumn::make('actual_collection_date')
- ->label('Ngày thu')
+ ->label(__('app.collection_date'))
->date('d/m/Y')
->sortable(),
TextColumn::make('remark')
- ->label('Ghi chú')
+ ->label(__('app.remark'))
->limit(50)
->toggleable(),
TextColumn::make('created_at')
@@ -55,10 +55,10 @@ class ProductServicesRelationManager extends RelationManager
->options(ServiceType::MANAGEMENT_FEE->options()),
SelectFilter::make('status')
->options([
- 'active' => 'Active',
- 'pending' => 'Pending',
- 'completed' => 'Completed',
- 'cancelled' => 'Cancelled',
+ 'active' => __('app.status_active'),
+ 'pending' => __('app.status_pending'),
+ 'completed' => __('app.status_completed'),
+ 'cancelled' => __('app.status_cancelled'),
]),
])
->defaultSort('created_at', 'desc')
diff --git a/app/Filament/Resources/Products/Schemas/ProductForm.php b/app/Filament/Resources/Products/Schemas/ProductForm.php
index f80dad67..db463178 100644
--- a/app/Filament/Resources/Products/Schemas/ProductForm.php
+++ b/app/Filament/Resources/Products/Schemas/ProductForm.php
@@ -17,15 +17,17 @@ class ProductForm
Section::make()
->schema([
TextInput::make('name')
+ ->label(__('app.name'))
->required()
->maxLength(255),
Textarea::make('description')
+ ->label(__('app.description'))
->columnSpanFull(),
Select::make('status')
->options([
- 'active' => 'Active',
- 'inactive' => 'Inactive',
- 'sold_out' => 'Sold Out',
+ 'active' => __('app.status_active'),
+ 'inactive' => __('app.status_inactive'),
+ 'sold_out' => __('app.status_sold_out'),
])
->default('active')
->required(),
diff --git a/app/Filament/Resources/Products/Tables/ProductsTable.php b/app/Filament/Resources/Products/Tables/ProductsTable.php
index 7868489c..58865f99 100644
--- a/app/Filament/Resources/Products/Tables/ProductsTable.php
+++ b/app/Filament/Resources/Products/Tables/ProductsTable.php
@@ -31,7 +31,7 @@ class ProductsTable
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('customers_count')
->counts('customers')
- ->label('Owners'),
+ ->label(__('app.owners')),
TextColumn::make('created_at')
->dateTime()
->sortable()
@@ -40,9 +40,9 @@ class ProductsTable
->filters([
SelectFilter::make('status')
->options([
- 'active' => 'Active',
- 'inactive' => 'Inactive',
- 'sold_out' => 'Sold Out',
+ 'active' => __('app.status_active'),
+ 'inactive' => __('app.status_inactive'),
+ 'sold_out' => __('app.status_sold_out'),
]),
])
->recordActions([
diff --git a/app/Filament/Resources/Roles/RoleResource.php b/app/Filament/Resources/Roles/RoleResource.php
index 785a48ca..34e7d559 100644
--- a/app/Filament/Resources/Roles/RoleResource.php
+++ b/app/Filament/Resources/Roles/RoleResource.php
@@ -20,15 +20,20 @@ class RoleResource extends Resource
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedShieldCheck;
- protected static ?string $pluralLabel = 'Roles';
+ protected static ?string $pluralLabel = 'app.resource_roles';
protected static ?int $navigationSort = 6;
protected static ?string $recordTitleAttribute = 'name';
+ public static function getPluralLabel(): ?string
+ {
+ return __('app.resource_roles');
+ }
+
public static function getNavigationGroup(): ?string
{
- return 'Management';
+ return __('app.nav_management');
}
public static function form(Schema $schema): Schema
diff --git a/app/Filament/Resources/Roles/Schemas/RoleForm.php b/app/Filament/Resources/Roles/Schemas/RoleForm.php
index 2509e042..bfd8b2fb 100644
--- a/app/Filament/Resources/Roles/Schemas/RoleForm.php
+++ b/app/Filament/Resources/Roles/Schemas/RoleForm.php
@@ -17,14 +17,14 @@ class RoleForm
Section::make()
->schema([
TextInput::make('name')
- ->label('Role Name')
+ ->label(__('app.role_name'))
->required()
->maxLength(255)
->unique(ignoreRecord: true),
]),
- Section::make('Permissions')
- ->description('Chọn quyền cho role này')
+ Section::make(__('app.permissions'))
+ ->description(__('app.permissions'))
->schema([
CheckboxList::make('permissions')
->label('')
diff --git a/app/Filament/Resources/Roles/Tables/RolesTable.php b/app/Filament/Resources/Roles/Tables/RolesTable.php
index 5d1c1b24..fbc1656e 100644
--- a/app/Filament/Resources/Roles/Tables/RolesTable.php
+++ b/app/Filament/Resources/Roles/Tables/RolesTable.php
@@ -19,7 +19,7 @@ class RolesTable
->sortable(),
TextColumn::make('name')
- ->label('Role Name')
+ ->label(__('app.role_name'))
->searchable()
->sortable()
->badge()
@@ -31,13 +31,13 @@ class RolesTable
}),
TextColumn::make('permissions_count')
- ->label('Permissions')
+ ->label(__('app.permissions'))
->counts('permissions')
->badge()
->color('primary'),
TextColumn::make('users_count')
- ->label('Users')
+ ->label(__('app.resource_users'))
->counts('users')
->badge()
->color('success'),
diff --git a/app/Filament/Resources/Users/Pages/ListUsers.php b/app/Filament/Resources/Users/Pages/ListUsers.php
index 90c9cb59..bcd8fd9a 100644
--- a/app/Filament/Resources/Users/Pages/ListUsers.php
+++ b/app/Filament/Resources/Users/Pages/ListUsers.php
@@ -22,14 +22,18 @@ class ListUsers extends ListRecords
public function getTabs(): array
{
return [
- 'all' => Tab::make(),
+ 'all' => Tab::make()
+ ->label(__('app.tab_all')),
'admin' => Tab::make()
+ ->label('Admin')
->modifyQueryUsing(fn (Builder $query) => $query->where('role', 'admin'))
->badge(fn () => static::getResource()::getModel()::where('role', 'admin')->count()),
'manager' => Tab::make()
+ ->label('Manager')
->modifyQueryUsing(fn (Builder $query) => $query->where('role', 'manager'))
->badge(fn () => static::getResource()::getModel()::where('role', 'manager')->count()),
'staff' => Tab::make()
+ ->label('Staff')
->modifyQueryUsing(fn (Builder $query) => $query->where('role', 'staff'))
->badge(fn () => static::getResource()::getModel()::where('role', 'staff')->count()),
];
diff --git a/app/Filament/Resources/Users/Schemas/UserForm.php b/app/Filament/Resources/Users/Schemas/UserForm.php
index 34e1cf2a..edc187ec 100644
--- a/app/Filament/Resources/Users/Schemas/UserForm.php
+++ b/app/Filament/Resources/Users/Schemas/UserForm.php
@@ -14,16 +14,19 @@ class UserForm
return $schema
->components([
TextInput::make('name')
+ ->label(__('app.name'))
->required()
->maxLength(255),
TextInput::make('email')
+ ->label(__('app.email'))
->email()
->required()
->maxLength(255)
->unique(ignoreRecord: true),
TextInput::make('password')
+ ->label(__('app.password'))
->password()
->revealable()
->required(fn (string $operation): bool => $operation === 'create')
@@ -33,7 +36,7 @@ class UserForm
->columnSpanFull(),
Select::make('role')
- ->label('Role')
+ ->label(__('app.role'))
->options(Role::pluck('name', 'name')->toArray())
->required()
->searchable(),
diff --git a/app/Filament/Resources/Users/UserResource.php b/app/Filament/Resources/Users/UserResource.php
index 085d39f4..868f65c9 100644
--- a/app/Filament/Resources/Users/UserResource.php
+++ b/app/Filament/Resources/Users/UserResource.php
@@ -20,15 +20,20 @@ class UserResource extends Resource
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedUsers;
- protected static ?string $pluralLabel = 'Users';
+ protected static ?string $pluralLabel = 'app.resource_users';
protected static ?int $navigationSort = 5;
protected static ?string $recordTitleAttribute = 'name';
+ public static function getPluralLabel(): ?string
+ {
+ return __('app.resource_users');
+ }
+
public static function getNavigationGroup(): ?string
{
- return 'Management';
+ return __('app.nav_management');
}
public static function form(Schema $schema): Schema
diff --git a/app/Filament/Widgets/AvgProcessingTimeWidget.php b/app/Filament/Widgets/AvgProcessingTimeWidget.php
index 4c7d73e9..36b2552f 100644
--- a/app/Filament/Widgets/AvgProcessingTimeWidget.php
+++ b/app/Filament/Widgets/AvgProcessingTimeWidget.php
@@ -35,29 +35,29 @@ class AvgProcessingTimeWidget extends BaseWidget
$avgDisplay = $avgMinutes ? round($avgMinutes) . ' min' : 'N/A';
$avgDescription = $avgMinutes
- ? 'Avg time from creation to resolution'
- : 'No resolved tickets yet';
+ ? __('app.widget_avg_time_desc')
+ : __('app.widget_no_resolved_tickets');
return [
- Stat::make('Resolved Tickets', (string) $totalResolved)
- ->description('Total resolved/closed tickets')
+ Stat::make(__('app.widget_resolved_tickets'), (string) $totalResolved)
+ ->description(__('app.widget_total_resolved_closed'))
->descriptionIcon('heroicon-m-check-circle')
->color('success')
->chart([7, 3, 4, 5, 6, 8, $totalResolved]),
- Stat::make('Avg Processing Time', $avgDisplay)
+ Stat::make(__('app.widget_avg_processing_time'), $avgDisplay)
->description($avgDescription)
->descriptionIcon('heroicon-m-clock')
->color('info'),
- Stat::make('Pending Tickets', (string) $totalPending)
- ->description('Still in progress')
+ Stat::make(__('app.widget_pending_tickets'), (string) $totalPending)
+ ->description(__('app.widget_still_in_progress'))
->descriptionIcon('heroicon-m-arrow-path')
->color('warning')
->chart([3, 5, 4, 6, 5, 4, $totalPending]),
- Stat::make('Escalated', (string) $escalatedCount)
- ->description('Tickets marked as escalated')
+ Stat::make(__('app.widget_escalated'), (string) $escalatedCount)
+ ->description(__('app.widget_escalated_desc'))
->descriptionIcon('heroicon-m-exclamation-triangle')
->color('danger'),
];
diff --git a/app/Filament/Widgets/HandlerPerformanceWidget.php b/app/Filament/Widgets/HandlerPerformanceWidget.php
index ed254f26..8a31a6cc 100644
--- a/app/Filament/Widgets/HandlerPerformanceWidget.php
+++ b/app/Filament/Widgets/HandlerPerformanceWidget.php
@@ -5,15 +5,22 @@ namespace App\Filament\Widgets;
use App\Models\Department;
use App\Models\Feedback;
use Filament\Widgets\ChartWidget;
+use Illuminate\Contracts\Support\Htmlable;
class HandlerPerformanceWidget extends ChartWidget
{
- protected ?string $heading = 'Avg Processing Time by Handler';
-
- protected ?string $description = 'Average time from ticket creation to resolution, broken down by handler.';
-
protected int|string|array $columnSpan = 'full';
+ public function getHeading(): string | Htmlable | null
+ {
+ return __('app.widget_avg_time_by_handler');
+ }
+
+ public function getDescription(): string | Htmlable | null
+ {
+ return __('app.widget_avg_time_by_handler_desc');
+ }
+
protected function getType(): string
{
return 'bar';
@@ -39,19 +46,18 @@ class HandlerPerformanceWidget extends ChartWidget
->with('assignedTo')
->get();
- $labels = $handlers->map(fn ($h) => $h->assignedTo?->name ?? 'Unknown')->toArray();
+ $labels = $handlers->map(fn ($h) => $h->assignedTo?->name ?? __('app.widget_unknown'))->toArray();
$data = $handlers->map(fn ($h) => round($h->avg_minutes, 1))->toArray();
- // Generate gradient colors based on performance
$maxMinutes = max($data) ?: 1;
$colors = collect($data)->map(function ($minutes) use ($maxMinutes) {
$ratio = $minutes / $maxMinutes;
if ($ratio > 0.8) {
- return 'rgba(220, 38, 38, 0.8)'; // Red for slow
+ return 'rgba(220, 38, 38, 0.8)';
} elseif ($ratio > 0.5) {
- return 'rgba(217, 119, 6, 0.8)'; // Amber for medium
+ return 'rgba(217, 119, 6, 0.8)';
}
- return 'rgba(26, 86, 219, 0.8)'; // Blue for fast
+ return 'rgba(26, 86, 219, 0.8)';
})->toArray();
$borderColors = collect($data)->map(function ($minutes) use ($maxMinutes) {
@@ -67,7 +73,7 @@ class HandlerPerformanceWidget extends ChartWidget
return [
'datasets' => [
[
- 'label' => 'Avg Time (minutes)',
+ 'label' => __('app.widget_avg_time_minutes'),
'data' => $data,
'backgroundColor' => $colors,
'borderColor' => $borderColors,
diff --git a/app/Filament/Widgets/ResolvedTicketsWidget.php b/app/Filament/Widgets/ResolvedTicketsWidget.php
index 4242cc9f..0d3027c3 100644
--- a/app/Filament/Widgets/ResolvedTicketsWidget.php
+++ b/app/Filament/Widgets/ResolvedTicketsWidget.php
@@ -35,37 +35,37 @@ class ResolvedTicketsWidget extends BaseWidget
->color('primary'),
TextColumn::make('title')
- ->label('Ticket')
+ ->label(__('app.widget_ticket'))
->searchable()
->weight('semibold')
->url(fn (Feedback $record): string => FeedbackResource::getUrl('edit', ['record' => $record]))
->color('primary'),
TextColumn::make('customer.name')
- ->label('Customer')
+ ->label(__('app.widget_customer'))
->searchable(),
TextColumn::make('customerProduct.product.name')
- ->label('Product')
- ->placeholder('General'),
+ ->label(__('app.product'))
+ ->placeholder(__('app.general')),
TextColumn::make('assignedTo.name')
- ->label('Handler'),
+ ->label(__('app.widget_handler')),
TextColumn::make('currentDepartment.name')
- ->label('Department')
+ ->label(__('app.department'))
->badge()
->color('info'),
TextColumn::make('updated_at')
- ->label('Resolved')
+ ->label(__('app.widget_resolved'))
->since()
->sortable()
->color('secondary'),
])
->defaultSort('updated_at', 'desc')
- ->heading('Tickets Awaiting Closure')
- ->description('Tickets that have been resolved and are pending manager review for closure.')
+ ->heading(__('app.widget_tickets_awaiting_closure'))
+ ->description(__('app.widget_tickets_awaiting_desc'))
->paginated([5]);
}
diff --git a/app/Notifications/TicketClosed.php b/app/Notifications/TicketClosed.php
index ee6516c1..28621510 100644
--- a/app/Notifications/TicketClosed.php
+++ b/app/Notifications/TicketClosed.php
@@ -25,16 +25,16 @@ class TicketClosed extends Notification
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
- ->subject(__('Ticket #{id} đã được đóng', ['id' => $this->feedback->id]))
- ->line(__('Ticket: :title', ['title' => $this->feedback->title]))
- ->line(__('Đóng bởi: :name', ['name' => $this->actor->name]))
- ->action(__('Xem Ticket'), url('/admin/feedbacks/' . $this->feedback->id . '/edit'));
+ ->subject(__('feedback.notif_ticket_closed_subject', ['id' => $this->feedback->id]))
+ ->line(__('feedback.notif_ticket_closed_ticket', ['title' => $this->feedback->title]))
+ ->line(__('feedback.notif_ticket_closed_by', ['name' => $this->actor->name]))
+ ->action(__('feedback.notif_view_ticket'), url('/admin/feedbacks/' . $this->feedback->id . '/edit'));
}
public function toDatabase(object $notifiable): array
{
return [
- 'message' => __('Ticket #{id} ":title" đã được đóng bởi :actor.', [
+ 'message' => __('feedback.notif_ticket_closed_message', [
'id' => $this->feedback->id,
'title' => $this->feedback->title,
'actor' => $this->actor->name,
diff --git a/app/Notifications/TicketTransferred.php b/app/Notifications/TicketTransferred.php
index c65de62b..ad67bc4e 100644
--- a/app/Notifications/TicketTransferred.php
+++ b/app/Notifications/TicketTransferred.php
@@ -27,17 +27,17 @@ class TicketTransferred extends Notification
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
- ->subject(__('Ticket #{id} đã được chuyển đến phòng của bạn', ['id' => $this->feedback->id]))
- ->line(__('Ticket: :title', ['title' => $this->feedback->title]))
- ->line(__('Người chuyển: :name', ['name' => $this->sender->name]))
- ->line(__('Lý do: :reason', ['reason' => $this->transferLog->reason]))
- ->action(__('Xem Ticket'), url('/admin/feedbacks/' . $this->feedback->id . '/edit'));
+ ->subject(__('feedback.notif_ticket_transferred_subject', ['id' => $this->feedback->id]))
+ ->line(__('feedback.notif_ticket_closed_ticket', ['title' => $this->feedback->title]))
+ ->line(__('feedback.notif_ticket_transferred_by', ['name' => $this->sender->name]))
+ ->line(__('feedback.notif_ticket_transferred_reason', ['reason' => $this->transferLog->reason]))
+ ->action(__('feedback.notif_view_ticket'), url('/admin/feedbacks/' . $this->feedback->id . '/edit'));
}
public function toDatabase(object $notifiable): array
{
return [
- 'message' => __('Ticket #{id} ":title" đã được chuyển đến phòng của bạn bởi :sender.', [
+ 'message' => __('feedback.notif_ticket_transferred_message', [
'id' => $this->feedback->id,
'title' => $this->feedback->title,
'sender' => $this->sender->name,
diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php
index 972a5eb3..5a4d1f49 100644
--- a/app/Providers/Filament/AdminPanelProvider.php
+++ b/app/Providers/Filament/AdminPanelProvider.php
@@ -56,10 +56,10 @@ class AdminPanelProvider extends PanelProvider
])
->navigationGroups([
NavigationGroup::make()
- ->label('Customer Care')
+ ->label(__('app.nav_customer_care'))
->icon('heroicon-o-chat-bubble-left-right'),
NavigationGroup::make()
- ->label('Management')
+ ->label(__('app.nav_management'))
->icon('heroicon-o-cog-6-tooth'),
])
->middleware([
diff --git a/app/Rules/RequireProofOfResolution.php b/app/Rules/RequireProofOfResolution.php
index f16d3cca..68a37dd4 100644
--- a/app/Rules/RequireProofOfResolution.php
+++ b/app/Rules/RequireProofOfResolution.php
@@ -26,7 +26,7 @@ class RequireProofOfResolution implements ValidationRule
$fileService = App::make(FileService::class);
if (! $fileService->hasCollection($this->feedback, 'proof_of_resolution')) {
- $fail(__('Yêu cầu cung cấp tài liệu bằng chứng để hoàn tất quy trình.'));
+ $fail(__('feedback.proof_required'));
}
}
}
diff --git a/app/Services/ClosingService.php b/app/Services/ClosingService.php
index 08eae6e7..46bb78e8 100644
--- a/app/Services/ClosingService.php
+++ b/app/Services/ClosingService.php
@@ -11,25 +11,22 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
class ClosingService
{
- /**
- * Close a feedback. Throws if staff tries to close or no proof exists.
- */
public function close(Feedback $feedback, User $actor): void
{
if ($feedback->status === 'closed') {
throw ValidationException::withMessages([
- 'status' => __('Phiếu này đã được đóng.'),
+ 'status' => __('feedback.already_closed'),
]);
}
if (! $actor->hasPermissionTo('close-ticket')) {
- throw new HttpException(403, __('Chỉ lãnh đạo cấp phòng mới có quyền đóng phiếu.'));
+ throw new HttpException(403, __('feedback.close_permission_denied'));
}
if ($actor->hasRole('manager') && ! $actor->hasRole('admin')) {
$managedDeptIds = Department::where('manager_id', $actor->id)->pluck('id');
if (! $managedDeptIds->contains($feedback->current_department_id)) {
- throw new HttpException(403, __('Bạn chỉ có thể đóng phiếu thuộc phòng ban mình quản lý.'));
+ throw new HttpException(403, __('feedback.close_department_only'));
}
}
@@ -37,7 +34,7 @@ class ClosingService
if (! $fileService->hasCollection($feedback, 'proof_of_resolution')) {
throw ValidationException::withMessages([
- 'status' => __('Yêu cầu cung cấp tài liệu bằng chứng để hoàn tất quy trình.'),
+ 'status' => __('feedback.proof_required'),
]);
}
@@ -46,14 +43,11 @@ class ClosingService
$this->notifyStakeholders($feedback, $actor);
}
- /**
- * Resolve a feedback. Any role can resolve.
- */
public function resolve(Feedback $feedback, User $actor): void
{
if (in_array($feedback->status, ['closed', 'resolved'])) {
throw ValidationException::withMessages([
- 'status' => __('Phiếu này đã được xử lý hoặc đóng.'),
+ 'status' => __('feedback.already_resolved'),
]);
}
diff --git a/app/Services/FileService.php b/app/Services/FileService.php
index c5231287..1456edfd 100644
--- a/app/Services/FileService.php
+++ b/app/Services/FileService.php
@@ -126,11 +126,11 @@ class FileService
$errors = [];
if (! in_array($file->getMimeType(), $this->allowedMimes)) {
- $errors['file'] = __('File type :type is not allowed. Allowed: jpg, png, pdf, mp4, mov.', ['type' => $file->getMimeType()]);
+ $errors['file'] = __('feedback.file_type_not_allowed', ['type' => $file->getMimeType()]);
}
if ($file->getSize() > $this->maxSize * 1024) {
- $errors['file'] = __('File size exceeds :max KB.', ['max' => $this->maxSize]);
+ $errors['file'] = __('feedback.file_size_exceeds', ['max' => $this->maxSize]);
}
if (! empty($errors)) {
diff --git a/app/Services/TransferService.php b/app/Services/TransferService.php
index ec132bfc..add0b215 100644
--- a/app/Services/TransferService.php
+++ b/app/Services/TransferService.php
@@ -10,9 +10,6 @@ use Illuminate\Validation\ValidationException;
class TransferService
{
- /**
- * Transfer a feedback to another department.
- */
public function transfer(
Feedback $feedback,
Department $toDepartment,
@@ -22,7 +19,7 @@ class TransferService
): TicketTransferLog {
if (empty(trim($reason))) {
throw ValidationException::withMessages([
- 'reason' => __('Vui lòng nhập lý do chuyển tiếp.'),
+ 'reason' => __('feedback.transfer_reason_required'),
]);
}
diff --git a/lang/en/app.php b/lang/en/app.php
new file mode 100644
index 00000000..d1953109
--- /dev/null
+++ b/lang/en/app.php
@@ -0,0 +1,133 @@
+ 'Customer Care',
+ 'nav_management' => 'Management',
+
+ // Resource labels
+ 'resource_feedbacks' => 'Feedbacks',
+ 'resource_products' => 'Products',
+ 'resource_customers' => 'Customers',
+ 'resource_contracts' => 'Contracts',
+ 'resource_channels' => 'Channels',
+ 'resource_tags' => 'Tags',
+ 'resource_roles' => 'Roles',
+ 'resource_users' => 'Users',
+
+ // Status
+ 'status_pending' => 'Pending',
+ 'status_processing' => 'Processing',
+ 'status_resolved' => 'Resolved',
+ 'status_closed' => 'Closed',
+ 'status_active' => 'Active',
+ 'status_inactive' => 'Inactive',
+ 'status_sold_out' => 'Sold Out',
+ 'status_completed' => 'Completed',
+ 'status_cancelled' => 'Cancelled',
+
+ // General labels
+ 'name' => 'Name',
+ 'email' => 'Email',
+ 'password' => 'Password',
+ 'phone' => 'Phone',
+ 'address' => 'Address',
+ 'slug' => 'Slug',
+ 'icon' => 'Icon',
+ 'description' => 'Description',
+ 'status' => 'Status',
+ 'customer_product' => 'Customer & Product',
+ 'select_customer_product' => 'Select the customer and their associated product',
+ 'general_feedback' => 'General feedback (not related to a product)',
+ 'product' => 'Product',
+ 'contract' => 'Contract',
+ 'feedback_details' => 'Feedback Details',
+ 'describe_feedback' => 'Describe the customer feedback',
+ 'assignment_status' => 'Assignment & Status',
+ 'assign_handler' => 'Assign handler and set initial status',
+ 'department' => 'Department',
+ 'escalated' => 'Escalated',
+ 'attachments' => 'Attachments',
+ 'upload_files' => 'Upload files related to this feedback',
+ 'collection' => 'Collection',
+ 'collection_general' => 'General',
+ 'collection_proof_of_resolution' => 'Proof of Resolution',
+ 'collection_customer_evidence' => 'Customer Evidence',
+ 'files' => 'Files',
+ 'channel' => 'Channel',
+ 'assigned_to' => 'Assigned To',
+ 'created' => 'Created',
+ 'general' => 'General',
+ 'new_status' => 'New Status',
+ 'assign_to' => 'Assign To',
+ 'target_department' => 'Target Department',
+ 'by' => 'By',
+ 'details' => 'Details',
+ 'unassigned' => 'Unassigned',
+ 'role' => 'Role',
+ 'role_name' => 'Role Name',
+ 'permissions' => 'Permissions',
+ 'color' => 'Color',
+ 'customer_information' => 'Customer Information',
+ 'owned_products' => 'Owned Products',
+ 'icon_hint' => 'Heroicon name or emoji',
+ 'feedbacks' => 'Feedbacks',
+ 'used' => 'Used',
+ 'owners' => 'Owners',
+ 'tickets' => 'Tickets',
+ 'signed' => 'Signed',
+ 'interactions' => 'Interactions',
+ 'services' => 'Services',
+ 'handovers' => 'Handovers',
+ 'contracts' => 'Contracts',
+ 'collection_date' => 'Collection Date',
+ 'remark' => 'Remark',
+ 'handover_date' => 'Handover Date',
+ 'start_date' => 'Start Date',
+ 'end_date' => 'End Date',
+ 'printed_at' => 'Print Date',
+ 'signed_status' => 'Signed Status',
+ 'contract_info' => 'Contract Information',
+ 'feedback_history' => 'Feedback History',
+ 'tab_all' => 'All',
+ 'min' => 'min',
+ 'na' => 'N/A',
+
+ // Widget labels
+ 'widget_tickets_awaiting_closure' => 'Tickets Awaiting Closure',
+ 'widget_tickets_awaiting_desc' => 'Tickets that have been resolved and are pending manager review for closure.',
+ 'widget_ticket' => 'Ticket',
+ 'widget_customer' => 'Customer',
+ 'widget_handler' => 'Handler',
+ 'widget_resolved' => 'Resolved',
+ 'widget_resolved_tickets' => 'Resolved Tickets',
+ 'widget_total_resolved_closed' => 'Total resolved/closed tickets',
+ 'widget_avg_processing_time' => 'Avg Processing Time',
+ 'widget_avg_time_desc' => 'Avg time from creation to resolution',
+ 'widget_no_resolved_tickets' => 'No resolved tickets yet',
+ 'widget_pending_tickets' => 'Pending Tickets',
+ 'widget_still_in_progress' => 'Still in progress',
+ 'widget_escalated' => 'Escalated',
+ 'widget_escalated_desc' => 'Tickets marked as escalated',
+ 'widget_avg_time_by_handler' => 'Avg Processing Time by Handler',
+ 'widget_avg_time_by_handler_desc' => 'Average time from ticket creation to resolution, broken down by handler.',
+ 'widget_avg_time_minutes' => 'Avg Time (minutes)',
+ 'widget_unknown' => 'Unknown',
+
+ // Blade views
+ 'blade_similar_cases' => 'Similar Cases',
+ 'blade_matching_tags' => 'Matching tags:',
+ 'blade_title' => 'Title',
+ 'blade_customer' => 'Customer',
+ 'blade_status' => 'Status',
+ 'blade_handler' => 'Handler',
+ 'blade_date' => 'Date',
+ 'blade_no_attachments' => 'No attachments.',
+ 'blade_proof_of_resolution' => 'Proof of Resolution',
+ 'blade_customer_evidence' => 'Customer Evidence',
+ 'blade_open' => 'Open',
+ 'blade_links_expire' => 'Click to open. Links expire after 60 minutes.',
+ 'blade_file_count' => ':count file(s)',
+ 'blade_proof' => 'Proof',
+ 'blade_evidence' => 'Evidence',
+];
diff --git a/lang/en/enums.php b/lang/en/enums.php
new file mode 100644
index 00000000..a9222934
--- /dev/null
+++ b/lang/en/enums.php
@@ -0,0 +1,28 @@
+ [
+ 'not_ready' => 'Not ready',
+ 'ready' => 'Ready',
+ 'handed_over' => 'Handed over',
+ 'scheduled' => 'Scheduled',
+ ],
+
+ 'service' => [
+ 'management_fee' => 'Management fee',
+ 'gratitude' => 'Gratitude',
+ 'self_business' => 'Self business',
+ ],
+
+ 'contract_status' => [
+ 'active' => 'Active',
+ 'expired' => 'Expired',
+ 'liquidated' => 'Liquidated',
+ ],
+
+ 'contract_type' => [
+ 'sale' => 'Sale contract',
+ 'lease' => 'Lease contract',
+ 'bcc' => 'Business cooperation contract (BCC)',
+ ],
+];
diff --git a/lang/en/feedback.php b/lang/en/feedback.php
new file mode 100644
index 00000000..e6281d96
--- /dev/null
+++ b/lang/en/feedback.php
@@ -0,0 +1,54 @@
+ 'This ticket has already been closed.',
+ 'close_permission_denied' => 'Only department managers can close tickets.',
+ 'close_department_only' => 'You can only close tickets in your managed department.',
+ 'proof_required' => 'Proof of resolution is required to complete the process.',
+ 'already_resolved' => 'This ticket has already been resolved or closed.',
+
+ // TransferService
+ 'transfer_reason_required' => 'Please enter a transfer reason.',
+
+ // FileService
+ 'file_type_not_allowed' => 'File type :type is not allowed. Allowed: jpg, png, pdf, mp4, mov.',
+ 'file_size_exceeds' => 'File size exceeds :max KB.',
+
+ // EditFeedback page
+ 'transfer_department' => 'Transfer Department',
+ 'target_department' => 'Target Department',
+ 'assign_to_optional' => 'Assign To (optional)',
+ 'reason' => 'Reason',
+ 'transferred_successfully' => 'Transferred successfully',
+ 'close_ticket' => 'Close Ticket',
+ 'close_confirm' => 'Are you sure you want to close this ticket? This action CANNOT be undone.',
+ 'close_submit' => 'Close',
+ 'closed_successfully' => 'Ticket closed successfully',
+
+ // InteractionsRelationManager
+ 'interaction_history' => 'Interaction History',
+ 'interaction_type_note' => 'Note',
+ 'interaction_type_status_change' => 'Status Change',
+ 'interaction_type_assignment' => 'Assignment / Forward',
+ 'view_attachments' => 'View Attachments',
+ 'add_interaction' => 'Add Interaction',
+ 'assigned_successfully' => 'Feedback assigned successfully',
+ 'status_updated' => 'Status updated successfully',
+
+ // Notifications
+ 'notif_ticket_closed_subject' => 'Ticket #:id has been closed',
+ 'notif_ticket_closed_ticket' => 'Ticket: :title',
+ 'notif_ticket_closed_by' => 'Closed by: :name',
+ 'notif_view_ticket' => 'View Ticket',
+ 'notif_ticket_closed_message' => 'Ticket #:id ":title" has been closed by :actor.',
+
+ 'notif_ticket_transferred_subject' => 'Ticket #:id has been transferred to your department',
+ 'notif_ticket_transferred_by' => 'Transferred by: :name',
+ 'notif_ticket_transferred_reason' => 'Reason: :reason',
+ 'notif_ticket_transferred_message' => 'Ticket #:id ":title" has been transferred to your department by :sender.',
+
+ // CreateFeedback
+ 'created_via' => 'Feedback created via :channel',
+ 'unknown_channel' => 'unknown channel',
+];
diff --git a/lang/vi/app.php b/lang/vi/app.php
new file mode 100644
index 00000000..29d5390a
--- /dev/null
+++ b/lang/vi/app.php
@@ -0,0 +1,133 @@
+ 'Chăm sóc khách hàng',
+ 'nav_management' => 'Quản lý',
+
+ // Resource labels
+ 'resource_feedbacks' => 'Phản ánh',
+ 'resource_products' => 'Sản phẩm',
+ 'resource_customers' => 'Khách hàng',
+ 'resource_contracts' => 'Hợp đồng',
+ 'resource_channels' => 'Kênh phản ánh',
+ 'resource_tags' => 'Thẻ',
+ 'resource_roles' => 'Vai trò',
+ 'resource_users' => 'Người dùng',
+
+ // Status
+ 'status_pending' => 'Chờ xử lý',
+ 'status_processing' => 'Đang xử lý',
+ 'status_resolved' => 'Đã xử lý',
+ 'status_closed' => 'Đã đóng',
+ 'status_active' => 'Hoạt động',
+ 'status_inactive' => 'Không hoạt động',
+ 'status_sold_out' => 'Hết hàng',
+ 'status_completed' => 'Hoàn thành',
+ 'status_cancelled' => 'Đã hủy',
+
+ // General labels
+ 'name' => 'Tên',
+ 'email' => 'Email',
+ 'password' => 'Mật khẩu',
+ 'phone' => 'Điện thoại',
+ 'address' => 'Địa chỉ',
+ 'slug' => 'Slug',
+ 'icon' => 'Biểu tượng',
+ 'description' => 'Mô tả',
+ 'status' => 'Trạng thái',
+ 'customer_product' => 'Khách hàng & Sản phẩm',
+ 'select_customer_product' => 'Chọn khách hàng và sản phẩm liên quan',
+ 'general_feedback' => 'Phản ánh chung (không liên quan đến sản phẩm)',
+ 'product' => 'Sản phẩm',
+ 'contract' => 'Hợp đồng',
+ 'feedback_details' => 'Chi tiết phản ánh',
+ 'describe_feedback' => 'Mô tả phản ánh của khách hàng',
+ 'assignment_status' => 'Phân công & Trạng thái',
+ 'assign_handler' => 'Phân công người xử lý và trạng thái ban đầu',
+ 'department' => 'Phòng ban',
+ 'escalated' => 'Khẩn cấp',
+ 'attachments' => 'Tệp đính kèm',
+ 'upload_files' => 'Tải lên tệp liên quan đến phản ánh này',
+ 'collection' => 'Loại',
+ 'collection_general' => 'Chung',
+ 'collection_proof_of_resolution' => 'Bằng chứng giải quyết',
+ 'collection_customer_evidence' => 'Bằng chứng khách hàng',
+ 'files' => 'Tệp',
+ 'channel' => 'Kênh',
+ 'assigned_to' => 'Người xử lý',
+ 'created' => 'Ngày tạo',
+ 'general' => 'Chung',
+ 'new_status' => 'Trạng thái mới',
+ 'assign_to' => 'Chỉ định cho',
+ 'target_department' => 'Phòng ban đích',
+ 'by' => 'Người thực hiện',
+ 'details' => 'Chi tiết',
+ 'unassigned' => 'Chưa phân công',
+ 'role' => 'Vai trò',
+ 'role_name' => 'Tên vai trò',
+ 'permissions' => 'Quyền',
+ 'color' => 'Màu sắc',
+ 'customer_information' => 'Thông tin khách hàng',
+ 'owned_products' => 'Sản phẩm sở hữu',
+ 'icon_hint' => 'Tên Heroicon hoặc emoji',
+ 'feedbacks' => 'Phản ánh',
+ 'used' => 'Đã dùng',
+ 'owners' => 'Chủ sở hữu',
+ 'tickets' => 'Phiếu',
+ 'signed' => 'Đã ký',
+ 'interactions' => 'Tương tác',
+ 'services' => 'Dịch vụ',
+ 'handovers' => 'Bàn giao',
+ 'contracts' => 'Hợp đồng',
+ 'collection_date' => 'Ngày thu',
+ 'remark' => 'Ghi chú',
+ 'handover_date' => 'Ngày bàn giao',
+ 'start_date' => 'Ngày bắt đầu',
+ 'end_date' => 'Ngày kết thúc',
+ 'printed_at' => 'Ngày in HĐ',
+ 'signed_status' => 'Tình trạng ký',
+ 'contract_info' => 'Thông tin hợp đồng',
+ 'feedback_history' => 'Lịch sử phản ánh',
+ 'tab_all' => 'Tất cả',
+ 'min' => 'phút',
+ 'na' => 'N/A',
+
+ // Widget labels
+ 'widget_tickets_awaiting_closure' => 'Phiếu chờ đóng',
+ 'widget_tickets_awaiting_desc' => 'Các phiếu đã xử lý và đang chờ quản lý xem xét để đóng.',
+ 'widget_ticket' => 'Phiếu',
+ 'widget_customer' => 'Khách hàng',
+ 'widget_handler' => 'Người xử lý',
+ 'widget_resolved' => 'Đã xử lý',
+ 'widget_resolved_tickets' => 'Phiếu đã xử lý',
+ 'widget_total_resolved_closed' => 'Tổng phiếu đã xử lý/đã đóng',
+ 'widget_avg_processing_time' => 'Thời gian xử lý TB',
+ 'widget_avg_time_desc' => 'Thời gian TB từ khi tạo đến khi xử lý',
+ 'widget_no_resolved_tickets' => 'Chưa có phiếu đã xử lý',
+ 'widget_pending_tickets' => 'Phiếu đang chờ',
+ 'widget_still_in_progress' => 'Đang xử lý',
+ 'widget_escalated' => 'Khẩn cấp',
+ 'widget_escalated_desc' => 'Phiếu được đánh dấu khẩn cấp',
+ 'widget_avg_time_by_handler' => 'Thời gian xử lý TB theo người xử lý',
+ 'widget_avg_time_by_handler_desc' => 'Thời gian trung bình từ khi tạo phiếu đến khi xử lý, phân theo người xử lý.',
+ 'widget_avg_time_minutes' => 'Thời gian TB (phút)',
+ 'widget_unknown' => 'Không xác định',
+
+ // Blade views
+ 'blade_similar_cases' => 'Trường hợp tương tự',
+ 'blade_matching_tags' => 'Thẻ trùng khớp:',
+ 'blade_title' => 'Tiêu đề',
+ 'blade_customer' => 'Khách hàng',
+ 'blade_status' => 'Trạng thái',
+ 'blade_handler' => 'Người xử lý',
+ 'blade_date' => 'Ngày',
+ 'blade_no_attachments' => 'Không có tệp đính kèm.',
+ 'blade_proof_of_resolution' => 'Bằng chứng giải quyết',
+ 'blade_customer_evidence' => 'Bằng chứng khách hàng',
+ 'blade_open' => 'Mở',
+ 'blade_links_expire' => 'Nhấn để mở. Liên kết hết hạn sau 60 phút.',
+ 'blade_file_count' => ':count tệp',
+ 'blade_proof' => 'Bằng chứng',
+ 'blade_evidence' => 'Chứng cứ',
+];
diff --git a/lang/vi/enums.php b/lang/vi/enums.php
new file mode 100644
index 00000000..6bd35b50
--- /dev/null
+++ b/lang/vi/enums.php
@@ -0,0 +1,28 @@
+ [
+ 'not_ready' => 'Chưa đủ điều kiện',
+ 'ready' => 'Đủ điều kiện',
+ 'handed_over' => 'Đã bàn giao',
+ 'scheduled' => 'Đã lên lịch',
+ ],
+
+ 'service' => [
+ 'management_fee' => 'Phí quản lý',
+ 'gratitude' => 'Tri ân',
+ 'self_business' => 'Tự doanh',
+ ],
+
+ 'contract_status' => [
+ 'active' => 'Đang hiệu lực',
+ 'expired' => 'Hết hạn',
+ 'liquidated' => 'Đã thanh lý',
+ ],
+
+ 'contract_type' => [
+ 'sale' => 'HĐ Mua bán',
+ 'lease' => 'HĐ Thuê',
+ 'bcc' => 'HĐ Hợp tác kinh doanh (BCC)',
+ ],
+];
diff --git a/lang/vi/feedback.php b/lang/vi/feedback.php
new file mode 100644
index 00000000..d966864e
--- /dev/null
+++ b/lang/vi/feedback.php
@@ -0,0 +1,54 @@
+ '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.',
+ 'close_department_only' => 'Bạn chỉ có thể đóng phiếu thuộc phòng ban mình quản lý.',
+ 'proof_required' => 'Yêu cầu cung cấp tài liệu bằng chứng để hoàn tất quy trình.',
+ 'already_resolved' => 'Phiếu này đã được xử lý hoặc đóng.',
+
+ // TransferService
+ 'transfer_reason_required' => 'Vui lòng nhập lý do chuyển tiếp.',
+
+ // FileService
+ 'file_type_not_allowed' => 'Loại file :type không được phép. Cho phép: jpg, png, pdf, mp4, mov.',
+ 'file_size_exceeds' => 'Kích thước file vượt quá :max KB.',
+
+ // EditFeedback page
+ 'transfer_department' => 'Chuyển phòng ban',
+ 'target_department' => 'Phòng ban đích',
+ 'assign_to_optional' => 'Chỉ định người xử lý (tùy chọn)',
+ 'reason' => 'Lý do',
+ 'transferred_successfully' => 'Chuyển tiếp thành công',
+ 'close_ticket' => 'Đóng phiếu',
+ 'close_confirm' => 'Bạn có chắc muốn đóng phiếu này? Hành động này KHÔNG thể hoàn tác.',
+ 'close_submit' => 'Đóng',
+ 'closed_successfully' => 'Đóng phiếu thành công',
+
+ // InteractionsRelationManager
+ 'interaction_history' => 'Lịch sử tương tác',
+ 'interaction_type_note' => 'Ghi chú',
+ 'interaction_type_status_change' => 'Thay đổi trạng thái',
+ 'interaction_type_assignment' => 'Phân công / Chuyển tiếp',
+ 'view_attachments' => 'Xem tệp đính kèm',
+ 'add_interaction' => 'Thêm tương tác',
+ 'assigned_successfully' => 'Phân công thành công',
+ 'status_updated' => 'Cập nhật trạng thái thành công',
+
+ // Notifications
+ 'notif_ticket_closed_subject' => 'Ticket #:id đã được đóng',
+ 'notif_ticket_closed_ticket' => 'Ticket: :title',
+ 'notif_ticket_closed_by' => 'Đóng bởi: :name',
+ 'notif_view_ticket' => 'Xem Ticket',
+ 'notif_ticket_closed_message' => 'Ticket #:id ":title" đã được đóng bởi :actor.',
+
+ 'notif_ticket_transferred_subject' => 'Ticket #:id đã được chuyển đến phòng của bạn',
+ 'notif_ticket_transferred_by' => 'Người chuyển: :name',
+ 'notif_ticket_transferred_reason' => 'Lý do: :reason',
+ 'notif_ticket_transferred_message' => 'Ticket #:id ":title" đã được chuyển đến phòng của bạn bởi :sender.',
+
+ // CreateFeedback
+ 'created_via' => 'Phản ánh được tạo qua :channel',
+ 'unknown_channel' => 'kênh không xác định',
+];
diff --git a/previews/dashboard.html b/previews/dashboard.html
new file mode 100644
index 00000000..2622ddd7
--- /dev/null
+++ b/previews/dashboard.html
@@ -0,0 +1,441 @@
+
+
+
+
+
+ AfterSales CRM — Dashboard Preview
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ AD
+
+
+
Admin User
+
admin@minicrm.local
+
+
+
+
+
+
+
+
+
+
+
Dashboard
+
Welcome back, Admin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Resolved Tickets
+
156
+
+
+
+
+
+
+ +12%
+
+
vs last week
+
+
+
+
+
+
+
+
Avg Processing Time
+
45 min
+
+
+
+
+
+
+
+
+
+
+
+
+ -3
+
+
from yesterday
+
+
+
+
+
+
+
+
+
+ +2
+
+
needs attention
+
+
+
+
+
+
+
+
+
+
+
Handler Performance
+
Average processing time per handler
+
+
+ This Week
+ This Month
+ This Quarter
+
+
+
+
+
Nguyễn A
+
+
+ Fast
+
+
+
+
Trần B
+
+
+ Normal
+
+
+
+
+
+
Hoàng E
+
+
+ Alert
+
+
+
+
+
+
+
+
+
+
Tickets by Status
+
Current distribution
+
+
+
+
+
+
+
+
+
+
+ 207
+ Total
+
+
+
+
+
+
+
+
+
+
+
+
+
Tickets Awaiting Closure
+
Resolved tickets pending manager review
+
+
+ 4 tickets
+
+
+
+
+
+
+
+ #
+ Ticket
+ Customer
+ Handler
+ Department
+ Contract
+ Resolved Date
+ Action
+
+
+
+
+ #001
+
+ Khách hàng phản ánh nước nóng
+
+ Nguyễn Văn A
+
+
+
+ CSKH
+ SALE
+ 30/04/2026
+
+
+
+ Close
+
+
+
+
+ #003
+
+ Yêu cầu sửa chữa cửa sổ
+
+ Trần Thị B
+
+
+
+ Kỹ thuật
+ LEASE
+ 29/04/2026
+
+
+
+ Close
+
+
+
+
+ #005
+
+ Hỗ trợ đăng ký cư trú
+
+ Lê Minh C
+
+
+
+ Pháp lý
+ BCC
+ 28/04/2026
+
+
+
+ Close
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/previews/feedback-list.html b/previews/feedback-list.html
new file mode 100644
index 00000000..65f61b10
--- /dev/null
+++ b/previews/feedback-list.html
@@ -0,0 +1,372 @@
+
+
+
+
+
+ AfterSales CRM — Feedback List Preview
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
AD
+
Admin User
admin@minicrm.local
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 5
+ All
+
+
+ 2
+ Pending
+
+
+ 1
+ Processing
+
+
+ 1
+ Resolved
+
+
+ 1
+ Closed
+
+
+
+
+
+
+
+
+
+
+ All Channels
+ Email
+ Zalo
+ Phone
+ Document
+ In Person
+
+
+ All Departments
+ CSKH
+ Kỹ thuật
+ Kế toán
+ Pháp lý
+
+
+ All Handlers
+ Staff User
+ Manager User
+
+
+
+
+
+
+
+
+
+
+
+
+
+
#002
+
Khách hàng phản ánh về chất lượng nước
+
+
+ ESCALATED
+
+
+
Khách hàng phản ánh nước sinh hoạt có màu vàng, mùi lạ trong 3 ngày liên tiếp tại căn hộ A2-0805...
+
+
+
+ Pending
+
+
Zalo
+
CSKH
+
SALE
+
+
+ Sunrise A2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ #001
+
Khách hàng phản ánh hệ thống nước nóng không hoạt động
+
+
Khách hàng phản ánh về việc hệ thống nước nóng không hoạt động trong căn hộ A1-1205. Đã kiểm tra nhưng chưa xác định được nguyên nhân...
+
+
+
+ Processing
+
+
Email
+
Kỹ thuật
+
SALE
+
+
+ Sunrise A1
+
+
+
+ Technical
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ #004
+
Yêu cầu hỗ trợ đăng ký cư trú
+
+
Khách hàng cần hỗ trợ về thủ tục đăng ký cư trú cho căn hộ mới bàn giao...
+
+ Pending
+ In Person
+ Pháp lý
+ BCC
+ Green Valley
+
+
+
+
Lê Minh C
+
27/04/2026
+
+
+
+
+
+
+
+
+
+
+
+
+ #003
+
Yêu cầu sửa chữa hệ thống cửa sổ
+
+
Cửa sổ phòng ngủ bị kẹt không đóng được. Đã cử kỹ thuật viên sửa chữa xong...
+
+
+
+ Resolved
+
+
Phone
+
Kỹ thuật
+
LEASE
+
Ocean Tower
+
+
+
+
Phạm Thị D
+
29/04/2026
+
+
+
+
+
+
+
+
+
+
+
+
+ #005
+
Phản ánh về phí quản lý
+
+
Khách hàng thắc mắc về phí quản lý tháng 3/2026. Đã giải thích và khách hàng đồng ý...
+
+ Closed
+ Email
+ Kế toán
+ SALE
+
+
+
+
Hoàng Văn E
+
25/04/2026
+
+
+
+
+
+
+
+
+
+
+
Showing 1-5 of 5 results
+
+ Previous
+ 1
+ Next
+
+
+
+
+
+
+
+
diff --git a/resources/views/filament/resources/feedback/attachment-links.blade.php b/resources/views/filament/resources/feedback/attachment-links.blade.php
index bc64d340..64530575 100644
--- a/resources/views/filament/resources/feedback/attachment-links.blade.php
+++ b/resources/views/filament/resources/feedback/attachment-links.blade.php
@@ -1,6 +1,6 @@
@if(empty($links) || count($links) === 0)
-
No attachments.
+
{{ __('app.blade_no_attachments') }}
@else
@foreach($links as $link)
@@ -38,11 +38,11 @@
{{ $link['size'] }}
@if(($link['collection'] ?? '') === 'proof_of_resolution')
- Proof of Resolution
+ {{ __('app.blade_proof_of_resolution') }}
@elseif(($link['collection'] ?? '') === 'customer_evidence')
- Customer Evidence
+ {{ __('app.blade_customer_evidence') }}
@else
- {{ $link['collection'] ?? 'General' }}
+ {{ $link['collection'] ?? __('app.collection_general') }}
@endif
@@ -51,11 +51,11 @@
- Open
+ {{ __('app.blade_open') }}
@endforeach
- Click to open. Links expire after 60 minutes.
+ {{ __('app.blade_links_expire') }}
@endif
diff --git a/resources/views/filament/resources/feedback/feedback-attachments.blade.php b/resources/views/filament/resources/feedback/feedback-attachments.blade.php
index e205c3cb..0e702ff2 100644
--- a/resources/views/filament/resources/feedback/feedback-attachments.blade.php
+++ b/resources/views/filament/resources/feedback/feedback-attachments.blade.php
@@ -10,9 +10,9 @@
- Attachments
+ {{ __('app.attachments') }}
-
{{ $attachments->count() }} file(s)
+
{{ __('app.blade_file_count', ['count' => $attachments->count()]) }}
@@ -45,11 +45,11 @@
{{ $sizeKb }}
@if($attachment->collection === 'proof_of_resolution')
- Proof
+ {{ __('app.blade_proof') }}
@elseif($attachment->collection === 'customer_evidence')
- Evidence
+ {{ __('app.blade_evidence') }}
@else
- General
+ {{ __('app.collection_general') }}
@endif
diff --git a/resources/views/filament/resources/feedback/similar-cases.blade.php b/resources/views/filament/resources/feedback/similar-cases.blade.php
index e02aff49..cf5378d2 100644
--- a/resources/views/filament/resources/feedback/similar-cases.blade.php
+++ b/resources/views/filament/resources/feedback/similar-cases.blade.php
@@ -19,19 +19,19 @@
- Similar Cases
+ {{ __('app.blade_similar_cases') }}
-
Matching tags: {{ $record->tags->pluck('name')->implode(', ') }}
+
{{ __('app.blade_matching_tags') }} {{ $record->tags->pluck('name')->implode(', ') }}
- Title
- Customer
- Status
- Handler
- Date
+ {{ __('app.blade_title') }}
+ {{ __('app.blade_customer') }}
+ {{ __('app.blade_status') }}
+ {{ __('app.blade_handler') }}
+ {{ __('app.blade_date') }}
@@ -47,21 +47,21 @@
@switch($case->status)
@case('pending')
- Pending
+ {{ __('app.status_pending') }}
@break
@case('processing')
- Processing
+ {{ __('app.status_processing') }}
@break
@case('resolved')
- Resolved
+ {{ __('app.status_resolved') }}
@break
@case('closed')
- Closed
+ {{ __('app.status_closed') }}
@break
@endswitch
- {{ $case->assignedTo?->name ?? 'Unassigned' }}
+ {{ $case->assignedTo?->name ?? __('app.unassigned') }}
{{ $case->created_at->format('d/m/Y') }}
diff --git a/webappUI/dashboard.html b/webappUI/dashboard.html
new file mode 100644
index 00000000..6195625c
--- /dev/null
+++ b/webappUI/dashboard.html
@@ -0,0 +1,417 @@
+
+
+
+
+
+ Dashboard — AfterSales CRM
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
42
+
Resolved Tickets
+
+
+
+
+
2.5h
+
Avg Processing Time
+
+
+
+
+
8
+
Pending Tickets
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 45m
+ Staff A
+
+
+ 62m
+ Staff B
+
+
+ 31m
+ Staff C
+
+
+ 52m
+ Staff D
+
+
+ 38m
+ Staff E
+
+
+ 68m
+ Staff F
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Title
+ Customer
+ Product
+ Contract
+ Channel
+ Status
+ Department
+ Assigned To
+ Created
+
+
+
+
+ Leak in bathroom
+
+
+
+ Sunrise A1
+ Sale
+ chat Zalo
+ Pending
+ CSKH
+
+
+
+ 2 hours ago
+
+
+ AC not working
+
+
+
+ Green Valley
+ Lease
+ mail Email
+ Processing
+ Kỹ thuật
+
+
+
+ 5 hours ago
+
+
+
+
+
+
+
+
+ Ocean Tower
+ BCC
+ phone Phone
+ Escalated
+ CSKH
+
+
+
+ 1 day ago
+
+
+ Paint crack in living room
+
+
+
+ Park Hill
+ Sale
+ description Document
+ Resolved
+ Kỹ thuật
+
+
+
+ 3 days ago
+
+
+ Window seal broken
+
+
+
+ Sunrise A2
+ Lease
+ person In Person
+ Closed
+ CSKH
+
+
+
+ 1 week ago
+
+
+
+
+
+
+
+
+
+
+
diff --git a/webappUI/feedback-detail.html b/webappUI/feedback-detail.html
new file mode 100644
index 00000000..30c8acd6
--- /dev/null
+++ b/webappUI/feedback-detail.html
@@ -0,0 +1,602 @@
+
+
+
+
+
+ Ticket #12 — AfterSales CRM
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ arrow_back
+
+
Leak in bathroom
+
Pending
+
+ warning
+ Escalated
+
+
+
+ Ticket #12
+ •
+ Created 2 hours ago
+ •
+ By Nguyễn A
+ •
+ Via Zalo
+
+
+
+
+ edit
+ Edit
+
+
+ print
+ Print
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Content
+
+ Water is leaking from the bathroom ceiling. The leak started 3 days ago and has gotten worse. There's water damage on the ceiling and water dripping onto the floor. I've placed a bucket but need urgent repair. The apartment is Sunrise A1, Floor 12, Unit 1205.
+
+
+
+
+
+
Customer
+
+
NA
+
+
Nguyễn A
+
nguyen.a@email.com
+
+
+
+
+
Product
+
+
+ apartment
+
+
+
Sunrise A1
+
Floor 12, Unit 1205
+
+
+
+
+
+
+
Tags
+
+ Plumbing
+ Urgent
+ Water Damage
+
+
+
+
+
+
+
+
+
+
+
+
+
+ edit_note
+
+
+
+
+ Contacted customer to schedule inspection. Customer available tomorrow 2-4 PM. Will send maintenance team to check the ceiling leak. Need to coordinate with building management for access to the unit above.
+
+
+ attach_file
+ inspection_photo.jpg (2.3 MB)
+
+
+
+
+
+
+
+ sync
+
+
+
+
+
+ Pending
+ arrow_forward
+ Processing
+
+
+
+
+
+
+
+
+ person_add
+
+
+
+
+ Assigned to Staff User for handling
+
+
+
+
+
+
+
+ swap_horiz
+
+
+
+
+
+ Kỹ thuật
+ arrow_forward
+ CSKH
+
+
+ "Đã kiểm tra kỹ thuật, cần CSKH liên hệ khách hàng để sắp lịch sửa chữa"
+
+
+
+
+
+
+
+
+ edit_note
+
+
+
+
+ Feedback created via Zalo channel. Auto-assigned to CSKH department.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ File
+ Collection
+ Size
+ Uploaded By
+ Date
+
+
+
+
+
+
+
+
+ image
+
+
+
inspection_photo.jpg
+
image/jpeg
+
+
+
+ customer_evidence
+ 2.3 MB
+
+
+
+ 2h ago
+
+
+ download
+
+
+
+
+
+
+
+ description
+
+
+
repair_estimate.pdf
+
application/pdf
+
+
+
+ general
+ 1.1 MB
+
+
+
+ 1d ago
+
+
+ download
+
+
+
+
+
+
+
+ check_circle
+
+
+
proof_fixed.jpg
+
image/jpeg
+
+
+
+ proof_of_resolution
+ 3.8 MB
+
+
+
+ 3d ago
+
+
+ download
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Kỹ thuật
+
arrow_forward
+
CSKH
+
— Admin User
+
+
+
+
CSKH
+
arrow_forward
+
Kỹ thuật
+
— Manager User
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/webappUI/feedback-list.html b/webappUI/feedback-list.html
new file mode 100644
index 00000000..e57014bc
--- /dev/null
+++ b/webappUI/feedback-list.html
@@ -0,0 +1,429 @@
+
+
+
+
+
+ Feedbacks — AfterSales CRM
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Feedbacks
+
Manage customer feedback and support tickets
+
+
+ add
+ New Feedback
+
+
+
+
+
+
+ list
+ All
+ 24
+
+
+ schedule
+ Pending
+ 8
+
+
+ autorenew
+ Processing
+ 6
+
+
+ check_circle
+ Resolved
+ 5
+
+
+ cancel
+ Closed
+ 5
+
+
+ warning
+ Escalated
+ 3
+
+
+
+
+
+
+ search
+
+
+
+ filter_list
+ All Status
+ expand_more
+
+
+ inbox_stack
+ All Channels
+ expand_more
+
+
+ apartment
+ All Departments
+ expand_more
+
+
+ person
+ All Handlers
+ expand_more
+
+
+ download
+ Export
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/webappUI/login.html b/webappUI/login.html
new file mode 100644
index 00000000..039543c5
--- /dev/null
+++ b/webappUI/login.html
@@ -0,0 +1,127 @@
+
+
+
+
+
+ Login — AfterSales CRM
+
+
+
+
+
+
+
+
+ support_agent
+
+
AfterSales CRM
+
Real-estate after-sales customer care system. Manage feedbacks, track tickets, and deliver exceptional service.
+
+
+
+
3,658
+
Records Imported
+
+
+
+
+
+
+
+ format_quote
+ Customer Feedback
+
+
+ "The support team resolved my issue within 24 hours. Excellent service and very professional follow-up."
+
+
+
NA
+
+
Nguyễn A
+
Sunrise A1 Resident
+
+
+
+
+
+
+
+
+
+
+
diff --git a/webappUI/shared.css b/webappUI/shared.css
new file mode 100644
index 00000000..1a2f7807
--- /dev/null
+++ b/webappUI/shared.css
@@ -0,0 +1,1363 @@
+/* ================================================================
+ AfterSales CRM — Design System v2
+ Material Design 3 inspired, Real-estate CRM
+ ================================================================ */
+
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Manrope:wght@500;600;700;800&family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap');
+
+/* ---------- CSS Variables (Design Tokens) ---------- */
+:root {
+ /* Primary palette — Indigo */
+ --crm-primary: #1a56db;
+ --crm-primary-hover: #1e40af;
+ --crm-primary-light: #e8effc;
+ --crm-primary-container: #dbeafe;
+ --crm-on-primary: #ffffff;
+ --crm-on-primary-container: #1e3a5f;
+
+ /* Secondary palette — Slate */
+ --crm-secondary: #64748b;
+ --crm-secondary-container: #f1f5f9;
+ --crm-on-secondary: #ffffff;
+ --crm-on-secondary-container: #334155;
+
+ /* Accent */
+ --crm-accent: #7c3aed;
+ --crm-accent-light: #ede9fe;
+
+ /* Surface / Background */
+ --crm-background: #f1f5f9;
+ --crm-surface: #ffffff;
+ --crm-surface-dim: #f8fafc;
+ --crm-surface-container: #f1f5f9;
+ --crm-surface-container-high: #e2e8f0;
+ --crm-surface-hover: #e8effc;
+
+ /* Text */
+ --crm-text-primary: #0f172a;
+ --crm-text-secondary: #475569;
+ --crm-text-tertiary: #94a3b8;
+ --crm-text-inverse: #ffffff;
+
+ /* Borders */
+ --crm-border: #e2e8f0;
+ --crm-border-strong: #cbd5e1;
+ --crm-border-focus: #1a56db;
+
+ /* Status colors */
+ --crm-pending: #d97706;
+ --crm-pending-bg: #fffbeb;
+ --crm-pending-border: #fde68a;
+ --crm-processing: #2563eb;
+ --crm-processing-bg: #eff6ff;
+ --crm-processing-border: #bfdbfe;
+ --crm-resolved: #059669;
+ --crm-resolved-bg: #ecfdf5;
+ --crm-resolved-border: #a7f3d0;
+ --crm-closed: #6b7280;
+ --crm-closed-bg: #f9fafb;
+ --crm-closed-border: #e5e7eb;
+ --crm-escalated: #dc2626;
+ --crm-escalated-bg: #fef2f2;
+
+ /* Spacing */
+ --crm-nav-height: 64px;
+ --crm-sidebar-width: 260px;
+ --crm-radius-sm: 6px;
+ --crm-radius-md: 10px;
+ --crm-radius-lg: 14px;
+ --crm-radius-xl: 18px;
+ --crm-radius-full: 9999px;
+
+ /* Shadows */
+ --crm-shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
+ --crm-shadow-md: 0 4px 6px -1px rgba(0,0,0,0.07), 0 2px 4px -2px rgba(0,0,0,0.05);
+ --crm-shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.08), 0 4px 6px -4px rgba(0,0,0,0.04);
+ --crm-shadow-xl: 0 20px 25px -5px rgba(0,0,0,0.1), 0 8px 10px -6px rgba(0,0,0,0.06);
+
+ /* Fonts */
+ --font-heading: 'Manrope', sans-serif;
+ --font-body: 'Inter', sans-serif;
+}
+
+/* ---------- Reset ---------- */
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+body {
+ font-family: var(--font-body);
+ background: var(--crm-background);
+ color: var(--crm-text-primary);
+ line-height: 1.6;
+ -webkit-font-smoothing: antialiased;
+}
+
+/* ---------- Layout ---------- */
+.app-layout {
+ display: flex;
+ min-height: 100vh;
+}
+
+/* Sidebar */
+.sidebar {
+ width: var(--crm-sidebar-width);
+ background: var(--crm-surface);
+ border-right: 1px solid var(--crm-border);
+ display: flex;
+ flex-direction: column;
+ position: fixed;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ z-index: 100;
+ transition: transform 0.3s ease;
+}
+
+.sidebar-brand {
+ padding: 20px 24px;
+ border-bottom: 1px solid var(--crm-border);
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.sidebar-brand-icon {
+ width: 36px;
+ height: 36px;
+ background: linear-gradient(135deg, var(--crm-primary), #3b82f6);
+ border-radius: var(--crm-radius-md);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ font-size: 20px;
+}
+
+.sidebar-brand-text {
+ font-family: var(--font-heading);
+ font-weight: 800;
+ font-size: 18px;
+ color: var(--crm-text-primary);
+ letter-spacing: -0.02em;
+}
+
+.sidebar-brand-badge {
+ font-size: 10px;
+ font-weight: 600;
+ background: var(--crm-primary-light);
+ color: var(--crm-primary);
+ padding: 2px 8px;
+ border-radius: var(--crm-radius-full);
+ margin-left: auto;
+}
+
+.sidebar-nav {
+ flex: 1;
+ padding: 16px 12px;
+ overflow-y: auto;
+}
+
+.sidebar-group {
+ margin-bottom: 24px;
+}
+
+.sidebar-group-label {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--crm-text-tertiary);
+ padding: 0 12px;
+ margin-bottom: 8px;
+}
+
+.sidebar-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 12px;
+ border-radius: var(--crm-radius-md);
+ color: var(--crm-text-secondary);
+ text-decoration: none;
+ font-size: 14px;
+ font-weight: 500;
+ transition: all 0.15s ease;
+ cursor: pointer;
+ position: relative;
+}
+
+.sidebar-item:hover {
+ background: var(--crm-surface-container);
+ color: var(--crm-text-primary);
+}
+
+.sidebar-item.active {
+ background: var(--crm-primary-light);
+ color: var(--crm-primary);
+ font-weight: 600;
+}
+
+.sidebar-item.active::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 3px;
+ height: 20px;
+ background: var(--crm-primary);
+ border-radius: 0 4px 4px 0;
+}
+
+.sidebar-item-icon {
+ width: 20px;
+ height: 20px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 20px;
+ flex-shrink: 0;
+}
+
+.sidebar-item-badge {
+ margin-left: auto;
+ background: var(--crm-escalated);
+ color: white;
+ font-size: 11px;
+ font-weight: 700;
+ padding: 1px 7px;
+ border-radius: var(--crm-radius-full);
+ min-width: 20px;
+ text-align: center;
+}
+
+.sidebar-footer {
+ padding: 16px;
+ border-top: 1px solid var(--crm-border);
+}
+
+.sidebar-user {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 8px;
+ border-radius: var(--crm-radius-md);
+ cursor: pointer;
+ transition: background 0.15s;
+}
+
+.sidebar-user:hover {
+ background: var(--crm-surface-container);
+}
+
+.sidebar-user-avatar {
+ width: 36px;
+ height: 36px;
+ border-radius: var(--crm-radius-full);
+ background: linear-gradient(135deg, #6366f1, #8b5cf6);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ font-weight: 700;
+ font-size: 14px;
+}
+
+.sidebar-user-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.sidebar-user-name {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--crm-text-primary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.sidebar-user-role {
+ font-size: 11px;
+ color: var(--crm-text-tertiary);
+ text-transform: capitalize;
+}
+
+/* Main Content */
+.main-content {
+ flex: 1;
+ margin-left: var(--crm-sidebar-width);
+ min-height: 100vh;
+}
+
+.topbar {
+ height: var(--crm-nav-height);
+ background: var(--crm-surface);
+ border-bottom: 1px solid var(--crm-border);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 32px;
+ position: sticky;
+ top: 0;
+ z-index: 50;
+ backdrop-filter: blur(8px);
+ background: rgba(255,255,255,0.9);
+}
+
+.topbar-left {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.breadcrumb {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 13px;
+ color: var(--crm-text-tertiary);
+}
+
+.breadcrumb a {
+ color: var(--crm-text-tertiary);
+ text-decoration: none;
+ transition: color 0.15s;
+}
+
+.breadcrumb a:hover { color: var(--crm-primary); }
+.breadcrumb-sep { font-size: 16px; }
+.breadcrumb-current { color: var(--crm-text-primary); font-weight: 600; }
+
+.topbar-right {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.topbar-btn {
+ width: 40px;
+ height: 40px;
+ border-radius: var(--crm-radius-md);
+ border: none;
+ background: transparent;
+ color: var(--crm-text-secondary);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.15s;
+ position: relative;
+}
+
+.topbar-btn:hover {
+ background: var(--crm-surface-container);
+ color: var(--crm-text-primary);
+}
+
+.topbar-btn .material-symbols-outlined { font-size: 22px; }
+
+.topbar-btn-badge {
+ position: absolute;
+ top: 6px;
+ right: 6px;
+ width: 8px;
+ height: 8px;
+ background: var(--crm-escalated);
+ border-radius: 50%;
+ border: 2px solid var(--crm-surface);
+}
+
+.page-content {
+ padding: 28px 32px;
+ max-width: 1400px;
+}
+
+/* ---------- Page Header ---------- */
+.page-header {
+ margin-bottom: 28px;
+}
+
+.page-title {
+ font-family: var(--font-heading);
+ font-size: 28px;
+ font-weight: 800;
+ color: var(--crm-text-primary);
+ letter-spacing: -0.02em;
+}
+
+.page-subtitle {
+ font-size: 14px;
+ color: var(--crm-text-secondary);
+ margin-top: 4px;
+}
+
+/* ---------- Stat Cards ---------- */
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 20px;
+ margin-bottom: 28px;
+}
+
+.stat-card {
+ background: var(--crm-surface);
+ border-radius: var(--crm-radius-lg);
+ padding: 24px;
+ border: 1px solid var(--crm-border);
+ position: relative;
+ overflow: hidden;
+ transition: all 0.2s ease;
+}
+
+.stat-card:hover {
+ box-shadow: var(--crm-shadow-md);
+ transform: translateY(-2px);
+}
+
+.stat-card::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 3px;
+}
+
+.stat-card--success::before { background: linear-gradient(90deg, #059669, #34d399); }
+.stat-card--warning::before { background: linear-gradient(90deg, #d97706, #fbbf24); }
+.stat-card--info::before { background: linear-gradient(90deg, #2563eb, #60a5fa); }
+.stat-card--danger::before { background: linear-gradient(90deg, #dc2626, #f87171); }
+
+.stat-card-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 16px;
+}
+
+.stat-card-icon {
+ width: 44px;
+ height: 44px;
+ border-radius: var(--crm-radius-md);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 22px;
+}
+
+.stat-card--success .stat-card-icon { background: var(--crm-resolved-bg); color: var(--crm-resolved); }
+.stat-card--warning .stat-card-icon { background: var(--crm-pending-bg); color: var(--crm-pending); }
+.stat-card--info .stat-card-icon { background: var(--crm-processing-bg); color: var(--crm-processing); }
+.stat-card--danger .stat-card-icon { background: var(--crm-escalated-bg); color: var(--crm-escalated); }
+
+.stat-card-trend {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 12px;
+ font-weight: 600;
+ padding: 3px 8px;
+ border-radius: var(--crm-radius-full);
+}
+
+.stat-card-trend--up { background: var(--crm-resolved-bg); color: var(--crm-resolved); }
+.stat-card-trend--down { background: var(--crm-escalated-bg); color: var(--crm-escalated); }
+
+.stat-card-value {
+ font-family: var(--font-heading);
+ font-size: 36px;
+ font-weight: 800;
+ color: var(--crm-text-primary);
+ line-height: 1;
+ letter-spacing: -0.02em;
+}
+
+.stat-card-label {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--crm-text-secondary);
+ margin-top: 6px;
+}
+
+/* ---------- Cards ---------- */
+.card {
+ background: var(--crm-surface);
+ border-radius: var(--crm-radius-lg);
+ border: 1px solid var(--crm-border);
+ overflow: hidden;
+}
+
+.card-header {
+ padding: 20px 24px;
+ border-bottom: 1px solid var(--crm-border);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.card-title {
+ font-family: var(--font-heading);
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--crm-text-primary);
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.card-title-icon {
+ width: 32px;
+ height: 32px;
+ border-radius: var(--crm-radius-sm);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 18px;
+ background: var(--crm-primary-light);
+ color: var(--crm-primary);
+}
+
+.card-body { padding: 24px; }
+.card-body--compact { padding: 0; }
+
+/* ---------- Buttons ---------- */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 10px 20px;
+ border-radius: var(--crm-radius-md);
+ font-family: var(--font-body);
+ font-size: 14px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ border: none;
+ text-decoration: none;
+ white-space: nowrap;
+}
+
+.btn:hover { transform: translateY(-1px); }
+.btn:active { transform: translateY(0); }
+
+.btn--primary {
+ background: var(--crm-primary);
+ color: var(--crm-on-primary);
+ box-shadow: 0 1px 3px rgba(26,86,219,0.3);
+}
+.btn--primary:hover { background: var(--crm-primary-hover); box-shadow: 0 4px 12px rgba(26,86,219,0.3); }
+
+.btn--secondary {
+ background: var(--crm-surface);
+ color: var(--crm-text-secondary);
+ border: 1px solid var(--crm-border);
+}
+.btn--secondary:hover { background: var(--crm-surface-container); border-color: var(--crm-border-strong); }
+
+.btn--danger {
+ background: var(--crm-surface);
+ color: var(--crm-escalated);
+ border: 1px solid rgba(220,38,38,0.3);
+}
+.btn--danger:hover { background: var(--crm-escalated-bg); }
+
+.btn--ghost {
+ background: transparent;
+ color: var(--crm-text-secondary);
+}
+.btn--ghost:hover { background: var(--crm-surface-container); color: var(--crm-text-primary); }
+
+.btn--sm { padding: 6px 14px; font-size: 13px; }
+.btn--lg { padding: 12px 28px; font-size: 15px; }
+
+.btn .material-symbols-outlined { font-size: 18px; }
+
+.btn-group { display: flex; gap: 8px; }
+
+/* ---------- Badges ---------- */
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 3px 10px;
+ border-radius: var(--crm-radius-full);
+ font-size: 12px;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.badge--pending { background: var(--crm-pending-bg); color: var(--crm-pending); border: 1px solid var(--crm-pending-border); }
+.badge--processing { background: var(--crm-processing-bg); color: var(--crm-processing); border: 1px solid var(--crm-processing-border); }
+.badge--resolved { background: var(--crm-resolved-bg); color: var(--crm-resolved); border: 1px solid var(--crm-resolved-border); }
+.badge--closed { background: var(--crm-closed-bg); color: var(--crm-closed); border: 1px solid var(--crm-closed-border); }
+.badge--escalated { background: var(--crm-escalated-bg); color: var(--crm-escalated); border: 1px solid rgba(220,38,38,0.2); }
+
+.badge--sale { background: #ecfdf5; color: #059669; border: 1px solid #a7f3d0; }
+.badge--lease { background: #eff6ff; color: #2563eb; border: 1px solid #bfdbfe; }
+.badge--bcc { background: #fffbeb; color: #d97706; border: 1px solid #fde68a; }
+
+.badge--dot::before {
+ content: '';
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: currentColor;
+}
+
+/* ---------- Tables ---------- */
+.table-container {
+ overflow-x: auto;
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+thead { background: var(--crm-surface-dim); }
+
+th {
+ padding: 12px 16px;
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--crm-text-secondary);
+ text-align: left;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ border-bottom: 1px solid var(--crm-border);
+ white-space: nowrap;
+}
+
+td {
+ padding: 14px 16px;
+ font-size: 14px;
+ color: var(--crm-text-primary);
+ border-bottom: 1px solid var(--crm-border);
+ vertical-align: middle;
+}
+
+tbody tr {
+ transition: background 0.12s ease;
+}
+
+tbody tr:hover { background: var(--crm-surface-hover); }
+tbody tr:nth-child(even) { background: var(--crm-surface-dim); }
+tbody tr:nth-child(even):hover { background: var(--crm-surface-hover); }
+
+.table-link {
+ color: var(--crm-primary);
+ text-decoration: none;
+ font-weight: 600;
+}
+.table-link:hover { text-decoration: underline; }
+
+.table-meta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.table-avatar {
+ width: 28px;
+ height: 28px;
+ border-radius: var(--crm-radius-full);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 11px;
+ font-weight: 700;
+ color: white;
+ flex-shrink: 0;
+}
+
+.table-avatar--blue { background: linear-gradient(135deg, #3b82f6, #6366f1); }
+.table-avatar--green { background: linear-gradient(135deg, #10b981, #059669); }
+.table-avatar--purple { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
+.table-avatar--amber { background: linear-gradient(135deg, #f59e0b, #d97706); }
+
+.table-empty {
+ text-align: center;
+ padding: 48px 24px;
+ color: var(--crm-text-tertiary);
+}
+
+.table-empty-icon {
+ font-size: 48px;
+ margin-bottom: 12px;
+ opacity: 0.5;
+}
+
+/* ---------- Filter Bar ---------- */
+.filter-bar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 20px;
+ flex-wrap: wrap;
+}
+
+.search-input {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ background: var(--crm-surface);
+ border: 1px solid var(--crm-border);
+ border-radius: var(--crm-radius-md);
+ padding: 0 14px;
+ height: 40px;
+ min-width: 280px;
+ transition: all 0.15s;
+}
+
+.search-input:focus-within {
+ border-color: var(--crm-primary);
+ box-shadow: 0 0 0 3px rgba(26,86,219,0.1);
+}
+
+.search-input .material-symbols-outlined { font-size: 20px; color: var(--crm-text-tertiary); }
+
+.search-input input {
+ border: none;
+ background: transparent;
+ outline: none;
+ font-size: 14px;
+ color: var(--crm-text-primary);
+ flex: 1;
+ font-family: var(--font-body);
+}
+
+.search-input input::placeholder { color: var(--crm-text-tertiary); }
+
+.filter-select {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ background: var(--crm-surface);
+ border: 1px solid var(--crm-border);
+ border-radius: var(--crm-radius-md);
+ padding: 0 12px;
+ height: 40px;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--crm-text-secondary);
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.filter-select:hover { border-color: var(--crm-border-strong); background: var(--crm-surface-dim); }
+
+.filter-select .material-symbols-outlined { font-size: 18px; }
+
+/* ---------- Charts placeholder ---------- */
+.chart-placeholder {
+ height: 280px;
+ display: flex;
+ align-items: flex-end;
+ gap: 12px;
+ padding: 20px 0;
+}
+
+.chart-bar {
+ flex: 1;
+ border-radius: var(--crm-radius-sm) var(--crm-radius-sm) 0 0;
+ position: relative;
+ transition: all 0.3s ease;
+ cursor: pointer;
+ min-width: 40px;
+}
+
+.chart-bar:hover { opacity: 0.85; transform: scaleY(1.02); transform-origin: bottom; }
+
+.chart-bar-label {
+ position: absolute;
+ bottom: -28px;
+ left: 50%;
+ transform: translateX(-50%);
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--crm-text-tertiary);
+ white-space: nowrap;
+}
+
+.chart-bar-value {
+ position: absolute;
+ top: -24px;
+ left: 50%;
+ transform: translateX(-50%);
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--crm-text-primary);
+ white-space: nowrap;
+}
+
+/* ---------- Interaction Timeline ---------- */
+.timeline {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+
+.timeline-item {
+ display: flex;
+ gap: 16px;
+ padding: 20px 0;
+ position: relative;
+}
+
+.timeline-item:not(:last-child)::after {
+ content: '';
+ position: absolute;
+ left: 19px;
+ top: 56px;
+ bottom: 0;
+ width: 2px;
+ background: var(--crm-border);
+}
+
+.timeline-dot {
+ width: 40px;
+ height: 40px;
+ border-radius: var(--crm-radius-full);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ font-size: 18px;
+ z-index: 1;
+}
+
+.timeline-dot--note { background: var(--crm-processing-bg); color: var(--crm-processing); }
+.timeline-dot--status { background: var(--crm-resolved-bg); color: var(--crm-resolved); }
+.timeline-dot--assignment { background: var(--crm-accent-light); color: var(--crm-accent); }
+.timeline-dot--transfer { background: var(--crm-pending-bg); color: var(--crm-pending); }
+
+.timeline-content { flex: 1; min-width: 0; }
+
+.timeline-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 6px;
+ flex-wrap: wrap;
+}
+
+.timeline-author {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--crm-text-primary);
+}
+
+.timeline-time {
+ font-size: 12px;
+ color: var(--crm-text-tertiary);
+}
+
+.timeline-type {
+ font-size: 11px;
+ font-weight: 600;
+ padding: 2px 8px;
+ border-radius: var(--crm-radius-full);
+ background: var(--crm-surface-container);
+ color: var(--crm-text-secondary);
+}
+
+.timeline-body {
+ font-size: 14px;
+ color: var(--crm-text-secondary);
+ line-height: 1.6;
+}
+
+.timeline-attachment {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ margin-top: 8px;
+ padding: 6px 12px;
+ background: var(--crm-surface-dim);
+ border: 1px solid var(--crm-border);
+ border-radius: var(--crm-radius-sm);
+ font-size: 12px;
+ color: var(--crm-primary);
+ text-decoration: none;
+ transition: all 0.15s;
+}
+
+.timeline-attachment:hover {
+ background: var(--crm-primary-light);
+ border-color: var(--crm-primary);
+}
+
+.timeline-attachment .material-symbols-outlined { font-size: 16px; }
+
+/* ---------- Detail Sidebar ---------- */
+.detail-grid {
+ display: grid;
+ grid-template-columns: 1fr 340px;
+ gap: 24px;
+}
+
+.detail-main { min-width: 0; }
+
+.detail-sidebar {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+/* ---------- Action Card ---------- */
+.action-card {
+ background: var(--crm-surface);
+ border-radius: var(--crm-radius-lg);
+ border: 1px solid var(--crm-border);
+ padding: 20px;
+}
+
+.action-card-title {
+ font-family: var(--font-heading);
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--crm-text-primary);
+ margin-bottom: 16px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.action-card-title .material-symbols-outlined { font-size: 20px; color: var(--crm-primary); }
+
+.action-btn {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px 16px;
+ border-radius: var(--crm-radius-md);
+ border: 1px solid var(--crm-border);
+ background: var(--crm-surface);
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--crm-text-primary);
+ cursor: pointer;
+ transition: all 0.15s;
+ margin-bottom: 8px;
+}
+
+.action-btn:last-child { margin-bottom: 0; }
+
+.action-btn:hover {
+ background: var(--crm-primary-light);
+ border-color: var(--crm-primary);
+ color: var(--crm-primary);
+}
+
+.action-btn--danger:hover {
+ background: var(--crm-escalated-bg);
+ border-color: var(--crm-escalated);
+ color: var(--crm-escalated);
+}
+
+.action-btn .material-symbols-outlined { font-size: 20px; }
+
+/* ---------- Info List ---------- */
+.info-list {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.info-item {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+}
+
+.info-item-icon {
+ width: 32px;
+ height: 32px;
+ border-radius: var(--crm-radius-sm);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 18px;
+ background: var(--crm-surface-container);
+ color: var(--crm-text-secondary);
+ flex-shrink: 0;
+}
+
+.info-item-content { flex: 1; min-width: 0; }
+
+.info-item-label {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--crm-text-tertiary);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.info-item-value {
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--crm-text-primary);
+ margin-top: 2px;
+}
+
+/* ---------- Transfer Log ---------- */
+.transfer-log {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.transfer-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 12px;
+ background: var(--crm-surface-dim);
+ border-radius: var(--crm-radius-sm);
+ font-size: 13px;
+}
+
+.transfer-arrow {
+ color: var(--crm-text-tertiary);
+ font-size: 18px;
+}
+
+.transfer-dept {
+ font-weight: 600;
+ color: var(--crm-text-primary);
+ padding: 2px 8px;
+ background: var(--crm-surface);
+ border-radius: var(--crm-radius-sm);
+ border: 1px solid var(--crm-border);
+}
+
+.transfer-meta {
+ margin-left: auto;
+ font-size: 11px;
+ color: var(--crm-text-tertiary);
+ text-align: right;
+}
+
+/* ---------- Forms ---------- */
+.form-group {
+ margin-bottom: 20px;
+}
+
+.form-label {
+ display: block;
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--crm-text-primary);
+ margin-bottom: 6px;
+}
+
+.form-input {
+ width: 100%;
+ padding: 10px 14px;
+ border: 1px solid var(--crm-border);
+ border-radius: var(--crm-radius-md);
+ font-size: 14px;
+ font-family: var(--font-body);
+ color: var(--crm-text-primary);
+ background: var(--crm-surface);
+ transition: all 0.15s;
+ outline: none;
+}
+
+.form-input:focus {
+ border-color: var(--crm-primary);
+ box-shadow: 0 0 0 3px rgba(26,86,219,0.1);
+}
+
+.form-input::placeholder { color: var(--crm-text-tertiary); }
+
+textarea.form-input { resize: vertical; min-height: 100px; line-height: 1.6; }
+
+select.form-input {
+ cursor: pointer;
+ appearance: none;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 12px center;
+ padding-right: 40px;
+}
+
+.form-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+}
+
+.form-toggle {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ cursor: pointer;
+}
+
+.toggle-switch {
+ width: 44px;
+ height: 24px;
+ background: var(--crm-border-strong);
+ border-radius: 12px;
+ position: relative;
+ transition: background 0.2s;
+}
+
+.toggle-switch::after {
+ content: '';
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 20px;
+ height: 20px;
+ background: white;
+ border-radius: 50%;
+ transition: transform 0.2s;
+ box-shadow: var(--crm-shadow-sm);
+}
+
+.toggle-switch.active { background: var(--crm-primary); }
+.toggle-switch.active::after { transform: translateX(20px); }
+
+.toggle-label { font-size: 14px; font-weight: 500; color: var(--crm-text-primary); }
+
+/* ---------- Login Page ---------- */
+.login-layout {
+ min-height: 100vh;
+ display: flex;
+}
+
+.login-left {
+ flex: 1;
+ background: linear-gradient(135deg, #1a56db 0%, #3b82f6 50%, #6366f1 100%);
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ padding: 48px;
+ position: relative;
+ overflow: hidden;
+}
+
+.login-left::before {
+ content: '';
+ position: absolute;
+ top: -50%;
+ right: -50%;
+ width: 100%;
+ height: 100%;
+ background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
+}
+
+.login-left-content {
+ position: relative;
+ z-index: 1;
+ text-align: center;
+ color: white;
+ max-width: 400px;
+}
+
+.login-left-icon {
+ width: 80px;
+ height: 80px;
+ background: rgba(255,255,255,0.2);
+ border-radius: var(--crm-radius-xl);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin: 0 auto 24px;
+ font-size: 40px;
+ backdrop-filter: blur(10px);
+}
+
+.login-left h1 {
+ font-family: var(--font-heading);
+ font-size: 32px;
+ font-weight: 800;
+ margin-bottom: 12px;
+ letter-spacing: -0.02em;
+}
+
+.login-left p {
+ font-size: 16px;
+ opacity: 0.9;
+ line-height: 1.6;
+}
+
+.login-right {
+ width: 480px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ padding: 48px;
+ background: var(--crm-surface);
+}
+
+.login-form-container {
+ width: 100%;
+ max-width: 360px;
+}
+
+.login-form-header {
+ margin-bottom: 32px;
+}
+
+.login-form-header h2 {
+ font-family: var(--font-heading);
+ font-size: 24px;
+ font-weight: 800;
+ color: var(--crm-text-primary);
+ margin-bottom: 8px;
+}
+
+.login-form-header p {
+ font-size: 14px;
+ color: var(--crm-text-secondary);
+}
+
+.login-form .form-group { margin-bottom: 20px; }
+
+.login-form .btn--primary {
+ width: 100%;
+ padding: 12px;
+ font-size: 15px;
+}
+
+.login-divider {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ margin: 24px 0;
+ color: var(--crm-text-tertiary);
+ font-size: 12px;
+}
+
+.login-divider::before,
+.login-divider::after {
+ content: '';
+ flex: 1;
+ height: 1px;
+ background: var(--crm-border);
+}
+
+.login-demo {
+ background: var(--crm-surface-dim);
+ border: 1px solid var(--crm-border);
+ border-radius: var(--crm-radius-md);
+ padding: 16px;
+}
+
+.login-demo-title {
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--crm-text-tertiary);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ margin-bottom: 12px;
+}
+
+.login-demo-accounts {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.login-demo-account {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ background: var(--crm-surface);
+ border-radius: var(--crm-radius-sm);
+ border: 1px solid var(--crm-border);
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.login-demo-account:hover {
+ border-color: var(--crm-primary);
+ background: var(--crm-primary-light);
+}
+
+.login-demo-role {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--crm-text-primary);
+}
+
+.login-demo-email {
+ font-size: 12px;
+ color: var(--crm-text-tertiary);
+ font-family: 'Courier New', monospace;
+}
+
+/* ---------- Pagination ---------- */
+.pagination {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 16px 0;
+}
+
+.pagination-info {
+ font-size: 13px;
+ color: var(--crm-text-secondary);
+}
+
+.pagination-buttons {
+ display: flex;
+ gap: 4px;
+}
+
+.pagination-btn {
+ width: 36px;
+ height: 36px;
+ border-radius: var(--crm-radius-sm);
+ border: 1px solid var(--crm-border);
+ background: var(--crm-surface);
+ color: var(--crm-text-secondary);
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.15s;
+}
+
+.pagination-btn:hover { background: var(--crm-surface-container); }
+.pagination-btn.active { background: var(--crm-primary); color: white; border-color: var(--crm-primary); }
+
+/* ---------- Responsive ---------- */
+@media (max-width: 1280px) {
+ .stats-grid { grid-template-columns: repeat(2, 1fr); }
+ .detail-grid { grid-template-columns: 1fr; }
+}
+
+@media (max-width: 768px) {
+ .sidebar { transform: translateX(-100%); }
+ .main-content { margin-left: 0; }
+ .stats-grid { grid-template-columns: 1fr; }
+ .page-content { padding: 20px 16px; }
+ .filter-bar { flex-direction: column; align-items: stretch; }
+ .search-input { min-width: auto; }
+}
+
+/* ---------- Utility ---------- */
+.text-center { text-align: center; }
+.text-right { text-align: right; }
+.mt-4 { margin-top: 16px; }
+.mt-6 { margin-top: 24px; }
+.mb-4 { margin-bottom: 16px; }
+.mb-6 { margin-bottom: 24px; }
+.flex { display: flex; }
+.items-center { align-items: center; }
+.justify-between { justify-content: space-between; }
+.gap-2 { gap: 8px; }
+.gap-3 { gap: 12px; }
+.gap-4 { gap: 16px; }
+.w-full { width: 100%; }