| Current Path : /var/www/html/reddsis/docs/module/ |
| Current File : /var/www/html/reddsis/docs/module/content-feedback.md |
# Content Feedback Module
## 1. Pengenalan
Module ticketing/feedback — admin boleh create feedback untuk user, reply, update status, dan export data. Tidak seperti modul content lain, module ini **tiada multi-language support** (single language).
### Architecture
Guna **2-table ticketing system** (standard industri macam Zendesk, Freshdesk):
```
content_feedback_main (ticket utama)
└── content_feedback_messages (reply/chat)
```
Bila admin reply, dia **create row baru** dalam `content_feedback_messages`, bukan update column `feedback_reply` dalam main table.
---
## 2. Database
### Migration
**File:** `database/migrations/2026_06_11_000001_create_content_feedback_tables.php`
```php
Schema::create('content_feedback_main', function (Blueprint $table) {
$table->id();
$table->string('ticket_no', 50)->unique();
$table->string('name', 255)->nullable();
$table->string('email', 255)->nullable();
$table->string('type', 255)->nullable(); // Ref: FEEDBACK_TYPE
$table->string('cat', 255)->nullable(); // Ref: FEEDBACK_CATEGORY
$table->string('subject', 255)->nullable();
$table->text('feedback');
$table->string('status', 255)->default('PENDING'); // Ref: FEEDBACK_STATUS
$table->string('created_user_type', 50)->nullable();
$table->timestamps();
$table->unsignedBigInteger('created_by')->nullable();
$table->unsignedBigInteger('updated_by')->nullable();
$table->string('updated_user_type', 50)->nullable();
});
Schema::create('content_feedback_messages', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('feedback_id');
$table->text('message');
$table->string('read_status', 50)->default('UNREAD');
$table->string('send_to', 255)->nullable(); // 'user' / 'admin'
$table->string('created_user_type', 50)->nullable();
$table->timestamp('created_at')->nullable();
$table->unsignedBigInteger('created_by')->nullable();
$table->foreign('feedback_id')
->references('id')->on('content_feedback_main')
->onDelete('cascade'); // auto-delete messages bila main ticket delete
});
```
### Table: `content_feedback_main`
| Column | Type | Description |
|--------|------|-------------|
| id | bigint unsigned (PK) | Primary key |
| ticket_no | varchar(50) (unique) | Auto-generated: `FDB-YYYYMMDD-XXXX` |
| name | varchar(255) (nullable) | Nama pengirim |
| email | varchar(255) (nullable) | Email pengirim |
| type | varchar(255) (nullable) | Ref: `FEEDBACK_TYPE` |
| cat | varchar(255) (nullable) | Ref: `FEEDBACK_CATEGORY` |
| subject | varchar(255) (nullable) | Subjek feedback |
| feedback | text | Isi feedback |
| status | varchar(255) | Default `PENDING`, Ref: `FEEDBACK_STATUS` |
| created_user_type | varchar(50) (nullable) | `admin` atau `user` |
| created_by | bigint unsigned (nullable) | FK → `backend_users.id` |
| updated_by | bigint unsigned (nullable) | FK → `backend_users.id` |
| updated_user_type | varchar(50) (nullable) | `admin` atau `user` |
| created_at | timestamp | |
| updated_at | timestamp | |
### Table: `content_feedback_messages`
| Column | Type | Description |
|--------|------|-------------|
| id | bigint unsigned (PK) | Primary key |
| feedback_id | bigint unsigned | FK → `content_feedback_main.id` (cascade delete) |
| message | text | Isi reply |
| read_status | varchar(50) | Default `UNREAD` |
| send_to | varchar(255) (nullable) | `user` atau `admin` |
| created_user_type | varchar(50) (nullable) | `admin` atau `user` |
| created_at | timestamp | |
| created_by | bigint unsigned (nullable) | FK → `backend_users.id` |
---
## 3. Routes
| Method | URI | Name | Permission |
|--------|-----|------|------------|
| GET | `/admin/content-feedback` | `content-feedback.index` | `content-feedback.view` |
| GET | `/admin/content-feedback/create` | `content-feedback.create` | `content-feedback.create` |
| POST | `/admin/content-feedback` | `content-feedback.store` | `content-feedback.create` |
| GET | `/admin/content-feedback/{id}/edit` | `content-feedback.edit` | `content-feedback.update` |
| PUT | `/admin/content-feedback/{id}` | `content-feedback.update` | `content-feedback.update` |
| DELETE | `/admin/content-feedback/{id}` | `content-feedback.destroy` | `content-feedback.delete` |
| GET | `/admin/content-feedback/export` | `content-feedback.export` | `content-feedback.export` |
```php
Route::group(['middleware' => ['auth:admin', 'permission:content-feedback.view,admin']], function () {
Route::get('/content-feedback', 'index')->name('content-feedback.index');
Route::get('/content-feedback/create', 'create')->middleware('permission:content-feedback.create,admin')->name('content-feedback.create');
Route::post('/content-feedback', 'store')->middleware('permission:content-feedback.create,admin')->name('content-feedback.store');
Route::get('/content-feedback/{id}/edit', 'edit')->middleware('permission:content-feedback.update,admin')->name('content-feedback.edit');
Route::put('/content-feedback/{id}', 'update')->middleware('permission:content-feedback.update,admin')->name('content-feedback.update');
Route::delete('/content-feedback/{id}', 'destroy')->middleware('permission:content-feedback.delete,admin')->name('content-feedback.destroy');
Route::get('/content-feedback/export', 'export')->middleware('permission:content-feedback.export,admin')->name('content-feedback.export');
});
```
---
## 4. Controller
**File:** `app/Http/Controllers/Backend/ContentFeedbackController.php`
### index()
Guna `withCount` untuk kira unread messages. Filter by search, date range, status, type.
```php
$feedbacks = ContentFeedbackMain::query()
->withCount(['messages' => fn($q) => $q->where('read_status', 'UNREAD')])
->when($search, function ($q) use ($search) {
$q->where(function ($sub) use ($search) {
$sub->where('ticket_no', 'like', "%{$search}%")
->orWhere('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")
->orWhere('subject', 'like', "%{$search}%")
->orWhere('feedback', 'like', "%{$search}%");
});
})
->when($from, fn($q) => $q->whereDate('created_at', '>=', $from))
->when($to, fn($q) => $q->whereDate('created_at', '<=', $to))
->when($statusFilter, fn($q) => $q->where('status', $statusFilter))
->when($typeFilter, fn($q) => $q->where('type', $typeFilter))
->orderBy('created_at', 'desc')
->paginate(10)->onEachSide(1)->withQueryString();
```
### store() — Create & Email
Auto-generate ticket number, then send 2 emails:
```php
$feedback = ContentFeedbackMain::create([
'ticket_no' => ContentFeedbackMain::generateTicketNo(),
'name' => $request->name,
'email' => $request->email,
'type' => $request->type,
'cat' => $request->cat,
'subject' => $request->subject,
'feedback' => $request->feedback,
'status' => 'PENDING',
'created_user_type' => 'admin',
'created_by' => auth()->id(),
]);
// Email confirmation to user
if ($feedback->email) {
Mail::to($feedback->email)->send(
new FeedbackSubmittedMail([...], 'user')
);
}
// Email notification to all superadmins
$admins = BackendUser::role('superadmin')->get();
foreach ($admins as $admin) {
Mail::to($admin->email)->send(
new FeedbackSubmittedMail([...], 'admin')
);
}
```
### edit() — Auto-mark as READ
```php
$feedback = ContentFeedbackMain::with('messages')->findOrFail($id);
// PENDING → READ bila admin buka ticket
if ($feedback->status === 'PENDING') {
$feedback->update(['status' => 'READ', 'updated_by' => auth()->id()]);
}
// Mark unread admin messages as READ
ContentFeedbackMessage::where('feedback_id', $id)
->where('read_status', 'UNREAD')
->where('send_to', 'admin')
->update(['read_status' => 'READ']);
```
### update() — Reply & Status
```php
$data = [
'type' => $request->type ?? $feedback->type,
'cat' => $request->cat ?? $feedback->cat,
'status' => $request->status ?? $feedback->status,
'updated_by' => auth()->id(),
'updated_user_type' => 'admin',
];
if ($request->filled('reply_message')) {
ContentFeedbackMessage::create([
'feedback_id' => $feedback->id,
'message' => $request->reply_message,
'read_status' => 'UNREAD',
'send_to' => 'user',
'created_user_type' => 'admin',
'created_at' => now(),
'created_by' => auth()->id(),
]);
$data['status'] = 'REPLIED'; // auto-update status
if ($feedback->email) {
Mail::to($feedback->email)->send(
new FeedbackSubmittedMail([...], 'reply')
);
}
}
$feedback->update($data);
```
### export()
Accept `format` param and delegate to export classes:
```php
return match ($format) {
'xlsx' => app(FeedbackExport::class)->xlsx($from, $to, $statusFilter, $typeFilter),
'pdf' => app(FeedbackPdfExport::class)->pdf($from, $to, $statusFilter, $typeFilter),
default => app(FeedbackExport::class)->csv($from, $to, $statusFilter, $typeFilter),
};
```
---
## 5. Models
### ContentFeedbackMain
**File:** `app/Models/Backend/ContentFeedbackMain.php` | **Table:** `content_feedback_main`
```php
#[Table('content_feedback_main')]
#[Fillable(['ticket_no', 'name', 'email', 'type', 'cat', 'subject', 'feedback', 'status',
'created_user_type', 'created_by', 'updated_by', 'updated_user_type'])]
class ContentFeedbackMain extends Model
{
use HasFactory;
public function messages(): HasMany
{
return $this->hasMany(ContentFeedbackMessage::class, 'feedback_id', 'id');
}
public static function generateTicketNo(): string
{
$prefix = 'FDB';
$date = now()->format('Ymd');
$last = static::whereDate('created_at', today())->count();
$seq = str_pad($last + 1, 4, '0', STR_PAD_LEFT);
return "{$prefix}-{$date}-{$seq}";
// Contoh: FDB-20260611-0001
}
}
```
### ContentFeedbackMessage
**File:** `app/Models/Backend/ContentFeedbackMessage.php` | **Table:** `content_feedback_messages`
```php
#[Table('content_feedback_messages')]
#[Fillable(['feedback_id', 'message', 'read_status', 'send_to', 'created_user_type', 'created_at', 'created_by'])]
class ContentFeedbackMessage extends Model
{
use HasFactory;
public $timestamps = false; // created_at di-set manual
public function feedback(): BelongsTo
{
return $this->belongsTo(ContentFeedbackMain::class, 'feedback_id', 'id');
}
public function sender(): BelongsTo
{
return $this->belongsTo(BackendUser::class, 'created_by');
}
public function getSenderLabelAttribute(): string
{
$name = $this->sender
? trim($this->sender->first_name . ' ' . $this->sender->last_name)
: null;
$role = $this->sender?->roles->first()?->name;
return $name && $role ? "$name ($role)" : ($name ?: 'Admin');
}
}
```
Aksesor `sender_label` dipanggil secara automatik — `$msg->sender_label` → **"Nizar (Super Admin)"**.
---
## 6. Status Flow & Color Coding
```
PENDING ──(admin click edit/pencil)──→ READ
READ ──(admin hantar reply)──→ REPLIED
PENDING / READ / REPLIED ──(admin tukar manual)──→ CLOSED
```
| Status | Maksud | CSS Classes | Warna |
|--------|--------|-------------|-------|
| PENDING | Belum dibaca | `bg-warning-50 text-warning-600` | Yellow |
| READ | Dah dibaca, belum reply | `bg-error-50 text-error-600` | Red |
| REPLIED | Dah dibalas | `bg-success-50 text-success-600` | Green |
| CLOSED | Selesai | `bg-gray-100 text-gray-600` | Gray |
```php
$statusClass = match($feedback->status) {
'PENDING' => 'bg-warning-50 text-warning-600',
'READ' => 'bg-error-50 text-error-600',
'REPLIED' => 'bg-success-50 text-success-600',
'CLOSED' => 'bg-gray-100 text-gray-600',
default => 'bg-gray-100 text-gray-600',
};
```
---
## 7. Email Notifications
**Mailable:** `app/Mail/FeedbackSubmittedMail.php`
```php
public function envelope(): Envelope
{
return match($this->type) {
'admin' => new Envelope(subject: 'New Feedback: ' . ($this->data['subject'] ?? 'No Subject')),
'reply' => new Envelope(subject: 'Feedback Reply: ' . ($this->data['ticket_no'] ?? '')),
default => new Envelope(subject: 'Thank You! Your Feedback Has Been Received'),
};
}
public function content(): Content
{
$view = match($this->type) {
'admin' => 'emails.feedback.admin-notification',
'reply' => 'emails.feedback.reply-notification',
default => 'emails.feedback.user-confirmation',
};
return new Content(view: $view, with: ['data' => $this->data]);
}
```
| Type | Recipient | Subject | View |
|------|-----------|---------|------|
| `user` | Pengirim feedback | "Thank You! Your Feedback Has Been Received" | `emails.feedback.user-confirmation` |
| `admin` | All superadmin users | "New Feedback: {subject}" | `emails.feedback.admin-notification` |
| `reply` | Pengirim feedback | "Feedback Reply: {ticket_no}" | `emails.feedback.reply-notification` |
**Important:** Guna `Mail::send()` bukan `Mail::queue()` sebab `QUEUE_CONNECTION=database`. Queue tak akan process sampai worker jalan.
---
## 8. Exports
**Export classes:** `app/Exports/FeedbackExport.php`, `app/Exports/FeedbackPdfExport.php`
| Format | Library | Method |
|--------|---------|--------|
| CSV | Native PHP (`fputcsv`) | `FeedbackExport::csv()` |
| XLSX | OpenSpout | `FeedbackExport::xlsx()` |
| PDF | Dompdf | `FeedbackPdfExport::pdf()` |
Export respect semua active filters (date range, status, type) — kalau user filter dulu, data yang diexport ikut filter tu.
---
## 9. Validation
**File:** `app/Http/Requests/Backend/ContentFeedbackRequest.php`
```php
public function rules(): array
{
return [
'name' => $this->isMethod('POST') ? 'required|string|max:255' : 'nullable',
'email' => 'nullable|email|max:255',
'subject' => $this->isMethod('POST') ? 'required|string|max:255' : 'nullable',
'feedback' => $this->isMethod('POST') ? 'required|string' : 'nullable',
'type' => 'nullable|string',
'cat' => 'nullable|string',
'status' => 'nullable|string',
'reply_message' => 'nullable|string',
];
}
```
Guna `$this->isMethod('POST')` untuk bezakan validation masa create vs update.
---
## 10. Views — Key Design Decisions
### index.blade.php
- **Filter form guna `GET`** — URL boleh dibookmark, support back button
- **Date picker** guna `onclick="this.showPicker()"` + `appearance-none` + ikon kalendar — consistent dengan module ContentArticle
- **Export dropdown** guna Alpine.js (`x-data`, `x-show`, `@click.outside`) — dropdown button dengan 3 pilihan format
- **Status badges** guna match expression untuk color coding
### create.blade.php
- Form layout guna grid 2-column macam modul content lain — consistent UX
- Type + Category guna select dropdown dari Ref table
- Cancel/Save button **dalam card** (bukan luar)
### edit.blade.php
- **Ticket Information Card** — guna grid 2-column, setiap value dalam `bg-gray-50` container (bezakan label vs value)
- **Conversation Card** — chat bubbles:
- Admin → right-aligned, brand color (`bg-brand-50`)
- User → left-aligned, gray (`bg-gray-50`)
- Label guna `$msg->sender_label` → **"Nama (Role)"**
- **Reply/Update Form** — Type & Category **readonly** (tukar ke text display + hidden input) sebab user tak sepatutnya ubah kategori lepas tiket dah jadi
---
## 11. Seeder
### RefDataSeeder
```php
// FEEDBACK_STATUS
['cat' => 'FEEDBACK_STATUS', 'code' => 'PENDING', 'descr' => 'Pending', ...],
['cat' => 'FEEDBACK_STATUS', 'code' => 'READ', 'descr' => 'Read', ...],
['cat' => 'FEEDBACK_STATUS', 'code' => 'REPLIED', 'descr' => 'Replied', ...],
['cat' => 'FEEDBACK_STATUS', 'code' => 'CLOSED', 'descr' => 'Closed', ...],
// FEEDBACK_TYPE
['cat' => 'FEEDBACK_TYPE', 'code' => 'INQUIRY', 'descr' => 'Inquiry', ...],
['cat' => 'FEEDBACK_TYPE', 'code' => 'FEEDBACK', 'descr' => 'Feedback', ...],
['cat' => 'FEEDBACK_TYPE', 'code' => 'COMPLAINT', 'descr' => 'Complaint', ...],
['cat' => 'FEEDBACK_TYPE', 'code' => 'SUGGESTION', 'descr' => 'Suggestion', ...],
['cat' => 'FEEDBACK_TYPE', 'code' => 'REQUEST', 'descr' => 'Request', ...],
// FEEDBACK_CATEGORY
['cat' => 'FEEDBACK_CATEGORY', 'code' => 'ACCOUNTS', 'descr' => 'Accounts', ...],
['cat' => 'FEEDBACK_CATEGORY', 'code' => 'OTHERS', 'descr' => 'Others', ...],
```
### BackendMenuSeeder
- Menu: **Feedback** — icon `fa-solid fa-comment`, diletak bawah **Videos** dalam group Content
- Permissions: `content-feedback.view`, `.create`, `.update`, `.delete`, `.export`
- Assigned to role: **super-admin** (sahaja buat masa ni)
---
## 12. Complete Flow
```text
Admin create ticket → POST /content-feedback
├── Generate ticket_no (FDB-20260611-0001)
├── Simpan ke content_feedback_main (status PENDING)
├── Email confirmation ke user
└── Email notification ke semua superadmin
Admin buka ticket → GET /content-feedback/{id}/edit
├── Status auto jadi READ
├── Unread messages (send_to=admin) auto mark READ
└── Display ticket info + conversation
Admin reply → PUT /content-feedback/{id}
├── Simpan message ke content_feedback_messages
├── Status auto jadi REPLIED
└── Email reply ke user
Admin tutup → PUT /content-feedback/{id} (tukar status CLOSED)
Admin export → GET /content-feedback/export?format=xlsx&from=...&to=...
└── Download CSV / Excel / PDF (ikut filter aktif)
```