From dc8ecf6c5288ff12c5fa77409b94485b4691e912 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Sat, 1 Aug 2026 02:11:56 +0700 Subject: [PATCH] Refactor code structure for improved readability and maintainability --- app/Enums/PriceType.php | 24 +- app/Enums/ProductStatus.php | 9 + .../Admin/Master/ProductController.php | 85 + .../Api/PresignedUrlController.php | 4 +- .../Requests/Admin/Master/ProductRequest.php | 93 + app/Models/Product.php | 2 + app/Models/ProductCategory.php | 9 +- app/Models/ProductPrice.php | 12 +- app/Models/ProductVariant.php | 6 +- app/Services/Admin/Master/ProductService.php | 235 ++ ..._20_235900_create_product_prices_table.php | 1 - resources/js/components/data-table.tsx | 92 +- resources/js/components/file-upload.tsx | 39 +- resources/js/components/rupiah-input.tsx | 20 +- .../js/layouts/app/app-sidebar-layout.tsx | 2 +- resources/js/lib/upload.ts | 2 - .../js/pages/admin/master/product/columns.tsx | 364 +++ .../js/pages/admin/master/product/create.tsx | 474 ++++ .../js/pages/admin/master/product/edit.tsx | 528 +++++ .../js/pages/admin/master/product/index.tsx | 320 +++ routes/api.php | 3 +- routes/web.php | 9 +- tests/Feature/Admin/Master/ProductTest.php | 1994 +++++++++++++++++ 23 files changed, 4266 insertions(+), 61 deletions(-) create mode 100644 app/Http/Controllers/Admin/Master/ProductController.php create mode 100644 app/Http/Requests/Admin/Master/ProductRequest.php create mode 100644 app/Services/Admin/Master/ProductService.php create mode 100644 resources/js/pages/admin/master/product/columns.tsx create mode 100644 resources/js/pages/admin/master/product/create.tsx create mode 100644 resources/js/pages/admin/master/product/edit.tsx create mode 100644 resources/js/pages/admin/master/product/index.tsx create mode 100644 tests/Feature/Admin/Master/ProductTest.php diff --git a/app/Enums/PriceType.php b/app/Enums/PriceType.php index b4a4559..c1eb662 100644 --- a/app/Enums/PriceType.php +++ b/app/Enums/PriceType.php @@ -8,6 +8,28 @@ enum PriceType: string { use HasValues; - case RETAIL = 'retail'; + case DISTRIBUTOR = 'distributor'; + case AGEN = 'agent'; + case SUB_AGEN = 'sub_agent'; case WHOLESALE = 'wholesale'; + case RETAIL = 'retail'; + case TIKTOK = 'tiktok'; + case SHOPEE = 'shopee'; + case CAPITAL = 'capital'; + case REJECT = 'reject'; + + public function label(): string + { + return match ($this) { + self::DISTRIBUTOR => 'Distributor', + self::AGEN => 'Agen', + self::SUB_AGEN => 'Sub Agen', + self::WHOLESALE => 'Grosir', + self::RETAIL => 'Ecer', + self::TIKTOK => 'TikTok', + self::SHOPEE => 'Shopee', + self::CAPITAL => 'Modal', + self::REJECT => 'Reject', + }; + } } diff --git a/app/Enums/ProductStatus.php b/app/Enums/ProductStatus.php index 4383e73..092ab1a 100644 --- a/app/Enums/ProductStatus.php +++ b/app/Enums/ProductStatus.php @@ -11,4 +11,13 @@ enum ProductStatus: string case ACTIVE = 'active'; case INACTIVE = 'inactive'; case DRAFT = 'draft'; + + public function label(): string + { + return match ($this) { + self::ACTIVE => 'Aktif', + self::INACTIVE => 'Non Aktif', + self::DRAFT => 'Draft', + }; + } } diff --git a/app/Http/Controllers/Admin/Master/ProductController.php b/app/Http/Controllers/Admin/Master/ProductController.php new file mode 100644 index 0000000..4cb86b5 --- /dev/null +++ b/app/Http/Controllers/Admin/Master/ProductController.php @@ -0,0 +1,85 @@ + $this->service->getAll($request->only(['status', 'name'])), + 'filters' => $request->only(['status', 'name']), + ]); + } + + public function create(): Response + { + return Inertia::render('admin/master/product/create', [ + 'categories' => $this->categoryService->getAll(), + ]); + } + + public function store(ProductRequest $request): RedirectResponse + { + return $this->handleAction( + fn() => $this->service->create($request->validated()), + 'Produk berhasil ditambahkan.', + 'admin.master.products.index', + 'admin.master.products.create' + ); + } + + public function edit(Product $product): Response + { + return Inertia::render('admin/master/product/edit', [ + 'product' => $this->service->getForEdit($product), + 'categories' => $this->categoryService->getAll(), + ]); + } + + public function update(ProductRequest $request, Product $product): RedirectResponse + { + return $this->handleAction( + fn() => $this->service->update($product, $request->validated()), + 'Produk berhasil diperbarui.', + 'admin.master.products.index', + 'admin.master.products.edit', + ['product' => $product] + ); + } + + public function destroy(Product $product): RedirectResponse + { + return $this->handleAction( + fn() => $this->service->delete($product), + 'Produk berhasil dihapus.', + 'admin.master.products.index' + ); + } + + public function toggleStatus(Product $product): RedirectResponse + { + $this->service->toggleStatus($product); + $status = $product->fresh()->status; + + Inertia::flash('toast', ['type' => 'success', 'message' => "Status produk berhasil diubah menjadi {$status->label()}."]); + + return to_route('admin.master.products.index'); + } +} diff --git a/app/Http/Controllers/Api/PresignedUrlController.php b/app/Http/Controllers/Api/PresignedUrlController.php index cf9e98b..1d8904f 100644 --- a/app/Http/Controllers/Api/PresignedUrlController.php +++ b/app/Http/Controllers/Api/PresignedUrlController.php @@ -30,8 +30,8 @@ public function store(PresignedUrlRequest $request): JsonResponse public function show(string $key): RedirectResponse { $minutes = (int) request('minutes', 60); - $url = $this->service->getTemporaryUrl(urldecode($key), $minutes); + $url = $this->service->getTemporaryUrl($key, $minutes); - return redirect($url); + return redirect()->away($url); } } diff --git a/app/Http/Requests/Admin/Master/ProductRequest.php b/app/Http/Requests/Admin/Master/ProductRequest.php new file mode 100644 index 0000000..6abac09 --- /dev/null +++ b/app/Http/Requests/Admin/Master/ProductRequest.php @@ -0,0 +1,93 @@ +shared_prices; + if (is_array($sharedPrices)) { + foreach ($sharedPrices as $i => $price) { + if (isset($price['price']) && is_string($price['price'])) { + $this->request->set("shared_prices.$i.price", (int) str_replace('.', '', $price['price'])); + } + } + } + + $variants = $this->variants; + if (is_array($variants)) { + foreach ($variants as $i => $variant) { + if (isset($variant['prices']) && is_array($variant['prices'])) { + foreach ($variant['prices'] as $j => $price) { + if (isset($price['price']) && is_string($price['price'])) { + $this->request->set("variants.$i.prices.$j.price", (int) str_replace('.', '', $price['price'])); + } + } + } + } + } + } + + public function rules(): array + { + $useSamePrice = $this->boolean('use_same_price'); + + return [ + 'name' => [ + 'required', + 'string', + 'max:200', + ], + 'description' => ['nullable', 'string'], + 'status' => ['nullable', Rule::in(['active', 'inactive', 'draft'])], + 'category_ids' => ['required', 'array', 'min:1'], + 'category_ids.*' => ['exists:categories,id'], + 'use_same_price' => ['nullable', 'boolean'], + 'shared_prices' => ['required_if:use_same_price,true', 'nullable', 'array', ...($useSamePrice ? ['size:9'] : [])], + 'shared_prices.*.type' => ['required_if:use_same_price,true', 'nullable', Rule::in(PriceType::values())], + 'shared_prices.*.price' => ['required_if:use_same_price,true', 'nullable', 'integer', 'min:0'], + 'variants' => ['required', 'array', 'min:1'], + 'variants.*.id' => ['nullable', 'integer'], + 'variants.*.name' => ['required', 'string', 'max:200'], + 'variants.*.stock' => ['required', 'integer', 'min:0'], + 'variants.*.reject_stock' => ['required', 'integer', 'min:0'], + 'variants.*.retail_stock' => ['required', 'integer', 'min:0'], + 'variants.*.photo_key' => ['required', 'string', 'max:500'], + 'variants.*.prices' => ['required_if:use_same_price,false', 'nullable', 'array', ...(!$useSamePrice ? ['size:9'] : [])], + 'variants.*.prices.*.id' => ['nullable', 'integer'], + 'variants.*.prices.*.type' => ['required_if:use_same_price,false', 'nullable', Rule::in(PriceType::values())], + 'variants.*.prices.*.price' => ['required_if:use_same_price,false', 'nullable', 'integer', 'min:0'], + ]; + } + + public function attributes(): array + { + return [ + 'name' => 'Nama Produk', + 'description' => 'Deskripsi', + 'status' => 'Status', + 'category_ids' => 'Kategori', + 'variants' => 'Varian', + 'variants.*.name' => 'Nama Varian', + 'variants.*.stock' => 'Stok', + 'variants.*.reject_stock' => 'Stok Reject', + 'variants.*.retail_stock' => 'Stok Retail', + 'variants.*.photo_key' => 'Foto', + 'variants.*.prices' => 'Harga', + 'variants.*.prices.*.type' => 'Tipe Harga', + 'variants.*.prices.*.price' => 'Harga', + ]; + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php index dd7c107..cad4783 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -11,8 +11,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Spatie\Sluggable\Attributes\Sluggable; #[Guarded(['id'])] +#[Sluggable(from: 'name', to: 'slug')] class Product extends Model { use HasFactory, SoftDeletes; diff --git a/app/Models/ProductCategory.php b/app/Models/ProductCategory.php index 79f660f..32ad6b8 100644 --- a/app/Models/ProductCategory.php +++ b/app/Models/ProductCategory.php @@ -3,19 +3,14 @@ namespace App\Models; use Illuminate\Database\Eloquent\Attributes\Guarded; -use Illuminate\Database\Eloquent\Factories\HasFactory; -use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\Pivot; #[Guarded(['id'])] -class ProductCategory extends Model +class ProductCategory extends Pivot { - use HasFactory; - public $timestamps = false; - protected $fillable = ['product_id', 'category_id']; - public function category(): BelongsTo { return $this->belongsTo(Category::class); diff --git a/app/Models/ProductPrice.php b/app/Models/ProductPrice.php index a363d41..74ada8d 100644 --- a/app/Models/ProductPrice.php +++ b/app/Models/ProductPrice.php @@ -3,18 +3,21 @@ namespace App\Models; use App\Enums\PriceType; +use Illuminate\Database\Eloquent\Attributes\Appends; use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Attributes\Scope; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; #[Guarded(['id'])] +#[Appends(['type_label'])] class ProductPrice extends Model { - use HasFactory, SoftDeletes; + use HasFactory; protected function casts(): array { @@ -40,4 +43,11 @@ public function variant(): BelongsTo { return $this->belongsTo(ProductVariant::class, 'variant_id'); } + + protected function typeLabel(): Attribute + { + return Attribute::make( + get: fn () => $this->type->label(), + ); + } } diff --git a/app/Models/ProductVariant.php b/app/Models/ProductVariant.php index 5cb4f75..862568b 100644 --- a/app/Models/ProductVariant.php +++ b/app/Models/ProductVariant.php @@ -8,11 +8,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Spatie\MediaLibrary\HasMedia; +use Spatie\MediaLibrary\InteractsWithMedia; #[Guarded(['id'])] -class ProductVariant extends Model +class ProductVariant extends Model implements HasMedia { - use HasFactory, SoftDeletes; + use HasFactory, SoftDeletes, InteractsWithMedia; public function orderItems(): HasMany { diff --git a/app/Services/Admin/Master/ProductService.php b/app/Services/Admin/Master/ProductService.php new file mode 100644 index 0000000..2459f58 --- /dev/null +++ b/app/Services/Admin/Master/ProductService.php @@ -0,0 +1,235 @@ +with([ + 'categories:id,name', + 'productVariants:id,product_id,name,stock,reject_stock,retail_stock', + 'productVariants.productPrices:id,variant_id,type,price', + 'productVariants.media', + ]) + ->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status)) + ->when($filters['name'] ?? null, fn ($q, $name) => $q->where('name', 'like', "%{$name}%")) + ->latest() + ->get(); + + $products->each(function ($product) { + $product->productVariants->each(function ($variant) { + $media = $variant->getMedia('photos')->first(); + $variant->photo_url = $media + ? $this->s3Service->getTemporaryUrl($media->file_name) + : null; + }); + }); + + return $products; + } + + public function create(array $data): Product + { + return DB::transaction(function () use ($data) { + $product = Product::create([ + 'name' => $data['name'], + 'description' => $data['description'] ?? null, + 'status' => $data['status'] ?? 'active', + ]); + + $product->categories()->sync($data['category_ids']); + + $useSamePrice = $data['use_same_price'] ?? false; + + foreach ($data['variants'] as $index => $variantData) { + $variant = $product->productVariants()->create([ + 'name' => $variantData['name'], + 'stock' => $variantData['stock'], + 'reject_stock' => $variantData['reject_stock'], + 'retail_stock' => $variantData['retail_stock'], + ]); + + $prices = $useSamePrice + ? $data['shared_prices'] + : $variantData['prices']; + + foreach ($prices as $priceData) { + ProductPrice::create([ + 'variant_id' => $variant->id, + 'type' => $priceData['type'], + 'price' => $priceData['price'], + ]); + } + + if (! empty($variantData['photo_key'])) { + $this->registerPhotos($variant, [$variantData['photo_key']]); + } + } + + return $product; + }); + } + + public function getForEdit(Product $product): array + { + $product->load([ + 'categories:id,name', + 'productVariants.productPrices', + 'productVariants.media', + ]); + + $variants = $product->productVariants->map(function (ProductVariant $variant) { + $media = $variant->getMedia('photos')->first(); + return [ + 'id' => $variant->id, + 'name' => $variant->name, + 'stock' => $variant->stock, + 'reject_stock' => $variant->reject_stock, + 'retail_stock' => $variant->retail_stock, + 'photo_key' => $media?->file_name, + 'photo_url' => $media ? $this->s3Service->getTemporaryUrl($media->file_name) : null, + 'prices' => $variant->productPrices->map(fn ($p) => [ + 'type' => $p->type->value, + 'price' => $p->price, + ]), + ]; + }); + + return [ + 'id' => $product->id, + 'name' => $product->name, + 'description' => $product->description, + 'status' => $product->status->value, + 'category_ids' => $product->categories->pluck('id'), + 'product_variants' => $variants, + ]; + } + + public function update(Product $product, array $data): Product + { + return DB::transaction(function () use ($product, $data) { + $product->update([ + 'name' => $data['name'], + 'description' => $data['description'] ?? null, + 'status' => $data['status'] ?? $product->status, + ]); + + $product->categories()->sync($data['category_ids']); + + $useSamePrice = $data['use_same_price'] ?? false; + + $existingVariantIds = collect($data['variants']) + ->pluck('id') + ->filter() + ->toArray(); + + $product->productVariants() + ->whereNotIn('id', $existingVariantIds) + ->each(function (ProductVariant $variant) { + $variant->productPrices()->delete(); + $variant->clearMediaCollection('photos'); + $variant->delete(); + }); + + foreach ($data['variants'] as $variantData) { + $variantId = $variantData['id'] ?? null; + + $variant = $variantId + ? $product->productVariants()->findOrFail($variantId) + : $product->productVariants()->create([]); + + $variant->update([ + 'name' => $variantData['name'], + 'stock' => $variantData['stock'], + 'reject_stock' => $variantData['reject_stock'], + 'retail_stock' => $variantData['retail_stock'], + ]); + + $variant->productPrices()->delete(); + + $prices = $useSamePrice + ? $data['shared_prices'] + : $variantData['prices']; + + foreach ($prices as $priceData) { + ProductPrice::create([ + 'variant_id' => $variant->id, + 'type' => $priceData['type'], + 'price' => $priceData['price'], + ]); + } + + if (! empty($variantData['photo_key'])) { + $variant->clearMediaCollection('photos'); + $this->registerPhotos($variant, [$variantData['photo_key']]); + } + } + + return $product; + }); + } + + public function delete(Product $product): bool + { + return DB::transaction(function () use ($product) { + $product->productVariants->each(function (ProductVariant $variant) { + $variant->productPrices()->delete(); + $variant->clearMediaCollection('photos'); + $variant->delete(); + }); + + $product->categories()->detach(); + + return $product->delete(); + }); + } + + public function toggleStatus(Product $product): void + { + $product->update([ + 'status' => $product->status->value === 'active' ? 'inactive' : 'active', + ]); + } + + private function registerPhotos(ProductVariant $variant, array $photoKeys): void + { + foreach ($photoKeys as $order => $s3Key) { + $fileName = pathinfo($s3Key, PATHINFO_BASENAME); + $name = pathinfo($s3Key, PATHINFO_FILENAME); + + Media::create([ + 'model_type' => ProductVariant::class, + 'model_id' => $variant->id, + 'uuid' => Str::uuid(), + 'collection_name' => 'photos', + 'name' => $name, + 'file_name' => $s3Key, + 'mime_type' => 'image/jpeg', + 'disk' => 's3', + 'conversions_disk' => 's3', + 'size' => 0, + 'manipulations' => [], + 'custom_properties' => [], + 'generated_conversions' => [], + 'responsive_images' => [], + 'order_column' => $order + 1, + ]); + } + } +} diff --git a/database/migrations/2026_06_20_235900_create_product_prices_table.php b/database/migrations/2026_06_20_235900_create_product_prices_table.php index 4634192..3c26da5 100644 --- a/database/migrations/2026_06_20_235900_create_product_prices_table.php +++ b/database/migrations/2026_06_20_235900_create_product_prices_table.php @@ -19,7 +19,6 @@ public function up(): void $table->timestamp('created_at')->useCurrent(); $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); - $table->softDeletes(); $table->unique(['variant_id', 'type']); $table->index('variant_id'); diff --git a/resources/js/components/data-table.tsx b/resources/js/components/data-table.tsx index 5cffabb..825ef69 100644 --- a/resources/js/components/data-table.tsx +++ b/resources/js/components/data-table.tsx @@ -14,17 +14,15 @@ import { } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { - - - flexRender, getCoreRowModel, + getExpandedRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table'; -import type { ColumnDef, ColumnFiltersState, SortingState } from '@tanstack/react-table'; +import type { ColumnDef, ColumnFiltersState, ExpandedState, SortingState, Row } from '@tanstack/react-table'; import { GripVertical } from 'lucide-react'; import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'; import * as React from 'react'; @@ -50,6 +48,8 @@ interface DataTableProps { onReorder?: (items: TData[]) => void; getRowId?: (item: TData) => string | number; toolbar?: React.ReactNode; + renderSubRow?: (row: Row, searchValue?: string) => React.ReactNode; + defaultExpanded?: boolean; } const DragHandleContext = React.createContext<{ @@ -117,10 +117,20 @@ export function DataTable({ onReorder, getRowId, toolbar, + renderSubRow, + defaultExpanded = false, }: DataTableProps) { const [sorting, setSorting] = React.useState([]); const [columnFilters, setColumnFilters] = React.useState([]); + const [expanded, setExpanded] = React.useState(() => { + if (!defaultExpanded || !data.length) return {}; + const initial: Record = {}; + data.forEach((item, index) => { + initial[String(index)] = true; + }); + return initial; + }); const isSortable = !!onReorder && !!getRowId; @@ -151,6 +161,8 @@ export function DataTable({ columns: visibleColumns, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), + getExpandedRowModel: renderSubRow ? getExpandedRowModel() : undefined, + onExpandedChange: setExpanded, onSortingChange: setSorting, getSortedRowModel: getSortedRowModel(), onColumnFiltersChange: setColumnFilters, @@ -158,6 +170,7 @@ export function DataTable({ state: { sorting, columnFilters, + expanded, }, }); @@ -300,38 +313,51 @@ export function DataTable({ table .getRowModel() .rows.map((row) => ( - - {row - .getVisibleCells() - .map((cell) => ( - + + {row + .getVisibleCells() + .map((cell) => ( + + {flexRender( + cell.column .columnDef - .meta as { - className?: string; - } - )?.className - } + .cell, + cell.getContext(), + )} + + ))} + + {renderSubRow && row.getIsExpanded() && ( + + - {flexRender( - cell.column - .columnDef - .cell, - cell.getContext(), - )} +
+ {renderSubRow(row, (table.getColumn(searchKey ?? '')?.getFilterValue() as string) ?? '')} +
- ))} -
+
+ )} + )) ) ) : ( diff --git a/resources/js/components/file-upload.tsx b/resources/js/components/file-upload.tsx index 4501261..6246d48 100644 --- a/resources/js/components/file-upload.tsx +++ b/resources/js/components/file-upload.tsx @@ -17,6 +17,7 @@ type FileUploadProps = { onChange: (key: string | null) => void; folder?: string; accept?: string; + maxSize?: number; onUploadingChange?: (uploading: boolean) => void; existingUrl?: string | null; onFileMeta?: (meta: { size: number; type: string } | null) => void; @@ -34,7 +35,32 @@ function formatFileSize(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } -export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image/png,image/webp,image/gif', onUploadingChange, existingUrl, onFileMeta }: FileUploadProps) { +function acceptToLabels(accept: string): string[] { + const mimeMap: Record = { + 'image/jpeg': 'JPG', + 'image/png': 'PNG', + 'image/webp': 'WebP', + 'image/gif': 'GIF', + 'image/svg+xml': 'SVG', + 'application/pdf': 'PDF', + 'video/mp4': 'MP4', + 'application/zip': 'ZIP', + }; + + return accept + .split(',') + .map((mime) => mimeMap[mime.trim()] || mime.trim().split('/').pop()?.toUpperCase() || 'File') + .filter((v, i, a) => a.indexOf(v) === i); +} + +function formatMaxSize(bytes: number): string { + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(0)}KB`; + } + return `${(bytes / (1024 * 1024)).toFixed(0)}MB`; +} + +export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image/png,image/webp,image/gif', maxSize = 10 * 1024 * 1024, onUploadingChange, existingUrl, onFileMeta }: FileUploadProps) { const inputRef = useRef(null); const uploadId = useId(); const [uploading, setUploading] = useState(false); @@ -43,9 +69,12 @@ export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image const [fileSize, setFileSize] = useState(null); const [preview, setPreview] = useState(null); + const onUploadingChangeRef = useRef(onUploadingChange); + onUploadingChangeRef.current = onUploadingChange; + useEffect(() => { - onUploadingChange?.(uploading); - }, [uploading, onUploadingChange]); + onUploadingChangeRef.current?.(uploading); + }, [uploading]); async function handleFileChange(e: React.ChangeEvent) { const file = e.target.files?.[0]; @@ -149,7 +178,9 @@ export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image ) : ( <> Unggah File - Opsional · JPG, PNG, WebP, GIF · Maks 10MB + + {`${acceptToLabels(accept).join(', ')} · Maks ${formatMaxSize(maxSize)}`} + )} diff --git a/resources/js/components/rupiah-input.tsx b/resources/js/components/rupiah-input.tsx index ead5912..79236db 100644 --- a/resources/js/components/rupiah-input.tsx +++ b/resources/js/components/rupiah-input.tsx @@ -9,6 +9,8 @@ import { type RupiahInputProps = { name?: string; defaultValue?: number; + value?: number; + onValueChange?: (value: number) => void; placeholder?: string; disabled?: boolean; min?: number; @@ -29,14 +31,25 @@ function parseRupiah(value: string): number { export function RupiahInput({ name, defaultValue = 0, + value, + onValueChange, placeholder = '0', disabled = false, min, max, className, }: RupiahInputProps) { - const [displayValue, setDisplayValue] = useState(formatRupiah(defaultValue)); - const lastValidRef = useRef(defaultValue); + const isControlled = value !== undefined; + const [displayValue, setDisplayValue] = useState(formatRupiah(isControlled ? value : defaultValue)); + const lastValidRef = useRef(isControlled ? value : defaultValue); + + if (isControlled) { + const formatted = formatRupiah(value); + if (formatted !== displayValue) { + setDisplayValue(formatted); + lastValidRef.current = value; + } + } const handleChange = useCallback( (e: React.ChangeEvent) => { @@ -53,8 +66,9 @@ export function RupiahInput({ lastValidRef.current = clamped; setDisplayValue(formatRupiah(clamped)); + onValueChange?.(clamped); }, - [min, max], + [min, max, onValueChange], ); const handleBlur = useCallback(() => { diff --git a/resources/js/layouts/app/app-sidebar-layout.tsx b/resources/js/layouts/app/app-sidebar-layout.tsx index a6edb62..cfe43ab 100644 --- a/resources/js/layouts/app/app-sidebar-layout.tsx +++ b/resources/js/layouts/app/app-sidebar-layout.tsx @@ -11,7 +11,7 @@ export default function AppSidebarLayout({ return ( - + {children} diff --git a/resources/js/lib/upload.ts b/resources/js/lib/upload.ts index 1692c0a..2d54497 100644 --- a/resources/js/lib/upload.ts +++ b/resources/js/lib/upload.ts @@ -73,7 +73,5 @@ export async function uploadFile(file: File, folder?: string): Promise { } export function getTemporaryUrl(key: string, minutes = 60): string { - // This is a client-side helper - the actual presigned GET URL - // should be generated server-side via the Expense model accessor return `/api/presigned-url/${encodeURIComponent(key)}?minutes=${minutes}`; } diff --git a/resources/js/pages/admin/master/product/columns.tsx b/resources/js/pages/admin/master/product/columns.tsx new file mode 100644 index 0000000..603cce3 --- /dev/null +++ b/resources/js/pages/admin/master/product/columns.tsx @@ -0,0 +1,364 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import { router } from '@inertiajs/react'; +import { ArrowUpDown, ChevronRight, Pencil, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; + +export type ProductVariant = { + id: number; + name: string; + stock: number; + reject_stock: number; + retail_stock: number; + photo_url: string | null; + product_prices: { + id: number; + type: string; + type_label: string; + price: number; + }[]; +}; + +export type Product = { + id: number; + name: string; + slug: string; + description: string | null; + status: string; + categories: { + id: number; + name: string; + }[]; + product_variants: ProductVariant[]; +}; + +function getStatusLabel(status: string): string { + const labels: Record = { + active: 'Aktif', + inactive: 'Non Aktif', + draft: 'Draft', + }; + + return labels[status] ?? status; +} + +function getStatusVariant(status: string): string { + const variants: Record = { + active: 'bg-green-100 text-green-800', + inactive: 'bg-red-100 text-red-800', + draft: 'bg-yellow-100 text-yellow-800', + }; + + return variants[status] ?? 'bg-gray-100 text-gray-800'; +} + +function formatCurrency(amount: number): string { + return new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: 'IDR', + minimumFractionDigits: 0, + }).format(amount); +} + +function formatNumber(num: number): string { + return new Intl.NumberFormat('id-ID').format(num); +} + +function getFilteredVariants(allVariants: ProductVariant[], searchValue: string): ProductVariant[] { + const query = searchValue.toLowerCase().trim(); + return query + ? allVariants.filter((v) => v.name.toLowerCase().includes(query)) + : allVariants; +} + +type CreateColumnsParams = { + handleEdit: (product: Product) => void; + handleDeleteClick: (product: Product) => void; + toggleStatusUrl: (id: number) => string; +}; + +export function createProductColumns( + params: CreateColumnsParams, +): ColumnDef[] { + const { handleEdit, handleDeleteClick, toggleStatusUrl } = params; + + return [ + { + id: 'expand', + header: '', + cell: ({ row }) => { + const hasVariants = (row.original.product_variants?.length ?? 0) > 0; + + if (!hasVariants) { + return null; + } + + return ( + + ); + }, + meta: { + className: 'w-[40px]', + headerClassName: 'w-[40px]', + }, + }, + { + id: 'variant_names', + accessorFn: (row) => row.product_variants?.map((v) => v.name).join(' ') ?? '', + header: () => null, + cell: () => null, + meta: { + className: 'hidden', + headerClassName: 'hidden', + }, + }, + { + id: 'no', + header: () => No, + cell: ({ row }) => ( + + {row.index + 1} + + ), + meta: { + className: 'w-[50px] text-center', + headerClassName: 'w-[50px] text-center', + }, + }, + { + accessorKey: 'name', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const product = row.original; + + return ( +
+ + {product.name} + + + {product.categories?.map((c) => c.name).join(', ') || '-'} + +
+ ); + }, + }, + { + id: 'variants', + header: () => Varian, + cell: ({ row, table }) => { + const searchValue = (table.getColumn('variant_names')?.getFilterValue() as string) ?? ''; + const variants = getFilteredVariants(row.original.product_variants ?? [], searchValue); + + return ( + + {variants.length} varian + + ); + }, + }, + { + id: 'stock', + header: () => Stok Bagus, + meta: { + className: 'w-[80px] text-center', + headerClassName: 'w-[80px] text-center', + }, + cell: ({ row, table }) => { + const searchValue = (table.getColumn('variant_names')?.getFilterValue() as string) ?? ''; + const variants = getFilteredVariants(row.original.product_variants ?? [], searchValue); + const totalStock = variants.reduce((sum, v) => sum + (v.stock ?? 0), 0); + + return ( + + {formatNumber(totalStock)} + + ); + }, + }, + { + id: 'reject_stock', + header: () => Stok Reject, + meta: { + className: 'w-[80px] text-center', + headerClassName: 'w-[80px] text-center', + }, + cell: ({ row, table }) => { + const searchValue = (table.getColumn('variant_names')?.getFilterValue() as string) ?? ''; + const variants = getFilteredVariants(row.original.product_variants ?? [], searchValue); + const totalReject = variants.reduce((sum, v) => sum + (v.reject_stock ?? 0), 0); + + return ( + + {formatNumber(totalReject)} + + ); + }, + }, + { + id: 'retail_stock', + header: () => Stok Ecer, + meta: { + className: 'w-[80px] text-center', + headerClassName: 'w-[80px] text-center', + }, + cell: ({ row, table }) => { + const searchValue = (table.getColumn('variant_names')?.getFilterValue() as string) ?? ''; + const variants = getFilteredVariants(row.original.product_variants ?? [], searchValue); + const totalRetail = variants.reduce((sum, v) => sum + (v.retail_stock ?? 0), 0); + + return ( + + {formatNumber(totalRetail)} + + ); + }, + }, + { + id: 'total_stock', + header: () => Total Stok, + meta: { + className: 'w-[80px] text-center', + headerClassName: 'w-[80px] text-center', + }, + cell: ({ row, table }) => { + const searchValue = (table.getColumn('variant_names')?.getFilterValue() as string) ?? ''; + const variants = getFilteredVariants(row.original.product_variants ?? [], searchValue); + const totalStock = variants.reduce((sum, v) => sum + (v.stock ?? 0), 0); + const totalReject = variants.reduce((sum, v) => sum + (v.reject_stock ?? 0), 0); + const totalRetail = variants.reduce((sum, v) => sum + (v.retail_stock ?? 0), 0); + const total = totalStock + totalReject + totalRetail; + + return ( + + {formatNumber(total)} + + ); + }, + }, + { + accessorKey: 'status', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const product = row.original; + const isToggleable = product.status === 'active' || product.status === 'inactive'; + const isChecked = product.status === 'active'; + + function handleToggle(checked: boolean) { + router.post(toggleStatusUrl(product.id), {}, { + preserveScroll: true, + }); + } + + if (!isToggleable) { + return ( + + {getStatusLabel(product.status)} + + ); + } + + return ( +
+ + + {getStatusLabel(product.status)} + +
+ ); + }, + }, + { + id: 'actions', + header: () => Aksi, + meta: { + className: 'w-[100px] text-center', + headerClassName: 'w-[100px] text-center', + }, + cell: ({ row }) => { + const product = row.original; + + return ( + +
+ + + + + + Edit + + + + + + + + + Hapus + + +
+
+ ); + }, + }, + ]; +} diff --git a/resources/js/pages/admin/master/product/create.tsx b/resources/js/pages/admin/master/product/create.tsx new file mode 100644 index 0000000..b55fe57 --- /dev/null +++ b/resources/js/pages/admin/master/product/create.tsx @@ -0,0 +1,474 @@ +import InputError from '@/components/input-error'; +import { RupiahInput } from '@/components/rupiah-input'; +import { FileUpload } from '@/components/file-upload'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { Textarea } from '@/components/ui/textarea'; +import { index as productIndex, store } from '@/routes/admin/master/products'; +import { Form, Head } from '@inertiajs/react'; +import { ArrowLeft, Copy, ClipboardPaste, Check, Plus, Trash2 } from 'lucide-react'; +import { useCallback, useRef, useState } from 'react'; + +type Category = { + id: number; + name: string; +}; + +type Props = { + categories: Category[]; +}; + +const PRICE_TYPES = [ + { key: 'distributor', label: 'Distributor' }, + { key: 'agent', label: 'Agen' }, + { key: 'sub_agent', label: 'Sub Agen' }, + { key: 'wholesale', label: 'Grosir' }, + { key: 'retail', label: 'Ecer' }, + { key: 'tiktok', label: 'TikTok' }, + { key: 'shopee', label: 'Shopee' }, + { key: 'capital', label: 'Modal' }, + { key: 'reject', label: 'Reject' }, +]; + +function createEmptyPrices(): Array<{ type: string; price: number }> { + return PRICE_TYPES.map((pt) => ({ type: pt.key, price: 0 })); +} + +type VariantState = { + name: string; + stock: number; + reject_stock: number; + retail_stock: number; + photo: string | null; + uploading: boolean; + prices: Array<{ type: string; price: number }>; +}; + +export default function ProductCreate({ categories }: Props) { + const [categoryIds, setCategoryIds] = useState([]); + const [useSamePrice, setUseSamePrice] = useState(true); + const [sharedPrices, setSharedPrices] = useState>(createEmptyPrices()); + const [variants, setVariants] = useState([ + { + name: '', + stock: 0, + reject_stock: 0, + retail_stock: 0, + photo: null, + uploading: false, + prices: createEmptyPrices(), + }, + ]); + + const variantsRef = useRef(variants); + variantsRef.current = variants; + + const addVariant = useCallback(() => { + setVariants((prev) => [ + ...prev, + { + name: '', + stock: 0, + reject_stock: 0, + retail_stock: 0, + photo: null, + uploading: false, + prices: createEmptyPrices(), + }, + ]); + }, []); + + const removeVariant = useCallback((index: number) => { + setVariants((prev) => prev.filter((_, i) => i !== index)); + }, []); + + const updateVariant = useCallback((index: number, field: keyof VariantState, value: unknown) => { + setVariants((prev) => { + const updated = [...prev]; + (updated[index] as Record)[field] = value; + return updated; + }); + }, []); + + const updateVariantPrice = useCallback((variantIndex: number, priceIndex: number, value: number) => { + setVariants((prev) => { + const updated = [...prev]; + updated[variantIndex] = { + ...updated[variantIndex], + prices: updated[variantIndex].prices.map((p, i) => + i === priceIndex ? { ...p, price: value } : p + ), + }; + return updated; + }); + }, []); + + const updateSharedPrice = useCallback((priceIndex: number, value: number) => { + setSharedPrices((prev) => { + const updated = [...prev]; + updated[priceIndex] = { ...updated[priceIndex], price: value }; + return updated; + }); + }, []); + + const [copiedIndex, setCopiedIndex] = useState(null); + + const copyPrices = useCallback((variantIndex: number) => { + setVariants((prev) => { + const prices = prev[variantIndex].prices; + navigator.clipboard.writeText(JSON.stringify(prices)); + setCopiedIndex(variantIndex); + setTimeout(() => setCopiedIndex(null), 1500); + return prev; + }); + }, []); + + const pastePrices = useCallback((variantIndex: number) => { + navigator.clipboard.readText().then((text) => { + try { + const prices = JSON.parse(text) as Array<{ type: string; price: number }>; + setVariants((prev) => { + const updated = [...prev]; + updated[variantIndex] = { ...updated[variantIndex], prices }; + return updated; + }); + } catch { + // invalid clipboard data + } + }); + }, []); + + const applyToAll = useCallback((variantIndex: number) => { + setVariants((prev) => { + const sourcePrices = prev[variantIndex].prices; + return prev.map((v, i) => + i === variantIndex ? v : { ...v, prices: [...sourcePrices] } + ); + }); + }, []); + + function getPayload() { + return { + category_ids: categoryIds, + use_same_price: useSamePrice, + shared_prices: useSamePrice ? sharedPrices.map((p) => ({ + type: p.type, + price: Number(p.price), + })) : [], + variants: variantsRef.current.map((v) => ({ + name: v.name, + stock: Number(v.stock), + reject_stock: Number(v.reject_stock), + retail_stock: Number(v.retail_stock), + photo_key: v.photo, + prices: useSamePrice + ? [] + : v.prices.map((p) => ({ type: p.type, price: Number(p.price) })), + })), + }; + } + + return ( + <> + + +
+
+

Tambah Produk

+ +
+ +
({ + ...data, + ...getPayload(), + })} + > + {({ errors, processing }) => ( + <> +
+ + + Informasi Produk + + +
+ + + +
+
+ + +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ {categories.map((category) => ( + + ))} +
+ +
+
+ +