diff --git a/README.md b/README.md deleted file mode 100644 index 53505bd..0000000 --- a/README.md +++ /dev/null @@ -1,235 +0,0 @@ -# Portal New - Hệ thống Quản lý Hosting & VPS - -[![Laravel](https://img.shields.io/badge/Laravel-10.x-red.svg)](https://laravel.com) -[![PHP](https://img.shields.io/badge/PHP-8.1+-blue.svg)](https://php.net) -[![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) - -## 📋 Mô tả - -Portal New là một hệ thống quản lý hosting và VPS toàn diện được xây dựng trên nền tảng Laravel 10. Hệ thống cung cấp giao diện quản trị cho admin và giao diện khách hàng để mua sắm, quản lý dịch vụ hosting, VPS, domain và các dịch vụ liên quan. - -## ✨ Tính năng chính - -### 🛒 Giao diện khách hàng -- **Đăng ký/Đăng nhập**: Hệ thống xác thực khách hàng -- **Mua sắm dịch vụ**: Hosting, VPS, Domain -- **Giỏ hàng**: Quản lý đơn hàng và thanh toán -- **Quản lý dịch vụ**: Theo dõi trạng thái hosting, VPS -- **Hỗ trợ**: Hệ thống ticket support -- **Thanh toán**: Tích hợp cổng thanh toán VNPay -- **Nạp tiền**: Quản lý tài khoản và giao dịch - -### 🔧 Giao diện quản trị -- **Quản lý khách hàng**: Thông tin, dịch vụ, hóa đơn -- **Quản lý sản phẩm**: Hosting, VPS, Domain, Addon -- **Quản lý đơn hàng**: Xử lý đơn hàng và thanh toán -- **Quản lý hóa đơn**: Tạo và quản lý hóa đơn -- **Quản lý server**: VMware vCenter, DNS, Hosting servers -- **Hỗ trợ khách hàng**: Xử lý ticket và yêu cầu -- **Báo cáo**: Thống kê và báo cáo hệ thống - -### 🚀 Tính năng kỹ thuật -- **VMware Integration**: Quản lý VPS thông qua vCenter -- **DNS Management**: Quản lý DNS servers -- **Hosting Management**: Tự động tạo hosting accounts -- **Snapshot Management**: Backup và restore VPS -- **Console Access**: Truy cập console VPS qua WMKS -- **Email System**: Gửi email thông báo tự động -- **Payment Gateway**: Tích hợp VNPay - -## 🛠️ Yêu cầu hệ thống - -- **PHP**: >= 8.1 -- **Laravel**: 10.x -- **Database**: MySQL 8.0+ hoặc MariaDB 10.5+ -- **Redis**: Cho cache và queue -- **Composer**: Để quản lý dependencies -- **Node.js**: Cho build assets (tùy chọn) - -## 📦 Cài đặt - -### 1. Clone repository -```bash -git clone -cd portal_new -``` - -### 2. Cài đặt dependencies -```bash -composer install -npm install -``` - -### 3. Cấu hình môi trường -```bash -cp .env.example .env -php artisan key:generate -``` - -### 4. Cấu hình database -Chỉnh sửa file `.env`: -```env -DB_CONNECTION=mysql -DB_HOST=127.0.0.1 -DB_PORT=3306 -DB_DATABASE=portal_new -DB_USERNAME=your_username -DB_PASSWORD=your_password -``` - -### 5. Chạy migration và seeder -```bash -php artisan migrate -php artisan db:seed -``` - -### 6. Cấu hình storage -```bash -php artisan storage:link -``` - -### 7. Build assets -```bash -npm run build -``` - -### 8. Cấu hình queue (tùy chọn) -```bash -php artisan queue:work -``` - -## 🔧 Cấu hình - -### VMware vCenter -Cấu hình kết nối VMware vCenter trong file `.env`: -```env -VCENTER_HOST=your_vcenter_host -VCENTER_USERNAME=your_username -VCENTER_PASSWORD=your_password -VCENTER_PORT=443 -``` - -### Payment Gateway (VNPay) -```env -VNPAY_TMN_CODE=your_tmn_code -VNPAY_HASH_SECRET=your_hash_secret -VNPAY_URL=https://sandbox.vnpayment.vn/paymentv2/vpcpay.html -``` - -### SMTP Configuration -```env -MAIL_MAILER=smtp -MAIL_HOST=your_smtp_host -MAIL_PORT=587 -MAIL_USERNAME=your_email -MAIL_PASSWORD=your_password -MAIL_ENCRYPTION=tls -``` - -## 📁 Cấu trúc thư mục - -``` -portal_new/ -├── app/ -│ ├── Console/Commands/ # Artisan commands -│ ├── Events/ # Event classes -│ ├── Http/ -│ │ ├── Controllers/ # Controllers -│ │ │ ├── admin/ # Admin controllers -│ │ │ └── frontend/ # Frontend controllers -│ │ ├── Livewire/ # Livewire components -│ │ ├── Middleware/ # Custom middleware -│ │ ├── Requests/ # Form requests -│ │ └── Services/ # Business logic services -│ ├── Jobs/ # Queue jobs -│ ├── Mail/ # Mail classes -│ ├── Models/ # Eloquent models -│ ├── Notifications/ # Notification classes -│ ├── Observers/ # Model observers -│ ├── Providers/ # Service providers -│ └── Repositories/ # Repository pattern -├── config/ # Configuration files -├── database/ -│ ├── migrations/ # Database migrations -│ ├── seeders/ # Database seeders -│ └── factories/ # Model factories -├── public/ # Public assets -├── resources/ -│ ├── views/ # Blade templates -│ ├── css/ # Stylesheets -│ └── js/ # JavaScript files -└── routes/ # Route definitions -``` - -## 🚀 Sử dụng - -### Truy cập hệ thống -- **Admin Panel**: `http://your-domain/administrator` -- **Frontend**: `http://your-domain` - -### Tài khoản mặc định -Sau khi chạy seeder, bạn có thể đăng nhập với: -- **Admin**: admin@example.com / password -- **Customer**: customer@example.com / password - -## 🔒 Bảo mật - -- Sử dụng Laravel Sanctum cho API authentication -- CSRF protection cho tất cả forms -- Input validation và sanitization -- Role-based access control (RBAC) -- Secure file upload handling - -## 📊 Monitoring - -Hệ thống sử dụng Laravel Horizon để monitoring queues: -```bash -php artisan horizon -``` - -## 🧪 Testing - -```bash -php artisan test -``` - -## 📝 API Documentation - -API endpoints được định nghĩa trong `routes/api.php`: -- Authentication endpoints -- Customer management -- Service management -- Payment processing -- VMware operations - -## 🤝 Đóng góp - -1. Fork the project -2. Create your feature branch (`git checkout -b feature/AmazingFeature`) -3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) -4. Push to the branch (`git push origin feature/AmazingFeature`) -5. Open a Pull Request - -## 📄 License - -Dự án này được phân phối dưới giấy phép MIT. Xem file `LICENSE` để biết thêm chi tiết. - -## 📞 Hỗ trợ - -Nếu bạn gặp vấn đề hoặc có câu hỏi, vui lòng: -- Tạo issue trên GitHub -- Liên hệ qua email: support@example.com - -## 🔄 Changelog - -### Version 1.0.0 -- Initial release -- Basic hosting and VPS management -- Customer portal -- Admin panel -- Payment integration -- VMware integration - ---- - -**Lưu ý**: Đây là dự án đang phát triển. Vui lòng kiểm tra kỹ trước khi sử dụng trong môi trường production. diff --git a/README_OBFUSCATION.md b/README_OBFUSCATION.md deleted file mode 100644 index 24f9855..0000000 --- a/README_OBFUSCATION.md +++ /dev/null @@ -1,238 +0,0 @@ -# JavaScript Obfuscation & Minification Guide - -## 🚀 Overview - -This project uses `javascript-obfuscator` to both minify and obfuscate JavaScript files, providing better security and performance. The obfuscation makes the code harder to reverse-engineer while maintaining functionality. - -## 📦 Installation - -1. Install dependencies: -```bash -npm install -``` - -2. Install javascript-obfuscator: -```bash -npm install --save-dev javascript-obfuscator -``` - -## 🔧 Available Commands - -### Laravel Mix Minification -```bash -# Development build -npm run dev - -# Production build (minified) -npm run prod - -# Watch for changes -npm run watch -``` - -### Custom Obfuscation Script -```bash -# Obfuscate all JS files using custom script -npm run minify - -# Run production build + obfuscation -npm run minify-all -``` - -## 📁 Files Processed - -### Laravel Mix (webpack.mix.js) -- `resources/js/app.js` → `public/js/app.js` -- `resources/js/service/service.js` → `public/js/service/service.min.js` -- `resources/js/whois.js` → `public/js/whois.min.js` -- `resources/js/core.js` → `public/js/core.min.js` -- `resources/js/main.js` → `public/js/main.min.js` -- `resources/js/service/vminfo.js` → `public/js/service/vminfo.min.js` - -### Custom Script (scripts/minify-js.js) -- All `.js` files in `resources/js/` -- All `.js` files in `resources/js/service/` - -## ⚙️ Obfuscation Configuration - -```javascript -const config = { - compact: true, // Minify code - controlFlowFlattening: false, // Disable control flow flattening - controlFlowFlatteningThreshold: 0.75, - deadCodeInjection: false, // Disable dead code injection - deadCodeInjectionThreshold: 0.4, - debugProtection: false, // Disable debug protection - debugProtectionInterval: 0, - disableConsoleOutput: false, // Keep console.log for debugging - identifierNamesGenerator: 'hexadecimal', // Use hex names for variables - log: false, - numbersToExpressions: false, // Keep numbers as-is - renameGlobals: false, // Don't rename global variables - selfDefending: false, // Disable self-defending - simplify: true, // Simplify code - splitStrings: false, // Don't split strings - splitStringsChunkLength: 10, - stringArray: true, // Enable string array transformation - stringArrayEncoding: [], // No encoding for strings - stringArrayThreshold: 0.75, - transformObjectKeys: false, // Don't transform object keys - unicodeEscapeSequence: false // Don't use unicode escape sequences -}; -``` - -## 🔒 Security Features - -### What Obfuscation Does: -- ✅ **Variable name obfuscation** - Variables become hex names -- ✅ **String array transformation** - Strings are moved to arrays -- ✅ **Code minification** - Removes whitespace and comments -- ✅ **Identifier obfuscation** - Function names become unreadable -- ✅ **Dead code injection protection** - Makes reverse engineering harder - -### Security Benefits: -- 🔒 **Code protection** - Makes it difficult to understand the logic -- 🔒 **Intellectual property protection** - Protects your business logic -- 🔒 **Tampering detection** - Can detect if code has been modified -- 🔒 **License enforcement** - Can embed licensing checks - -## 📊 Performance Benefits - -### Typical Size Reduction: -- **whois.js**: ~8.4KB → ~3.2KB (62% reduction) -- **core.js**: ~21KB → ~8.5KB (60% reduction) -- **main.js**: ~6.6KB → ~2.8KB (58% reduction) - -### Loading Time Improvement: -- **Faster parsing** - Minified code loads faster -- **Reduced bandwidth** - Smaller file sizes -- **Better caching** - Consistent file hashes - -## 🔄 Usage in Blade Templates - -### Before (Development): -```html - - -``` - -### After (Production): -```html - - -``` - -## 🛠️ Development Workflow - -### 1. Development -```bash -npm run watch -``` -- Edit JS files in real-time -- Automatic compilation -- Source maps for debugging - -### 2. Production Build -```bash -npm run minify-all -``` -- Obfuscate and minify all assets -- Generate production-ready files -- Optimize for performance and security - -### 3. Deployment -- Upload obfuscated files to server -- Update Blade templates to use `.min.js` files -- Enable server-side caching - -## 🔍 Monitoring - -### Check File Sizes: -```bash -# List file sizes -ls -lh public/js/*.js -ls -lh public/js/*.min.js -``` - -### Compare Original vs Obfuscated: -```bash -# Example for whois.js -wc -c public/js/whois.js -wc -c public/js/whois.min.js -``` - -## 🚨 Troubleshooting - -### Common Issues: - -1. **javascript-obfuscator not found** - ```bash - npm install --save-dev javascript-obfuscator - ``` - -2. **Obfuscation errors** - - Check for syntax errors in source files - - Ensure all dependencies are properly imported - - Verify file paths are correct - -3. **Performance issues** - - Reduce obfuscation intensity for large files - - Disable heavy features like `controlFlowFlattening` - -### Debug Mode: -```javascript -// In scripts/minify-js.js, modify config: -disableConsoleOutput: false, -log: true, -compact: false -``` - -## 📈 Best Practices - -1. **Test thoroughly** - Obfuscated code may behave differently -2. **Keep source maps** for debugging (development only) -3. **Use versioning** for cache busting -4. **Monitor performance** after obfuscation -5. **Backup original files** before obfuscation - -## 🔧 Advanced Configuration - -### High Security Mode: -```javascript -const highSecurityConfig = { - ...config, - controlFlowFlattening: true, - deadCodeInjection: true, - debugProtection: true, - selfDefending: true, - stringArrayEncoding: ['base64'], - transformObjectKeys: true -}; -``` - -### Performance Mode: -```javascript -const performanceConfig = { - ...config, - stringArray: false, - controlFlowFlattening: false, - deadCodeInjection: false, - debugProtection: false -}; -``` - -## 🔗 Related Files - -- `webpack.mix.js` - Laravel Mix configuration -- `scripts/minify-js.js` - Custom obfuscation script -- `package.json` - NPM scripts and dependencies -- `resources/js/` - JavaScript source files -- `public/js/*.min.js` - Obfuscated output files - -## 🎯 Benefits Summary - -- 🔒 **Enhanced Security** - Code is protected from reverse engineering -- 📦 **Smaller File Sizes** - Reduced bandwidth usage -- ⚡ **Better Performance** - Faster loading times -- 🛡️ **IP Protection** - Protects intellectual property -- 🔧 **Easy Integration** - Works with existing Laravel workflow \ No newline at end of file diff --git a/app/Http/Controllers/admin/package/OsVersion.php b/app/Http/Controllers/admin/package/OsVersion.php index b99955a..f8a5851 100644 --- a/app/Http/Controllers/admin/package/OsVersion.php +++ b/app/Http/Controllers/admin/package/OsVersion.php @@ -117,16 +117,19 @@ class OsVersion extends Controller return DataTables::eloquent($query) ->editColumn('os_group', function ($d) { return $d->osGroup ? $d->osGroup->name . ' - ' . $d->osGroup->host->name : ''; - }) - ->editColumn('status', function ($d) { - return $d->getStatus(); - }) + }) ->addColumn('action', function ($d) { return view('administrator.pages.package.version.datatable.action', [ 'id' => $d->id, 'version' => $d->version ])->render(); }) + ->addColumn( + 'status', + function ($d) { + return view('administrator.pages.package.version.datatable.status', ['row' => $d])->render(); + } + ) ->filterColumn('host', function ($query, $keyword) { $query->whereHas('host', function ($q) use ($keyword) { $q->where('host', 'like', "%{$keyword}%"); diff --git a/app/Http/Controllers/admin/product/ProductController.php b/app/Http/Controllers/admin/product/ProductController.php index b8d570a..3c05ff1 100644 --- a/app/Http/Controllers/admin/product/ProductController.php +++ b/app/Http/Controllers/admin/product/ProductController.php @@ -4,8 +4,7 @@ namespace App\Http\Controllers\admin\product; use App\Enums\Product\ProductTypeEnums; use App\Http\Controllers\Controller; -use App\Http\Requests\Product\StoreProductRequest; -use App\Http\Requests\Product\UpdateProductRequest; +use App\Http\Requests\Product\ProductRequest; use App\Http\Services\ProductService; use App\Repositories\Group\GroupRepositoryInterface; use App\Repositories\Admin\Product\ProductRepositoryInterface; @@ -42,11 +41,15 @@ class ProductController extends Controller { $step = $request->get('step') ?? 1; $title = 'Add new product'; - $group = $this->groupRepository->getAll(); + // Fetch Product Categories instead of generic groups + $group = \App\Models\ProductCategories::where('is_active', \App\Enums\Status::ACTIVE->value)->get(); $type = $this->typeRepository->getAll(); - + + // Fetch OS versions with their group for the OS selector component + $osVersions = \App\Models\Os::with('osGroup')->where('status', 1)->get(); + if ($step === 1) { - return view("administrator.pages.product.create", compact('group', 'type', 'title')); + return view("administrator.pages.product.create", compact('group', 'type', 'title', 'osVersions')); } if (!$request->has(['step', 'type'])) { @@ -58,59 +61,62 @@ class ProductController extends Controller $view = "administrator.pages.product.create"; - $data = compact('type_id', 'title', 'group', 'type', 'product_name'); + $data = compact('type_id', 'title', 'group', 'type', 'product_name', 'osOptions'); return view($view, $data); } - public function store(StoreProductRequest $request) + public function store(ProductRequest $request) { try { $this->productService->store($request->validated()); - return redirect()->route('product.index')->with('success', 'Add new product success'); + return redirect()->route('products.index')->with('success', 'Add new product success'); } catch (Exception $e) { return back()->withErrors($e->getMessage())->withInput(); } } - public function edit(Request $request) + public function edit(Request $request, $id) { - $product = $this->productRepository->find($request->product_id); + $product = $this->productRepository->find($id); if (!$product) { return back()->withErrors("Can't find product."); } - $title = 'View product item'; - $group = $this->groupRepository->getAll(); + // Load relationships + $product->load(['pricings', 'osVersions', 'category']); + + $title = 'Edit product: ' . $product->name; + $group = \App\Models\ProductCategories::where('is_active', \App\Enums\Status::ACTIVE->value)->get(); $type = $this->typeRepository->getAll(); - $productType = ProductTypeEnums::tryFrom($product->product_type); - $type_id = $this->mapEnumToTypeId($productType); + // Fetch OS versions for select + $osVersions = \App\Models\Os::with('osGroup')->where('status', 1)->get(); $view = "administrator.pages.product.edit"; - $data = compact('product', 'title', 'group', 'type', 'type_id'); + $data = compact('product', 'title', 'group', 'type', 'osVersions'); return view($view, $data); } - public function update(UpdateProductRequest $request) + public function update(ProductRequest $request, $id) { try { - $this->productService->update($request->product_id, $request->validated()); - return redirect()->route('product.index')->with('success', 'Update product success'); + $this->productService->update((int) $id, $request->validated()); + return redirect()->route('products.index')->with('success', 'Update product success'); } catch (Exception $e) { return back()->withErrors($e->getMessage())->withInput(); } } - public function destroy(Request $request) + public function destroy(Request $request, $id) { try { - $this->productService->delete($request->product_id); - return redirect()->route('product.index')->with('success', 'Deleted successfully'); + $this->productService->delete((int) $id); + return redirect()->route('products.index')->with('success', 'Deleted successfully'); } catch (Exception $e) { return back()->withErrors($e->getMessage()); } @@ -120,19 +126,33 @@ class ProductController extends Controller { $query = $this->productRepository->filter( $request->all(), - [], + ['category'], [], ['*'], true ); return DataTables::eloquent($query) + ->addColumn('category_name', function ($d) { + return $d->category?->name ?? '-'; + }) + ->addColumn('product_type_label', function ($d) { + $type = ProductTypeEnums::tryFrom($d->product_type); + return $type ? $type->label() : '-'; + }) + ->addColumn('specs', function ($d) { + return view('administrator.pages.product.datatable.specs', ['row' => $d])->render(); + }) + ->addColumn('status', function ($d) { + return view('administrator.pages.product.datatable.status', ['row' => $d])->render(); + }) ->addColumn( 'action', function ($d) { return view('administrator.pages.product.datatable.action', ['row' => $d])->render(); } ) + ->rawColumns(['specs', 'status', 'action']) ->make(true); } diff --git a/app/Http/Requests/Product/ProductRequest.php b/app/Http/Requests/Product/ProductRequest.php new file mode 100644 index 0000000..86b94c3 --- /dev/null +++ b/app/Http/Requests/Product/ProductRequest.php @@ -0,0 +1,179 @@ +|string> + */ + public function rules(): array + { + $productId = $this->route('id'); + + $rules = [ + 'product_type' => ['required', 'string', 'max:50'], + 'product_categories_id' => ['required', 'exists:product_categories,id'], + 'name' => [ + 'required', + 'string', + 'max:255', + function ($attribute, $value, $fail) use ($productId) { + $slug = Str::slug($value); + $query = Product::where('slug', $slug); + + // Exclude current product when updating + if ($productId) { + $query->where('id', '!=', $productId); + } + + if ($query->exists()) { + $fail('Tên sản phẩm đã tồn tại. Vui lòng chọn tên khác.'); + } + }, + ], + 'short_description' => ['nullable', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'stock' => ['nullable', 'integer', 'min:0'], + 'is_unlimited' => ['boolean'], + 'cpu' => ['nullable', 'string', 'max:255'], + 'ram' => ['nullable', 'string', 'max:255'], + 'gpu' => ['nullable', 'string', 'max:255'], + 'disk' => ['nullable', 'string', 'max:255'], + 'bandwidth' => ['nullable', 'string', 'max:255'], + 'features' => ['nullable', 'json'], + 'metadata' => ['nullable', 'json'], + 'setup_fee' => ['nullable', 'numeric', 'min:0'], + 'trial_days' => ['nullable', 'integer', 'min:0'], + 'currency' => ['nullable', 'string', 'max:10'], + 'is_active' => ['boolean'], + 'sort_order' => ['nullable', 'integer'], + 'published_at' => ['nullable', 'date'], + + // OS versions (pivot table os_product) + 'os_ids' => ['nullable', 'array'], + 'os_ids.*' => ['exists:os,id'], + + // Pricing cycles (table product_pricings) + 'pricing' => ['nullable', 'array'], + ]; + + // Add dynamic validation for each billing cycle + foreach (ProductBillingCycle::cases() as $cycle) { + $cycleKey = $cycle->value; + + $rules["pricing.{$cycleKey}.enabled"] = ['nullable', 'boolean']; + $rules["pricing.{$cycleKey}.price"] = ['nullable', 'numeric', 'min:0']; + $rules["pricing.{$cycleKey}.price_sale"] = [ + 'nullable', + 'numeric', + 'min:0', + "required_with:pricing.{$cycleKey}.price_sale_check", + function ($attribute, $value, $fail) use ($cycleKey) { + $price = $this->input("pricing.{$cycleKey}.price"); + + // price_sale requires price to be set first + if ($value !== null && $value !== '' && (empty($price) || $price <= 0)) { + $fail("Bạn phải nhập giá gốc trước khi nhập giá khuyến mãi cho {$cycleKey}."); + return; + } + + // price_sale must be less than price + if ($value !== null && $value !== '' && $price !== null && $value >= $price) { + $fail("Giá khuyến mãi phải nhỏ hơn giá gốc cho {$cycleKey}."); + } + }, + ]; + } + + return $rules; + } + + /** + * Get the error messages for the defined validation rules. + * + * @return array + */ + public function messages(): array + { + return [ + 'product_type.required' => 'Vui lòng chọn loại sản phẩm.', + 'product_type.string' => 'Loại sản phẩm phải là chuỗi ký tự.', + 'product_type.max' => 'Loại sản phẩm không được vượt quá 50 ký tự.', + + 'product_categories_id.required' => 'Vui lòng chọn danh mục sản phẩm.', + 'product_categories_id.exists' => 'Danh mục sản phẩm không hợp lệ.', + + 'name.required' => 'Vui lòng nhập tên sản phẩm.', + 'name.string' => 'Tên sản phẩm phải là chuỗi ký tự.', + 'name.max' => 'Tên sản phẩm không được vượt quá 255 ký tự.', + + 'short_description.string' => 'Mô tả ngắn phải là chuỗi ký tự.', + 'short_description.max' => 'Mô tả ngắn không được vượt quá 255 ký tự.', + + 'description.string' => 'Mô tả phải là chuỗi ký tự.', + + 'stock.integer' => 'Số lượng tồn kho phải là số nguyên.', + 'stock.min' => 'Số lượng tồn kho không được âm.', + + 'is_unlimited.boolean' => 'Giá trị không giới hạn không hợp lệ.', + + 'cpu.string' => 'CPU phải là chuỗi ký tự.', + 'cpu.max' => 'CPU không được vượt quá 255 ký tự.', + + 'ram.string' => 'RAM phải là chuỗi ký tự.', + 'ram.max' => 'RAM không được vượt quá 255 ký tự.', + + 'gpu.string' => 'GPU phải là chuỗi ký tự.', + 'gpu.max' => 'GPU không được vượt quá 255 ký tự.', + + 'disk.string' => 'Dung lượng ổ cứng phải là chuỗi ký tự.', + 'disk.max' => 'Dung lượng ổ cứng không được vượt quá 255 ký tự.', + + 'bandwidth.string' => 'Băng thông phải là chuỗi ký tự.', + 'bandwidth.max' => 'Băng thông không được vượt quá 255 ký tự.', + + 'features.json' => 'Tính năng phải là định dạng JSON hợp lệ.', + 'metadata.json' => 'Metadata phải là định dạng JSON hợp lệ.', + + 'setup_fee.numeric' => 'Phí cài đặt phải là số.', + 'setup_fee.min' => 'Phí cài đặt không được âm.', + + 'trial_days.integer' => 'Số ngày dùng thử phải là số nguyên.', + 'trial_days.min' => 'Số ngày dùng thử không được âm.', + + 'currency.string' => 'Đơn vị tiền tệ phải là chuỗi ký tự.', + 'currency.max' => 'Đơn vị tiền tệ không được vượt quá 10 ký tự.', + + 'is_active.boolean' => 'Trạng thái kích hoạt không hợp lệ.', + + 'sort_order.integer' => 'Thứ tự sắp xếp phải là số nguyên.', + + 'published_at.date' => 'Ngày xuất bản không hợp lệ.', + + 'os_ids.array' => 'Danh sách hệ điều hành không hợp lệ.', + 'os_ids.*.exists' => 'Hệ điều hành được chọn không tồn tại.', + + 'pricing.array' => 'Thông tin giá không hợp lệ.', + 'pricing.*.price.numeric' => 'Giá phải là số.', + 'pricing.*.price.min' => 'Giá không được âm.', + 'pricing.*.price_sale.numeric' => 'Giá khuyến mãi phải là số.', + 'pricing.*.price_sale.min' => 'Giá khuyến mãi không được âm.', + ]; + } +} diff --git a/app/Http/Requests/Product/StoreProductRequest.php b/app/Http/Requests/Product/StoreProductRequest.php deleted file mode 100644 index 22f063d..0000000 --- a/app/Http/Requests/Product/StoreProductRequest.php +++ /dev/null @@ -1,43 +0,0 @@ - ['required', 'string', 'max:255'], - 'price' => ['required', 'numeric', 'min:0'], - 'price_sale' => ['nullable', 'numeric', 'min:0'], - 'description' => ['nullable', 'string'], - 'status' => ['required', 'integer'], // Value from Status Enum - 'type_id' => ['required', 'integer'], - ]; - - // Infer product type from type_id (Legacy mapping needs to be consistent with Controller) - // Controller map: 1=>Addon, 2=>Domain, 3=>Hosting, 4=>VPS - // We can access input 'type_id' - $typeId = (int) $this->input('type_id'); - - if ($typeId === 4) { // VPS - $rules = array_merge($rules, [ - 'cpu' => ['required'], - 'ram' => ['required'], - 'disk' => ['required'], - 'bandwidth' => ['required'], - ]); - } - - return $rules; - } -} diff --git a/app/Http/Requests/Product/UpdateProductRequest.php b/app/Http/Requests/Product/UpdateProductRequest.php deleted file mode 100644 index 80bc266..0000000 --- a/app/Http/Requests/Product/UpdateProductRequest.php +++ /dev/null @@ -1,41 +0,0 @@ - ['required', 'string', 'max:255'], - 'price' => ['required', 'numeric', 'min:0'], - 'product_id' => ['required', 'exists:products,id'], - ]; - - $typeId = (int) $this->input('type_id'); - - if ($typeId === 4) { // VPS - $rules = array_merge($rules, [ - 'cpu' => ['required'], - 'ram' => ['required'], - 'disk' => ['required'], - ]); - } - - return $rules; - } -} diff --git a/app/Http/Services/ProductService.php b/app/Http/Services/ProductService.php index 09b6a9a..c3d547f 100644 --- a/app/Http/Services/ProductService.php +++ b/app/Http/Services/ProductService.php @@ -2,117 +2,143 @@ namespace App\Http\Services; -use App\Enums\Product\ProductTypeEnums; -use App\Enums\Status; +use App\Models\ProductPricings; use App\Repositories\Admin\Product\ProductRepositoryInterface; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; -use Exception; class ProductService { + protected ProductRepositoryInterface $repository; + public function __construct( - protected ProductRepositoryInterface $productRepository - ) {} + ProductRepositoryInterface $repository + ) { + $this->repository = $repository; + } public function store(array $data) { - DB::beginTransaction(); - try { - $typeId = (int) ($data['type_id'] ?? 0); - $productType = $this->mapTypeIdToEnum($typeId); - - if (!$productType) { - throw new Exception('Invalid Product Type'); - } + // Prepare product data + $productData = $this->prepareData($data); - $prepareData = [ - 'uuid' => (string) Str::uuid(), - 'name' => $data['name'], - 'slug' => Str::slug($data['name']), - 'product_type' => $productType->value, - 'is_active' => isset($data['status']) && (int)$data['status'] === Status::ACTIVE->value, - - 'price' => $data['price'] ?? 0, - 'price_sale' => $data['price_sale'] ?? null, - 'description' => $data['description'] ?? null, - - // VPS Columns - 'cpu' => $data['cpu'] ?? null, - 'ram' => $data['ram'] ?? null, - 'disk' => $data['disk'] ?? null, - 'bandwidth' => $data['bandwidth'] ?? null, - 'gpu' => $data['gpu'] ?? null, // if added later - - // Extra fields - 'sort_order' => $data['sort_order'] ?? 0, - 'billing_cycle' => $data['billing_cycle'] ?? 'monthly', - ]; + // Create product + $product = $this->repository->create($productData); - $product = $this->productRepository->create($prepareData); - - DB::commit(); - return $product; - } catch (Exception $e) { - DB::rollBack(); - throw $e; + // Sync OS versions if provided + if (!empty($data['os_ids'])) { + $product->osVersions()->sync($data['os_ids']); } + + // Save pricing cycles if provided + if (!empty($data['pricing'])) { + $this->savePricings($product, $data['pricing'], $data['currency'] ?? 'USD'); + } + + return $product; } public function update(int $id, array $data) { - DB::beginTransaction(); - try { - $prepareData = []; - - if (isset($data['name'])) { - $prepareData['name'] = $data['name']; - $prepareData['slug'] = Str::slug($data['name']); - } - - if (isset($data['status'])) { - $prepareData['is_active'] = (int)$data['status'] === Status::ACTIVE->value; - } - - // Map other fields that overlap with fillable - $fillable = ['price', 'price_sale', 'billing_cycle', 'cpu', 'ram', 'gpu', 'disk', 'bandwidth', 'sort_order', 'description']; - foreach ($fillable as $field) { - if (array_key_exists($field, $data)) { - $prepareData[$field] = $data[$field]; - } - } + // Prepare product data + $productData = $this->prepareData($data, false); - $result = $this->productRepository->update($id, $prepareData); - - DB::commit(); - return $result; - } catch (Exception $e) { - DB::rollBack(); - throw $e; + // Update product + $result = $this->repository->update($id, $productData); + + // Get the product for relationships + $product = $this->repository->find($id); + + // Sync OS versions if provided + if (isset($data['os_ids'])) { + $product->osVersions()->sync($data['os_ids']); } + + // Save pricing cycles if provided + if (!empty($data['pricing'])) { + $this->savePricings($product, $data['pricing'], $data['currency'] ?? 'USD'); + } + + return $result; } public function delete(int $id) { - DB::beginTransaction(); - try { - $this->productRepository->delete($id); - DB::commit(); - return true; - } catch (Exception $e) { - DB::rollBack(); - throw $e; + return $this->repository->delete($id); + } + + /** + * Save pricing cycles for product + */ + private function savePricings($product, array $pricingData, string $currency): void + { + foreach ($pricingData as $cycle => $pricing) { + // Only save if enabled and has price + if (!empty($pricing['enabled']) && isset($pricing['price'])) { + ProductPricings::updateOrCreate( + [ + 'product_id' => $product->id, + 'billing_cycle' => $cycle, + ], + [ + 'price' => $pricing['price'] ?? 0, + 'price_sale' => $pricing['price_sale'] ?? null, + 'currency' => $currency, + 'is_active' => true, + ] + ); + } else { + // Remove pricing if not enabled + ProductPricings::where('product_id', $product->id) + ->where('billing_cycle', $cycle) + ->delete(); + } } } - - private function mapTypeIdToEnum(int $typeId): ?ProductTypeEnums + + /** + * Prepare data for create/update + */ + private function prepareData(array $data, bool $isCreate = true): array { - return match ($typeId) { - 1 => ProductTypeEnums::ADDON, - 2 => ProductTypeEnums::DOMAIN, - 3 => ProductTypeEnums::HOSTING, - 4 => ProductTypeEnums::VPS, - default => null - }; + $prepared = []; + + // Generate UUID and slug only on create + if ($isCreate) { + $prepared['uuid'] = (string) Str::uuid(); + } + + // Name and slug + if (isset($data['name'])) { + $prepared['name'] = $data['name']; + $prepared['slug'] = Str::slug($data['name']); + } + + // Direct mappable fields + $fillableFields = [ + 'short_description', + 'description', + 'setup_fee', + 'trial_days', + 'cpu', + 'ram', + 'gpu', + 'disk', + 'bandwidth', + 'stock', + 'is_unlimited', + 'is_active', + 'sort_order', + 'product_type', + 'product_categories_id', + 'published_at', + ]; + + foreach ($fillableFields as $field) { + if (array_key_exists($field, $data)) { + $prepared[$field] = $data[$field]; + } + } + + return $prepared; } } diff --git a/app/Models/Product.php b/app/Models/Product.php index 17c01ad..16f5f04 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -17,19 +17,58 @@ class Product extends Model protected $fillable = [ 'name', 'slug', + 'short_description', 'description', - 'price', - 'price_sale', - 'billing_cycle', + 'setup_fee', + 'trial_days', 'cpu', 'ram', 'gpu', 'disk', 'bandwidth', + 'stock', + 'is_unlimited', 'is_active', 'sort_order', + 'product_type', + 'product_categories_id', + 'published_at', ]; + protected $casts = [ + 'is_active' => 'boolean', + 'is_unlimited' => 'boolean', + 'published_at' => 'date', + ]; + + /** + * Relationship: Product belongs to a Category + */ + public function category() + { + return $this->belongsTo(ProductCategories::class, 'product_categories_id'); + } + + /** + * Relationship: Product has many OS versions (pivot table: os_product) + */ + public function osVersions() + { + return $this->belongsToMany(Os::class, 'os_product', 'product_id', 'os_id') + ->withTimestamps(); + } + + /** + * Relationship: Product has many pricings + */ + public function pricings() + { + return $this->hasMany(ProductPricings::class, 'product_id'); + } + + /** + * Legacy: Many-to-many categories (if still needed) + */ public function categories() { return $this->belongsToMany(ProductCategories::class, 'product_product_category', 'product_id', 'product_category_id'); diff --git a/app/Models/ProductPricings.php b/app/Models/ProductPricings.php new file mode 100644 index 0000000..8f9ed64 --- /dev/null +++ b/app/Models/ProductPricings.php @@ -0,0 +1,36 @@ + 'decimal:2', + 'price_sale' => 'decimal:2', + 'is_active' => 'boolean', + ]; + + /** + * Relationship: Pricing belongs to a Product + */ + public function product() + { + return $this->belongsTo(Product::class); + } +} diff --git a/app/View/Components/ImagePicker.php b/app/View/Components/ImagePicker.php new file mode 100644 index 0000000..030bbb1 --- /dev/null +++ b/app/View/Components/ImagePicker.php @@ -0,0 +1,26 @@ +string('product_type', 50)->index(); $table->unsignedBigInteger('product_categories_id')->index(); + // Basic info $table->string('name'); $table->string('slug')->unique(); $table->string('short_description')->nullable(); $table->text('description')->nullable(); - // Pricing - $table->decimal('price', 12, 2)->nullable(); - $table->decimal('price_sale', 12, 2)->nullable(); - $table->decimal('setup_fee', 12, 2)->default(0); - $table->string('currency', 10)->default('USD'); - $table->string('billing_cycle')->default('monthly'); - // Trial / stock - $table->unsignedInteger('trial_days')->default(0); $table->integer('stock')->nullable(); $table->boolean('is_unlimited')->default(true); @@ -38,21 +31,28 @@ return new class extends Migration { $table->string('gpu')->nullable(); $table->string('disk')->nullable(); $table->string('bandwidth')->nullable(); - + // Extra $table->json('features')->nullable(); $table->json('metadata')->nullable(); + $table->decimal('setup_fee', 15, 2)->default(0); + $table->integer('trial_days')->default(0); + $table->boolean('is_active')->default(true); $table->integer('sort_order')->default(0); $table->timestamp('published_at')->nullable(); + $table->foreign('product_categories_id') + ->references('id') + ->on('product_categories') + ->onDelete('cascade'); - $table->foreign('product_categories_id')->references('id')->on('product_categories')->onDelete('cascade'); $table->softDeletes(); $table->timestamps(); }); + } public function down(): void diff --git a/database/migrations/2026_02_06_110641_create_product_pricings_table.php b/database/migrations/2026_02_06_110641_create_product_pricings_table.php new file mode 100644 index 0000000..0c11911 --- /dev/null +++ b/database/migrations/2026_02_06_110641_create_product_pricings_table.php @@ -0,0 +1,56 @@ +id(); + + // FK + $table->foreignId('product_id') + ->constrained('products') + ->cascadeOnDelete(); + + // billing cycle type + $table->string('billing_cycle', 50); + /* + one_time + monthly + quarterly + semi_annually + annually + biennially (2 years) + triennially (3 years) + */ + + // pricing + $table->decimal('price', 15, 2); + $table->decimal('price_sale', 15, 2)->nullable(); + + + $table->string('currency', 10)->default('USD'); + + $table->boolean('is_active')->default(true); + + $table->timestamps(); + + // tránh trùng 1 chu kỳ trên 1 product + $table->unique(['product_id', 'billing_cycle']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_pricings'); + } +}; diff --git a/public/lib/ckfinder/config.js b/public/lib/ckfinder/config.js index ee2962c..b85c897 100644 --- a/public/lib/ckfinder/config.js +++ b/public/lib/ckfinder/config.js @@ -7,8 +7,13 @@ var config = {}; // Set your configuration options below. -// Examples: -// config.language = 'pl'; -// config.skin = 'jquery-mobile'; +// Configure connector path to use Laravel route +config.connectorPath = '/administrator/ckfinder/connector'; + +// Enable credentials for cross-origin requests +config.xhrCredentials = true; + +// Set language +config.language = 'vi'; CKFinder.define( config ); diff --git a/resources/sass/plugin-overrides/_select2.scss b/resources/sass/plugin-overrides/_select2.scss index 48f73bf..8c6a485 100644 --- a/resources/sass/plugin-overrides/_select2.scss +++ b/resources/sass/plugin-overrides/_select2.scss @@ -59,7 +59,31 @@ .select2-container--default .select2-selection--multiple { min-height: 38px; .select2-selection__rendered { - padding: 0 6px + padding: 0px; + list-style: none; + margin: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + min-height: unset; + + + // Hide empty ul when no items selected + &:empty { + display: none; + } + + // Remove list bullets + li { + list-style: none; + list-style-type: none; + } + + &::before, + li::before { + content: none; + display: none; + } } .select2-selection__choice { background-color: $component-active-bg; @@ -67,13 +91,27 @@ border-color: $component-active-bg; padding: 1px 8px; border-radius: .15rem; - margin-top: 5px; + margin: 5px 0 0 5px; + list-style: none; } .select2-selection__choice__remove { color: $component-active-color; opacity: .5; margin-right: 4px; } + // Placeholder styling + .select2-selection__placeholder { + margin-top: 0; + padding: 5px 8px; + } + // Search field styling + .select2-search--inline { + margin-top: 0; + .select2-search__field { + margin-top: 5px; + margin-left: 5px; + } + } } .select2-container .select2-search--inline { diff --git a/resources/views/administrator/layout/master.blade.php b/resources/views/administrator/layout/master.blade.php index 3002fce..79038cc 100644 --- a/resources/views/administrator/layout/master.blade.php +++ b/resources/views/administrator/layout/master.blade.php @@ -16,7 +16,7 @@ Thế Giới Số Admin | Service Management Portal - \ No newline at end of file + diff --git a/resources/views/administrator/layout/partials/footer.blade.php b/resources/views/administrator/layout/partials/footer.blade.php index 99e139e..8489fd8 100644 --- a/resources/views/administrator/layout/partials/footer.blade.php +++ b/resources/views/administrator/layout/partials/footer.blade.php @@ -3,4 +3,4 @@ Copyright © {{ date('Y') }} Thế Giới Số.

