Add product management features including Product, ProductVariant, and ProductPrice models. Implement ProductController for CRUD operations, integrate permissions in Role and Permission enums, and enhance UI components for product listing and management. Introduce new migrations for products and related entities, and update sidebar for product navigation.

This commit is contained in:
Yoga Pangestu 2026-06-10 02:29:46 +07:00
parent b695eeba9a
commit 448ac63260
28 changed files with 2018 additions and 31 deletions

View File

@ -22,6 +22,12 @@ enum Permission: string
case CATEGORIES_UPDATE = 'categories.update';
case CATEGORIES_DELETE = 'categories.delete';
case PRODUCTS_VIEW = 'products.view';
case PRODUCTS_CREATE = 'products.create';
case PRODUCTS_UPDATE = 'products.update';
case PRODUCTS_DELETE = 'products.delete';
case PRODUCTS_TOGGLE_STATUS = 'products.toggle-status';
public function label(): string
{
return match ($this) {
@ -38,6 +44,12 @@ public function label(): string
self::CATEGORIES_CREATE => 'Tambah Kategori',
self::CATEGORIES_UPDATE => 'Ubah Kategori',
self::CATEGORIES_DELETE => 'Hapus Kategori',
self::PRODUCTS_VIEW => 'Lihat Produk',
self::PRODUCTS_CREATE => 'Tambah Produk',
self::PRODUCTS_UPDATE => 'Ubah Produk',
self::PRODUCTS_DELETE => 'Hapus Produk',
self::PRODUCTS_TOGGLE_STATUS => 'Ubah Status Produk',
};
}
@ -49,6 +61,8 @@ public function group(): string
self::EMPLOYEES_DELETE, self::EMPLOYEES_RESET_PASSWORD, self::EMPLOYEES_TOGGLE_STATUS => 'Pegawai',
self::CATEGORIES_VIEW, self::CATEGORIES_CREATE, self::CATEGORIES_UPDATE,
self::CATEGORIES_DELETE => 'Kategori',
self::PRODUCTS_VIEW, self::PRODUCTS_CREATE, self::PRODUCTS_UPDATE,
self::PRODUCTS_DELETE, self::PRODUCTS_TOGGLE_STATUS => 'Produk',
};
}

39
app/Enums/PriceType.php Normal file
View File

