Refactor code structure for improved readability and maintainability

This commit is contained in:
Yoga Pangestu 2026-08-01 02:11:56 +07:00
parent c4db043fda
commit dc8ecf6c52
23 changed files with 4266 additions and 61 deletions

View File

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

View File

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

View File

@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers\Admin\Master;
use App\Enums\ProductStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Master\ProductRequest;
use App\Models\Product;
use App\Services\Admin\Master\CategoryService;
use App\Services\Admin\Master\ProductService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ProductController extends Controller
{
public function __construct(
private ProductService $service,
private CategoryService $categoryService,
) {}
public function index(Request $request): Response
{
return Inertia::render('admin/master/product/index', [
'products' => $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');
}
}

View File

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

View File

@ -0,0 +1,93 @@
<?php
namespace App\Http\Requests\Admin\Master;
use App\Enums\PriceType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Override;
class ProductRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
#[Override]
public function prepareForValidation()
{
$sharedPrices = $this->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',
];
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,235 @@
<?php
namespace App\Services\Admin\Master;
use App\Enums\PriceType;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\ProductPrice;
use App\Services\S3PresignedService;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class ProductService
{
public function __construct(
private S3PresignedService $s3Service = new S3PresignedService,
) {}
public function getAll(array $filters = []): Collection
{
$products = Product::select('id', 'name', 'slug', 'description', 'status')
->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,
]);
}
}
}

View File

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

View File

