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_CUSTOMERS = 'analysis.top_customers';
|
||||
case ANALYSIS_TOP_PRODUCTS = 'analysis.top_products';
|
||||
case ANALYSIS_MARKETING_SALES = 'analysis.marketing_sales';
|
||||
|
||||
case EMPLOYEES_VIEW = 'employees.view';
|
||||
case EMPLOYEES_CREATE = 'employees.create';
|
||||
@ -172,6 +173,7 @@ public function label(): string
|
||||
self::ANALYSIS_TOP_SUPPLIERS => 'Lihat Top 5 Supplier',
|
||||
self::ANALYSIS_TOP_CUSTOMERS => 'Lihat Top 5 Pelanggan',
|
||||
self::ANALYSIS_TOP_PRODUCTS => 'Lihat Top 5 Produk',
|
||||
self::ANALYSIS_MARKETING_SALES => 'Lihat Analisa Penjualan Marketing',
|
||||
|
||||
self::EMPLOYEES_VIEW => 'Lihat 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_EXPENSE, self::ANALYSIS_BUSY_HOURS, self::ANALYSIS_PROFIT_ORDERS,
|
||||
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_DELETE, self::EMPLOYEES_RESET_PASSWORD, self::EMPLOYEES_TOGGLE_STATUS => 'Pegawai',
|
||||
self::ATTENDANCES_VIEW, self::ATTENDANCES_CREATE, self::ATTENDANCES_DELETE,
|
||||
|
||||
@ -78,6 +78,7 @@ public function permissions(): array
|
||||
Permission::ANALYSIS_PROFIT_MARGIN,
|
||||
Permission::ANALYSIS_TOP_CUSTOMERS,
|
||||
Permission::ANALYSIS_TOP_PRODUCTS,
|
||||
Permission::ANALYSIS_MARKETING_SALES,
|
||||
|
||||
Permission::EMPLOYEES_VIEW,
|
||||
|
||||
@ -130,9 +131,11 @@ public function permissions(): array
|
||||
Permission::ANALYSIS_REVENUE,
|
||||
Permission::ANALYSIS_EXPENSE,
|
||||
Permission::ANALYSIS_BUSY_HOURS,
|
||||
Permission::ANALYSIS_PROFIT_ORDERS,
|
||||
Permission::ANALYSIS_TOP_SUPPLIERS,
|
||||
Permission::ANALYSIS_TOP_CUSTOMERS,
|
||||
Permission::ANALYSIS_TOP_PRODUCTS,
|
||||
Permission::ANALYSIS_MARKETING_SALES,
|
||||
|
||||
Permission::STOCKS_VIEW,
|
||||
|
||||
|
||||
@ -41,6 +41,7 @@ public function index(Request $request): Response
|
||||
'topSuppliers' => $this->analysisService->getTopSuppliers($startDate, $endDate),
|
||||
'topCustomers' => $this->analysisService->getTopCustomers($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;
|
||||
|
||||
use App\Enums\CuttingStatus;
|
||||
use App\Enums\RawMaterialUnit;
|
||||
use App\Http\Controllers\Concerns\FlashesEntityMessage;
|
||||
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\Manage\CuttingRequest;
|
||||
use App\Http\Requests\Admin\Manage\CuttingStatusTransitionRequest;
|
||||
use App\Models\Category;
|
||||
use App\Models\Cutting;
|
||||
use App\Services\Manage\CuttingService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@ -44,6 +46,8 @@ public function create(Request $request): Response
|
||||
'productCatalog' => $this->cuttingService->productCatalog(user: $user),
|
||||
'draftMaterials' => $this->cuttingService->draftMaterialsForUser($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),
|
||||
'rawMaterialCatalog' => $this->cuttingService->rawMaterialCatalog($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\Requests\Admin\Manage\CuttingDraftMaterialRequest;
|
||||
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\RawMaterialPrice;
|
||||
use App\Services\Manage\CuttingService;
|
||||
@ -44,4 +46,18 @@ public function destroyResult(Request $request, ProductVariant $productVariant):
|
||||
|
||||
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)],
|
||||
'birth_date' => ['nullable', 'date', 'before:today'],
|
||||
'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.*' => ['integer'],
|
||||
];
|
||||
@ -46,7 +46,7 @@ public function attributes(): array
|
||||
'gender' => 'Jenis Kelamin',
|
||||
'birth_date' => 'Tanggal Lahir',
|
||||
'address' => 'Alamat',
|
||||
'profile_photo' => 'Foto Profil',
|
||||
'profile_s3_key' => 'Foto Profil',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,6 +38,7 @@ public function rules(): array
|
||||
'address' => ['nullable', 'string'],
|
||||
'role' => ['required', Rule::in(Role::assignableValues())],
|
||||
'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.*' => ['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;
|
||||
|
||||
use App\Enums\Permission;
|
||||
use App\Http\Requests\Concerns\ValidatesMediaUploads;
|
||||
use App\Http\Requests\Concerns\HasProductVariantRules;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ProductRequest extends FormRequest
|
||||
{
|
||||
use ValidatesMediaUploads;
|
||||
use HasProductVariantRules;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
@ -32,27 +32,7 @@ public function rules(): array
|
||||
'category_ids' => ['required', 'array', 'min:1'],
|
||||
'category_ids.*' => ['integer', Rule::exists('categories', 'id')->whereNull('deleted_at')],
|
||||
|
||||
'variants' => ['required', 'array', 'min:1'],
|
||||
'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(),
|
||||
...$this->productVariantRules(productId: $this->route('product')?->id),
|
||||
];
|
||||
}
|
||||
|
||||
@ -66,20 +46,7 @@ public function attributes(): array
|
||||
'description' => 'Deskripsi',
|
||||
'category_ids' => 'Kategori',
|
||||
'category_ids.*' => 'Kategori',
|
||||
'variants' => 'Varian',
|
||||
'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'),
|
||||
...$this->productVariantAttributes(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,13 +4,13 @@
|
||||
|
||||
use App\Enums\Permission;
|
||||
use App\Enums\RawMaterialUnit;
|
||||
use App\Http\Requests\Concerns\ValidatesMediaUploads;
|
||||
use App\Http\Requests\Concerns\HasRawMaterialPriceRules;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class RawMaterialRequest extends FormRequest
|
||||
{
|
||||
use ValidatesMediaUploads;
|
||||
use HasRawMaterialPriceRules;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
@ -30,18 +30,7 @@ public function rules(): array
|
||||
'name' => ['required', 'string', 'max:200'],
|
||||
'unit' => ['required', Rule::enum(RawMaterialUnit::class)],
|
||||
|
||||
'prices' => ['required', 'array', 'min:1'],
|
||||
'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'),
|
||||
...$this->rawMaterialPriceRules(rawMaterialId: $this->route('rawMaterial')?->id),
|
||||
];
|
||||
}
|
||||
|
||||
@ -53,11 +42,7 @@ public function attributes(): array
|
||||
return [
|
||||
'name' => 'Nama Bahan Baku',
|
||||
'unit' => 'Satuan',
|
||||
'prices' => 'Varian',
|
||||
'prices.*.variant' => 'Nama Varian',
|
||||
'prices.*.price' => 'Harga',
|
||||
'prices.*.stock' => 'Stok',
|
||||
...$this->variantImageAttributes('prices', 'Foto Varian'),
|
||||
...$this->rawMaterialPriceAttributes(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,9 +19,11 @@ public function rules(): array
|
||||
{
|
||||
return [
|
||||
'hero_image' => ['nullable', 'image', 'max:5120'],
|
||||
'hero_image_s3_key' => ['nullable', 'string'],
|
||||
'about_image' => ['nullable', 'image', 'max:5120'],
|
||||
'gallery_images' => ['nullable', 'array', 'max:10'],
|
||||
'gallery_images.*' => ['image', 'max:5120'],
|
||||
'about_image_s3_key' => ['nullable', 'string'],
|
||||
'gallery_s3_keys' => ['nullable', 'array', 'max:10'],
|
||||
'gallery_s3_keys.*' => ['required', 'string'],
|
||||
'gallery_images_remove' => ['nullable', 'array'],
|
||||
'gallery_images_remove.*' => ['integer'],
|
||||
];
|
||||
@ -34,9 +36,11 @@ public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'hero_image' => 'Foto Hero',
|
||||
'hero_image_s3_key' => 'Foto Hero',
|
||||
'about_image' => 'Foto Tentang Kami',
|
||||
'gallery_images' => 'Foto Lookbook',
|
||||
'gallery_images.*' => 'Foto Lookbook',
|
||||
'about_image_s3_key' => 'Foto Tentang Kami',
|
||||
'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],
|
||||
'address' => ['required', 'string'],
|
||||
'logo' => ['nullable', 'image', 'max:2048'],
|
||||
'logo_s3_key' => ['nullable', 'string'],
|
||||
'favicon' => ['nullable', 'image', 'max:1024'],
|
||||
'favicon_s3_key' => ['nullable', 'string'],
|
||||
'login_cover' => ['nullable', 'image', 'max:5120'],
|
||||
'login_cover_s3_key' => ['nullable', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
@ -42,8 +45,11 @@ public function attributes(): array
|
||||
'phone' => 'nomor telepon',
|
||||
'address' => 'alamat',
|
||||
'logo' => 'logo',
|
||||
'logo_s3_key' => 'logo',
|
||||
'favicon' => 'favicon',
|
||||
'favicon_s3_key' => 'favicon',
|
||||
'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
|
||||
{
|
||||
return [
|
||||
$prefix => ['nullable', 'array', "max:{$max}"],
|
||||
"{$prefix}.*" => ['image', 'max:5120'],
|
||||
's3_keys' => ['nullable', 'array', "max:{$max}"],
|
||||
's3_keys.*' => ['required', 'string'],
|
||||
'remove_media_ids' => ['nullable', 'array'],
|
||||
'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
|
||||
{
|
||||
return [
|
||||
"{$variantsKey}.*.images" => ['nullable', 'array', "max:{$max}"],
|
||||
"{$variantsKey}.*.images.*" => ['image', 'max:5120'],
|
||||
"{$variantsKey}.*.s3_keys" => ['nullable', 'array', "max:{$max}"],
|
||||
"{$variantsKey}.*.s3_keys.*" => ['required', 'string'],
|
||||
"{$variantsKey}.*.remove_media_ids" => ['nullable', 'array'],
|
||||
"{$variantsKey}.*.remove_media_ids.*" => ['integer'],
|
||||
];
|
||||
@ -36,8 +36,8 @@ protected function variantImageRules(string $variantsKey = 'variants', int $max
|
||||
protected function photoUploadAttributes(string $label): array
|
||||
{
|
||||
return [
|
||||
'photos' => $label,
|
||||
'photos.*' => $label,
|
||||
's3_keys' => $label,
|
||||
's3_keys.*' => $label,
|
||||
'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
|
||||
{
|
||||
return [
|
||||
"{$variantsKey}.*.images" => $label,
|
||||
"{$variantsKey}.*.images.*" => $label,
|
||||
"{$variantsKey}.*.s3_keys" => $label,
|
||||
"{$variantsKey}.*.s3_keys.*" => $label,
|
||||
"{$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')
|
||||
->width(80)
|
||||
->height(80)
|
||||
->sharpen(10)
|
||||
->nonQueued();
|
||||
->sharpen(10);
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
@ -56,7 +57,19 @@ public function genderLabel(): Attribute
|
||||
public function profilePhotoUrl(): Attribute
|
||||
{
|
||||
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']] : [];
|
||||
$removeIds = $validated['remove_profile_photo_ids'] ?? [];
|
||||
$s3Keys = ! empty($validated['profile_s3_key']) ? [$validated['profile_s3_key']] : null;
|
||||
$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) {
|
||||
throw $e;
|
||||
|
||||
@ -418,6 +418,7 @@ private function syncPhotos(CashTransaction $transaction, array $validated): voi
|
||||
self::MAX_PHOTOS,
|
||||
required: true,
|
||||
errorKey: 'photos',
|
||||
s3Keys: $validated['s3_keys'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -97,7 +97,7 @@ public function create(array $validated, User $user): void
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'💰 Pengajuan Kasbon Baru',
|
||||
"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'),
|
||||
);
|
||||
}
|
||||
@ -127,7 +127,7 @@ public function update(EmployeeAdvance $employeeAdvance, array $validated, User
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'✏️ Kasbon Diperbarui',
|
||||
"Kasbon sebesar {$employeeAdvance->amount_formatted} telah diperbarui oleh {$user->profile?->full_name}.",
|
||||
['owner', 'developer'],
|
||||
['owner', 'developer', 'direktur'],
|
||||
route('admin.finance.employee_advances.index'),
|
||||
);
|
||||
}
|
||||
@ -142,7 +142,7 @@ public function delete(EmployeeAdvance $employeeAdvance, User $user): void
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'🗑️ Kasbon Dihapus',
|
||||
"Kasbon sebesar {$amount} dengan keterangan {$description} telah dihapus.",
|
||||
['owner', 'developer'],
|
||||
['owner', 'developer', 'direktur'],
|
||||
route('admin.finance.employee_advances.index'),
|
||||
);
|
||||
}
|
||||
|
||||
@ -92,7 +92,7 @@ public function create(array $validated, User $user): void
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'💸 Pengeluaran Baru',
|
||||
"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'),
|
||||
);
|
||||
}
|
||||
@ -134,7 +134,7 @@ public function update(Expense $expense, array $validated): void
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'✏️ Pengeluaran Diperbarui',
|
||||
"Pengeluaran dengan keterangan {$expense->description} diperbarui menjadi sebesar {$expense->amount_formatted}.",
|
||||
['owner', 'developer'],
|
||||
['owner', 'developer', 'direktur'],
|
||||
route('admin.finance.expenses.index'),
|
||||
);
|
||||
}
|
||||
@ -165,7 +165,7 @@ public function delete(Expense $expense): void
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'🗑️ Pengeluaran Dihapus',
|
||||
"Pengeluaran sebesar {$expense->amount_formatted} dengan keterangan {$expense->description} telah dihapus.",
|
||||
['owner', 'developer'],
|
||||
['owner', 'developer', 'direktur'],
|
||||
route('admin.finance.expenses.index'),
|
||||
);
|
||||
}
|
||||
@ -180,6 +180,7 @@ private function syncPhotos(Expense $expense, array $validated): void
|
||||
self::MAX_PHOTOS,
|
||||
required: true,
|
||||
errorKey: 'photos',
|
||||
s3Keys: $validated['s3_keys'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -100,7 +100,7 @@ public function checkIn(array $validated, User $user): void
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'⏰ Presensi Masuk',
|
||||
"Karyawan {$user->profile?->full_name} melakukan presensi masuk.",
|
||||
['owner', 'developer'],
|
||||
['owner', 'developer', 'direktur'],
|
||||
route('admin.hr.attendances.index'),
|
||||
);
|
||||
}
|
||||
@ -146,7 +146,7 @@ public function checkOut(array $validated, User $user): void
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'⏰ Presensi Pulang',
|
||||
"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'),
|
||||
);
|
||||
}
|
||||
@ -164,7 +164,7 @@ public function delete(Attendance $attendance): void
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'🗑️ Presensi Dihapus',
|
||||
"Data presensi {$employeeName} tanggal {$date} telah dihapus.",
|
||||
['owner', 'developer'],
|
||||
['owner', 'developer', 'direktur'],
|
||||
route('admin.hr.attendances.index'),
|
||||
);
|
||||
}
|
||||
|
||||
@ -223,10 +223,17 @@ public function delete(User $user): void
|
||||
|
||||
private function syncProfilePhoto(UserProfile $profile, array $validated): void
|
||||
{
|
||||
$newFiles = ! empty($validated['profile_photo']) ? [$validated['profile_photo']] : [];
|
||||
$removeIds = $validated['remove_profile_photo_ids'] ?? [];
|
||||
$s3Keys = ! empty($validated['profile_s3_key']) ? [$validated['profile_s3_key']] : null;
|
||||
$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
|
||||
|
||||
@ -78,7 +78,7 @@ public function create(array $validated, User $user): void
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'🏖️ Pengajuan Cuti Baru',
|
||||
"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'),
|
||||
);
|
||||
}
|
||||
@ -99,7 +99,7 @@ public function update(LeaveRequest $leaveRequest, array $validated, User $user)
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'✏️ Pengajuan Cuti Diperbarui',
|
||||
"Pengajuan cuti oleh {$user->profile?->full_name} telah diperbarui.",
|
||||
['owner', 'developer'],
|
||||
['owner', 'developer', 'direktur'],
|
||||
route('admin.hr.leave_requests.index'),
|
||||
);
|
||||
}
|
||||
@ -114,7 +114,7 @@ public function delete(LeaveRequest $leaveRequest): void
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'🗑️ Pengajuan Cuti Dihapus',
|
||||
"Pengajuan cuti {$totalDays} hari oleh {$employeeName} telah dihapus.",
|
||||
['owner', 'developer'],
|
||||
['owner', 'developer', 'direktur'],
|
||||
route('admin.hr.leave_requests.index'),
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Services\Manage;
|
||||
|
||||
use App\Enums\CuttingStatus;
|
||||
use App\Enums\RawMaterialUnit;
|
||||
use App\Enums\Role;
|
||||
use App\Models\Cutting;
|
||||
use App\Models\CuttingMaterial;
|
||||
@ -13,6 +14,7 @@
|
||||
use App\Models\RawMaterial;
|
||||
use App\Models\RawMaterialPrice;
|
||||
use App\Models\User;
|
||||
use App\Services\Media\MediaService;
|
||||
use App\Services\System\PushNotificationService;
|
||||
use App\Support\Media\MediaPresenter;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
@ -26,6 +28,7 @@ class CuttingService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PushNotificationService $pushNotificationService,
|
||||
private readonly MediaService $mediaService,
|
||||
) {}
|
||||
|
||||
public function paginateForIndex(array $tableQuery, User $user): LengthAwarePaginator
|
||||
@ -452,7 +455,7 @@ public function create(array $validated, User $user): Cutting
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'✂️ Proses Cutting Baru',
|
||||
"Proses cutting dengan deskripsi ({$cutting->description}) telah dimulai oleh {$user->profile?->full_name}.",
|
||||
['owner', 'developer'],
|
||||
['owner', 'developer', 'direktur'],
|
||||
route('admin.manage.cuttings.index'),
|
||||
);
|
||||
|
||||
@ -521,7 +524,7 @@ public function update(Cutting $cutting, array $validated): void
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'✏️ Proses Cutting Diperbarui',
|
||||
"Proses cutting dengan deskripsi '{$description}' telah diperbarui.",
|
||||
['owner', 'developer'],
|
||||
['owner', 'developer', 'direktur'],
|
||||
route('admin.manage.cuttings.index'),
|
||||
);
|
||||
}
|
||||
@ -565,7 +568,7 @@ public function delete(Cutting $cutting): void
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'🗑️ Proses Cutting Dihapus',
|
||||
"Proses cutting dengan deskripsi {$description} telah dihapus.",
|
||||
['owner', 'developer'],
|
||||
['owner', 'developer', 'direktur'],
|
||||
route('admin.manage.cuttings.index'),
|
||||
);
|
||||
}
|
||||
@ -658,8 +661,8 @@ public function transitionStatus(
|
||||
};
|
||||
|
||||
$roles = $status === CuttingStatus::COMPLETED
|
||||
? ['owner', 'developer', 'admin-toko']
|
||||
: ['owner', 'developer'];
|
||||
? ['owner', 'developer', 'direktur', 'admin-toko']
|
||||
: ['owner', 'developer', 'direktur'];
|
||||
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
$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(
|
||||
'📦 Pesanan Baru',
|
||||
"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'),
|
||||
);
|
||||
|
||||
@ -596,7 +596,7 @@ public function update(Order $order, array $validated): void
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'✏️ Pesanan Diperbarui',
|
||||
"Pesanan {$order->order_number} senilai {$order->total_amount_formatted} telah diperbarui.",
|
||||
['owner', 'developer'],
|
||||
['owner', 'developer', 'direktur'],
|
||||
route('admin.manage.orders.index'),
|
||||
);
|
||||
}
|
||||
@ -638,7 +638,7 @@ public function delete(Order $order): void
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'🗑️ Pesanan Dihapus',
|
||||
"Pesanan {$orderNumber} senilai {$order->total_amount_formatted} telah dihapus.",
|
||||
['owner', 'developer'],
|
||||
['owner', 'developer', 'direktur'],
|
||||
route('admin.manage.orders.index'),
|
||||
);
|
||||
}
|
||||
@ -684,7 +684,7 @@ public function transitionStatus(Order $order, OrderStatus $status): void
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'📦 Status Pesanan Diubah',
|
||||
"Pesanan {$order->order_number} diubah statusnya menjadi {$status->label()}.",
|
||||
['owner', 'developer'],
|
||||
['owner', 'developer', 'direktur'],
|
||||
route('admin.manage.orders.index'),
|
||||
);
|
||||
}
|
||||
@ -821,6 +821,7 @@ private function syncPhotos(Order $order, array $validated): void
|
||||
self::MAX_PHOTOS,
|
||||
required: $requiresPhoto,
|
||||
errorKey: 'photos',
|
||||
s3Keys: $validated['s3_keys'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -265,7 +265,7 @@ public function rejectRequest(
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'❌ Pengajuan Ditolak',
|
||||
"Pengajuan {$request->action->label()} {$subjectLabel} '{$title}' ditolak dengan alasan: '{$reason}'.",
|
||||
['owner', 'developer'],
|
||||
['owner', 'developer', 'direktur'],
|
||||
route('admin.dashboard'),
|
||||
);
|
||||
|
||||
|
||||
@ -211,15 +211,16 @@ public function createVariantAndDraft(array $validated, User $user): array
|
||||
'stock' => (float) ($validated['stock'] ?? 0),
|
||||
]);
|
||||
|
||||
if (! empty($validated['photos'])) {
|
||||
if (! empty($validated['photos']) || ! empty($validated['s3_keys'])) {
|
||||
$this->mediaService->syncCollection(
|
||||
$price,
|
||||
'images',
|
||||
$validated['photos'],
|
||||
$validated['photos'] ?? null,
|
||||
null,
|
||||
5,
|
||||
required: false,
|
||||
errorKey: 'photos',
|
||||
s3Keys: $validated['s3_keys'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
@ -543,6 +544,7 @@ private function syncPhotos(Purchase $purchase, array $validated): void
|
||||
self::MAX_PHOTOS,
|
||||
required: false,
|
||||
errorKey: 'photos',
|
||||
s3Keys: $validated['s3_keys'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
@ -593,7 +595,7 @@ private function notifyForPendingRequest(User $user, string $typeLabel, string $
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
"📦 {$typeLabel} Menunggu Persetujuan Owner",
|
||||
$body,
|
||||
['owner', 'developer'],
|
||||
['owner', 'developer', 'direktur'],
|
||||
$ownerUrl,
|
||||
);
|
||||
|
||||
@ -755,6 +757,7 @@ private function syncRequestPhotos(OwnerVerificationRequest $verificationRequest
|
||||
self::MAX_PHOTOS,
|
||||
required: false,
|
||||
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(
|
||||
'📦 Transfer Stok Ecer Menunggu Persetujuan 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]),
|
||||
);
|
||||
|
||||
|
||||
@ -126,7 +126,7 @@ public function submitVerification(
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'📦 Verifikasi Stok Menunggu Persetujuan',
|
||||
"Cutting dengan deskripsi '{$description}' telah diajukan verifikasi dan menunggu persetujuan owner.",
|
||||
['owner', 'developer'],
|
||||
['owner', 'developer', 'direktur'],
|
||||
route('admin.manage.stocks.index'),
|
||||
);
|
||||
}
|
||||
@ -176,7 +176,7 @@ public function approveVerification(
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'📦 Stok Cutting Diverifikasi',
|
||||
"Cutting dengan deskripsi '{$description}' telah disetujui owner dan stok produk telah ditambahkan ke toko.",
|
||||
['owner', 'developer'],
|
||||
['owner', 'developer', 'direktur'],
|
||||
route('admin.manage.stocks.index'),
|
||||
);
|
||||
}
|
||||
@ -218,7 +218,7 @@ public function rejectVerification(
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'📦 Verifikasi Cutting Ditolak Owner',
|
||||
"Cutting dengan deskripsi '{$description}' ditolak oleh owner dengan alasan: '{$reason}'.",
|
||||
['owner', 'developer'],
|
||||
['owner', 'developer', 'direktur'],
|
||||
route('admin.manage.stocks.index'),
|
||||
);
|
||||
|
||||
|
||||
@ -138,11 +138,13 @@ public function create(array $validated, User $user): void
|
||||
|
||||
$this->syncVariantImages($variant, $variantData, $index);
|
||||
|
||||
foreach ($variantData['prices'] as $type => $priceValue) {
|
||||
$variant->prices()->create([
|
||||
'type' => $type,
|
||||
'price' => $priceValue,
|
||||
]);
|
||||
if (! empty($variantData['prices'])) {
|
||||
foreach ($variantData['prices'] as $type => $priceValue) {
|
||||
$variant->prices()->create([
|
||||
'type' => $type,
|
||||
'price' => $priceValue,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -552,7 +554,7 @@ private function notifyForPendingRequest(User $user, string $typeLabel, string $
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
"📦 {$typeLabel} Menunggu Persetujuan Owner",
|
||||
$body,
|
||||
['owner', 'developer'],
|
||||
['owner', 'developer', 'direktur'],
|
||||
$ownerUrl,
|
||||
);
|
||||
|
||||
@ -689,6 +691,7 @@ private function syncVariantImages(
|
||||
self::MAX_VARIANT_IMAGES,
|
||||
required: true,
|
||||
errorKey: "variants.{$index}.images",
|
||||
s3Keys: $variantData['s3_keys'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
@ -708,6 +711,7 @@ private function syncRequestVariantImages(
|
||||
self::MAX_VARIANT_IMAGES,
|
||||
required: $required,
|
||||
errorKey: "variants.{$index}.images",
|
||||
s3Keys: $variantData['s3_keys'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -363,7 +363,7 @@ private function notifyForPendingRequest(User $user, string $typeLabel, string $
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
"📦 {$typeLabel} Menunggu Persetujuan Owner",
|
||||
$body,
|
||||
['owner', 'developer'],
|
||||
['owner', 'developer', 'direktur'],
|
||||
$ownerUrl,
|
||||
);
|
||||
|
||||
@ -644,6 +644,7 @@ private function syncPriceImages(RawMaterialPrice $price, array $priceData, int
|
||||
self::MAX_VARIANT_IMAGES,
|
||||
required: true,
|
||||
errorKey: "prices.{$index}.images",
|
||||
s3Keys: $priceData['s3_keys'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
@ -663,6 +664,7 @@ private function syncRequestPriceImages(
|
||||
self::MAX_VARIANT_IMAGES,
|
||||
required: $required,
|
||||
errorKey: "prices.{$index}.images",
|
||||
s3Keys: $priceData['s3_keys'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
|
||||
class MediaService
|
||||
@ -21,10 +22,10 @@ public function syncCollection(
|
||||
?string $type = null,
|
||||
bool $required = false,
|
||||
?string $errorKey = null,
|
||||
?array $s3Keys = null,
|
||||
): void {
|
||||
$newFiles = array_values(array_filter($newFiles ?? []));
|
||||
|
||||
if ($maxFiles === 1 && $newFiles !== []) {
|
||||
// Handle removals
|
||||
if ($maxFiles === 1 && (! empty($newFiles) || ! empty($s3Keys))) {
|
||||
$model->clearMediaCollection($collection);
|
||||
} elseif ($removeIds !== null && $removeIds !== []) {
|
||||
$model->getMedia($collection)
|
||||
@ -32,20 +33,30 @@ public function syncCollection(
|
||||
->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) {
|
||||
$newFiles = array_slice($newFiles, 0, 1);
|
||||
$items = array_slice($items, 0, 1);
|
||||
}
|
||||
|
||||
$currentCount = $model->getMedia($collection)->count();
|
||||
|
||||
if ($currentCount + count($newFiles) > $maxFiles) {
|
||||
if ($currentCount + count($items) > $maxFiles) {
|
||||
throw ValidationException::withMessages([
|
||||
$errorKey ?? $collection => "Maksimal {$maxFiles} gambar per item.",
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($newFiles as $file) {
|
||||
$this->addUploadedFile($model, $file, $collection, $type);
|
||||
foreach ($items as $item) {
|
||||
if ($useS3) {
|
||||
$this->registerS3Key($model, $item, $collection, $type);
|
||||
} else {
|
||||
$this->addUploadedFile($model, $item, $collection, $type);
|
||||
}
|
||||
}
|
||||
|
||||
if ($model instanceof Model) {
|
||||
@ -70,6 +81,24 @@ public function addUploadedFile(
|
||||
->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(
|
||||
HasMedia $model,
|
||||
string $base64Photo,
|
||||
@ -114,6 +143,17 @@ public function replaceSingleFile(
|
||||
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
|
||||
{
|
||||
$properties = [
|
||||
|
||||
@ -243,7 +243,7 @@ public function getRevenueSummary(?Carbon $startDate = null, ?Carbon $endDate =
|
||||
'total_discount' => (int) ($revenueSummary->total_discount ?? 0),
|
||||
'total_marketplace_fees' => $totalMarketplaceFees,
|
||||
'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),
|
||||
'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);
|
||||
$fees = $monthlyFees->get($key, 0);
|
||||
$hppVal = (int) $monthlyHpp->get($key, 0);
|
||||
$discount = (int) ($revenue->total_discount ?? 0);
|
||||
$deduction = $discount + $fees + $hppVal;
|
||||
$hpp = (int) ($monthlyHpp->get($key, 0));
|
||||
$deduction = $discount + $fees;
|
||||
|
||||
$result[] = [
|
||||
'month' => $monthLabel,
|
||||
'total' => (int) ($revenue->total_revenue ?? 0),
|
||||
'net' => (int) ($revenue->total_revenue ?? 0) - $deduction,
|
||||
'net' => (int) ($revenue->total_revenue ?? 0) - $deduction - $hpp,
|
||||
'deduction' => $deduction,
|
||||
];
|
||||
|
||||
@ -361,7 +361,7 @@ public function getExpenseSummary(?Carbon $startDate = null, ?Carbon $endDate =
|
||||
->first();
|
||||
|
||||
$purchaseTotal = (int) ($purchase->total ?? 0);
|
||||
if ($user?->hasRole(Role::ADMIN_TOKO->value)) {
|
||||
if (! $user?->hasAnyRole([Role::DEVELOPER->value, Role::OWNER->value])) {
|
||||
$purchaseTotal = 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');
|
||||
|
||||
$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;
|
||||
}
|
||||
$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))
|
||||
->sum('total');
|
||||
|
||||
if ($user?->hasRole(Role::ADMIN_TOKO->value)) {
|
||||
if (! $user?->hasAnyRole([Role::DEVELOPER->value, Role::OWNER->value])) {
|
||||
$purchaseTotal = 0;
|
||||
}
|
||||
|
||||
@ -535,9 +535,9 @@ public function getProfitMetrics(?Carbon $startDate = null, ?Carbon $endDate = n
|
||||
|
||||
$totalExpenses = (int) $purchaseTotal + (int) $expenseTotal + (int) $advanceTotal;
|
||||
|
||||
$labaKotor = $totalRevenue - $totalHpp - $totalDiscount - $marketplaceFees;
|
||||
$labaBersih = $labaKotor - $totalExpenses;
|
||||
$profitMargin = $totalRevenue > 0 ? round(($labaBersih / $totalRevenue) * 100, 1) : 0;
|
||||
$grossProfit = $totalRevenue - $totalHpp - $totalDiscount - $marketplaceFees;
|
||||
$netProfit = $grossProfit - (int) $expenseTotal - (int) $advanceTotal;
|
||||
$profitMargin = $totalRevenue > 0 ? round(($netProfit / $totalRevenue) * 100, 1) : 0;
|
||||
$aov = $totalOrders > 0 ? (int) round($totalRevenue / $totalOrders) : 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_products_sold' => $totalQty,
|
||||
'hpp' => $totalHpp,
|
||||
'laba_kotor' => $labaKotor,
|
||||
'laba_bersih' => $labaBersih,
|
||||
'gross_profit' => $grossProfit,
|
||||
'net_profit' => $netProfit,
|
||||
'profit_margin' => $profitMargin,
|
||||
'aov' => $aov,
|
||||
'items_per_transaction' => $itemsPerTransaction,
|
||||
@ -672,6 +672,37 @@ public function getTopProducts(?Carbon $startDate = null, ?Carbon $endDate = nul
|
||||
->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
|
||||
{
|
||||
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();
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
if (isset($validated['gallery_images']) || isset($validated['gallery_images_remove'])) {
|
||||
if (isset($validated['gallery_s3_keys']) || isset($validated['gallery_images_remove'])) {
|
||||
$this->mediaService->syncCollection(
|
||||
$configuration,
|
||||
'gallery',
|
||||
$validated['gallery_images'] ?? [],
|
||||
$validated['gallery_images_remove'] ?? [],
|
||||
null,
|
||||
$validated['gallery_images_remove'] ?? null,
|
||||
10,
|
||||
'gallery',
|
||||
s3Keys: $validated['gallery_s3_keys'] ?? null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -118,7 +118,7 @@ public function updateMarketplace(array $validated, User $user): void
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'⚙️ Pengaturan Marketplace Menunggu Persetujuan Owner',
|
||||
"Pengajuan ubah pengaturan marketplace oleh '{$user->username}' menunggu verifikasi owner.",
|
||||
['owner', 'developer'],
|
||||
['owner', 'developer', 'direktur'],
|
||||
route('admin.system.settings.index'),
|
||||
);
|
||||
|
||||
|
||||
@ -49,15 +49,21 @@ public function updateSystem(array $validated): void
|
||||
$settings->address = $validated['address'] ?? null;
|
||||
$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');
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Support\Media;
|
||||
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
|
||||
@ -35,10 +36,27 @@ public static function item(Media $media): array
|
||||
{
|
||||
return [
|
||||
'id' => $media->id,
|
||||
'url' => $media->getUrl(),
|
||||
'url' => self::temporaryUrl($media),
|
||||
'thumb_url' => $media->hasGeneratedConversion('thumb')
|
||||
? $media->getUrl('thumb')
|
||||
: $media->getUrl(),
|
||||
? self::temporaryUrl($media, 'thumb')
|
||||
: 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/tinker": "^3.0",
|
||||
"laravel/wayfinder": "^0.1.14",
|
||||
"league/flysystem-aws-s3-v3": "^3.0",
|
||||
"league/flysystem-ftp": "^3.0",
|
||||
"minishlink/web-push": "^9.0",
|
||||
"spatie/laravel-activitylog": "^5.0",
|
||||
"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",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "396b0f4aff13715436b182ab532948db",
|
||||
"content-hash": "ea578c35bcf9ab3ac130534272e48937",
|
||||
"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",
|
||||
"version": "0.14.8",
|
||||
@ -2004,6 +2155,111 @@
|
||||
},
|
||||
"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",
|
||||
"version": "3.31.0",
|
||||
@ -2539,6 +2795,72 @@
|
||||
],
|
||||
"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",
|
||||
"version": "3.11.4",
|
||||
@ -5311,6 +5633,77 @@
|
||||
],
|
||||
"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",
|
||||
"version": "v8.1.0",
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import DropZone from 'dropzone-vue';
|
||||
import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.vue';
|
||||
import {
|
||||
Field,
|
||||
@ -6,12 +7,77 @@ import {
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
} from '@/components/ui/field';
|
||||
import { uploadFileAndGetKey } from '@/lib/s3-upload';
|
||||
import type { MediaUploadState } from '@/types/media';
|
||||
import { createMediaUploadState } from '@/types/media';
|
||||
import DropZone from 'dropzone-vue';
|
||||
import 'dropzone-vue/dist/dropzone-vue.common.css';
|
||||
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(
|
||||
defineProps<{
|
||||
id: string;
|
||||
@ -36,6 +102,10 @@ const state = defineModel<MediaUploadState>({
|
||||
default: () => createMediaUploadState(),
|
||||
});
|
||||
|
||||
// ─── Upload progress tracking ────────────────────────────────────────────────
|
||||
const uploadProgress = ref<Record<string, number>>({});
|
||||
const uploadErrors = ref<Record<string, string>>({});
|
||||
|
||||
// ─── Dialog ──────────────────────────────────────────────────────────────────
|
||||
const previewOpen = ref(false);
|
||||
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 = [];
|
||||
state.value.newFiles = [];
|
||||
state.value.newFileS3Keys = [];
|
||||
}
|
||||
|
||||
state.value.newFiles.push(item.file);
|
||||
filePreviews.value.push({
|
||||
id: item.id,
|
||||
file: item.file,
|
||||
objectUrl: URL.createObjectURL(item.file),
|
||||
// Compress image asynchronously, then upload to S3
|
||||
compressImage(item.file).then((compressed) => {
|
||||
const fileIndex = state.value.newFiles.length;
|
||||
|
||||
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) {
|
||||
state.value.newFiles.splice(idx, 1);
|
||||
state.value.newFileS3Keys.splice(idx, 1);
|
||||
}
|
||||
|
||||
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);
|
||||
filePreviews.value.splice(pi, 1);
|
||||
}
|
||||
|
||||
delete uploadProgress.value[item.id];
|
||||
delete uploadErrors.value[item.id];
|
||||
}
|
||||
|
||||
// ─── Unified normalized entries ───────────────────────────────────────────────
|
||||
@ -111,6 +224,8 @@ type NormalizedEntry = {
|
||||
previewSrc: string; // src for fullscreen preview
|
||||
name: string;
|
||||
sizeLabel: string;
|
||||
uploadPercent: number | null;
|
||||
uploadError: string | null;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
@ -124,6 +239,8 @@ const allPreviews = computed<NormalizedEntry[]>(() => {
|
||||
previewSrc: item.url,
|
||||
name: item.url.split('/').pop() ?? `image-${item.id}`,
|
||||
sizeLabel: 'Tersimpan',
|
||||
uploadPercent: null,
|
||||
uploadError: null,
|
||||
onRemove: () => {
|
||||
const idx = state.value.existing.findIndex((e) => e.id === item.id);
|
||||
|
||||
@ -144,6 +261,8 @@ const allPreviews = computed<NormalizedEntry[]>(() => {
|
||||
previewSrc: p.objectUrl,
|
||||
name: p.file.name,
|
||||
sizeLabel: formatBytes(p.file.size),
|
||||
uploadPercent: uploadProgress.value[p.id] ?? null,
|
||||
uploadError: uploadErrors.value[p.id] ?? null,
|
||||
onRemove: () => dropzoneRef.value?.removeFile(p.id),
|
||||
}));
|
||||
|
||||
@ -193,49 +312,25 @@ watch(
|
||||
|
||||
<div class="dropzone-wrapper">
|
||||
<!-- Dropzone form: hidden when any preview exists to prevent layout corruption -->
|
||||
<div
|
||||
:id="`dz-container-${id}`"
|
||||
:class="[
|
||||
'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-message-class-name="`dz-message-${id}`"
|
||||
@added-file="onAddedFile"
|
||||
@removed-file="onRemovedFile"
|
||||
>
|
||||
<div :id="`dz-container-${id}`" :class="[
|
||||
'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-message-class-name="`dz-message-${id}`" @added-file="onAddedFile"
|
||||
@removed-file="onRemovedFile">
|
||||
<template #message>
|
||||
<div class="dz-placeholder">
|
||||
<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"
|
||||
/>
|
||||
<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" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
<span class="dz-placeholder-primary"
|
||||
>Klik atau seret file ke sini</span
|
||||
>
|
||||
<span class="dz-placeholder-primary">Klik atau seret file ke sini</span>
|
||||
<span class="dz-placeholder-secondary">
|
||||
{{ acceptedFiles.join(', ') }} — Maks.
|
||||
{{ maxFiles }} file,
|
||||
@ -247,29 +342,14 @@ watch(
|
||||
</div>
|
||||
|
||||
<!-- Overlay: shown when any preview exists, triggers file picker on click -->
|
||||
<div
|
||||
v-if="allPreviews.length > 0"
|
||||
class="dz-overlay"
|
||||
@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"
|
||||
>
|
||||
<div v-if="allPreviews.length > 0" class="dz-overlay" @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" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
<span class="dz-placeholder-primary"
|
||||
>Klik atau seret file ke sini</span
|
||||
>
|
||||
<span class="dz-placeholder-primary">Klik atau seret file ke sini</span>
|
||||
<span class="dz-placeholder-secondary">
|
||||
{{ acceptedFiles.join(', ') }} — Maks.
|
||||
{{ maxFiles }} file, {{ formatBytes(maxFileSize) }}/file
|
||||
@ -278,46 +358,39 @@ watch(
|
||||
</div>
|
||||
|
||||
<!-- Preview grid (existing + new) -->
|
||||
<div
|
||||
v-if="allPreviews.length > 0"
|
||||
:class="[
|
||||
'preview-grid',
|
||||
{ 'preview-grid--single': maxFiles === 1 },
|
||||
]"
|
||||
>
|
||||
<div
|
||||
v-for="entry in allPreviews"
|
||||
:key="entry.key"
|
||||
class="preview-card"
|
||||
>
|
||||
<div v-if="allPreviews.length > 0" :class="[
|
||||
'preview-grid',
|
||||
{ 'preview-grid--single': maxFiles === 1 },
|
||||
]">
|
||||
<div v-for="entry in allPreviews" :key="entry.key" class="preview-card">
|
||||
<!-- Thumbnail -->
|
||||
<div
|
||||
class="preview-thumb"
|
||||
@click="openPreview(entry.previewSrc)"
|
||||
>
|
||||
<img
|
||||
v-if="entry.isImage"
|
||||
:src="entry.thumbSrc"
|
||||
:alt="entry.name"
|
||||
/>
|
||||
<div class="preview-thumb" @click="openPreview(entry.previewSrc)">
|
||||
<img v-if="entry.isImage" :src="entry.thumbSrc" :alt="entry.name" />
|
||||
<div v-else class="preview-thumb-icon">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
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"
|
||||
/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" 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" />
|
||||
</svg>
|
||||
</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>
|
||||
|
||||
<!-- Info + Remove -->
|
||||
@ -326,25 +399,14 @@ watch(
|
||||
<p class="preview-name" :title="entry.name">
|
||||
{{ entry.name }}
|
||||
</p>
|
||||
<p class="preview-size">{{ entry.sizeLabel }}</p>
|
||||
<p class="preview-size">
|
||||
{{ entry.uploadError ? 'Gagal' : entry.sizeLabel }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="preview-remove"
|
||||
title="Hapus"
|
||||
@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"
|
||||
>
|
||||
<button type="button" class="preview-remove" title="Hapus" @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="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
@ -355,11 +417,7 @@ watch(
|
||||
|
||||
<FieldError :errors="errors" />
|
||||
|
||||
<MediaPreviewDialog
|
||||
v-model:open="previewOpen"
|
||||
:url="previewUrl"
|
||||
:title="label"
|
||||
/>
|
||||
<MediaPreviewDialog v-model:open="previewOpen" :url="previewUrl" :title="label" />
|
||||
</Field>
|
||||
</div>
|
||||
</template>
|
||||
@ -583,6 +641,7 @@ watch(
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.preview-thumb img {
|
||||
@ -605,6 +664,51 @@ watch(
|
||||
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 */
|
||||
.preview-footer {
|
||||
display: flex;
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@ -8,10 +9,20 @@ import {
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
defineProps<{
|
||||
url: string | null;
|
||||
title?: string;
|
||||
}>();
|
||||
|
||||
watch(open, (val) => {
|
||||
if (!val) {
|
||||
emit('close');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.vue';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
const props = defineProps<{
|
||||
@ -10,14 +16,78 @@ const props = defineProps<{
|
||||
|
||||
const previewOpen = ref(false);
|
||||
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 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) {
|
||||
previewUrl.value = url;
|
||||
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>
|
||||
|
||||
<template>
|
||||
@ -26,10 +96,27 @@ function openPreview(url: string) {
|
||||
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">
|
||||
</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 }}
|
||||
</span>
|
||||
</button>
|
||||
</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>
|
||||
|
||||
@ -7,15 +7,26 @@ function getXsrfToken(): string {
|
||||
}
|
||||
|
||||
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, {
|
||||
...options,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': getXsrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
...options.headers,
|
||||
},
|
||||
headers,
|
||||
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 admin from '@/routes/admin';
|
||||
|
||||
const { can, hasRole } = useCan();
|
||||
const { can, hasRole, hasAnyRole } = useCan();
|
||||
|
||||
const props = defineProps<{
|
||||
filters: {
|
||||
@ -102,8 +102,8 @@ const props = defineProps<{
|
||||
total_orders: number;
|
||||
total_products_sold: number;
|
||||
hpp: number;
|
||||
laba_kotor: number;
|
||||
laba_bersih: number;
|
||||
gross_profit: number;
|
||||
net_profit: number;
|
||||
profit_margin: number;
|
||||
aov: number;
|
||||
items_per_transaction: number;
|
||||
@ -123,6 +123,14 @@ const props = defineProps<{
|
||||
total_qty: 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 ?? '');
|
||||
@ -186,7 +194,7 @@ const revenueChartConfig = {
|
||||
|
||||
const revenueTotals = computed(() => ({
|
||||
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,
|
||||
}));
|
||||
|
||||
@ -222,17 +230,18 @@ const expenseTotals = computed(() => ({
|
||||
const visibleExpenseCharts = computed(() => {
|
||||
const charts = ['total', 'purchase', 'expense', 'advance'] as const;
|
||||
|
||||
if (hasRole('admin-toko')) {
|
||||
return charts.filter((c) => c !== 'purchase');
|
||||
if (hasAnyRole(['owner', 'developer'])) {
|
||||
return charts;
|
||||
}
|
||||
|
||||
return charts;
|
||||
return charts.filter((c) => c !== 'purchase');
|
||||
});
|
||||
|
||||
const expenseYAccessors = computed(() => {
|
||||
if (hasRole('admin-toko')) {
|
||||
if (hasAnyRole(['owner', 'developer'])) {
|
||||
return [
|
||||
(d: MonthlyExpenseData) => d.total,
|
||||
(d: MonthlyExpenseData) => d.purchase,
|
||||
(d: MonthlyExpenseData) => d.expense,
|
||||
(d: MonthlyExpenseData) => d.advance,
|
||||
];
|
||||
@ -240,16 +249,16 @@ const expenseYAccessors = computed(() => {
|
||||
|
||||
return [
|
||||
(d: MonthlyExpenseData) => d.total,
|
||||
(d: MonthlyExpenseData) => d.purchase,
|
||||
(d: MonthlyExpenseData) => d.expense,
|
||||
(d: MonthlyExpenseData) => d.advance,
|
||||
];
|
||||
});
|
||||
|
||||
const expenseColors = computed(() => {
|
||||
if (hasRole('admin-toko')) {
|
||||
if (hasAnyRole(['owner', 'developer'])) {
|
||||
return [
|
||||
expenseChartConfig.total.color,
|
||||
expenseChartConfig.purchase.color,
|
||||
expenseChartConfig.expense.color,
|
||||
expenseChartConfig.advance.color,
|
||||
];
|
||||
@ -257,7 +266,6 @@ const expenseColors = computed(() => {
|
||||
|
||||
return [
|
||||
expenseChartConfig.total.color,
|
||||
expenseChartConfig.purchase.color,
|
||||
expenseChartConfig.expense.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"
|
||||
:icon="TrendingUp" main-label="Laba Kotor"
|
||||
:main-value="'Rp' + formatRupiah(profitMetrics.laba_kotor)" :items="[
|
||||
:main-value="'Rp' + formatRupiah(profitMetrics.gross_profit)" :items="[
|
||||
{
|
||||
label: 'Pendapatan',
|
||||
value: 'Rp' + formatRupiah(revenueSummary.total_revenue),
|
||||
},
|
||||
{
|
||||
label: 'Pengeluaran',
|
||||
value: 'Rp' + formatRupiah(expenseSummary.total),
|
||||
label: 'HPP',
|
||||
value: 'Rp' + formatRupiah(profitMetrics.hpp),
|
||||
},
|
||||
{
|
||||
label: 'Laba Bersih',
|
||||
value: 'Rp' + formatRupiah(profitMetrics.laba_bersih) + ' (' + profitMetrics.profit_margin + '%)',
|
||||
value: 'Rp' + formatRupiah(profitMetrics.net_profit) + ' (' + profitMetrics.profit_margin + '%)',
|
||||
},
|
||||
]" />
|
||||
</div>
|
||||
@ -832,6 +840,43 @@ watch([startDate, endDate], () => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
@ -51,6 +51,8 @@ const profilePhotoState = ref<MediaUploadState>(
|
||||
createMediaUploadState(props.profilePhoto ? [props.profilePhoto] : []),
|
||||
);
|
||||
|
||||
const isUploading = computed(() => profilePhotoState.value.pendingUploads > 0);
|
||||
|
||||
function buildFormData(): FormData {
|
||||
const formData = new FormData();
|
||||
|
||||
@ -62,8 +64,8 @@ function buildFormData(): FormData {
|
||||
formData.append('birth_date', form.birth_date);
|
||||
formData.append('address', form.address);
|
||||
|
||||
profilePhotoState.value.newFiles.forEach((file) => {
|
||||
formData.append('profile_photo', file);
|
||||
profilePhotoState.value.newFileS3Keys.forEach((key) => {
|
||||
formData.append('profile_s3_key', key);
|
||||
});
|
||||
|
||||
profilePhotoState.value.removeIds.forEach((id) => {
|
||||
@ -175,9 +177,9 @@ function submit() {
|
||||
</Card>
|
||||
|
||||
<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" />
|
||||
{{ form.processing ? 'Menyimpan...' : 'Simpan' }}
|
||||
{{ isUploading ? 'Mengunggah...' : form.processing ? 'Menyimpan...' : 'Simpan' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -51,6 +51,8 @@ const isWithdrawal = computed(() => currentMode.value === CashTransactionType.WI
|
||||
|
||||
const photoState = ref<MediaUploadState>(createMediaUploadState());
|
||||
|
||||
const isUploading = computed(() => photoState.value.pendingUploads > 0);
|
||||
|
||||
const form = useForm({
|
||||
amount: '',
|
||||
description: '',
|
||||
@ -166,9 +168,9 @@ const placeholder = computed(() =>
|
||||
<Button type="button" variant="outline" :disabled="form.processing" @click="open = false">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
<Button type="submit" :disabled="form.processing || isUploading">
|
||||
<Save class="size-4" />
|
||||
{{ form.processing ? 'Menyimpan...' : 'Simpan' }}
|
||||
{{ isUploading ? 'Mengunggah...' : form.processing ? 'Menyimpan...' : 'Simpan' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@ -41,6 +41,8 @@ const isEditing = computed(() => props.expense != null);
|
||||
|
||||
const photoState = ref<MediaUploadState>(createMediaUploadState());
|
||||
|
||||
const isUploading = computed(() => photoState.value.pendingUploads > 0);
|
||||
|
||||
const form = useForm({
|
||||
amount: '',
|
||||
description: '',
|
||||
@ -143,9 +145,9 @@ function submit() {
|
||||
<Button type="button" variant="outline" :disabled="form.processing" @click="open = false">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
<Button type="submit" :disabled="form.processing || isUploading">
|
||||
<Save class="size-4" />
|
||||
{{ form.processing ? 'Menyimpan...' : 'Simpan' }}
|
||||
{{ isUploading ? 'Mengunggah...' : form.processing ? 'Menyimpan...' : 'Simpan' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@ -70,6 +70,8 @@ const profilePhotoState = ref<MediaUploadState>(
|
||||
createMediaUploadState(props.profilePhoto ? [props.profilePhoto] : []),
|
||||
);
|
||||
|
||||
const isUploading = computed(() => profilePhotoState.value.pendingUploads > 0);
|
||||
|
||||
function buildFormData(): FormData {
|
||||
const formData = new FormData();
|
||||
|
||||
@ -85,8 +87,8 @@ function buildFormData(): FormData {
|
||||
formData.append('base_salary', form.base_salary === '' ? '' : form.base_salary);
|
||||
formData.append('role', form.role);
|
||||
|
||||
profilePhotoState.value.newFiles.forEach((file) => {
|
||||
formData.append('profile_photo', file);
|
||||
profilePhotoState.value.newFileS3Keys.forEach((key) => {
|
||||
formData.append('profile_s3_key', key);
|
||||
});
|
||||
|
||||
profilePhotoState.value.removeIds.forEach((id) => {
|
||||
@ -248,9 +250,9 @@ function submit() {
|
||||
</Card>
|
||||
|
||||
<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" />
|
||||
{{ form.processing ? 'Menyimpan...' : submitLabel }}
|
||||
{{ isUploading ? 'Mengunggah...' : form.processing ? 'Menyimpan...' : submitLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -7,6 +7,8 @@ import type {
|
||||
CuttingRawMaterialCatalogItem,
|
||||
CuttingResultCartItem,
|
||||
} from '@/types/cutting';
|
||||
import type { CategoryOption } from '@/types/product';
|
||||
import type { EnumOption } from '@/types/raw-material';
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import CuttingPosForm from './form/CuttingPosForm.vue';
|
||||
import { index, store } from '@/routes/admin/manage/cuttings';
|
||||
@ -16,6 +18,8 @@ defineProps<{
|
||||
productCatalog: CuttingProductCatalogItem[];
|
||||
draftMaterials: CuttingMaterialCartItem[];
|
||||
draftResults: CuttingResultCartItem[];
|
||||
categories: CategoryOption[];
|
||||
units: EnumOption[];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@ -40,6 +44,8 @@ defineProps<{
|
||||
:product-catalog="productCatalog"
|
||||
:draft-materials="draftMaterials"
|
||||
:draft-results="draftResults"
|
||||
:categories="categories"
|
||||
:units="units"
|
||||
:submit-url="store.url()"
|
||||
method="post"
|
||||
submit-label="Simpan"
|
||||
|
||||
@ -6,6 +6,8 @@ import type {
|
||||
CuttingProductCatalogItem,
|
||||
CuttingRawMaterialCatalogItem,
|
||||
} from '@/types/cutting';
|
||||
import type { CategoryOption } from '@/types/product';
|
||||
import type { EnumOption } from '@/types/raw-material';
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import CuttingPosForm from './form/CuttingPosForm.vue';
|
||||
@ -15,6 +17,8 @@ const props = defineProps<{
|
||||
cutting: CuttingEditItem;
|
||||
rawMaterialCatalog: CuttingRawMaterialCatalogItem[];
|
||||
productCatalog: CuttingProductCatalogItem[];
|
||||
categories: CategoryOption[];
|
||||
units: EnumOption[];
|
||||
}>();
|
||||
|
||||
const initialData = computed(() => ({
|
||||
@ -45,7 +49,6 @@ const initialData = computed(() => ({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<Head title="Ubah Cutting" />
|
||||
|
||||
<AdminLayout>
|
||||
@ -58,6 +61,7 @@ const initialData = computed(() => ({
|
||||
</div>
|
||||
|
||||
<CuttingPosForm :raw-material-catalog="rawMaterialCatalog" :product-catalog="productCatalog"
|
||||
:categories="categories" :units="units"
|
||||
:initial-data="initialData" :submit-url="update.url(props.cutting.id)" method="put"
|
||||
submit-label="Perbarui" />
|
||||
</AdminLayout>
|
||||
|
||||
@ -9,6 +9,8 @@ import type {
|
||||
CuttingRawMaterialCatalogItem,
|
||||
CuttingResultCartItem,
|
||||
} from '@/types/cutting';
|
||||
import type { CategoryOption } from '@/types/product';
|
||||
import type { EnumOption } from '@/types/raw-material';
|
||||
import CuttingPosCartDetailDialog from './CuttingPosCartDetailDialog.vue';
|
||||
import CuttingPosMaterialCatalogPanel from './CuttingPosMaterialCatalogPanel.vue';
|
||||
import CuttingPosResultCatalogPanel from './CuttingPosResultCatalogPanel.vue';
|
||||
@ -18,6 +20,8 @@ import { useCuttingPosCart } from './useCuttingPosCart';
|
||||
const props = defineProps<{
|
||||
rawMaterialCatalog: CuttingRawMaterialCatalogItem[];
|
||||
productCatalog: CuttingProductCatalogItem[];
|
||||
categories: CategoryOption[];
|
||||
units: EnumOption[];
|
||||
initialData?: {
|
||||
description: string;
|
||||
sewing_cost?: string;
|
||||
@ -35,6 +39,13 @@ const props = defineProps<{
|
||||
const isCreateMode = computed(() => props.method === 'post');
|
||||
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({
|
||||
description: '',
|
||||
sewing_cost: '0',
|
||||
@ -65,8 +76,8 @@ const {
|
||||
syncMaterialField,
|
||||
syncResultField,
|
||||
} = useCuttingPosCart({
|
||||
rawMaterialCatalog: () => props.rawMaterialCatalog,
|
||||
productCatalog: () => props.productCatalog,
|
||||
rawMaterialCatalog: rawMaterialCatalogState,
|
||||
productCatalog: productCatalogState,
|
||||
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>
|
||||
|
||||
<template>
|
||||
@ -149,12 +184,16 @@ function submit() {
|
||||
<div class="space-y-4">
|
||||
<CuttingPosMaterialCatalogPanel v-model:material-search="materialSearch"
|
||||
: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"
|
||||
:get-result-cart-item="getResultCartItem" @add-result="addResult"
|
||||
:is-create-mode="isCreateMode"
|
||||
@decrease-result-qty="decreaseResultQty" />
|
||||
:categories="categories"
|
||||
@decrease-result-qty="decreaseResultQty"
|
||||
@product-created="onProductCreated" />
|
||||
</div>
|
||||
|
||||
<CuttingPosSummaryPanel :form="form" :material-cart="materialCart" :result-cart="resultCart"
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Minus, Plus, Search } from '@lucide/vue';
|
||||
import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue';
|
||||
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
|
||||
@ -14,11 +15,14 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { getFirstCoverImage } from '@/lib/catalog-cover';
|
||||
import type { CuttingMaterialCartItem, CuttingRawMaterialCatalogItem } from '@/types/cutting';
|
||||
import type { EnumOption } from '@/types/raw-material';
|
||||
import type { CuttingCatalogPrice } from './useCuttingPosCart';
|
||||
import QuickCreateRawMaterialModal from './QuickCreateRawMaterialModal.vue';
|
||||
|
||||
defineProps<{
|
||||
filteredRawMaterials: CuttingRawMaterialCatalogItem[];
|
||||
getMaterialCartItem: (priceId: number) => CuttingMaterialCartItem | undefined;
|
||||
units: EnumOption[];
|
||||
}>();
|
||||
|
||||
const materialSearch = defineModel<string>('materialSearch', { required: true });
|
||||
@ -26,13 +30,20 @@ const materialSearch = defineModel<string>('materialSearch', { required: true })
|
||||
const emit = defineEmits<{
|
||||
'add-material': [rawMaterial: CuttingRawMaterialCatalogItem, price: CuttingCatalogPrice];
|
||||
'decrease-material-qty': [priceId: number];
|
||||
'raw-material-created': [rawMaterial: CuttingRawMaterialCatalogItem];
|
||||
}>();
|
||||
|
||||
const quickCreateOpen = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<Button type="button" variant="outline" size="sm" @click="quickCreateOpen = true">
|
||||
<Plus class="size-3.5 mr-1" />
|
||||
Baru
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="space-y-4">
|
||||
@ -98,7 +109,7 @@ const emit = defineEmits<{
|
||||
>
|
||||
<Minus class="size-3.5" />
|
||||
</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 }}
|
||||
</span>
|
||||
<Button
|
||||
@ -126,4 +137,10 @@ const emit = defineEmits<{
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<QuickCreateRawMaterialModal
|
||||
v-model:open="quickCreateOpen"
|
||||
:units="units"
|
||||
@created="emit('raw-material-created', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Check, Minus, Plus, Search } from '@lucide/vue';
|
||||
import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue';
|
||||
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
|
||||
@ -13,12 +14,15 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { getFirstCoverImage } from '@/lib/catalog-cover';
|
||||
import type { CuttingProductCatalogItem, CuttingResultCartItem } from '@/types/cutting';
|
||||
import type { CategoryOption } from '@/types/product';
|
||||
import type { CuttingCatalogVariant } from './useCuttingPosCart';
|
||||
import QuickCreateProductModal from './QuickCreateProductModal.vue';
|
||||
|
||||
defineProps<{
|
||||
filteredProducts: CuttingProductCatalogItem[];
|
||||
getResultCartItem: (variantId: number) => CuttingResultCartItem | undefined;
|
||||
isCreateMode: boolean;
|
||||
categories: CategoryOption[];
|
||||
}>();
|
||||
|
||||
const productSearch = defineModel<string>('productSearch', { required: true });
|
||||
@ -26,13 +30,20 @@ const productSearch = defineModel<string>('productSearch', { required: true });
|
||||
const emit = defineEmits<{
|
||||
'add-result': [product: CuttingProductCatalogItem, variant: CuttingCatalogVariant];
|
||||
'decrease-result-qty': [variantId: number];
|
||||
'product-created': [product: CuttingProductCatalogItem];
|
||||
}>();
|
||||
|
||||
const quickCreateOpen = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<Button type="button" variant="outline" size="sm" @click="quickCreateOpen = true">
|
||||
<Plus class="size-3.5 mr-1" />
|
||||
Baru
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="space-y-4">
|
||||
@ -107,7 +118,7 @@ const emit = defineEmits<{
|
||||
>
|
||||
<Minus class="size-3.5" />
|
||||
</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 }}
|
||||
</span>
|
||||
<Button
|
||||
@ -136,4 +147,10 @@ const emit = defineEmits<{
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<QuickCreateProductModal
|
||||
v-model:open="quickCreateOpen"
|
||||
:categories="categories"
|
||||
@created="emit('product-created', $event)"
|
||||
/>
|
||||
</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 selectedPaperSize = defineModel<'58mm' | '80mm'>('selectedPaperSize', { required: true });
|
||||
const photoState = defineModel<MediaUploadState>('photoState', { required: true });
|
||||
|
||||
const isUploading = computed(() => photoState.value.pendingUploads > 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -126,8 +128,8 @@ const photoState = defineModel<MediaUploadState>('photoState', { required: true
|
||||
</div>
|
||||
</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" />
|
||||
{{ form.processing ? 'Menyimpan...' : submitLabel }}
|
||||
{{ isUploading ? 'Mengunggah...' : form.processing ? 'Menyimpan...' : submitLabel }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
@ -38,6 +38,8 @@ const stock = ref('0');
|
||||
const media = ref<MediaUploadState>(createMediaUploadState());
|
||||
const loading = ref(false);
|
||||
|
||||
const isUploading = computed(() => media.value.pendingUploads > 0);
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
variant.value = '';
|
||||
@ -76,8 +78,8 @@ async function submit() {
|
||||
formData.append('price', String(Number.parseInt(parseRupiah(price.value), 10) || 0));
|
||||
formData.append('stock', String(parseStockValue(stock.value)));
|
||||
|
||||
media.value.newFiles.forEach((file) => {
|
||||
formData.append('photos[]', file);
|
||||
media.value.newFileS3Keys.forEach((key) => {
|
||||
formData.append('s3_keys[]', key);
|
||||
});
|
||||
|
||||
const response = await fetch(storeNewVariantRoute.url(), {
|
||||
@ -163,8 +165,8 @@ async function submit() {
|
||||
<Button type="button" variant="outline" @click="open = false">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" :disabled="loading || !variant || !price">
|
||||
{{ loading ? 'Menyimpan...' : 'Tambah Varian' }}
|
||||
<Button type="submit" :disabled="loading || !variant || !price || isUploading">
|
||||
{{ isUploading ? 'Mengunggah...' : loading ? 'Menyimpan...' : 'Tambah Varian' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@ -29,6 +29,8 @@ defineProps<{
|
||||
}>();
|
||||
|
||||
const photoState = defineModel<MediaUploadState>('photoState', { required: true });
|
||||
|
||||
const isUploading = computed(() => photoState.value.pendingUploads > 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -75,8 +77,8 @@ const photoState = defineModel<MediaUploadState>('photoState', { required: true
|
||||
: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" />
|
||||
{{ form.processing ? 'Menyimpan...' : submitLabel }}
|
||||
{{ isUploading ? 'Mengunggah...' : form.processing ? 'Menyimpan...' : submitLabel }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
@ -5,12 +5,16 @@ import { computed, ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import { useVariantList } from '@/composables/useVariantList';
|
||||
import { appendMediaToFormData, createMediaUploadState } from '@/types/media';
|
||||
import type { CategoryOption, ProductFormInitialData, ProductVariantFormItem } from '@/types/product';
|
||||
import ProductInfoSection from './ProductInfoSection.vue';
|
||||
import ProductVariantSection from './ProductVariantSection.vue';
|
||||
|
||||
const { hasRole } = useCan();
|
||||
const showPrices = !hasRole('admin-bahan-baku');
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
categories: CategoryOption[];
|
||||
@ -29,6 +33,10 @@ function createClientId(): string {
|
||||
return `variant-${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
const isUploading = computed(() =>
|
||||
variants.value.some((v) => v.media.pendingUploads > 0)
|
||||
);
|
||||
|
||||
const {
|
||||
items: variants,
|
||||
addItem: addVariant,
|
||||
@ -156,7 +164,7 @@ function buildFormData(): FormData {
|
||||
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) {
|
||||
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));
|
||||
});
|
||||
@ -228,9 +236,9 @@ function submit() {
|
||||
Tambah Varian
|
||||
</Button>
|
||||
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
<Button type="submit" :disabled="form.processing || isUploading">
|
||||
<Save class="size-4" />
|
||||
{{ form.processing ? 'Menyimpan...' : submitLabel }}
|
||||
{{ isUploading ? 'Mengunggah...' : form.processing ? 'Menyimpan...' : submitLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Trash2, Copy, Clipboard, Check } from '@lucide/vue';
|
||||
import { ref } from 'vue';
|
||||
import { NumberInput } from '@/components/form/number-input';
|
||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||
import MediaDropzone from '@/components/media/MediaDropzone.vue';
|
||||
@ -14,10 +14,14 @@ import {
|
||||
FieldSet,
|
||||
} from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||
import type { MediaUploadState } from '@/types/media';
|
||||
import { PRICE_TYPES, PRICE_TYPE_LABELS } 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<{
|
||||
form: {
|
||||
@ -83,14 +87,8 @@ function updatePrice(type: string, value: string) {
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-start justify-between gap-4">
|
||||
<CardTitle>Varian {{ index + 1 }}</CardTitle>
|
||||
<Button
|
||||
v-if="canRemove"
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="text-destructive hover:text-destructive size-8"
|
||||
@click="emit('remove')"
|
||||
>
|
||||
<Button v-if="canRemove" type="button" variant="outline" size="icon"
|
||||
class="text-destructive hover:text-destructive size-8" @click="emit('remove')">
|
||||
<Trash2 class="size-4" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
@ -101,75 +99,53 @@ function updatePrice(type: string, value: string) {
|
||||
<FieldLabel :for="`variant_name_${variant.client_id}`" required>
|
||||
Nama Varian
|
||||
</FieldLabel>
|
||||
<Input
|
||||
:id="`variant_name_${variant.client_id}`"
|
||||
:model-value="variant.name"
|
||||
type="text"
|
||||
placeholder="Masukkan nama varian"
|
||||
:maxlength="FIELD_LIMITS.variantName"
|
||||
@update:model-value="emit('update:name', String($event))"
|
||||
/>
|
||||
<Input :id="`variant_name_${variant.client_id}`" :model-value="variant.name" 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')" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel :for="`variant_stock_${variant.client_id}`" required>
|
||||
Stok
|
||||
</FieldLabel>
|
||||
<NumberInput
|
||||
:id="`variant_stock_${variant.client_id}`"
|
||||
:model-value="variant.stock"
|
||||
@update:model-value="emit('update:stock', String($event))"
|
||||
/>
|
||||
<NumberInput :id="`variant_stock_${variant.client_id}`" :model-value="variant.stock"
|
||||
@update:model-value="emit('update:stock', String($event))" />
|
||||
<FieldError :errors="variantErrors(variant.client_id, 'stock')" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel :for="`variant_retail_stock_${variant.client_id}`" required>
|
||||
Stok Ecer
|
||||
</FieldLabel>
|
||||
<NumberInput
|
||||
:id="`variant_retail_stock_${variant.client_id}`"
|
||||
<NumberInput :id="`variant_retail_stock_${variant.client_id}`"
|
||||
: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')" />
|
||||
</Field>
|
||||
</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">
|
||||
<h4 class="text-sm font-semibold">Harga Varian</h4>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
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" />
|
||||
<Button type="button" variant="outline" size="sm"
|
||||
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" />
|
||||
{{ isCopied ? 'Berhasil' : 'Salin Harga' }}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 px-2.5 text-xs flex items-center gap-1.5"
|
||||
:disabled="!hasCopiedPrices"
|
||||
@click="handlePaste"
|
||||
>
|
||||
<Check v-if="isPasted" class="size-3.5 text-green-600 animate-in fade-in zoom-in-50 duration-200" />
|
||||
<Button type="button" variant="outline" size="sm"
|
||||
class="h-8 px-2.5 text-xs flex items-center gap-1.5" :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" />
|
||||
{{ isPasted ? 'Berhasil' : 'Tempel Harga' }}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
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" />
|
||||
<Button type="button" variant="outline" size="sm"
|
||||
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" />
|
||||
{{ isApplied ? 'Berhasil' : 'Terapkan ke Semua' }}
|
||||
</Button>
|
||||
@ -180,26 +156,18 @@ function updatePrice(type: string, value: string) {
|
||||
<FieldLabel class="text-xs" :for="`variant_price_${variant.client_id}_${type}`" required>
|
||||
{{ PRICE_TYPE_LABELS[type] }}
|
||||
</FieldLabel>
|
||||
<RupiahInput
|
||||
:id="`variant_price_${variant.client_id}_${type}`"
|
||||
<RupiahInput :id="`variant_price_${variant.client_id}_${type}`"
|
||||
: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}`)" />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<MediaDropzone
|
||||
:id="`variant_images_${variant.client_id}`"
|
||||
:model-value="variant.media"
|
||||
label="Foto Varian"
|
||||
:max-files="5"
|
||||
required
|
||||
:errors="variantErrors(variant.client_id, 'images')"
|
||||
@update:model-value="emit('update:media', $event)"
|
||||
/>
|
||||
<MediaDropzone :id="`variant_images_${variant.client_id}`" :model-value="variant.media"
|
||||
label="Foto Varian" :max-files="5" required :errors="variantErrors(variant.client_id, 'images')"
|
||||
@update:model-value="emit('update:media', $event)" />
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
|
||||
@ -86,8 +86,10 @@ function openVerificationDetail(requestId: number | undefined) {
|
||||
</h3>
|
||||
</div>
|
||||
<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 —
|
||||
<button type="button" class="underline-offset-2 hover:underline" @click="openVerificationDetail(product.pending_request_id)">lihat</button>
|
||||
{{ product.pending_request_submitted_by_name }} mengajukan {{
|
||||
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>
|
||||
<div v-if="product.categories.length" class="flex flex-wrap gap-1">
|
||||
<Badge v-for="category in product.categories" :key="category.id" variant="outline">
|
||||
@ -142,7 +144,7 @@ function openVerificationDetail(requestId: number | undefined) {
|
||||
{{ variant.name }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MediaThumbnailCell :items="variant.images ?? []" />
|
||||
<MediaThumbnailCell :items="variant.images ?? []" :max-visible="1" />
|
||||
</TableCell>
|
||||
<TableCell class="tabular-nums">
|
||||
{{ variant.stock_formatted }}
|
||||
|
||||
@ -46,6 +46,10 @@ function createClientId(): string {
|
||||
return `price-${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
const isUploading = computed(() =>
|
||||
prices.value.some((p) => p.media.pendingUploads > 0)
|
||||
);
|
||||
|
||||
const {
|
||||
items: prices,
|
||||
removeItem: removePrice,
|
||||
@ -218,9 +222,9 @@ function submit() {
|
||||
Tambah Varian
|
||||
</Button>
|
||||
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
<Button type="submit" :disabled="form.processing || isUploading">
|
||||
<Save class="size-4" />
|
||||
{{ form.processing ? 'Menyimpan...' : submitLabel }}
|
||||
{{ isUploading ? 'Mengunggah...' : form.processing ? 'Menyimpan...' : submitLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -23,6 +23,7 @@ defineProps<{
|
||||
form: FormWithErrors & { name: string; unit: string };
|
||||
units: EnumOption[];
|
||||
method: 'post' | 'put';
|
||||
selectPortalTarget?: HTMLElement;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@ -52,7 +53,7 @@ defineProps<{
|
||||
<SelectTrigger id="unit" class="w-full">
|
||||
<SelectValue placeholder="Pilih satuan" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent :to="selectPortalTarget">
|
||||
<SelectItem v-for="option in units" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
|
||||
@ -126,7 +126,7 @@ function openVerificationDetail(requestId: number | undefined) {
|
||||
{{ price.variant }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MediaThumbnailCell :items="price.images ?? []" />
|
||||
<MediaThumbnailCell :items="price.images ?? []" :max-visible="1" />
|
||||
</TableCell>
|
||||
<TableCell class="tabular-nums">
|
||||
{{ price.stock_formatted }}
|
||||
|
||||
@ -34,21 +34,27 @@ const galleryState = ref<MediaUploadState>(
|
||||
);
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const isUploading = computed(() =>
|
||||
heroImage.value.pendingUploads > 0 ||
|
||||
aboutImage.value.pendingUploads > 0 ||
|
||||
galleryState.value.pendingUploads > 0
|
||||
);
|
||||
|
||||
function submit() {
|
||||
isSubmitting.value = true;
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
heroImage.value.newFiles.forEach((file) => {
|
||||
formData.append('hero_image', file);
|
||||
heroImage.value.newFileS3Keys.forEach((key) => {
|
||||
formData.append('hero_image_s3_key', key);
|
||||
});
|
||||
|
||||
aboutImage.value.newFiles.forEach((file) => {
|
||||
formData.append('about_image', file);
|
||||
aboutImage.value.newFileS3Keys.forEach((key) => {
|
||||
formData.append('about_image_s3_key', key);
|
||||
});
|
||||
|
||||
galleryState.value.newFiles.forEach((file) => {
|
||||
formData.append('gallery_images[]', file);
|
||||
galleryState.value.newFileS3Keys.forEach((key) => {
|
||||
formData.append('gallery_s3_keys[]', key);
|
||||
});
|
||||
|
||||
galleryState.value.removeIds.forEach((id) => {
|
||||
@ -102,9 +108,9 @@ function submit() {
|
||||
</Card>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button type="submit" :disabled="isSubmitting">
|
||||
<Button type="submit" :disabled="isSubmitting || isUploading">
|
||||
<Save class="size-4" />
|
||||
{{ isSubmitting ? 'Menyimpan...' : 'Simpan' }}
|
||||
{{ isUploading ? 'Mengunggah...' : isSubmitting ? 'Menyimpan...' : 'Simpan' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -32,6 +32,12 @@ const logoState = ref<MediaUploadState>(createMediaUploadState());
|
||||
const faviconState = 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 {
|
||||
const formData = new FormData();
|
||||
|
||||
@ -41,16 +47,16 @@ function buildFormData(): FormData {
|
||||
formData.append('phone', form.phone);
|
||||
formData.append('address', form.address);
|
||||
|
||||
logoState.value.newFiles.forEach((file) => {
|
||||
formData.append('logo', file);
|
||||
logoState.value.newFileS3Keys.forEach((key) => {
|
||||
formData.append('logo_s3_key', key);
|
||||
});
|
||||
|
||||
faviconState.value.newFiles.forEach((file) => {
|
||||
formData.append('favicon', file);
|
||||
faviconState.value.newFileS3Keys.forEach((key) => {
|
||||
formData.append('favicon_s3_key', key);
|
||||
});
|
||||
|
||||
loginCoverState.value.newFiles.forEach((file) => {
|
||||
formData.append('login_cover', file);
|
||||
loginCoverState.value.newFileS3Keys.forEach((key) => {
|
||||
formData.append('login_cover_s3_key', key);
|
||||
});
|
||||
|
||||
return formData;
|
||||
@ -138,9 +144,9 @@ function submit() {
|
||||
</Card>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
<Button type="submit" :disabled="form.processing || isUploading">
|
||||
<Save class="size-4" />
|
||||
{{ form.processing ? 'Menyimpan...' : 'Simpan' }}
|
||||
{{ isUploading ? 'Mengunggah...' : form.processing ? 'Menyimpan...' : 'Simpan' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -7,14 +7,18 @@ export type MediaItem = {
|
||||
export type MediaUploadState = {
|
||||
existing: MediaItem[];
|
||||
newFiles: File[];
|
||||
newFileS3Keys: string[];
|
||||
removeIds: number[];
|
||||
pendingUploads: number;
|
||||
};
|
||||
|
||||
export function createMediaUploadState(existing: MediaItem[] = []): MediaUploadState {
|
||||
return {
|
||||
existing: [...existing],
|
||||
newFiles: [],
|
||||
newFileS3Keys: [],
|
||||
removeIds: [],
|
||||
pendingUploads: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@ -27,8 +31,8 @@ export function appendMediaToFormData(
|
||||
prefix: string,
|
||||
state: MediaUploadState,
|
||||
): void {
|
||||
state.newFiles.forEach((file) => {
|
||||
formData.append(`${prefix}[images][]`, file);
|
||||
state.newFileS3Keys.forEach((key) => {
|
||||
formData.append(`${prefix}[s3_keys][]`, key);
|
||||
});
|
||||
|
||||
state.removeIds.forEach((id) => {
|
||||
@ -51,8 +55,8 @@ export function appendPhotosToFormData(
|
||||
}
|
||||
|
||||
export function appendRootPhotosToFormData(formData: FormData, state: MediaUploadState): void {
|
||||
state.newFiles.forEach((file) => {
|
||||
formData.append('photos[]', file);
|
||||
state.newFileS3Keys.forEach((key) => {
|
||||
formData.append('s3_keys[]', key);
|
||||
});
|
||||
|
||||
state.removeIds.forEach((id) => {
|
||||
|
||||
@ -29,6 +29,7 @@
|
||||
use App\Http\Controllers\Admin\Master\ProductController;
|
||||
use App\Http\Controllers\Admin\Master\RawMaterialController;
|
||||
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\System\ActivityLogController;
|
||||
use App\Http\Controllers\Admin\System\RoleController;
|
||||
@ -49,6 +50,9 @@
|
||||
Route::middleware(['auth', 'permission:'.Permission::DASHBOARD_VIEW->value])->group(function () {
|
||||
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
|
||||
Route::post('/push-subscriptions', [PushSubscriptionController::class, 'store'])->name('push_subscriptions.store');
|
||||
Route::delete('/push-subscriptions', [PushSubscriptionController::class, 'destroy'])->name('push_subscriptions.destroy');
|
||||
@ -330,6 +334,14 @@
|
||||
->middleware('permission:'.Permission::CUTTINGS_CREATE->value)
|
||||
->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'])
|
||||
->middleware('permission:'.Permission::CUTTINGS_VIEW->value.'|'.Permission::CUTTINGS_CREATE->value)
|
||||
->name('store');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user