diff --git a/app/Http/Controllers/Admin/Master/ProductController.php b/app/Http/Controllers/Admin/Master/Product/ProductController.php similarity index 88% rename from app/Http/Controllers/Admin/Master/ProductController.php rename to app/Http/Controllers/Admin/Master/Product/ProductController.php index a10f081..4984758 100644 --- a/app/Http/Controllers/Admin/Master/ProductController.php +++ b/app/Http/Controllers/Admin/Master/Product/ProductController.php @@ -1,13 +1,13 @@ $this->service->paginated( ...$request->validatedWithDefaults(), - filters: $request->only(['status']), + filters: $request->only(['status', 'stock', 'category']), ), - 'filters' => $request->only(['status']), + 'categories' => $this->categoryService->getAll(), + 'filters' => $request->only(['status', 'stock', 'category']), ]); } diff --git a/app/Http/Controllers/Admin/Master/Product/ProductVariantController.php b/app/Http/Controllers/Admin/Master/Product/ProductVariantController.php new file mode 100644 index 0000000..2b001be --- /dev/null +++ b/app/Http/Controllers/Admin/Master/Product/ProductVariantController.php @@ -0,0 +1,44 @@ + $this->variantService->getForEdit($variant), + ]); + } + + public function update(ProductVariantRequest $request, Product $product, ProductVariant $variant): RedirectResponse + { + return $this->handleAction( + fn () => $this->variantService->update($variant, $request->validated()), + 'Varian berhasil diperbarui.', + 'admin.master.products.index' + ); + } + + public function destroy(Product $product, ProductVariant $variant): RedirectResponse + { + return $this->handleAction( + fn () => $this->variantService->delete($product, $variant), + 'Varian berhasil dihapus.', + 'admin.master.products.index' + ); + } +} diff --git a/app/Http/Requests/Admin/Master/Product/ProductVariantRequest.php b/app/Http/Requests/Admin/Master/Product/ProductVariantRequest.php new file mode 100644 index 0000000..81004f3 --- /dev/null +++ b/app/Http/Requests/Admin/Master/Product/ProductVariantRequest.php @@ -0,0 +1,55 @@ +prices; + if (is_array($prices)) { + foreach ($prices as $i => $price) { + if (isset($price['price']) && is_string($price['price'])) { + $this->request->set("prices.$i.price", (int) str_replace('.', '', $price['price'])); + } + } + } + } + + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:200'], + 'stock' => ['required', 'integer', 'min:0'], + 'reject_stock' => ['required', 'integer', 'min:0'], + 'retail_stock' => ['required', 'integer', 'min:0'], + 'photo_key' => ['required', 'string', 'max:500'], + 'prices' => ['required', 'array', 'size:9'], + 'prices.*.type' => ['required', Rule::in(PriceType::values())], + 'prices.*.price' => ['required', 'integer', 'min:0'], + ]; + } + + public function attributes(): array + { + return [ + 'name' => 'Nama Varian', + 'stock' => 'Stok Bagus', + 'reject_stock' => 'Stok Reject', + 'retail_stock' => 'Stok Ecer', + 'photo_key' => 'Foto', + 'prices' => 'Harga', + 'prices.*.type' => 'Tipe Harga', + 'prices.*.price' => 'Harga', + ]; + } +} diff --git a/app/Services/Admin/Master/ProductService.php b/app/Services/Admin/Master/Product/ProductService.php similarity index 74% rename from app/Services/Admin/Master/ProductService.php rename to app/Services/Admin/Master/Product/ProductService.php index d7735f3..b6778ee 100644 --- a/app/Services/Admin/Master/ProductService.php +++ b/app/Services/Admin/Master/Product/ProductService.php @@ -1,22 +1,19 @@ each(function ($product) { $product->productVariants->each(function ($variant) { - $media = $variant->getMedia('photos')->first(); - $variant->photo_url = $media - ? $this->s3Service->getTemporaryUrl($media->file_name) - : null; + $variant->photo_url = $this->variantService->getTemporaryUrl($variant); }); }); @@ -56,15 +50,21 @@ public function paginated(int $perPage = 15, string $search = '', string $sort = ]) ->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")) ->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status)) + ->when($filters['category'] ?? null, fn ($q, $categoryId) => $q->whereHas('categories', function ($cq) use ($categoryId) { + $cq->where('categories.id', $categoryId); + })) + ->when(($filters['stock'] ?? null) === 'empty', function ($q) { + $q->whereRaw('(SELECT IFNULL(SUM(stock), 0) FROM product_variants WHERE product_variants.product_id = products.id AND product_variants.deleted_at IS NULL) = 0'); + }) + ->when(($filters['stock'] ?? null) === 'low', function ($q) { + $q->whereRaw('(SELECT IFNULL(SUM(stock), 0) FROM product_variants WHERE product_variants.product_id = products.id AND product_variants.deleted_at IS NULL) BETWEEN 1 AND 9'); + }) ->orderBy($sort, $direction) ->paginate($perPage); $paginator->getCollection()->each(function ($product) { $product->productVariants->each(function ($variant) { - $media = $variant->getMedia('photos')->first(); - $variant->photo_url = $media - ? $this->s3Service->getTemporaryUrl($media->file_name) - : null; + $variant->photo_url = $this->variantService->getTemporaryUrl($variant); }); }); @@ -105,7 +105,7 @@ public function create(array $data): Product } if (! empty($variantData['photo_key'])) { - $this->registerPhotos($variant, [$variantData['photo_key']]); + $this->variantService->registerPhotos($variant, [$variantData['photo_key']]); } } @@ -131,16 +131,14 @@ public function getForEdit(Product $product): array ]); $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, + 'photo_key' => $variant->getMedia('photos')->first()?->file_name, + 'photo_url' => $this->variantService->getTemporaryUrl($variant), 'prices' => $variant->productPrices->map(fn ($p) => [ 'type' => $p->type->value, 'price' => $p->price, @@ -187,16 +185,22 @@ public function update(Product $product, array $data): Product 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'], - ]); + if ($variantId) { + $variant = $product->productVariants()->findOrFail($variantId); + $variant->update([ + 'name' => $variantData['name'], + 'stock' => $variantData['stock'], + 'reject_stock' => $variantData['reject_stock'], + 'retail_stock' => $variantData['retail_stock'], + ]); + } else { + $variant = $product->productVariants()->create([ + 'name' => $variantData['name'], + 'stock' => $variantData['stock'], + 'reject_stock' => $variantData['reject_stock'], + 'retail_stock' => $variantData['retail_stock'], + ]); + } $variant->productPrices()->delete(); @@ -214,7 +218,7 @@ public function update(Product $product, array $data): Product if (! empty($variantData['photo_key'])) { $variant->clearMediaCollection('photos'); - $this->registerPhotos($variant, [$variantData['photo_key']]); + $this->variantService->registerPhotos($variant, [$variantData['photo_key']]); } } @@ -233,7 +237,7 @@ public function update(Product $product, array $data): Product public function delete(Product $product): bool { - return DB::transaction(function () use ($product) { + $result = DB::transaction(function () use ($product) { $product->productVariants->each(function (ProductVariant $variant) { $variant->productPrices()->delete(); $variant->clearMediaCollection('photos'); @@ -244,6 +248,15 @@ public function delete(Product $product): bool return $product->delete(); }); + + NotificationService::notify( + roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + title: 'Produk Dihapus', + body: "Produk \"{$product->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.', + url: route('admin.master.products.index'), + ); + + return $result; } public function toggleStatus(Product $product): void @@ -252,30 +265,4 @@ public function toggleStatus(Product $product): void '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/app/Services/Admin/Master/Product/ProductVariantService.php b/app/Services/Admin/Master/Product/ProductVariantService.php new file mode 100644 index 0000000..a2ea25e --- /dev/null +++ b/app/Services/Admin/Master/Product/ProductVariantService.php @@ -0,0 +1,129 @@ +load('productPrices'); + + $media = $variant->getMedia('photos')->first(); + + return [ + 'id' => $variant->id, + 'product_id' => $variant->product_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, + ]), + ]; + } + + public function update(ProductVariant $variant, array $data): ProductVariant + { + DB::transaction(function () use ($variant, $data) { + $variant->update([ + 'name' => $data['name'], + 'stock' => $data['stock'], + 'reject_stock' => $data['reject_stock'], + 'retail_stock' => $data['retail_stock'], + ]); + + $variant->productPrices()->delete(); + + foreach ($data['prices'] as $priceData) { + ProductPrice::create([ + 'variant_id' => $variant->id, + 'type' => $priceData['type'], + 'price' => $priceData['price'], + ]); + } + + if (! empty($data['photo_key'])) { + $variant->clearMediaCollection('photos'); + $this->registerPhotos($variant, [$data['photo_key']]); + } + }); + + NotificationService::notify( + roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + title: 'Varian Diperbarui', + body: "Varian \"{$variant->name}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.', + url: route('admin.master.products.index'), + ); + + return $variant->fresh(); + } + + public function delete(Product $product, ProductVariant $variant): bool + { + $result = DB::transaction(function () use ($variant) { + $variant->productPrices()->delete(); + $variant->clearMediaCollection('photos'); + + return $variant->delete(); + }); + + NotificationService::notify( + roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + title: 'Varian Dihapus', + body: "Varian \"{$variant->name}\" dari produk \"{$product->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.', + url: route('admin.master.products.index'), + ); + + return $result; + } + + public 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, + ]); + } + } + + public function getTemporaryUrl(ProductVariant $variant): ?string + { + $media = $variant->getMedia('photos')->first(); + + return $media ? $this->s3Service->getTemporaryUrl($media->file_name) : null; + } +} diff --git a/resources/js/components/card-table.tsx b/resources/js/components/card-table.tsx new file mode 100644 index 0000000..201578b --- /dev/null +++ b/resources/js/components/card-table.tsx @@ -0,0 +1,235 @@ +import { + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, + Search, +} from 'lucide-react'; +import * as React from 'react'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +export interface PaginationState { + current_page: number; + last_page: number; + per_page: number; + total: number; +} + +interface CardRenderContext { + item: TData; + index: number; + isExpanded: boolean; + onToggleExpand: () => void; +} + +interface CardTableProps { + data: TData[]; + getItemKey: (item: TData) => number | string; + + renderCard: (ctx: CardRenderContext) => React.ReactNode; + renderSubContent: (item: TData) => React.ReactNode; + + expandedKeys: Set | 'all'; + onToggleExpand: (key: number | string) => void; + + searchPlaceholder?: string; + searchValue?: string; + onSearchChange?: (value: string) => void; + + toolbar?: React.ReactNode; + + pagination?: PaginationState; + onPageChange?: (page: number) => void; + onPerPageChange?: (perPage: number) => void; + + emptyText?: string; +} + +function useDebounce(callback: (value: string) => void, delay: number) { + const timeoutRef = React.useRef | null>(null); + + return React.useCallback( + (value: string) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + callback(value); + }, delay); + }, + [callback, delay], + ); +} + +export function CardTable({ + data, + getItemKey, + renderCard, + renderSubContent, + expandedKeys, + onToggleExpand, + searchPlaceholder = 'Cari...', + searchValue, + onSearchChange, + toolbar, + pagination, + onPageChange, + onPerPageChange, + emptyText = 'Tidak ada data.', +}: CardTableProps) { + const [localSearch, setLocalSearch] = React.useState(searchValue ?? ''); + + React.useEffect(() => { + setLocalSearch(searchValue ?? ''); + }, [searchValue]); + + const isServerMode = !!pagination && !!onPageChange; + + const handleSearchDebounced = useDebounce( + (value: string) => onSearchChange?.(value), + 300, + ); + + function handleSearchChange(value: string) { + setLocalSearch(value); + if (isServerMode) { + handleSearchDebounced(value); + } + } + + function isItemExpanded(key: number | string): boolean { + if (expandedKeys === 'all') return true; + return expandedKeys.has(key); + } + + const totalPages = pagination?.last_page ?? 1; + const currentPage = pagination?.current_page ?? 1; + + return ( + <> + {(onSearchChange || toolbar || isServerMode) && ( +
+ {onSearchChange && ( +
+ + + handleSearchChange(e.target.value) + } + className="pl-9" + /> +
+ )} + {toolbar} +
+ {isServerMode && onPerPageChange && ( + + )} +
+
+ )} + +
+ {data.length === 0 ? ( + + + {emptyText} + + + ) : ( + data.map((item, index) => { + const key = getItemKey(item); + const isExpanded = isItemExpanded(key); + + return ( +
+ {renderCard({ + item, + index, + isExpanded, + onToggleExpand: () => onToggleExpand(key), + })} + {isExpanded && ( +
+ {renderSubContent(item)} +
+ )} +
+ ); + }) + )} +
+ + {isServerMode && ( +
+ + Halaman {currentPage} dari {totalPages} + +
+ + + + +
+
+ )} + + ); +} diff --git a/resources/js/components/hooks/use-card-table-expand.ts b/resources/js/components/hooks/use-card-table-expand.ts new file mode 100644 index 0000000..a99e4a2 --- /dev/null +++ b/resources/js/components/hooks/use-card-table-expand.ts @@ -0,0 +1,48 @@ +import { useCallback, useState } from 'react'; + +type ExpandState = Set | 'all'; + +export function useCardTableExpand( + defaultExpanded: boolean | (number | string)[] = false, +) { + const [expandedKeys, setExpandedKeys] = useState(() => { + if (defaultExpanded === true) return 'all'; + if (Array.isArray(defaultExpanded)) return new Set(defaultExpanded); + return new Set(); + }); + + const toggleExpand = useCallback((key: number | string) => { + setExpandedKeys((prev) => { + if (prev === 'all') { + return new Set([key]); + } + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }, []); + + const expandAll = useCallback((keys: (number | string)[]) => { + setExpandedKeys(new Set(keys)); + }, []); + + const collapseAll = useCallback(() => { + setExpandedKeys(new Set()); + }, []); + + const isExpanded = useCallback( + (key: number | string): boolean => { + if (expandedKeys === 'all') return true; + return expandedKeys.has(key); + }, + [expandedKeys], + ); + + return { expandedKeys, toggleExpand, expandAll, collapseAll, isExpanded }; +} + +export type { ExpandState }; diff --git a/resources/js/components/notification-bell.tsx b/resources/js/components/notification-bell.tsx index 67cfb64..405dc45 100644 --- a/resources/js/components/notification-bell.tsx +++ b/resources/js/components/notification-bell.tsx @@ -161,7 +161,7 @@ export function NotificationBell() { Notifikasi - +
Notifikasi {unreadCount > 0 && ( diff --git a/resources/js/pages/admin/master/product/columns.tsx b/resources/js/pages/admin/master/product/columns.tsx index 019cbd8..6dcd8b4 100644 --- a/resources/js/pages/admin/master/product/columns.tsx +++ b/resources/js/pages/admin/master/product/columns.tsx @@ -83,13 +83,24 @@ function getFilteredVariants( type CreateColumnsParams = { handleEdit: (product: Product) => void; handleDeleteClick: (product: Product) => void; + handleVariantEdit: (product: Product) => void; + handleVariantDeleteClick: ( + product: Product, + variant: ProductVariant, + ) => void; toggleStatusUrl: (id: number) => string; }; export function createProductColumns( params: CreateColumnsParams, ): ColumnDef[] { - const { handleEdit, handleDeleteClick, toggleStatusUrl } = params; + const { + handleEdit, + handleDeleteClick, + handleVariantEdit, + handleVariantDeleteClick, + toggleStatusUrl, + } = params; return [ { diff --git a/resources/js/pages/admin/master/product/index.tsx b/resources/js/pages/admin/master/product/index.tsx index b45e353..338cfe6 100644 --- a/resources/js/pages/admin/master/product/index.tsx +++ b/resources/js/pages/admin/master/product/index.tsx @@ -1,11 +1,9 @@ import { Head, router } from '@inertiajs/react'; -import type { Row } from '@tanstack/react-table'; import { Filter, Plus, X } from 'lucide-react'; import { useCallback, useMemo, useState } from 'react'; +import { CardTable } from '@/components/card-table'; +import { useCardTableExpand } from '@/components/hooks/use-card-table-expand'; import { ConfirmDialog } from '@/components/confirm-dialog'; -import type { PaginationState } from '@/components/data-table'; -import { DataTable } from '@/components/data-table'; -import { ImagePreviewModal } from '@/components/image-preview-modal'; import { Button } from '@/components/ui/button'; import { Combobox, @@ -27,14 +25,6 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; import { destroy, create as productCreate, @@ -42,8 +32,13 @@ import { edit as productEdit, toggleStatus, } from '@/routes/admin/master/products'; -import type { Product } from './columns'; -import { createProductColumns } from './columns'; +import { + destroy as variantDestroy, + edit as variantEdit, +} from '@/routes/admin/master/products/variants'; +import type { Product, ProductVariant } from './columns'; +import { ProductCardRow } from './product-card'; +import { VariantSubRow } from './variant/sub-row'; type Props = { products: { @@ -53,145 +48,31 @@ type Props = { per_page: number; total: number; }; + categories: { + id: number; + name: string; + }[]; filters: { status?: string; name?: string; + stock?: string; + category?: 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 ( - <> - - - - ); -} - -function VariantSubRow({ - row, - searchValue, -}: { - row: Row; - 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 ( - - - - Foto - Nama Varian - Stok Bagus - Stok Reject - Stok Ecer - Harga - - - - {variants.length === 0 ? ( - - - Tidak ada varian. - - - ) : ( - variants.map((variant) => ( - - - {variant.photo_url ? ( - - ) : ( -
- N/A -
- )} -
- - {variant.name} - - - {formatNumber(variant.stock)} - - - {formatNumber(variant.reject_stock)} - - - {formatNumber(variant.retail_stock)} - - - {variant.product_prices?.length > 0 ? ( -
- {variant.product_prices.map((p) => ( - - - {p.type_label}: - {' '} - {formatCurrency(p.price)} - - ))} -
- ) : ( - '-' - )} -
-
- )) - )} -
-
- ); -} - -export default function ProductIndex({ products, filters }: Props) { +export default function ProductIndex({ products, categories, filters }: Props) { const [deleting, setDeleting] = useState(null); + const [deletingVariant, setDeletingVariant] = useState<{ + product: Product; + variant: ProductVariant; + } | null>(null); const [filterOpen, setFilterOpen] = useState(false); const [search, setSearch] = useState(''); - const hasActiveFilters = filters.status || filters.name; + const expand = useCardTableExpand(true); + const hasActiveFilters = + filters.status || filters.name || filters.stock || filters.category; - const pagination: PaginationState = { + const pagination = { current_page: products.current_page, last_page: products.last_page, per_page: products.per_page, @@ -200,7 +81,6 @@ export default function ProductIndex({ products, filters }: Props) { const productNames = useMemo(() => { const names = products.data.map((p) => p.name); - return [...new Set(names)].sort(); }, [products.data]); @@ -284,13 +164,21 @@ export default function ProductIndex({ products, filters }: Props) { }); } - const columns = createProductColumns({ - handleEdit: (product) => { - window.location.href = productEdit.url(product.id); - }, - handleDeleteClick: (product) => setDeleting(product), - toggleStatusUrl: (id) => toggleStatus.url(id), - }); + function handleDeleteVariant() { + if (!deletingVariant) { + return; + } + + router.delete( + variantDestroy.url({ + product: deletingVariant.product.id, + variant: deletingVariant.variant.id, + }), + { + onSuccess: () => setDeletingVariant(null), + }, + ); + } const filterToolbar = ( @@ -376,6 +264,64 @@ export default function ProductIndex({ products, filters }: Props) {
+ +
+ + + Berdasarkan stok bagus + + +
+ +
+ + + applyFilter('category', value as string) + } + > + + + + Tidak ada kategori ditemukan. + + + {categories.map((cat) => ( + + {cat.name} + + ))} + + + +
@@ -400,22 +346,55 @@ export default function ProductIndex({ products, filters }: Props) { - p.id} + expandedKeys={expand.expandedKeys} + onToggleExpand={expand.toggleExpand} + searchValue={search} + onSearchChange={handleSearchChange} searchPlaceholder="Cari produk..." - emptyText="Belum ada data produk." pagination={pagination} onPageChange={handlePageChange} onPerPageChange={handlePerPageChange} - onSearchChange={handleSearchChange} - searchValue={search} - renderSubRow={(row, searchValue) => ( - - )} - defaultExpanded toolbar={filterToolbar} + renderCard={({ + item, + index, + isExpanded, + onToggleExpand, + }) => ( + { + window.location.href = productEdit.url(p.id); + }} + onDelete={(p) => setDeleting(p)} + toggleStatusUrl={(id) => toggleStatus.url(id)} + /> + )} + renderSubContent={(product) => ( + { + window.location.href = variantEdit.url({ + product: p.id, + variant: v.id, + }); + }} + onDeleteVariantClick={(p, v) => + setDeletingVariant({ product: p, variant: v }) + } + /> + )} /> + + { + if (!open) { + setDeletingVariant(null); + } + }} + title="Hapus Varian" + description={`Apakah Anda yakin ingin menghapus varian "${deletingVariant?.variant.name}" dari produk "${deletingVariant?.product.name}"? Tindakan ini tidak dapat dibatalkan.`} + confirmLabel="Hapus" + onConfirm={handleDeleteVariant} + /> ); diff --git a/resources/js/pages/admin/master/product/product-card.tsx b/resources/js/pages/admin/master/product/product-card.tsx new file mode 100644 index 0000000..6276895 --- /dev/null +++ b/resources/js/pages/admin/master/product/product-card.tsx @@ -0,0 +1,197 @@ +import { router } from '@inertiajs/react'; +import { ChevronDown, Pencil, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { Switch } from '@/components/ui/switch'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import type { Product } from './columns'; + +function formatNumber(num: number): string { + return new Intl.NumberFormat('id-ID').format(num); +} + +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'; +} + +export type ProductCardRowParams = { + product: Product; + index: number; + isExpanded: boolean; + onToggleExpand: () => void; + onEdit: (product: Product) => void; + onDelete: (product: Product) => void; + toggleStatusUrl: (id: number) => string; +}; + +export function ProductCardRow({ + product, + index, + isExpanded, + onToggleExpand, + onEdit, + onDelete, + toggleStatusUrl, +}: ProductCardRowParams) { + const variants = product.product_variants ?? []; + 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 totalAll = totalStock + totalReject + totalRetail; + + const isToggleable = + product.status === 'active' || product.status === 'inactive'; + const isChecked = product.status === 'active'; + + function handleToggle() { + router.post(toggleStatusUrl(product.id), {}, { preserveScroll: true }); + } + + return ( + + +
+ + +
+
+ + {index}. + +

+ {product.name} +

+ {product.categories?.length > 0 && ( + + ( + {product.categories + .map((c) => c.name) + .join(', ')} + ) + + )} +
+ +
+ + {variants.length} varian + + + Bagus:{' '} + + {formatNumber(totalStock)} + + + + Reject:{' '} + + {formatNumber(totalReject)} + + + + Ecer:{' '} + + {formatNumber(totalRetail)} + + + + Total:{' '} + + {formatNumber(totalAll)} + + +
+ +
+ {isToggleable ? ( +
+ + + {getStatusLabel(product.status)} + +
+ ) : ( + + {getStatusLabel(product.status)} + + )} +
+
+ + +
+ + + + + Edit + + + + + + + Hapus + + +
+
+
+
+
+ ); +} diff --git a/resources/js/pages/admin/master/product/variant/edit.tsx b/resources/js/pages/admin/master/product/variant/edit.tsx new file mode 100644 index 0000000..701817d --- /dev/null +++ b/resources/js/pages/admin/master/product/variant/edit.tsx @@ -0,0 +1,271 @@ +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 { index as productIndex } from '@/routes/admin/master/products'; +import { Form, Head } from '@inertiajs/react'; +import { ArrowLeft } from 'lucide-react'; +import { useState } from 'react'; + +type Props = { + variant: { + id: number; + product_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 }>; + }; +}; + +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' }, +]; + +export default function ProductVariantEdit({ variant }: Props) { + const [name, setName] = useState(variant.name); + const [stock, setStock] = useState(variant.stock); + const [rejectStock, setRejectStock] = useState(variant.reject_stock); + const [retailStock, setRetailStock] = useState(variant.retail_stock); + const [photo, setPhoto] = useState(variant.photo_key); + const [uploading, setUploading] = useState(false); + const [prices, setPrices] = useState< + Array<{ type: string; price: number }> + >( + variant.prices.length > 0 + ? variant.prices + : PRICE_TYPES.map((pt) => ({ type: pt.key, price: 0 })), + ); + + function updatePrice(priceIndex: number, value: number) { + setPrices((prev) => + prev.map((p, i) => (i === priceIndex ? { ...p, price: value } : p)), + ); + } + + function getPayload() { + return { + name, + stock: Number(stock), + reject_stock: Number(rejectStock), + retail_stock: Number(retailStock), + photo_key: photo, + prices: prices.map((p) => ({ + type: p.type, + price: Number(p.price), + })), + }; + } + + return ( + <> + + +
+
+

+ Edit Varian +

+ +
+ +
getPayload()} + > + {({ errors, processing }) => ( + <> +
+ + + Informasi Varian + + +
+ + + setName(e.target.value) + } + placeholder="Contoh: Ukuran L, Warna Merah" + /> + +
+
+ + + setStock( + Number(e.target.value), + ) + } + /> + +
+
+ + + setRejectStock( + Number(e.target.value), + ) + } + /> + +
+
+ + + setRetailStock( + Number(e.target.value), + ) + } + /> + +
+
+
+ + + + Foto Varian + + + + + + + + + + Harga + + +
+ {PRICE_TYPES.map( + (priceType, priceIndex) => ( +
+ + + updatePrice( + priceIndex, + val, + ) + } + /> + +
+ ), + )} +
+
+
+
+ +
+ +
+ + )} +
+
+ + ); +} diff --git a/resources/js/pages/admin/master/product/variant/sub-row.tsx b/resources/js/pages/admin/master/product/variant/sub-row.tsx new file mode 100644 index 0000000..91b4d75 --- /dev/null +++ b/resources/js/pages/admin/master/product/variant/sub-row.tsx @@ -0,0 +1,202 @@ +import { Pencil, Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import { ImagePreviewModal } from '@/components/image-preview-modal'; +import { Button } from '@/components/ui/button'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import type { Product, ProductVariant } from './columns'; + +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 ( + <> + + + + ); +} + +export function VariantSubRow({ + product, + onEditVariant, + onDeleteVariantClick, +}: { + product: Product; + onEditVariant: (product: Product, variant: ProductVariant) => void; + onDeleteVariantClick: (product: Product, variant: ProductVariant) => void; +}) { + const variants = product.product_variants ?? []; + + return ( +
+ + + + + No + + Foto + Nama Varian + + Stok Bagus + + + Stok Reject + + Stok Ecer + Harga + + Aksi + + + + + {variants.length === 0 ? ( + + + Tidak ada varian. + + + ) : ( + variants.map((variant, index) => ( + + + {index + 1} + + + {variant.photo_url ? ( + + ) : ( +
+ N/A +
+ )} +
+ + {variant.name} + + + {formatNumber(variant.stock)} + + + {formatNumber(variant.reject_stock)} + + + {formatNumber(variant.retail_stock)} + + + {variant.product_prices?.length > 0 ? ( +
+ {variant.product_prices.map((p) => ( + + + {p.type_label}: + {' '} + {formatCurrency(p.price)} + + ))} +
+ ) : ( + '-' + )} +
+ + +
+ + + + + + Edit + + + + + + + + Hapus + + +
+
+
+
+ )) + )} +
+
+
+ ); +} diff --git a/routes/web.php b/routes/web.php index bd57610..471d3a7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -12,7 +12,8 @@ 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\Product\ProductController; +use App\Http\Controllers\Admin\Master\Product\ProductVariantController; use App\Http\Controllers\Admin\Master\SupplierController; use App\Http\Controllers\Admin\RoleController; use Illuminate\Support\Facades\Route; @@ -33,6 +34,9 @@ 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::delete('products/{product}/variants/{variant}', [ProductVariantController::class, 'destroy'])->name('products.variants.destroy'); + Route::get('products/{product}/variants/{variant}/edit', [ProductVariantController::class, 'edit'])->name('products.variants.edit'); + Route::put('products/{product}/variants/{variant}', [ProductVariantController::class, 'update'])->name('products.variants.update'); Route::resource('suppliers', SupplierController::class)->except(['show', 'create', 'edit']); Route::resource('customers', CustomerController::class)->except(['show', 'create', 'edit']); }); diff --git a/tests/Feature/Admin/Master/ProductTest.php b/tests/Feature/Admin/Master/ProductTest.php index 2d3f525..843d371 100644 --- a/tests/Feature/Admin/Master/ProductTest.php +++ b/tests/Feature/Admin/Master/ProductTest.php @@ -2006,3 +2006,263 @@ function allPriceTypes(): array // but shared_prices is empty, so it should fail $response->assertSessionHasErrors('shared_prices'); }); + +/* +|-------------------------------------------------------------------------- +| PRODUCT VARIANT - EDIT PAGE +|-------------------------------------------------------------------------- +*/ + +test('guest cannot access variant edit page', function () { + $product = Product::factory()->create(); + $variant = ProductVariant::factory()->for($product)->create(); + + $response = $this->get(route('admin.master.products.variants.edit', [$product, $variant])); + $response->assertRedirect(route('login')); +}); + +test('authenticated user can access variant edit page', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $product = Product::factory()->create(); + $variant = ProductVariant::factory()->for($product)->create(); + + $response = $this->get(route('admin.master.products.variants.edit', [$product, $variant])); + $response->assertStatus(200); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/master/product/variant/edit') + ->has('variant') + ); +}); + +test('variant edit page shows variant data', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $product = Product::factory()->create(); + $variant = ProductVariant::factory()->for($product)->create([ + 'name' => 'Varian Edit Test', + 'stock' => 100, + 'reject_stock' => 10, + 'retail_stock' => 20, + ]); + + $response = $this->get(route('admin.master.products.variants.edit', [$product, $variant])); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/master/product/variant/edit') + ->where('variant.id', $variant->id) + ->where('variant.product_id', $product->id) + ->where('variant.name', 'Varian Edit Test') + ->where('variant.stock', 100) + ->where('variant.reject_stock', 10) + ->where('variant.retail_stock', 20) + ); +}); + +/* +|-------------------------------------------------------------------------- +| PRODUCT VARIANT - UPDATE +|-------------------------------------------------------------------------- +*/ + +test('guest cannot update variant', function () { + $product = Product::factory()->create(); + $variant = ProductVariant::factory()->for($product)->create(); + + $response = $this->put(route('admin.master.products.variants.update', [$product, $variant]), [ + 'name' => 'Updated', + 'stock' => 50, + 'reject_stock' => 5, + 'retail_stock' => 10, + 'photo_key' => 'product-variant/updated.jpg', + 'prices' => allPriceTypes(), + ]); + + $response->assertRedirect(route('login')); +}); + +test('variant can be updated', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $product = Product::factory()->create(); + $variant = ProductVariant::factory()->for($product)->create([ + 'name' => 'Original Name', + 'stock' => 100, + ]); + + $response = $this->put(route('admin.master.products.variants.update', [$product, $variant]), [ + 'name' => 'Updated Name', + 'stock' => 200, + 'reject_stock' => 15, + 'retail_stock' => 25, + 'photo_key' => 'product-variant/new-photo.jpg', + 'prices' => allPriceTypes(), + ]); + + $response->assertRedirect(); + $this->assertDatabaseHas('product_variants', [ + 'id' => $variant->id, + 'name' => 'Updated Name', + 'stock' => 200, + 'reject_stock' => 15, + 'retail_stock' => 25, + ]); +}); + +test('variant update replaces old prices', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $product = Product::factory()->create(); + $variant = ProductVariant::factory()->for($product)->create(); + + ProductPrice::create([ + 'variant_id' => $variant->id, + 'type' => 'retail', + 'price' => 10000, + ]); + + $this->assertDatabaseCount('product_prices', 1); + + $response = $this->put(route('admin.master.products.variants.update', [$product, $variant]), [ + 'name' => $variant->name, + 'stock' => $variant->stock, + 'reject_stock' => $variant->reject_stock, + 'retail_stock' => $variant->retail_stock, + 'photo_key' => 'product-variant/test.jpg', + 'prices' => allPriceTypes(), + ]); + + $response->assertRedirect(); + $this->assertDatabaseCount('product_prices', 9); +}); + +test('variant update name is required', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $product = Product::factory()->create(); + $variant = ProductVariant::factory()->for($product)->create(); + + $response = $this->put(route('admin.master.products.variants.update', [$product, $variant]), [ + 'name' => '', + 'stock' => 100, + 'reject_stock' => 10, + 'retail_stock' => 20, + 'photo_key' => 'product-variant/test.jpg', + 'prices' => allPriceTypes(), + ]); + + $response->assertSessionHasErrors('name'); +}); + +test('variant update requires exactly 9 prices', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $product = Product::factory()->create(); + $variant = ProductVariant::factory()->for($product)->create(); + + $response = $this->put(route('admin.master.products.variants.update', [$product, $variant]), [ + 'name' => 'Test', + 'stock' => 100, + 'reject_stock' => 10, + 'retail_stock' => 20, + 'photo_key' => 'product-variant/test.jpg', + 'prices' => [ + ['type' => 'retail', 'price' => 10000], + ], + ]); + + $response->assertSessionHasErrors('prices'); +}); + +/* +|-------------------------------------------------------------------------- +| PRODUCT VARIANT - DELETE +|-------------------------------------------------------------------------- +*/ + +test('guest cannot delete variant', function () { + $product = Product::factory()->create(); + $variant = ProductVariant::factory()->for($product)->create(); + + $response = $this->delete(route('admin.master.products.variants.destroy', [$product, $variant])); + $response->assertRedirect(route('login')); + $this->assertDatabaseHas('product_variants', ['id' => $variant->id, 'deleted_at' => null]); +}); + +test('variant can be deleted', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $product = Product::factory()->create(); + $variant = ProductVariant::factory()->for($product)->create(); + + $response = $this->delete(route('admin.master.products.variants.destroy', [$product, $variant])); + $response->assertRedirect(); + $this->assertSoftDeleted('product_variants', ['id' => $variant->id]); +}); + +test('delete variant cascades to product prices', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $product = Product::factory()->create(); + $variant = ProductVariant::factory()->for($product)->create(); + + ProductPrice::create([ + 'variant_id' => $variant->id, + 'type' => 'retail', + 'price' => 10000, + ]); + ProductPrice::create([ + 'variant_id' => $variant->id, + 'type' => 'wholesale', + 'price' => 8000, + ]); + + $this->assertDatabaseCount('product_prices', 2); + + $response = $this->delete(route('admin.master.products.variants.destroy', [$product, $variant])); + $response->assertRedirect(); + $this->assertDatabaseCount('product_prices', 0); +}); + +test('delete variant does not affect other variants', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $product = Product::factory()->create(); + $variant1 = ProductVariant::factory()->for($product)->create(['name' => 'Keep']); + $variant2 = ProductVariant::factory()->for($product)->create(['name' => 'Delete']); + + ProductPrice::create([ + 'variant_id' => $variant1->id, + 'type' => 'retail', + 'price' => 10000, + ]); + ProductPrice::create([ + 'variant_id' => $variant2->id, + 'type' => 'retail', + 'price' => 15000, + ]); + + $response = $this->delete(route('admin.master.products.variants.destroy', [$product, $variant2])); + $response->assertRedirect(); + + $this->assertDatabaseHas('product_variants', ['id' => $variant1->id, 'name' => 'Keep', 'deleted_at' => null]); + $this->assertDatabaseHas('product_prices', ['variant_id' => $variant1->id]); +}); + +test('deleting non-existent variant returns 404', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $product = Product::factory()->create(); + + $response = $this->delete(route('admin.master.products.variants.destroy', [$product, 99999])); + $response->assertStatus(404); +}); diff --git a/tests/Feature/Admin/NotificationTest.php b/tests/Feature/Admin/NotificationTest.php index c677a1c..be1a3d8 100644 --- a/tests/Feature/Admin/NotificationTest.php +++ b/tests/Feature/Admin/NotificationTest.php @@ -15,7 +15,7 @@ use App\Services\Admin\Finance\PayrollPeriodService; use App\Services\Admin\HR\AttendanceService; use App\Services\Admin\HR\LeaveRequestService; -use App\Services\Admin\Master\ProductService; +use App\Services\Admin\Master\Product\ProductService; use App\Services\NotificationService; use Database\Seeders\RolePermissionSeeder; use Illuminate\Foundation\Testing\RefreshDatabase;