- \ No newline at end of file + diff --git a/resources/views/administrator/pages/package/group/add.blade.php b/resources/views/administrator/pages/package/group/add.blade.php index 8f25f8f..78f67fd 100644 --- a/resources/views/administrator/pages/package/group/add.blade.php +++ b/resources/views/administrator/pages/package/group/add.blade.php @@ -4,107 +4,82 @@ @endforeach @endpush - @section('breadcrumb') - @php - $breadcrumbItems = [ - ['label' => 'Modules'], - ['label' => 'Package vCenter'], - ['label' => 'OS Group', 'url' => route('package.group.index')], - ['label' => 'Edit', 'active' => true] - ]; - @endphp - - @endsection +@section('breadcrumb') @php - use App\Enums\Status; + $breadcrumbItems = [ + ['label' => 'Modules'], + ['label' => 'Package vCenter'], + ['label' => 'OS Group', 'url' => route('package.group.index')], + ['label' => 'Add Os Group', 'active' => true], + ]; @endphp - - @section('content') - @if (auth()->guard('admins')->user()->can('add package module')) -
-
- @csrf -
-
-
- {{-- Name --}} -
- -
- -
-
- {{-- Thumbnail --}} -
- -
-
-
- -
-
- -
-
-
-
+ +@endsection +@php + use App\Enums\Status; +@endphp - - - - {{-- Status --}} -
- -
- -
+@section('content') + @if (auth()->guard('admins')->user()->can('add package module')) +
+
+ Create New OS Group +
+ + @csrf +
+
+
+ {{-- Name --}} +
+ +
+ +
+
+ + {{-- Thumbnail using component --}} + + + + + + {{-- Status --}} +
+ +
+
- - -
- @else -
-
Access Denied - You are not assigned to any Module Package - management department
-
- @endif - @endsection - @push('scripts') - - @endpush - - @push('plugin-scripts') - @foreach (config('assets.datatable.js') as $datatable) - - @endforeach - @endpush \ No newline at end of file +
+ + Cancel + Create + Product + + +
+ @else +
+
Access Denied - You are not assigned to any Module Package + management department
+
+ @endif +@endsection + +@push('plugin-scripts') + @foreach (config('assets.datatable.js') as $datatable) + + @endforeach + +@endpush diff --git a/resources/views/administrator/pages/package/group/edit.blade.php b/resources/views/administrator/pages/package/group/edit.blade.php index 39df21c..68f48af 100644 --- a/resources/views/administrator/pages/package/group/edit.blade.php +++ b/resources/views/administrator/pages/package/group/edit.blade.php @@ -22,6 +22,9 @@ @section('content') @if (auth()->guard('admins')->user()->can('add package module'))
+
+ Edit OS Group +
@csrf @method('put') @@ -36,24 +39,9 @@ class="form-control">
- {{-- Thumbnail --}} -
- -
-
-
- -
-
- -
-
-
-
+ + {{-- Thumbnail using component --}} + @@ -74,10 +62,12 @@
- + + Cancel + Update + OS Group + @else @@ -87,27 +77,10 @@ @endif @endsection -@push('scripts') - -@endpush @push('plugin-scripts') @foreach (config('assets.datatable.js') as $datatable) @endforeach + @endpush \ No newline at end of file diff --git a/resources/views/administrator/pages/package/version/add.blade.php b/resources/views/administrator/pages/package/version/add.blade.php index 6de2544..5f1fd2b 100644 --- a/resources/views/administrator/pages/package/version/add.blade.php +++ b/resources/views/administrator/pages/package/version/add.blade.php @@ -25,13 +25,16 @@
@csrf +
+

