Compare commits
10 Commits
070e1e52d1
...
d02af40836
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d02af40836 | ||
|
|
deabe6b043 | ||
|
|
82fdb9ee0a | ||
|
|
15f2f0e1d0 | ||
|
|
5a96b48725 | ||
|
|
47289d158e | ||
|
|
78e30c2aba | ||
|
|
fff6b147be | ||
|
|
7c307b991e | ||
|
|
13a75a1a25 |
@ -33,6 +33,7 @@ enum Permission: string
|
|||||||
case ANALYSIS_TOP_SUPPLIERS = 'analysis.top_suppliers';
|
case ANALYSIS_TOP_SUPPLIERS = 'analysis.top_suppliers';
|
||||||
case ANALYSIS_TOP_CUSTOMERS = 'analysis.top_customers';
|
case ANALYSIS_TOP_CUSTOMERS = 'analysis.top_customers';
|
||||||
case ANALYSIS_TOP_PRODUCTS = 'analysis.top_products';
|
case ANALYSIS_TOP_PRODUCTS = 'analysis.top_products';
|
||||||
|
case ANALYSIS_MARKETING_SALES = 'analysis.marketing_sales';
|
||||||
|
|
||||||
case EMPLOYEES_VIEW = 'employees.view';
|
case EMPLOYEES_VIEW = 'employees.view';
|
||||||
case EMPLOYEES_CREATE = 'employees.create';
|
case EMPLOYEES_CREATE = 'employees.create';
|
||||||
@ -172,6 +173,7 @@ public function label(): string
|
|||||||
self::ANALYSIS_TOP_SUPPLIERS => 'Lihat Top 5 Supplier',
|
self::ANALYSIS_TOP_SUPPLIERS => 'Lihat Top 5 Supplier',
|
||||||
self::ANALYSIS_TOP_CUSTOMERS => 'Lihat Top 5 Pelanggan',
|
self::ANALYSIS_TOP_CUSTOMERS => 'Lihat Top 5 Pelanggan',
|
||||||
self::ANALYSIS_TOP_PRODUCTS => 'Lihat Top 5 Produk',
|
self::ANALYSIS_TOP_PRODUCTS => 'Lihat Top 5 Produk',
|
||||||
|
self::ANALYSIS_MARKETING_SALES => 'Lihat Analisa Penjualan Marketing',
|
||||||
|
|
||||||
self::EMPLOYEES_VIEW => 'Lihat Pegawai',
|
self::EMPLOYEES_VIEW => 'Lihat Pegawai',
|
||||||
self::EMPLOYEES_CREATE => 'Tambah Pegawai',
|
self::EMPLOYEES_CREATE => 'Tambah Pegawai',
|
||||||
@ -295,7 +297,8 @@ public function group(): string
|
|||||||
self::ANALYSIS_RAW_MATERIALS, self::ANALYSIS_PRODUCT_STOCK, self::ANALYSIS_REVENUE,
|
self::ANALYSIS_RAW_MATERIALS, self::ANALYSIS_PRODUCT_STOCK, self::ANALYSIS_REVENUE,
|
||||||
self::ANALYSIS_EXPENSE, self::ANALYSIS_BUSY_HOURS, self::ANALYSIS_PROFIT_ORDERS,
|
self::ANALYSIS_EXPENSE, self::ANALYSIS_BUSY_HOURS, self::ANALYSIS_PROFIT_ORDERS,
|
||||||
self::ANALYSIS_PROFIT_HPP, self::ANALYSIS_PROFIT_GROSS, self::ANALYSIS_PROFIT_MARGIN,
|
self::ANALYSIS_PROFIT_HPP, self::ANALYSIS_PROFIT_GROSS, self::ANALYSIS_PROFIT_MARGIN,
|
||||||
self::ANALYSIS_TOP_SUPPLIERS, self::ANALYSIS_TOP_CUSTOMERS, self::ANALYSIS_TOP_PRODUCTS => 'Umum',
|
self::ANALYSIS_TOP_SUPPLIERS, self::ANALYSIS_TOP_CUSTOMERS, self::ANALYSIS_TOP_PRODUCTS,
|
||||||
|
self::ANALYSIS_MARKETING_SALES => 'Umum',
|
||||||
self::EMPLOYEES_VIEW, self::EMPLOYEES_CREATE, self::EMPLOYEES_UPDATE,
|
self::EMPLOYEES_VIEW, self::EMPLOYEES_CREATE, self::EMPLOYEES_UPDATE,
|
||||||
self::EMPLOYEES_DELETE, self::EMPLOYEES_RESET_PASSWORD, self::EMPLOYEES_TOGGLE_STATUS => 'Pegawai',
|
self::EMPLOYEES_DELETE, self::EMPLOYEES_RESET_PASSWORD, self::EMPLOYEES_TOGGLE_STATUS => 'Pegawai',
|
||||||
self::ATTENDANCES_VIEW, self::ATTENDANCES_CREATE, self::ATTENDANCES_DELETE,
|
self::ATTENDANCES_VIEW, self::ATTENDANCES_CREATE, self::ATTENDANCES_DELETE,
|
||||||
|
|||||||
@ -78,6 +78,7 @@ public function permissions(): array
|
|||||||
Permission::ANALYSIS_PROFIT_MARGIN,
|
Permission::ANALYSIS_PROFIT_MARGIN,
|
||||||
Permission::ANALYSIS_TOP_CUSTOMERS,
|
Permission::ANALYSIS_TOP_CUSTOMERS,
|
||||||
Permission::ANALYSIS_TOP_PRODUCTS,
|
Permission::ANALYSIS_TOP_PRODUCTS,
|
||||||
|
Permission::ANALYSIS_MARKETING_SALES,
|
||||||
|
|
||||||
Permission::EMPLOYEES_VIEW,
|
Permission::EMPLOYEES_VIEW,
|
||||||
|
|
||||||
@ -130,9 +131,11 @@ public function permissions(): array
|
|||||||
Permission::ANALYSIS_REVENUE,
|
Permission::ANALYSIS_REVENUE,
|
||||||
Permission::ANALYSIS_EXPENSE,
|
Permission::ANALYSIS_EXPENSE,
|
||||||
Permission::ANALYSIS_BUSY_HOURS,
|
Permission::ANALYSIS_BUSY_HOURS,
|
||||||
|
Permission::ANALYSIS_PROFIT_ORDERS,
|
||||||
Permission::ANALYSIS_TOP_SUPPLIERS,
|
Permission::ANALYSIS_TOP_SUPPLIERS,
|
||||||
Permission::ANALYSIS_TOP_CUSTOMERS,
|
Permission::ANALYSIS_TOP_CUSTOMERS,
|
||||||
Permission::ANALYSIS_TOP_PRODUCTS,
|
Permission::ANALYSIS_TOP_PRODUCTS,
|
||||||
|
Permission::ANALYSIS_MARKETING_SALES,
|
||||||
|
|
||||||
Permission::STOCKS_VIEW,
|
Permission::STOCKS_VIEW,
|
||||||
|
|
||||||
|
|||||||
@ -41,6 +41,7 @@ public function index(Request $request): Response
|
|||||||
'topSuppliers' => $this->analysisService->getTopSuppliers($startDate, $endDate),
|
'topSuppliers' => $this->analysisService->getTopSuppliers($startDate, $endDate),
|
||||||
'topCustomers' => $this->analysisService->getTopCustomers($startDate, $endDate),
|
'topCustomers' => $this->analysisService->getTopCustomers($startDate, $endDate),
|
||||||
'topProducts' => $this->analysisService->getTopProducts($startDate, $endDate),
|
'topProducts' => $this->analysisService->getTopProducts($startDate, $endDate),
|
||||||
|
'marketingSales' => $this->analysisService->getMarketingSales($startDate, $endDate),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,11 +3,13 @@
|
|||||||
namespace App\Http\Controllers\Admin\Manage\Cutting;
|
namespace App\Http\Controllers\Admin\Manage\Cutting;
|
||||||
|
|
||||||
use App\Enums\CuttingStatus;
|
use App\Enums\CuttingStatus;
|
||||||
|
use App\Enums\RawMaterialUnit;
|
||||||
use App\Http\Controllers\Concerns\FlashesEntityMessage;
|
use App\Http\Controllers\Concerns\FlashesEntityMessage;
|
||||||
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
|
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Admin\Manage\CuttingRequest;
|
use App\Http\Requests\Admin\Manage\CuttingRequest;
|
||||||
use App\Http\Requests\Admin\Manage\CuttingStatusTransitionRequest;
|
use App\Http\Requests\Admin\Manage\CuttingStatusTransitionRequest;
|
||||||
|
use App\Models\Category;
|
||||||
use App\Models\Cutting;
|
use App\Models\Cutting;
|
||||||
use App\Services\Manage\CuttingService;
|
use App\Services\Manage\CuttingService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
@ -44,6 +46,8 @@ public function create(Request $request): Response
|
|||||||
'productCatalog' => $this->cuttingService->productCatalog(user: $user),
|
'productCatalog' => $this->cuttingService->productCatalog(user: $user),
|
||||||
'draftMaterials' => $this->cuttingService->draftMaterialsForUser($user),
|
'draftMaterials' => $this->cuttingService->draftMaterialsForUser($user),
|
||||||
'draftResults' => $this->cuttingService->draftResultsForUser($user),
|
'draftResults' => $this->cuttingService->draftResultsForUser($user),
|
||||||
|
'categories' => Category::query()->orderBy('name')->get(['id', 'name'])->map(fn ($c) => ['value' => $c->id, 'label' => $c->name])->all(),
|
||||||
|
'units' => collect(RawMaterialUnit::cases())->map(fn ($u) => ['value' => $u->value, 'label' => $u->label()])->all(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,6 +74,8 @@ public function edit(Cutting $cutting): Response|RedirectResponse
|
|||||||
'cutting' => $this->cuttingService->findForEdit($cutting),
|
'cutting' => $this->cuttingService->findForEdit($cutting),
|
||||||
'rawMaterialCatalog' => $this->cuttingService->rawMaterialCatalog($cutting),
|
'rawMaterialCatalog' => $this->cuttingService->rawMaterialCatalog($cutting),
|
||||||
'productCatalog' => $this->cuttingService->productCatalog($cutting),
|
'productCatalog' => $this->cuttingService->productCatalog($cutting),
|
||||||
|
'categories' => Category::query()->orderBy('name')->get(['id', 'name'])->map(fn ($c) => ['value' => $c->id, 'label' => $c->name])->all(),
|
||||||
|
'units' => collect(RawMaterialUnit::cases())->map(fn ($u) => ['value' => $u->value, 'label' => $u->label()])->all(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,8 @@
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Admin\Manage\CuttingDraftMaterialRequest;
|
use App\Http\Requests\Admin\Manage\CuttingDraftMaterialRequest;
|
||||||
use App\Http\Requests\Admin\Manage\CuttingDraftResultRequest;
|
use App\Http\Requests\Admin\Manage\CuttingDraftResultRequest;
|
||||||
|
use App\Http\Requests\Admin\Manage\CuttingQuickCreateProductRequest;
|
||||||
|
use App\Http\Requests\Admin\Manage\CuttingQuickCreateRawMaterialRequest;
|
||||||
use App\Models\ProductVariant;
|
use App\Models\ProductVariant;
|
||||||
use App\Models\RawMaterialPrice;
|
use App\Models\RawMaterialPrice;
|
||||||
use App\Services\Manage\CuttingService;
|
use App\Services\Manage\CuttingService;
|
||||||
@ -44,4 +46,18 @@ public function destroyResult(Request $request, ProductVariant $productVariant):
|
|||||||
|
|
||||||
return response()->json(['ok' => true]);
|
return response()->json(['ok' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function quickCreateRawMaterial(CuttingQuickCreateRawMaterialRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$rawMaterial = $this->cuttingService->quickCreateRawMaterial($request->validated());
|
||||||
|
|
||||||
|
return response()->json(['raw_material' => $rawMaterial]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function quickCreateProduct(CuttingQuickCreateProductRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$product = $this->cuttingService->quickCreateProduct($request->validated());
|
||||||
|
|
||||||
|
return response()->json(['product' => $product]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin\Media;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Aws\S3\S3Client;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class PresignedUploadController extends Controller
|
||||||
|
{
|
||||||
|
public function presign(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'filename' => ['required', 'string', 'max:255'],
|
||||||
|
'mime_type' => ['required', 'string', 'max:100'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$extension = pathinfo($validated['filename'], PATHINFO_EXTENSION) ?: 'bin';
|
||||||
|
$key = 'temp/'.Str::uuid().'.'.$extension;
|
||||||
|
|
||||||
|
$s3 = new S3Client([
|
||||||
|
'region' => config('filesystems.disks.s3.region'),
|
||||||
|
'endpoint' => config('filesystems.disks.s3.endpoint'),
|
||||||
|
'use_path_style_endpoint' => config('filesystems.disks.s3.use_path_style_endpoint'),
|
||||||
|
'credentials' => [
|
||||||
|
'key' => config('filesystems.disks.s3.key'),
|
||||||
|
'secret' => config('filesystems.disks.s3.secret'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$command = $s3->getCommand('PutObject', [
|
||||||
|
'Bucket' => config('filesystems.disks.s3.bucket'),
|
||||||
|
'Key' => $key,
|
||||||
|
'ContentType' => $validated['mime_type'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$presignedUrl = (string) $s3->createPresignedRequest($command, '+15 minutes')->getUri();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'key' => $key,
|
||||||
|
'url' => $presignedUrl,
|
||||||
|
'expires_at' => now()->addMinutes(15)->toISOString(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -27,7 +27,7 @@ public function rules(): array
|
|||||||
'gender' => ['nullable', Rule::enum(Gender::class)],
|
'gender' => ['nullable', Rule::enum(Gender::class)],
|
||||||
'birth_date' => ['nullable', 'date', 'before:today'],
|
'birth_date' => ['nullable', 'date', 'before:today'],
|
||||||
'address' => ['nullable', 'string'],
|
'address' => ['nullable', 'string'],
|
||||||
'profile_photo' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
|
'profile_s3_key' => ['nullable', 'string'],
|
||||||
'remove_profile_photo_ids' => ['nullable', 'array'],
|
'remove_profile_photo_ids' => ['nullable', 'array'],
|
||||||
'remove_profile_photo_ids.*' => ['integer'],
|
'remove_profile_photo_ids.*' => ['integer'],
|
||||||
];
|
];
|
||||||
@ -46,7 +46,7 @@ public function attributes(): array
|
|||||||
'gender' => 'Jenis Kelamin',
|
'gender' => 'Jenis Kelamin',
|
||||||
'birth_date' => 'Tanggal Lahir',
|
'birth_date' => 'Tanggal Lahir',
|
||||||
'address' => 'Alamat',
|
'address' => 'Alamat',
|
||||||
'profile_photo' => 'Foto Profil',
|
'profile_s3_key' => 'Foto Profil',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,6 +38,7 @@ public function rules(): array
|
|||||||
'address' => ['nullable', 'string'],
|
'address' => ['nullable', 'string'],
|
||||||
'role' => ['required', Rule::in(Role::assignableValues())],
|
'role' => ['required', Rule::in(Role::assignableValues())],
|
||||||
'profile_photo' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
|
'profile_photo' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
|
||||||
|
'profile_s3_key' => ['nullable', 'string'],
|
||||||
'remove_profile_photo_ids' => ['nullable', 'array'],
|
'remove_profile_photo_ids' => ['nullable', 'array'],
|
||||||
'remove_profile_photo_ids.*' => ['integer'],
|
'remove_profile_photo_ids.*' => ['integer'],
|
||||||
];
|
];
|
||||||
|
|||||||
@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Admin\Manage;
|
||||||
|
|
||||||
|
use App\Enums\Permission;
|
||||||
|
use App\Http\Requests\Concerns\HasProductVariantRules;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class CuttingQuickCreateProductRequest extends FormRequest
|
||||||
|
{
|
||||||
|
use HasProductVariantRules;
|
||||||
|
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->can(Permission::CUTTINGS_CREATE->value) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:200'],
|
||||||
|
'description' => ['nullable', 'string'],
|
||||||
|
|
||||||
|
'category_ids' => ['nullable', 'array'],
|
||||||
|
'category_ids.*' => ['integer', Rule::exists('categories', 'id')->whereNull('deleted_at')],
|
||||||
|
|
||||||
|
...$this->productVariantRules(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function attributes(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => 'Nama Produk',
|
||||||
|
'description' => 'Deskripsi',
|
||||||
|
'category_ids' => 'Kategori',
|
||||||
|
'category_ids.*' => 'Kategori',
|
||||||
|
...$this->productVariantAttributes(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Admin\Manage;
|
||||||
|
|
||||||
|
use App\Enums\Permission;
|
||||||
|
use App\Enums\RawMaterialUnit;
|
||||||
|
use App\Http\Requests\Concerns\HasRawMaterialPriceRules;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class CuttingQuickCreateRawMaterialRequest extends FormRequest
|
||||||
|
{
|
||||||
|
use HasRawMaterialPriceRules;
|
||||||
|
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->can(Permission::CUTTINGS_CREATE->value) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:200'],
|
||||||
|
'unit' => ['required', Rule::enum(RawMaterialUnit::class)],
|
||||||
|
|
||||||
|
...$this->rawMaterialPriceRules(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function attributes(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => 'Nama Bahan Baku',
|
||||||
|
'unit' => 'Satuan',
|
||||||
|
...$this->rawMaterialPriceAttributes(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,13 +3,13 @@
|
|||||||
namespace App\Http\Requests\Admin\Master;
|
namespace App\Http\Requests\Admin\Master;
|
||||||
|
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
use App\Http\Requests\Concerns\ValidatesMediaUploads;
|
use App\Http\Requests\Concerns\HasProductVariantRules;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class ProductRequest extends FormRequest
|
class ProductRequest extends FormRequest
|
||||||
{
|
{
|
||||||
use ValidatesMediaUploads;
|
use HasProductVariantRules;
|
||||||
|
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
@ -32,27 +32,7 @@ public function rules(): array
|
|||||||
'category_ids' => ['required', 'array', 'min:1'],
|
'category_ids' => ['required', 'array', 'min:1'],
|
||||||
'category_ids.*' => ['integer', Rule::exists('categories', 'id')->whereNull('deleted_at')],
|
'category_ids.*' => ['integer', Rule::exists('categories', 'id')->whereNull('deleted_at')],
|
||||||
|
|
||||||
'variants' => ['required', 'array', 'min:1'],
|
...$this->productVariantRules(productId: $this->route('product')?->id),
|
||||||
'variants.*.id' => [
|
|
||||||
'nullable',
|
|
||||||
'integer',
|
|
||||||
Rule::exists('product_variants', 'id')
|
|
||||||
->where('product_id', $this->route('product')?->id)
|
|
||||||
->whereNull('deleted_at'),
|
|
||||||
],
|
|
||||||
'variants.*.name' => ['required', 'string', 'max:200'],
|
|
||||||
'variants.*.stock' => ['required', 'integer', 'min:0'],
|
|
||||||
'variants.*.retail_stock' => ['required', 'integer', 'min:0'],
|
|
||||||
'variants.*.prices' => ['required', 'array'],
|
|
||||||
'variants.*.prices.distributor' => ['required', 'integer', 'min:0'],
|
|
||||||
'variants.*.prices.agent' => ['required', 'integer', 'min:0'],
|
|
||||||
'variants.*.prices.sub_agent' => ['required', 'integer', 'min:0'],
|
|
||||||
'variants.*.prices.grosir' => ['required', 'integer', 'min:0'],
|
|
||||||
'variants.*.prices.retail' => ['required', 'integer', 'min:0'],
|
|
||||||
'variants.*.prices.tiktok' => ['required', 'integer', 'min:0'],
|
|
||||||
'variants.*.prices.shopee' => ['required', 'integer', 'min:0'],
|
|
||||||
'variants.*.prices.harga_modal' => ['required', 'integer', 'min:0'],
|
|
||||||
...$this->variantImageRules(),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,20 +46,7 @@ public function attributes(): array
|
|||||||
'description' => 'Deskripsi',
|
'description' => 'Deskripsi',
|
||||||
'category_ids' => 'Kategori',
|
'category_ids' => 'Kategori',
|
||||||
'category_ids.*' => 'Kategori',
|
'category_ids.*' => 'Kategori',
|
||||||
'variants' => 'Varian',
|
...$this->productVariantAttributes(),
|
||||||
'variants.*.name' => 'Nama Varian',
|
|
||||||
'variants.*.stock' => 'Stok',
|
|
||||||
'variants.*.retail_stock' => 'Stok Ecer',
|
|
||||||
'variants.*.prices' => 'Harga',
|
|
||||||
'variants.*.prices.distributor' => 'Distributor',
|
|
||||||
'variants.*.prices.agent' => 'Agen',
|
|
||||||
'variants.*.prices.sub_agent' => 'Sub Agen',
|
|
||||||
'variants.*.prices.grosir' => 'Grosir',
|
|
||||||
'variants.*.prices.retail' => 'Eceran',
|
|
||||||
'variants.*.prices.tiktok' => 'TikTok',
|
|
||||||
'variants.*.prices.shopee' => 'Shopee',
|
|
||||||
'variants.*.prices.harga_modal' => 'Harga Modal',
|
|
||||||
...$this->variantImageAttributes('variants', 'Foto Varian'),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,13 +4,13 @@
|
|||||||
|
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
use App\Enums\RawMaterialUnit;
|
use App\Enums\RawMaterialUnit;
|
||||||
use App\Http\Requests\Concerns\ValidatesMediaUploads;
|
use App\Http\Requests\Concerns\HasRawMaterialPriceRules;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class RawMaterialRequest extends FormRequest
|
class RawMaterialRequest extends FormRequest
|
||||||
{
|
{
|
||||||
use ValidatesMediaUploads;
|
use HasRawMaterialPriceRules;
|
||||||
|
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
@ -30,18 +30,7 @@ public function rules(): array
|
|||||||
'name' => ['required', 'string', 'max:200'],
|
'name' => ['required', 'string', 'max:200'],
|
||||||
'unit' => ['required', Rule::enum(RawMaterialUnit::class)],
|
'unit' => ['required', Rule::enum(RawMaterialUnit::class)],
|
||||||
|
|
||||||
'prices' => ['required', 'array', 'min:1'],
|
...$this->rawMaterialPriceRules(rawMaterialId: $this->route('rawMaterial')?->id),
|
||||||
'prices.*.id' => [
|
|
||||||
'nullable',
|
|
||||||
'integer',
|
|
||||||
Rule::exists('raw_material_prices', 'id')
|
|
||||||
->where('raw_material_id', $this->route('rawMaterial')?->id)
|
|
||||||
->whereNull('deleted_at'),
|
|
||||||
],
|
|
||||||
'prices.*.variant' => ['required', 'string', 'max:200'],
|
|
||||||
'prices.*.price' => ['required', 'integer', 'gt:0'],
|
|
||||||
'prices.*.stock' => ['required', 'numeric', 'decimal:0,4', 'min:0'],
|
|
||||||
...$this->variantImageRules('prices'),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,11 +42,7 @@ public function attributes(): array
|
|||||||
return [
|
return [
|
||||||
'name' => 'Nama Bahan Baku',
|
'name' => 'Nama Bahan Baku',
|
||||||
'unit' => 'Satuan',
|
'unit' => 'Satuan',
|
||||||
'prices' => 'Varian',
|
...$this->rawMaterialPriceAttributes(),
|
||||||
'prices.*.variant' => 'Nama Varian',
|
|
||||||
'prices.*.price' => 'Harga',
|
|
||||||
'prices.*.stock' => 'Stok',
|
|
||||||
...$this->variantImageAttributes('prices', 'Foto Varian'),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,9 +19,11 @@ public function rules(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'hero_image' => ['nullable', 'image', 'max:5120'],
|
'hero_image' => ['nullable', 'image', 'max:5120'],
|
||||||
|
'hero_image_s3_key' => ['nullable', 'string'],
|
||||||
'about_image' => ['nullable', 'image', 'max:5120'],
|
'about_image' => ['nullable', 'image', 'max:5120'],
|
||||||
'gallery_images' => ['nullable', 'array', 'max:10'],
|
'about_image_s3_key' => ['nullable', 'string'],
|
||||||
'gallery_images.*' => ['image', 'max:5120'],
|
'gallery_s3_keys' => ['nullable', 'array', 'max:10'],
|
||||||
|
'gallery_s3_keys.*' => ['required', 'string'],
|
||||||
'gallery_images_remove' => ['nullable', 'array'],
|
'gallery_images_remove' => ['nullable', 'array'],
|
||||||
'gallery_images_remove.*' => ['integer'],
|
'gallery_images_remove.*' => ['integer'],
|
||||||
];
|
];
|
||||||
@ -34,9 +36,11 @@ public function attributes(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'hero_image' => 'Foto Hero',
|
'hero_image' => 'Foto Hero',
|
||||||
|
'hero_image_s3_key' => 'Foto Hero',
|
||||||
'about_image' => 'Foto Tentang Kami',
|
'about_image' => 'Foto Tentang Kami',
|
||||||
'gallery_images' => 'Foto Lookbook',
|
'about_image_s3_key' => 'Foto Tentang Kami',
|
||||||
'gallery_images.*' => 'Foto Lookbook',
|
'gallery_s3_keys' => 'Foto Lookbook',
|
||||||
|
'gallery_s3_keys.*' => 'Foto Lookbook',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,8 +25,11 @@ public function rules(): array
|
|||||||
'phone' => ['required', 'string', 'max:20', new PhoneNumber],
|
'phone' => ['required', 'string', 'max:20', new PhoneNumber],
|
||||||
'address' => ['required', 'string'],
|
'address' => ['required', 'string'],
|
||||||
'logo' => ['nullable', 'image', 'max:2048'],
|
'logo' => ['nullable', 'image', 'max:2048'],
|
||||||
|
'logo_s3_key' => ['nullable', 'string'],
|
||||||
'favicon' => ['nullable', 'image', 'max:1024'],
|
'favicon' => ['nullable', 'image', 'max:1024'],
|
||||||
|
'favicon_s3_key' => ['nullable', 'string'],
|
||||||
'login_cover' => ['nullable', 'image', 'max:5120'],
|
'login_cover' => ['nullable', 'image', 'max:5120'],
|
||||||
|
'login_cover_s3_key' => ['nullable', 'string'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -42,8 +45,11 @@ public function attributes(): array
|
|||||||
'phone' => 'nomor telepon',
|
'phone' => 'nomor telepon',
|
||||||
'address' => 'alamat',
|
'address' => 'alamat',
|
||||||
'logo' => 'logo',
|
'logo' => 'logo',
|
||||||
|
'logo_s3_key' => 'logo',
|
||||||
'favicon' => 'favicon',
|
'favicon' => 'favicon',
|
||||||
|
'favicon_s3_key' => 'favicon',
|
||||||
'login_cover' => 'cover login',
|
'login_cover' => 'cover login',
|
||||||
|
'login_cover_s3_key' => 'cover login',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
78
app/Http/Requests/Concerns/HasProductVariantRules.php
Normal file
78
app/Http/Requests/Concerns/HasProductVariantRules.php
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Concerns;
|
||||||
|
|
||||||
|
use App\Enums\Role;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @method FormRequest user()
|
||||||
|
*/
|
||||||
|
trait HasProductVariantRules
|
||||||
|
{
|
||||||
|
use ValidatesMediaUploads;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function productVariantRules(string $variantsKey = 'variants', ?int $productId = null): array
|
||||||
|
{
|
||||||
|
$isAdminBahanBaku = $this->user()?->hasRole(Role::ADMIN_BAHAN_BAKU->value) ?? false;
|
||||||
|
|
||||||
|
$rules = [
|
||||||
|
"{$variantsKey}" => ['required', 'array', 'min:1'],
|
||||||
|
"{$variantsKey}.*.name" => ['required', 'string', 'max:200'],
|
||||||
|
"{$variantsKey}.*.stock" => ['required', 'integer', 'min:0'],
|
||||||
|
"{$variantsKey}.*.retail_stock" => ['required', 'integer', 'min:0'],
|
||||||
|
...$this->variantImageRules($variantsKey),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($productId) {
|
||||||
|
$rules["{$variantsKey}.*.id"] = [
|
||||||
|
'nullable',
|
||||||
|
'integer',
|
||||||
|
Rule::exists('product_variants', 'id')
|
||||||
|
->where('product_id', $productId)
|
||||||
|
->whereNull('deleted_at'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $isAdminBahanBaku) {
|
||||||
|
$rules["{$variantsKey}.*.prices"] = ['required', 'array'];
|
||||||
|
$rules["{$variantsKey}.*.prices.distributor"] = ['required', 'integer', 'min:0'];
|
||||||
|
$rules["{$variantsKey}.*.prices.agent"] = ['required', 'integer', 'min:0'];
|
||||||
|
$rules["{$variantsKey}.*.prices.sub_agent"] = ['required', 'integer', 'min:0'];
|
||||||
|
$rules["{$variantsKey}.*.prices.grosir"] = ['required', 'integer', 'min:0'];
|
||||||
|
$rules["{$variantsKey}.*.prices.retail"] = ['required', 'integer', 'min:0'];
|
||||||
|
$rules["{$variantsKey}.*.prices.tiktok"] = ['required', 'integer', 'min:0'];
|
||||||
|
$rules["{$variantsKey}.*.prices.shopee"] = ['required', 'integer', 'min:0'];
|
||||||
|
$rules["{$variantsKey}.*.prices.harga_modal"] = ['required', 'integer', 'min:0'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function productVariantAttributes(string $variantsKey = 'variants'): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
$variantsKey => 'Varian',
|
||||||
|
"{$variantsKey}.*.name" => 'Nama Varian',
|
||||||
|
"{$variantsKey}.*.stock" => 'Stok',
|
||||||
|
"{$variantsKey}.*.retail_stock" => 'Stok Ecer',
|
||||||
|
"{$variantsKey}.*.prices" => 'Harga',
|
||||||
|
"{$variantsKey}.*.prices.distributor" => 'Distributor',
|
||||||
|
"{$variantsKey}.*.prices.agent" => 'Agen',
|
||||||
|
"{$variantsKey}.*.prices.sub_agent" => 'Sub Agen',
|
||||||
|
"{$variantsKey}.*.prices.grosir" => 'Grosir',
|
||||||
|
"{$variantsKey}.*.prices.retail" => 'Eceran',
|
||||||
|
"{$variantsKey}.*.prices.tiktok" => 'TikTok',
|
||||||
|
"{$variantsKey}.*.prices.shopee" => 'Shopee',
|
||||||
|
"{$variantsKey}.*.prices.harga_modal" => 'Harga Modal',
|
||||||
|
...$this->variantImageAttributes($variantsKey, 'Foto Varian'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/Http/Requests/Concerns/HasRawMaterialPriceRules.php
Normal file
50
app/Http/Requests/Concerns/HasRawMaterialPriceRules.php
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Concerns;
|
||||||
|
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
trait HasRawMaterialPriceRules
|
||||||
|
{
|
||||||
|
use ValidatesMediaUploads;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function rawMaterialPriceRules(string $pricesKey = 'prices', ?int $rawMaterialId = null): array
|
||||||
|
{
|
||||||
|
$rules = [
|
||||||
|
"{$pricesKey}" => ['required', 'array', 'min:1'],
|
||||||
|
"{$pricesKey}.*.variant" => ['required', 'string', 'max:200'],
|
||||||
|
"{$pricesKey}.*.price" => ['required', 'integer', 'gt:0'],
|
||||||
|
"{$pricesKey}.*.stock" => ['required', 'numeric', 'decimal:0,4', 'min:0'],
|
||||||
|
...$this->variantImageRules($pricesKey),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($rawMaterialId) {
|
||||||
|
$rules["{$pricesKey}.*.id"] = [
|
||||||
|
'nullable',
|
||||||
|
'integer',
|
||||||
|
Rule::exists('raw_material_prices', 'id')
|
||||||
|
->where('raw_material_id', $rawMaterialId)
|
||||||
|
->whereNull('deleted_at'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function rawMaterialPriceAttributes(string $pricesKey = 'prices'): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
$pricesKey => 'Varian',
|
||||||
|
"{$pricesKey}.*.variant" => 'Nama Varian',
|
||||||
|
"{$pricesKey}.*.price" => 'Harga',
|
||||||
|
"{$pricesKey}.*.stock" => 'Stok',
|
||||||
|
...$this->variantImageAttributes($pricesKey, 'Foto Varian'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,8 +10,8 @@ trait ValidatesMediaUploads
|
|||||||
protected function photoRules(string $prefix = 'photos', int $max = 1): array
|
protected function photoRules(string $prefix = 'photos', int $max = 1): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
$prefix => ['nullable', 'array', "max:{$max}"],
|
's3_keys' => ['nullable', 'array', "max:{$max}"],
|
||||||
"{$prefix}.*" => ['image', 'max:5120'],
|
's3_keys.*' => ['required', 'string'],
|
||||||
'remove_media_ids' => ['nullable', 'array'],
|
'remove_media_ids' => ['nullable', 'array'],
|
||||||
'remove_media_ids.*' => ['integer'],
|
'remove_media_ids.*' => ['integer'],
|
||||||
];
|
];
|
||||||
@ -23,8 +23,8 @@ protected function photoRules(string $prefix = 'photos', int $max = 1): array
|
|||||||
protected function variantImageRules(string $variantsKey = 'variants', int $max = 5): array
|
protected function variantImageRules(string $variantsKey = 'variants', int $max = 5): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
"{$variantsKey}.*.images" => ['nullable', 'array', "max:{$max}"],
|
"{$variantsKey}.*.s3_keys" => ['nullable', 'array', "max:{$max}"],
|
||||||
"{$variantsKey}.*.images.*" => ['image', 'max:5120'],
|
"{$variantsKey}.*.s3_keys.*" => ['required', 'string'],
|
||||||
"{$variantsKey}.*.remove_media_ids" => ['nullable', 'array'],
|
"{$variantsKey}.*.remove_media_ids" => ['nullable', 'array'],
|
||||||
"{$variantsKey}.*.remove_media_ids.*" => ['integer'],
|
"{$variantsKey}.*.remove_media_ids.*" => ['integer'],
|
||||||
];
|
];
|
||||||
@ -36,8 +36,8 @@ protected function variantImageRules(string $variantsKey = 'variants', int $max
|
|||||||
protected function photoUploadAttributes(string $label): array
|
protected function photoUploadAttributes(string $label): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'photos' => $label,
|
's3_keys' => $label,
|
||||||
'photos.*' => $label,
|
's3_keys.*' => $label,
|
||||||
'remove_media_ids' => 'media yang dihapus',
|
'remove_media_ids' => 'media yang dihapus',
|
||||||
'remove_media_ids.*' => 'media yang dihapus',
|
'remove_media_ids.*' => 'media yang dihapus',
|
||||||
];
|
];
|
||||||
@ -49,8 +49,8 @@ protected function photoUploadAttributes(string $label): array
|
|||||||
protected function variantImageAttributes(string $variantsKey, string $label): array
|
protected function variantImageAttributes(string $variantsKey, string $label): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
"{$variantsKey}.*.images" => $label,
|
"{$variantsKey}.*.s3_keys" => $label,
|
||||||
"{$variantsKey}.*.images.*" => $label,
|
"{$variantsKey}.*.s3_keys.*" => $label,
|
||||||
"{$variantsKey}.*.remove_media_ids" => 'media yang dihapus',
|
"{$variantsKey}.*.remove_media_ids" => 'media yang dihapus',
|
||||||
"{$variantsKey}.*.remove_media_ids.*" => 'media yang dihapus',
|
"{$variantsKey}.*.remove_media_ids.*" => 'media yang dihapus',
|
||||||
];
|
];
|
||||||
|
|||||||
@ -16,7 +16,6 @@ public function registerMediaConversions(?Media $media = null): void
|
|||||||
$this->addMediaConversion('thumb')
|
$this->addMediaConversion('thumb')
|
||||||
->width(80)
|
->width(80)
|
||||||
->height(80)
|
->height(80)
|
||||||
->sharpen(10)
|
->sharpen(10);
|
||||||
->nonQueued();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Spatie\MediaLibrary\HasMedia;
|
use Spatie\MediaLibrary\HasMedia;
|
||||||
|
|
||||||
#[Guarded(['id'])]
|
#[Guarded(['id'])]
|
||||||
@ -56,7 +57,19 @@ public function genderLabel(): Attribute
|
|||||||
public function profilePhotoUrl(): Attribute
|
public function profilePhotoUrl(): Attribute
|
||||||
{
|
{
|
||||||
return Attribute::make(
|
return Attribute::make(
|
||||||
get: fn () => $this->getFirstMediaUrl('profile_photo') ?: null,
|
get: function () {
|
||||||
|
$media = $this->getFirstMedia('profile_photo');
|
||||||
|
|
||||||
|
if (! $media) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($media->disk === 's3') {
|
||||||
|
return Storage::disk('s3')->temporaryUrl($media->getPath(), now()->addMinutes(30));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $media->getUrl();
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -35,10 +35,17 @@ public function update(array $validated, User $user): void
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
$newFiles = ! empty($validated['profile_photo']) ? [$validated['profile_photo']] : [];
|
$s3Keys = ! empty($validated['profile_s3_key']) ? [$validated['profile_s3_key']] : null;
|
||||||
$removeIds = $validated['remove_profile_photo_ids'] ?? [];
|
$removeIds = $validated['remove_profile_photo_ids'] ?? null;
|
||||||
|
|
||||||
$this->mediaService->syncCollection($profile, 'profile_photo', $newFiles, $removeIds, 1);
|
$this->mediaService->syncCollection(
|
||||||
|
$profile,
|
||||||
|
'profile_photo',
|
||||||
|
null,
|
||||||
|
$removeIds,
|
||||||
|
1,
|
||||||
|
s3Keys: $s3Keys,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
} catch (ValidationException $e) {
|
} catch (ValidationException $e) {
|
||||||
throw $e;
|
throw $e;
|
||||||
|
|||||||
@ -418,6 +418,7 @@ private function syncPhotos(CashTransaction $transaction, array $validated): voi
|
|||||||
self::MAX_PHOTOS,
|
self::MAX_PHOTOS,
|
||||||
required: true,
|
required: true,
|
||||||
errorKey: 'photos',
|
errorKey: 'photos',
|
||||||
|
s3Keys: $validated['s3_keys'] ?? null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -97,7 +97,7 @@ public function create(array $validated, User $user): void
|
|||||||
$this->pushNotificationService->sendToRoles(
|
$this->pushNotificationService->sendToRoles(
|
||||||
'💰 Pengajuan Kasbon Baru',
|
'💰 Pengajuan Kasbon Baru',
|
||||||
"Karyawan {$user->profil?->full_name} mengajukan kasbon sebesar {$employeeAdvance->amount_formatted} dengan keterangan: {$employeeAdvance->description}.",
|
"Karyawan {$user->profil?->full_name} mengajukan kasbon sebesar {$employeeAdvance->amount_formatted} dengan keterangan: {$employeeAdvance->description}.",
|
||||||
['owner', 'developer'],
|
['owner', 'developer', 'direktur'],
|
||||||
route('admin.finance.employee_advances.index'),
|
route('admin.finance.employee_advances.index'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -127,7 +127,7 @@ public function update(EmployeeAdvance $employeeAdvance, array $validated, User
|
|||||||
$this->pushNotificationService->sendToRoles(
|
$this->pushNotificationService->sendToRoles(
|
||||||
'✏️ Kasbon Diperbarui',
|
'✏️ Kasbon Diperbarui',
|
||||||
"Kasbon sebesar {$employeeAdvance->amount_formatted} telah diperbarui oleh {$user->profile?->full_name}.",
|
"Kasbon sebesar {$employeeAdvance->amount_formatted} telah diperbarui oleh {$user->profile?->full_name}.",
|
||||||
['owner', 'developer'],
|
['owner', 'developer', 'direktur'],
|
||||||
route('admin.finance.employee_advances.index'),
|
route('admin.finance.employee_advances.index'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -142,7 +142,7 @@ public function delete(EmployeeAdvance $employeeAdvance, User $user): void
|
|||||||
$this->pushNotificationService->sendToRoles(
|
$this->pushNotificationService->sendToRoles(
|
||||||
'🗑️ Kasbon Dihapus',
|
'🗑️ Kasbon Dihapus',
|
||||||
"Kasbon sebesar {$amount} dengan keterangan {$description} telah dihapus.",
|
"Kasbon sebesar {$amount} dengan keterangan {$description} telah dihapus.",
|
||||||
['owner', 'developer'],
|
['owner', 'developer', 'direktur'],
|
||||||
route('admin.finance.employee_advances.index'),
|
route('admin.finance.employee_advances.index'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -92,7 +92,7 @@ public function create(array $validated, User $user): void
|
|||||||
$this->pushNotificationService->sendToRoles(
|
$this->pushNotificationService->sendToRoles(
|
||||||
'💸 Pengeluaran Baru',
|
'💸 Pengeluaran Baru',
|
||||||
"Pengeluaran baru sebesar {$expense->amount_formatted} dengan keterangan {$expense->description} telah dicatat oleh {$user->profile?->full_name}.",
|
"Pengeluaran baru sebesar {$expense->amount_formatted} dengan keterangan {$expense->description} telah dicatat oleh {$user->profile?->full_name}.",
|
||||||
['owner', 'developer'],
|
['owner', 'developer', 'direktur'],
|
||||||
route('admin.finance.expenses.index'),
|
route('admin.finance.expenses.index'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -134,7 +134,7 @@ public function update(Expense $expense, array $validated): void
|
|||||||
$this->pushNotificationService->sendToRoles(
|
$this->pushNotificationService->sendToRoles(
|
||||||
'✏️ Pengeluaran Diperbarui',
|
'✏️ Pengeluaran Diperbarui',
|
||||||
"Pengeluaran dengan keterangan {$expense->description} diperbarui menjadi sebesar {$expense->amount_formatted}.",
|
"Pengeluaran dengan keterangan {$expense->description} diperbarui menjadi sebesar {$expense->amount_formatted}.",
|
||||||
['owner', 'developer'],
|
['owner', 'developer', 'direktur'],
|
||||||
route('admin.finance.expenses.index'),
|
route('admin.finance.expenses.index'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -165,7 +165,7 @@ public function delete(Expense $expense): void
|
|||||||
$this->pushNotificationService->sendToRoles(
|
$this->pushNotificationService->sendToRoles(
|
||||||
'🗑️ Pengeluaran Dihapus',
|
'🗑️ Pengeluaran Dihapus',
|
||||||
"Pengeluaran sebesar {$expense->amount_formatted} dengan keterangan {$expense->description} telah dihapus.",
|
"Pengeluaran sebesar {$expense->amount_formatted} dengan keterangan {$expense->description} telah dihapus.",
|
||||||
['owner', 'developer'],
|
['owner', 'developer', 'direktur'],
|
||||||
route('admin.finance.expenses.index'),
|
route('admin.finance.expenses.index'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -180,6 +180,7 @@ private function syncPhotos(Expense $expense, array $validated): void
|
|||||||
self::MAX_PHOTOS,
|
self::MAX_PHOTOS,
|
||||||
required: true,
|
required: true,
|
||||||
errorKey: 'photos',
|
errorKey: 'photos',
|
||||||
|
s3Keys: $validated['s3_keys'] ?? null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -100,7 +100,7 @@ public function checkIn(array $validated, User $user): void
|
|||||||
$this->pushNotificationService->sendToRoles(
|
$this->pushNotificationService->sendToRoles(
|
||||||
'⏰ Presensi Masuk',
|
'⏰ Presensi Masuk',
|
||||||
"Karyawan {$user->profile?->full_name} melakukan presensi masuk.",
|
"Karyawan {$user->profile?->full_name} melakukan presensi masuk.",
|
||||||
['owner', 'developer'],
|
['owner', 'developer', 'direktur'],
|
||||||
route('admin.hr.attendances.index'),
|
route('admin.hr.attendances.index'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -146,7 +146,7 @@ public function checkOut(array $validated, User $user): void
|
|||||||
$this->pushNotificationService->sendToRoles(
|
$this->pushNotificationService->sendToRoles(
|
||||||
'⏰ Presensi Pulang',
|
'⏰ Presensi Pulang',
|
||||||
"Karyawan {$user->profile?->full_name} melakukan presensi pulang (Durasi kerja: ".round($workDurationMinutes / 60, 1).' jam).',
|
"Karyawan {$user->profile?->full_name} melakukan presensi pulang (Durasi kerja: ".round($workDurationMinutes / 60, 1).' jam).',
|
||||||
['owner', 'developer'],
|
['owner', 'developer', 'direktur'],
|
||||||
route('admin.hr.attendances.index'),
|
route('admin.hr.attendances.index'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -164,7 +164,7 @@ public function delete(Attendance $attendance): void
|
|||||||
$this->pushNotificationService->sendToRoles(
|
$this->pushNotificationService->sendToRoles(
|
||||||
'🗑️ Presensi Dihapus',
|
'🗑️ Presensi Dihapus',
|
||||||
"Data presensi {$employeeName} tanggal {$date} telah dihapus.",
|
"Data presensi {$employeeName} tanggal {$date} telah dihapus.",
|
||||||
['owner', 'developer'],
|
['owner', 'developer', 'direktur'],
|
||||||
route('admin.hr.attendances.index'),
|
route('admin.hr.attendances.index'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -223,10 +223,17 @@ public function delete(User $user): void
|
|||||||
|
|
||||||
private function syncProfilePhoto(UserProfile $profile, array $validated): void
|
private function syncProfilePhoto(UserProfile $profile, array $validated): void
|
||||||
{
|
{
|
||||||
$newFiles = ! empty($validated['profile_photo']) ? [$validated['profile_photo']] : [];
|
$s3Keys = ! empty($validated['profile_s3_key']) ? [$validated['profile_s3_key']] : null;
|
||||||
$removeIds = $validated['remove_profile_photo_ids'] ?? [];
|
$removeIds = $validated['remove_profile_photo_ids'] ?? null;
|
||||||
|
|
||||||
$this->mediaService->syncCollection($profile, 'profile_photo', $newFiles, $removeIds, 1);
|
$this->mediaService->syncCollection(
|
||||||
|
$profile,
|
||||||
|
'profile_photo',
|
||||||
|
null,
|
||||||
|
$removeIds,
|
||||||
|
1,
|
||||||
|
s3Keys: $s3Keys,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function applySorting(Builder $query, string $sort, string $direction): void
|
private function applySorting(Builder $query, string $sort, string $direction): void
|
||||||
|
|||||||
@ -78,7 +78,7 @@ public function create(array $validated, User $user): void
|
|||||||
$this->pushNotificationService->sendToRoles(
|
$this->pushNotificationService->sendToRoles(
|
||||||
'🏖️ Pengajuan Cuti Baru',
|
'🏖️ Pengajuan Cuti Baru',
|
||||||
"Karyawan {$user->profile?->full_name} mengajukan cuti selama {$leaveRequest->total_days} hari mulai dari tanggal {$leaveRequest->start_date_formatted}.",
|
"Karyawan {$user->profile?->full_name} mengajukan cuti selama {$leaveRequest->total_days} hari mulai dari tanggal {$leaveRequest->start_date_formatted}.",
|
||||||
['owner', 'developer'],
|
['owner', 'developer', 'direktur'],
|
||||||
route('admin.hr.leave_requests.index'),
|
route('admin.hr.leave_requests.index'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -99,7 +99,7 @@ public function update(LeaveRequest $leaveRequest, array $validated, User $user)
|
|||||||
$this->pushNotificationService->sendToRoles(
|
$this->pushNotificationService->sendToRoles(
|
||||||
'✏️ Pengajuan Cuti Diperbarui',
|
'✏️ Pengajuan Cuti Diperbarui',
|
||||||
"Pengajuan cuti oleh {$user->profile?->full_name} telah diperbarui.",
|
"Pengajuan cuti oleh {$user->profile?->full_name} telah diperbarui.",
|
||||||
['owner', 'developer'],
|
['owner', 'developer', 'direktur'],
|
||||||
route('admin.hr.leave_requests.index'),
|
route('admin.hr.leave_requests.index'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -114,7 +114,7 @@ public function delete(LeaveRequest $leaveRequest): void
|
|||||||
$this->pushNotificationService->sendToRoles(
|
$this->pushNotificationService->sendToRoles(
|
||||||
'🗑️ Pengajuan Cuti Dihapus',
|
'🗑️ Pengajuan Cuti Dihapus',
|
||||||
"Pengajuan cuti {$totalDays} hari oleh {$employeeName} telah dihapus.",
|
"Pengajuan cuti {$totalDays} hari oleh {$employeeName} telah dihapus.",
|
||||||
['owner', 'developer'],
|
['owner', 'developer', 'direktur'],
|
||||||
route('admin.hr.leave_requests.index'),
|
route('admin.hr.leave_requests.index'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
namespace App\Services\Manage;
|
namespace App\Services\Manage;
|
||||||
|
|
||||||
use App\Enums\CuttingStatus;
|
use App\Enums\CuttingStatus;
|
||||||
|
use App\Enums\RawMaterialUnit;
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
use App\Models\Cutting;
|
use App\Models\Cutting;
|
||||||
use App\Models\CuttingMaterial;
|
use App\Models\CuttingMaterial;
|
||||||
@ -13,6 +14,7 @@
|
|||||||
use App\Models\RawMaterial;
|
use App\Models\RawMaterial;
|
||||||
use App\Models\RawMaterialPrice;
|
use App\Models\RawMaterialPrice;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Media\MediaService;
|
||||||
use App\Services\System\PushNotificationService;
|
use App\Services\System\PushNotificationService;
|
||||||
use App\Support\Media\MediaPresenter;
|
use App\Support\Media\MediaPresenter;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
@ -26,6 +28,7 @@ class CuttingService
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly PushNotificationService $pushNotificationService,
|
private readonly PushNotificationService $pushNotificationService,
|
||||||
|
private readonly MediaService $mediaService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function paginateForIndex(array $tableQuery, User $user): LengthAwarePaginator
|
public function paginateForIndex(array $tableQuery, User $user): LengthAwarePaginator
|
||||||
@ -452,7 +455,7 @@ public function create(array $validated, User $user): Cutting
|
|||||||
$this->pushNotificationService->sendToRoles(
|
$this->pushNotificationService->sendToRoles(
|
||||||
'✂️ Proses Cutting Baru',
|
'✂️ Proses Cutting Baru',
|
||||||
"Proses cutting dengan deskripsi ({$cutting->description}) telah dimulai oleh {$user->profile?->full_name}.",
|
"Proses cutting dengan deskripsi ({$cutting->description}) telah dimulai oleh {$user->profile?->full_name}.",
|
||||||
['owner', 'developer'],
|
['owner', 'developer', 'direktur'],
|
||||||
route('admin.manage.cuttings.index'),
|
route('admin.manage.cuttings.index'),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -521,7 +524,7 @@ public function update(Cutting $cutting, array $validated): void
|
|||||||
$this->pushNotificationService->sendToRoles(
|
$this->pushNotificationService->sendToRoles(
|
||||||
'✏️ Proses Cutting Diperbarui',
|
'✏️ Proses Cutting Diperbarui',
|
||||||
"Proses cutting dengan deskripsi '{$description}' telah diperbarui.",
|
"Proses cutting dengan deskripsi '{$description}' telah diperbarui.",
|
||||||
['owner', 'developer'],
|
['owner', 'developer', 'direktur'],
|
||||||
route('admin.manage.cuttings.index'),
|
route('admin.manage.cuttings.index'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -565,7 +568,7 @@ public function delete(Cutting $cutting): void
|
|||||||
$this->pushNotificationService->sendToRoles(
|
$this->pushNotificationService->sendToRoles(
|
||||||
'🗑️ Proses Cutting Dihapus',
|
'🗑️ Proses Cutting Dihapus',
|
||||||
"Proses cutting dengan deskripsi {$description} telah dihapus.",
|
"Proses cutting dengan deskripsi {$description} telah dihapus.",
|
||||||
['owner', 'developer'],
|
['owner', 'developer', 'direktur'],
|
||||||
route('admin.manage.cuttings.index'),
|
route('admin.manage.cuttings.index'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -658,8 +661,8 @@ public function transitionStatus(
|
|||||||
};
|
};
|
||||||
|
|
||||||
$roles = $status === CuttingStatus::COMPLETED
|
$roles = $status === CuttingStatus::COMPLETED
|
||||||
? ['owner', 'developer', 'admin-toko']
|
? ['owner', 'developer', 'direktur', 'admin-toko']
|
||||||
: ['owner', 'developer'];
|
: ['owner', 'developer', 'direktur'];
|
||||||
|
|
||||||
$this->pushNotificationService->sendToRoles(
|
$this->pushNotificationService->sendToRoles(
|
||||||
$title,
|
$title,
|
||||||
@ -1034,4 +1037,131 @@ private function storeResultPrices(Cutting $cutting, array $resultPrices): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick-create a raw material with variants, bypassing owner verification.
|
||||||
|
*/
|
||||||
|
public function quickCreateRawMaterial(array $validated): array
|
||||||
|
{
|
||||||
|
$rawMaterial = RawMaterial::create([
|
||||||
|
'name' => $validated['name'],
|
||||||
|
'unit' => $validated['unit'],
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$createdPrices = [];
|
||||||
|
$maxVariantImages = 5;
|
||||||
|
|
||||||
|
foreach ($validated['prices'] as $index => $priceData) {
|
||||||
|
$price = $rawMaterial->prices()->create([
|
||||||
|
'variant' => $priceData['variant'],
|
||||||
|
'price' => $priceData['price'],
|
||||||
|
'stock' => $priceData['stock'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->mediaService->syncCollection(
|
||||||
|
$price,
|
||||||
|
'images',
|
||||||
|
$priceData['images'] ?? null,
|
||||||
|
null,
|
||||||
|
$maxVariantImages,
|
||||||
|
required: true,
|
||||||
|
errorKey: "prices.{$index}.images",
|
||||||
|
s3Keys: $priceData['s3_keys'] ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$price->refresh();
|
||||||
|
|
||||||
|
$createdPrices[] = [
|
||||||
|
'id' => $price->id,
|
||||||
|
'variant' => $price->variant,
|
||||||
|
'price' => $price->price,
|
||||||
|
'price_formatted' => 'Rp '.number_format($price->price, 0, ',', '.'),
|
||||||
|
'stock' => $price->stock,
|
||||||
|
'stock_formatted' => $this->formatStockForUnit((float) $price->stock, $rawMaterial->unit),
|
||||||
|
'stock_input' => $this->formatQuantityInput((float) $price->stock),
|
||||||
|
'images' => MediaPresenter::collection($price, 'images'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $rawMaterial->id,
|
||||||
|
'name' => $rawMaterial->name,
|
||||||
|
'unit' => $rawMaterial->unit->value,
|
||||||
|
'unit_label' => $rawMaterial->unit->label(),
|
||||||
|
'unit_abbreviation' => $rawMaterial->unit->abbreviation(),
|
||||||
|
'is_active' => true,
|
||||||
|
'prices' => $createdPrices,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick-create a product with variants and prices, bypassing owner verification.
|
||||||
|
*/
|
||||||
|
public function quickCreateProduct(array $validated): array
|
||||||
|
{
|
||||||
|
$product = Product::create([
|
||||||
|
'name' => $validated['name'],
|
||||||
|
'description' => $validated['description'] ?? null,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! empty($validated['category_ids'])) {
|
||||||
|
$product->categories()->sync($validated['category_ids']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$createdVariants = [];
|
||||||
|
$maxVariantImages = 5;
|
||||||
|
|
||||||
|
foreach ($validated['variants'] as $index => $variantData) {
|
||||||
|
$variant = $product->variants()->create([
|
||||||
|
'name' => $variantData['name'],
|
||||||
|
'stock' => $variantData['stock'] ?? 0,
|
||||||
|
'retail_stock' => $variantData['retail_stock'] ?? 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! empty($variantData['prices'])) {
|
||||||
|
foreach ($variantData['prices'] as $type => $priceValue) {
|
||||||
|
$variant->prices()->create([
|
||||||
|
'type' => $type,
|
||||||
|
'price' => (int) $priceValue,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->mediaService->syncCollection(
|
||||||
|
$variant,
|
||||||
|
'images',
|
||||||
|
$variantData['images'] ?? null,
|
||||||
|
null,
|
||||||
|
$maxVariantImages,
|
||||||
|
required: true,
|
||||||
|
errorKey: "variants.{$index}.images",
|
||||||
|
s3Keys: $variantData['s3_keys'] ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$variant->refresh();
|
||||||
|
|
||||||
|
$createdVariants[] = [
|
||||||
|
'id' => $variant->id,
|
||||||
|
'name' => $variant->name,
|
||||||
|
'stock' => $variant->stock,
|
||||||
|
'images' => MediaPresenter::collection($variant, 'images'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $product->id,
|
||||||
|
'name' => $product->name,
|
||||||
|
'is_active' => true,
|
||||||
|
'variants' => $createdVariants,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatStockForUnit(float $stock, RawMaterialUnit $unit): string
|
||||||
|
{
|
||||||
|
$formatted = rtrim(rtrim(number_format($stock, 2, ',', '.'), '0'), ',');
|
||||||
|
|
||||||
|
return "{$formatted} {$unit->abbreviation()}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -498,7 +498,7 @@ public function create(array $validated, User $user): Order
|
|||||||
$this->pushNotificationService->sendToRoles(
|
$this->pushNotificationService->sendToRoles(
|
||||||
'📦 Pesanan Baru',
|
'📦 Pesanan Baru',
|
||||||
"Pesanan baru {$order->order_number} senilai {$order->total_amount_formatted} telah dibuat oleh {$user->profile?->full_name}.",
|
"Pesanan baru {$order->order_number} senilai {$order->total_amount_formatted} telah dibuat oleh {$user->profile?->full_name}.",
|
||||||
['owner', 'developer'],
|
['owner', 'developer', 'direktur'],
|
||||||
route('admin.manage.orders.index'),
|
route('admin.manage.orders.index'),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -596,7 +596,7 @@ public function update(Order $order, array $validated): void
|
|||||||
$this->pushNotificationService->sendToRoles(
|
$this->pushNotificationService->sendToRoles(
|
||||||
'✏️ Pesanan Diperbarui',
|
'✏️ Pesanan Diperbarui',
|
||||||
"Pesanan {$order->order_number} senilai {$order->total_amount_formatted} telah diperbarui.",
|
"Pesanan {$order->order_number} senilai {$order->total_amount_formatted} telah diperbarui.",
|
||||||
['owner', 'developer'],
|
['owner', 'developer', 'direktur'],
|
||||||
route('admin.manage.orders.index'),
|
route('admin.manage.orders.index'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -638,7 +638,7 @@ public function delete(Order $order): void
|
|||||||
$this->pushNotificationService->sendToRoles(
|
$this->pushNotificationService->sendToRoles(
|
||||||
'🗑️ Pesanan Dihapus',
|
'🗑️ Pesanan Dihapus',
|
||||||
"Pesanan {$orderNumber} senilai {$order->total_amount_formatted} telah dihapus.",
|
"Pesanan {$orderNumber} senilai {$order->total_amount_formatted} telah dihapus.",
|
||||||
['owner', 'developer'],
|
['owner', 'developer', 'direktur'],
|
||||||
route('admin.manage.orders.index'),
|
route('admin.manage.orders.index'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -684,7 +684,7 @@ public function transitionStatus(Order $order, OrderStatus $status): void
|
|||||||
$this->pushNotificationService->sendToRoles(
|
$this->pushNotificationService->sendToRoles(
|
||||||
'📦 Status Pesanan Diubah',
|
'📦 Status Pesanan Diubah',
|
||||||
"Pesanan {$order->order_number} diubah statusnya menjadi {$status->label()}.",
|
"Pesanan {$order->order_number} diubah statusnya menjadi {$status->label()}.",
|
||||||
['owner', 'developer'],
|
['owner', 'developer', 'direktur'],
|
||||||
route('admin.manage.orders.index'),
|
route('admin.manage.orders.index'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -821,6 +821,7 @@ private function syncPhotos(Order $order, array $validated): void
|
|||||||
self::MAX_PHOTOS,
|
self::MAX_PHOTOS,
|
||||||
required: $requiresPhoto,
|
required: $requiresPhoto,
|
||||||
errorKey: 'photos',
|
errorKey: 'photos',
|
||||||
|
s3Keys: $validated['s3_keys'] ?? null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -265,7 +265,7 @@ public function rejectRequest(
|
|||||||
$this->pushNotificationService->sendToRoles(
|
$this->pushNotificationService->sendToRoles(
|
||||||
'❌ Pengajuan Ditolak',
|
'❌ Pengajuan Ditolak',
|
||||||
"Pengajuan {$request->action->label()} {$subjectLabel} '{$title}' ditolak dengan alasan: '{$reason}'.",
|
"Pengajuan {$request->action->label()} {$subjectLabel} '{$title}' ditolak dengan alasan: '{$reason}'.",
|
||||||
['owner', 'developer'],
|
['owner', 'developer', 'direktur'],
|
||||||
route('admin.dashboard'),
|
route('admin.dashboard'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -211,15 +211,16 @@ public function createVariantAndDraft(array $validated, User $user): array
|
|||||||
'stock' => (float) ($validated['stock'] ?? 0),
|
'stock' => (float) ($validated['stock'] ?? 0),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (! empty($validated['photos'])) {
|
if (! empty($validated['photos']) || ! empty($validated['s3_keys'])) {
|
||||||
$this->mediaService->syncCollection(
|
$this->mediaService->syncCollection(
|
||||||
$price,
|
$price,
|
||||||
'images',
|
'images',
|
||||||
$validated['photos'],
|
$validated['photos'] ?? null,
|
||||||
null,
|
null,
|
||||||
5,
|
5,
|
||||||
required: false,
|
required: false,
|
||||||
errorKey: 'photos',
|
errorKey: 'photos',
|
||||||
|
s3Keys: $validated['s3_keys'] ?? null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -543,6 +544,7 @@ private function syncPhotos(Purchase $purchase, array $validated): void
|
|||||||
self::MAX_PHOTOS,
|
self::MAX_PHOTOS,
|
||||||
required: false,
|
required: false,
|
||||||
errorKey: 'photos',
|
errorKey: 'photos',
|
||||||
|
s3Keys: $validated['s3_keys'] ?? null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -593,7 +595,7 @@ private function notifyForPendingRequest(User $user, string $typeLabel, string $
|
|||||||
$this->pushNotificationService->sendToRoles(
|
$this->pushNotificationService->sendToRoles(
|
||||||
"📦 {$typeLabel} Menunggu Persetujuan Owner",
|
"📦 {$typeLabel} Menunggu Persetujuan Owner",
|
||||||
$body,
|
$body,
|
||||||
['owner', 'developer'],
|
['owner', 'developer', 'direktur'],
|
||||||
$ownerUrl,
|
$ownerUrl,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -755,6 +757,7 @@ private function syncRequestPhotos(OwnerVerificationRequest $verificationRequest
|
|||||||
self::MAX_PHOTOS,
|
self::MAX_PHOTOS,
|
||||||
required: false,
|
required: false,
|
||||||
errorKey: 'photos',
|
errorKey: 'photos',
|
||||||
|
s3Keys: $validated['s3_keys'] ?? null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -85,7 +85,7 @@ public function transfer(int $variantId, int $quantity, User $user, ?string $not
|
|||||||
$this->pushNotificationService->sendToRoles(
|
$this->pushNotificationService->sendToRoles(
|
||||||
'📦 Transfer Stok Ecer Menunggu Persetujuan Owner',
|
'📦 Transfer Stok Ecer Menunggu Persetujuan Owner',
|
||||||
"Pengajuan transfer {$quantity} pcs stok ecer untuk varian '{$variant->name}' menunggu verifikasi owner.",
|
"Pengajuan transfer {$quantity} pcs stok ecer untuk varian '{$variant->name}' menunggu verifikasi owner.",
|
||||||
['owner', 'developer'],
|
['owner', 'developer', 'direktur'],
|
||||||
route('admin.master.products.index', ['search' => $productName]),
|
route('admin.master.products.index', ['search' => $productName]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -126,7 +126,7 @@ public function submitVerification(
|
|||||||
$this->pushNotificationService->sendToRoles(
|
$this->pushNotificationService->sendToRoles(
|
||||||
'📦 Verifikasi Stok Menunggu Persetujuan',
|
'📦 Verifikasi Stok Menunggu Persetujuan',
|
||||||
"Cutting dengan deskripsi '{$description}' telah diajukan verifikasi dan menunggu persetujuan owner.",
|
"Cutting dengan deskripsi '{$description}' telah diajukan verifikasi dan menunggu persetujuan owner.",
|
||||||
['owner', 'developer'],
|
['owner', 'developer', 'direktur'],
|
||||||
route('admin.manage.stocks.index'),
|
route('admin.manage.stocks.index'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -176,7 +176,7 @@ public function approveVerification(
|
|||||||
$this->pushNotificationService->sendToRoles(
|
$this->pushNotificationService->sendToRoles(
|
||||||
'📦 Stok Cutting Diverifikasi',
|
'📦 Stok Cutting Diverifikasi',
|
||||||
"Cutting dengan deskripsi '{$description}' telah disetujui owner dan stok produk telah ditambahkan ke toko.",
|
"Cutting dengan deskripsi '{$description}' telah disetujui owner dan stok produk telah ditambahkan ke toko.",
|
||||||
['owner', 'developer'],
|
['owner', 'developer', 'direktur'],
|
||||||
route('admin.manage.stocks.index'),
|
route('admin.manage.stocks.index'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -218,7 +218,7 @@ public function rejectVerification(
|
|||||||
$this->pushNotificationService->sendToRoles(
|
$this->pushNotificationService->sendToRoles(
|
||||||
'📦 Verifikasi Cutting Ditolak Owner',
|
'📦 Verifikasi Cutting Ditolak Owner',
|
||||||
"Cutting dengan deskripsi '{$description}' ditolak oleh owner dengan alasan: '{$reason}'.",
|
"Cutting dengan deskripsi '{$description}' ditolak oleh owner dengan alasan: '{$reason}'.",
|
||||||
['owner', 'developer'],
|
['owner', 'developer', 'direktur'],
|
||||||
route('admin.manage.stocks.index'),
|
route('admin.manage.stocks.index'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -138,11 +138,13 @@ public function create(array $validated, User $user): void
|
|||||||
|
|
||||||
$this->syncVariantImages($variant, $variantData, $index);
|
$this->syncVariantImages($variant, $variantData, $index);
|
||||||
|
|
||||||
foreach ($variantData['prices'] as $type => $priceValue) {
|
if (! empty($variantData['prices'])) {
|
||||||
$variant->prices()->create([
|
foreach ($variantData['prices'] as $type => $priceValue) {
|
||||||
'type' => $type,
|
$variant->prices()->create([
|
||||||
'price' => $priceValue,
|
'type' => $type,
|
||||||
]);
|
'price' => $priceValue,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -552,7 +554,7 @@ private function notifyForPendingRequest(User $user, string $typeLabel, string $
|
|||||||
$this->pushNotificationService->sendToRoles(
|
$this->pushNotificationService->sendToRoles(
|
||||||
"📦 {$typeLabel} Menunggu Persetujuan Owner",
|
"📦 {$typeLabel} Menunggu Persetujuan Owner",
|
||||||
$body,
|
$body,
|
||||||
['owner', 'developer'],
|
['owner', 'developer', 'direktur'],
|
||||||
$ownerUrl,
|
$ownerUrl,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -689,6 +691,7 @@ private function syncVariantImages(
|
|||||||
self::MAX_VARIANT_IMAGES,
|
self::MAX_VARIANT_IMAGES,
|
||||||
required: true,
|
required: true,
|
||||||
errorKey: "variants.{$index}.images",
|
errorKey: "variants.{$index}.images",
|
||||||
|
s3Keys: $variantData['s3_keys'] ?? null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -708,6 +711,7 @@ private function syncRequestVariantImages(
|
|||||||
self::MAX_VARIANT_IMAGES,
|
self::MAX_VARIANT_IMAGES,
|
||||||
required: $required,
|
required: $required,
|
||||||
errorKey: "variants.{$index}.images",
|
errorKey: "variants.{$index}.images",
|
||||||
|
s3Keys: $variantData['s3_keys'] ?? null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -363,7 +363,7 @@ private function notifyForPendingRequest(User $user, string $typeLabel, string $
|
|||||||
$this->pushNotificationService->sendToRoles(
|
$this->pushNotificationService->sendToRoles(
|
||||||
"📦 {$typeLabel} Menunggu Persetujuan Owner",
|
"📦 {$typeLabel} Menunggu Persetujuan Owner",
|
||||||
$body,
|
$body,
|
||||||
['owner', 'developer'],
|
['owner', 'developer', 'direktur'],
|
||||||
$ownerUrl,
|
$ownerUrl,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -644,6 +644,7 @@ private function syncPriceImages(RawMaterialPrice $price, array $priceData, int
|
|||||||
self::MAX_VARIANT_IMAGES,
|
self::MAX_VARIANT_IMAGES,
|
||||||
required: true,
|
required: true,
|
||||||
errorKey: "prices.{$index}.images",
|
errorKey: "prices.{$index}.images",
|
||||||
|
s3Keys: $priceData['s3_keys'] ?? null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -663,6 +664,7 @@ private function syncRequestPriceImages(
|
|||||||
self::MAX_VARIANT_IMAGES,
|
self::MAX_VARIANT_IMAGES,
|
||||||
required: $required,
|
required: $required,
|
||||||
errorKey: "prices.{$index}.images",
|
errorKey: "prices.{$index}.images",
|
||||||
|
s3Keys: $priceData['s3_keys'] ?? null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Spatie\MediaLibrary\HasMedia;
|
use Spatie\MediaLibrary\HasMedia;
|
||||||
|
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||||
|
|
||||||
class MediaService
|
class MediaService
|
||||||
@ -21,10 +22,10 @@ public function syncCollection(
|
|||||||
?string $type = null,
|
?string $type = null,
|
||||||
bool $required = false,
|
bool $required = false,
|
||||||
?string $errorKey = null,
|
?string $errorKey = null,
|
||||||
|
?array $s3Keys = null,
|
||||||
): void {
|
): void {
|
||||||
$newFiles = array_values(array_filter($newFiles ?? []));
|
// Handle removals
|
||||||
|
if ($maxFiles === 1 && (! empty($newFiles) || ! empty($s3Keys))) {
|
||||||
if ($maxFiles === 1 && $newFiles !== []) {
|
|
||||||
$model->clearMediaCollection($collection);
|
$model->clearMediaCollection($collection);
|
||||||
} elseif ($removeIds !== null && $removeIds !== []) {
|
} elseif ($removeIds !== null && $removeIds !== []) {
|
||||||
$model->getMedia($collection)
|
$model->getMedia($collection)
|
||||||
@ -32,20 +33,30 @@ public function syncCollection(
|
|||||||
->each->delete();
|
->each->delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine items to add (S3 keys take priority)
|
||||||
|
$useS3 = ! empty($s3Keys);
|
||||||
|
$items = $useS3
|
||||||
|
? array_values(array_filter($s3Keys))
|
||||||
|
: array_values(array_filter($newFiles ?? []));
|
||||||
|
|
||||||
if ($maxFiles === 1) {
|
if ($maxFiles === 1) {
|
||||||
$newFiles = array_slice($newFiles, 0, 1);
|
$items = array_slice($items, 0, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
$currentCount = $model->getMedia($collection)->count();
|
$currentCount = $model->getMedia($collection)->count();
|
||||||
|
|
||||||
if ($currentCount + count($newFiles) > $maxFiles) {
|
if ($currentCount + count($items) > $maxFiles) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
$errorKey ?? $collection => "Maksimal {$maxFiles} gambar per item.",
|
$errorKey ?? $collection => "Maksimal {$maxFiles} gambar per item.",
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($newFiles as $file) {
|
foreach ($items as $item) {
|
||||||
$this->addUploadedFile($model, $file, $collection, $type);
|
if ($useS3) {
|
||||||
|
$this->registerS3Key($model, $item, $collection, $type);
|
||||||
|
} else {
|
||||||
|
$this->addUploadedFile($model, $item, $collection, $type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($model instanceof Model) {
|
if ($model instanceof Model) {
|
||||||
@ -70,6 +81,24 @@ public function addUploadedFile(
|
|||||||
->toMediaCollection($collection);
|
->toMediaCollection($collection);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param HasMedia&InteractsWithMedia $model
|
||||||
|
*/
|
||||||
|
public function registerS3Key(
|
||||||
|
HasMedia $model,
|
||||||
|
string $s3Key,
|
||||||
|
string $collection,
|
||||||
|
?string $type = null,
|
||||||
|
): Media {
|
||||||
|
try {
|
||||||
|
return $model->addMediaFromDisk($s3Key, 's3')
|
||||||
|
->withCustomProperties($this->customProperties($model, $type ?? $collection))
|
||||||
|
->toMediaCollection($collection);
|
||||||
|
} finally {
|
||||||
|
Storage::disk('s3')->delete($s3Key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function addBase64Image(
|
public function addBase64Image(
|
||||||
HasMedia $model,
|
HasMedia $model,
|
||||||
string $base64Photo,
|
string $base64Photo,
|
||||||
@ -114,6 +143,17 @@ public function replaceSingleFile(
|
|||||||
return $this->addUploadedFile($model, $file, $collection, $type);
|
return $this->addUploadedFile($model, $file, $collection, $type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function replaceSingleS3Key(
|
||||||
|
HasMedia $model,
|
||||||
|
string $s3Key,
|
||||||
|
string $collection,
|
||||||
|
?string $type = null,
|
||||||
|
): Media {
|
||||||
|
$model->clearMediaCollection($collection);
|
||||||
|
|
||||||
|
return $this->registerS3Key($model, $s3Key, $collection, $type);
|
||||||
|
}
|
||||||
|
|
||||||
private function customProperties(HasMedia $model, ?string $type): array
|
private function customProperties(HasMedia $model, ?string $type): array
|
||||||
{
|
{
|
||||||
$properties = [
|
$properties = [
|
||||||
|
|||||||
@ -243,7 +243,7 @@ public function getRevenueSummary(?Carbon $startDate = null, ?Carbon $endDate =
|
|||||||
'total_discount' => (int) ($revenueSummary->total_discount ?? 0),
|
'total_discount' => (int) ($revenueSummary->total_discount ?? 0),
|
||||||
'total_marketplace_fees' => $totalMarketplaceFees,
|
'total_marketplace_fees' => $totalMarketplaceFees,
|
||||||
'total_cost_price' => (int) ($totalCostPrice ?? 0),
|
'total_cost_price' => (int) ($totalCostPrice ?? 0),
|
||||||
'total_deduction' => (int) ($revenueSummary->total_discount ?? 0) + $totalMarketplaceFees + (int) ($totalCostPrice ?? 0),
|
'total_deduction' => (int) ($revenueSummary->total_discount ?? 0) + $totalMarketplaceFees,
|
||||||
'total_orders' => (int) ($revenueSummary->total_orders ?? 0),
|
'total_orders' => (int) ($revenueSummary->total_orders ?? 0),
|
||||||
'avg_order' => (int) ($revenueSummary->avg_order ?? 0),
|
'avg_order' => (int) ($revenueSummary->avg_order ?? 0),
|
||||||
];
|
];
|
||||||
@ -318,14 +318,14 @@ public function getMonthlyRevenue(?Carbon $startDate = null, ?Carbon $endDate =
|
|||||||
|
|
||||||
$revenue = $monthlyData->firstWhere('month_key', $key);
|
$revenue = $monthlyData->firstWhere('month_key', $key);
|
||||||
$fees = $monthlyFees->get($key, 0);
|
$fees = $monthlyFees->get($key, 0);
|
||||||
$hppVal = (int) $monthlyHpp->get($key, 0);
|
|
||||||
$discount = (int) ($revenue->total_discount ?? 0);
|
$discount = (int) ($revenue->total_discount ?? 0);
|
||||||
$deduction = $discount + $fees + $hppVal;
|
$hpp = (int) ($monthlyHpp->get($key, 0));
|
||||||
|
$deduction = $discount + $fees;
|
||||||
|
|
||||||
$result[] = [
|
$result[] = [
|
||||||
'month' => $monthLabel,
|
'month' => $monthLabel,
|
||||||
'total' => (int) ($revenue->total_revenue ?? 0),
|
'total' => (int) ($revenue->total_revenue ?? 0),
|
||||||
'net' => (int) ($revenue->total_revenue ?? 0) - $deduction,
|
'net' => (int) ($revenue->total_revenue ?? 0) - $deduction - $hpp,
|
||||||
'deduction' => $deduction,
|
'deduction' => $deduction,
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -361,7 +361,7 @@ public function getExpenseSummary(?Carbon $startDate = null, ?Carbon $endDate =
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
$purchaseTotal = (int) ($purchase->total ?? 0);
|
$purchaseTotal = (int) ($purchase->total ?? 0);
|
||||||
if ($user?->hasRole(Role::ADMIN_TOKO->value)) {
|
if (! $user?->hasAnyRole([Role::DEVELOPER->value, Role::OWNER->value])) {
|
||||||
$purchaseTotal = 0;
|
$purchaseTotal = 0;
|
||||||
}
|
}
|
||||||
$expenseTotal = (int) ($expenses->total ?? 0);
|
$expenseTotal = (int) ($expenses->total ?? 0);
|
||||||
@ -428,7 +428,7 @@ public function getMonthlyExpense(?Carbon $startDate = null, ?Carbon $endDate =
|
|||||||
$monthLabel = $current->locale('id')->translatedFormat('M Y');
|
$monthLabel = $current->locale('id')->translatedFormat('M Y');
|
||||||
|
|
||||||
$purchaseAmount = (int) ($purchases->firstWhere('month_key', $key)->total ?? 0);
|
$purchaseAmount = (int) ($purchases->firstWhere('month_key', $key)->total ?? 0);
|
||||||
if ($user?->hasRole(Role::ADMIN_TOKO->value)) {
|
if (! $user?->hasAnyRole([Role::DEVELOPER->value, Role::OWNER->value])) {
|
||||||
$purchaseAmount = 0;
|
$purchaseAmount = 0;
|
||||||
}
|
}
|
||||||
$expenseAmount = (int) ($expenses->firstWhere('month_key', $key)->total ?? 0);
|
$expenseAmount = (int) ($expenses->firstWhere('month_key', $key)->total ?? 0);
|
||||||
@ -518,7 +518,7 @@ public function getProfitMetrics(?Carbon $startDate = null, ?Carbon $endDate = n
|
|||||||
->when(! $isSuper, fn ($q) => $q->where('purchases.created_by_id', $user->id))
|
->when(! $isSuper, fn ($q) => $q->where('purchases.created_by_id', $user->id))
|
||||||
->sum('total');
|
->sum('total');
|
||||||
|
|
||||||
if ($user?->hasRole(Role::ADMIN_TOKO->value)) {
|
if (! $user?->hasAnyRole([Role::DEVELOPER->value, Role::OWNER->value])) {
|
||||||
$purchaseTotal = 0;
|
$purchaseTotal = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -535,9 +535,9 @@ public function getProfitMetrics(?Carbon $startDate = null, ?Carbon $endDate = n
|
|||||||
|
|
||||||
$totalExpenses = (int) $purchaseTotal + (int) $expenseTotal + (int) $advanceTotal;
|
$totalExpenses = (int) $purchaseTotal + (int) $expenseTotal + (int) $advanceTotal;
|
||||||
|
|
||||||
$labaKotor = $totalRevenue - $totalHpp - $totalDiscount - $marketplaceFees;
|
$grossProfit = $totalRevenue - $totalHpp - $totalDiscount - $marketplaceFees;
|
||||||
$labaBersih = $labaKotor - $totalExpenses;
|
$netProfit = $grossProfit - (int) $expenseTotal - (int) $advanceTotal;
|
||||||
$profitMargin = $totalRevenue > 0 ? round(($labaBersih / $totalRevenue) * 100, 1) : 0;
|
$profitMargin = $totalRevenue > 0 ? round(($netProfit / $totalRevenue) * 100, 1) : 0;
|
||||||
$aov = $totalOrders > 0 ? (int) round($totalRevenue / $totalOrders) : 0;
|
$aov = $totalOrders > 0 ? (int) round($totalRevenue / $totalOrders) : 0;
|
||||||
$itemsPerTransaction = $totalOrders > 0 ? round($totalItems / $totalOrders, 1) : 0;
|
$itemsPerTransaction = $totalOrders > 0 ? round($totalItems / $totalOrders, 1) : 0;
|
||||||
|
|
||||||
@ -545,8 +545,8 @@ public function getProfitMetrics(?Carbon $startDate = null, ?Carbon $endDate = n
|
|||||||
'total_orders' => $totalOrders,
|
'total_orders' => $totalOrders,
|
||||||
'total_products_sold' => $totalQty,
|
'total_products_sold' => $totalQty,
|
||||||
'hpp' => $totalHpp,
|
'hpp' => $totalHpp,
|
||||||
'laba_kotor' => $labaKotor,
|
'gross_profit' => $grossProfit,
|
||||||
'laba_bersih' => $labaBersih,
|
'net_profit' => $netProfit,
|
||||||
'profit_margin' => $profitMargin,
|
'profit_margin' => $profitMargin,
|
||||||
'aov' => $aov,
|
'aov' => $aov,
|
||||||
'items_per_transaction' => $itemsPerTransaction,
|
'items_per_transaction' => $itemsPerTransaction,
|
||||||
@ -672,6 +672,37 @@ public function getTopProducts(?Carbon $startDate = null, ?Carbon $endDate = nul
|
|||||||
->toArray();
|
->toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getMarketingSales(?Carbon $startDate = null, ?Carbon $endDate = null): array
|
||||||
|
{
|
||||||
|
return Order::query()
|
||||||
|
->completed()
|
||||||
|
->whereNotNull('marketing_id')
|
||||||
|
->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate]))
|
||||||
|
->join('users', 'orders.marketing_id', '=', 'users.id')
|
||||||
|
->leftJoin('user_profiles', 'users.id', '=', 'user_profiles.user_id')
|
||||||
|
->selectRaw('
|
||||||
|
users.id as marketing_id,
|
||||||
|
COALESCE(user_profiles.full_name, users.username) as marketing_name,
|
||||||
|
COUNT(*) as total_orders,
|
||||||
|
SUM(orders.total_amount) as total_revenue,
|
||||||
|
SUM(orders.subtotal) as total_subtotal,
|
||||||
|
SUM(orders.discount) as total_discount,
|
||||||
|
AVG(orders.total_amount) as avg_order
|
||||||
|
')
|
||||||
|
->groupBy('users.id', 'user_profiles.full_name', 'users.username')
|
||||||
|
->orderByDesc('total_revenue')
|
||||||
|
->get()
|
||||||
|
->map(fn ($item) => [
|
||||||
|
'marketing_name' => $item->marketing_name,
|
||||||
|
'total_orders' => (int) $item->total_orders,
|
||||||
|
'total_revenue' => (int) $item->total_revenue,
|
||||||
|
'total_subtotal' => (int) $item->total_subtotal,
|
||||||
|
'total_discount' => (int) $item->total_discount,
|
||||||
|
'avg_order' => (int) $item->avg_order,
|
||||||
|
])
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
public function isManager(?User $user): bool
|
public function isManager(?User $user): bool
|
||||||
{
|
{
|
||||||
return $user?->hasAnyRole([Role::OWNER->value, Role::DEVELOPER->value, Role::DIREKTUR->value]) ?? false;
|
return $user?->hasAnyRole([Role::OWNER->value, Role::DEVELOPER->value, Role::DIREKTUR->value]) ?? false;
|
||||||
|
|||||||
@ -33,22 +33,27 @@ public function updateHomepage(array $validated): void
|
|||||||
{
|
{
|
||||||
$configuration = HomepageConfiguration::instance();
|
$configuration = HomepageConfiguration::instance();
|
||||||
|
|
||||||
if (isset($validated['hero_image']) && $validated['hero_image'] instanceof UploadedFile) {
|
if (isset($validated['hero_image_s3_key'])) {
|
||||||
|
$this->mediaService->replaceSingleS3Key($configuration, $validated['hero_image_s3_key'], 'hero_image', 'hero-image');
|
||||||
|
} elseif (isset($validated['hero_image']) && $validated['hero_image'] instanceof UploadedFile) {
|
||||||
$this->mediaService->replaceSingleFile($configuration, $validated['hero_image'], 'hero_image', 'hero-image');
|
$this->mediaService->replaceSingleFile($configuration, $validated['hero_image'], 'hero_image', 'hero-image');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($validated['about_image']) && $validated['about_image'] instanceof UploadedFile) {
|
if (isset($validated['about_image_s3_key'])) {
|
||||||
|
$this->mediaService->replaceSingleS3Key($configuration, $validated['about_image_s3_key'], 'about_image', 'about-image');
|
||||||
|
} elseif (isset($validated['about_image']) && $validated['about_image'] instanceof UploadedFile) {
|
||||||
$this->mediaService->replaceSingleFile($configuration, $validated['about_image'], 'about_image', 'about-image');
|
$this->mediaService->replaceSingleFile($configuration, $validated['about_image'], 'about_image', 'about-image');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($validated['gallery_images']) || isset($validated['gallery_images_remove'])) {
|
if (isset($validated['gallery_s3_keys']) || isset($validated['gallery_images_remove'])) {
|
||||||
$this->mediaService->syncCollection(
|
$this->mediaService->syncCollection(
|
||||||
$configuration,
|
$configuration,
|
||||||
'gallery',
|
'gallery',
|
||||||
$validated['gallery_images'] ?? [],
|
null,
|
||||||
$validated['gallery_images_remove'] ?? [],
|
$validated['gallery_images_remove'] ?? null,
|
||||||
10,
|
10,
|
||||||
'gallery',
|
'gallery',
|
||||||
|
s3Keys: $validated['gallery_s3_keys'] ?? null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -118,7 +118,7 @@ public function updateMarketplace(array $validated, User $user): void
|
|||||||
$this->pushNotificationService->sendToRoles(
|
$this->pushNotificationService->sendToRoles(
|
||||||
'⚙️ Pengaturan Marketplace Menunggu Persetujuan Owner',
|
'⚙️ Pengaturan Marketplace Menunggu Persetujuan Owner',
|
||||||
"Pengajuan ubah pengaturan marketplace oleh '{$user->username}' menunggu verifikasi owner.",
|
"Pengajuan ubah pengaturan marketplace oleh '{$user->username}' menunggu verifikasi owner.",
|
||||||
['owner', 'developer'],
|
['owner', 'developer', 'direktur'],
|
||||||
route('admin.system.settings.index'),
|
route('admin.system.settings.index'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -49,15 +49,21 @@ public function updateSystem(array $validated): void
|
|||||||
$settings->address = $validated['address'] ?? null;
|
$settings->address = $validated['address'] ?? null;
|
||||||
$settings->save();
|
$settings->save();
|
||||||
|
|
||||||
if (isset($validated['logo']) && $validated['logo'] instanceof UploadedFile) {
|
if (isset($validated['logo_s3_key'])) {
|
||||||
|
$this->mediaService->replaceSingleS3Key($configuration, $validated['logo_s3_key'], 'logo', 'logo');
|
||||||
|
} elseif (isset($validated['logo']) && $validated['logo'] instanceof UploadedFile) {
|
||||||
$this->mediaService->replaceSingleFile($configuration, $validated['logo'], 'logo', 'logo');
|
$this->mediaService->replaceSingleFile($configuration, $validated['logo'], 'logo', 'logo');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($validated['favicon']) && $validated['favicon'] instanceof UploadedFile) {
|
if (isset($validated['favicon_s3_key'])) {
|
||||||
|
$this->mediaService->replaceSingleS3Key($configuration, $validated['favicon_s3_key'], 'favicon', 'favicon');
|
||||||
|
} elseif (isset($validated['favicon']) && $validated['favicon'] instanceof UploadedFile) {
|
||||||
$this->mediaService->replaceSingleFile($configuration, $validated['favicon'], 'favicon', 'favicon');
|
$this->mediaService->replaceSingleFile($configuration, $validated['favicon'], 'favicon', 'favicon');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($validated['login_cover']) && $validated['login_cover'] instanceof UploadedFile) {
|
if (isset($validated['login_cover_s3_key'])) {
|
||||||
|
$this->mediaService->replaceSingleS3Key($configuration, $validated['login_cover_s3_key'], 'login_cover', 'login-cover');
|
||||||
|
} elseif (isset($validated['login_cover']) && $validated['login_cover'] instanceof UploadedFile) {
|
||||||
$this->mediaService->replaceSingleFile($configuration, $validated['login_cover'], 'login_cover', 'login-cover');
|
$this->mediaService->replaceSingleFile($configuration, $validated['login_cover'], 'login_cover', 'login-cover');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Support\Media;
|
namespace App\Support\Media;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Spatie\MediaLibrary\HasMedia;
|
use Spatie\MediaLibrary\HasMedia;
|
||||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||||
|
|
||||||
@ -35,10 +36,27 @@ public static function item(Media $media): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'id' => $media->id,
|
'id' => $media->id,
|
||||||
'url' => $media->getUrl(),
|
'url' => self::temporaryUrl($media),
|
||||||
'thumb_url' => $media->hasGeneratedConversion('thumb')
|
'thumb_url' => $media->hasGeneratedConversion('thumb')
|
||||||
? $media->getUrl('thumb')
|
? self::temporaryUrl($media, 'thumb')
|
||||||
: $media->getUrl(),
|
: self::temporaryUrl($media),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function temporaryUrl(Media $media, ?string $conversion = null): string
|
||||||
|
{
|
||||||
|
$disk = $media->disk;
|
||||||
|
|
||||||
|
if ($disk === 's3') {
|
||||||
|
$path = $conversion
|
||||||
|
? $media->getPath($conversion)
|
||||||
|
: $media->getPath();
|
||||||
|
|
||||||
|
return Storage::disk($disk)->temporaryUrl($path, now()->addMinutes(30));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $conversion
|
||||||
|
? $media->getUrl($conversion)
|
||||||
|
: $media->getUrl();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,8 @@
|
|||||||
"laravel/framework": "^13.7",
|
"laravel/framework": "^13.7",
|
||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^3.0",
|
||||||
"laravel/wayfinder": "^0.1.14",
|
"laravel/wayfinder": "^0.1.14",
|
||||||
|
"league/flysystem-aws-s3-v3": "^3.0",
|
||||||
|
"league/flysystem-ftp": "^3.0",
|
||||||
"minishlink/web-push": "^9.0",
|
"minishlink/web-push": "^9.0",
|
||||||
"spatie/laravel-activitylog": "^5.0",
|
"spatie/laravel-activitylog": "^5.0",
|
||||||
"spatie/laravel-medialibrary": "^11.23",
|
"spatie/laravel-medialibrary": "^11.23",
|
||||||
|
|||||||
395
composer.lock
generated
395
composer.lock
generated
@ -4,8 +4,159 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "396b0f4aff13715436b182ab532948db",
|
"content-hash": "ea578c35bcf9ab3ac130534272e48937",
|
||||||
"packages": [
|
"packages": [
|
||||||
|
{
|
||||||
|
"name": "aws/aws-crt-php",
|
||||||
|
"version": "v1.2.7",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/awslabs/aws-crt-php.git",
|
||||||
|
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e",
|
||||||
|
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=5.5"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^4.8.35||^5.6.3||^9.5",
|
||||||
|
"yoast/phpunit-polyfills": "^1.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality."
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"classmap": [
|
||||||
|
"src/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"Apache-2.0"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "AWS SDK Common Runtime Team",
|
||||||
|
"email": "aws-sdk-common-runtime@amazon.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "AWS Common Runtime for PHP",
|
||||||
|
"homepage": "https://github.com/awslabs/aws-crt-php",
|
||||||
|
"keywords": [
|
||||||
|
"amazon",
|
||||||
|
"aws",
|
||||||
|
"crt",
|
||||||
|
"sdk"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/awslabs/aws-crt-php/issues",
|
||||||
|
"source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7"
|
||||||
|
},
|
||||||
|
"time": "2024-10-18T22:15:13+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "aws/aws-sdk-php",
|
||||||
|
"version": "3.387.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/aws/aws-sdk-php.git",
|
||||||
|
"reference": "38422b3eaed583a6056cef6d40e5594cd90cc895"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/38422b3eaed583a6056cef6d40e5594cd90cc895",
|
||||||
|
"reference": "38422b3eaed583a6056cef6d40e5594cd90cc895",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"aws/aws-crt-php": "^1.2.3",
|
||||||
|
"ext-json": "*",
|
||||||
|
"ext-pcre": "*",
|
||||||
|
"ext-simplexml": "*",
|
||||||
|
"guzzlehttp/guzzle": "^7.4.5",
|
||||||
|
"guzzlehttp/promises": "^2.0",
|
||||||
|
"guzzlehttp/psr7": "^2.4.5",
|
||||||
|
"mtdowling/jmespath.php": "^2.9.1",
|
||||||
|
"php": ">=8.1",
|
||||||
|
"psr/http-message": "^1.0 || ^2.0",
|
||||||
|
"symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"andrewsville/php-token-reflection": "^1.4",
|
||||||
|
"aws/aws-php-sns-message-validator": "~1.0",
|
||||||
|
"behat/behat": "~3.0",
|
||||||
|
"composer/composer": "^2.7.8",
|
||||||
|
"dms/phpunit-arraysubset-asserts": "^v0.5.0",
|
||||||
|
"doctrine/cache": "~1.4",
|
||||||
|
"ext-dom": "*",
|
||||||
|
"ext-openssl": "*",
|
||||||
|
"ext-sockets": "*",
|
||||||
|
"phpunit/phpunit": "^10.0",
|
||||||
|
"psr/cache": "^2.0 || ^3.0",
|
||||||
|
"psr/simple-cache": "^2.0 || ^3.0",
|
||||||
|
"sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0",
|
||||||
|
"yoast/phpunit-polyfills": "^2.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"aws/aws-php-sns-message-validator": "To validate incoming SNS notifications",
|
||||||
|
"doctrine/cache": "To use the DoctrineCacheAdapter",
|
||||||
|
"ext-curl": "To send requests using cURL",
|
||||||
|
"ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages",
|
||||||
|
"ext-pcntl": "To use client-side monitoring",
|
||||||
|
"ext-sockets": "To use client-side monitoring"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "3.0-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"src/functions.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Aws\\": "src/"
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"src/data/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"Apache-2.0"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Amazon Web Services",
|
||||||
|
"homepage": "https://aws.amazon.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project",
|
||||||
|
"homepage": "https://aws.amazon.com/sdk-for-php",
|
||||||
|
"keywords": [
|
||||||
|
"amazon",
|
||||||
|
"aws",
|
||||||
|
"cloud",
|
||||||
|
"dynamodb",
|
||||||
|
"ec2",
|
||||||
|
"glacier",
|
||||||
|
"s3",
|
||||||
|
"sdk"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"forum": "https://github.com/aws/aws-sdk-php/discussions",
|
||||||
|
"issues": "https://github.com/aws/aws-sdk-php/issues",
|
||||||
|
"source": "https://github.com/aws/aws-sdk-php/tree/3.387.0"
|
||||||
|
},
|
||||||
|
"time": "2026-06-30T18:31:55+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
"version": "0.14.8",
|
"version": "0.14.8",
|
||||||
@ -2004,6 +2155,111 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-05-14T10:28:08+00:00"
|
"time": "2026-05-14T10:28:08+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "league/flysystem-aws-s3-v3",
|
||||||
|
"version": "3.35.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
|
||||||
|
"reference": "3f93d3f4bd5c12a5dfb34a7267c40b1bc4587b94"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/3f93d3f4bd5c12a5dfb34a7267c40b1bc4587b94",
|
||||||
|
"reference": "3f93d3f4bd5c12a5dfb34a7267c40b1bc4587b94",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"aws/aws-sdk-php": "^3.371.5",
|
||||||
|
"league/flysystem": "^3.10.0",
|
||||||
|
"league/mime-type-detection": "^1.0.0",
|
||||||
|
"php": "^8.0.2"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"guzzlehttp/guzzle": "<7.0",
|
||||||
|
"guzzlehttp/ringphp": "<1.1.1"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"League\\Flysystem\\AwsS3V3\\": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Frank de Jonge",
|
||||||
|
"email": "info@frankdejonge.nl"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "AWS S3 filesystem adapter for Flysystem.",
|
||||||
|
"keywords": [
|
||||||
|
"Flysystem",
|
||||||
|
"aws",
|
||||||
|
"file",
|
||||||
|
"files",
|
||||||
|
"filesystem",
|
||||||
|
"s3",
|
||||||
|
"storage"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.35.1"
|
||||||
|
},
|
||||||
|
"time": "2026-06-25T06:51:08+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "league/flysystem-ftp",
|
||||||
|
"version": "3.31.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/thephpleague/flysystem-ftp.git",
|
||||||
|
"reference": "cd6ab064a695bc340e3090c360021d26cd417e67"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/thephpleague/flysystem-ftp/zipball/cd6ab064a695bc340e3090c360021d26cd417e67",
|
||||||
|
"reference": "cd6ab064a695bc340e3090c360021d26cd417e67",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-ftp": "*",
|
||||||
|
"league/flysystem": "^3.0.0",
|
||||||
|
"league/mime-type-detection": "^1.0.0",
|
||||||
|
"php": "^8.0.2"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"League\\Flysystem\\Ftp\\": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Frank de Jonge",
|
||||||
|
"email": "info@frankdejonge.nl"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "FTP filesystem adapter for Flysystem.",
|
||||||
|
"keywords": [
|
||||||
|
"Flysystem",
|
||||||
|
"file",
|
||||||
|
"files",
|
||||||
|
"filesystem",
|
||||||
|
"ftp",
|
||||||
|
"ftpd"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/thephpleague/flysystem-ftp/tree/3.31.0"
|
||||||
|
},
|
||||||
|
"time": "2026-01-23T15:30:45+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "league/flysystem-local",
|
"name": "league/flysystem-local",
|
||||||
"version": "3.31.0",
|
"version": "3.31.0",
|
||||||
@ -2539,6 +2795,72 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-01-02T08:56:05+00:00"
|
"time": "2026-01-02T08:56:05+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "mtdowling/jmespath.php",
|
||||||
|
"version": "2.9.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/jmespath/jmespath.php.git",
|
||||||
|
"reference": "9c208ba27ae7d90853c288b3795d6702eb251d34"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/9c208ba27ae7d90853c288b3795d6702eb251d34",
|
||||||
|
"reference": "9c208ba27ae7d90853c288b3795d6702eb251d34",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.2.5 || ^8.0",
|
||||||
|
"symfony/polyfill-mbstring": "^1.17"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"composer/xdebug-handler": "^3.0.3",
|
||||||
|
"phpunit/phpunit": "^8.5.52"
|
||||||
|
},
|
||||||
|
"bin": [
|
||||||
|
"bin/jp.php"
|
||||||
|
],
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "2.9-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"src/JmesPath.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"JmesPath\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Graham Campbell",
|
||||||
|
"email": "hello@gjcampbell.co.uk",
|
||||||
|
"homepage": "https://github.com/GrahamCampbell"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Michael Dowling",
|
||||||
|
"email": "mtdowling@gmail.com",
|
||||||
|
"homepage": "https://github.com/mtdowling"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Declaratively specify how to extract elements from a JSON document",
|
||||||
|
"keywords": [
|
||||||
|
"json",
|
||||||
|
"jsonpath"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/jmespath/jmespath.php/issues",
|
||||||
|
"source": "https://github.com/jmespath/jmespath.php/tree/2.9.1"
|
||||||
|
},
|
||||||
|
"time": "2026-06-11T10:43:56+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "nesbot/carbon",
|
"name": "nesbot/carbon",
|
||||||
"version": "3.11.4",
|
"version": "3.11.4",
|
||||||
@ -5311,6 +5633,77 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-01-05T13:30:16+00:00"
|
"time": "2026-01-05T13:30:16+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/filesystem",
|
||||||
|
"version": "v8.1.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/filesystem.git",
|
||||||
|
"reference": "99aec13b82b4967ec5088222c4a3ecca955949c2"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/filesystem/zipball/99aec13b82b4967ec5088222c4a3ecca955949c2",
|
||||||
|
"reference": "99aec13b82b4967ec5088222c4a3ecca955949c2",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.4.1",
|
||||||
|
"symfony/deprecation-contracts": "^2.5|^3",
|
||||||
|
"symfony/polyfill-ctype": "~1.8",
|
||||||
|
"symfony/polyfill-mbstring": "~1.8"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"symfony/process": "^7.4|^8.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Component\\Filesystem\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Tests/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Fabien Potencier",
|
||||||
|
"email": "fabien@symfony.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Provides basic utilities for the filesystem",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/filesystem/tree/v8.1.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-05-29T05:06:50+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/finder",
|
"name": "symfony/finder",
|
||||||
"version": "v8.1.0",
|
"version": "v8.1.0",
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import DropZone from 'dropzone-vue';
|
||||||
import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.vue';
|
import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.vue';
|
||||||
import {
|
import {
|
||||||
Field,
|
Field,
|
||||||
@ -6,12 +7,77 @@ import {
|
|||||||
FieldError,
|
FieldError,
|
||||||
FieldLabel,
|
FieldLabel,
|
||||||
} from '@/components/ui/field';
|
} from '@/components/ui/field';
|
||||||
|
import { uploadFileAndGetKey } from '@/lib/s3-upload';
|
||||||
import type { MediaUploadState } from '@/types/media';
|
import type { MediaUploadState } from '@/types/media';
|
||||||
import { createMediaUploadState } from '@/types/media';
|
import { createMediaUploadState } from '@/types/media';
|
||||||
import DropZone from 'dropzone-vue';
|
|
||||||
import 'dropzone-vue/dist/dropzone-vue.common.css';
|
import 'dropzone-vue/dist/dropzone-vue.common.css';
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
// ─── Client-side image compression ───────────────────────────────────────────
|
||||||
|
const COMPRESS_MAX_PX = 1200; // max width/height in pixels
|
||||||
|
const COMPRESS_QUALITY = 0.80; // JPEG quality (0–1)
|
||||||
|
|
||||||
|
async function compressImage(file: File): Promise<File> {
|
||||||
|
// Only compress raster images; skip SVG, GIF, etc.
|
||||||
|
if (!file.type.startsWith('image/') || file.type === 'image/svg+xml' || file.type === 'image/gif') {
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const img = new Image();
|
||||||
|
const originalUrl = URL.createObjectURL(file);
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
URL.revokeObjectURL(originalUrl);
|
||||||
|
|
||||||
|
let { width, height } = img;
|
||||||
|
|
||||||
|
// Scale down proportionally if image exceeds max dimension
|
||||||
|
if (width > COMPRESS_MAX_PX || height > COMPRESS_MAX_PX) {
|
||||||
|
if (width >= height) {
|
||||||
|
height = Math.round((height / width) * COMPRESS_MAX_PX);
|
||||||
|
width = COMPRESS_MAX_PX;
|
||||||
|
} else {
|
||||||
|
width = Math.round((width / height) * COMPRESS_MAX_PX);
|
||||||
|
height = COMPRESS_MAX_PX;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
|
||||||
|
// Output as JPEG for photos regardless of original format (except PNG transparency)
|
||||||
|
const outputMime = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
|
||||||
|
|
||||||
|
canvas.toBlob(
|
||||||
|
(blob) => {
|
||||||
|
if (!blob || blob.size >= file.size) {
|
||||||
|
// Compression made it bigger or failed — keep original
|
||||||
|
resolve(file);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(new File([blob], file.name, { type: outputMime, lastModified: Date.now() }));
|
||||||
|
},
|
||||||
|
outputMime,
|
||||||
|
COMPRESS_QUALITY,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
URL.revokeObjectURL(originalUrl);
|
||||||
|
resolve(file); // fallback to original
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = originalUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
id: string;
|
id: string;
|
||||||
@ -36,6 +102,10 @@ const state = defineModel<MediaUploadState>({
|
|||||||
default: () => createMediaUploadState(),
|
default: () => createMediaUploadState(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Upload progress tracking ────────────────────────────────────────────────
|
||||||
|
const uploadProgress = ref<Record<string, number>>({});
|
||||||
|
const uploadErrors = ref<Record<string, string>>({});
|
||||||
|
|
||||||
// ─── Dialog ──────────────────────────────────────────────────────────────────
|
// ─── Dialog ──────────────────────────────────────────────────────────────────
|
||||||
const previewOpen = ref(false);
|
const previewOpen = ref(false);
|
||||||
const previewUrl = ref<string | null>(null);
|
const previewUrl = ref<string | null>(null);
|
||||||
@ -78,13 +148,52 @@ function onAddedFile(item: { id: string; file: File }) {
|
|||||||
filePreviews.value.forEach((p) => URL.revokeObjectURL(p.objectUrl));
|
filePreviews.value.forEach((p) => URL.revokeObjectURL(p.objectUrl));
|
||||||
filePreviews.value = [];
|
filePreviews.value = [];
|
||||||
state.value.newFiles = [];
|
state.value.newFiles = [];
|
||||||
|
state.value.newFileS3Keys = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
state.value.newFiles.push(item.file);
|
// Compress image asynchronously, then upload to S3
|
||||||
filePreviews.value.push({
|
compressImage(item.file).then((compressed) => {
|
||||||
id: item.id,
|
const fileIndex = state.value.newFiles.length;
|
||||||
file: item.file,
|
|
||||||
objectUrl: URL.createObjectURL(item.file),
|
state.value.newFiles.push(compressed);
|
||||||
|
filePreviews.value.push({
|
||||||
|
id: item.id,
|
||||||
|
file: compressed,
|
||||||
|
objectUrl: URL.createObjectURL(compressed),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upload to S3 in background
|
||||||
|
uploadProgress.value[item.id] = 0;
|
||||||
|
delete uploadErrors.value[item.id];
|
||||||
|
state.value.pendingUploads++;
|
||||||
|
|
||||||
|
uploadFileAndGetKey(compressed, (percent) => {
|
||||||
|
uploadProgress.value[item.id] = percent;
|
||||||
|
}).then((s3Key) => {
|
||||||
|
state.value.newFileS3Keys[fileIndex] = s3Key;
|
||||||
|
delete uploadProgress.value[item.id];
|
||||||
|
state.value.pendingUploads--;
|
||||||
|
}).catch((error: Error) => {
|
||||||
|
uploadErrors.value[item.id] = error.message;
|
||||||
|
delete uploadProgress.value[item.id];
|
||||||
|
state.value.pendingUploads--;
|
||||||
|
// Remove the file from state on upload failure
|
||||||
|
const idx = state.value.newFiles.indexOf(compressed);
|
||||||
|
|
||||||
|
if (idx !== -1) {
|
||||||
|
state.value.newFiles.splice(idx, 1);
|
||||||
|
state.value.newFileS3Keys.splice(idx, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pi = filePreviews.value.findIndex((p) => p.id === item.id);
|
||||||
|
|
||||||
|
if (pi !== -1) {
|
||||||
|
URL.revokeObjectURL(filePreviews.value[pi].objectUrl);
|
||||||
|
filePreviews.value.splice(pi, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
dropzoneRef.value?.removeFile(item.id);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,6 +202,7 @@ function onRemovedFile(item: { id: string; file: File }) {
|
|||||||
|
|
||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
state.value.newFiles.splice(idx, 1);
|
state.value.newFiles.splice(idx, 1);
|
||||||
|
state.value.newFileS3Keys.splice(idx, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pi = filePreviews.value.findIndex((p) => p.id === item.id);
|
const pi = filePreviews.value.findIndex((p) => p.id === item.id);
|
||||||
@ -101,6 +211,9 @@ function onRemovedFile(item: { id: string; file: File }) {
|
|||||||
URL.revokeObjectURL(filePreviews.value[pi].objectUrl);
|
URL.revokeObjectURL(filePreviews.value[pi].objectUrl);
|
||||||
filePreviews.value.splice(pi, 1);
|
filePreviews.value.splice(pi, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
delete uploadProgress.value[item.id];
|
||||||
|
delete uploadErrors.value[item.id];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Unified normalized entries ───────────────────────────────────────────────
|
// ─── Unified normalized entries ───────────────────────────────────────────────
|
||||||
@ -111,6 +224,8 @@ type NormalizedEntry = {
|
|||||||
previewSrc: string; // src for fullscreen preview
|
previewSrc: string; // src for fullscreen preview
|
||||||
name: string;
|
name: string;
|
||||||
sizeLabel: string;
|
sizeLabel: string;
|
||||||
|
uploadPercent: number | null;
|
||||||
|
uploadError: string | null;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -124,6 +239,8 @@ const allPreviews = computed<NormalizedEntry[]>(() => {
|
|||||||
previewSrc: item.url,
|
previewSrc: item.url,
|
||||||
name: item.url.split('/').pop() ?? `image-${item.id}`,
|
name: item.url.split('/').pop() ?? `image-${item.id}`,
|
||||||
sizeLabel: 'Tersimpan',
|
sizeLabel: 'Tersimpan',
|
||||||
|
uploadPercent: null,
|
||||||
|
uploadError: null,
|
||||||
onRemove: () => {
|
onRemove: () => {
|
||||||
const idx = state.value.existing.findIndex((e) => e.id === item.id);
|
const idx = state.value.existing.findIndex((e) => e.id === item.id);
|
||||||
|
|
||||||
@ -144,6 +261,8 @@ const allPreviews = computed<NormalizedEntry[]>(() => {
|
|||||||
previewSrc: p.objectUrl,
|
previewSrc: p.objectUrl,
|
||||||
name: p.file.name,
|
name: p.file.name,
|
||||||
sizeLabel: formatBytes(p.file.size),
|
sizeLabel: formatBytes(p.file.size),
|
||||||
|
uploadPercent: uploadProgress.value[p.id] ?? null,
|
||||||
|
uploadError: uploadErrors.value[p.id] ?? null,
|
||||||
onRemove: () => dropzoneRef.value?.removeFile(p.id),
|
onRemove: () => dropzoneRef.value?.removeFile(p.id),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -193,49 +312,25 @@ watch(
|
|||||||
|
|
||||||
<div class="dropzone-wrapper">
|
<div class="dropzone-wrapper">
|
||||||
<!-- Dropzone form: hidden when any preview exists to prevent layout corruption -->
|
<!-- Dropzone form: hidden when any preview exists to prevent layout corruption -->
|
||||||
<div
|
<div :id="`dz-container-${id}`" :class="[
|
||||||
:id="`dz-container-${id}`"
|
'dz-form-host',
|
||||||
:class="[
|
{ 'dz-form-hidden': allPreviews.length > 0 },
|
||||||
'dz-form-host',
|
]">
|
||||||
{ 'dz-form-hidden': allPreviews.length > 0 },
|
<DropZone :id="id" ref="dropzoneRef" :max-files="maxFiles" :max-file-size="maxFileSize"
|
||||||
]"
|
:accepted-files="acceptedFiles" :upload-on-drop="false" :clickable="true"
|
||||||
>
|
:hidden-input-container="`#dz-container-${id}`" dropzone-class-name="dz-box"
|
||||||
<DropZone
|
:dropzone-message-class-name="`dz-message-${id}`" @added-file="onAddedFile"
|
||||||
:id="id"
|
@removed-file="onRemovedFile">
|
||||||
ref="dropzoneRef"
|
|
||||||
:max-files="maxFiles"
|
|
||||||
:max-file-size="maxFileSize"
|
|
||||||
:accepted-files="acceptedFiles"
|
|
||||||
:upload-on-drop="false"
|
|
||||||
:clickable="true"
|
|
||||||
:hidden-input-container="`#dz-container-${id}`"
|
|
||||||
dropzone-class-name="dz-box"
|
|
||||||
:dropzone-message-class-name="`dz-message-${id}`"
|
|
||||||
@added-file="onAddedFile"
|
|
||||||
@removed-file="onRemovedFile"
|
|
||||||
>
|
|
||||||
<template #message>
|
<template #message>
|
||||||
<div class="dz-placeholder">
|
<div class="dz-placeholder">
|
||||||
<svg
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"
|
||||||
width="22"
|
stroke-linejoin="round">
|
||||||
height="22"
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.75"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"
|
|
||||||
/>
|
|
||||||
<polyline points="17 8 12 3 7 8" />
|
<polyline points="17 8 12 3 7 8" />
|
||||||
<line x1="12" y1="3" x2="12" y2="15" />
|
<line x1="12" y1="3" x2="12" y2="15" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="dz-placeholder-primary"
|
<span class="dz-placeholder-primary">Klik atau seret file ke sini</span>
|
||||||
>Klik atau seret file ke sini</span
|
|
||||||
>
|
|
||||||
<span class="dz-placeholder-secondary">
|
<span class="dz-placeholder-secondary">
|
||||||
{{ acceptedFiles.join(', ') }} — Maks.
|
{{ acceptedFiles.join(', ') }} — Maks.
|
||||||
{{ maxFiles }} file,
|
{{ maxFiles }} file,
|
||||||
@ -247,29 +342,14 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Overlay: shown when any preview exists, triggers file picker on click -->
|
<!-- Overlay: shown when any preview exists, triggers file picker on click -->
|
||||||
<div
|
<div v-if="allPreviews.length > 0" class="dz-overlay" @click.stop="triggerDropzonePicker($event)">
|
||||||
v-if="allPreviews.length > 0"
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none"
|
||||||
class="dz-overlay"
|
stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||||
@click.stop="triggerDropzonePicker($event)"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="22"
|
|
||||||
height="22"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.75"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
<polyline points="17 8 12 3 7 8" />
|
<polyline points="17 8 12 3 7 8" />
|
||||||
<line x1="12" y1="3" x2="12" y2="15" />
|
<line x1="12" y1="3" x2="12" y2="15" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="dz-placeholder-primary"
|
<span class="dz-placeholder-primary">Klik atau seret file ke sini</span>
|
||||||
>Klik atau seret file ke sini</span
|
|
||||||
>
|
|
||||||
<span class="dz-placeholder-secondary">
|
<span class="dz-placeholder-secondary">
|
||||||
{{ acceptedFiles.join(', ') }} — Maks.
|
{{ acceptedFiles.join(', ') }} — Maks.
|
||||||
{{ maxFiles }} file, {{ formatBytes(maxFileSize) }}/file
|
{{ maxFiles }} file, {{ formatBytes(maxFileSize) }}/file
|
||||||
@ -278,46 +358,39 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Preview grid (existing + new) -->
|
<!-- Preview grid (existing + new) -->
|
||||||
<div
|
<div v-if="allPreviews.length > 0" :class="[
|
||||||
v-if="allPreviews.length > 0"
|
'preview-grid',
|
||||||
:class="[
|
{ 'preview-grid--single': maxFiles === 1 },
|
||||||
'preview-grid',
|
]">
|
||||||
{ 'preview-grid--single': maxFiles === 1 },
|
<div v-for="entry in allPreviews" :key="entry.key" class="preview-card">
|
||||||
]"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="entry in allPreviews"
|
|
||||||
:key="entry.key"
|
|
||||||
class="preview-card"
|
|
||||||
>
|
|
||||||
<!-- Thumbnail -->
|
<!-- Thumbnail -->
|
||||||
<div
|
<div class="preview-thumb" @click="openPreview(entry.previewSrc)">
|
||||||
class="preview-thumb"
|
<img v-if="entry.isImage" :src="entry.thumbSrc" :alt="entry.name" />
|
||||||
@click="openPreview(entry.previewSrc)"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
v-if="entry.isImage"
|
|
||||||
:src="entry.thumbSrc"
|
|
||||||
:alt="entry.name"
|
|
||||||
/>
|
|
||||||
<div v-else class="preview-thumb-icon">
|
<div v-else class="preview-thumb-icon">
|
||||||
<svg
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
|
||||||
width="32"
|
stroke-linejoin="round">
|
||||||
height="32"
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
|
||||||
/>
|
|
||||||
<polyline points="14 2 14 8 20 8" />
|
<polyline points="14 2 14 8 20 8" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Upload progress overlay -->
|
||||||
|
<div v-if="entry.uploadPercent !== null" class="upload-progress-overlay">
|
||||||
|
<div class="upload-progress-bar">
|
||||||
|
<div class="upload-progress-fill" :style="{ width: `${entry.uploadPercent}%` }" />
|
||||||
|
</div>
|
||||||
|
<span class="upload-progress-text">{{ entry.uploadPercent }}%</span>
|
||||||
|
</div>
|
||||||
|
<!-- Upload error overlay -->
|
||||||
|
<div v-if="entry.uploadError" class="upload-error-overlay">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||||
|
stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="15" y1="9" x2="9" y2="15" />
|
||||||
|
<line x1="9" y1="9" x2="15" y2="15" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Info + Remove -->
|
<!-- Info + Remove -->
|
||||||
@ -326,25 +399,14 @@ watch(
|
|||||||
<p class="preview-name" :title="entry.name">
|
<p class="preview-name" :title="entry.name">
|
||||||
{{ entry.name }}
|
{{ entry.name }}
|
||||||
</p>
|
</p>
|
||||||
<p class="preview-size">{{ entry.sizeLabel }}</p>
|
<p class="preview-size">
|
||||||
|
{{ entry.uploadError ? 'Gagal' : entry.sizeLabel }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button type="button" class="preview-remove" title="Hapus" @click="entry.onRemove()">
|
||||||
type="button"
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
|
||||||
class="preview-remove"
|
fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
|
||||||
title="Hapus"
|
stroke-linejoin="round">
|
||||||
@click="entry.onRemove()"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2.5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
</svg>
|
</svg>
|
||||||
@ -355,11 +417,7 @@ watch(
|
|||||||
|
|
||||||
<FieldError :errors="errors" />
|
<FieldError :errors="errors" />
|
||||||
|
|
||||||
<MediaPreviewDialog
|
<MediaPreviewDialog v-model:open="previewOpen" :url="previewUrl" :title="label" />
|
||||||
v-model:open="previewOpen"
|
|
||||||
:url="previewUrl"
|
|
||||||
:title="label"
|
|
||||||
/>
|
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -583,6 +641,7 @@ watch(
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-thumb img {
|
.preview-thumb img {
|
||||||
@ -605,6 +664,51 @@ watch(
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Upload progress overlay */
|
||||||
|
.upload-progress-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: color-mix(in oklch, var(--background) 80%, transparent);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-progress-bar {
|
||||||
|
width: 80%;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--muted);
|
||||||
|
border-radius: 9999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--primary);
|
||||||
|
border-radius: 9999px;
|
||||||
|
transition: width 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-progress-text {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Upload error overlay */
|
||||||
|
.upload-error-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: color-mix(in oklch, var(--destructive) 20%, transparent);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--destructive);
|
||||||
|
}
|
||||||
|
|
||||||
/* Footer row: info + remove button */
|
/* Footer row: info + remove button */
|
||||||
.preview-footer {
|
.preview-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { watch } from 'vue';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -8,10 +9,20 @@ import {
|
|||||||
|
|
||||||
const open = defineModel<boolean>('open', { default: false });
|
const open = defineModel<boolean>('open', { default: false });
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
url: string | null;
|
url: string | null;
|
||||||
title?: string;
|
title?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
watch(open, (val) => {
|
||||||
|
if (!val) {
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@ -1,6 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.vue';
|
import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.vue';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import type { MediaItem } from '@/types/media';
|
import type { MediaItem } from '@/types/media';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@ -10,14 +16,78 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const previewOpen = ref(false);
|
const previewOpen = ref(false);
|
||||||
const previewUrl = ref<string | null>(null);
|
const previewUrl = ref<string | null>(null);
|
||||||
|
const galleryOpen = ref(false);
|
||||||
|
const cameFromGallery = ref(false);
|
||||||
|
|
||||||
const visibleItems = computed(() => props.items.slice(0, props.maxVisible ?? 3));
|
const visibleItems = computed(() => props.items.slice(0, props.maxVisible ?? 3));
|
||||||
const hiddenCount = computed(() => Math.max(props.items.length - visibleItems.value.length, 0));
|
const hiddenCount = computed(() => Math.max(props.items.length - visibleItems.value.length, 0));
|
||||||
|
|
||||||
|
const galleryWidthClass = computed(() => {
|
||||||
|
const count = props.items.length;
|
||||||
|
|
||||||
|
if (count <= 1) {
|
||||||
|
return 'sm:max-w-xs';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count <= 2) {
|
||||||
|
return 'sm:max-w-sm';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count <= 4) {
|
||||||
|
return 'sm:max-w-md';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count <= 8) {
|
||||||
|
return 'sm:max-w-lg';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'sm:max-w-xl';
|
||||||
|
});
|
||||||
|
|
||||||
|
const galleryGridClass = computed(() => {
|
||||||
|
const count = props.items.length;
|
||||||
|
|
||||||
|
if (count === 1) {
|
||||||
|
return 'grid-cols-1';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count === 2) {
|
||||||
|
return 'grid-cols-2';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count === 3) {
|
||||||
|
return 'grid-cols-3';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'grid-cols-4';
|
||||||
|
});
|
||||||
|
|
||||||
function openPreview(url: string) {
|
function openPreview(url: string) {
|
||||||
previewUrl.value = url;
|
previewUrl.value = url;
|
||||||
previewOpen.value = true;
|
previewOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openPreviewFromGallery(url: string) {
|
||||||
|
cameFromGallery.value = true;
|
||||||
|
galleryOpen.value = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
previewUrl.value = url;
|
||||||
|
previewOpen.value = true;
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPreviewClose() {
|
||||||
|
if (cameFromGallery.value) {
|
||||||
|
cameFromGallery.value = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
galleryOpen.value = true;
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGallery() {
|
||||||
|
galleryOpen.value = true;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -26,10 +96,27 @@ function openPreview(url: string) {
|
|||||||
class="size-8 overflow-hidden rounded border bg-muted/30" @click="openPreview(item.url)">
|
class="size-8 overflow-hidden rounded border bg-muted/30" @click="openPreview(item.url)">
|
||||||
<img :src="item.thumb_url" alt="Foto" class="size-full object-cover">
|
<img :src="item.thumb_url" alt="Foto" class="size-full object-cover">
|
||||||
</button>
|
</button>
|
||||||
<span v-if="hiddenCount > 0" class="text-muted-foreground text-xs">
|
<button v-if="hiddenCount > 0" type="button"
|
||||||
|
class="flex size-8 items-center justify-center rounded border bg-muted/30 text-muted-foreground text-xs font-medium hover:bg-muted/50 transition-colors"
|
||||||
|
@click="openGallery">
|
||||||
+{{ hiddenCount }}
|
+{{ hiddenCount }}
|
||||||
</span>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MediaPreviewDialog v-model:open="previewOpen" :url="previewUrl" />
|
<Dialog v-model:open="galleryOpen">
|
||||||
|
<DialogContent :class="galleryWidthClass">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Semua Foto ({{ items.length }})</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="grid gap-2 max-h-[60vh] overflow-y-auto p-1" :class="galleryGridClass">
|
||||||
|
<button v-for="item in items" :key="item.id" type="button"
|
||||||
|
class="aspect-square overflow-hidden rounded border bg-muted/30"
|
||||||
|
@click="openPreviewFromGallery(item.url)">
|
||||||
|
<img :src="item.thumb_url" alt="Foto" class="size-full object-cover">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<MediaPreviewDialog v-model:open="previewOpen" :url="previewUrl" @close="onPreviewClose" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -7,15 +7,26 @@ function getXsrfToken(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function apiFetch<T>(url: string, options: RequestInit = {}): Promise<T> {
|
export async function apiFetch<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const isFormData = options.body instanceof FormData;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'X-XSRF-TOKEN': getXsrfToken(),
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
...(!isFormData && { 'Content-Type': 'application/json' }),
|
||||||
|
...(options.headers as Record<string, string>),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove any null/undefined headers
|
||||||
|
Object.keys(headers).forEach((key) => {
|
||||||
|
if (headers[key] == null) {
|
||||||
|
delete headers[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers,
|
||||||
Accept: 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-XSRF-TOKEN': getXsrfToken(),
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
...options.headers,
|
|
||||||
},
|
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
59
resources/js/lib/s3-upload.ts
Normal file
59
resources/js/lib/s3-upload.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { apiFetch } from '@/lib/api';
|
||||||
|
|
||||||
|
export type PresignedUploadResponse = {
|
||||||
|
key: string;
|
||||||
|
url: string;
|
||||||
|
expires_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getPresignedUploadUrl(
|
||||||
|
filename: string,
|
||||||
|
mimeType: string,
|
||||||
|
): Promise<PresignedUploadResponse> {
|
||||||
|
return apiFetch<PresignedUploadResponse>('/admin/media/presign', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ filename, mime_type: mimeType }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFileToS3(
|
||||||
|
presignedUrl: string,
|
||||||
|
file: File,
|
||||||
|
onProgress?: (percent: number) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
|
||||||
|
xhr.upload.addEventListener('progress', (event) => {
|
||||||
|
if (event.lengthComputable && onProgress) {
|
||||||
|
onProgress(Math.round((event.loaded / event.total) * 100));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('load', () => {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Upload gagal (HTTP ${xhr.status})`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('error', () => reject(new Error('Upload gagal. Periksa koneksi internet Anda.')));
|
||||||
|
xhr.addEventListener('abort', () => reject(new Error('Upload dibatalkan.')));
|
||||||
|
|
||||||
|
xhr.open('PUT', presignedUrl);
|
||||||
|
xhr.setRequestHeader('Content-Type', file.type);
|
||||||
|
xhr.send(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFileAndGetKey(
|
||||||
|
file: File,
|
||||||
|
onProgress?: (percent: number) => void,
|
||||||
|
): Promise<string> {
|
||||||
|
const { key, url } = await getPresignedUploadUrl(file.name, file.type);
|
||||||
|
|
||||||
|
await uploadFileToS3(url, file, onProgress);
|
||||||
|
|
||||||
|
return key;
|
||||||
|
}
|
||||||
@ -22,7 +22,7 @@ import AdminLayout from '@/layouts/AdminLayout.vue';
|
|||||||
import { formatRupiah, formatRupiahShort } from '@/lib/rupiah';
|
import { formatRupiah, formatRupiahShort } from '@/lib/rupiah';
|
||||||
import admin from '@/routes/admin';
|
import admin from '@/routes/admin';
|
||||||
|
|
||||||
const { can, hasRole } = useCan();
|
const { can, hasRole, hasAnyRole } = useCan();
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
filters: {
|
filters: {
|
||||||
@ -102,8 +102,8 @@ const props = defineProps<{
|
|||||||
total_orders: number;
|
total_orders: number;
|
||||||
total_products_sold: number;
|
total_products_sold: number;
|
||||||
hpp: number;
|
hpp: number;
|
||||||
laba_kotor: number;
|
gross_profit: number;
|
||||||
laba_bersih: number;
|
net_profit: number;
|
||||||
profit_margin: number;
|
profit_margin: number;
|
||||||
aov: number;
|
aov: number;
|
||||||
items_per_transaction: number;
|
items_per_transaction: number;
|
||||||
@ -123,6 +123,14 @@ const props = defineProps<{
|
|||||||
total_qty: number;
|
total_qty: number;
|
||||||
total_revenue: number;
|
total_revenue: number;
|
||||||
}>;
|
}>;
|
||||||
|
marketingSales: Array<{
|
||||||
|
marketing_name: string;
|
||||||
|
total_orders: number;
|
||||||
|
total_revenue: number;
|
||||||
|
total_subtotal: number;
|
||||||
|
total_discount: number;
|
||||||
|
avg_order: number;
|
||||||
|
}>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const startDate = ref(props.filters.start_date ?? '');
|
const startDate = ref(props.filters.start_date ?? '');
|
||||||
@ -186,7 +194,7 @@ const revenueChartConfig = {
|
|||||||
|
|
||||||
const revenueTotals = computed(() => ({
|
const revenueTotals = computed(() => ({
|
||||||
total: props.revenueSummary.total_revenue,
|
total: props.revenueSummary.total_revenue,
|
||||||
net: props.revenueSummary.total_revenue - props.revenueSummary.total_deduction,
|
net: props.revenueSummary.total_revenue - props.revenueSummary.total_deduction - props.profitMetrics.hpp,
|
||||||
deduction: props.revenueSummary.total_deduction,
|
deduction: props.revenueSummary.total_deduction,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -222,17 +230,18 @@ const expenseTotals = computed(() => ({
|
|||||||
const visibleExpenseCharts = computed(() => {
|
const visibleExpenseCharts = computed(() => {
|
||||||
const charts = ['total', 'purchase', 'expense', 'advance'] as const;
|
const charts = ['total', 'purchase', 'expense', 'advance'] as const;
|
||||||
|
|
||||||
if (hasRole('admin-toko')) {
|
if (hasAnyRole(['owner', 'developer'])) {
|
||||||
return charts.filter((c) => c !== 'purchase');
|
return charts;
|
||||||
}
|
}
|
||||||
|
|
||||||
return charts;
|
return charts.filter((c) => c !== 'purchase');
|
||||||
});
|
});
|
||||||
|
|
||||||
const expenseYAccessors = computed(() => {
|
const expenseYAccessors = computed(() => {
|
||||||
if (hasRole('admin-toko')) {
|
if (hasAnyRole(['owner', 'developer'])) {
|
||||||
return [
|
return [
|
||||||
(d: MonthlyExpenseData) => d.total,
|
(d: MonthlyExpenseData) => d.total,
|
||||||
|
(d: MonthlyExpenseData) => d.purchase,
|
||||||
(d: MonthlyExpenseData) => d.expense,
|
(d: MonthlyExpenseData) => d.expense,
|
||||||
(d: MonthlyExpenseData) => d.advance,
|
(d: MonthlyExpenseData) => d.advance,
|
||||||
];
|
];
|
||||||
@ -240,16 +249,16 @@ const expenseYAccessors = computed(() => {
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
(d: MonthlyExpenseData) => d.total,
|
(d: MonthlyExpenseData) => d.total,
|
||||||
(d: MonthlyExpenseData) => d.purchase,
|
|
||||||
(d: MonthlyExpenseData) => d.expense,
|
(d: MonthlyExpenseData) => d.expense,
|
||||||
(d: MonthlyExpenseData) => d.advance,
|
(d: MonthlyExpenseData) => d.advance,
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
const expenseColors = computed(() => {
|
const expenseColors = computed(() => {
|
||||||
if (hasRole('admin-toko')) {
|
if (hasAnyRole(['owner', 'developer'])) {
|
||||||
return [
|
return [
|
||||||
expenseChartConfig.total.color,
|
expenseChartConfig.total.color,
|
||||||
|
expenseChartConfig.purchase.color,
|
||||||
expenseChartConfig.expense.color,
|
expenseChartConfig.expense.color,
|
||||||
expenseChartConfig.advance.color,
|
expenseChartConfig.advance.color,
|
||||||
];
|
];
|
||||||
@ -257,7 +266,6 @@ const expenseColors = computed(() => {
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
expenseChartConfig.total.color,
|
expenseChartConfig.total.color,
|
||||||
expenseChartConfig.purchase.color,
|
|
||||||
expenseChartConfig.expense.color,
|
expenseChartConfig.expense.color,
|
||||||
expenseChartConfig.advance.color,
|
expenseChartConfig.advance.color,
|
||||||
];
|
];
|
||||||
@ -733,18 +741,18 @@ watch([startDate, endDate], () => {
|
|||||||
|
|
||||||
<StatCard v-if="can('analysis.profit_gross') || can('analysis.profit_hpp')" title="Laba Kotor"
|
<StatCard v-if="can('analysis.profit_gross') || can('analysis.profit_hpp')" title="Laba Kotor"
|
||||||
:icon="TrendingUp" main-label="Laba Kotor"
|
:icon="TrendingUp" main-label="Laba Kotor"
|
||||||
:main-value="'Rp' + formatRupiah(profitMetrics.laba_kotor)" :items="[
|
:main-value="'Rp' + formatRupiah(profitMetrics.gross_profit)" :items="[
|
||||||
{
|
{
|
||||||
label: 'Pendapatan',
|
label: 'Pendapatan',
|
||||||
value: 'Rp' + formatRupiah(revenueSummary.total_revenue),
|
value: 'Rp' + formatRupiah(revenueSummary.total_revenue),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Pengeluaran',
|
label: 'HPP',
|
||||||
value: 'Rp' + formatRupiah(expenseSummary.total),
|
value: 'Rp' + formatRupiah(profitMetrics.hpp),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Laba Bersih',
|
label: 'Laba Bersih',
|
||||||
value: 'Rp' + formatRupiah(profitMetrics.laba_bersih) + ' (' + profitMetrics.profit_margin + '%)',
|
value: 'Rp' + formatRupiah(profitMetrics.net_profit) + ' (' + profitMetrics.profit_margin + '%)',
|
||||||
},
|
},
|
||||||
]" />
|
]" />
|
||||||
</div>
|
</div>
|
||||||
@ -832,6 +840,43 @@ watch([startDate, endDate], () => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Marketing Sales Table -->
|
||||||
|
<Card v-if="can('analysis.marketing_sales')">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Penjualan Marketing</CardTitle>
|
||||||
|
<CardDescription>Rekap penjualan per marketing</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div v-if="marketingSales.length > 0" class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b">
|
||||||
|
<th class="py-3 px-4 text-left font-medium text-muted-foreground">Marketing</th>
|
||||||
|
<th class="py-3 px-4 text-right font-medium text-muted-foreground">Total Order</th>
|
||||||
|
<th class="py-3 px-4 text-right font-medium text-muted-foreground">Total Pendapatan</th>
|
||||||
|
<th class="py-3 px-4 text-right font-medium text-muted-foreground">Total Subtotal</th>
|
||||||
|
<th class="py-3 px-4 text-right font-medium text-muted-foreground">Total Diskon</th>
|
||||||
|
<th class="py-3 px-4 text-right font-medium text-muted-foreground">Rata-rata Order</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(item, index) in marketingSales" :key="index" class="border-b last:border-0">
|
||||||
|
<td class="py-3 px-4 font-medium">{{ item.marketing_name }}</td>
|
||||||
|
<td class="py-3 px-4 text-right tabular-nums">{{ item.total_orders.toLocaleString('id-ID') }}</td>
|
||||||
|
<td class="py-3 px-4 text-right tabular-nums">Rp{{ formatRupiah(item.total_revenue) }}</td>
|
||||||
|
<td class="py-3 px-4 text-right tabular-nums">Rp{{ formatRupiah(item.total_subtotal) }}</td>
|
||||||
|
<td class="py-3 px-4 text-right tabular-nums">Rp{{ formatRupiah(item.total_discount) }}</td>
|
||||||
|
<td class="py-3 px-4 text-right tabular-nums">Rp{{ formatRupiah(item.avg_order) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex h-[150px] items-center justify-center text-muted-foreground">
|
||||||
|
Belum ada data penjualan marketing
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -51,6 +51,8 @@ const profilePhotoState = ref<MediaUploadState>(
|
|||||||
createMediaUploadState(props.profilePhoto ? [props.profilePhoto] : []),
|
createMediaUploadState(props.profilePhoto ? [props.profilePhoto] : []),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isUploading = computed(() => profilePhotoState.value.pendingUploads > 0);
|
||||||
|
|
||||||
function buildFormData(): FormData {
|
function buildFormData(): FormData {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
@ -62,8 +64,8 @@ function buildFormData(): FormData {
|
|||||||
formData.append('birth_date', form.birth_date);
|
formData.append('birth_date', form.birth_date);
|
||||||
formData.append('address', form.address);
|
formData.append('address', form.address);
|
||||||
|
|
||||||
profilePhotoState.value.newFiles.forEach((file) => {
|
profilePhotoState.value.newFileS3Keys.forEach((key) => {
|
||||||
formData.append('profile_photo', file);
|
formData.append('profile_s3_key', key);
|
||||||
});
|
});
|
||||||
|
|
||||||
profilePhotoState.value.removeIds.forEach((id) => {
|
profilePhotoState.value.removeIds.forEach((id) => {
|
||||||
@ -175,9 +177,9 @@ function submit() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div class="flex items-center justify-end gap-2">
|
<div class="flex items-center justify-end gap-2">
|
||||||
<Button type="submit" :disabled="form.processing">
|
<Button type="submit" :disabled="form.processing || isUploading">
|
||||||
<Save class="size-4" />
|
<Save class="size-4" />
|
||||||
{{ form.processing ? 'Menyimpan...' : 'Simpan' }}
|
{{ isUploading ? 'Mengunggah...' : form.processing ? 'Menyimpan...' : 'Simpan' }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -51,6 +51,8 @@ const isWithdrawal = computed(() => currentMode.value === CashTransactionType.WI
|
|||||||
|
|
||||||
const photoState = ref<MediaUploadState>(createMediaUploadState());
|
const photoState = ref<MediaUploadState>(createMediaUploadState());
|
||||||
|
|
||||||
|
const isUploading = computed(() => photoState.value.pendingUploads > 0);
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
amount: '',
|
amount: '',
|
||||||
description: '',
|
description: '',
|
||||||
@ -166,9 +168,9 @@ const placeholder = computed(() =>
|
|||||||
<Button type="button" variant="outline" :disabled="form.processing" @click="open = false">
|
<Button type="button" variant="outline" :disabled="form.processing" @click="open = false">
|
||||||
Batal
|
Batal
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" :disabled="form.processing">
|
<Button type="submit" :disabled="form.processing || isUploading">
|
||||||
<Save class="size-4" />
|
<Save class="size-4" />
|
||||||
{{ form.processing ? 'Menyimpan...' : 'Simpan' }}
|
{{ isUploading ? 'Mengunggah...' : form.processing ? 'Menyimpan...' : 'Simpan' }}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -41,6 +41,8 @@ const isEditing = computed(() => props.expense != null);
|
|||||||
|
|
||||||
const photoState = ref<MediaUploadState>(createMediaUploadState());
|
const photoState = ref<MediaUploadState>(createMediaUploadState());
|
||||||
|
|
||||||
|
const isUploading = computed(() => photoState.value.pendingUploads > 0);
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
amount: '',
|
amount: '',
|
||||||
description: '',
|
description: '',
|
||||||
@ -143,9 +145,9 @@ function submit() {
|
|||||||
<Button type="button" variant="outline" :disabled="form.processing" @click="open = false">
|
<Button type="button" variant="outline" :disabled="form.processing" @click="open = false">
|
||||||
Batal
|
Batal
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" :disabled="form.processing">
|
<Button type="submit" :disabled="form.processing || isUploading">
|
||||||
<Save class="size-4" />
|
<Save class="size-4" />
|
||||||
{{ form.processing ? 'Menyimpan...' : 'Simpan' }}
|
{{ isUploading ? 'Mengunggah...' : form.processing ? 'Menyimpan...' : 'Simpan' }}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -70,6 +70,8 @@ const profilePhotoState = ref<MediaUploadState>(
|
|||||||
createMediaUploadState(props.profilePhoto ? [props.profilePhoto] : []),
|
createMediaUploadState(props.profilePhoto ? [props.profilePhoto] : []),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isUploading = computed(() => profilePhotoState.value.pendingUploads > 0);
|
||||||
|
|
||||||
function buildFormData(): FormData {
|
function buildFormData(): FormData {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
@ -85,8 +87,8 @@ function buildFormData(): FormData {
|
|||||||
formData.append('base_salary', form.base_salary === '' ? '' : form.base_salary);
|
formData.append('base_salary', form.base_salary === '' ? '' : form.base_salary);
|
||||||
formData.append('role', form.role);
|
formData.append('role', form.role);
|
||||||
|
|
||||||
profilePhotoState.value.newFiles.forEach((file) => {
|
profilePhotoState.value.newFileS3Keys.forEach((key) => {
|
||||||
formData.append('profile_photo', file);
|
formData.append('profile_s3_key', key);
|
||||||
});
|
});
|
||||||
|
|
||||||
profilePhotoState.value.removeIds.forEach((id) => {
|
profilePhotoState.value.removeIds.forEach((id) => {
|
||||||
@ -248,9 +250,9 @@ function submit() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div class="flex items-center justify-end gap-2">
|
<div class="flex items-center justify-end gap-2">
|
||||||
<Button type="submit" :disabled="form.processing">
|
<Button type="submit" :disabled="form.processing || isUploading">
|
||||||
<Save class="size-4" />
|
<Save class="size-4" />
|
||||||
{{ form.processing ? 'Menyimpan...' : submitLabel }}
|
{{ isUploading ? 'Mengunggah...' : form.processing ? 'Menyimpan...' : submitLabel }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import type {
|
|||||||
CuttingRawMaterialCatalogItem,
|
CuttingRawMaterialCatalogItem,
|
||||||
CuttingResultCartItem,
|
CuttingResultCartItem,
|
||||||
} from '@/types/cutting';
|
} from '@/types/cutting';
|
||||||
|
import type { CategoryOption } from '@/types/product';
|
||||||
|
import type { EnumOption } from '@/types/raw-material';
|
||||||
import { Head } from '@inertiajs/vue3';
|
import { Head } from '@inertiajs/vue3';
|
||||||
import CuttingPosForm from './form/CuttingPosForm.vue';
|
import CuttingPosForm from './form/CuttingPosForm.vue';
|
||||||
import { index, store } from '@/routes/admin/manage/cuttings';
|
import { index, store } from '@/routes/admin/manage/cuttings';
|
||||||
@ -16,6 +18,8 @@ defineProps<{
|
|||||||
productCatalog: CuttingProductCatalogItem[];
|
productCatalog: CuttingProductCatalogItem[];
|
||||||
draftMaterials: CuttingMaterialCartItem[];
|
draftMaterials: CuttingMaterialCartItem[];
|
||||||
draftResults: CuttingResultCartItem[];
|
draftResults: CuttingResultCartItem[];
|
||||||
|
categories: CategoryOption[];
|
||||||
|
units: EnumOption[];
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -40,6 +44,8 @@ defineProps<{
|
|||||||
:product-catalog="productCatalog"
|
:product-catalog="productCatalog"
|
||||||
:draft-materials="draftMaterials"
|
:draft-materials="draftMaterials"
|
||||||
:draft-results="draftResults"
|
:draft-results="draftResults"
|
||||||
|
:categories="categories"
|
||||||
|
:units="units"
|
||||||
:submit-url="store.url()"
|
:submit-url="store.url()"
|
||||||
method="post"
|
method="post"
|
||||||
submit-label="Simpan"
|
submit-label="Simpan"
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import type {
|
|||||||
CuttingProductCatalogItem,
|
CuttingProductCatalogItem,
|
||||||
CuttingRawMaterialCatalogItem,
|
CuttingRawMaterialCatalogItem,
|
||||||
} from '@/types/cutting';
|
} from '@/types/cutting';
|
||||||
|
import type { CategoryOption } from '@/types/product';
|
||||||
|
import type { EnumOption } from '@/types/raw-material';
|
||||||
import { Head } from '@inertiajs/vue3';
|
import { Head } from '@inertiajs/vue3';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import CuttingPosForm from './form/CuttingPosForm.vue';
|
import CuttingPosForm from './form/CuttingPosForm.vue';
|
||||||
@ -15,6 +17,8 @@ const props = defineProps<{
|
|||||||
cutting: CuttingEditItem;
|
cutting: CuttingEditItem;
|
||||||
rawMaterialCatalog: CuttingRawMaterialCatalogItem[];
|
rawMaterialCatalog: CuttingRawMaterialCatalogItem[];
|
||||||
productCatalog: CuttingProductCatalogItem[];
|
productCatalog: CuttingProductCatalogItem[];
|
||||||
|
categories: CategoryOption[];
|
||||||
|
units: EnumOption[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const initialData = computed(() => ({
|
const initialData = computed(() => ({
|
||||||
@ -45,7 +49,6 @@ const initialData = computed(() => ({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<Head title="Ubah Cutting" />
|
<Head title="Ubah Cutting" />
|
||||||
|
|
||||||
<AdminLayout>
|
<AdminLayout>
|
||||||
@ -58,6 +61,7 @@ const initialData = computed(() => ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CuttingPosForm :raw-material-catalog="rawMaterialCatalog" :product-catalog="productCatalog"
|
<CuttingPosForm :raw-material-catalog="rawMaterialCatalog" :product-catalog="productCatalog"
|
||||||
|
:categories="categories" :units="units"
|
||||||
:initial-data="initialData" :submit-url="update.url(props.cutting.id)" method="put"
|
:initial-data="initialData" :submit-url="update.url(props.cutting.id)" method="put"
|
||||||
submit-label="Perbarui" />
|
submit-label="Perbarui" />
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import type {
|
|||||||
CuttingRawMaterialCatalogItem,
|
CuttingRawMaterialCatalogItem,
|
||||||
CuttingResultCartItem,
|
CuttingResultCartItem,
|
||||||
} from '@/types/cutting';
|
} from '@/types/cutting';
|
||||||
|
import type { CategoryOption } from '@/types/product';
|
||||||
|
import type { EnumOption } from '@/types/raw-material';
|
||||||
import CuttingPosCartDetailDialog from './CuttingPosCartDetailDialog.vue';
|
import CuttingPosCartDetailDialog from './CuttingPosCartDetailDialog.vue';
|
||||||
import CuttingPosMaterialCatalogPanel from './CuttingPosMaterialCatalogPanel.vue';
|
import CuttingPosMaterialCatalogPanel from './CuttingPosMaterialCatalogPanel.vue';
|
||||||
import CuttingPosResultCatalogPanel from './CuttingPosResultCatalogPanel.vue';
|
import CuttingPosResultCatalogPanel from './CuttingPosResultCatalogPanel.vue';
|
||||||
@ -18,6 +20,8 @@ import { useCuttingPosCart } from './useCuttingPosCart';
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
rawMaterialCatalog: CuttingRawMaterialCatalogItem[];
|
rawMaterialCatalog: CuttingRawMaterialCatalogItem[];
|
||||||
productCatalog: CuttingProductCatalogItem[];
|
productCatalog: CuttingProductCatalogItem[];
|
||||||
|
categories: CategoryOption[];
|
||||||
|
units: EnumOption[];
|
||||||
initialData?: {
|
initialData?: {
|
||||||
description: string;
|
description: string;
|
||||||
sewing_cost?: string;
|
sewing_cost?: string;
|
||||||
@ -35,6 +39,13 @@ const props = defineProps<{
|
|||||||
const isCreateMode = computed(() => props.method === 'post');
|
const isCreateMode = computed(() => props.method === 'post');
|
||||||
const cartDetailOpen = ref(false);
|
const cartDetailOpen = ref(false);
|
||||||
|
|
||||||
|
// Make catalogs mutable so we can add new items from quick-create dialogs
|
||||||
|
const rawMaterialCatalogState = ref<CuttingRawMaterialCatalogItem[]>([...props.rawMaterialCatalog]);
|
||||||
|
const productCatalogState = ref<CuttingProductCatalogItem[]>([...props.productCatalog]);
|
||||||
|
|
||||||
|
watch(() => props.rawMaterialCatalog, (val) => { rawMaterialCatalogState.value = [...val]; });
|
||||||
|
watch(() => props.productCatalog, (val) => { productCatalogState.value = [...val]; });
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
description: '',
|
description: '',
|
||||||
sewing_cost: '0',
|
sewing_cost: '0',
|
||||||
@ -65,8 +76,8 @@ const {
|
|||||||
syncMaterialField,
|
syncMaterialField,
|
||||||
syncResultField,
|
syncResultField,
|
||||||
} = useCuttingPosCart({
|
} = useCuttingPosCart({
|
||||||
rawMaterialCatalog: () => props.rawMaterialCatalog,
|
rawMaterialCatalog: rawMaterialCatalogState,
|
||||||
productCatalog: () => props.productCatalog,
|
productCatalog: productCatalogState,
|
||||||
isCreateMode: () => isCreateMode.value,
|
isCreateMode: () => isCreateMode.value,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -142,6 +153,30 @@ function submit() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onRawMaterialCreated(rawMaterial: CuttingRawMaterialCatalogItem) {
|
||||||
|
// Add to catalog
|
||||||
|
rawMaterialCatalogState.value.push(rawMaterial);
|
||||||
|
|
||||||
|
// Auto-add the first price variant to cart
|
||||||
|
const firstPrice = rawMaterial.prices[0];
|
||||||
|
|
||||||
|
if (firstPrice) {
|
||||||
|
addMaterial(rawMaterial, firstPrice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onProductCreated(product: CuttingProductCatalogItem) {
|
||||||
|
// Add to catalog
|
||||||
|
productCatalogState.value.push(product);
|
||||||
|
|
||||||
|
// Auto-add the first variant to cart
|
||||||
|
const firstVariant = product.variants[0];
|
||||||
|
|
||||||
|
if (firstVariant) {
|
||||||
|
addResult(product, firstVariant);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -149,12 +184,16 @@ function submit() {
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<CuttingPosMaterialCatalogPanel v-model:material-search="materialSearch"
|
<CuttingPosMaterialCatalogPanel v-model:material-search="materialSearch"
|
||||||
:filtered-raw-materials="filteredRawMaterials" :get-material-cart-item="getMaterialCartItem"
|
:filtered-raw-materials="filteredRawMaterials" :get-material-cart-item="getMaterialCartItem"
|
||||||
@add-material="addMaterial" @decrease-material-qty="decreaseMaterialQty" />
|
:units="units"
|
||||||
|
@add-material="addMaterial" @decrease-material-qty="decreaseMaterialQty"
|
||||||
|
@raw-material-created="onRawMaterialCreated" />
|
||||||
|
|
||||||
<CuttingPosResultCatalogPanel v-model:product-search="productSearch" :filtered-products="filteredProducts"
|
<CuttingPosResultCatalogPanel v-model:product-search="productSearch" :filtered-products="filteredProducts"
|
||||||
:get-result-cart-item="getResultCartItem" @add-result="addResult"
|
:get-result-cart-item="getResultCartItem" @add-result="addResult"
|
||||||
:is-create-mode="isCreateMode"
|
:is-create-mode="isCreateMode"
|
||||||
@decrease-result-qty="decreaseResultQty" />
|
:categories="categories"
|
||||||
|
@decrease-result-qty="decreaseResultQty"
|
||||||
|
@product-created="onProductCreated" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CuttingPosSummaryPanel :form="form" :material-cart="materialCart" :result-cart="resultCart"
|
<CuttingPosSummaryPanel :form="form" :material-cart="materialCart" :result-cart="resultCart"
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
import { Minus, Plus, Search } from '@lucide/vue';
|
import { Minus, Plus, Search } from '@lucide/vue';
|
||||||
import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue';
|
import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue';
|
||||||
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
|
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
|
||||||
@ -14,11 +15,14 @@ import {
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { getFirstCoverImage } from '@/lib/catalog-cover';
|
import { getFirstCoverImage } from '@/lib/catalog-cover';
|
||||||
import type { CuttingMaterialCartItem, CuttingRawMaterialCatalogItem } from '@/types/cutting';
|
import type { CuttingMaterialCartItem, CuttingRawMaterialCatalogItem } from '@/types/cutting';
|
||||||
|
import type { EnumOption } from '@/types/raw-material';
|
||||||
import type { CuttingCatalogPrice } from './useCuttingPosCart';
|
import type { CuttingCatalogPrice } from './useCuttingPosCart';
|
||||||
|
import QuickCreateRawMaterialModal from './QuickCreateRawMaterialModal.vue';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
filteredRawMaterials: CuttingRawMaterialCatalogItem[];
|
filteredRawMaterials: CuttingRawMaterialCatalogItem[];
|
||||||
getMaterialCartItem: (priceId: number) => CuttingMaterialCartItem | undefined;
|
getMaterialCartItem: (priceId: number) => CuttingMaterialCartItem | undefined;
|
||||||
|
units: EnumOption[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const materialSearch = defineModel<string>('materialSearch', { required: true });
|
const materialSearch = defineModel<string>('materialSearch', { required: true });
|
||||||
@ -26,13 +30,20 @@ const materialSearch = defineModel<string>('materialSearch', { required: true })
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'add-material': [rawMaterial: CuttingRawMaterialCatalogItem, price: CuttingCatalogPrice];
|
'add-material': [rawMaterial: CuttingRawMaterialCatalogItem, price: CuttingCatalogPrice];
|
||||||
'decrease-material-qty': [priceId: number];
|
'decrease-material-qty': [priceId: number];
|
||||||
|
'raw-material-created': [rawMaterial: CuttingRawMaterialCatalogItem];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const quickCreateOpen = ref(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Card class="min-w-0">
|
<Card class="min-w-0">
|
||||||
<CardHeader class="pb-3">
|
<CardHeader class="flex flex-row items-center justify-between pb-3 space-y-0">
|
||||||
<CardTitle class="text-base">Pilih Bahan Baku</CardTitle>
|
<CardTitle class="text-base">Pilih Bahan Baku</CardTitle>
|
||||||
|
<Button type="button" variant="outline" size="sm" @click="quickCreateOpen = true">
|
||||||
|
<Plus class="size-3.5 mr-1" />
|
||||||
|
Baru
|
||||||
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@ -98,7 +109,7 @@ const emit = defineEmits<{
|
|||||||
>
|
>
|
||||||
<Minus class="size-3.5" />
|
<Minus class="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<span class="min-w-[1.25rem] text-center text-xs font-semibold tabular-nums">
|
<span class="min-w-5 text-center text-xs font-semibold tabular-nums">
|
||||||
{{ getMaterialCartItem(price.id)!.material_usage }}
|
{{ getMaterialCartItem(price.id)!.material_usage }}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
@ -126,4 +137,10 @@ const emit = defineEmits<{
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<QuickCreateRawMaterialModal
|
||||||
|
v-model:open="quickCreateOpen"
|
||||||
|
:units="units"
|
||||||
|
@created="emit('raw-material-created', $event)"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
import { Check, Minus, Plus, Search } from '@lucide/vue';
|
import { Check, Minus, Plus, Search } from '@lucide/vue';
|
||||||
import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue';
|
import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue';
|
||||||
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
|
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
|
||||||
@ -13,12 +14,15 @@ import {
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { getFirstCoverImage } from '@/lib/catalog-cover';
|
import { getFirstCoverImage } from '@/lib/catalog-cover';
|
||||||
import type { CuttingProductCatalogItem, CuttingResultCartItem } from '@/types/cutting';
|
import type { CuttingProductCatalogItem, CuttingResultCartItem } from '@/types/cutting';
|
||||||
|
import type { CategoryOption } from '@/types/product';
|
||||||
import type { CuttingCatalogVariant } from './useCuttingPosCart';
|
import type { CuttingCatalogVariant } from './useCuttingPosCart';
|
||||||
|
import QuickCreateProductModal from './QuickCreateProductModal.vue';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
filteredProducts: CuttingProductCatalogItem[];
|
filteredProducts: CuttingProductCatalogItem[];
|
||||||
getResultCartItem: (variantId: number) => CuttingResultCartItem | undefined;
|
getResultCartItem: (variantId: number) => CuttingResultCartItem | undefined;
|
||||||
isCreateMode: boolean;
|
isCreateMode: boolean;
|
||||||
|
categories: CategoryOption[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const productSearch = defineModel<string>('productSearch', { required: true });
|
const productSearch = defineModel<string>('productSearch', { required: true });
|
||||||
@ -26,13 +30,20 @@ const productSearch = defineModel<string>('productSearch', { required: true });
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'add-result': [product: CuttingProductCatalogItem, variant: CuttingCatalogVariant];
|
'add-result': [product: CuttingProductCatalogItem, variant: CuttingCatalogVariant];
|
||||||
'decrease-result-qty': [variantId: number];
|
'decrease-result-qty': [variantId: number];
|
||||||
|
'product-created': [product: CuttingProductCatalogItem];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const quickCreateOpen = ref(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Card class="min-w-0">
|
<Card class="min-w-0">
|
||||||
<CardHeader class="pb-3">
|
<CardHeader class="flex flex-row items-center justify-between pb-3 space-y-0">
|
||||||
<CardTitle class="text-base">Pilih Produk Hasil</CardTitle>
|
<CardTitle class="text-base">Pilih Produk Hasil</CardTitle>
|
||||||
|
<Button type="button" variant="outline" size="sm" @click="quickCreateOpen = true">
|
||||||
|
<Plus class="size-3.5 mr-1" />
|
||||||
|
Baru
|
||||||
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@ -107,7 +118,7 @@ const emit = defineEmits<{
|
|||||||
>
|
>
|
||||||
<Minus class="size-3.5" />
|
<Minus class="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<span class="min-w-[1.25rem] text-center text-xs font-semibold tabular-nums">
|
<span class="min-w-5 text-center text-xs font-semibold tabular-nums">
|
||||||
{{ getResultCartItem(variant.id)!.cutting_result }}
|
{{ getResultCartItem(variant.id)!.cutting_result }}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
@ -136,4 +147,10 @@ const emit = defineEmits<{
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<QuickCreateProductModal
|
||||||
|
v-model:open="quickCreateOpen"
|
||||||
|
:categories="categories"
|
||||||
|
@created="emit('product-created', $event)"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -0,0 +1,251 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Plus, Save } from '@lucide/vue';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { toast } from 'vue-sonner';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useCan } from '@/composables/useCan';
|
||||||
|
import { useVariantList } from '@/composables/useVariantList';
|
||||||
|
import { apiFetch } from '@/lib/api';
|
||||||
|
import ProductInfoSection from '@/pages/admin/master/products/form/ProductInfoSection.vue';
|
||||||
|
import ProductVariantSection from '@/pages/admin/master/products/form/ProductVariantSection.vue';
|
||||||
|
import type { CuttingProductCatalogItem } from '@/types/cutting';
|
||||||
|
import { appendMediaToFormData, createMediaUploadState } from '@/types/media';
|
||||||
|
import type { CategoryOption, ProductVariantFormItem } from '@/types/product';
|
||||||
|
|
||||||
|
const { hasRole } = useCan();
|
||||||
|
const showPrices = !hasRole('admin-bahan-baku');
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
categories: CategoryOption[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const open = defineModel<boolean>('open', { required: true });
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'created': [product: CuttingProductCatalogItem];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const isUploading = computed(() =>
|
||||||
|
variants.value.some((v) => v.media.pendingUploads > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
function createClientId(): string {
|
||||||
|
return `variant-${crypto.randomUUID()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
category_ids: [] as number[],
|
||||||
|
errors: {} as Record<string, string>,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
items: variants,
|
||||||
|
addItem: addVariant,
|
||||||
|
removeItem: removeVariant,
|
||||||
|
setField: setVariantField,
|
||||||
|
appendToFormData,
|
||||||
|
itemErrors: variantErrors,
|
||||||
|
} = useVariantList<ProductVariantFormItem>(
|
||||||
|
'variants',
|
||||||
|
() => ({
|
||||||
|
client_id: createClientId(),
|
||||||
|
name: '',
|
||||||
|
stock: '0',
|
||||||
|
retail_stock: '0',
|
||||||
|
prices: {
|
||||||
|
distributor: '0',
|
||||||
|
agent: '0',
|
||||||
|
sub_agent: '0',
|
||||||
|
grosir: '0',
|
||||||
|
retail: '0',
|
||||||
|
tiktok: '0',
|
||||||
|
shopee: '0',
|
||||||
|
harga_modal: '0',
|
||||||
|
},
|
||||||
|
media: createMediaUploadState(),
|
||||||
|
}),
|
||||||
|
() => [{
|
||||||
|
client_id: createClientId(),
|
||||||
|
name: '',
|
||||||
|
stock: '0',
|
||||||
|
retail_stock: '0',
|
||||||
|
prices: {
|
||||||
|
distributor: '0',
|
||||||
|
agent: '0',
|
||||||
|
sub_agent: '0',
|
||||||
|
grosir: '0',
|
||||||
|
retail: '0',
|
||||||
|
tiktok: '0',
|
||||||
|
shopee: '0',
|
||||||
|
harga_modal: '0',
|
||||||
|
},
|
||||||
|
media: createMediaUploadState(),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
|
||||||
|
const copiedPrices = ref<Record<string, string> | null>(null);
|
||||||
|
|
||||||
|
function copyPrices(variantPrices: Record<string, string>) {
|
||||||
|
copiedPrices.value = { ...variantPrices };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pastePrices(clientId: string) {
|
||||||
|
if (!copiedPrices.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVariantField(clientId, 'prices', { ...copiedPrices.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyToAllPrices(variantPrices: Record<string, string>) {
|
||||||
|
variants.value.forEach((v) => {
|
||||||
|
setVariantField(v.client_id, 'prices', { ...variantPrices });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCategory(categoryId: number, checked: boolean) {
|
||||||
|
if (checked) {
|
||||||
|
if (!form.value.category_ids.includes(categoryId)) {
|
||||||
|
form.value.category_ids = [...form.value.category_ids, categoryId];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
form.value.category_ids = form.value.category_ids.filter((id) => id !== categoryId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryError = computed(() => form.value.errors.category_ids ?? '');
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form.value = { name: '', description: '', category_ids: [], errors: {} };
|
||||||
|
variants.value = [{
|
||||||
|
client_id: createClientId(),
|
||||||
|
name: '',
|
||||||
|
stock: '0',
|
||||||
|
retail_stock: '0',
|
||||||
|
prices: {
|
||||||
|
distributor: '0',
|
||||||
|
agent: '0',
|
||||||
|
sub_agent: '0',
|
||||||
|
grosir: '0',
|
||||||
|
retail: '0',
|
||||||
|
tiktok: '0',
|
||||||
|
shopee: '0',
|
||||||
|
harga_modal: '0',
|
||||||
|
},
|
||||||
|
media: createMediaUploadState(),
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFormData(): FormData {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
formData.append('name', form.value.name.trim());
|
||||||
|
formData.append('description', form.value.description.trim());
|
||||||
|
|
||||||
|
form.value.category_ids.forEach((categoryId) => {
|
||||||
|
formData.append('category_ids[]', String(categoryId));
|
||||||
|
});
|
||||||
|
|
||||||
|
appendToFormData(formData, (formData, index, variant) => {
|
||||||
|
formData.append(`variants[${index}][name]`, variant.name.trim());
|
||||||
|
formData.append(`variants[${index}][stock]`, String(Number.parseInt(String(variant.stock), 10) || 0));
|
||||||
|
formData.append(`variants[${index}][retail_stock]`, String(Number.parseInt(String(variant.retail_stock), 10) || 0));
|
||||||
|
|
||||||
|
if (variant.prices && showPrices) {
|
||||||
|
Object.entries(variant.prices as Record<string, string>).forEach(([type, value]) => {
|
||||||
|
formData.append(`variants[${index}][prices][${type}]`, String(Number.parseInt(value, 10) || 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
appendMediaToFormData(formData, `variants[${index}]`, variant.media);
|
||||||
|
}, 'post');
|
||||||
|
|
||||||
|
return formData;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
loading.value = true;
|
||||||
|
form.value.errors = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = buildFormData();
|
||||||
|
const { product } = await apiFetch<{ product: CuttingProductCatalogItem }>(
|
||||||
|
'/admin/manage/cuttings/quick-create-product',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
toast.success(`Produk "${product.name}" berhasil ditambahkan.`);
|
||||||
|
emit('created', product);
|
||||||
|
open.value = false;
|
||||||
|
resetForm();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
toast.error(error.message);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog v-model:open="open">
|
||||||
|
<DialogContent class="flex max-h-[90vh] flex-col sm:max-w-5xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Tambah Produk Baru</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div class="scrollbar-thin flex-1 overflow-y-auto pr-1">
|
||||||
|
<form id="quick-create-product-form" @submit.prevent="submit" class="space-y-6">
|
||||||
|
<ProductInfoSection :form="form" :categories="categories" :category-error="categoryError"
|
||||||
|
@update:name="form.name = $event" @update:description="form.description = $event"
|
||||||
|
@toggle-category="toggleCategory" />
|
||||||
|
|
||||||
|
<ProductVariantSection v-for="(variant, index) in variants" :key="variant.client_id" :form="form"
|
||||||
|
:variant="variant" :index="index" :can-remove="variants.length > 1"
|
||||||
|
:has-copied-prices="!!copiedPrices"
|
||||||
|
:variant-errors="(clientId, field) => variantErrors(form, clientId, field)"
|
||||||
|
@remove="removeVariant(variant.client_id)"
|
||||||
|
@update:name="setVariantField(variant.client_id, 'name', $event)"
|
||||||
|
@update:stock="setVariantField(variant.client_id, 'stock', $event)"
|
||||||
|
@update:retail-stock="setVariantField(variant.client_id, 'retail_stock', $event)"
|
||||||
|
@update:prices="setVariantField(variant.client_id, 'prices', $event)"
|
||||||
|
@update:media="setVariantField(variant.client_id, 'media', $event)"
|
||||||
|
@copy-prices="copyPrices(variant.prices as Record<string, string>)"
|
||||||
|
@paste-prices="pastePrices(variant.client_id)"
|
||||||
|
@apply-to-all-prices="applyToAllPrices(variant.prices as Record<string, string>)" />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-2 border-t pt-4">
|
||||||
|
<Button type="button" variant="outline" @click="addVariant">
|
||||||
|
<Plus class="size-4" />
|
||||||
|
Tambah Varian
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button type="button" variant="outline" :disabled="loading" @click="open = false">
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form="quick-create-product-form" :disabled="loading || isUploading">
|
||||||
|
<Save class="size-4" />
|
||||||
|
{{ isUploading ? 'Mengunggah...' : loading ? 'Menyimpan...' : 'Simpan' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,243 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Plus, Save } from '@lucide/vue';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { toast } from 'vue-sonner';
|
||||||
|
import { apiFetch } from '@/lib/api';
|
||||||
|
import { useVariantList } from '@/composables/useVariantList';
|
||||||
|
import { appendMediaToFormData, createMediaUploadState } from '@/types/media';
|
||||||
|
import { formErrors } from '@/lib/form';
|
||||||
|
import { parseRupiah } from '@/lib/rupiah';
|
||||||
|
import type { EnumOption, RawMaterialPriceFormItem } from '@/types/raw-material';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { FieldError } from '@/components/ui/field';
|
||||||
|
import RawMaterialInfoSection from '@/pages/admin/master/raw-materials/form/RawMaterialInfoSection.vue';
|
||||||
|
import RawMaterialSharedPriceSection from '@/pages/admin/master/raw-materials/form/RawMaterialSharedPriceSection.vue';
|
||||||
|
import RawMaterialVariantSection from '@/pages/admin/master/raw-materials/form/RawMaterialVariantSection.vue';
|
||||||
|
import type { CuttingRawMaterialCatalogItem } from '@/types/cutting';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
units: EnumOption[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const open = defineModel<boolean>('open', { required: true });
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'created': [rawMaterial: CuttingRawMaterialCatalogItem];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const selectPortalTarget = ref<HTMLElement>();
|
||||||
|
|
||||||
|
const isUploading = computed(() =>
|
||||||
|
prices.value.some((p) => p.media.pendingUploads > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
function createClientId(): string {
|
||||||
|
return `price-${crypto.randomUUID()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
name: '',
|
||||||
|
unit: '',
|
||||||
|
errors: {} as Record<string, string>,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
items: prices,
|
||||||
|
removeItem: removePrice,
|
||||||
|
setField: setPriceField,
|
||||||
|
appendToFormData,
|
||||||
|
itemErrors: priceErrors,
|
||||||
|
} = useVariantList<RawMaterialPriceFormItem>(
|
||||||
|
'prices',
|
||||||
|
() => ({
|
||||||
|
client_id: createClientId(),
|
||||||
|
variant: '',
|
||||||
|
price: '',
|
||||||
|
stock: '0',
|
||||||
|
media: createMediaUploadState(),
|
||||||
|
}),
|
||||||
|
() => [{
|
||||||
|
client_id: createClientId(),
|
||||||
|
variant: '',
|
||||||
|
price: '',
|
||||||
|
stock: '0',
|
||||||
|
media: createMediaUploadState(),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
|
||||||
|
const useSamePrice = ref(true);
|
||||||
|
|
||||||
|
function addPrice() {
|
||||||
|
const newPrice: RawMaterialPriceFormItem = {
|
||||||
|
client_id: createClientId(),
|
||||||
|
variant: '',
|
||||||
|
price: '',
|
||||||
|
stock: '0',
|
||||||
|
media: createMediaUploadState(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (useSamePrice.value && prices.value[0]) {
|
||||||
|
newPrice.price = prices.value[0].price;
|
||||||
|
}
|
||||||
|
|
||||||
|
prices.value = [...prices.value, newPrice];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPriceValue(clientId: string, value: string) {
|
||||||
|
setPriceField(clientId, 'price', value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSharedPrice(value: string) {
|
||||||
|
prices.value = prices.value.map((price) => ({ ...price, price: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleUseSamePrice(checked: boolean) {
|
||||||
|
useSamePrice.value = checked;
|
||||||
|
|
||||||
|
if (!checked || !prices.value[0]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourcePrice = prices.value[0].price;
|
||||||
|
prices.value = prices.value.map((price) => ({ ...price, price: sourcePrice }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPriceToAllVariants(sourceClientId: string) {
|
||||||
|
const source = prices.value.find((price) => price.client_id === sourceClientId);
|
||||||
|
|
||||||
|
if (!source) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
prices.value = prices.value.map((price) => ({ ...price, price: source.price }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStockValue(value: string): number {
|
||||||
|
const parsed = Number.parseFloat(value.replace(',', '.'));
|
||||||
|
|
||||||
|
return Number.isNaN(parsed) ? 0 : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form.value = { name: '', unit: '', errors: {} };
|
||||||
|
prices.value = [{
|
||||||
|
client_id: createClientId(),
|
||||||
|
variant: '',
|
||||||
|
price: '',
|
||||||
|
stock: '0',
|
||||||
|
media: createMediaUploadState(),
|
||||||
|
}];
|
||||||
|
useSamePrice.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFormData(): FormData {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
formData.append('name', form.value.name.trim());
|
||||||
|
formData.append('unit', form.value.unit);
|
||||||
|
|
||||||
|
appendToFormData(formData, (formData, index, price) => {
|
||||||
|
formData.append(`prices[${index}][variant]`, price.variant.trim());
|
||||||
|
formData.append(`prices[${index}][price]`, String(Number.parseInt(parseRupiah(price.price), 10) || 0));
|
||||||
|
formData.append(`prices[${index}][stock]`, String(parseStockValue(price.stock)));
|
||||||
|
appendMediaToFormData(formData, `prices[${index}]`, price.media);
|
||||||
|
}, 'post');
|
||||||
|
|
||||||
|
return formData;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
loading.value = true;
|
||||||
|
form.value.errors = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = buildFormData();
|
||||||
|
const { raw_material } = await apiFetch<{ raw_material: CuttingRawMaterialCatalogItem }>(
|
||||||
|
'/admin/manage/cuttings/quick-create-raw-material',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
toast.success(`Bahan baku "${raw_material.name}" berhasil ditambahkan.`);
|
||||||
|
emit('created', raw_material);
|
||||||
|
open.value = false;
|
||||||
|
resetForm();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
toast.error(error.message);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog v-model:open="open">
|
||||||
|
<DialogContent class="flex max-h-[90vh] flex-col sm:max-w-5xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Tambah Bahan Baku Baru</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div class="scrollbar-thin flex-1 overflow-y-auto pr-1">
|
||||||
|
<form id="quick-create-raw-material-form" @submit.prevent="submit" class="space-y-6">
|
||||||
|
<RawMaterialInfoSection :form="form" :units="units" method="post" :select-portal-target="selectPortalTarget" />
|
||||||
|
|
||||||
|
<RawMaterialSharedPriceSection
|
||||||
|
:form="form"
|
||||||
|
:prices="prices"
|
||||||
|
:use-same-price="useSamePrice"
|
||||||
|
@toggle-use-same-price="toggleUseSamePrice"
|
||||||
|
@set-shared-price="setSharedPrice"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RawMaterialVariantSection
|
||||||
|
v-for="(price, index) in prices"
|
||||||
|
:key="price.client_id"
|
||||||
|
:form="form"
|
||||||
|
:price="price"
|
||||||
|
:index="index"
|
||||||
|
:total-prices="prices.length"
|
||||||
|
:use-same-price="useSamePrice"
|
||||||
|
:price-errors="(clientId, field) => priceErrors(form, clientId, field)"
|
||||||
|
@remove="removePrice(price.client_id)"
|
||||||
|
@apply-price-to-all="applyPriceToAllVariants(price.client_id)"
|
||||||
|
@update:variant="setPriceField(price.client_id, 'variant', $event)"
|
||||||
|
@update:stock="setPriceField(price.client_id, 'stock', $event)"
|
||||||
|
@update:price="setPriceValue(price.client_id, $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FieldError :errors="formErrors(form, 'prices')" />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-2 border-t pt-4">
|
||||||
|
<Button type="button" variant="outline" @click="addPrice">
|
||||||
|
<Plus class="size-4" />
|
||||||
|
Tambah Varian
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button type="button" variant="outline" :disabled="loading" @click="open = false">
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form="quick-create-raw-material-form" :disabled="loading || isUploading">
|
||||||
|
<Save class="size-4" />
|
||||||
|
{{ isUploading ? 'Mengunggah...' : loading ? 'Menyimpan...' : 'Simpan' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="selectPortalTarget" class="contents"></div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@ -40,6 +40,8 @@ defineProps<{
|
|||||||
const printAfterSave = defineModel<boolean>('printAfterSave', { required: true });
|
const printAfterSave = defineModel<boolean>('printAfterSave', { required: true });
|
||||||
const selectedPaperSize = defineModel<'58mm' | '80mm'>('selectedPaperSize', { required: true });
|
const selectedPaperSize = defineModel<'58mm' | '80mm'>('selectedPaperSize', { required: true });
|
||||||
const photoState = defineModel<MediaUploadState>('photoState', { required: true });
|
const photoState = defineModel<MediaUploadState>('photoState', { required: true });
|
||||||
|
|
||||||
|
const isUploading = computed(() => photoState.value.pendingUploads > 0);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -126,8 +128,8 @@ const photoState = defineModel<MediaUploadState>('photoState', { required: true
|
|||||||
</div>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Button type="submit" class="w-full" :disabled="form.processing || cartEmpty">
|
<Button type="submit" class="w-full" :disabled="form.processing || cartEmpty || isUploading">
|
||||||
<Save class="size-4" />
|
<Save class="size-4" />
|
||||||
{{ form.processing ? 'Menyimpan...' : submitLabel }}
|
{{ isUploading ? 'Mengunggah...' : form.processing ? 'Menyimpan...' : submitLabel }}
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -38,6 +38,8 @@ const stock = ref('0');
|
|||||||
const media = ref<MediaUploadState>(createMediaUploadState());
|
const media = ref<MediaUploadState>(createMediaUploadState());
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const isUploading = computed(() => media.value.pendingUploads > 0);
|
||||||
|
|
||||||
watch(open, (isOpen) => {
|
watch(open, (isOpen) => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
variant.value = '';
|
variant.value = '';
|
||||||
@ -76,8 +78,8 @@ async function submit() {
|
|||||||
formData.append('price', String(Number.parseInt(parseRupiah(price.value), 10) || 0));
|
formData.append('price', String(Number.parseInt(parseRupiah(price.value), 10) || 0));
|
||||||
formData.append('stock', String(parseStockValue(stock.value)));
|
formData.append('stock', String(parseStockValue(stock.value)));
|
||||||
|
|
||||||
media.value.newFiles.forEach((file) => {
|
media.value.newFileS3Keys.forEach((key) => {
|
||||||
formData.append('photos[]', file);
|
formData.append('s3_keys[]', key);
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(storeNewVariantRoute.url(), {
|
const response = await fetch(storeNewVariantRoute.url(), {
|
||||||
@ -163,8 +165,8 @@ async function submit() {
|
|||||||
<Button type="button" variant="outline" @click="open = false">
|
<Button type="button" variant="outline" @click="open = false">
|
||||||
Batal
|
Batal
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" :disabled="loading || !variant || !price">
|
<Button type="submit" :disabled="loading || !variant || !price || isUploading">
|
||||||
{{ loading ? 'Menyimpan...' : 'Tambah Varian' }}
|
{{ isUploading ? 'Mengunggah...' : loading ? 'Menyimpan...' : 'Tambah Varian' }}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -29,6 +29,8 @@ defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const photoState = defineModel<MediaUploadState>('photoState', { required: true });
|
const photoState = defineModel<MediaUploadState>('photoState', { required: true });
|
||||||
|
|
||||||
|
const isUploading = computed(() => photoState.value.pendingUploads > 0);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -75,8 +77,8 @@ const photoState = defineModel<MediaUploadState>('photoState', { required: true
|
|||||||
:errors="formErrors(form, 'photos')"
|
:errors="formErrors(form, 'photos')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button type="submit" class="w-full" :disabled="form.processing || cartEmpty">
|
<Button type="submit" class="w-full" :disabled="form.processing || cartEmpty || isUploading">
|
||||||
<Save class="size-4" />
|
<Save class="size-4" />
|
||||||
{{ form.processing ? 'Menyimpan...' : submitLabel }}
|
{{ isUploading ? 'Mengunggah...' : form.processing ? 'Menyimpan...' : submitLabel }}
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -5,12 +5,16 @@ import { computed, ref } from 'vue';
|
|||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { useCan } from '@/composables/useCan';
|
||||||
import { useVariantList } from '@/composables/useVariantList';
|
import { useVariantList } from '@/composables/useVariantList';
|
||||||
import { appendMediaToFormData, createMediaUploadState } from '@/types/media';
|
import { appendMediaToFormData, createMediaUploadState } from '@/types/media';
|
||||||
import type { CategoryOption, ProductFormInitialData, ProductVariantFormItem } from '@/types/product';
|
import type { CategoryOption, ProductFormInitialData, ProductVariantFormItem } from '@/types/product';
|
||||||
import ProductInfoSection from './ProductInfoSection.vue';
|
import ProductInfoSection from './ProductInfoSection.vue';
|
||||||
import ProductVariantSection from './ProductVariantSection.vue';
|
import ProductVariantSection from './ProductVariantSection.vue';
|
||||||
|
|
||||||
|
const { hasRole } = useCan();
|
||||||
|
const showPrices = !hasRole('admin-bahan-baku');
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
categories: CategoryOption[];
|
categories: CategoryOption[];
|
||||||
@ -29,6 +33,10 @@ function createClientId(): string {
|
|||||||
return `variant-${crypto.randomUUID()}`;
|
return `variant-${crypto.randomUUID()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isUploading = computed(() =>
|
||||||
|
variants.value.some((v) => v.media.pendingUploads > 0)
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
items: variants,
|
items: variants,
|
||||||
addItem: addVariant,
|
addItem: addVariant,
|
||||||
@ -156,7 +164,7 @@ function buildFormData(): FormData {
|
|||||||
formData.append(`variants[${index}][name]`, variant.name.trim());
|
formData.append(`variants[${index}][name]`, variant.name.trim());
|
||||||
formData.append(`variants[${index}][stock]`, String(Number.parseInt(String(variant.stock), 10) || 0));
|
formData.append(`variants[${index}][stock]`, String(Number.parseInt(String(variant.stock), 10) || 0));
|
||||||
formData.append(`variants[${index}][retail_stock]`, String(Number.parseInt(String(variant.retail_stock), 10) || 0));
|
formData.append(`variants[${index}][retail_stock]`, String(Number.parseInt(String(variant.retail_stock), 10) || 0));
|
||||||
if (variant.prices) {
|
if (variant.prices && showPrices) {
|
||||||
Object.entries(variant.prices as Record<string, string>).forEach(([type, value]) => {
|
Object.entries(variant.prices as Record<string, string>).forEach(([type, value]) => {
|
||||||
formData.append(`variants[${index}][prices][${type}]`, String(Number.parseInt(value, 10) || 0));
|
formData.append(`variants[${index}][prices][${type}]`, String(Number.parseInt(value, 10) || 0));
|
||||||
});
|
});
|
||||||
@ -228,9 +236,9 @@ function submit() {
|
|||||||
Tambah Varian
|
Tambah Varian
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="submit" :disabled="form.processing">
|
<Button type="submit" :disabled="form.processing || isUploading">
|
||||||
<Save class="size-4" />
|
<Save class="size-4" />
|
||||||
{{ form.processing ? 'Menyimpan...' : submitLabel }}
|
{{ isUploading ? 'Mengunggah...' : form.processing ? 'Menyimpan...' : submitLabel }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
|
||||||
import { Trash2, Copy, Clipboard, Check } from '@lucide/vue';
|
import { Trash2, Copy, Clipboard, Check } from '@lucide/vue';
|
||||||
|
import { ref } from 'vue';
|
||||||
import { NumberInput } from '@/components/form/number-input';
|
import { NumberInput } from '@/components/form/number-input';
|
||||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||||
import MediaDropzone from '@/components/media/MediaDropzone.vue';
|
import MediaDropzone from '@/components/media/MediaDropzone.vue';
|
||||||
@ -14,10 +14,14 @@ import {
|
|||||||
FieldSet,
|
FieldSet,
|
||||||
} from '@/components/ui/field';
|
} from '@/components/ui/field';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { useCan } from '@/composables/useCan';
|
||||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||||
|
import type { MediaUploadState } from '@/types/media';
|
||||||
import { PRICE_TYPES, PRICE_TYPE_LABELS } from '@/types/product';
|
import { PRICE_TYPES, PRICE_TYPE_LABELS } from '@/types/product';
|
||||||
import type { ProductVariantFormItem } from '@/types/product';
|
import type { ProductVariantFormItem } from '@/types/product';
|
||||||
import type { MediaUploadState } from '@/types/media';
|
|
||||||
|
const { hasRole } = useCan();
|
||||||
|
const showPrices = !hasRole('admin-bahan-baku');
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
form: {
|
form: {
|
||||||
@ -83,14 +87,8 @@ function updatePrice(type: string, value: string) {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader class="flex flex-row items-start justify-between gap-4">
|
<CardHeader class="flex flex-row items-start justify-between gap-4">
|
||||||
<CardTitle>Varian {{ index + 1 }}</CardTitle>
|
<CardTitle>Varian {{ index + 1 }}</CardTitle>
|
||||||
<Button
|
<Button v-if="canRemove" type="button" variant="outline" size="icon"
|
||||||
v-if="canRemove"
|
class="text-destructive hover:text-destructive size-8" @click="emit('remove')">
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
class="text-destructive hover:text-destructive size-8"
|
|
||||||
@click="emit('remove')"
|
|
||||||
>
|
|
||||||
<Trash2 class="size-4" />
|
<Trash2 class="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@ -101,75 +99,53 @@ function updatePrice(type: string, value: string) {
|
|||||||
<FieldLabel :for="`variant_name_${variant.client_id}`" required>
|
<FieldLabel :for="`variant_name_${variant.client_id}`" required>
|
||||||
Nama Varian
|
Nama Varian
|
||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
<Input
|
<Input :id="`variant_name_${variant.client_id}`" :model-value="variant.name" type="text"
|
||||||
:id="`variant_name_${variant.client_id}`"
|
placeholder="Masukkan nama varian" :maxlength="FIELD_LIMITS.variantName"
|
||||||
:model-value="variant.name"
|
@update:model-value="emit('update:name', String($event))" />
|
||||||
type="text"
|
|
||||||
placeholder="Masukkan nama varian"
|
|
||||||
:maxlength="FIELD_LIMITS.variantName"
|
|
||||||
@update:model-value="emit('update:name', String($event))"
|
|
||||||
/>
|
|
||||||
<FieldError :errors="variantErrors(variant.client_id, 'name')" />
|
<FieldError :errors="variantErrors(variant.client_id, 'name')" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel :for="`variant_stock_${variant.client_id}`" required>
|
<FieldLabel :for="`variant_stock_${variant.client_id}`" required>
|
||||||
Stok
|
Stok
|
||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
<NumberInput
|
<NumberInput :id="`variant_stock_${variant.client_id}`" :model-value="variant.stock"
|
||||||
:id="`variant_stock_${variant.client_id}`"
|
@update:model-value="emit('update:stock', String($event))" />
|
||||||
:model-value="variant.stock"
|
|
||||||
@update:model-value="emit('update:stock', String($event))"
|
|
||||||
/>
|
|
||||||
<FieldError :errors="variantErrors(variant.client_id, 'stock')" />
|
<FieldError :errors="variantErrors(variant.client_id, 'stock')" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel :for="`variant_retail_stock_${variant.client_id}`" required>
|
<FieldLabel :for="`variant_retail_stock_${variant.client_id}`" required>
|
||||||
Stok Ecer
|
Stok Ecer
|
||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
<NumberInput
|
<NumberInput :id="`variant_retail_stock_${variant.client_id}`"
|
||||||
:id="`variant_retail_stock_${variant.client_id}`"
|
|
||||||
:model-value="variant.retail_stock"
|
:model-value="variant.retail_stock"
|
||||||
@update:model-value="emit('update:retail-stock', String($event))"
|
@update:model-value="emit('update:retail-stock', String($event))" />
|
||||||
/>
|
|
||||||
<FieldError :errors="variantErrors(variant.client_id, 'retail_stock')" />
|
<FieldError :errors="variantErrors(variant.client_id, 'retail_stock')" />
|
||||||
</Field>
|
</Field>
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
|
|
||||||
<div class="border-t pt-4 mt-2">
|
<div v-if="showPrices" class="border-t pt-4 mt-2">
|
||||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-3">
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-3">
|
||||||
<h4 class="text-sm font-semibold">Harga Varian</h4>
|
<h4 class="text-sm font-semibold">Harga Varian</h4>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button type="button" variant="outline" size="sm"
|
||||||
type="button"
|
class="h-8 px-2.5 text-xs flex items-center gap-1.5" @click="handleCopy">
|
||||||
variant="outline"
|
<Check v-if="isCopied"
|
||||||
size="sm"
|
class="size-3.5 text-green-600 animate-in fade-in zoom-in-50 duration-200" />
|
||||||
class="h-8 px-2.5 text-xs flex items-center gap-1.5"
|
|
||||||
@click="handleCopy"
|
|
||||||
>
|
|
||||||
<Check v-if="isCopied" class="size-3.5 text-green-600 animate-in fade-in zoom-in-50 duration-200" />
|
|
||||||
<Copy v-else class="size-3.5" />
|
<Copy v-else class="size-3.5" />
|
||||||
{{ isCopied ? 'Berhasil' : 'Salin Harga' }}
|
{{ isCopied ? 'Berhasil' : 'Salin Harga' }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button type="button" variant="outline" size="sm"
|
||||||
type="button"
|
class="h-8 px-2.5 text-xs flex items-center gap-1.5" :disabled="!hasCopiedPrices"
|
||||||
variant="outline"
|
@click="handlePaste">
|
||||||
size="sm"
|
<Check v-if="isPasted"
|
||||||
class="h-8 px-2.5 text-xs flex items-center gap-1.5"
|
class="size-3.5 text-green-600 animate-in fade-in zoom-in-50 duration-200" />
|
||||||
:disabled="!hasCopiedPrices"
|
|
||||||
@click="handlePaste"
|
|
||||||
>
|
|
||||||
<Check v-if="isPasted" class="size-3.5 text-green-600 animate-in fade-in zoom-in-50 duration-200" />
|
|
||||||
<Clipboard v-else class="size-3.5" />
|
<Clipboard v-else class="size-3.5" />
|
||||||
{{ isPasted ? 'Berhasil' : 'Tempel Harga' }}
|
{{ isPasted ? 'Berhasil' : 'Tempel Harga' }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button type="button" variant="outline" size="sm"
|
||||||
type="button"
|
class="h-8 px-2.5 text-xs flex items-center gap-1.5" @click="handleApply">
|
||||||
variant="outline"
|
<Check v-if="isApplied"
|
||||||
size="sm"
|
class="size-3.5 text-green-600 animate-in fade-in zoom-in-50 duration-200" />
|
||||||
class="h-8 px-2.5 text-xs flex items-center gap-1.5"
|
|
||||||
@click="handleApply"
|
|
||||||
>
|
|
||||||
<Check v-if="isApplied" class="size-3.5 text-green-600 animate-in fade-in zoom-in-50 duration-200" />
|
|
||||||
<Check v-else class="size-3.5" />
|
<Check v-else class="size-3.5" />
|
||||||
{{ isApplied ? 'Berhasil' : 'Terapkan ke Semua' }}
|
{{ isApplied ? 'Berhasil' : 'Terapkan ke Semua' }}
|
||||||
</Button>
|
</Button>
|
||||||
@ -180,26 +156,18 @@ function updatePrice(type: string, value: string) {
|
|||||||
<FieldLabel class="text-xs" :for="`variant_price_${variant.client_id}_${type}`" required>
|
<FieldLabel class="text-xs" :for="`variant_price_${variant.client_id}_${type}`" required>
|
||||||
{{ PRICE_TYPE_LABELS[type] }}
|
{{ PRICE_TYPE_LABELS[type] }}
|
||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
<RupiahInput
|
<RupiahInput :id="`variant_price_${variant.client_id}_${type}`"
|
||||||
:id="`variant_price_${variant.client_id}_${type}`"
|
|
||||||
:model-value="(variant.prices as Record<string, string>)?.[type] ?? '0'"
|
:model-value="(variant.prices as Record<string, string>)?.[type] ?? '0'"
|
||||||
@update:model-value="updatePrice(type, $event)"
|
@update:model-value="updatePrice(type, $event)" />
|
||||||
/>
|
|
||||||
<FieldError :errors="variantErrors(variant.client_id, `prices.${type}`)" />
|
<FieldError :errors="variantErrors(variant.client_id, `prices.${type}`)" />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<MediaDropzone
|
<MediaDropzone :id="`variant_images_${variant.client_id}`" :model-value="variant.media"
|
||||||
:id="`variant_images_${variant.client_id}`"
|
label="Foto Varian" :max-files="5" required :errors="variantErrors(variant.client_id, 'images')"
|
||||||
:model-value="variant.media"
|
@update:model-value="emit('update:media', $event)" />
|
||||||
label="Foto Varian"
|
|
||||||
:max-files="5"
|
|
||||||
required
|
|
||||||
:errors="variantErrors(variant.client_id, 'images')"
|
|
||||||
@update:model-value="emit('update:media', $event)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -86,8 +86,10 @@ function openVerificationDetail(requestId: number | undefined) {
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="product.has_pending_request" class="text-sm text-amber-600">
|
<p v-if="product.has_pending_request" class="text-sm text-amber-600">
|
||||||
{{ product.pending_request_submitted_by_name }} mengajukan {{ product.pending_request_action_label?.toLowerCase() }} produk ini —
|
{{ product.pending_request_submitted_by_name }} mengajukan {{
|
||||||
<button type="button" class="underline-offset-2 hover:underline" @click="openVerificationDetail(product.pending_request_id)">lihat</button>
|
product.pending_request_action_label?.toLowerCase() }} produk ini —
|
||||||
|
<button type="button" class="underline-offset-2 hover:underline"
|
||||||
|
@click="openVerificationDetail(product.pending_request_id)">lihat</button>
|
||||||
</p>
|
</p>
|
||||||
<div v-if="product.categories.length" class="flex flex-wrap gap-1">
|
<div v-if="product.categories.length" class="flex flex-wrap gap-1">
|
||||||
<Badge v-for="category in product.categories" :key="category.id" variant="outline">
|
<Badge v-for="category in product.categories" :key="category.id" variant="outline">
|
||||||
@ -142,7 +144,7 @@ function openVerificationDetail(requestId: number | undefined) {
|
|||||||
{{ variant.name }}
|
{{ variant.name }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<MediaThumbnailCell :items="variant.images ?? []" />
|
<MediaThumbnailCell :items="variant.images ?? []" :max-visible="1" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="tabular-nums">
|
<TableCell class="tabular-nums">
|
||||||
{{ variant.stock_formatted }}
|
{{ variant.stock_formatted }}
|
||||||
|
|||||||
@ -46,6 +46,10 @@ function createClientId(): string {
|
|||||||
return `price-${crypto.randomUUID()}`;
|
return `price-${crypto.randomUUID()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isUploading = computed(() =>
|
||||||
|
prices.value.some((p) => p.media.pendingUploads > 0)
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
items: prices,
|
items: prices,
|
||||||
removeItem: removePrice,
|
removeItem: removePrice,
|
||||||
@ -218,9 +222,9 @@ function submit() {
|
|||||||
Tambah Varian
|
Tambah Varian
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="submit" :disabled="form.processing">
|
<Button type="submit" :disabled="form.processing || isUploading">
|
||||||
<Save class="size-4" />
|
<Save class="size-4" />
|
||||||
{{ form.processing ? 'Menyimpan...' : submitLabel }}
|
{{ isUploading ? 'Mengunggah...' : form.processing ? 'Menyimpan...' : submitLabel }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -23,6 +23,7 @@ defineProps<{
|
|||||||
form: FormWithErrors & { name: string; unit: string };
|
form: FormWithErrors & { name: string; unit: string };
|
||||||
units: EnumOption[];
|
units: EnumOption[];
|
||||||
method: 'post' | 'put';
|
method: 'post' | 'put';
|
||||||
|
selectPortalTarget?: HTMLElement;
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -52,7 +53,7 @@ defineProps<{
|
|||||||
<SelectTrigger id="unit" class="w-full">
|
<SelectTrigger id="unit" class="w-full">
|
||||||
<SelectValue placeholder="Pilih satuan" />
|
<SelectValue placeholder="Pilih satuan" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent :to="selectPortalTarget">
|
||||||
<SelectItem v-for="option in units" :key="option.value" :value="option.value">
|
<SelectItem v-for="option in units" :key="option.value" :value="option.value">
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|||||||
@ -126,7 +126,7 @@ function openVerificationDetail(requestId: number | undefined) {
|
|||||||
{{ price.variant }}
|
{{ price.variant }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<MediaThumbnailCell :items="price.images ?? []" />
|
<MediaThumbnailCell :items="price.images ?? []" :max-visible="1" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="tabular-nums">
|
<TableCell class="tabular-nums">
|
||||||
{{ price.stock_formatted }}
|
{{ price.stock_formatted }}
|
||||||
|
|||||||
@ -34,21 +34,27 @@ const galleryState = ref<MediaUploadState>(
|
|||||||
);
|
);
|
||||||
const isSubmitting = ref(false);
|
const isSubmitting = ref(false);
|
||||||
|
|
||||||
|
const isUploading = computed(() =>
|
||||||
|
heroImage.value.pendingUploads > 0 ||
|
||||||
|
aboutImage.value.pendingUploads > 0 ||
|
||||||
|
galleryState.value.pendingUploads > 0
|
||||||
|
);
|
||||||
|
|
||||||
function submit() {
|
function submit() {
|
||||||
isSubmitting.value = true;
|
isSubmitting.value = true;
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
heroImage.value.newFiles.forEach((file) => {
|
heroImage.value.newFileS3Keys.forEach((key) => {
|
||||||
formData.append('hero_image', file);
|
formData.append('hero_image_s3_key', key);
|
||||||
});
|
});
|
||||||
|
|
||||||
aboutImage.value.newFiles.forEach((file) => {
|
aboutImage.value.newFileS3Keys.forEach((key) => {
|
||||||
formData.append('about_image', file);
|
formData.append('about_image_s3_key', key);
|
||||||
});
|
});
|
||||||
|
|
||||||
galleryState.value.newFiles.forEach((file) => {
|
galleryState.value.newFileS3Keys.forEach((key) => {
|
||||||
formData.append('gallery_images[]', file);
|
formData.append('gallery_s3_keys[]', key);
|
||||||
});
|
});
|
||||||
|
|
||||||
galleryState.value.removeIds.forEach((id) => {
|
galleryState.value.removeIds.forEach((id) => {
|
||||||
@ -102,9 +108,9 @@ function submit() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<Button type="submit" :disabled="isSubmitting">
|
<Button type="submit" :disabled="isSubmitting || isUploading">
|
||||||
<Save class="size-4" />
|
<Save class="size-4" />
|
||||||
{{ isSubmitting ? 'Menyimpan...' : 'Simpan' }}
|
{{ isUploading ? 'Mengunggah...' : isSubmitting ? 'Menyimpan...' : 'Simpan' }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -32,6 +32,12 @@ const logoState = ref<MediaUploadState>(createMediaUploadState());
|
|||||||
const faviconState = ref<MediaUploadState>(createMediaUploadState());
|
const faviconState = ref<MediaUploadState>(createMediaUploadState());
|
||||||
const loginCoverState = ref<MediaUploadState>(createMediaUploadState());
|
const loginCoverState = ref<MediaUploadState>(createMediaUploadState());
|
||||||
|
|
||||||
|
const isUploading = computed(() =>
|
||||||
|
logoState.value.pendingUploads > 0 ||
|
||||||
|
faviconState.value.pendingUploads > 0 ||
|
||||||
|
loginCoverState.value.pendingUploads > 0
|
||||||
|
);
|
||||||
|
|
||||||
function buildFormData(): FormData {
|
function buildFormData(): FormData {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
@ -41,16 +47,16 @@ function buildFormData(): FormData {
|
|||||||
formData.append('phone', form.phone);
|
formData.append('phone', form.phone);
|
||||||
formData.append('address', form.address);
|
formData.append('address', form.address);
|
||||||
|
|
||||||
logoState.value.newFiles.forEach((file) => {
|
logoState.value.newFileS3Keys.forEach((key) => {
|
||||||
formData.append('logo', file);
|
formData.append('logo_s3_key', key);
|
||||||
});
|
});
|
||||||
|
|
||||||
faviconState.value.newFiles.forEach((file) => {
|
faviconState.value.newFileS3Keys.forEach((key) => {
|
||||||
formData.append('favicon', file);
|
formData.append('favicon_s3_key', key);
|
||||||
});
|
});
|
||||||
|
|
||||||
loginCoverState.value.newFiles.forEach((file) => {
|
loginCoverState.value.newFileS3Keys.forEach((key) => {
|
||||||
formData.append('login_cover', file);
|
formData.append('login_cover_s3_key', key);
|
||||||
});
|
});
|
||||||
|
|
||||||
return formData;
|
return formData;
|
||||||
@ -138,9 +144,9 @@ function submit() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<Button type="submit" :disabled="form.processing">
|
<Button type="submit" :disabled="form.processing || isUploading">
|
||||||
<Save class="size-4" />
|
<Save class="size-4" />
|
||||||
{{ form.processing ? 'Menyimpan...' : 'Simpan' }}
|
{{ isUploading ? 'Mengunggah...' : form.processing ? 'Menyimpan...' : 'Simpan' }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,14 +7,18 @@ export type MediaItem = {
|
|||||||
export type MediaUploadState = {
|
export type MediaUploadState = {
|
||||||
existing: MediaItem[];
|
existing: MediaItem[];
|
||||||
newFiles: File[];
|
newFiles: File[];
|
||||||
|
newFileS3Keys: string[];
|
||||||
removeIds: number[];
|
removeIds: number[];
|
||||||
|
pendingUploads: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createMediaUploadState(existing: MediaItem[] = []): MediaUploadState {
|
export function createMediaUploadState(existing: MediaItem[] = []): MediaUploadState {
|
||||||
return {
|
return {
|
||||||
existing: [...existing],
|
existing: [...existing],
|
||||||
newFiles: [],
|
newFiles: [],
|
||||||
|
newFileS3Keys: [],
|
||||||
removeIds: [],
|
removeIds: [],
|
||||||
|
pendingUploads: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -27,8 +31,8 @@ export function appendMediaToFormData(
|
|||||||
prefix: string,
|
prefix: string,
|
||||||
state: MediaUploadState,
|
state: MediaUploadState,
|
||||||
): void {
|
): void {
|
||||||
state.newFiles.forEach((file) => {
|
state.newFileS3Keys.forEach((key) => {
|
||||||
formData.append(`${prefix}[images][]`, file);
|
formData.append(`${prefix}[s3_keys][]`, key);
|
||||||
});
|
});
|
||||||
|
|
||||||
state.removeIds.forEach((id) => {
|
state.removeIds.forEach((id) => {
|
||||||
@ -51,8 +55,8 @@ export function appendPhotosToFormData(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function appendRootPhotosToFormData(formData: FormData, state: MediaUploadState): void {
|
export function appendRootPhotosToFormData(formData: FormData, state: MediaUploadState): void {
|
||||||
state.newFiles.forEach((file) => {
|
state.newFileS3Keys.forEach((key) => {
|
||||||
formData.append('photos[]', file);
|
formData.append('s3_keys[]', key);
|
||||||
});
|
});
|
||||||
|
|
||||||
state.removeIds.forEach((id) => {
|
state.removeIds.forEach((id) => {
|
||||||
|
|||||||
@ -29,6 +29,7 @@
|
|||||||
use App\Http\Controllers\Admin\Master\ProductController;
|
use App\Http\Controllers\Admin\Master\ProductController;
|
||||||
use App\Http\Controllers\Admin\Master\RawMaterialController;
|
use App\Http\Controllers\Admin\Master\RawMaterialController;
|
||||||
use App\Http\Controllers\Admin\Master\SupplierController;
|
use App\Http\Controllers\Admin\Master\SupplierController;
|
||||||
|
use App\Http\Controllers\Admin\Media\PresignedUploadController;
|
||||||
use App\Http\Controllers\Admin\NotificationController;
|
use App\Http\Controllers\Admin\NotificationController;
|
||||||
use App\Http\Controllers\Admin\System\ActivityLogController;
|
use App\Http\Controllers\Admin\System\ActivityLogController;
|
||||||
use App\Http\Controllers\Admin\System\RoleController;
|
use App\Http\Controllers\Admin\System\RoleController;
|
||||||
@ -49,6 +50,9 @@
|
|||||||
Route::middleware(['auth', 'permission:'.Permission::DASHBOARD_VIEW->value])->group(function () {
|
Route::middleware(['auth', 'permission:'.Permission::DASHBOARD_VIEW->value])->group(function () {
|
||||||
Route::post('/auth/logout', [LogoutController::class, 'store'])->name('logout');
|
Route::post('/auth/logout', [LogoutController::class, 'store'])->name('logout');
|
||||||
|
|
||||||
|
// Media presigned upload
|
||||||
|
Route::post('/admin/media/presign', [PresignedUploadController::class, 'presign'])->name('media.presign');
|
||||||
|
|
||||||
// Push Notifications
|
// Push Notifications
|
||||||
Route::post('/push-subscriptions', [PushSubscriptionController::class, 'store'])->name('push_subscriptions.store');
|
Route::post('/push-subscriptions', [PushSubscriptionController::class, 'store'])->name('push_subscriptions.store');
|
||||||
Route::delete('/push-subscriptions', [PushSubscriptionController::class, 'destroy'])->name('push_subscriptions.destroy');
|
Route::delete('/push-subscriptions', [PushSubscriptionController::class, 'destroy'])->name('push_subscriptions.destroy');
|
||||||
@ -330,6 +334,14 @@
|
|||||||
->middleware('permission:'.Permission::CUTTINGS_CREATE->value)
|
->middleware('permission:'.Permission::CUTTINGS_CREATE->value)
|
||||||
->name('draft_results.destroy');
|
->name('draft_results.destroy');
|
||||||
|
|
||||||
|
Route::post('quick-create-raw-material', [CuttingDraftItemController::class, 'quickCreateRawMaterial'])
|
||||||
|
->middleware('permission:'.Permission::CUTTINGS_CREATE->value)
|
||||||
|
->name('quick_create_raw_material');
|
||||||
|
|
||||||
|
Route::post('quick-create-product', [CuttingDraftItemController::class, 'quickCreateProduct'])
|
||||||
|
->middleware('permission:'.Permission::CUTTINGS_CREATE->value)
|
||||||
|
->name('quick_create_product');
|
||||||
|
|
||||||
Route::post('/', [CuttingController::class, 'store'])
|
Route::post('/', [CuttingController::class, 'store'])
|
||||||
->middleware('permission:'.Permission::CUTTINGS_VIEW->value.'|'.Permission::CUTTINGS_CREATE->value)
|
->middleware('permission:'.Permission::CUTTINGS_VIEW->value.'|'.Permission::CUTTINGS_CREATE->value)
|
||||||
->name('store');
|
->name('store');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user