diff --git a/app/Enums/AddonType.php b/app/Enums/AddonType.php index 51e2eae..d195a80 100644 --- a/app/Enums/AddonType.php +++ b/app/Enums/AddonType.php @@ -7,6 +7,7 @@ enum AddonType: string case VPS = 'vps'; case HOSTING = 'hosting'; case DOMAIN = 'domain'; + case WEB_DESIGN = 'web_design'; public function label(): string { @@ -14,6 +15,7 @@ enum AddonType: string self::VPS => 'VPS', self::HOSTING => 'Hosting', self::DOMAIN => 'Domain', + self::WEB_DESIGN => 'Web Design', }; } @@ -23,6 +25,7 @@ enum AddonType: string self::VPS->value => self::VPS->label(), self::HOSTING->value => self::HOSTING->label(), self::DOMAIN->value => self::DOMAIN->label(), + self::WEB_DESIGN->value => self::WEB_DESIGN->label(), ]; } diff --git a/app/Enums/WebDesign/WebDesignPlatform.php b/app/Enums/WebDesign/WebDesignPlatform.php new file mode 100644 index 0000000..1d37c69 --- /dev/null +++ b/app/Enums/WebDesign/WebDesignPlatform.php @@ -0,0 +1,71 @@ + 'WordPress + Elementor', + self::WORDPRESS_HEADLESS => 'WordPress Headless', + self::LARAVEL => 'Laravel', + self::NESTJS => 'NestJS', + self::SPRING_BOOT => 'Spring Boot', + }; + } + + /** + * Các framework frontend cho phép theo từng nền tảng. + * Mảng rỗng = không có lựa chọn frontend riêng. + */ + public function frontendOptions(): array + { + return match ($this) { + self::WORDPRESS_ELEMENTOR => [], + self::WORDPRESS_HEADLESS => ['nextjs', 'astro', 'vue', 'react', 'nuxt', 'angular'], + self::LARAVEL, self::NESTJS => ['react', 'nextjs', 'nuxt', 'vue'], + self::SPRING_BOOT => ['react', 'nextjs', 'nuxt', 'vue', 'angular'], + }; + } + + public function defaultFrontend(): ?string + { + return match ($this) { + self::WORDPRESS_ELEMENTOR => null, + self::WORDPRESS_HEADLESS => 'nextjs', + self::LARAVEL, self::NESTJS => 'react', + self::SPRING_BOOT => 'react', + }; + } + + /** + * Từ gói Laravel trở lên có thể custom admin. + */ + public function supportsCustomAdmin(): bool + { + return match ($this) { + self::WORDPRESS_ELEMENTOR, self::WORDPRESS_HEADLESS => false, + self::LARAVEL, self::NESTJS, self::SPRING_BOOT => true, + }; + } + + /** + * Phụ phí bản quyền hằng năm theo USD (Elementor Pro). + */ + public function annualLicenseFeeUsd(): int + { + return $this === self::WORDPRESS_ELEMENTOR ? 60 : 0; + } + + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/app/Enums/WebDesign/WebDesignQuoteStatus.php b/app/Enums/WebDesign/WebDesignQuoteStatus.php new file mode 100644 index 0000000..cc90dbf --- /dev/null +++ b/app/Enums/WebDesign/WebDesignQuoteStatus.php @@ -0,0 +1,26 @@ + 'Nháp', + self::SENT => 'Đã gửi khách', + self::ACCEPTED => 'Khách đồng ý', + self::REJECTED => 'Khách từ chối', + }; + } + + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/app/Http/Controllers/Api/Admin/WebDesign/WebDesignQuoteController.php b/app/Http/Controllers/Api/Admin/WebDesign/WebDesignQuoteController.php new file mode 100644 index 0000000..aeefdee --- /dev/null +++ b/app/Http/Controllers/Api/Admin/WebDesign/WebDesignQuoteController.php @@ -0,0 +1,233 @@ +get('per_page', 15), 100); + + $query = WebDesignQuote::with('creator:id,name') + ->when($request->get('filter'), fn($q, $f) => + $q->where(fn($sub) => $sub + ->where('quote_number', 'like', "%{$f}%") + ->orWhere('customer_name', 'like', "%{$f}%") + ->orWhere('website_name', 'like', "%{$f}%") + ->orWhere('customer_email', 'like', "%{$f}%")) + )->when($request->filled('status'), fn($q) => + $q->where('status', $request->get('status')) + )->when($request->filled('platform'), fn($q) => + $q->where('platform', $request->get('platform')) + )->orderByDesc('id'); + + $paginator = $query->paginate($perPage); + + return response()->json([ + 'error' => false, + 'data' => [ + 'items' => $paginator->items(), + 'total' => $paginator->total(), + 'per_page' => $paginator->perPage(), + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } + + public function show(int $id): JsonResponse + { + $quote = WebDesignQuote::with('creator:id,name')->find($id); + + if (!$quote) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy báo giá.'], 404); + } + + return response()->json([ + 'error' => false, + 'data' => $quote, + ]); + } + + public function store(Request $request): JsonResponse + { + $validated = $this->validateQuote($request); + + $validated['quote_number'] = WebDesignQuote::generateQuoteNumber(); + $validated['created_by'] = $request->user()?->id; + $validated = $this->applyTotals($validated); + + $quote = WebDesignQuote::create($validated); + + return response()->json([ + 'error' => false, + 'message' => 'Tạo báo giá thành công.', + 'data' => $quote, + ], 201); + } + + public function update(Request $request, int $id): JsonResponse + { + $quote = WebDesignQuote::find($id); + + if (!$quote) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy báo giá.'], 404); + } + + $validated = $this->validateQuote($request); + $validated = $this->applyTotals($validated); + + $quote->update($validated); + + return response()->json([ + 'error' => false, + 'message' => 'Cập nhật báo giá thành công.', + 'data' => $quote->fresh('creator:id,name'), + ]); + } + + public function updateStatus(Request $request, int $id): JsonResponse + { + $quote = WebDesignQuote::find($id); + + if (!$quote) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy báo giá.'], 404); + } + + $validated = $request->validate([ + 'status' => ['required', 'string', Rule::in(WebDesignQuoteStatus::values())], + ]); + + $quote->update($validated); + + return response()->json([ + 'error' => false, + 'message' => 'Cập nhật trạng thái báo giá thành công.', + 'data' => $quote->fresh(), + ]); + } + + public function destroy(int $id): JsonResponse + { + $quote = WebDesignQuote::find($id); + + if (!$quote) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy báo giá.'], 404); + } + + $quote->delete(); + + return response()->json([ + 'error' => false, + 'message' => 'Xoá báo giá thành công.', + ]); + } + + public function downloadPdf(int $id, WebDesignQuotePdfService $pdfService): Response|JsonResponse + { + $quote = WebDesignQuote::find($id); + + if (!$quote) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy báo giá.'], 404); + } + + $fileName = $pdfService->generateFileName($quote); + + return response($pdfService->generate($quote), 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'attachment; filename="' . $fileName . '"', + ]); + } + + /** + * Danh sách cấu hình nền tảng cho form báo giá (label, frontend, custom admin, phí bản quyền). + */ + public function platformOptions(): JsonResponse + { + $platforms = array_map(fn(WebDesignPlatform $p) => [ + 'value' => $p->value, + 'label' => $p->label(), + 'frontend_options' => $p->frontendOptions(), + 'default_frontend' => $p->defaultFrontend(), + 'supports_custom_admin' => $p->supportsCustomAdmin(), + 'annual_license_fee_usd' => $p->annualLicenseFeeUsd(), + ], WebDesignPlatform::cases()); + + return response()->json([ + 'error' => false, + 'data' => $platforms, + ]); + } + + private function validateQuote(Request $request): array + { + $platform = WebDesignPlatform::tryFrom((string) $request->get('platform')); + + $validated = $request->validate([ + 'status' => ['nullable', 'string', Rule::in(WebDesignQuoteStatus::values())], + 'customer_name' => 'required|string|max:150', + 'customer_email' => 'nullable|email|max:150', + 'customer_phone' => 'nullable|string|max:30', + 'customer_company' => 'nullable|string|max:200', + 'website_name' => 'required|string|max:200', + 'website_category' => 'required|string|max:100', + 'description' => 'nullable|string|max:5000', + 'requirements' => 'nullable|string|max:5000', + 'reference_websites' => 'nullable|array|max:20', + 'reference_websites.*' => 'string|max:500', + 'platform' => ['required', 'string', Rule::in(WebDesignPlatform::values())], + 'frontend_framework' => [ + Rule::requiredIf(fn() => $platform && count($platform->frontendOptions()) > 0), + 'nullable', + 'string', + Rule::in($platform?->frontendOptions() ?? []), + ], + 'custom_admin' => 'boolean', + 'items' => 'required|array|min:1', + 'items.*.name' => 'required|string|max:300', + 'items.*.description' => 'nullable|string|max:1000', + 'items.*.quantity' => 'required|numeric|min:0', + 'items.*.unit_price' => 'required|numeric|min:0', + 'discount' => 'nullable|numeric|min:0', + 'vat_percent' => 'nullable|numeric|min:0|max:100', + 'currency' => ['nullable', 'string', Rule::in(['VND', 'USD'])], + 'valid_until' => 'nullable|date', + 'note' => 'nullable|string|max:2000', + ]); + + if ($platform && !$platform->supportsCustomAdmin()) { + $validated['custom_admin'] = false; + } + + if ($platform && count($platform->frontendOptions()) === 0) { + $validated['frontend_framework'] = null; + } + + return $validated; + } + + private function applyTotals(array $data): array + { + $subtotal = collect($data['items']) + ->sum(fn($item) => (float) $item['quantity'] * (float) $item['unit_price']); + + $discount = (float) ($data['discount'] ?? 0); + $vatPercent = (float) ($data['vat_percent'] ?? 0); + $taxable = max($subtotal - $discount, 0); + + $data['subtotal'] = round($subtotal, 2); + $data['total'] = round($taxable + $taxable * $vatPercent / 100, 2); + + return $data; + } +} diff --git a/app/Models/WebDesignQuote.php b/app/Models/WebDesignQuote.php new file mode 100644 index 0000000..b5dd209 --- /dev/null +++ b/app/Models/WebDesignQuote.php @@ -0,0 +1,72 @@ + 'array', + 'items' => 'array', + 'custom_admin' => 'boolean', + 'subtotal' => 'float', + 'discount' => 'float', + 'vat_percent' => 'float', + 'total' => 'float', + 'valid_until' => 'date', + ]; + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public static function generateQuoteNumber(): string + { + $prefix = 'BG-WEB-' . now()->format('Ym') . '-'; + + $last = static::withTrashed() + ->where('quote_number', 'like', $prefix . '%') + ->orderByDesc('quote_number') + ->value('quote_number'); + + $next = $last ? ((int) substr($last, strlen($prefix))) + 1 : 1; + + return $prefix . str_pad((string) $next, 4, '0', STR_PAD_LEFT); + } +} diff --git a/app/Services/WebDesignQuotePdfService.php b/app/Services/WebDesignQuotePdfService.php new file mode 100644 index 0000000..bc8da50 --- /dev/null +++ b/app/Services/WebDesignQuotePdfService.php @@ -0,0 +1,43 @@ +load('creator'); + + $setting = Setting::where('key', 'setting')->first(); + $settingValue = $setting ? json_decode((string) $setting->value) : null; + + $platform = WebDesignPlatform::tryFrom($quote->platform); + + $data = [ + 'quote' => $quote, + 'platformLabel' => $platform?->label() ?? $quote->platform, + 'licenseFeeUsd' => $platform?->annualLicenseFeeUsd() ?? 0, + 'companyName' => $settingValue->company ?? 'Thế Giới Số', + 'companyAddress' => $settingValue->address ?? '', + 'companyPhone' => $settingValue->hotline ?? ($settingValue->tel ?? ''), + 'companyEmail' => $settingValue->email ?? '', + 'companyLogo' => $settingValue->logo ?? '', + 'companyTaxCode' => $settingValue->tax ?? '', + 'brandColor' => $settingValue->brand_color ?? '#1a56db', + ]; + + return Pdf::loadView('pdf.web-design-quote', $data) + ->setPaper('a4', 'portrait') + ->output(); + } + + public function generateFileName(WebDesignQuote $quote): string + { + return 'Bao-gia-' . $quote->quote_number . '.pdf'; + } +} diff --git a/database/migrations/2026_07_03_000001_create_web_design_quotes_table.php b/database/migrations/2026_07_03_000001_create_web_design_quotes_table.php new file mode 100644 index 0000000..3411fff --- /dev/null +++ b/database/migrations/2026_07_03_000001_create_web_design_quotes_table.php @@ -0,0 +1,54 @@ +id(); + $table->string('quote_number', 30)->unique(); + $table->string('status', 20)->default('draft')->index(); + + // Thông tin khách hàng + $table->string('customer_name', 150); + $table->string('customer_email', 150)->nullable(); + $table->string('customer_phone', 30)->nullable(); + $table->string('customer_company', 200)->nullable(); + + // Thông tin website + $table->string('website_name', 200); + $table->string('website_category', 100)->index(); + $table->text('description')->nullable(); + $table->text('requirements')->nullable(); + $table->json('reference_websites')->nullable(); + + // Nền tảng + $table->string('platform', 50)->index(); + $table->string('frontend_framework', 50)->nullable(); + $table->boolean('custom_admin')->default(false); + + // Báo giá + $table->json('items'); + $table->decimal('subtotal', 15, 2)->default(0); + $table->decimal('discount', 15, 2)->default(0); + $table->decimal('vat_percent', 5, 2)->default(0); + $table->decimal('total', 15, 2)->default(0); + $table->string('currency', 10)->default('VND'); + $table->date('valid_until')->nullable(); + $table->text('note')->nullable(); + + $table->foreignId('created_by')->nullable()->constrained('user')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('web_design_quotes'); + } +}; diff --git a/resources/views/pdf/web-design-quote.blade.php b/resources/views/pdf/web-design-quote.blade.php new file mode 100644 index 0000000..e6af940 --- /dev/null +++ b/resources/views/pdf/web-design-quote.blade.php @@ -0,0 +1,265 @@ +@php +$brandColor = $brandColor ?? '#1a56db'; + +$frontendLabels = [ + 'nextjs' => 'Next.js', + 'react' => 'React', + 'vue' => 'Vue', + 'nuxt' => 'Nuxt', + 'astro' => 'Astro', + 'angular' => 'Angular', +]; +$frontendLabel = $quote->frontend_framework + ? ($frontendLabels[$quote->frontend_framework] ?? $quote->frontend_framework) + : null; + +$items = is_array($quote->items) ? $quote->items : []; +$referenceWebsites = is_array($quote->reference_websites) ? $quote->reference_websites : []; + +$vatAmount = round(($quote->subtotal - $quote->discount) * $quote->vat_percent / 100, 2); +$fmt = fn ($n) => number_format((float) $n, 0, ',', '.'); +@endphp + + + + +Báo giá {{ $quote->quote_number }} + + + + + + @if ($companyLogo) + + @endif + + + +
logo +
{{ $companyName }}
+ @if ($companyAddress)
Địa chỉ: {{ $companyAddress }}
@endif + @if ($companyPhone)
Điện thoại: {{ $companyPhone }}
@endif + @if ($companyEmail)
Email: {{ $companyEmail }}
@endif + @if ($companyTaxCode)
MST: {{ $companyTaxCode }}
@endif +
+
Số báo giá: {{ $quote->quote_number }}
+
Ngày lập: {{ $quote->created_at?->format('d/m/Y') }}
+ @if ($quote->valid_until) +
Hiệu lực đến: {{ $quote->valid_until->format('d/m/Y') }}
+ @endif +
+ +
+

Báo giá thiết kế website

+
Kính gửi Quý khách hàng
+
+ +
+ +
+
Thông tin khách hàng
+ + + + + +
+ + + @if ($quote->customer_company) + + @endif +
Khách hàng:{{ $quote->customer_name }}
Công ty:{{ $quote->customer_company }}
+
+ + @if ($quote->customer_phone) + + @endif + @if ($quote->customer_email) + + @endif +
Điện thoại:{{ $quote->customer_phone }}
Email:{{ $quote->customer_email }}
+
+
+ +
+
Thông tin dự án website
+ + + + + + + + @if ($licenseFeeUsd > 0) + + @endif + @if ($quote->description) + + @endif + @if ($quote->requirements) + + @endif + @if (count($referenceWebsites) > 0) + + + + + @endif +
Website:{{ $quote->website_name }}
Thể loại:{{ $quote->website_category }}
Nền tảng: + {{ $platformLabel }} + @if ($frontendLabel) — Frontend: {{ $frontendLabel }} @endif + @if ($quote->custom_admin) Custom Admin @endif +
Bản quyền:Elementor Pro — {{ $licenseFeeUsd }}$/năm
Mô tả:{{ $quote->description }}
Yêu cầu:{{ $quote->requirements }}
Website mẫu: +
    + @foreach ($referenceWebsites as $ref) +
  • {{ $ref }}
  • + @endforeach +
+
+
+ +
+
Chi tiết báo giá
+ + + + + + + + + + + + @foreach ($items as $i => $item) + + + + + + + + @endforeach + +
STTHạng mụcSLĐơn giá ({{ $quote->currency }})Thành tiền ({{ $quote->currency }})
{{ $i + 1 }} + {{ $item['name'] ?? '' }} + @if (!empty($item['description'])) +
{{ $item['description'] }}
+ @endif +
{{ $item['quantity'] ?? 1 }}{{ $fmt($item['unit_price'] ?? 0) }}{{ $fmt(($item['quantity'] ?? 1) * ($item['unit_price'] ?? 0)) }}
+ + + + @if ($quote->discount > 0) + + @endif + @if ($quote->vat_percent > 0) + + @endif + +
Tạm tính:{{ $fmt($quote->subtotal) }}
Giảm giá:-{{ $fmt($quote->discount) }}
VAT ({{ rtrim(rtrim(number_format($quote->vat_percent, 2, '.', ''), '0'), '.') }}%):{{ $fmt($vatAmount) }}
Tổng cộng:{{ $fmt($quote->total) }} {{ $quote->currency }}
+
+ +@if ($quote->note) +
+ Ghi chú: {{ $quote->note }} +
+@endif + + + + + + +
+
Đại diện khách hàng
+
(Ký, ghi rõ họ tên)
+
+
Đại diện {{ $companyName }}
+
(Ký, ghi rõ họ tên)
+ @if ($quote->creator) +
{{ $quote->creator->name }}
+ @endif +
+ + + + diff --git a/routes/api.php b/routes/api.php index 441a65b..094dab0 100644 --- a/routes/api.php +++ b/routes/api.php @@ -13,6 +13,7 @@ use App\Http\Controllers\Api\Admin\Invoice\InvoiceTestController; use App\Http\Controllers\Api\Admin\Order\OrderController; use App\Http\Controllers\Api\Admin\Payment\PaymentController; use App\Http\Controllers\Api\Admin\Addon\AddonController; +use App\Http\Controllers\Api\Admin\WebDesign\WebDesignQuoteController; use App\Http\Controllers\Api\Admin\Product\ProductCategoryController; use App\Http\Controllers\Api\Admin\Product\ProductController; use App\Http\Controllers\Api\Admin\VPS\VPSController; @@ -214,6 +215,20 @@ Route::prefix('v1') Route::delete('{id}', 'destroy')->middleware('perm:product-service.destroy'); }); + // Báo giá thiết kế website (module: order) + Route::prefix('web-design-quotes') + ->controller(WebDesignQuoteController::class) + ->group(function () { + Route::get('platform-options', 'platformOptions')->middleware('perm:order.view'); + Route::get('/', 'index')->middleware('perm:order.view'); + Route::post('/', 'store')->middleware('perm:order.create'); + Route::get('{id}', 'show')->middleware('perm:order.view'); + Route::put('{id}', 'update')->middleware('perm:order.edit'); + Route::patch('{id}/status', 'updateStatus')->middleware('perm:order.edit'); + Route::get('{id}/pdf', 'downloadPdf')->middleware('perm:order.view'); + Route::delete('{id}', 'destroy')->middleware('perm:order.destroy'); + }); + Route::prefix('post-categories') ->controller(AdminPostCategoryController::class) ->as('post-categories.')