Compare commits

..

10 Commits

Author SHA1 Message Date
Yoga Pangestu
d02af40836 feat: implement presigned upload functionality for S3 in PresignedUploadController; update media upload handling across various components to support S3 keys and improve user experience
Some checks failed
linter / quality (push) Waiting to run
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
2026-07-02 14:28:51 +07:00
Yoga Pangestu
deabe6b043 refactor: rename profit metrics in AnalysisService and Analysis.vue for clarity; update variable names from laba_kotor and laba_bersih to gross_profit and net_profit 2026-07-02 13:34:05 +07:00
Yoga Pangestu
82fdb9ee0a feat: update AnalysisService and Analysis.vue to include HPP in net revenue calculations for more accurate financial reporting 2026-07-02 13:20:12 +07:00
Yoga Pangestu
15f2f0e1d0 feat: add marketing sales analysis feature; enhance AnalysisService to retrieve marketing sales data and update Analysis.vue to display marketing sales table with relevant metrics 2026-07-02 13:11:18 +07:00
Yoga Pangestu
5a96b48725 feat: implement quick-create functionality for raw materials and products in Cutting module; enhance CuttingController and related components to support new features and improve user experience 2026-07-02 12:04:30 +07:00
Yoga Pangestu
47289d158e feat: update ProductRequest and related components to conditionally handle price fields based on user roles; enhance form behavior for better variant price management 2026-07-02 10:44:47 +07:00
Yoga Pangestu
78e30c2aba feat: enhance MediaThumbnailCell and MediaPreviewDialog components with gallery functionality and improved item display; add close event emission for better dialog management 2026-07-02 10:37:02 +07:00
Yoga Pangestu
fff6b147be feat: implement client-side image compression in MediaDropzone component to optimize file uploads 2026-07-02 09:50:54 +07:00
Yoga Pangestu
7c307b991e refactor: remove nonQueued() method from media conversion in HasModuleMedia for cleaner code 2026-07-02 09:28:58 +07:00
Yoga Pangestu
13a75a1a25 feat: add AWS S3 and FTP support by including league/flysystem-aws-s3-v3 and league/flysystem-ftp packages; enhance UserProfile and MediaPresenter for temporary URL generation 2026-07-01 23:09:38 +07:00
72 changed files with 2260 additions and 419 deletions

View File

@ -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,

View File

@ -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,

View File

@ -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),
]);
}
}

View File

@ -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(),
]);
}

View File

@ -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]);
}
}

View File

@ -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(),
]);
}
}

View File

@ -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',
];
}
}

View File

@ -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'],
];

View File

@ -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(),
];
}
}

View File

@ -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(),
];
}
}

View File

@ -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(),
];
}
}

View File

@ -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(),
];
}
}

View File

@ -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',
];
}
}

View File

@ -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',
];
}
}

View 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'),
];
}
}

View 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'),
];
}
}

View File

@ -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',
];

View File

@ -16,7 +16,6 @@ public function registerMediaConversions(?Media $media = null): void
$this->addMediaConversion('thumb')
->width(80)
->height(80)
->sharpen(10)
->nonQueued();
->sharpen(10);
}
}

View File

@ -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();
},
);
}

View File

@ -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;

View File

@ -418,6 +418,7 @@ private function syncPhotos(CashTransaction $transaction, array $validated): voi
self::MAX_PHOTOS,
required: true,
errorKey: 'photos',
s3Keys: $validated['s3_keys'] ?? null,
);
}

View File

@ -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'),
);
}

View File

@ -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,
);
}

View File

@ -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'),
);
}

View File

@ -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

View File

@ -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'),
);
}

View File

@ -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()}";
}
}

View File

@ -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,
);
}

View File

@ -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'),
);

View File

@ -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,
);
}

View File

@ -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]),
);

View File

@ -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'),
);

View File

@ -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,
);
}

View File

@ -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,
);
}

View File

@ -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 = [

View File

@ -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;

View File

@ -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,
);
}
}

View File

@ -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'),
);

View File

@ -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');
}

View File

@ -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();
}
}

View File

@ -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
View File

@ -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",

View File

@ -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 (01)
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(', ') }} &mdash; 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(', ') }} &mdash; 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;

View File

@ -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>

View File

@ -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>

View File

@ -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',
});

View 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;
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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"

View File

@ -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>

View File

@ -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"

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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 }}

View File

@ -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>

View File

@ -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>

View File

@ -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 }}

View File

@ -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>

View File

@ -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>

View File

@ -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) => {

View File

@ -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');