diff --git a/app/Http/Controllers/Api/Admin/Docs/PostCategoryController.php b/app/Http/Controllers/Api/Admin/Docs/PostCategoryController.php
index bf9b2e8..abefce4 100644
--- a/app/Http/Controllers/Api/Admin/Docs/PostCategoryController.php
+++ b/app/Http/Controllers/Api/Admin/Docs/PostCategoryController.php
@@ -102,6 +102,75 @@ class PostCategoryController extends Controller
]);
}
+ public function trashed(Request $request): JsonResponse
+ {
+ $perPage = min((int) $request->get('per_page', 15), 100);
+
+ $paginator = PostCategory::onlyTrashed()
+ ->withCount('posts')
+ ->when(
+ $request->filled('keyword'),
+ fn ($q) => $q->where('name', 'like', '%' . $request->string('keyword')->toString() . '%')
+ )
+ ->orderByDesc('deleted_at')
+ ->paginate($perPage);
+
+ return response()->json([
+ 'error' => false,
+ 'data' => [
+ 'items' => PostCategoryResource::collection($paginator->items()),
+ 'total' => $paginator->total(),
+ 'per_page' => $paginator->perPage(),
+ 'current_page' => $paginator->currentPage(),
+ 'last_page' => $paginator->lastPage(),
+ ],
+ ]);
+ }
+
+ public function restore(int $id): JsonResponse
+ {
+ $category = PostCategory::onlyTrashed()->find($id);
+
+ if (!$category) {
+ return response()->json(['error' => true, 'message' => 'Không tìm thấy danh mục.'], 404);
+ }
+
+ $category->restore();
+ $this->revalidation->revalidateDocs();
+
+ return response()->json(['error' => false, 'message' => 'Khôi phục danh mục thành công.']);
+ }
+
+ public function forceDelete(int $id): JsonResponse
+ {
+ $category = PostCategory::onlyTrashed()->find($id);
+
+ if (!$category) {
+ return response()->json(['error' => true, 'message' => 'Không tìm thấy danh mục.'], 404);
+ }
+
+ $category->forceDelete();
+
+ return response()->json(['error' => false, 'message' => 'Xoá vĩnh viễn danh mục thành công.']);
+ }
+
+ public function bulkRestore(Request $request): JsonResponse
+ {
+ $request->validate(['ids' => 'required|array|min:1', 'ids.*' => 'integer']);
+ $count = PostCategory::onlyTrashed()->whereIn('id', $request->ids)->restore();
+ $this->revalidation->revalidateDocs();
+
+ return response()->json(['error' => false, 'message' => "Đã khôi phục {$count} danh mục."]);
+ }
+
+ public function bulkForceDelete(Request $request): JsonResponse
+ {
+ $request->validate(['ids' => 'required|array|min:1', 'ids.*' => 'integer']);
+ $count = PostCategory::onlyTrashed()->whereIn('id', $request->ids)->forceDelete();
+
+ return response()->json(['error' => false, 'message' => "Đã xoá vĩnh viễn {$count} danh mục."]);
+ }
+
public function destroy(int $id): JsonResponse
{
$category = PostCategory::withCount('posts')->find($id);
diff --git a/app/Http/Controllers/Api/Admin/Docs/PostController.php b/app/Http/Controllers/Api/Admin/Docs/PostController.php
index 80f24e4..785fbfd 100644
--- a/app/Http/Controllers/Api/Admin/Docs/PostController.php
+++ b/app/Http/Controllers/Api/Admin/Docs/PostController.php
@@ -136,6 +136,97 @@ class PostController extends Controller
]);
}
+ public function trashed(Request $request): JsonResponse
+ {
+ $perPage = min((int) $request->get('per_page', 15), 100);
+
+ $paginator = Post::onlyTrashed()
+ ->with('category')
+ ->when(
+ $request->filled('keyword'),
+ fn ($q) => $q->where('title', 'like', '%' . $request->string('keyword')->toString() . '%')
+ )
+ ->orderByDesc('deleted_at')
+ ->paginate($perPage);
+
+ return response()->json([
+ 'error' => false,
+ 'data' => [
+ 'items' => PostResource::collection($paginator->items()),
+ 'total' => $paginator->total(),
+ 'per_page' => $paginator->perPage(),
+ 'current_page' => $paginator->currentPage(),
+ 'last_page' => $paginator->lastPage(),
+ ],
+ ]);
+ }
+
+ public function restore(int $id): JsonResponse
+ {
+ $post = Post::onlyTrashed()->find($id);
+
+ if (!$post) {
+ return response()->json(['error' => true, 'message' => 'Không tìm thấy bài viết.'], 404);
+ }
+
+ $post->restore();
+ $this->revalidation->revalidateDocs();
+
+ return response()->json(['error' => false, 'message' => 'Khôi phục bài viết thành công.']);
+ }
+
+ public function forceDelete(int $id): JsonResponse
+ {
+ $post = Post::onlyTrashed()->find($id);
+
+ if (!$post) {
+ return response()->json(['error' => true, 'message' => 'Không tìm thấy bài viết.'], 404);
+ }
+
+ $post->forceDelete();
+
+ return response()->json(['error' => false, 'message' => 'Xoá vĩnh viễn bài viết thành công.']);
+ }
+
+ public function bulkRestore(Request $request): JsonResponse
+ {
+ $request->validate(['ids' => 'required|array|min:1', 'ids.*' => 'integer']);
+ $count = Post::onlyTrashed()->whereIn('id', $request->ids)->restore();
+ $this->revalidation->revalidateDocs();
+
+ return response()->json(['error' => false, 'message' => "Đã khôi phục {$count} bài viết."]);
+ }
+
+ public function bulkForceDelete(Request $request): JsonResponse
+ {
+ $request->validate(['ids' => 'required|array|min:1', 'ids.*' => 'integer']);
+ $count = Post::onlyTrashed()->whereIn('id', $request->ids)->forceDelete();
+
+ return response()->json(['error' => false, 'message' => "Đã xoá vĩnh viễn {$count} bài viết."]);
+ }
+
+ public function bulkDelete(Request $request): JsonResponse
+ {
+ $request->validate(['ids' => 'required|array|min:1', 'ids.*' => 'integer']);
+ $count = Post::whereIn('id', $request->ids)->delete();
+ $this->revalidation->revalidateDocs();
+
+ return response()->json(['error' => false, 'message' => "Đã xoá {$count} bài viết."]);
+ }
+
+ public function bulkUpdateStatus(Request $request): JsonResponse
+ {
+ $request->validate([
+ 'ids' => 'required|array|min:1',
+ 'ids.*' => 'integer',
+ 'status' => 'required|in:draft,published,hidden',
+ ]);
+ $count = Post::whereIn('id', $request->ids)->update(['status' => $request->status]);
+ $this->revalidation->revalidateDocs();
+
+ return response()->json(['error' => false, 'message' => "Đã cập nhật trạng thái {$count} bài viết."]);
+ }
+
public function syncWordpress(Request $request): JsonResponse
{
$validated = $request->validate([
diff --git a/app/Http/Controllers/Api/Admin/Ticket/TicketController.php b/app/Http/Controllers/Api/Admin/Ticket/TicketController.php
index c3f82e7..b7c2825 100644
--- a/app/Http/Controllers/Api/Admin/Ticket/TicketController.php
+++ b/app/Http/Controllers/Api/Admin/Ticket/TicketController.php
@@ -110,7 +110,7 @@ class TicketController extends Controller
public function reply(Request $request, string $ticket_num)
{
$request->validate([
- 'message' => ['required', 'string', 'max:2000'],
+ 'message' => ['required', 'string'],
]);
$admin = auth('admin')->user();
diff --git a/app/Http/Controllers/Api/Admin/VCenter/VCenterController.php b/app/Http/Controllers/Api/Admin/VCenter/VCenterController.php
index c49d7af..1e32ac3 100644
--- a/app/Http/Controllers/Api/Admin/VCenter/VCenterController.php
+++ b/app/Http/Controllers/Api/Admin/VCenter/VCenterController.php
@@ -93,6 +93,7 @@ class VCenterController extends Controller
]);
}
+
public function storeNode(Request $request, string $type): JsonResponse
{
$rules = match ($type) {
@@ -187,9 +188,9 @@ class VCenterController extends Controller
$allHosts = ModuleHost::with(['datastore', 'network'])->get()->groupBy('module_datacenter_id');
$allFolders = ModuleFolder::all()->groupBy('module_datacenter_id');
// VMs with a host assignment — grouped by host DB id
- $allVMs = VirtualMachines::whereNotNull('host_id')->get()->groupBy('host_id');
+ $allVMs = VirtualMachines::with('vmware')->whereNotNull('host_id')->get()->groupBy('host_id');
// VMs without a host assignment (synced before host_id was tracked) — grouped by datacenter DB id
- $orphanVMs = VirtualMachines::whereNull('host_id')->get()->groupBy('datacenter_id');
+ $orphanVMs = VirtualMachines::with('vmware')->whereNull('host_id')->get()->groupBy('datacenter_id');
$tree = $vcenters->map(function ($vc) use ($allHosts, $allFolders, $allVMs, $orphanVMs) {
$dcGroups = ($vc->datacenters ?? collect())->groupBy('datacenter_id');
@@ -227,8 +228,8 @@ class VCenterController extends Controller
'uuid' => $vm->uuid,
'vm_id' => $vm->vm_id,
'ip' => $vm->ip,
- 'username' => $vm->username,
- 'password' => $vm->password,
+ 'username' => $vm->vmware?->user,
+ 'password' => $vm->vmware?->plain_password,
'power_state' => $vm->power_state,
'folder' => $vm->folder,
'cpu' => $vm->cpu,
@@ -304,6 +305,8 @@ class VCenterController extends Controller
'uuid' => $vm->uuid,
'vm_id' => $vm->vm_id,
'ip' => $vm->ip,
+ 'username' => $vm->vmware?->user,
+ 'password' => $vm->vmware?->plain_password,
'power_state' => $vm->power_state,
'folder' => $vm->folder,
'cpu' => $vm->cpu,
diff --git a/app/Http/Controllers/Api/Client/Ticket/TicketController.php b/app/Http/Controllers/Api/Client/Ticket/TicketController.php
index 0040b1d..527dee5 100644
--- a/app/Http/Controllers/Api/Client/Ticket/TicketController.php
+++ b/app/Http/Controllers/Api/Client/Ticket/TicketController.php
@@ -5,6 +5,10 @@ namespace App\Http\Controllers\Api\Client\Ticket;
use App\Http\Controllers\Controller;
use App\Http\Resources\Api\Ticket\TicketResource;
use App\Http\Resources\Api\Ticket\TicketReplyResource;
+use App\Jobs\Ticket\TicketJobs;
+use App\Mail\TicketCreated;
+use App\Mail\Admin\TicketAdminNotification;
+use App\Models\Setting;
use App\Models\Ticket;
use App\Models\Notification;
use App\Models\TicketReply;
@@ -12,6 +16,8 @@ use App\Events\TicketReplyCreated;
use App\Repositories\Ticket\TicketRepositoryInterface;
use App\Repositories\TicketReply\TicketReplyRepositoryInterface;
use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Mail;
+use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class TicketController extends Controller
@@ -60,22 +66,41 @@ class TicketController extends Controller
'description' => ['required', 'string', 'min:20'],
'room' => ['nullable', 'string'],
'data' => ['nullable', 'array'],
+ 'images' => ['nullable', 'array', 'max:5'],
+ 'images.*' => ['image', 'mimes:jpg,jpeg,png,gif,webp', 'max:5120'],
]);
$customer = auth('client')->user();
$ticketNum = 'TKT-' . now()->format('Ymd') . '-' . strtoupper(Str::random(5));
+ $gallery = [];
+ foreach ($request->file('images', []) as $image) {
+ $path = $image->store('tickets', 'public');
+ $gallery[] = Storage::disk('public')->url($path);
+ }
+
$ticket = $this->ticketRepository->create([
+ 'uuid' => (string) Str::uuid(),
'ticket_num' => $ticketNum,
'subject' => $request->input('subject'),
'description' => $request->input('description'),
'status' => 'open',
'room' => $request->input('room'),
'data' => $request->input('data') ? json_encode($request->input('data')) : null,
+ 'gallery' => $gallery ?: null,
'customer_id' => $customer->id,
]);
+ TicketJobs::dispatch($ticket, $customer);
+ Mail::to($customer->email)->queue(new TicketCreated($ticket, $customer));
+
+ $setting = Setting::where('key', 'setting')->first();
+ $adminEmail = $setting ? (json_decode((string) $setting->value)?->admin_email ?? null) : null;
+ if ($adminEmail) {
+ Mail::to($adminEmail)->queue(new TicketAdminNotification($ticket, $customer));
+ }
+
return response()->json([
'error' => false,
'message' => 'Ticket đã được tạo thành công.',
@@ -115,7 +140,7 @@ class TicketController extends Controller
public function reply(Request $request, string $ticket_num)
{
$request->validate([
- 'message' => ['required', 'string', 'max:2000'],
+ 'message' => ['required', 'string'],
]);
$customer = auth('client')->user();
diff --git a/app/Http/Controllers/Api/Service/OrderControllerRefactor.php b/app/Http/Controllers/Api/Service/OrderControllerRefactor.php
index e05f143..8682584 100644
--- a/app/Http/Controllers/Api/Service/OrderControllerRefactor.php
+++ b/app/Http/Controllers/Api/Service/OrderControllerRefactor.php
@@ -20,8 +20,10 @@ use App\Services\ActivityLogger;
use App\Services\Api\Order\OrderQueryService;
use App\Services\Api\Order\OrderService;
use App\Services\InvoicePaymentService;
+use App\Services\InvoicePdfService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
+use Symfony\Component\HttpFoundation\Response;
class OrderControllerRefactor extends Controller
{
@@ -225,6 +227,34 @@ class OrderControllerRefactor extends Controller
}
}
+ public function getClientOrderDetailByOrderNum(Request $request, string $orderNum): JsonResponse
+ {
+ $order = $this->orderQueryService->findCustomerInvoiceByOrderNum((int) $request->user()->id, $orderNum);
+
+ if (! $order) {
+ return response()->json(['success' => false, 'message' => 'Đơn hàng không tồn tại.'], 404);
+ }
+
+ return response()->json(['success' => true, 'data' => new OrderDetailResource($order)]);
+ }
+
+ public function downloadInvoicePdfByOrderNum(Request $request, string $orderNum, InvoicePdfService $pdfService): Response
+ {
+ $order = $this->orderQueryService->findCustomerInvoiceByOrderNum((int) $request->user()->id, $orderNum);
+
+ if (! $order?->invoice) {
+ abort(404, 'Hóa đơn không tồn tại.');
+ }
+
+ $pdf = $pdfService->generate($order->invoice);
+ $filename = $pdfService->generateFileName($order->invoice);
+
+ return response($pdf, 200, [
+ 'Content-Type' => 'application/pdf',
+ 'Content-Disposition' => 'attachment; filename="' . $filename . '"',
+ ]);
+ }
+
public function getClientServices(Request $request): JsonResponse
{
$services = $this->orderQueryService->getCustomerServices((int) $request->user()->id);
diff --git a/app/Http/Resources/Api/Ticket/TicketResource.php b/app/Http/Resources/Api/Ticket/TicketResource.php
index c76bf33..127e8af 100644
--- a/app/Http/Resources/Api/Ticket/TicketResource.php
+++ b/app/Http/Resources/Api/Ticket/TicketResource.php
@@ -11,15 +11,18 @@ class TicketResource extends JsonResource
{
return [
'id' => $this->id,
+ 'uuid' => $this->uuid,
'ticket_num' => $this->ticket_num,
'subject' => $this->subject,
'description' => $this->description,
'status' => $this->status,
'room' => $this->room,
'data' => $this->data ? (is_string($this->data) ? json_decode($this->data, true) : $this->data) : null,
+ 'gallery' => $this->gallery ?? [],
'customer_id' => $this->customer_id,
'customer' => $this->whenLoaded('customer', fn() => [
'id' => $this->customer->id,
+ 'uuid' => $this->customer->uuid,
'fullname' => $this->customer->fullname,
'email' => $this->customer->email,
]),
diff --git a/app/Jobs/VirtualMachine/ChangeVmPasswordJob.php b/app/Jobs/VirtualMachine/ChangeVmPasswordJob.php
index 0001f2f..f79f04e 100644
--- a/app/Jobs/VirtualMachine/ChangeVmPasswordJob.php
+++ b/app/Jobs/VirtualMachine/ChangeVmPasswordJob.php
@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Jobs\VirtualMachine;
use App\Http\Facade\SignRSA;
+use App\Mail\Customer\VmPasswordChanged;
+use App\Models\ModuleVmware;
use App\Models\vCenter;
use App\Services\VmProvisionService;
use App\Services\VsphereHostSshService;
@@ -14,6 +16,7 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Mail;
/**
* ChangeVmPasswordJob
@@ -71,6 +74,8 @@ class ChangeVmPasswordJob implements ShouldQueue
// Persist encrypted password to DB
$provision->updatePassword($this->moduleVmwareId, $this->newPassword);
+ $this->notifyCustomer();
+
Log::info('ChangeVmPasswordJob: password changed successfully', [
'module_vmware_id' => $this->moduleVmwareId,
'vm_id' => $this->vmId,
@@ -86,6 +91,19 @@ class ChangeVmPasswordJob implements ShouldQueue
]);
}
+ private function notifyCustomer(): void
+ {
+ $vm = ModuleVmware::with('customer')->find($this->moduleVmwareId);
+ if (!$vm?->customer?->email) {
+ return;
+ }
+
+ Mail::to($vm->customer->email)->queue(new VmPasswordChanged([
+ 'fullname' => $vm->customer->fullname,
+ 'vm_name' => $vm->name,
+ ]));
+ }
+
private function assertSuccess(string $output): void
{
if (str_contains(strtoupper(trim($output)), 'SUCCESS')) {
diff --git a/app/Mail/Admin/TicketAdminNotification.php b/app/Mail/Admin/TicketAdminNotification.php
new file mode 100644
index 0000000..cd51c92
--- /dev/null
+++ b/app/Mail/Admin/TicketAdminNotification.php
@@ -0,0 +1,34 @@
+subject("[ADMIN] Ticket mới #{$this->ticket->ticket_num} — {$this->customer->email}")
+ ->view('emails.admin.ticket-notification')
+ ->with([
+ 'ticketNum' => $this->ticket->ticket_num,
+ 'subject' => $this->ticket->subject,
+ 'room' => $this->ticket->room,
+ 'description' => strip_tags($this->ticket->description),
+ 'customerName' => trim($this->customer->firstname . ' ' . $this->customer->lastname),
+ 'customerEmail' => $this->customer->email,
+ 'ticketUrl' => config('app.frontend_url') . '/tickets/' . $this->ticket->ticket_num,
+ ]);
+ }
+}
diff --git a/app/Mail/Customer/VmPasswordChanged.php b/app/Mail/Customer/VmPasswordChanged.php
new file mode 100644
index 0000000..7094264
--- /dev/null
+++ b/app/Mail/Customer/VmPasswordChanged.php
@@ -0,0 +1,65 @@
+payload()['company_name'] ?? config('app.name', 'The Gioi So');
+
+ return new Envelope(
+ subject: 'Mật khẩu VM của bạn đã được thay đổi — ' . $appName,
+ );
+ }
+
+ public function content(): Content
+ {
+ $data = $this->payload();
+
+ return new Content(
+ view: 'emails.customer.vm-password-changed',
+ with: [
+ 'appName' => $data['company_name'] ?? config('app.name', 'The Gioi So'),
+ 'fullName' => $data['fullname'] ?? 'Quý khách',
+ 'vmName' => $data['vm_name'] ?? '',
+ 'loginUrl' => $data['login_url'] ?? rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap',
+ 'supportEmail' => $data['support_email'] ?? config('mail.support_email', 'support@thegioiso.com'),
+ 'supportPhone' => $data['support_phone'] ?? config('mail.support_phone', '1900.xxxx'),
+ ],
+ );
+ }
+
+ public function attachments(): array
+ {
+ return [];
+ }
+
+ private function payload(): array
+ {
+ try {
+ $setting = Setting::where('key', 'setting')->first();
+ $settingValue = $setting ? json_decode((string) $setting->value, true) : [];
+ } catch (\Throwable) {
+ $settingValue = [];
+ }
+
+ return array_merge([
+ 'company_name' => $settingValue['company'] ?? config('app.name', 'The Gioi So'),
+ 'support_email' => $settingValue['email'] ?? config('mail.support_email', 'support@thegioiso.com'),
+ 'support_phone' => $settingValue['hotline'] ?? ($settingValue['phone'] ?? config('mail.support_phone', '1900.xxxx')),
+ 'login_url' => rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap',
+ ], $this->data);
+ }
+}
diff --git a/app/Mail/TicketCreated.php b/app/Mail/TicketCreated.php
new file mode 100644
index 0000000..7282bd8
--- /dev/null
+++ b/app/Mail/TicketCreated.php
@@ -0,0 +1,46 @@
+ticket->ticket_num}] Yêu cầu hỗ trợ của bạn đã được tiếp nhận",
+ );
+ }
+
+ public function content(): Content
+ {
+ return new Content(
+ view: 'emails.customer.ticket-created',
+ with: [
+ 'fullName' => trim("{$this->customer->firstname} {$this->customer->lastname}") ?: 'Quý khách',
+ 'ticketNum' => $this->ticket->ticket_num,
+ 'subject' => $this->ticket->subject,
+ 'room' => $this->ticket->room,
+ 'ticketUrl' => rtrim(config('app.frontend_url', ''), '/') . '/tickets/' . $this->ticket->ticket_num,
+ ],
+ );
+ }
+
+ public function attachments(): array
+ {
+ return [];
+ }
+}
diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php
index b389f67..1749f95 100644
--- a/app/Models/Ticket.php
+++ b/app/Models/Ticket.php
@@ -13,6 +13,7 @@ class Ticket extends Model
protected $table = 'ticket';
protected $fillable = [
+ 'uuid',
'ticket_num',
'subject',
'customer_id',
@@ -20,6 +21,11 @@ class Ticket extends Model
'status',
'room',
'data',
+ 'gallery',
+ ];
+
+ protected $casts = [
+ 'gallery' => 'array',
];
public function customer()
diff --git a/app/Models/VirtualMachines.php b/app/Models/VirtualMachines.php
index 962f9f3..f3a25fc 100644
--- a/app/Models/VirtualMachines.php
+++ b/app/Models/VirtualMachines.php
@@ -25,8 +25,6 @@ class VirtualMachines extends Model
'host_id',
'ip_pool_id',
'ip',
- 'username',
- 'password',
];
public function datacenter()
@@ -43,4 +41,9 @@ class VirtualMachines extends Model
{
return $this->belongsTo(IpPool::class, 'ip_pool_id');
}
+
+ public function vmware()
+ {
+ return $this->hasOne(ModuleVmware::class, 'vmware_id', 'vm_id');
+ }
}
diff --git a/database/migrations/2026_07_14_000001_add_credentials_to_virtual_machines_table.php b/database/migrations/2026_07_14_000001_add_credentials_to_virtual_machines_table.php
deleted file mode 100644
index c7359d3..0000000
--- a/database/migrations/2026_07_14_000001_add_credentials_to_virtual_machines_table.php
+++ /dev/null
@@ -1,23 +0,0 @@
-string('username')->nullable()->after('ip');
- $table->string('password')->nullable()->after('username');
- });
- }
-
- public function down(): void
- {
- Schema::table('virtual_machines', function (Blueprint $table) {
- $table->dropColumn(['username', 'password']);
- });
- }
-};
diff --git a/database/migrations/2026_07_14_000001_add_gallery_and_fk_to_ticket_table.php b/database/migrations/2026_07_14_000001_add_gallery_and_fk_to_ticket_table.php
new file mode 100644
index 0000000..60bc446
--- /dev/null
+++ b/database/migrations/2026_07_14_000001_add_gallery_and_fk_to_ticket_table.php
@@ -0,0 +1,29 @@
+json('gallery')->nullable()->after('data');
+ }
+ // customer_id was bigInteger (signed); must match customers.id (unsignedBigInteger)
+ $table->unsignedBigInteger('customer_id')->change();
+ $table->foreign('customer_id')->references('id')->on('customers')->onDelete('cascade');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('ticket', function (Blueprint $table) {
+ $table->dropForeign(['customer_id']);
+ $table->bigInteger('customer_id')->default(0)->change();
+ $table->dropColumn('gallery');
+ });
+ }
+};
diff --git a/database/migrations/2026_07_14_000002_change_ticket_reply_message_to_longtext.php b/database/migrations/2026_07_14_000002_change_ticket_reply_message_to_longtext.php
new file mode 100644
index 0000000..c491933
--- /dev/null
+++ b/database/migrations/2026_07_14_000002_change_ticket_reply_message_to_longtext.php
@@ -0,0 +1,22 @@
+longText('message')->nullable()->change();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('ticket_reply', function (Blueprint $table) {
+ $table->text('message')->nullable()->change();
+ });
+ }
+};
diff --git a/resources/views/emails/admin/ticket-notification.blade.php b/resources/views/emails/admin/ticket-notification.blade.php
new file mode 100644
index 0000000..d69346c
--- /dev/null
+++ b/resources/views/emails/admin/ticket-notification.blade.php
@@ -0,0 +1,83 @@
+
+
+
+
+
+ Thông báo ticket mới
+
+
+
+
+
+
+
+
+
Thông tin ticket
+
+
+ | Mã ticket |
+ #{{ $ticketNum ?? 'N/A' }} |
+
+
+ | Tiêu đề |
+ {{ $subject ?? 'N/A' }} |
+
+
+ | Phòng ban |
+ {{ $room ?? 'N/A' }} |
+
+
+
+
Thông tin khách hàng
+
+
+ | Khách hàng |
+ {{ $customerName ?? 'N/A' }} |
+
+
+ | Email khách |
+ {{ $customerEmail ?? 'N/A' }} |
+
+
+
+
Nội dung
+
{{ mb_substr($description ?? '', 0, 500) }}{{ mb_strlen($description ?? '') > 500 ? '…' : '' }}
+
+
+
+
+
+
+
+
+
diff --git a/resources/views/emails/customer/ticket-created.blade.php b/resources/views/emails/customer/ticket-created.blade.php
new file mode 100644
index 0000000..3582c47
--- /dev/null
+++ b/resources/views/emails/customer/ticket-created.blade.php
@@ -0,0 +1,209 @@
+@php
+ $appName = $appName ?? config('app.name', 'Thế Giới Số');
+ $fullName = $fullName ?? 'Quý khách';
+ $supportEmail = $supportEmail ?? 'support@tgs.com.vn';
+ $supportPhone = $supportPhone ?? '1900. 6119';
+@endphp
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ 🎫
+
+ Yêu cầu hỗ trợ đã được tiếp nhận
+
+
+ Chúng tôi sẽ phản hồi trong thời gian sớm nhất
+
+ |
+
+
+
+
+
+
+ |
+
+ Xin chào {{ $fullName }},
+
+
+ Chúng tôi đã nhận được yêu cầu hỗ trợ của Quý khách và sẽ xử lý trong thời gian sớm nhất. Dưới đây là thông tin ticket của Quý khách:
+
+
+
+ {{-- Ticket info box --}}
+
+
+
+
+
+
+
+ | Mã ticket: |
+ #{{ $ticketNum }} |
+
+
+ | Tiêu đề: |
+ {{ $subject }} |
+
+ @if($room)
+
+ | Phòng ban: |
+ {{ $room }} |
+
+ @endif
+
+ | Trạng thái: |
+
+ Đang mở
+ |
+
+
+
+ |
+
+
+
+
+
+
+
+
+ Đội ngũ hỗ trợ của {{ $appName }} sẽ liên hệ với Quý khách qua email hoặc cập nhật trực tiếp trên hệ thống. Cảm ơn Quý khách đã tin tưởng sử dụng dịch vụ của chúng tôi.
+
+
+
+ Email này được gửi tự động. Vui lòng không trả lời email này.
+
+
+ |
+
+
+
+ |
+
+
+
+ |
+
+
+
+
+
+
+ |
+
+ |
+
+
+ | |
+
+
+ |
+
+ |
+
+
+ | |
+
+
+ |
+
+ |
+
+
+
+ |
+
+
+ |
+
+ |
+
+
+
+ |
+
+
+
diff --git a/resources/views/emails/customer/vm-password-changed.blade.php b/resources/views/emails/customer/vm-password-changed.blade.php
new file mode 100644
index 0000000..04f7427
--- /dev/null
+++ b/resources/views/emails/customer/vm-password-changed.blade.php
@@ -0,0 +1,190 @@
+@php
+ $appName = $appName ?? config('app.name', 'Thế Giới Số');
+ $fullName = $fullName ?? 'Quý khách';
+ $vmName = $vmName ?? '';
+ $loginUrl = $loginUrl ?? rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap';
+ $supportEmail = $supportEmail ?? 'support@tgs.com.vn';
+ $supportPhone = $supportPhone ?? '1900. 6119';
+@endphp
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ 🔑
+
+ Mật khẩu VM đã được thay đổi
+
+
+ Mật khẩu máy chủ ảo của bạn đã được cập nhật thành công
+
+ |
+
+
+
+
+
+
+ |
+
+ Xin chào {{ $fullName }},
+
+
+ Mật khẩu đăng nhập vào máy chủ ảo (VM) của bạn tại {{ $appName }} đã được thay đổi thành công.
+
+
+
+
+
+ |
+ Máy chủ: {{ $vmName }}
+ Trạng thái: Đổi mật khẩu thành công ✓
+ |
+
+
+
+
+
+ Bạn có thể đăng nhập vào cổng quản lý để kiểm tra trạng thái dịch vụ.
+
+
+
+
+
+ Nếu bạn không thực hiện thao tác này, vui lòng liên hệ ngay với chúng tôi qua {{ $supportEmail }} hoặc {{ $supportPhone }}.
+
+
+
+ Email này được gửi tự động. Vui lòng không trả lời email này.
+
+
+ |
+
+
+
+ |
+
+
+
+ |
+
+
+
+
+
+
+ |
+
+ |
+
+
+ | |
+
+
+ |
+
+ |
+
+
+ | |
+
+
+ |
+
+ |
+
+
+
+ |
+
+
+ |
+
+ |
+
+
+
+ |
+
+
+
diff --git a/routes/api.php b/routes/api.php
index 93911d3..0bfe3e8 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -244,6 +244,11 @@ Route::prefix('v1')
->group(function () {
Route::get('/', 'index')->name('index');
Route::post('/', 'store')->name('store');
+ Route::get('trashed', 'trashed')->name('trashed');
+ Route::post('bulk-restore', 'bulkRestore')->name('bulk-restore');
+ Route::post('bulk-force-delete', 'bulkForceDelete')->name('bulk-force-delete');
+ Route::post('{id}/restore', 'restore')->name('restore');
+ Route::delete('{id}/force-delete', 'forceDelete')->name('force-delete');
Route::get('{id}', 'show')->name('show');
Route::put('{id}', 'update')->name('update');
Route::delete('{id}', 'destroy')->name('destroy');
@@ -256,6 +261,13 @@ Route::prefix('v1')
Route::get('/', 'index')->name('index');
Route::post('/', 'store')->name('store');
Route::post('sync-wordpress', 'syncWordpress')->name('sync-wordpress');
+ Route::post('bulk-delete', 'bulkDelete')->name('bulk-delete');
+ Route::post('bulk-update-status', 'bulkUpdateStatus')->name('bulk-update-status');
+ Route::get('trashed', 'trashed')->name('trashed');
+ Route::post('bulk-restore', 'bulkRestore')->name('bulk-restore');
+ Route::post('bulk-force-delete', 'bulkForceDelete')->name('bulk-force-delete');
+ Route::post('{id}/restore', 'restore')->name('restore');
+ Route::delete('{id}/force-delete', 'forceDelete')->name('force-delete');
Route::get('{id}', 'show')->name('show');
Route::put('{id}', 'update')->name('update');
Route::delete('{id}', 'destroy')->name('destroy');
diff --git a/routes/client.php b/routes/client.php
index bc87645..d1acf93 100644
--- a/routes/client.php
+++ b/routes/client.php
@@ -50,6 +50,7 @@ Route::prefix('v1')
->controller(OrderControllerRefactor::class)
->group(function () {
Route::get('orders', 'getClientOrders')->name('orders.list');
+ Route::get('orders/num/{orderNum}', 'getClientOrderDetailByOrderNum')->name('orders.detailByOrderNum');
Route::get('orders/{id}', 'getClientOrderDetail')->name('orders.detail');
Route::post('orders/{id}/retry', 'retryOrderProvisioning')->name('orders.retry');
Route::get('invoices', 'getClientInvoices')->name('invoices.list');
@@ -62,6 +63,9 @@ Route::prefix('v1')
Route::get('invoices/order/{orderNum}', 'getClientInvoiceDetailByOrderNum')->name(
'invoices.detailByOrderNum',
);
+ Route::get('invoices/order/{orderNum}/pdf', 'downloadInvoicePdfByOrderNum')->name(
+ 'invoices.pdfByOrderNum',
+ );
Route::get('invoices/{id}', 'getClientInvoiceDetail')->name('invoices.detail');
Route::get('dich-vu', 'getClientServices')->name('services.list');
Route::patch('dich-vu/{id}/auto-renew', 'updateClientServiceAutoRenew')->name(
diff --git a/storage/app/public/tickets/f6cAr67eqedaG5YahnL3dXECkY2npfBQw4BNHHaB.png b/storage/app/public/tickets/f6cAr67eqedaG5YahnL3dXECkY2npfBQw4BNHHaB.png
new file mode 100644
index 0000000..b9d7a0e
Binary files /dev/null and b/storage/app/public/tickets/f6cAr67eqedaG5YahnL3dXECkY2npfBQw4BNHHaB.png differ
diff --git a/storage/app/public/tickets/iC9NQbtmGh5MbvJGyPX5cg34vPojpfIvpEG2d5eK.png b/storage/app/public/tickets/iC9NQbtmGh5MbvJGyPX5cg34vPojpfIvpEG2d5eK.png
new file mode 100644
index 0000000..631bc82
Binary files /dev/null and b/storage/app/public/tickets/iC9NQbtmGh5MbvJGyPX5cg34vPojpfIvpEG2d5eK.png differ
diff --git a/storage/app/public/tickets/smN4so93ITuBlpwUUcM9hH6UWL8ntDUlrNhGaNSY.jpg b/storage/app/public/tickets/smN4so93ITuBlpwUUcM9hH6UWL8ntDUlrNhGaNSY.jpg
new file mode 100644
index 0000000..0a5180a
Binary files /dev/null and b/storage/app/public/tickets/smN4so93ITuBlpwUUcM9hH6UWL8ntDUlrNhGaNSY.jpg differ