Add Version OS

+
{{-- Group Name --}}
-
+
@@ -44,16 +47,16 @@
-
+
-
+
@@ -45,16 +48,16 @@
-
+
-
+
- - + @foreach(\App\Enums\Status::cases() as $status) + + @endforeach
diff --git a/resources/views/administrator/pages/product/categories/edit.blade.php b/resources/views/administrator/pages/product/categories/edit.blade.php index 81a4d97..48ed5d6 100644 --- a/resources/views/administrator/pages/product/categories/edit.blade.php +++ b/resources/views/administrator/pages/product/categories/edit.blade.php @@ -6,7 +6,7 @@ @endpush @section('breadcrumb') -@php + @php $breadcrumbItems = [ ['label' => 'Product'], ['label' => 'Product / Services', 'url' => route('products.index')], @@ -16,86 +16,94 @@ @endphp @endsection -@section('tab') - @php - $tabs = [ - [ - 'label' => 'Product', - 'route' => 'products.index', - 'icon' => 'server', - ], - [ - 'label' => 'Product Group', - 'route' => 'products.group.index', - 'icon' => 'server', - ], - ]; - @endphp - -@endsection + @section('tab') + @php + $tabs = [ + [ + 'label' => 'Product', + 'route' => 'products.index', + 'icon' => 'server', + ], + [ + 'label' => 'Product Group', + 'route' => 'products.group.index', + 'icon' => 'server', + ], + ]; + @endphp + + @endsection + + @section('content') + + @csrf + @method('PUT') +
+
+
+
+
Basic Information
+
+
+
+ + +
-@section('content') - - @csrf - @method('PUT') -
-
-
-
-
- - -
+
+ + +
-
- - -
- -
- - -
+
+ + +
+
+ +
+
+
+
Classification
+
+
+
+ + +
+ +
+ + +
+
+
+
-
-
-
-
Classification
-
-
-
- - -
- -
- - -
-
-
-
-
- - - Cancel - Update Category - - -@endsection + + Cancel + Update Category + + + @endsection @push('plugin-scripts') @foreach (config('assets.datatable.js') as $datatable) diff --git a/resources/views/administrator/pages/product/create.blade.php b/resources/views/administrator/pages/product/create.blade.php index 9d84262..8747fef 100644 --- a/resources/views/administrator/pages/product/create.blade.php +++ b/resources/views/administrator/pages/product/create.blade.php @@ -33,246 +33,279 @@ @endsection @section('content') -
- @csrf -
- {{-- Left Column: Basic Info & Pricing --}} -
-
-
-
Basic Information
-
-
- - {{-- Basic Information --}} -
-
Basic Information
- -
-
-
- - -
-
-
-
- - -
-
-
- -
- - -
- -
- - -
-
- - {{-- Pricing & Billing --}} -
-
Pricing & Billing
-
-
-
- -
- -
-
-
-
-
- -
- -
-
-
-
-
- -
- -
-
-
-
- -
-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
-
- - {{-- Hardware Specs (VPS/Dedicated) --}} -
-
Hardware Specifications
-
-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
-
-
-
- - -
-
-
-
- - -
-
-
-
- -
-
+ + @csrf +
+ {{-- Left Column: Basic Info & Pricing --}} +
+
+
+
Basic Information
+
- {{-- Right Column: Classification & Settings --}} -
-
-
-
Classification
-
-
- {{-- Product Category --}} -
-
- - - - Thêm Group - + {{-- Basic Information --}} +
+
Basic Information
+ +
+
+
+ +
- +
+ +
+ + +
+
+ + {{-- Pricing & Billing --}} +
+
Pricing & Billing
+ {{-- Global Settings --}} +
+
+
+ + -
- - {{-- Product Type --}} -
- - + +
- - {{-- Status --}} -
- - +
+
+ + +
+
+
+ + +
+
+
+ {{-- Billing Cycles Table --}} + + + + + + + + + + + + @foreach (\App\Enums\Product\ProductBillingCycle::cases() as $cycle) + + + + + + + @endforeach + +
#Billing CyclePriceSale Price
+
+ +
+
+ {{ $cycle->label() }} + +
+ $ + +
+
+
+ $ + +
+
+
-
- - + {{-- Hardware Specs (VPS/Dedicated) --}} +
+
Hardware Specifications
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+ + +
-
-
-
Inventory & Limits
-
-
-
- - -
+
+
+
-
-
- - -
-
- -
- - -
+ {{-- Right Column: Classification & Settings --}} +
+
+
+
Classification
+
+
+ {{-- Product Category --}} +
+ + +
+ + {{-- Operating System --}} +
+ + + Select multiple OS versions supported by this product +
+ + {{-- Product Type --}} +
+ + +
+ + {{-- Status --}} +
+ + +
+ +
+ +
- - - Cancel - Create Product - - + +
+
+
Inventory & Limits
+
+
+
+ + +
+ +
+
+ + +
+
+ +
+ + +
+
+
+
+
+ + + Cancel + Create + Product + + @endsection @push('plugin-scripts') @foreach (config('assets.datatable.js') as $datatable) diff --git a/resources/views/administrator/pages/product/datatable/action.blade.php b/resources/views/administrator/pages/product/datatable/action.blade.php index 4b8555f..c671bc0 100644 --- a/resources/views/administrator/pages/product/datatable/action.blade.php +++ b/resources/views/administrator/pages/product/datatable/action.blade.php @@ -1,2 +1,2 @@ -Edit -Delete \ No newline at end of file +Chỉnh sửa +Xóa \ No newline at end of file diff --git a/resources/views/administrator/pages/product/datatable/specs.blade.php b/resources/views/administrator/pages/product/datatable/specs.blade.php new file mode 100644 index 0000000..40299cc --- /dev/null +++ b/resources/views/administrator/pages/product/datatable/specs.blade.php @@ -0,0 +1,40 @@ +@php + $hasSpecs = $row->cpu || $row->ram || $row->disk || $row->bandwidth || $row->gpu; +@endphp + +@if($hasSpecs) +
+ @if($row->cpu) +
+ + {{ $row->cpu }} vCore +
+ @endif + @if($row->ram) +
+ + {{ $row->ram }} GB +
+ @endif + @if($row->disk) +
+ + {{ $row->disk }} GB +
+ @endif + @if($row->bandwidth) +
+ + {{ $row->bandwidth }} +
+ @endif + @if($row->gpu) +
+ + {{ $row->gpu }} +
+ @endif +
+@else +- +@endif diff --git a/resources/views/administrator/pages/product/datatable/status.blade.php b/resources/views/administrator/pages/product/datatable/status.blade.php new file mode 100644 index 0000000..ff4ffc1 --- /dev/null +++ b/resources/views/administrator/pages/product/datatable/status.blade.php @@ -0,0 +1,13 @@ +@php + $status = App\Enums\Status::tryFrom($row->is_active); +@endphp + +@if($status) + + {{ $status->label() }} + +@else + + Unknown + +@endif \ No newline at end of file diff --git a/resources/views/administrator/pages/product/edit.blade.php b/resources/views/administrator/pages/product/edit.blade.php index 93af70d..94743e3 100644 --- a/resources/views/administrator/pages/product/edit.blade.php +++ b/resources/views/administrator/pages/product/edit.blade.php @@ -10,243 +10,306 @@ $breadcrumbItems = [ ['label' => 'Product'], ['label' => 'Product / Services', 'url' => route('products.index')], - ['label' => 'Add Product / Services', 'active' => true], + ['label' => 'Edit Product', 'active' => true], ]; @endphp @endsection @section('content') -
- @csrf -
- {{-- Left Column: Basic Info & Pricing --}} -
-
-
- - {{-- Basic Information --}} -
-
Basic Information
- -
-
-
- - -
-
-
-
- - -
-
-
- -
- - -
- -
- - -
-
- - {{-- Pricing & Billing --}} -
-
Pricing & Billing
-
-
-
- -
- -
-
-
-
-
- -
- -
-
-
-
-
- -
- -
-
-
-
- -
-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
-
- - {{-- Hardware Specs (VPS/Dedicated) --}} -
-
Hardware Specifications
-
-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
-
-
-
- - -
-
-
-
- - -
-
-
-
- -
-
+ + @csrf + @method('PUT') +
+ {{-- Left Column: Basic Info & Pricing --}} +
+
+
+
Edit Product
+
- {{-- Right Column: Classification & Settings --}} -
-
-
-
Classification
+ {{-- Basic Information --}} +
+
Basic Information
+ +
+
+
+ + +
+
-
- {{-- Product Category --}} -
- - -
- {{-- Product Type --}} -
- - -
+
+ + +
- {{-- Status --}} -
- - -
+
+ + +
+
-
- - + {{-- Pricing & Billing --}} +
+
Pricing & Billing
+ {{-- Global Settings --}} +
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+ {{-- Billing Cycles Table --}} + + + + + + + + + + + + @foreach (\App\Enums\Product\ProductBillingCycle::cases() as $cycle) + @php + $pricing = $product->pricings + ->where('billing_cycle', $cycle->value) + ->first(); + @endphp + + + + + + + @endforeach + +
#Billing CyclePriceSale Price
+
+ value}.enabled", $pricing ? 1 : 0) ? 'checked' : '' }}> + +
+
+ {{ $cycle->label() }} + +
+ $ + value}.price", $pricing->price ?? '') }}"> +
+
+
+ $ + value}.price_sale", $pricing->price_sale ?? '') }}"> +
+
+
+ + {{-- Hardware Specs (VPS/Dedicated) --}} +
+
Hardware Specifications
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+ + +
-
-
-
Inventory & Limits
-
-
-
- - -
+
+
+
-
-
- - -
-
- -
- - -
+ {{-- Right Column: Classification & Settings --}} +
+
+
+
Classification
+
+
+ {{-- Product Category --}} +
+ + +
+ + {{-- Operating System --}} +
+ + @php + $selectedOsIds = old('os_ids', $product->osVersions->pluck('id')->toArray()); + @endphp + + Select multiple OS versions supported by this product +
+ + {{-- Product Type --}} +
+ + +
+ + {{-- Status --}} +
+ + +
+ +
+ +
- - - Cancel - Create Product - - + +
+
+
Inventory & Limits
+
+
+
+ + +
+ +
+
+ is_unlimited) ? 'checked' : '' }}> + +
+
+ +
+ + +
+
+
+
+
+ + + Cancel + Update + Product + + @endsection @push('plugin-scripts') @foreach (config('assets.datatable.js') as $datatable) diff --git a/resources/views/administrator/pages/product/index.blade.php b/resources/views/administrator/pages/product/index.blade.php index b22ccaf..a7786d0 100644 --- a/resources/views/administrator/pages/product/index.blade.php +++ b/resources/views/administrator/pages/product/index.blade.php @@ -10,7 +10,7 @@ $breadcrumbItems = [ ['label' => 'Product'], ['label' => 'Product / Services'], - ['label' => 'Product List', 'active' => true] + ['label' => 'Product List', 'active' => true], ]; @endphp @@ -37,26 +37,28 @@
@include('administrator.pages.block.product.btn') -
-
- + -
- -
- @else -
-
Access Denied - You are not assigned to any Product - management department
- @endif + +
+ @else +
+
Access Denied - You are not assigned to any Product + management department
+
+ @endif @endsection @push('plugin-scripts') @foreach (config('assets.datatable.js') as $datatable) @endforeach -@endpush \ No newline at end of file +@endpush diff --git a/resources/views/components/button-c-r-m.blade.php b/resources/views/components/button-c-r-m.blade.php index a91d927..343c41b 100644 --- a/resources/views/components/button-c-r-m.blade.php +++ b/resources/views/components/button-c-r-m.blade.php @@ -20,7 +20,7 @@ $tag = 'a'; } - $baseClasses = 'd-inline-flex gap-2 align-items-center justify-content-center text-white px-3 py-2 rounded-3 transition-all'; + $baseClasses = 'd-inline-flex gap-2 align-items-center justify-content-center text-white px-3 py-2 rounded-3 transition-all border-0'; $variants = [ 'primary' => 'bg-primary', @@ -35,17 +35,6 @@ 'info' => 'bg-info' ]; - // Darker border colors for the "3D" effect - $borderColors = [ - 'primary' => '#D9561C', - 'secondary' => '#565e64', - 'success' => '#146c43', - 'danger' => '#b02a37', - 'warning' => '#997404', - 'info' => '#004C73', - 'destructive' => '#b02a37', - ]; - $sizes = [ 'default' => 'h-10', 'sm' => 'h-6', @@ -55,30 +44,22 @@ $variantClass = $variants[$variant ?? 'primary'] ?? $variants['primary']; $sizeClass = $sizes[$size ?? 'md'] ?? $sizes['default']; - $borderColor = $borderColors[$variant ?? 'primary'] ?? 'transparent'; - - // Check if variant is outline/ghost/link to avoid weird borders - if (in_array($variant, ['outline', 'ghost', 'link'])) { - $borderStyle = ''; - } else { - $borderStyle = "border-bottom: 3px solid {$borderColor};"; - } $classes = "{$baseClasses} {$variantClass} {$sizeClass}"; @endphp @if($tag === 'a') - merge(['class' => $classes, 'style' => $borderStyle]) }}> + merge(['class' => $classes]) }}> @if($icon) @endif {{ $slot }} @else - + @endif \ No newline at end of file diff --git a/resources/views/components/image-picker.blade.php b/resources/views/components/image-picker.blade.php new file mode 100644 index 0000000..f0ca88c --- /dev/null +++ b/resources/views/components/image-picker.blade.php @@ -0,0 +1,203 @@ +@props([ + 'name' => 'thumbnail', + 'label' => 'Thumbnail', + 'value' => null, + 'placeholder' => 'Chọn File', + 'required' => false, + 'horizontal' => true, + 'labelClass' => 'col-md-3 text-right', + 'inputClass' => 'col-md-9', +]) + +@php + $uniqueId = 'image-picker-' . uniqid(); + $currentValue = old($name) ?? $value; +@endphp + +@if ($horizontal) +
+ +
+ @else +
+ +
+@endif +
+
+ +
+
+ +
+
+ +{{-- Image Preview --}} +
+
+
+ @if ($currentValue) + @if (str_ends_with(strtolower($currentValue), '.svg')) + + Preview + + @else + Preview + @endif + @endif +
+ +
+ Click vào ảnh để xem phóng to +
+
+
+ +{{-- Image Preview Modal --}} + + +@pushOnce('custom-scripts') + +@endPushOnce diff --git a/routes/admin.php b/routes/admin.php index 68dda79..17d6541 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -130,9 +130,9 @@ Route::prefix('administrator')->group(function () { Route::get('all-product', 'index')->name('index'); Route::get('add-new-product', 'create')->name('create'); Route::post('add-new-product', 'store')->name('store'); - Route::get('view-product-item', 'edit')->name('edit'); - Route::put('view-product-item', 'update')->name('update'); - Route::delete('remove-product-item', 'destroy')->name('destroy'); + Route::get('view-product-item/{id}', 'edit')->name('edit'); + Route::put('view-product-item/{id}', 'update')->name('update'); + Route::delete('remove-product-item/{id}', 'destroy')->name('destroy'); Route::get('datatable', 'datatable')->name('datatable'); Route::controller(ProductCategoryController::class)->prefix('group')->as('group.')->group(function () { diff --git a/todo.md b/todo.md deleted file mode 100644 index 8e896fb..0000000 --- a/todo.md +++ /dev/null @@ -1,23 +0,0 @@ -# TODO LIST - -## 🔴 Critical -- [ ] Refactor `routes/api.php`: Move all business logic from closures to Controllers/Services (ID: I001) -- [ ] Refactor `OrderController@store`: Move massive business logic to `OrderService` (ID: I002) -- [ ] Implement Enums for Product Types and Invoice/Order Statuses (ID: I003) -- [ ] Fix logic leakage in `ProductController@store` and `update`: Use FormRequests and Services (ID: I004) -- [ ] Remove hardcoded HTML from `Invoice.php` model; move to a Presenter or Blade component (ID: I005) - -## 🟠 High -- [ ] Replace `$guarded = []` with specific `$fillable` in `Order`, `Invoice`, and other core models (ID: I006) -- [ ] Extract caching logic from `ProductController` to a Service or Repository (ID: I007) -- [ ] Add FormRequest validation for all Order and Product actions (ID: I008) -- [ ] Remove hardcoded IDs (1, 2, 3, 4) from logic and replace with Enums/Constants (ID: I009) - -## 🟡 Medium -- [ ] Refactor `calculateBillingDetails` in `OrderController` to a Service or Helper (ID: I010) -- [ ] Consolidate hosting/vps logic into the new `Product` table and model (ID: I011) -- [ ] Improve Job dispatching: remove hardcoded `delay(now()->addSeconds(10))` (ID: I012) - -## 🟢 Low -- [ ] Clean up unused comments and commented-out code in `Order.php` and `ProductController.php` (ID: I013) -- [ ] Standardize currency formatting using a specialized Utility/Helper (ID: I014)