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 + + + + + + +
+ + + + + +
+ +
+
+

Dashboard

+

Welcome back, Admin

+
+
+ +
+ AD +
+
+
+ + +
+ + +
+ +
+
+
+

Resolved Tickets

+

156

+
+
+ +
+
+
+ + + +12% + + vs last week +
+
+ + +
+
+
+

Avg Processing Time

+

45 min

+
+
+ +
+
+
+ + + -8% + + improved +
+
+ + +
+
+
+

Pending Tickets

+

23

+
+
+ +
+
+
+ + + -3 + + from yesterday +
+
+ + +
+
+
+

Escalated

+

5

+
+
+ +
+
+
+ + + +2 + + needs attention +
+
+
+ + +
+ +
+
+
+

Handler Performance

+

Average processing time per handler

+
+ +
+
+
+
Nguyễn A
+
+
+
+ 32 min +
+
+
+
+ Fast +
+
+
+
Trần B
+
+
+
+ 48 min +
+
+
+
+ Normal +
+
+
+
Lê C
+
+
+
+ 65 min +
+
+
+
+ Slow +
+
+
+
Phạm D
+
+
+
+ 28 min +
+
+
+
+ Fast +
+
+
+
Hoàng E
+
+
+
+ 82 min +
+
+
+
+ Alert +
+
+
+
+ + +
+
+
+

Tickets by Status

+

Current distribution

+
+
+
+ +
+ + + + + + 207 + Total + +
+
+
+
+
+ Pending + 23 +
+
+
+ Processing + 18 +
+
+
+ Resolved + 156 +
+
+
+ Closed + 10 +
+
+
+
+ + +
+
+
+
+

Tickets Awaiting Closure

+

Resolved tickets pending manager review

+
+ + 4 tickets + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#TicketCustomerHandlerDepartmentContractResolved DateAction
#001 + Khách hàng phản ánh nước nóng + Nguyễn Văn A +
+
S
+ Staff User +
+
CSKHSALE30/04/2026 + +
#003 + Yêu cầu sửa chữa cửa sổ + Trần Thị B +
+
T
+ Trần Staff +
+
Kỹ thuậtLEASE29/04/2026 + +
#005 + Hỗ trợ đăng ký cư trú + Lê Minh C +
+
L
+ Lê Staff +
+
Pháp lýBCC28/04/2026 + +
+
+
+ +
+
+
+ + 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 + + + + + + +
+ + + + + +
+ +
+
+

Feedbacks

+

Manage customer feedback and tickets

+
+ +
+ +
+ + +
+ + + + + +
+ + +
+
+ +
+ + +
+ + + + +
+
+ + +
+ + +
+
+
+
+
+ #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 + +
+
+
+
+ + Trần Thị B +
+
+ + 28/04/2026 +
+
+
?
+ Unassigned +
+
+
+
+
+ + +
+
+
+
+
+ #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 + +
+
+
+
+ + Nguyễn Văn A +
+
+ + 30/04/2026 +
+
+
S
+ Staff User +
+
+
+
+
+ + +
+
+
+
+
+ #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
+
+
?
+ Unassigned +
+
+
+
+
+ + +
+
+
+
+
+ #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
+
+
T
+ Trần Staff +
+
+
+
+
+ + +
+
+
+
+
+ #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
+
+
L
+ Lê Staff +
+
+
+
+
+ +
+ + +
+

Showing 1-5 of 5 results