@ -0,0 +1,39 @@
<?php
namespace App\Enums;
use App\Traits\ProvidesEnumOptions;
enum PriceType: string
{
use ProvidesEnumOptions;
case DISTRIBUTOR = 'distributor';
case AGENT = 'agent';
case SUB_AGENT = 'sub_agent';
case GROSIR = 'grosir';
case ECER = 'ecer';
case TIKTOK = 'tiktok';
case SHOPEE = 'shopee';
public function label(): string
{
return match ($this) {
self::DISTRIBUTOR => 'Distributor',
self::AGENT => 'Agen',
self::SUB_AGENT => 'Sub Agen',
self::GROSIR => 'Grosir',
self::ECER => 'Eceran',
self::TIKTOK => 'TikTok',
self::SHOPEE => 'Shopee',
};
}
/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@ -48,6 +48,11 @@ public function permissions(): array
Permission::CATEGORIES_CREATE,
Permission::CATEGORIES_UPDATE,
Permission::CATEGORIES_DELETE,
Permission::PRODUCTS_VIEW,
Permission::PRODUCTS_CREATE,
Permission::PRODUCTS_UPDATE,
Permission::PRODUCTS_DELETE,
Permission::PRODUCTS_TOGGLE_STATUS,
],
self::ADMIN_TOKO => [
Permission::DASHBOARD_VIEW,
@ -60,6 +65,11 @@ public function permissions(): array
Permission::CATEGORIES_CREATE,
Permission::CATEGORIES_UPDATE,
Permission::CATEGORIES_DELETE,
Permission::PRODUCTS_VIEW,
Permission::PRODUCTS_CREATE,
Permission::PRODUCTS_UPDATE,
Permission::PRODUCTS_DELETE,
Permission::PRODUCTS_TOGGLE_STATUS,
],
self::ADMIN_BAHAN_BAKU => [
Permission::DASHBOARD_VIEW,
@ -68,11 +78,17 @@ public function permissions(): array
Permission::CATEGORIES_CREATE,
Permission::CATEGORIES_UPDATE,
Permission::CATEGORIES_DELETE,
Permission::PRODUCTS_VIEW,
Permission::PRODUCTS_CREATE,
Permission::PRODUCTS_UPDATE,
Permission::PRODUCTS_DELETE,
Permission::PRODUCTS_TOGGLE_STATUS,
],
self::MARKETING => [
Permission::DASHBOARD_VIEW,
Permission::EMPLOYEES_VIEW,
Permission::CATEGORIES_VIEW,
Permission::PRODUCTS_VIEW,
],
self::NON_OPERATOR => [
Permission::DASHBOARD_VIEW,

View File

@ -0,0 +1,338 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Enums\PriceType;
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\ProductRequest;
use App\Models\Category;
use App\Models\Product;
use App\Models\ProductPrice;
use App\Models\ProductVariant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
class ProductController extends Controller
{
use ParsesDataTableQuery;
public function index(Request $request): Response
{
$tableQuery = $this->parseDataTableQuery($request);
$search = $tableQuery['search'];
$sort = $tableQuery['sort'];
$direction = $tableQuery['direction'];
$isActive = $request->string('is_active')->toString();
$query = Product::query()
->with([
'categories',
'variants' => fn ($query) => $query
->with(['prices' => fn ($query) => $query->orderBy('type')])
->orderBy('created_at'),
])
->when($search !== '', function (Builder $query) use ($search): void {
$query->where(function (Builder $query) use ($search): void {
$query->where('name', 'like', "%{$search}%")
->orWhere('slug', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%")
->orWhereHas('categories', fn (Builder $query) => $query->where('name', 'like', "%{$search}%"))
->orWhereHas('variants', fn (Builder $query) => $query->where('name', 'like', "%{$search}%"));
});
})
->when(
$isActive !== '',
fn (Builder $query) => $query->where('is_active', $isActive === '1')
);
$this->applySorting($query, $sort, $direction);
$products = $query
->paginate(10)
->withQueryString();
$rows = $products->getCollection()
->flatMap(fn (Product $product, int $index) => $this->flattenProductForTable($product, $index, $products->firstItem() ?? 1))
->values()
->all();
return Inertia::render('admin/products/Index', [
'rows' => $rows,
'pagination' => [
'current_page' => $products->currentPage(),
'last_page' => $products->lastPage(),
'per_page' => $products->perPage(),
'total' => $products->total(),
'links' => $products->linkCollection()->toArray(),
],
'filters' => $this->dataTableFilters($tableQuery, [
'is_active' => $isActive,
]),
]);
}
public function create(): Response
{
return Inertia::render('admin/products/Create', [
'categories' => $this->categoryOptions(),
'priceTypes' => PriceType::selectOptions(),
]);
}
public function store(ProductRequest $request): RedirectResponse
{
$validated = $request->validated();
DB::transaction(function () use ($validated): void {
$product = Product::create([
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
'is_active' => true,
]);
$product->categories()->sync($validated['category_ids']);
foreach ($validated['variants'] as $variantData) {
$this->createVariant($product, $variantData);
}
});
Inertia::flash('success', 'Produk berhasil ditambahkan.');
return redirect()->route('admin.master.products.index');
}
public function edit(Product $product): Response
{
$product->load([
'categories',
'variants' => fn ($query) => $query
->with(['prices' => fn ($query) => $query->orderBy('type')])
->orderBy('created_at'),
]);
return Inertia::render('admin/products/Edit', [
'categories' => $this->categoryOptions(),
'priceTypes' => PriceType::selectOptions(),
'product' => $this->transformProductForForm($product),
]);
}
public function update(ProductRequest $request, Product $product): RedirectResponse
{
$validated = $request->validated();
DB::transaction(function () use ($validated, $product): void {
$product->name = $validated['name'];
$product->description = $validated['description'] ?? null;
$product->save();
$product->categories()->sync($validated['category_ids']);
$submittedVariantIds = collect($validated['variants'])
->pluck('id')
->filter()
->map(fn ($id) => (int) $id)
->all();
$product->variants()
->whereNotIn('id', $submittedVariantIds)
->get()
->each(fn (ProductVariant $variant) => $variant->delete());
foreach ($validated['variants'] as $variantData) {
if (! empty($variantData['id'])) {
$variant = $product->variants()->findOrFail($variantData['id']);
$variant->name = $variantData['name'];
$variant->stock = $variantData['stock'];
$variant->save();
$this->syncVariantPrices($variant, $variantData['prices']);
continue;
}
$this->createVariant($product, $variantData);
}
});
Inertia::flash('success', 'Produk berhasil diperbarui.');
return redirect()->route('admin.master.products.index');
}
public function toggleStatus(Request $request, Product $product): RedirectResponse
{
$validated = $request->validate([
'is_active' => ['required', 'boolean'],
]);
$product->is_active = $validated['is_active'];
$product->save();
Inertia::flash('success', 'Status produk berhasil diperbarui.');
return back();
}
public function destroy(Product $product): RedirectResponse
{
DB::transaction(function () use ($product): void {
$product->variants()->delete();
$product->categories()->detach();
$product->delete();
});
Inertia::flash('success', 'Produk berhasil dihapus.');
return redirect()->route('admin.master.products.index');
}
private function applySorting(Builder $query, string $sort, string $direction): void
{
if (in_array($sort, ['name', 'slug', 'is_active'], true)) {
$query->orderBy($sort, $direction);
return;
}
$query->latest();
}
/**
* @return list<array{value: int, label: string}>
*/
private function categoryOptions(): array
{
return Category::query()
->orderBy('name')
->get(['id', 'name'])
->map(fn (Category $category) => [
'value' => $category->id,
'label' => $category->name,
])
->all();
}
/**
* @param array<string, mixed> $variantData
*/
private function createVariant(Product $product, array $variantData): ProductVariant
{
$variant = $product->variants()->create([
'name' => $variantData['name'],
'stock' => $variantData['stock'],
]);
$this->syncVariantPrices($variant, $variantData['prices']);
return $variant;
}
/**
* @param list<array{type: string, price: float|int|string}> $prices
*/
private function syncVariantPrices(ProductVariant $variant, array $prices): void
{
foreach ($prices as $priceData) {
ProductPrice::updateOrCreate(
[
'variant_id' => $variant->id,
'type' => $priceData['type'],
],
[
'price' => $priceData['price'],
],
);
}
}
/**
* @return list<array<string, mixed>>
*/
private function flattenProductForTable(Product $product, int $productIndex, int $firstItem): array
{
$variants = $product->variants->isNotEmpty()
? $product->variants
: collect([null]);
$variantCount = $variants->count();
return $variants
->values()
->map(function ($variant, int $variantIndex) use ($product, $productIndex, $firstItem, $variantCount) {
return [
'row_id' => $variant
? "{$product->id}-{$variant->id}"
: "{$product->id}-empty",
'product_id' => $product->id,
'product_name' => $product->name,
'categories' => $product->categories
->map(fn (Category $category) => [
'id' => $category->id,
'name' => $category->name,
])
->values()
->all(),
'is_active' => $product->is_active,
'is_first_variant' => $variantIndex === 0,
'variant_count' => $variantCount,
'product_row_number' => $firstItem + $productIndex,
'variant_id' => $variant?->id,
'variant_name' => $variant?->name,
'stock' => $variant?->stock,
'prices' => $variant
? $variant->prices
->map(fn (ProductPrice $price) => [
'type' => $price->type->value,
'type_label' => $price->type->label(),
'price' => $price->price,
'price_formatted' => $this->formatPrice($price->price),
])
->values()
->all()
: [],
];
})
->all();
}
/**
* @return array<string, mixed>
*/
private function transformProductForForm(Product $product): array
{
return [
'id' => $product->id,
'name' => $product->name,
'description' => $product->description,
'category_ids' => $product->categories->pluck('id')->all(),
'variants' => $product->variants
->map(fn (ProductVariant $variant) => [
'id' => $variant->id,
'name' => $variant->name,
'stock' => $variant->stock,
'prices' => collect(PriceType::cases())
->mapWithKeys(function (PriceType $type) use ($variant) {
$price = $variant->prices->firstWhere('type', $type);
return [
$type->value => $price ? (string) (int) $price->price : '',
];
})
->all(),
])
->values()
->all(),
];
}
private function formatPrice(float|string $price): string
{
return 'Rp '.number_format((float) $price, 0, ',', '.');
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Http\Requests\Admin;
use App\Enums\Permission;
use App\Enums\PriceType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ProductRequest extends FormRequest
{
public function authorize(): bool
{
$permission = $this->isMethod('POST')
? Permission::PRODUCTS_CREATE
: Permission::PRODUCTS_UPDATE;
return $this->user()?->can($permission->value) ?? false;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:200'],
'description' => ['nullable', 'string'],
'category_ids' => ['required', 'array', 'min:1'],
'category_ids.*' => ['integer', Rule::exists('categories', 'id')->whereNull('deleted_at')],
'variants' => ['required', 'array', 'min:1'],
'variants.*.id' => [
'nullable',
'integer',
Rule::exists('product_variants', 'id')
->where('product_id', $this->route('product')?->id)
->whereNull('deleted_at'),
],
'variants.*.name' => ['required', 'string', 'max:200'],
'variants.*.stock' => ['required', 'integer', 'min:0'],
'variants.*.prices' => ['required', 'array', 'min:1'],
'variants.*.prices.*.type' => ['required', Rule::enum(PriceType::class)],
'variants.*.prices.*.price' => ['required', 'numeric', 'gt:0'],
];
}
}

View File

@ -4,6 +4,7 @@
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Sluggable\Attributes\Sluggable;
@ -12,4 +13,9 @@
class Category extends Model
{
use SoftDeletes;
public function products(): BelongsToMany
{
return $this->belongsToMany(Product::class, 'product_categories');
}
}

34
app/Models/Product.php Normal file
View File

@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Model;
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 SoftDeletes;
protected function casts(): array
{
return [
'is_active' => 'boolean',
];
}
public function categories(): BelongsToMany
{
return $this->belongsToMany(Category::class, 'product_categories');
}
public function variants(): HasMany
{
return $this->hasMany(ProductVariant::class);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Models;
use App\Enums\PriceType;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Guarded(['id'])]
class ProductPrice extends Model
{
protected function casts(): array
{
return [
'type' => PriceType::class,
'price' => 'decimal:2',
];
}
public function variant(): BelongsTo
{
return $this->belongsTo(ProductVariant::class, 'variant_id');
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Guarded(['id'])]
class ProductVariant extends Model
{
use SoftDeletes;
protected function casts(): array
{
return [
'stock' => 'integer',
];
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function prices(): HasMany
{
return $this->hasMany(ProductPrice::class, 'variant_id');
}
}

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name', 200);
$table->string('slug', 200)->unique();
$table->text('description')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('products');
}
};

View File

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('product_categories', function (Blueprint $table) {
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
$table->foreignId('category_id')->constrained()->cascadeOnDelete();
$table->primary(['product_id', 'category_id']);
});
}
public function down(): void
{
Schema::dropIfExists('product_categories');
}
};

View File

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('product_variants', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
$table->string('name', 200);
$table->unsignedInteger('stock')->default(0);
$table->timestamps();
$table->softDeletes();
$table->index('product_id');
});
}
public function down(): void
{
Schema::dropIfExists('product_variants');
}
};