@ -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<TData, TValue> {
onReorder?: (items: TData[]) => void;
getRowId?: (item: TData) => string | number;
toolbar?: React.ReactNode;
renderSubRow?: (row: Row<TData>, searchValue?: string) => React.ReactNode;
defaultExpanded?: boolean;
}
const DragHandleContext = React.createContext<{
@ -117,10 +117,20 @@ export function DataTable<TData, TValue>({
onReorder,
getRowId,
toolbar,
renderSubRow,
defaultExpanded = false,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] =
React.useState<ColumnFiltersState>([]);
const [expanded, setExpanded] = React.useState<ExpandedState>(() => {
if (!defaultExpanded || !data.length) return {};
const initial: Record<string, boolean> = {};
data.forEach((item, index) => {
initial[String(index)] = true;
});
return initial;
});
const isSortable = !!onReorder && !!getRowId;
@ -151,6 +161,8 @@ export function DataTable<TData, TValue>({
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<TData, TValue>({
state: {
sorting,
columnFilters,
expanded,
},
});
@ -300,38 +313,51 @@ export function DataTable<TData, TValue>({
table
.getRowModel()
.rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() &&
'selected'
}
>
{row
.getVisibleCells()
.map((cell) => (
<TableCell
key={cell.id}
className={
(
cell
.column
<React.Fragment key={row.id}>
<TableRow
data-state={
row.getIsSelected() &&
'selected'
}
>
{row
.getVisibleCells()
.map((cell) => (
<TableCell
key={cell.id}
className={
(
cell
.column
.columnDef
.meta as {
className?: string;
}
)?.className
}
>
{flexRender(
cell.column
.columnDef
.meta as {
className?: string;
}
)?.className
}
.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
{renderSubRow && row.getIsExpanded() && (
<TableRow>
<TableCell
colSpan={visibleColumns.length}
className="bg-muted/50 p-0"
>
{flexRender(
cell.column
.columnDef
.cell,
cell.getContext(),
)}
<div className="p-4">
{renderSubRow(row, (table.getColumn(searchKey ?? '')?.getFilterValue() as string) ?? '')}
</div>
</TableCell>
))}
</TableRow>
</TableRow>
)}
</React.Fragment>
))
)
) : (

View File

@ -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<string, string> = {
'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<HTMLInputElement>(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<number | null>(null);
const [preview, setPreview] = useState<string | null>(null);
const onUploadingChangeRef = useRef(onUploadingChange);
onUploadingChangeRef.current = onUploadingChange;
useEffect(() => {
onUploadingChange?.(uploading);
}, [uploading, onUploadingChange]);
onUploadingChangeRef.current?.(uploading);
}, [uploading]);
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
@ -149,7 +178,9 @@ export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image
) : (
<>
<AttachmentTitle>Unggah File</AttachmentTitle>
<AttachmentDescription>Opsional · JPG, PNG, WebP, GIF · Maks 10MB</AttachmentDescription>
<AttachmentDescription>
{`${acceptToLabels(accept).join(', ')} · Maks ${formatMaxSize(maxSize)}`}
</AttachmentDescription>
</>
)}
</AttachmentContent>

View File

@ -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<HTMLInputElement>) => {
@ -53,8 +66,9 @@ export function RupiahInput({
lastValidRef.current = clamped;
setDisplayValue(formatRupiah(clamped));
onValueChange?.(clamped);
},
[min, max],
[min, max, onValueChange],
);
const handleBlur = useCallback(() => {

View File

@ -11,7 +11,7 @@ export default function AppSidebarLayout({
return (
<AppShell variant="sidebar">
<AppSidebar />
<AppContent variant="sidebar" className="overflow-x-hidden">
<AppContent variant="sidebar" className="min-h-svh overflow-x-hidden overflow-y-auto">
<AppSidebarHeader breadcrumbs={breadcrumbs} />
{children}
</AppContent>

View File

@ -73,7 +73,5 @@ export async function uploadFile(file: File, folder?: string): Promise<string> {
}
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}`;
}

View File

@ -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<string, string> = {
active: 'Aktif',
inactive: 'Non Aktif',
draft: 'Draft',
};
return labels[status] ?? status;
}
function getStatusVariant(status: string): string {
const variants: Record<string, string> = {
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<Product>[] {
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 (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => row.toggleExpanded()}
>
<ChevronRight
className={`h-4 w-4 transition-transform ${row.getIsExpanded() ? 'rotate-90' : ''}`}
/>
</Button>
);
},
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: () => <span className="block text-center">No</span>,
cell: ({ row }) => (
<span className="block text-center">
{row.index + 1}
</span>
),
meta: {
className: 'w-[50px] text-center',
headerClassName: 'w-[50px] text-center',
},
},
{
accessorKey: 'name',
header: ({ column }) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
}
>
<span>Nama Produk</span>
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => {
const product = row.original;
return (
<div className="flex flex-col">
<span className="font-medium">
{product.name}
</span>
<span className="text-xs text-muted-foreground">
{product.categories?.map((c) => c.name).join(', ') || '-'}
</span>
</div>
);
},
},
{
id: 'variants',
header: () => <span>Varian</span>,
cell: ({ row, table }) => {
const searchValue = (table.getColumn('variant_names')?.getFilterValue() as string) ?? '';
const variants = getFilteredVariants(row.original.product_variants ?? [], searchValue);
return (
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium">
{variants.length} varian
</span>
);
},
},
{
id: 'stock',
header: () => <span className="block text-center">Stok Bagus</span>,
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 (
<span className="block text-center font-medium">
{formatNumber(totalStock)}
</span>
);
},
},
{
id: 'reject_stock',
header: () => <span className="block text-center">Stok Reject</span>,
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 (
<span className="block text-center font-medium">
{formatNumber(totalReject)}
</span>
);
},
},
{
id: 'retail_stock',
header: () => <span className="block text-center">Stok Ecer</span>,
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 (
<span className="block text-center font-medium">
{formatNumber(totalRetail)}
</span>
);
},
},
{
id: 'total_stock',
header: () => <span className="block text-center">Total Stok</span>,
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 (
<span className="block text-center font-medium">
{formatNumber(total)}
</span>
);
},
},
{
accessorKey: 'status',
header: ({ column }) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
}
>
<span>Status</span>
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
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 (
<span className={`inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ${getStatusVariant(product.status)}`}>
{getStatusLabel(product.status)}
</span>
);
}
return (
<div className="flex items-center gap-2">
<Switch
size="sm"
checked={isChecked}
onCheckedChange={handleToggle}
/>
<span className={`text-xs font-medium ${isChecked ? 'text-green-700' : 'text-red-700'}`}>
{getStatusLabel(product.status)}
</span>
</div>
);
},
},
{
id: 'actions',
header: () => <span className="block text-center">Aksi</span>,
meta: {
className: 'w-[100px] text-center',
headerClassName: 'w-[100px] text-center',
},
cell: ({ row }) => {
const product = row.original;
return (
<TooltipProvider>
<div className="flex items-center justify-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(product)}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Edit
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteClick(product)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Hapus
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
);
},
},
];
}

View File

@ -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<number[]>([]);
const [useSamePrice, setUseSamePrice] = useState(true);
const [sharedPrices, setSharedPrices] = useState<Array<{ type: string; price: number }>>(createEmptyPrices());
const [variants, setVariants] = useState<VariantState[]>([
{
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<string, unknown>)[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<number | null>(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 (
<>
<Head title="Tambah Produk" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold tracking-tight">Tambah Produk</h2>
<Button asChild variant="outline">
<a href={productIndex.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
</a>
</Button>
</div>
<Form
action={store()}
transform={(data) => ({
...data,
...getPayload(),
})}
>
{({ errors, processing }) => (
<>
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Informasi Produk</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="name">
Nama Produk <span className="text-destructive">*</span>
</Label>
<Input
id="name"
name="name"
placeholder="Masukkan nama produk"
/>
<InputError message={errors.name} />
</div>
<div className="grid gap-2">
<Label>Status <span className="text-destructive">*</span></Label>
<RadioGroup name="status" defaultValue="active" className="flex gap-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="active" id="status-active" />
<Label htmlFor="status-active" className="font-normal">Aktif</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="inactive" id="status-inactive" />
<Label htmlFor="status-inactive" className="font-normal">Non Aktif</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="draft" id="status-draft" />
<Label htmlFor="status-draft" className="font-normal">Draft</Label>
</div>
</RadioGroup>
<InputError message={errors.status} />
</div>
<div className="grid gap-2 md:col-span-2">
<Label>Kategori <span className="text-destructive">*</span></Label>
<div className="flex flex-wrap gap-2">
{categories.map((category) => (
<label
key={category.id}
className="flex items-center space-x-2 rounded-md border px-3 py-2 text-sm"
>
<input
type="checkbox"
name="category_ids[]"
value={category.id}
checked={categoryIds.includes(category.id)}
onChange={(e) => {
setCategoryIds((prev) =>
e.target.checked
? [...prev, category.id]
: prev.filter((id) => id !== category.id)
);
}}
className="rounded"
/>
<span>{category.name}</span>
</label>
))}
</div>
<InputError message={errors.category_ids} />
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="description">Deskripsi</Label>
<Textarea
id="description"
name="description"
placeholder="Masukkan deskripsi produk"
rows={3}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Harga</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<RadioGroup
name="use_same_price"
value={useSamePrice ? '1' : '0'}
onValueChange={(val) => setUseSamePrice(val === '1')}
className="flex gap-6"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="1" id="price-same" />
<Label htmlFor="price-same" className="font-normal">Semua varian sama</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="0" id="price-different" />
<Label htmlFor="price-different" className="font-normal">Harga per varian</Label>
</div>
</RadioGroup>
{useSamePrice && (
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
{PRICE_TYPES.map((priceType, priceIndex) => (
<div key={priceType.key} className="grid gap-2">
<Label>Harga {priceType.label} <span className="text-destructive">*</span></Label>
<RupiahInput
value={sharedPrices[priceIndex]?.price ?? 0}
onValueChange={(val) => updateSharedPrice(priceIndex, val)}
/>
<InputError message={errors[`shared_prices.${priceIndex}.price`]} />
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Varian Produk</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{variants.map((variant, variantIndex) => (
<div key={variantIndex} className="rounded-lg border p-4 space-y-4">
<div className="flex items-center justify-between">
<h4 className="font-medium">Varian {variantIndex + 1}</h4>
<div className="flex items-center gap-1">
{!useSamePrice && (
<>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => copyPrices(variantIndex)}
>
{copiedIndex === variantIndex ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
Salin
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => pastePrices(variantIndex)}
>
<ClipboardPaste className="h-4 w-4" />
Tempel
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => applyToAll(variantIndex)}
>
Terapkan ke Semua
</Button>
</>
)}
{variantIndex > 0 && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() => removeVariant(variantIndex)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<div className="grid gap-2">
<Label>Nama Varian <span className="text-destructive">*</span></Label>
<Input
value={variant.name}
onChange={(e) => updateVariant(variantIndex, 'name', e.target.value)}
placeholder="Contoh: Ukuran L, Warna Merah"
/>
<InputError message={errors[`variants.${variantIndex}.name`]} />
</div>
<div className="grid gap-2">
<Label>Stok Bagus <span className="text-destructive">*</span></Label>
<Input
type="number"
min={0}
value={variant.stock}
onChange={(e) => updateVariant(variantIndex, 'stock', Number(e.target.value))}
/>
<InputError message={errors[`variants.${variantIndex}.stock`]} />
</div>
<div className="grid gap-2">
<Label>Stok Reject <span className="text-destructive">*</span></Label>
<Input
type="number"
min={0}
value={variant.reject_stock}
onChange={(e) => updateVariant(variantIndex, 'reject_stock', Number(e.target.value))}
/>
<InputError message={errors[`variants.${variantIndex}.reject_stock`]} />
</div>
<div className="grid gap-2">
<Label>Stok Ecer <span className="text-destructive">*</span></Label>
<Input
type="number"
min={0}
value={variant.retail_stock}
onChange={(e) => updateVariant(variantIndex, 'retail_stock', Number(e.target.value))}
/>
<InputError message={errors[`variants.${variantIndex}.retail_stock`]} />
</div>
</div>
<div className="grid gap-2">
<Label>Foto Varian <span className="text-destructive">*</span></Label>
<FileUpload
value={variant.photo}
onChange={(photo) => updateVariant(variantIndex, 'photo', photo)}
folder="product-variant"
onUploadingChange={(uploading) => updateVariant(variantIndex, 'uploading', uploading)}
/>
<InputError message={errors[`variants.${variantIndex}.photo_key`]} />
</div>
{!useSamePrice && (
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{PRICE_TYPES.map((priceType, priceIndex) => (
<div key={priceType.key} className="grid gap-2">
<Label>Harga {priceType.label} <span className="text-destructive">*</span></Label>
<RupiahInput
value={variant.prices[priceIndex]?.price ?? 0}
onValueChange={(val) => updateVariantPrice(variantIndex, priceIndex, val)}
/>
<InputError message={errors[`variants.${variantIndex}.prices.${priceIndex}.price`]} />
</div>
))}
</div>
)}
</div>
))}
<Button type="button" variant="outline" onClick={addVariant}>
<Plus className="h-4 w-4" />
Tambah Varian
</Button>
</CardContent>
</Card>
</div>
<div className="flex items-center gap-4 mt-6">
<Button type="submit" disabled={processing || variants.some((v) => v.uploading)}>
{processing
? 'Menyimpan...'
: variants.some((v) => v.uploading)
? 'Mengunggah...'
: 'Simpan'}
</Button>
</div>
</>
)}
</Form>
</div>
</>
);
}
ProductCreate.layout = {
breadcrumbs: [
{
title: 'Master',
href: productIndex.url(),
},
{
title: 'Produk',
href: productIndex.url(),
},
{
title: 'Tambah',
href: '#',
},
],
};

View File

@ -0,0 +1,528 @@
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, update } 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 ProductVariant = {
id: number;
name: string;
stock: number;
reject_stock: number;
retail_stock: number;
photo_key: string | null;
photo_url: string | null;
prices: Array<{ type: string; price: number }>;
};
type Props = {
product: {
id: number;
name: string;
description: string | null;
status: 'active' | 'inactive' | 'draft';
category_ids: number[];
product_variants: ProductVariant[];
};
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 }));
}
function arePricesEqual(a: Array<{ type: string; price: number }>, b: Array<{ type: string; price: number }>): boolean {
if (a.length !== b.length) return false;
return a.every((pa, i) => pa.type === b[i]?.type && pa.price === b[i]?.price);
}
type VariantState = {
id: number | null;
name: string;
stock: number;
reject_stock: number;
retail_stock: number;
photo: string | null;
photoUrl: string | null;
uploading: boolean;
prices: Array<{ type: string; price: number }>;
};
export default function ProductEdit({ product, categories }: Props) {
const initialVariants: VariantState[] = product.product_variants.map((v) => ({
id: v.id,
name: v.name,
stock: v.stock,
reject_stock: v.reject_stock,
retail_stock: v.retail_stock,
photo: v.photo_key,
photoUrl: v.photo_url,
uploading: false,
prices: v.prices.length > 0 ? v.prices : createEmptyPrices(),
}));
const allSamePrice = initialVariants.length > 1
? initialVariants.every((v) => arePricesEqual(v.prices, initialVariants[0].prices))
: true;
const [categoryIds, setCategoryIds] = useState<number[]>(product.category_ids);
const [useSamePrice, setUseSamePrice] = useState(allSamePrice);
const [sharedPrices, setSharedPrices] = useState<Array<{ type: string; price: number }>>(
initialVariants.length > 0 ? initialVariants[0].prices : createEmptyPrices()
);
const [variants, setVariants] = useState<VariantState[]>(
initialVariants.length > 0 ? initialVariants : [
{
id: null,
name: '',
stock: 0,
reject_stock: 0,
retail_stock: 0,
photo: null,
photoUrl: null,
uploading: false,
prices: createEmptyPrices(),
},
]
);
const variantsRef = useRef(variants);
variantsRef.current = variants;
const addVariant = useCallback(() => {
setVariants((prev) => [
...prev,
{
id: null,
name: '',
stock: 0,
reject_stock: 0,
retail_stock: 0,
photo: null,
photoUrl: 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<string, unknown>)[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<number | null>(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) => ({
id: v.id,
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 (
<>
<Head title="Edit Produk" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold tracking-tight">Edit Produk</h2>
<Button asChild variant="outline">
<a href={productIndex.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
</a>
</Button>
</div>
<Form
action={update(product.id)}
method="put"
transform={(data) => ({
...data,
...getPayload(),
})}
>
{({ errors, processing }) => (
<>
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Informasi Produk</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="name">
Nama Produk <span className="text-destructive">*</span>
</Label>
<Input
id="name"
name="name"
defaultValue={product.name}
placeholder="Masukkan nama produk"
/>
<InputError message={errors.name} />
</div>
<div className="grid gap-2">
<Label>Status <span className="text-destructive">*</span></Label>
<RadioGroup name="status" defaultValue={product.status} className="flex gap-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="active" id="status-active" />
<Label htmlFor="status-active" className="font-normal">Aktif</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="inactive" id="status-inactive" />
<Label htmlFor="status-inactive" className="font-normal">Non Aktif</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="draft" id="status-draft" />
<Label htmlFor="status-draft" className="font-normal">Draft</Label>
</div>
</RadioGroup>
<InputError message={errors.status} />
</div>
<div className="grid gap-2 md:col-span-2">
<Label>Kategori <span className="text-destructive">*</span></Label>
<div className="flex flex-wrap gap-2">
{categories.map((category) => (
<label
key={category.id}
className="flex items-center space-x-2 rounded-md border px-3 py-2 text-sm"
>
<input
type="checkbox"
name="category_ids[]"
value={category.id}
checked={categoryIds.includes(category.id)}
onChange={(e) => {
setCategoryIds((prev) =>
e.target.checked
? [...prev, category.id]
: prev.filter((id) => id !== category.id)
);
}}
className="rounded"
/>
<span>{category.name}</span>
</label>
))}
</div>
<InputError message={errors.category_ids} />
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="description">Deskripsi</Label>
<Textarea
id="description"
name="description"
defaultValue={product.description ?? ''}
placeholder="Masukkan deskripsi produk"
rows={3}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Harga</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<RadioGroup
name="use_same_price"
value={useSamePrice ? '1' : '0'}
onValueChange={(val) => setUseSamePrice(val === '1')}
className="flex gap-6"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="1" id="price-same" />
<Label htmlFor="price-same" className="font-normal">Semua varian sama</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="0" id="price-different" />
<Label htmlFor="price-different" className="font-normal">Harga per varian</Label>
</div>
</RadioGroup>
{useSamePrice && (
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{PRICE_TYPES.map((priceType, priceIndex) => (
<div key={priceType.key} className="grid gap-2">
<Label>Harga {priceType.label} <span className="text-destructive">*</span></Label>
<RupiahInput
value={sharedPrices[priceIndex]?.price ?? 0}
onValueChange={(val) => updateSharedPrice(priceIndex, val)}
/>
<InputError message={errors[`shared_prices.${priceIndex}.price`]} />
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Varian Produk</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{variants.map((variant, variantIndex) => (
<div key={variantIndex} className="rounded-lg border p-4 space-y-4">
<div className="flex items-center justify-between">
<h4 className="font-medium">Varian {variantIndex + 1}</h4>
<div className="flex items-center gap-1">
{!useSamePrice && (
<>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => copyPrices(variantIndex)}
>
{copiedIndex === variantIndex ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
Salin
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => pastePrices(variantIndex)}
>
<ClipboardPaste className="h-4 w-4" />
Tempel
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => applyToAll(variantIndex)}
>
Terapkan ke Semua
</Button>
</>
)}
{variantIndex > 0 && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() => removeVariant(variantIndex)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<div className="grid gap-2">
<Label>Nama Varian <span className="text-destructive">*</span></Label>
<Input
value={variant.name}
onChange={(e) => updateVariant(variantIndex, 'name', e.target.value)}
placeholder="Contoh: Ukuran L, Warna Merah"
/>
<InputError message={errors[`variants.${variantIndex}.name`]} />
</div>
<div className="grid gap-2">
<Label>Stok Bagus <span className="text-destructive">*</span></Label>
<Input
type="number"
min={0}
value={variant.stock}
onChange={(e) => updateVariant(variantIndex, 'stock', Number(e.target.value))}
/>
<InputError message={errors[`variants.${variantIndex}.stock`]} />
</div>
<div className="grid gap-2">
<Label>Stok Reject <span className="text-destructive">*</span></Label>
<Input
type="number"
min={0}
value={variant.reject_stock}
onChange={(e) => updateVariant(variantIndex, 'reject_stock', Number(e.target.value))}
/>
<InputError message={errors[`variants.${variantIndex}.reject_stock`]} />
</div>
<div className="grid gap-2">
<Label>Stok Ecer <span className="text-destructive">*</span></Label>
<Input
type="number"
min={0}
value={variant.retail_stock}
onChange={(e) => updateVariant(variantIndex, 'retail_stock', Number(e.target.value))}
/>
<InputError message={errors[`variants.${variantIndex}.retail_stock`]} />
</div>
</div>
<div className="grid gap-2">
<Label>Foto Varian <span className="text-destructive">*</span></Label>
<FileUpload
value={variant.photo}
onChange={(photo) => updateVariant(variantIndex, 'photo', photo)}
folder="product-variant"
existingUrl={variant.photoUrl}
onUploadingChange={(uploading) => updateVariant(variantIndex, 'uploading', uploading)}
/>
<InputError message={errors[`variants.${variantIndex}.photo_key`]} />
</div>
{!useSamePrice && (
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{PRICE_TYPES.map((priceType, priceIndex) => (
<div key={priceType.key} className="grid gap-2">
<Label>Harga {priceType.label} <span className="text-destructive">*</span></Label>
<RupiahInput
value={variant.prices[priceIndex]?.price ?? 0}
onValueChange={(val) => updateVariantPrice(variantIndex, priceIndex, val)}
/>
<InputError message={errors[`variants.${variantIndex}.prices.${priceIndex}.price`]} />
</div>
))}
</div>
)}
</div>
))}
<Button type="button" variant="outline" onClick={addVariant}>
<Plus className="h-4 w-4" />
Tambah Varian
</Button>
</CardContent>
</Card>
</div>
<div className="flex items-center gap-4 mt-6">
<Button type="submit" disabled={processing || variants.some((v) => v.uploading)}>
{processing
? 'Menyimpan...'
: variants.some((v) => v.uploading)
? 'Mengunggah...'
: 'Simpan'}
</Button>
</div>
</>
)}
</Form>
</div>
</>
);
}
ProductEdit.layout = {
breadcrumbs: [
{
title: 'Master',
href: productIndex.url(),
},
{
title: 'Produk',
href: productIndex.url(),
},
{
title: 'Edit',
href: '#',
},
],
};

View File

@ -0,0 +1,320 @@
import { ConfirmDialog } from '@/components/confirm-dialog';
import { DataTable } from '@/components/data-table';
import { ImagePreviewModal } from '@/components/image-preview-modal';
import { Button } from '@/components/ui/button';
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from '@/components/ui/combobox';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { destroy, create as productCreate, index as productIndex, edit as productEdit, toggleStatus } from '@/routes/admin/master/products';
import { Head, router } from '@inertiajs/react';
import type { Row } from '@tanstack/react-table';
import { Filter, Plus, X } from 'lucide-react';
import { useMemo, useState } from 'react';
import type { Product } from './columns';
import { createProductColumns } from './columns';
type Props = {
products: Product[];
filters: {
status?: string;
name?: string;
};
};
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 VariantPhotoPreview({ url, title }: { url: string; title: string }) {
const [open, setOpen] = useState(false);
return (
<>
<button
onClick={() => setOpen(true)}
className="block h-10 w-10 overflow-hidden rounded-md border bg-muted transition-opacity hover:opacity-80"
>
<img
src={url}
alt={title}
className="h-full w-full object-cover"
/>
</button>
<ImagePreviewModal
open={open}
onOpenChange={setOpen}
src={url}
title={title}
/>
</>
);
}
function VariantSubRow({ row, searchValue }: { row: Row<Product>; searchValue?: string }) {
const allVariants = row.original.product_variants ?? [];
const query = (searchValue ?? '').toLowerCase().trim();
const variants = query
? allVariants.filter((v) => v.name.toLowerCase().includes(query))
: allVariants;
return (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px]">Foto</TableHead>
<TableHead className="w-[200px]">Nama Varian</TableHead>
<TableHead className="text-center">Stok Bagus</TableHead>
<TableHead className="text-center">Stok Reject</TableHead>
<TableHead className="text-center">Stok Ecer</TableHead>
<TableHead>Harga</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{variants.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
Tidak ada varian.
</TableCell>
</TableRow>
) : (
variants.map((variant) => (
<TableRow key={variant.id}>
<TableCell>
{variant.photo_url ? (
<VariantPhotoPreview
url={variant.photo_url}
title={variant.name}
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
N/A
</div>
)}
</TableCell>
<TableCell className="font-medium">{variant.name}</TableCell>
<TableCell className="text-center">{formatNumber(variant.stock)}</TableCell>
<TableCell className="text-center">{formatNumber(variant.reject_stock)}</TableCell>
<TableCell className="text-center">{formatNumber(variant.retail_stock)}</TableCell>
<TableCell>
{variant.product_prices?.length > 0 ? (
<div className="flex flex-col gap-0.5">
{variant.product_prices.map((p) => (
<span key={p.id} className="text-xs">
<span className="text-muted-foreground">{p.type_label}:</span>{' '}
{formatCurrency(p.price)}
</span>
))}
</div>
) : '-'}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
);
}
export default function ProductIndex({ products, filters }: Props) {
const [deleting, setDeleting] = useState<Product | null>(null);
const [filterOpen, setFilterOpen] = useState(false);
const hasActiveFilters = filters.status || filters.name;
const productNames = useMemo(() => {
const names = products.map((p) => p.name);
return [...new Set(names)].sort();
}, [products]);
function applyFilter(key: string, value: string) {
const newFilters = { ...filters };
if (value === '' || value === 'all') {
delete newFilters[key as keyof typeof newFilters];
} else {
newFilters[key as keyof typeof newFilters] = value;
}
router.get(productIndex(), newFilters, {
preserveState: true,
replace: true,
});
}
function clearFilters() {
router.get(productIndex(), {}, {
preserveState: true,
replace: true,
});
setFilterOpen(false);
}
function handleDelete() {
if (!deleting) {
return;
}
router.delete(destroy.url(deleting.id), {
onSuccess: () => setDeleting(null),
});
}
const columns = createProductColumns({
handleEdit: (product) => {
window.location.href = productEdit.url(product.id);
},
handleDeleteClick: (product) => setDeleting(product),
toggleStatusUrl: (id) => toggleStatus.url(id),
});
const filterToolbar = (
<Popover open={filterOpen} onOpenChange={setFilterOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
<Filter className="h-4 w-4" />
Filter
{hasActiveFilters && (
<span className="ml-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">
{Object.values(filters).filter(Boolean).length}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64" align="end">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Filter</span>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={clearFilters}
>
<X className="mr-1 h-3 w-3" />
Hapus Semua
</Button>
)}
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Nama Produk
</label>
<Combobox
value={filters.name ?? ''}
onValueChange={(value) => applyFilter('name', value as string)}
>
<ComboboxInput placeholder="Pilih produk..." className="w-full" />
<ComboboxContent>
<ComboboxEmpty>Tidak ada produk ditemukan.</ComboboxEmpty>
<ComboboxList>
{productNames.map((name) => (
<ComboboxItem key={name} value={name}>
{name}
</ComboboxItem>
))}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Status
</label>
<Select
value={filters.status ?? 'all'}
onValueChange={(value) => applyFilter('status', value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Status</SelectItem>
<SelectItem value="active">Aktif</SelectItem>
<SelectItem value="inactive">Non Aktif</SelectItem>
<SelectItem value="draft">Draft</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</PopoverContent>
</Popover>
);
return (
<>
<Head title="Produk" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
Produk
</h2>
</div>
<Button asChild>
<a href={productCreate.url()}>
<Plus className="h-4 w-4" />
Tambah
</a>
</Button>
</div>
<DataTable
columns={columns}
data={products}
searchKey="variant_names"
searchPlaceholder="Cari varian..."
emptyText="Belum ada data produk."
renderSubRow={(row, searchValue) => <VariantSubRow row={row} searchValue={searchValue} />}
defaultExpanded
toolbar={filterToolbar}
/>
<ConfirmDialog
open={deleting !== null}
onOpenChange={(open) => {
if (!open) {
setDeleting(null);
}
}}
title="Hapus Produk"
description={`Apakah Anda yakin ingin menghapus produk "${deleting?.name}"? Tindakan ini tidak dapat dibatalkan.`}
confirmLabel="Hapus"
onConfirm={handleDelete}
/>
</div>
</>
);
}
ProductIndex.layout = {
breadcrumbs: [
{
title: 'Master',
href: productIndex.url(),
},
{
title: 'Produk',
href: productIndex.url(),
},
],
};

View File

@ -9,4 +9,5 @@
Route::get('/presigned-url/{key}', [PresignedUrlController::class, 'show'])
->middleware(['web', 'auth', 'verified'])
->name('presigned-url.show');
->name('presigned-url.show')
->where('key', '.*');

View File

@ -12,6 +12,7 @@
use App\Http\Controllers\Admin\HR\LeaveRequestController;
use App\Http\Controllers\Admin\Master\CategoryController;
use App\Http\Controllers\Admin\Master\CustomerController;
use App\Http\Controllers\Admin\Master\ProductController;
use App\Http\Controllers\Admin\Master\SupplierController;
use App\Http\Controllers\Admin\RoleController;
use Illuminate\Support\Facades\Route;
@ -29,9 +30,11 @@
Route::inertia('dashboard', 'dashboard')->name('dashboard');
Route::prefix('admin/master')->name('admin.master.')->group(function () {
Route::resource('categories', CategoryController::class)->except(['show', 'create', 'edit'])->middleware('permission:category.view|category.create|category.update|category.delete');
Route::resource('suppliers', SupplierController::class)->except(['show', 'create', 'edit'])->middleware('permission:supplier.view|supplier.create|supplier.update|supplier.delete');
Route::resource('customers', CustomerController::class)->except(['show', 'create', 'edit'])->middleware('permission:customer.view|customer.create|customer.update|customer.delete');
Route::resource('categories', CategoryController::class)->except(['show', 'create', 'edit']);
Route::resource('products', ProductController::class)->except(['show']);
Route::post('products/{product}/toggle-status', [ProductController::class, 'toggleStatus'])->name('products.toggle-status');
Route::resource('suppliers', SupplierController::class)->except(['show', 'create', 'edit']);
Route::resource('customers', CustomerController::class)->except(['show', 'create', 'edit']);
});
Route::prefix('admin/finance')->name('admin.finance.')->group(function () {

File diff suppressed because it is too large Load Diff