+
+ + + +
+
+ +
+
+
+ + 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(', ') }}
- - - - - + + + + + @@ -47,21 +47,21 @@
TitleCustomerStatusHandlerDate{{ __('app.blade_title') }}{{ __('app.blade_customer') }}{{ __('app.blade_status') }}{{ __('app.blade_handler') }}{{ __('app.blade_date') }}
@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 + + + +
+ + + + +
+ +
+
+ +
+
+ + + +
+
+ + +
+ + + +
+
+
+
+ check_circle +
+
+ trending_up + +12% +
+
+
42
+
Resolved Tickets
+
+ +
+
+
+ schedule +
+
+ trending_down + -8% +
+
+
2.5h
+
Avg Processing Time
+
+ +
+
+
+ pending +
+
+ trending_up + +3 +
+
+
8
+
Pending Tickets
+
+ +
+
+
+ warning +
+
+
3
+
Escalated
+
+
+ + +
+ +
+
+
+
+ bar_chart +
+ Avg Processing Time by Handler +
+
+ + +
+
+
+
+
+ 45m + Staff A +
+
+ 62m + Staff B +
+
+ 31m + Staff C +
+
+ 52m + Staff D +
+
+ 38m + Staff E +
+
+ 68m + Staff F +
+
+
+
+ + +
+
+
+
+ task_alt +
+ Resolved — Awaiting Closure +
+ 5 tickets +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TicketCustomerDept
#12 Leak issueNguyễn ACSKHchevron_right
#8 AC brokenTrần BKỹ thuậtchevron_right
#5 Door fixLê CCSKHchevron_right
#3 Paint crackPhạm DKỹ thuậtchevron_right
#1 Window leakHoàng ECSKHchevron_right
+
+
+
+ + +
+
+
+
+ history +
+ Recent Feedbacks +
+ + View All + arrow_forward + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TitleCustomerProductContractChannelStatusDepartmentAssigned ToCreated
Leak in bathroom +
+
NA
+ Nguyễn A +
+
Sunrise A1Salechat ZaloPendingCSKH +
+
ST
+ Staff User +
+
2 hours ago
AC not working +
+
TB
+ Trần B +
+
Green ValleyLeasemail EmailProcessingKỹ thuật +
+
MG
+ Manager User +
+
5 hours ago
+
+ Door lock broken + priority_high +
+
+
+
LC
+ Lê C +
+
Ocean TowerBCCphone PhoneEscalatedCSKH +
+
ST
+ Staff User +
+
1 day ago
Paint crack in living room +
+
PD
+ Phạm D +
+
Park HillSaledescription DocumentResolvedKỹ thuật +
+
AD
+ Admin User +
+
3 days ago
Window seal broken +
+
HE
+ Hoàng E +
+
Sunrise A2Leaseperson In PersonClosedCSKH +
+
ST
+ Staff User +
+
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 +
+
+
+ + +
+
+ + +
+ +
+ +
+
+
+
+ description +
+ Feedback Details +
+
+
+
+
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 +
+
+
+
+ + +
+
+
+
+ forum +
+ Interaction History +
+ +
+
+
+ +
+
+ edit_note +
+
+
+ Staff User + Note + 2 hours ago +
+
+ 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 +
+
+
+ Manager User + Status Change + 5 hours ago +
+
+
+ Pending + arrow_forward + Processing +
+
+
+
+ + +
+
+ person_add +
+
+
+ Manager User + Assignment + 5 hours ago +
+
+ Assigned to Staff User for handling +
+
+
+ + +
+
+ swap_horiz +
+
+
+ Admin User + Transfer + 1 day ago +
+
+
+ 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 +
+
+
+ System + Created + 2 days ago +
+
+ Feedback created via Zalo channel. Auto-assigned to CSKH department. +
+
+
+
+
+
+ + +
+
+
+
+ attach_file +
+ Attachments +
+ 3 files +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileCollectionSizeUploaded ByDate
+
+
+ image +
+
+
inspection_photo.jpg
+
image/jpeg
+
+
+
customer_evidence2.3 MB +
+
ST
+ Staff User +
+
2h ago + +
+
+
+ description +
+
+
repair_estimate.pdf
+
application/pdf
+
+
+
general1.1 MB +
+
MG
+ Manager User +
+
1d ago + +
+
+
+ check_circle +
+
+
proof_fixed.jpg
+
image/jpeg
+
+
+
proof_of_resolution3.8 MB +
+
ST
+ Staff User +
+
3d ago + +
+
+
+ + +
+
+
+
+ swap_horiz +
+ Transfer History +
+
+
+
+
+ Kỹ thuật + arrow_forward + CSKH + — Admin User +
+
"Đã kiểm tra kỹ thuật, cần CSKH liên hệ"
+
1 day ago
+
+
+
+ CSKH + arrow_forward + Kỹ thuật + — Manager User +
+
"Cần kiểm tra kỹ thuật"
+
2 days ago
+
+
+
+
+
+
+ + +
+ +
+
+ bolt + Quick Actions +
+ + + +
+ + +
+
+ info + Ticket Information +
+
+
+
+ sync +
+
+
Status
+
Pending
+
+
+
+
+ apartment +
+
+
Department
+
CSKH
+
+
+
+
+ person +
+
+
Assigned To
+
+
+
ST
+ Staff User +
+
+
+
+
+
+ description +
+
+
Contract
+
Sale Active
+
+
+
+
+ inbox_stack +
+
+
Channel
+
+ chat + Zalo +
+
+
+
+
+ warning +
+
+
Escalated
+
Yes
+
+
+
+
+ calendar_today +
+
+
Created
+
Jan 15, 2026 10:30 AM
+
+
+
+
+ update +
+
+
Last Updated
+
Jan 15, 2026 12:45 PM
+
+
+
+
+ + + +
+
+
+
+
+ + 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

+
+ +
+ + +
+
+ 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 +
+ +
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TitleCustomerProductContractChannelTagsStatusDepartmentAssigned ToCreated
Leak in bathroom +
+
NA
+ Nguyễn A +
+
Sunrise A1Salechat ZaloPlumbingPendingCSKH +
+
ST
+ Staff +
+
2h ago + +
AC not working +
+
TB
+ Trần B +
+
Green ValleyLeasemail EmailHVACProcessingKỹ thuật +
+
MG
+ Manager +
+
5h ago + +
+
+ Door lock broken + priority_high +
+
+
+
LC
+ Lê C +
+
Ocean TowerBCCphone PhoneUrgentEscalatedCSKH +
+
ST
+ Staff +
+
1d ago + +
Paint crack in living room +
+
PD
+ Phạm D +
+
Park HillSaledescription DocPaintingResolvedKỹ thuật +
+
AD
+ Admin +
+
3d ago + +
Window seal broken +
+
HE
+ Hoàng E +
+
Sunrise A2Leaseperson In PersonWindowClosedCSKH +
+
ST
+ Staff +
+
1w ago + +
Elevator noise complaint +
+
TB
+ Trần B +
+
Green ValleyLeasechat ZaloNoisePendingKỹ thuật +
+
MG
+ Manager +
+
2d ago + +
Billing discrepancy +
+
NA
+ Nguyễn A +
+
Sunrise A1Salemail EmailBillingProcessingKế toán +
+
ST
+ Staff +
+
4d ago + +
+
+
+
+ + + +
+
+
+ + 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 + + + + + + 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%; }