View File

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('product_prices', function (Blueprint $table) {
$table->id();
$table->foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete();
$table->string('type', 20);
$table->decimal('price', 18, 2);
$table->timestamps();
$table->unique(['variant_id', 'type']);
$table->index('variant_id');
});
}
public function down(): void
{
Schema::dropIfExists('product_prices');
}
};

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import { FolderTree, LayoutDashboard, Users } from '@lucide/vue';
import { FolderTree, LayoutDashboard, Package, Users } from '@lucide/vue';
import { computed } from 'vue';
import {
Sidebar,
@ -22,6 +22,8 @@ const { can } = useCan();
const isDashboardActive = computed(() => page.url.startsWith('/admin/dashboard'));
const isEmployeesActive = computed(() => page.url.startsWith('/admin/hr/employees'));
const isCategoriesActive = computed(() => page.url.startsWith('/admin/master/categories'));
const isProductsActive = computed(() => page.url.startsWith('/admin/master/products'));
const showMasterMenu = computed(() => can('categories.view') || can('products.view'));
</script>
<template>
@ -55,11 +57,11 @@ const isCategoriesActive = computed(() => page.url.startsWith('/admin/master/cat
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup v-if="can('categories.view')">
<SidebarGroup v-if="showMasterMenu">
<SidebarGroupLabel>Master</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuItem v-if="can('categories.view')">
<SidebarMenuButton as-child tooltip="Kategori" :is-active="isCategoriesActive">
<Link href="/admin/master/categories">
<FolderTree />
@ -67,6 +69,14 @@ const isCategoriesActive = computed(() => page.url.startsWith('/admin/master/cat
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem v-if="can('products.view')">
<SidebarMenuButton as-child tooltip="Produk" :is-active="isProductsActive">
<Link href="/admin/master/products">
<Package />
<span>Produk</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>

View File

@ -35,9 +35,15 @@ const props = withDefaults(
filterValues?: Record<string, string>;
searchPlaceholder?: string;
paginationLinks?: DataTablePaginationLink[];
showRowNumber?: boolean;
paginationDisplayedCount?: number;
paginationItemLabel?: string;
getRowClassName?: (row: TData, index: number) => string | undefined;
}>(),
{
searchPlaceholder: 'Ketikkan sesuatu...',
showRowNumber: true,
paginationItemLabel: 'data',
}
);
@ -66,7 +72,9 @@ const rowNumberColumn: ColumnDef<TData> = {
},
};
const resolvedColumns = computed(() => [rowNumberColumn, ...props.columns]);
const resolvedColumns = computed(() => (
props.showRowNumber ? [rowNumberColumn, ...props.columns] : props.columns
));
const table = useVueTable({
get data() {
@ -102,7 +110,7 @@ provide('data-table-sort', {
onSort: handleSort,
});
const showingCount = computed(() => props.data.length);
const showingCount = computed(() => props.paginationDisplayedCount ?? props.data.length);
const paginationSummary = computed(() => {
if (!props.pagination) {
@ -110,13 +118,36 @@ const paginationSummary = computed(() => {
}
const { total } = props.pagination;
const label = props.paginationItemLabel;
if (total === 0) {
return 'Menampilkan 0 data';
return `Menampilkan 0 ${label}`;
}
return `Menampilkan ${showingCount.value} data dari ${total}`;
return `Menampilkan ${showingCount.value} ${label} dari ${total}`;
});
function resolveRowSpan(row: TData, columnId: string): number | undefined {
const column = resolvedColumns.value.find((item) => {
if ('id' in item && item.id === columnId) {
return true;
}
if ('accessorKey' in item && item.accessorKey === columnId) {
return true;
}
return false;
});
const getRowSpan = column?.meta?.getRowSpan;
if (!getRowSpan) {
return undefined;
}
return getRowSpan(row);
}
</script>
<template>
@ -141,15 +172,30 @@ const paginationSummary = computed(() => {
</TableHeader>
<TableBody>
<template v-if="table.getRowModel().rows.length">
<TableRow v-for="row in table.getRowModel().rows" :key="row.id"
:data-state="row.getIsSelected() ? 'selected' : undefined">
<TableCell
<TableRow
v-for="row in table.getRowModel().rows"
:key="row.id"
:class="getRowClassName?.(row.original, row.index)"
:data-state="row.getIsSelected() ? 'selected' : undefined"
>
<template
v-for="cell in row.getVisibleCells()"
:key="cell.id"
:class="cell.column.id === '_row_number' ? ROW_NUMBER_COLUMN_CLASS : undefined"
>
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</TableCell>
<TableCell
v-if="(resolveRowSpan(row.original, cell.column.id) ?? 1) !== 0"
:rowspan="(resolveRowSpan(row.original, cell.column.id) ?? 1) > 1
? resolveRowSpan(row.original, cell.column.id)
: undefined"
:class="[
cell.column.id === '_row_number' ? ROW_NUMBER_COLUMN_CLASS : undefined,
(resolveRowSpan(row.original, cell.column.id) ?? 1) > 1 ? 'align-middle' : undefined,
cell.column.columnDef.meta?.cellClassName,
]"
>
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</TableCell>
</template>
</TableRow>
</template>
<TableEmpty v-else :colspan="resolvedColumns.length">

View File

@ -0,0 +1,394 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Copy, Plus, Save, Trash2 } from '@lucide/vue';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { RupiahInput } from '@/components/ui/rupiah-input';
import { Textarea } from '@/components/ui/textarea';
import { parseRupiah } from '@/lib/rupiah';
import {
PRICE_TYPES,
PRICE_TYPE_LABELS
} from '@/types/product';
import type { CategoryOption, ProductFormData, ProductVariantFormItem } from '@/types/product';
const props = withDefaults(
defineProps<{
categories: CategoryOption[];
initialData?: {
name?: string;
description?: string;
category_ids?: number[];
variants?: Array<{
id?: number;
name?: string;
stock?: number | string;
prices?: Record<string, string>;
}>;
};
submitUrl: string;
method?: 'post' | 'put';
submitLabel?: string;
}>(),
{
method: 'post',
submitLabel: 'Simpan',
},
);
function createClientId(): string {
return `variant-${crypto.randomUUID()}`;
}
function buildEmptyPrices(): Record<string, string> {
return Object.fromEntries(PRICE_TYPES.map((type) => [type, '']));
}
function createEmptyVariant(): ProductVariantFormItem {
return {
client_id: createClientId(),
name: '',
stock: '0',
prices: buildEmptyPrices(),
};
}
function buildInitialVariants(): ProductVariantFormItem[] {
if (!props.initialData?.variants?.length) {
return [createEmptyVariant()];
}
return props.initialData.variants.map((variant) => ({
client_id: createClientId(),
id: variant.id,
name: variant.name ?? '',
stock: variant.stock != null ? String(variant.stock) : '0',
prices: {
...buildEmptyPrices(),
...(variant.prices ?? {}),
},
}));
}
const variants = ref<ProductVariantFormItem[]>(buildInitialVariants());
const useSamePrices = ref(variants.value.length <= 1 || allVariantsHaveSamePrices(variants.value));
const form = useForm({
name: props.initialData?.name ?? '',
description: props.initialData?.description ?? '',
category_ids: props.initialData?.category_ids ?? [],
});
function allVariantsHaveSamePrices(items: ProductVariantFormItem[]): boolean {
if (items.length <= 1) {
return true;
}
const first = items[0].prices;
return items.every((variant) =>
PRICE_TYPES.every((type) => variant.prices[type].trim() === first[type].trim()),
);
}
function toggleCategory(categoryId: number, checked: boolean) {
if (checked) {
if (!form.category_ids.includes(categoryId)) {
form.category_ids = [...form.category_ids, categoryId];
}
return;
}
form.category_ids = form.category_ids.filter((id) => id !== categoryId);
}
function isCategoryChecked(categoryId: number): boolean {
return form.category_ids.includes(categoryId);
}
function addVariant() {
const newVariant = createEmptyVariant();
if (useSamePrices.value && variants.value[0]) {
newVariant.prices = { ...variants.value[0].prices };
}
variants.value = [...variants.value, newVariant];
}
function removeVariant(clientId: string) {
if (variants.value.length <= 1) {
return;
}
variants.value = variants.value.filter((variant) => variant.client_id !== clientId);
}
function setVariantField(clientId: string, key: 'name' | 'stock', value: string) {
variants.value = variants.value.map((variant) =>
variant.client_id === clientId ? { ...variant, [key]: value } : variant,
);
}
function setVariantPrice(clientId: string, type: string, value: string) {
variants.value = variants.value.map((variant) =>
variant.client_id === clientId
? { ...variant, prices: { ...variant.prices, [type]: value } }
: variant,
);
}
function setSharedPrice(type: string, value: string) {
variants.value = variants.value.map((variant) => ({
...variant,
prices: { ...variant.prices, [type]: value },
}));
}
function toggleUseSamePrices(checked: boolean) {
useSamePrices.value = checked;
if (!checked || !variants.value[0]) {
return;
}
const sourcePrices = { ...variants.value[0].prices };
variants.value = variants.value.map((variant) => ({
...variant,
prices: { ...sourcePrices },
}));
}
function applyPricesToAllVariants(sourceClientId: string) {
const source = variants.value.find((variant) => variant.client_id === sourceClientId);
if (!source) {
return;
}
variants.value = variants.value.map((variant) => ({
...variant,
prices: { ...source.prices },
}));
}
function buildSubmitPayload(): ProductFormData {
return {
name: form.name.trim(),
description: form.description.trim(),
category_ids: form.category_ids,
variants: variants.value.map((variant) => ({
...(variant.id ? { id: variant.id } : {}),
name: variant.name.trim(),
stock: Number.parseInt(variant.stock, 10) || 0,
prices: PRICE_TYPES.map((type) => ({
type,
price: Number.parseInt(parseRupiah(variant.prices[type]), 10) || 0,
})),
})),
};
}
function formError(key: string): string | undefined {
return (form.errors as Record<string, string>)[key];
}
function variantError(clientId: string, field: string): string | undefined {
const index = variants.value.findIndex((variant) => variant.client_id === clientId);
if (index === -1) {
return undefined;
}
return formError(`variants.${index}.${field}`)
?? formError(`variants.${index}.prices`);
}
function variantPriceError(clientId: string, type: string): string | undefined {
const index = variants.value.findIndex((variant) => variant.client_id === clientId);
if (index === -1) {
return undefined;
}
const priceIndex = PRICE_TYPES.indexOf(type as typeof PRICE_TYPES[number]);
return formError(`variants.${index}.prices.${priceIndex}.price`)
?? formError(`variants.${index}.prices.${priceIndex}.type`);
}
const categoryError = computed(() => form.errors.category_ids);
function submit() {
const options = {
onError: () => {
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
},
};
const payload = buildSubmitPayload();
if (props.method === 'put') {
form.transform(() => payload).put(props.submitUrl, options);
} else {
form.transform(() => payload).post(props.submitUrl, options);
}
}
</script>
<template>
<form @submit.prevent="submit">
<div class="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Informasi Produk</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<FieldSet class="grid gap-4">
<Field>
<FieldLabel for="name" required>Nama Produk</FieldLabel>
<Input id="name" v-model="form.name" type="text" placeholder="Masukkan nama produk" />
<FieldError :errors="form.errors.name ? [form.errors.name] : []" />
</Field>
<Field>
<FieldLabel for="description">Deskripsi</FieldLabel>
<Textarea id="description" v-model="form.description"
placeholder="Deskripsi produk (opsional)" rows="4" />
<FieldError :errors="form.errors.description ? [form.errors.description] : []" />
</Field>
<Field>
<FieldLabel required>Kategori</FieldLabel>
<div v-if="categories.length > 0"
class="grid gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
<label v-for="category in categories" :key="category.value"
class="flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2">
<input type="checkbox" class="size-4 rounded border-input"
:checked="isCategoryChecked(category.value)"
@change="toggleCategory(category.value, ($event.target as HTMLInputElement).checked)">
<span class="text-sm">{{ category.label }}</span>
</label>
</div>
<p v-else class="text-muted-foreground text-sm">
Belum ada kategori. Tambahkan kategori terlebih dahulu.
</p>
<FieldError :errors="categoryError ? [categoryError] : []" />
</Field>
</FieldSet>
</FieldGroup>
</CardContent>
</Card>
<Card v-if="variants.length > 1">
<CardHeader>
<CardTitle>Harga Bersama</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<label class="mb-4 flex cursor-pointer items-center gap-2">
<input type="checkbox" class="size-4 rounded border-input" :checked="useSamePrices"
@change="toggleUseSamePrices(($event.target as HTMLInputElement).checked)">
<span class="text-sm">Gunakan harga yang sama untuk semua varian</span>
</label>
<FieldSet v-if="useSamePrices && variants[0]"
class="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
<Field v-for="type in PRICE_TYPES" :key="type">
<FieldLabel :for="`shared_price_${type}`" required>
{{ PRICE_TYPE_LABELS[type] }}
</FieldLabel>
<RupiahInput :id="`shared_price_${type}`" :model-value="variants[0].prices[type]"
@update:model-value="setSharedPrice(type, $event)" />
<FieldError
:errors="variantPriceError(variants[0].client_id, type) ? [variantPriceError(variants[0].client_id, type)!] : []" />
</Field>
</FieldSet>
</FieldGroup>
</CardContent>
</Card>
<Card v-for="(variant, index) in variants" :key="variant.client_id">
<CardHeader class="flex flex-row items-start justify-between gap-4">
<CardTitle>Varian {{ index + 1 }}</CardTitle>
<div class="flex items-center gap-2">
<Button v-if="variants.length > 1 && !useSamePrices" type="button" variant="outline" size="sm"
@click="applyPricesToAllVariants(variant.client_id)">
<Copy class="size-4" />
Terapkan Harga ke Semua
</Button>
<Button v-if="variants.length > 1" type="button" variant="outline" size="icon"
class="text-destructive hover:text-destructive size-8"
@click="removeVariant(variant.client_id)">
<Trash2 class="size-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<FieldGroup>
<FieldSet class="grid gap-4 md:grid-cols-2">
<Field>
<FieldLabel :for="`variant_name_${variant.client_id}`" required>
Nama Varian
</FieldLabel>
<Input :id="`variant_name_${variant.client_id}`" :model-value="variant.name" type="text"
placeholder="Contoh: Merah / L"
@update:model-value="setVariantField(variant.client_id, 'name', String($event))" />
<FieldError
:errors="variantError(variant.client_id, 'name') ? [variantError(variant.client_id, 'name')!] : []" />
</Field>
<Field>
<FieldLabel :for="`variant_stock_${variant.client_id}`" required>
Stok
</FieldLabel>
<Input :id="`variant_stock_${variant.client_id}`" :model-value="variant.stock"
type="number" min="0"
@update:model-value="setVariantField(variant.client_id, 'stock', String($event))" />
<FieldError
:errors="variantError(variant.client_id, 'stock') ? [variantError(variant.client_id, 'stock')!] : []" />
</Field>
</FieldSet>
<FieldSet v-if="!useSamePrices || variants.length === 1"
class="mt-4 grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
<Field v-for="type in PRICE_TYPES" :key="`${variant.client_id}_${type}`">
<FieldLabel :for="`price_${variant.client_id}_${type}`" required>
{{ PRICE_TYPE_LABELS[type] }}
</FieldLabel>
<RupiahInput :id="`price_${variant.client_id}_${type}`"
:model-value="variant.prices[type]"
@update:model-value="setVariantPrice(variant.client_id, type, $event)" />
<FieldError
:errors="variantPriceError(variant.client_id, type) ? [variantPriceError(variant.client_id, type)!] : []" />
</Field>
</FieldSet>
</FieldGroup>
</CardContent>
</Card>
<div class="flex items-center justify-between gap-2">
<Button type="button" variant="outline" @click="addVariant">
<Plus class="size-4" />
Tambah Varian
</Button>
<Button type="submit" :disabled="form.processing">
<Save class="size-4" />
{{ form.processing ? 'Menyimpan...' : submitLabel }}
</Button>
</div>
</div>
</form>
</template>

View File

@ -0,0 +1,221 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { computed } from 'vue';
import DataTableActions from '@/components/products/data-table-actions.vue';
import ProductStatusToggle from '@/components/products/product-status-toggle.vue';
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/components/ui/empty';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { groupProductRows } from '@/lib/products';
import type {
DataTableFilterDef,
DataTablePagination,
DataTablePaginationLink,
} from '@/types/data-table';
import {
PRICE_TYPES,
PRICE_TYPE_LABELS,
type ProductTableRow,
} from '@/types/product';
const props = defineProps<{
rows: ProductTableRow[];
pagination?: DataTablePagination;
paginationLinks?: DataTablePaginationLink[];
paginationDisplayedCount?: number;
filterDefs?: DataTableFilterDef[];
filterValues?: Record<string, string>;
}>();
const search = defineModel<string>('search', { default: '' });
const emit = defineEmits<{
'filter-change': [key: string, value: string];
'filters-reset': [];
}>();
const productGroups = computed(() => groupProductRows(props.rows));
const showingCount = computed(() => props.paginationDisplayedCount ?? productGroups.value.length);
const paginationSummary = computed(() => {
if (!props.pagination) {
return null;
}
const { total } = props.pagination;
if (total === 0) {
return 'Menampilkan 0 produk';
}
return `Menampilkan ${showingCount.value} produk dari ${total}`;
});
function formatStock(value: number | null): string {
if (value === null) {
return '-';
}
return value.toLocaleString('id-ID');
}
function groupHeaderRow(group: ReturnType<typeof groupProductRows>[number]): ProductTableRow {
return group.variants[0];
}
</script>
<template>
<div class="space-y-4">
<DataTableToolbar
v-model:search="search"
:filter-defs="filterDefs"
:filter-values="filterValues"
@filter-change="(key, value) => emit('filter-change', key, value)"
@filters-reset="emit('filters-reset')"
/>
<div v-if="productGroups.length" class="space-y-4">
<div
v-for="group in productGroups"
:key="group.product_id"
class="overflow-hidden rounded-md border"
>
<div class="flex flex-col gap-3 border-b bg-muted/30 px-4 py-3 sm:flex-row sm:items-start sm:justify-between">
<div class="flex min-w-0 items-start gap-3">
<span class="text-muted-foreground w-8 shrink-0 pt-0.5 text-center text-sm tabular-nums">
{{ group.product_row_number }}
</span>
<div class="min-w-0 space-y-2">
<h3 class="font-medium leading-tight">
{{ group.product_name }}
</h3>
<div v-if="group.categories.length" class="flex flex-wrap gap-1">
<Badge
v-for="category in group.categories"
:key="category.id"
variant="outline"
>
{{ category.name }}
</Badge>
</div>
<p v-else class="text-muted-foreground text-sm">
Tanpa kategori
</p>
</div>
</div>
<div class="flex shrink-0 items-center justify-end gap-2 sm:pt-0.5">
<ProductStatusToggle :row="groupHeaderRow(group)" />
<DataTableActions :row="groupHeaderRow(group)" />
</div>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Varian</TableHead>
<TableHead>Stok</TableHead>
<TableHead>Harga</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="variant in group.variants"
:key="variant.row_id"
>
<TableCell class="font-medium">
{{ variant.variant_name ?? '-' }}
</TableCell>
<TableCell class="tabular-nums">
{{ formatStock(variant.stock) }}
</TableCell>
<TableCell>
<div
v-if="variant.prices.length"
class="space-y-0.5 text-xs"
>
<div
v-for="type in PRICE_TYPES"
:key="type"
>
<div
v-if="variant.prices.find((item) => item.type === type)"
class="flex items-center justify-between gap-3"
>
<span class="text-muted-foreground">
{{ PRICE_TYPE_LABELS[type] }}
</span>
<span class="font-medium tabular-nums">
{{
variant.prices.find((item) => item.type === type)?.price_formatted
}}
</span>
</div>
</div>
</div>
<span v-else>-</span>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
<div
v-else
class="rounded-md border px-6 py-10"
>
<Empty>
<EmptyHeader>
<EmptyTitle>Data tidak ditemukan</EmptyTitle>
<EmptyDescription>
Silakan lakukan pencarian atau filter untuk menemukan data yang Anda cari.
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
<div
v-if="pagination"
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<p class="text-muted-foreground text-sm">
{{ paginationSummary }}
</p>
<div
v-if="paginationLinks?.length && pagination.lastPage > 1"
class="flex flex-wrap items-center justify-center gap-1 sm:justify-end"
>
<Button
v-for="link in paginationLinks"
:key="`${link.label}-${link.url}`"
variant="outline"
size="sm"
:disabled="!link.url || link.active"
as-child
>
<Link v-if="link.url" :href="link.url" preserve-scroll>
<span v-html="link.label" />
</Link>
<span v-else v-html="link.label" />
</Button>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,128 @@
import type { ColumnDef } from '@tanstack/vue-table';
import { h } from 'vue';
import { DataTableColumnHeader } from '@/components/data-table';
import DataTableActions from '@/components/products/data-table-actions.vue';
import ProductStatusToggle from '@/components/products/product-status-toggle.vue';
import { Badge } from '@/components/ui/badge';
import { PRICE_TYPES, PRICE_TYPE_LABELS, type ProductTableRow } from '@/types/product';
const GROUPED_CELL_CLASS = 'align-middle';
function productRowSpan(row: ProductTableRow): number {
return row.is_first_variant ? row.variant_count : 0;
}
function formatStock(value: number | null): string {
if (value === null) {
return '-';
}
return value.toLocaleString('id-ID');
}
export const columns: ColumnDef<ProductTableRow>[] = [
{
id: 'row_number',
enableSorting: false,
enableHiding: false,
meta: {
getRowSpan: productRowSpan,
cellClassName: 'w-12 max-w-12 shrink-0 text-center',
},
header: () => h('div', { class: 'text-center' }, 'No.'),
cell: ({ row }) => row.original.product_row_number,
},
{
accessorKey: 'product_name',
enableSorting: true,
meta: {
getRowSpan: productRowSpan,
cellClassName: GROUPED_CELL_CLASS,
},
header: () => h(DataTableColumnHeader, { title: 'Nama', column: 'name' }),
cell: ({ row }) => h('div', { class: 'font-medium' }, row.original.product_name),
},
{
id: 'categories',
enableSorting: false,
meta: {
getRowSpan: productRowSpan,
cellClassName: GROUPED_CELL_CLASS,
},
header: () => h(DataTableColumnHeader, { title: 'Kategori', column: 'categories' }),
cell: ({ row }) => {
const categories = row.original.categories;
if (categories.length === 0) {
return '-';
}
return h(
'div',
{ class: 'flex flex-wrap gap-1' },
categories.map((category) => h(Badge, { variant: 'outline' }, () => category.name)),
);
},
},
{
accessorKey: 'variant_name',
enableSorting: false,
header: () => h(DataTableColumnHeader, { title: 'Varian', column: 'variant_name' }),
cell: ({ row }) => row.original.variant_name ?? '-',
},
{
accessorKey: 'stock',
enableSorting: false,
header: () => h(DataTableColumnHeader, { title: 'Stok', column: 'stock' }),
cell: ({ row }) => h('span', { class: 'tabular-nums' }, formatStock(row.original.stock)),
},
{
id: 'prices',
enableSorting: false,
header: () => h(DataTableColumnHeader, { title: 'Harga', column: 'prices' }),
cell: ({ row }) => {
const prices = row.original.prices;
if (prices.length === 0) {
return '-';
}
return h(
'div',
{ class: 'space-y-0.5 text-xs' },
PRICE_TYPES.map((type) => {
const price = prices.find((item) => item.type === type);
if (!price) {
return null;
}
return h('div', { class: 'flex items-center justify-between gap-3' }, [
h('span', { class: 'text-muted-foreground' }, PRICE_TYPE_LABELS[type]),
h('span', { class: 'font-medium tabular-nums' }, price.price_formatted),
]);
}).filter(Boolean),
);
},
},
{
id: 'status_toggle',
enableSorting: true,
meta: {
getRowSpan: productRowSpan,
cellClassName: GROUPED_CELL_CLASS,
},
header: () => h(DataTableColumnHeader, { title: 'Status', column: 'is_active' }),
cell: ({ row }) => h(ProductStatusToggle, { row: row.original }),
},
{
id: 'actions',
enableSorting: false,
enableHiding: false,
meta: {
getRowSpan: productRowSpan,
cellClassName: GROUPED_CELL_CLASS,
},
cell: ({ row }) => h(DataTableActions, { row: row.original }),
},
];

View File

@ -0,0 +1,79 @@
<script setup lang="ts">
import { Link, router } from '@inertiajs/vue3';
import { Pencil, Trash2 } from '@lucide/vue';
import { ref } from 'vue';
import { toast } from 'vue-sonner';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useCan } from '@/composables/useCan';
import type { ProductTableRow } from '@/types/product';
const props = defineProps<{
row: ProductTableRow;
}>();
const { can } = useCan();
const deleteConfirmOpen = ref(false);
const deleteProcessing = ref(false);
function destroyProduct() {
deleteProcessing.value = true;
router.delete(`/admin/master/products/${props.row.product_id}`, {
onSuccess: () => {
deleteConfirmOpen.value = false;
},
onError: () => {
toast.error('Gagal menghapus produk.');
},
onFinish: () => {
deleteProcessing.value = false;
},
});
}
</script>
<template>
<div class="flex items-center justify-end gap-1">
<Tooltip v-if="can('products.update')">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8" as-child>
<Link :href="`/admin/master/products/${row.product_id}/edit`">
<Pencil class="size-4" />
<span class="sr-only">Ubah</span>
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>Ubah</TooltipContent>
</Tooltip>
<Tooltip v-if="can('products.delete')">
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="text-destructive hover:text-destructive size-8"
@click="deleteConfirmOpen = true"
>
<Trash2 class="size-4" />
<span class="sr-only">Hapus</span>
</Button>
</TooltipTrigger>
<TooltipContent>Hapus</TooltipContent>
</Tooltip>
</div>
<ConfirmDialog
v-if="can('products.delete')"
v-model:open="deleteConfirmOpen"
title="Hapus produk?"
:description="`Produk ${row.product_name} akan dihapus beserta seluruh variannya. Tindakan ini tidak dapat dibatalkan.`"
confirm-label="Hapus"
cancel-label="Batal"
destructive
:loading="deleteProcessing"
@confirm="destroyProduct"
/>
</template>

View File

@ -0,0 +1,61 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { useCan } from '@/composables/useCan';
import type { ProductTableRow } from '@/types/product';
const props = defineProps<{
row: ProductTableRow;
}>();
const { can } = useCan();
const isActive = ref(props.row.is_active);
const processing = ref(false);
watch(
() => props.row.is_active,
(value) => {
isActive.value = value;
},
);
function toggleStatus(checked: boolean) {
if (!can('products.toggle-status')) {
return;
}
const previous = isActive.value;
isActive.value = checked;
processing.value = true;
router.patch(`/admin/master/products/${props.row.product_id}/toggle-status`, {
is_active: checked,
}, {
preserveScroll: true,
onError: () => {
isActive.value = previous;
toast.error('Gagal memperbarui status produk.');
},
onFinish: () => {
processing.value = false;
},
});
}
</script>
<template>
<div class="flex items-center gap-2">
<Switch
:model-value="isActive"
:disabled="processing || !can('products.toggle-status')"
@update:model-value="toggleStatus"
/>
<Badge :variant="isActive ? 'default' : 'secondary'">
{{ isActive ? 'Aktif' : 'Nonaktif' }}
</Badge>
</div>
</template>

View File

@ -4,12 +4,14 @@ import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
rowspan?: number
}>()
</script>
<template>
<td
data-slot="table-cell"
:rowspan="rowspan"
:class="
cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 *:[[role=checkbox]]:translate-y-0.5',

View File

@ -0,0 +1,28 @@
import type { ProductTableGroup, ProductTableRow } from '@/types/product';
export function groupProductRows(rows: ProductTableRow[]): ProductTableGroup[] {
const groups: ProductTableGroup[] = [];
const indexByProductId = new Map<number, number>();
for (const row of rows) {
const existingIndex = indexByProductId.get(row.product_id);
if (existingIndex === undefined) {
indexByProductId.set(row.product_id, groups.length);
groups.push({
product_id: row.product_id,
product_row_number: row.product_row_number,
product_name: row.product_name,
categories: row.categories,
is_active: row.is_active,
variants: [row],
});
continue;
}
groups[existingIndex].variants.push(row);
}
return groups;
}

View File

@ -0,0 +1,42 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { ArrowLeft } from '@lucide/vue';
import ProductForm from '@/components/products/ProductForm.vue';
import { Button } from '@/components/ui/button';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { CategoryOption, EnumOption } from '@/types/product';
defineProps<{
categories: CategoryOption[];
priceTypes: EnumOption[];
}>();
</script>
<template>
<Head title="Tambah Produk" />
<AdminLayout title="Tambah Produk">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<h2 class="text-2xl font-bold tracking-tight">
Tambah Produk
</h2>
</div>
<Button variant="outline" as-child class="shrink-0 self-start sm:self-center">
<Link href="/admin/master/products">
<ArrowLeft class="size-4" />
Kembali
</Link>
</Button>
</div>
<ProductForm
submit-url="/admin/master/products"
method="post"
submit-label="Simpan"
:categories="categories"
/>
</AdminLayout>
</template>

View File

@ -0,0 +1,52 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { ArrowLeft } from '@lucide/vue';
import { computed } from 'vue';
import ProductForm from '@/components/products/ProductForm.vue';
import { Button } from '@/components/ui/button';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { CategoryOption, EnumOption, ProductEditItem } from '@/types/product';
const props = defineProps<{
product: ProductEditItem;
categories: CategoryOption[];
priceTypes: EnumOption[];
}>();
const initialData = computed(() => ({
name: props.product.name ?? '',
description: props.product.description ?? '',
category_ids: props.product.category_ids ?? [],
variants: props.product.variants ?? [],
}));
</script>
<template>
<Head title="Ubah Produk" />
<AdminLayout title="Ubah Produk">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<h2 class="text-2xl font-bold tracking-tight">
Ubah Produk
</h2>
</div>
<Button variant="outline" as-child class="shrink-0 self-start sm:self-center">
<Link href="/admin/master/products">
<ArrowLeft class="size-4" />
Kembali
</Link>
</Button>
</div>
<ProductForm
:submit-url="`/admin/master/products/${product.id}`"
method="put"
submit-label="Perbarui"
:initial-data="initialData"
:categories="categories"
/>
</AdminLayout>
</template>

View File

@ -0,0 +1,111 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { Plus } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import ProductGroupedTable from '@/components/products/ProductGroupedTable.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useCan } from '@/composables/useCan';
import { useDataTableQuery, useDataTableQuerySync } from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { DataTableFilterDef } from '@/types/data-table';
import type { ProductPagination, ProductTableRow } from '@/types/product';
const props = defineProps<{
rows: ProductTableRow[];
pagination: ProductPagination;
filters: {
search: string;
sort?: string;
direction?: 'asc' | 'desc';
is_active?: string;
};
}>();
const { can } = useCan();
const search = ref(props.filters.search ?? '');
const { query, setSearch, setFilter, resetFilters, syncFromServer } = useDataTableQuery({
url: '/admin/master/products',
initial: { ...props.filters },
filterKeys: ['is_active'],
});
useDataTableQuerySync(() => props.filters, syncFromServer);
const filterDefs = computed<DataTableFilterDef[]>(() => [
{
key: 'is_active',
label: 'Status',
type: 'select',
options: [
{ value: '1', label: 'Aktif' },
{ value: '0', label: 'Nonaktif' },
],
},
]);
const filterValues = computed(() => ({
is_active: query.value.is_active ?? '',
}));
const tablePagination = computed(() => ({
currentPage: props.pagination.current_page,
perPage: props.pagination.per_page,
lastPage: props.pagination.last_page,
total: props.pagination.total,
}));
const productCountOnPage = computed(() => (
new Set(props.rows.map((row) => row.product_id)).size
));
watch(search, (value) => {
setSearch(value);
});
watch(
() => props.filters.search,
(value) => {
search.value = value ?? '';
},
);
</script>
<template>
<Head title="Produk" />
<AdminLayout>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<h2 class="text-2xl font-bold tracking-tight">
Produk
</h2>
</div>
<Button v-if="can('products.create')" as-child class="shrink-0 self-start sm:self-center">
<Link href="/admin/master/products/create">
<Plus class="size-4" />
Tambah
</Link>
</Button>
</div>
<Card class="min-w-0">
<CardContent class="min-w-0">
<ProductGroupedTable
v-model:search="search"
:rows="rows"
:pagination="tablePagination"
:pagination-links="pagination.links"
:pagination-displayed-count="productCountOnPage"
:filter-defs="filterDefs"
:filter-values="filterValues"
@filter-change="setFilter"
@filters-reset="resetFilters"
/>
</CardContent>
</Card>
</AdminLayout>
</template>

View File

@ -0,0 +1,122 @@
export type EnumOption = {
value: string;
label: string;
};
export type CategoryOption = {
value: number;
label: string;
};
export type ProductPriceItem = {
type: string;
type_label: string;
price: string;
price_formatted: string;
};
export type ProductCategoryItem = {
id: number;
name: string;
};
export type ProductTableGroup = {
product_id: number;
product_row_number: number;
product_name: string;
categories: ProductCategoryItem[];
is_active: boolean;
variants: ProductTableRow[];
};
export type ProductTableRow = {
row_id: string;
product_id: number;
product_name: string;
categories: ProductCategoryItem[];
is_active: boolean;
is_first_variant: boolean;
variant_count: number;
product_row_number: number;
variant_id: number | null;
variant_name: string | null;
stock: number | null;
prices: ProductPriceItem[];
};
export type ProductVariantFormItem = {
client_id: string;
id?: number;
name: string;
stock: string;
prices: Record<string, string>;
};
export type ProductEditItem = {
id: number;
name: string;
description: string | null;
category_ids: number[];
variants: Array<{
id: number;
name: string;
stock: number;
prices: Record<string, string>;
}>;
};
export type ProductFormData = {
name: string;
description: string;
category_ids: number[];
variants: Array<{
id?: number;
name: string;
stock: number;
prices: Array<{
type: string;
price: number;
}>;
}>;
};
export type ProductFilters = {
search: string;
sort?: string;
direction?: 'asc' | 'desc' | null;
is_active?: string;
};
export type ProductPagination = {
current_page: number;
last_page: number;
per_page: number;
total: number;
links: Array<{
url: string | null;
label: string;
active: boolean;
}>;
};
export const PRICE_TYPES = [
'distributor',
'agent',
'sub_agent',
'grosir',
'ecer',
'tiktok',
'shopee',
] as const;
export type PriceType = (typeof PRICE_TYPES)[number];
export const PRICE_TYPE_LABELS: Record<PriceType, string> = {
distributor: 'Distributor',
agent: 'Agen',
sub_agent: 'Sub Agen',
grosir: 'Grosir',
ecer: 'Eceran',
tiktok: 'TikTok',
shopee: 'Shopee',
};

View File

@ -0,0 +1,9 @@
import '@tanstack/vue-table';
declare module '@tanstack/vue-table' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData, TValue> {
getRowSpan?: (row: TData) => number;
cellClassName?: string;
}
}

View File

@ -4,6 +4,7 @@
use App\Http\Controllers\Admin\CategoryController;
use App\Http\Controllers\Admin\DashboardController;
use App\Http\Controllers\Admin\Hr\EmployeeController;
use App\Http\Controllers\Admin\ProductController;
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\Auth\LogoutController;
use Illuminate\Support\Facades\Route;
@ -22,27 +23,55 @@
Route::get('/dashboard', [DashboardController::class, 'index'])
->name('dashboard');
Route::prefix('master')->name('master.')
->middleware('permission:'.Permission::CATEGORIES_VIEW->value)
->group(function () {
Route::prefix('categories')->name('categories.')
->middleware('permission:'.Permission::CATEGORIES_VIEW->value)
->group(function () {
Route::get('/', [CategoryController::class, 'index'])->name('index');
Route::prefix('master')->name('master.')->group(function () {
Route::prefix('categories')->name('categories.')
->middleware('permission:'.Permission::CATEGORIES_VIEW->value)
->group(function () {
Route::get('/', [CategoryController::class, 'index'])->name('index');
Route::post('/', [CategoryController::class, 'store'])
->middleware('permission:'.Permission::CATEGORIES_CREATE->value)
->name('store');
Route::post('/', [CategoryController::class, 'store'])
->middleware('permission:'.Permission::CATEGORIES_CREATE->value)
->name('store');
Route::put('{category}', [CategoryController::class, 'update'])
->middleware('permission:'.Permission::CATEGORIES_UPDATE->value)
->name('update');
Route::put('{category}', [CategoryController::class, 'update'])
->middleware('permission:'.Permission::CATEGORIES_UPDATE->value)
->name('update');
Route::delete('{category}', [CategoryController::class, 'destroy'])
->middleware('permission:'.Permission::CATEGORIES_DELETE->value)
->name('destroy');
});
});
Route::delete('{category}', [CategoryController::class, 'destroy'])
->middleware('permission:'.Permission::CATEGORIES_DELETE->value)
->name('destroy');
});
Route::prefix('products')->name('products.')
->middleware('permission:'.Permission::PRODUCTS_VIEW->value)
->group(function () {
Route::get('create', [ProductController::class, 'create'])
->middleware('permission:'.Permission::PRODUCTS_CREATE->value)
->name('create');
Route::post('/', [ProductController::class, 'store'])
->middleware('permission:'.Permission::PRODUCTS_CREATE->value)
->name('store');
Route::get('{product}/edit', [ProductController::class, 'edit'])
->middleware('permission:'.Permission::PRODUCTS_UPDATE->value)
->name('edit');
Route::put('{product}', [ProductController::class, 'update'])
->middleware('permission:'.Permission::PRODUCTS_UPDATE->value)
->name('update');
Route::patch('{product}/toggle-status', [ProductController::class, 'toggleStatus'])
->middleware('permission:'.Permission::PRODUCTS_TOGGLE_STATUS->value)
->name('toggle-status');
Route::delete('{product}', [ProductController::class, 'destroy'])
->middleware('permission:'.Permission::PRODUCTS_DELETE->value)
->name('destroy');
Route::get('/', [ProductController::class, 'index'])->name('index');
});
});
Route::prefix('hr')->name('hr.')->middleware('permission:'.Permission::EMPLOYEES_VIEW->value)->group(function () {
Route::prefix('employees')->name('employees.')