update
This commit is contained in:
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums\WebDesign;
|
||||
|
||||
enum WebDesignPlatform: string
|
||||
{
|
||||
case WORDPRESS_ELEMENTOR = 'wordpress_elementor';
|
||||
case WORDPRESS_HEADLESS = 'wordpress_headless';
|
||||
case LARAVEL = 'laravel';
|
||||
case NESTJS = 'nestjs';
|
||||
case SPRING_BOOT = 'spring_boot';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::WORDPRESS_ELEMENTOR => '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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums\WebDesign;
|
||||
|
||||
enum WebDesignQuoteStatus: string
|
||||
{
|
||||
case DRAFT = 'draft';
|
||||
case SENT = 'sent';
|
||||
case ACCEPTED = 'accepted';
|
||||
case REJECTED = 'rejected';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::DRAFT => '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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\WebDesign;
|
||||
|
||||
use App\Enums\WebDesign\WebDesignPlatform;
|
||||
use App\Enums\WebDesign\WebDesignQuoteStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\WebDesignQuote;
|
||||
use App\Services\WebDesignQuotePdfService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class WebDesignQuoteController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = min((int) $request->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\LogsActivity;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class WebDesignQuote extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes, LogsActivity;
|
||||
|
||||
protected $table = 'web_design_quotes';
|
||||
|
||||
protected $fillable = [
|
||||
'quote_number',
|
||||
'status',
|
||||
'customer_name',
|
||||
'customer_email',
|
||||
'customer_phone',
|
||||
'customer_company',
|
||||
'website_name',
|
||||
'website_category',
|
||||
'description',
|
||||
'requirements',
|
||||
'reference_websites',
|
||||
'platform',
|
||||
'frontend_framework',
|
||||
'custom_admin',
|
||||
'items',
|
||||
'subtotal',
|
||||
'discount',
|
||||
'vat_percent',
|
||||
'total',
|
||||
'currency',
|
||||
'valid_until',
|
||||
'note',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'reference_websites' => '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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\WebDesign\WebDesignPlatform;
|
||||
use App\Models\Setting;
|
||||
use App\Models\WebDesignQuote;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
|
||||
class WebDesignQuotePdfService
|
||||
{
|
||||
public function generate(WebDesignQuote $quote): string
|
||||
{
|
||||
$quote->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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('web_design_quotes', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
<!DOCTYPE html>
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<title>Báo giá {{ $quote->quote_number }}</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 16mm 18mm; }
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: "DejaVu Sans", "Times New Roman", serif;
|
||||
color: #1f2937;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.top-table { width: 100%; border-collapse: collapse; margin-bottom: 8px; table-layout: fixed; }
|
||||
.top-table td { vertical-align: top; }
|
||||
.logo-cell { width: 32mm; }
|
||||
.logo-cell img { width: 30mm; max-height: 16mm; }
|
||||
.company-cell { padding-left: 8px; font-size: 10px; line-height: 1.5; }
|
||||
.company-cell .company-name { font-size: 13px; font-weight: bold; text-transform: uppercase; color: {{ $brandColor }}; }
|
||||
.meta-cell { width: 52mm; font-size: 10px; text-align: right; }
|
||||
|
||||
.doc-title { text-align: center; margin: 10px 0 2px; }
|
||||
.doc-title h1 { font-size: 20px; font-weight: bold; text-transform: uppercase; letter-spacing: 1px; color: {{ $brandColor }}; }
|
||||
.doc-title .sub { font-size: 11px; font-style: italic; color: #6b7280; }
|
||||
|
||||
.divider { border: none; border-top: 2px solid {{ $brandColor }}; margin: 8px 0 10px; }
|
||||
|
||||
/* Section */
|
||||
.section { margin-bottom: 10px; }
|
||||
.section-title {
|
||||
font-size: 12px; font-weight: bold; text-transform: uppercase;
|
||||
color: {{ $brandColor }}; border-bottom: 1px solid #e5e7eb;
|
||||
padding-bottom: 3px; margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.info-table { width: 100%; border-collapse: collapse; table-layout: fixed; }
|
||||
.info-table td { padding: 2px 8px 2px 0; vertical-align: top; }
|
||||
.info-table .lbl { width: 34mm; font-weight: bold; color: #374151; }
|
||||
|
||||
.two-col { width: 100%; border-collapse: collapse; table-layout: fixed; }
|
||||
.two-col > tbody > tr > td { width: 50%; vertical-align: top; padding-right: 10px; }
|
||||
|
||||
/* Items table */
|
||||
.items-table { width: 100%; border-collapse: collapse; margin-top: 4px; font-size: 10px; table-layout: fixed; }
|
||||
.items-table th, .items-table td { border: 1px solid #d1d5db; padding: 6px 7px; vertical-align: top; overflow-wrap: break-word; }
|
||||
.items-table thead th { background: {{ $brandColor }}; color: #fff; font-weight: bold; text-align: center; }
|
||||
.items-table tr { page-break-inside: avoid; }
|
||||
.items-table td.center { text-align: center; }
|
||||
.items-table td.right { text-align: right; }
|
||||
.items-table .item-desc { color: #6b7280; font-size: 9px; }
|
||||
|
||||
/* Summary */
|
||||
.summary-table { width: 62mm; border-collapse: collapse; margin-top: 6px; margin-left: auto; font-size: 11px; }
|
||||
.summary-table td { padding: 3px 6px; }
|
||||
.summary-table .lbl { text-align: left; color: #374151; }
|
||||
.summary-table .val { text-align: right; }
|
||||
.summary-table tr.total td {
|
||||
font-weight: bold; font-size: 13px; color: {{ $brandColor }};
|
||||
border-top: 1.5px solid {{ $brandColor }};
|
||||
}
|
||||
|
||||
.note-box { background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 3px; padding: 7px 9px; margin-top: 6px; }
|
||||
|
||||
.badge { display: inline-block; padding: 1px 6px; border: 1px solid {{ $brandColor }}; border-radius: 3px; color: {{ $brandColor }}; font-size: 9px; }
|
||||
|
||||
ul.ref-list { margin: 2px 0 0 14px; }
|
||||
|
||||
/* Footer / sign */
|
||||
.sign-table { width: 100%; border-collapse: collapse; margin-top: 12mm; text-align: center; page-break-inside: avoid; }
|
||||
.sign-table td { width: 50%; vertical-align: top; }
|
||||
.sign-title { font-weight: bold; }
|
||||
.sign-hint { font-size: 9px; font-style: italic; color: #6b7280; }
|
||||
.footer-note { margin-top: 8mm; text-align: center; font-size: 9px; font-style: italic; color: #6b7280; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<table class="top-table">
|
||||
<tr>
|
||||
@if ($companyLogo)
|
||||
<td class="logo-cell"><img src="{{ $companyLogo }}" alt="logo"/></td>
|
||||
@endif
|
||||
<td class="company-cell">
|
||||
<div class="company-name">{{ $companyName }}</div>
|
||||
@if ($companyAddress)<div>Địa chỉ: {{ $companyAddress }}</div>@endif
|
||||
@if ($companyPhone)<div>Điện thoại: {{ $companyPhone }}</div>@endif
|
||||
@if ($companyEmail)<div>Email: {{ $companyEmail }}</div>@endif
|
||||
@if ($companyTaxCode)<div>MST: {{ $companyTaxCode }}</div>@endif
|
||||
</td>
|
||||
<td class="meta-cell">
|
||||
<div><strong>Số báo giá:</strong> {{ $quote->quote_number }}</div>
|
||||
<div><strong>Ngày lập:</strong> {{ $quote->created_at?->format('d/m/Y') }}</div>
|
||||
@if ($quote->valid_until)
|
||||
<div><strong>Hiệu lực đến:</strong> {{ $quote->valid_until->format('d/m/Y') }}</div>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="doc-title">
|
||||
<h1>Báo giá thiết kế website</h1>
|
||||
<div class="sub">Kính gửi Quý khách hàng</div>
|
||||
</div>
|
||||
|
||||
<hr class="divider"/>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Thông tin khách hàng</div>
|
||||
<table class="two-col">
|
||||
<tr>
|
||||
<td>
|
||||
<table class="info-table">
|
||||
<tr><td class="lbl">Khách hàng:</td><td>{{ $quote->customer_name }}</td></tr>
|
||||
@if ($quote->customer_company)
|
||||
<tr><td class="lbl">Công ty:</td><td>{{ $quote->customer_company }}</td></tr>
|
||||
@endif
|
||||
</table>
|
||||
</td>
|
||||
<td>
|
||||
<table class="info-table">
|
||||
@if ($quote->customer_phone)
|
||||
<tr><td class="lbl">Điện thoại:</td><td>{{ $quote->customer_phone }}</td></tr>
|
||||
@endif
|
||||
@if ($quote->customer_email)
|
||||
<tr><td class="lbl">Email:</td><td>{{ $quote->customer_email }}</td></tr>
|
||||
@endif
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Thông tin dự án website</div>
|
||||
<table class="info-table">
|
||||
<tr><td class="lbl">Website:</td><td>{{ $quote->website_name }}</td></tr>
|
||||
<tr><td class="lbl">Thể loại:</td><td>{{ $quote->website_category }}</td></tr>
|
||||
<tr>
|
||||
<td class="lbl">Nền tảng:</td>
|
||||
<td>
|
||||
{{ $platformLabel }}
|
||||
@if ($frontendLabel) — Frontend: {{ $frontendLabel }} @endif
|
||||
@if ($quote->custom_admin) <span class="badge">Custom Admin</span> @endif
|
||||
</td>
|
||||
</tr>
|
||||
@if ($licenseFeeUsd > 0)
|
||||
<tr><td class="lbl">Bản quyền:</td><td>Elementor Pro — {{ $licenseFeeUsd }}$/năm</td></tr>
|
||||
@endif
|
||||
@if ($quote->description)
|
||||
<tr><td class="lbl">Mô tả:</td><td>{{ $quote->description }}</td></tr>
|
||||
@endif
|
||||
@if ($quote->requirements)
|
||||
<tr><td class="lbl">Yêu cầu:</td><td>{{ $quote->requirements }}</td></tr>
|
||||
@endif
|
||||
@if (count($referenceWebsites) > 0)
|
||||
<tr>
|
||||
<td class="lbl">Website mẫu:</td>
|
||||
<td>
|
||||
<ul class="ref-list">
|
||||
@foreach ($referenceWebsites as $ref)
|
||||
<li>{{ $ref }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
@endif
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Chi tiết báo giá</div>
|
||||
<table class="items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 8mm;">STT</th>
|
||||
<th>Hạng mục</th>
|
||||
<th style="width: 12mm;">SL</th>
|
||||
<th style="width: 26mm;">Đơn giá ({{ $quote->currency }})</th>
|
||||
<th style="width: 28mm;">Thành tiền ({{ $quote->currency }})</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($items as $i => $item)
|
||||
<tr>
|
||||
<td class="center">{{ $i + 1 }}</td>
|
||||
<td>
|
||||
{{ $item['name'] ?? '' }}
|
||||
@if (!empty($item['description']))
|
||||
<div class="item-desc">{{ $item['description'] }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="center">{{ $item['quantity'] ?? 1 }}</td>
|
||||
<td class="right">{{ $fmt($item['unit_price'] ?? 0) }}</td>
|
||||
<td class="right">{{ $fmt(($item['quantity'] ?? 1) * ($item['unit_price'] ?? 0)) }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table class="summary-table">
|
||||
<tr><td class="lbl">Tạm tính:</td><td class="val">{{ $fmt($quote->subtotal) }}</td></tr>
|
||||
@if ($quote->discount > 0)
|
||||
<tr><td class="lbl">Giảm giá:</td><td class="val">-{{ $fmt($quote->discount) }}</td></tr>
|
||||
@endif
|
||||
@if ($quote->vat_percent > 0)
|
||||
<tr><td class="lbl">VAT ({{ rtrim(rtrim(number_format($quote->vat_percent, 2, '.', ''), '0'), '.') }}%):</td><td class="val">{{ $fmt($vatAmount) }}</td></tr>
|
||||
@endif
|
||||
<tr class="total"><td class="lbl">Tổng cộng:</td><td class="val">{{ $fmt($quote->total) }} {{ $quote->currency }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if ($quote->note)
|
||||
<div class="note-box">
|
||||
<strong>Ghi chú:</strong> {{ $quote->note }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<table class="sign-table">
|
||||
<tr>
|
||||
<td>
|
||||
<div class="sign-title">Đại diện khách hàng</div>
|
||||
<div class="sign-hint">(Ký, ghi rõ họ tên)</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="sign-title">Đại diện {{ $companyName }}</div>
|
||||
<div class="sign-hint">(Ký, ghi rõ họ tên)</div>
|
||||
@if ($quote->creator)
|
||||
<div style="margin-top: 16mm;">{{ $quote->creator->name }}</div>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="footer-note">
|
||||
Báo giá có hiệu lực {{ $quote->valid_until ? 'đến ngày ' . $quote->valid_until->format('d/m/Y') : 'trong 30 ngày kể từ ngày lập' }}.
|
||||
Cảm ơn Quý khách đã quan tâm đến dịch vụ thiết kế website của {{ $companyName }}.
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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.')
|
||||
|
||||
Reference in New Issue
Block a user