Refactor admin controllers for categories, products, and raw materials. Introduce new Master namespace for CategoryController, ProductController, and RawMaterialController, enhancing organization and maintainability. Migrate existing logic to dedicated service classes for improved separation of concerns and code clarity. Remove outdated controllers to streamline the codebase.

This commit is contained in:
Yoga Pangestu 2026-06-10 15:02:14 +07:00
parent 9e3325c16a
commit 887b811cee
53 changed files with 1210 additions and 1522 deletions

View File

@ -1,97 +0,0 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\CategoryRequest;
use App\Models\Category;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class CategoryController extends Controller
{
use ParsesDataTableQuery;
public function index(Request $request): Response
{
$tableQuery = $this->parseDataTableQuery($request);
$search = $tableQuery['search'];
$sort = $tableQuery['sort'];
$direction = $tableQuery['direction'];
$query = Category::query()
->when($search !== '', function (Builder $query) use ($search): void {
$query->where(function (Builder $query) use ($search): void {
$query->where('name', 'like', "%{$search}%");
});
});
$this->applySorting($query, $sort, $direction);
$categories = $query
->paginate(10)
->withQueryString()
->through(fn (Category $category) => $this->transformCategory($category));
return Inertia::render('admin/categories/Index', [
'categories' => $categories,
'filters' => $this->dataTableFilters($tableQuery),
]);
}
public function store(CategoryRequest $request): RedirectResponse
{
Category::create($request->validated());
Inertia::flash('success', 'Kategori berhasil ditambahkan.');
return redirect()->route('admin.master.categories.index');
}
public function update(CategoryRequest $request, Category $category): RedirectResponse
{
$validated = $request->validated();
$category->name = $validated['name'];
$category->save();
Inertia::flash('success', 'Kategori berhasil diperbarui.');
return redirect()->route('admin.master.categories.index');
}
public function destroy(Category $category): RedirectResponse
{
$category->delete();
Inertia::flash('success', 'Kategori berhasil dihapus.');
return redirect()->route('admin.master.categories.index');
}
private function applySorting(Builder $query, string $sort, string $direction): void
{
if (in_array($sort, ['name', 'slug'], true)) {
$query->orderBy($sort, $direction);
return;
}
$query->latest();
}
/**
* @return array<string, mixed>
*/
private function transformCategory(Category $category): array
{
return [
'id' => $category->id,
'name' => $category->name,
'slug' => $category->slug,
];
}
}

View File

@ -8,14 +8,10 @@
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Hr\EmployeeRequest;
use App\Models\Employee;
use App\Models\User;
use App\Models\UserProfile;
use Illuminate\Database\Eloquent\Builder;
use App\Services\Hr\EmployeeService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Inertia\Inertia;
use Inertia\Response;
@ -23,42 +19,17 @@ class EmployeeController extends Controller
{
use ParsesDataTableQuery;
public function __construct(
private readonly EmployeeService $employeeService,
) {}
public function index(Request $request): Response
{
$tableQuery = $this->parseDataTableQuery($request);
$search = $tableQuery['search'];
$sort = $tableQuery['sort'];
$direction = $tableQuery['direction'];
$employmentStatus = $request->string('employment_status')->toString();
$query = User::query()
->with(['profile', 'employee', 'roles'])
->whereHas('employee')
->when($search !== '', function ($query) use ($search): void {
$query->where(function ($query) use ($search): void {
$query->where('email', 'like', "%{$search}%")
->orWhere('username', 'like', "%{$search}%")
->orWhereHas('profile', function ($query) use ($search): void {
$query->where('full_name', 'like', "%{$search}%")
->orWhere('phone_number', 'like', "%{$search}%");
});
});
})
->when(
$employmentStatus !== '',
fn ($query) => $query->whereHas('employee', fn ($query) => $query->where('employment_status', $employmentStatus))
);
$this->applySorting($query, $sort, $direction);
$employees = $query
->paginate(10)
->withQueryString()
->through(fn (User $user) => $this->transformEmployee($user));
return Inertia::render('admin/hr/employees/Index', [
'employees' => $employees,
'employees' => $this->employeeService->paginateForIndex($tableQuery, $employmentStatus),
'filters' => $this->dataTableFilters($tableQuery, [
'employment_status' => $employmentStatus,
]),
@ -77,33 +48,7 @@ public function create(): Response
public function store(EmployeeRequest $request): RedirectResponse
{
$validated = $request->validated();
DB::transaction(function () use ($validated): void {
$user = User::create([
'email' => $validated['email'],
'username' => $validated['username'],
'password' => Hash::make(config('auth.password_default')),
]);
UserProfile::create([
'user_id' => $user->id,
'full_name' => $validated['full_name'],
'phone_number' => $validated['phone_number'],
'gender' => $validated['gender'],
'birth_date' => $validated['birth_date'],
'address' => $validated['address'],
]);
Employee::create([
'user_id' => $user->id,
'join_date' => $validated['join_date'],
'employment_status' => $validated['employment_status'],
'base_salary' => $validated['base_salary'],
]);
$user->syncRoles([$validated['role']]);
});
$this->employeeService->create($request->validated());
Inertia::flash('success', 'Pegawai berhasil ditambahkan.');
@ -118,35 +63,13 @@ public function edit(User $user): Response
'genders' => Gender::selectOptions(),
'employmentStatuses' => EmploymentStatus::selectOptions(),
'roles' => Role::assignableSelectOptions(),
'employee' => $this->transformEmployeeForForm($user),
'employee' => $user,
]);
}
public function update(EmployeeRequest $request, User $user): RedirectResponse
{
$validated = $request->validated();
$employee = $user->employee;
DB::transaction(function () use ($validated, $user, $employee): void {
$user->email = $validated['email'];
$user->username = $validated['username'];
$user->save();
$profile = $user->profile;
$profile->full_name = $validated['full_name'];
$profile->phone_number = $validated['phone_number'];
$profile->gender = $validated['gender'];
$profile->birth_date = $validated['birth_date'];
$profile->address = $validated['address'];
$profile->save();
$employee->join_date = $validated['join_date'];
$employee->employment_status = $validated['employment_status'];
$employee->base_salary = $validated['base_salary'];
$employee->save();
$user->syncRoles([$validated['role']]);
});
$this->employeeService->update($user, $request->validated());
Inertia::flash('success', 'Data pegawai berhasil diperbarui.');
@ -159,12 +82,7 @@ public function toggleStatus(Request $request, User $user): RedirectResponse
'is_active' => ['required', 'boolean'],
]);
$user->is_active = $validated['is_active'];
$user->save();
if (! $validated['is_active']) {
DB::table('sessions')->where('user_id', $user->id)->delete();
}
$this->employeeService->toggleStatus($user, $validated['is_active']);
Inertia::flash('success', 'Status pegawai berhasil diperbarui.');
@ -173,10 +91,7 @@ public function toggleStatus(Request $request, User $user): RedirectResponse
public function resetPassword(User $user): RedirectResponse
{
$user->password = config('auth.password_default');
$user->save();
DB::table('sessions')->where('user_id', $user->id)->delete();
$this->employeeService->resetPassword($user);
Inertia::flash('success', 'Kata sandi berhasil direset. Pengguna telah logout dari semua sesi.');
@ -185,108 +100,10 @@ public function resetPassword(User $user): RedirectResponse
public function destroy(User $user): RedirectResponse
{
DB::transaction(function () use ($user): void {
$user->employee?->delete();
$user->profile?->delete();
$user->delete();
});
$this->employeeService->delete($user);
Inertia::flash('success', 'Pegawai berhasil dihapus.');
return redirect()->route('admin.hr.employees.index');
}
private function applySorting(Builder $query, string $sort, string $direction): void
{
$employeeSorts = [
'join_date',
'base_salary',
'employment_status',
];
if (in_array($sort, $employeeSorts, true)) {
$query->orderBy(
Employee::select($sort)
->whereColumn('employees.user_id', 'users.id')
->limit(1),
$direction
);
return;
}
if ($sort === 'full_name') {
$query->orderBy(
UserProfile::select('full_name')
->whereColumn('user_profiles.user_id', 'users.id')
->limit(1),
$direction
);
return;
}
if ($sort === 'email') {
$query->orderBy('email', $direction);
return;
}
$query->latest();
}
/**
* @return array<string, mixed>
*/
private function transformEmployeeForForm(User $user): array
{
$employee = $user->employee;
$profile = $user->profile;
return [
'id' => $user->id,
'email' => $user->email,
'username' => $user->username,
'full_name' => $profile->full_name,
'phone_number' => $profile->phone_number,
'gender' => $profile->gender?->value,
'birth_date' => $profile->birth_date?->format('Y-m-d'),
'address' => $profile->address,
'join_date' => $employee->join_date?->format('Y-m-d'),
'employment_status' => $employee->employment_status?->value,
'base_salary' => $employee->base_salary,
'role' => $user->roles->first()?->name,
];
}
/**
* @return array<string, mixed>
*/
private function transformEmployee(User $user): array
{
$employee = $user->employee;
$profile = $user->profile;
$role = $user->roles->first();
return [
'id' => $user->id,
'join_date' => $employee->join_date_formatted,
'resign_date' => $employee->resign_date_formatted,
'employment_status' => $employee->employment_status?->value,
'employment_status_label' => $employee->employment_status?->label(),
'base_salary' => $employee->base_salary,
'base_salary_formatted' => $employee->base_salary_formatted,
'email' => $user->email,
'username' => $user->username,
'is_active' => $user->is_active,
'full_name' => $profile->full_name,
'phone_number' => $profile->phone_number,
'gender' => $profile->gender?->value,
'gender_label' => $profile->gender?->label(),
'birth_date' => $profile->birth_date_formatted,
'address' => $profile->address,
'role' => $role?->name,
'role_label' => $role ? Role::from($role->name)->label() : null,
];
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Admin\Master;
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Master\CategoryRequest;
use App\Models\Category;
use App\Services\Master\CategoryService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class CategoryController extends Controller
{
use ParsesDataTableQuery;
public function __construct(
private readonly CategoryService $categoryService,
) {}
public function index(Request $request): Response
{
$tableQuery = $this->parseDataTableQuery($request);
return Inertia::render('admin/master/categories/Index', [
'categories' => $this->categoryService->paginateForIndex($tableQuery),
'filters' => $this->dataTableFilters($tableQuery),
]);
}
public function store(CategoryRequest $request): RedirectResponse
{
$this->categoryService->create($request->validated());
Inertia::flash('success', 'Kategori berhasil ditambahkan.');
return redirect()->route('admin.master.categories.index');
}
public function update(CategoryRequest $request, Category $category): RedirectResponse
{
$this->categoryService->update($category, $request->validated());
Inertia::flash('success', 'Kategori berhasil diperbarui.');
return redirect()->route('admin.master.categories.index');
}
public function destroy(Category $category): RedirectResponse
{
$this->categoryService->delete($category);
Inertia::flash('success', 'Kategori berhasil dihapus.');
return redirect()->route('admin.master.categories.index');
}
}

View File

@ -0,0 +1,100 @@
<?php
namespace App\Http\Controllers\Admin\Master;
use App\Enums\PriceType;
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Master\ProductRequest;
use App\Models\Product;
use App\Services\Master\ProductService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ProductController extends Controller
{
use ParsesDataTableQuery;
public function __construct(
private readonly ProductService $productService,
) {}
public function index(Request $request): Response
{
$tableQuery = $this->parseDataTableQuery($request);
$isActive = $request->string('is_active')->toString();
return Inertia::render('admin/master/products/Index', [
'products' => $this->productService->paginateForIndex($tableQuery, $isActive),
'filters' => $this->dataTableFilters($tableQuery, [
'is_active' => $isActive,
]),
]);
}
public function create(): Response
{
return Inertia::render('admin/master/products/Create', [
'categories' => $this->productService->categoryOptions(),
'priceTypes' => PriceType::selectOptions(),
]);
}
public function store(ProductRequest $request): RedirectResponse
{
$this->productService->create($request->validated());
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/master/products/Edit', [
'categories' => $this->productService->categoryOptions(),
'priceTypes' => PriceType::selectOptions(),
'product' => $product,
]);
}
public function update(ProductRequest $request, Product $product): RedirectResponse
{
$this->productService->update($product, $request->validated());
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'],
]);
$this->productService->toggleStatus($product, $validated['is_active']);
Inertia::flash('success', 'Status produk berhasil diperbarui.');
return back();
}
public function destroy(Product $product): RedirectResponse
{
$this->productService->delete($product);
Inertia::flash('success', 'Produk berhasil dihapus.');
return redirect()->route('admin.master.products.index');
}
}

View File

@ -0,0 +1,95 @@
<?php
namespace App\Http\Controllers\Admin\Master;
use App\Enums\RawMaterialUnit;
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Master\RawMaterialRequest;
use App\Models\RawMaterial;
use App\Services\Master\RawMaterialService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class RawMaterialController extends Controller
{
use ParsesDataTableQuery;
public function __construct(
private readonly RawMaterialService $rawMaterialService,
) {}
public function index(Request $request): Response
{
$tableQuery = $this->parseDataTableQuery($request);
$isActive = $request->string('is_active')->toString();
return Inertia::render('admin/master/raw-materials/Index', [
'rawMaterials' => $this->rawMaterialService->paginateForIndex($tableQuery, $isActive),
'filters' => $this->dataTableFilters($tableQuery, [
'is_active' => $isActive,
]),
]);
}
public function create(): Response
{
return Inertia::render('admin/master/raw-materials/Create', [
'units' => RawMaterialUnit::selectOptions(),
]);
}
public function store(RawMaterialRequest $request): RedirectResponse
{
$this->rawMaterialService->create($request->validated());
Inertia::flash('success', 'Bahan baku berhasil ditambahkan.');
return redirect()->route('admin.master.raw-materials.index');
}
public function edit(RawMaterial $rawMaterial): Response
{
$rawMaterial->load([
'prices' => fn ($query) => $query->orderBy('created_at'),
]);
return Inertia::render('admin/master/raw-materials/Edit', [
'units' => RawMaterialUnit::selectOptions(),
'rawMaterial' => $rawMaterial,
]);
}
public function update(RawMaterialRequest $request, RawMaterial $rawMaterial): RedirectResponse
{
$this->rawMaterialService->update($rawMaterial, $request->validated());
Inertia::flash('success', 'Bahan baku berhasil diperbarui.');
return redirect()->route('admin.master.raw-materials.index');
}
public function toggleStatus(Request $request, RawMaterial $rawMaterial): RedirectResponse
{
$validated = $request->validate([
'is_active' => ['required', 'boolean'],
]);
$this->rawMaterialService->toggleStatus($rawMaterial, $validated['is_active']);
Inertia::flash('success', 'Status bahan baku berhasil diperbarui.');
return back();
}
public function destroy(RawMaterial $rawMaterial): RedirectResponse
{
$this->rawMaterialService->delete($rawMaterial);
Inertia::flash('success', 'Bahan baku berhasil dihapus.');
return redirect()->route('admin.master.raw-materials.index');
}
}

View File

@ -1,338 +0,0 @@
<?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

@ -1,278 +0,0 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Enums\RawMaterialUnit;
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\RawMaterialRequest;
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice;
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 RawMaterialController 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 = RawMaterial::query()
->with([
'prices' => fn ($query) => $query->orderBy('created_at'),
])
->when($search !== '', function (Builder $query) use ($search): void {
$query->where(function (Builder $query) use ($search): void {
$query->where('name', 'like', "%{$search}%")
->orWhereHas('prices', fn (Builder $query) => $query->where('variant', 'like', "%{$search}%"));
});
})
->when(
$isActive !== '',
fn (Builder $query) => $query->where('is_active', $isActive === '1')
);
$this->applySorting($query, $sort, $direction);
$rawMaterials = $query
->paginate(10)
->withQueryString();
$rows = $rawMaterials->getCollection()
->flatMap(fn (RawMaterial $rawMaterial, int $index) => $this->flattenRawMaterialForTable(
$rawMaterial,
$index,
$rawMaterials->firstItem() ?? 1,
))
->values()
->all();
return Inertia::render('admin/raw-materials/Index', [
'rows' => $rows,
'pagination' => [
'current_page' => $rawMaterials->currentPage(),
'last_page' => $rawMaterials->lastPage(),
'per_page' => $rawMaterials->perPage(),
'total' => $rawMaterials->total(),
'links' => $rawMaterials->linkCollection()->toArray(),
],
'filters' => $this->dataTableFilters($tableQuery, [
'is_active' => $isActive,
]),
]);
}
public function create(): Response
{
return Inertia::render('admin/raw-materials/Create', [
'units' => RawMaterialUnit::selectOptions(),
]);
}
public function store(RawMaterialRequest $request): RedirectResponse
{
$validated = $request->validated();
DB::transaction(function () use ($validated): void {
$rawMaterial = RawMaterial::create([
'name' => $validated['name'],
'unit' => $validated['unit'],
]);
foreach ($validated['prices'] as $priceData) {
$this->createPrice($rawMaterial, $priceData);
}
});
Inertia::flash('success', 'Bahan baku berhasil ditambahkan.');
return redirect()->route('admin.master.raw-materials.index');
}
public function edit(RawMaterial $rawMaterial): Response
{
$rawMaterial->load([
'prices' => fn ($query) => $query->orderBy('created_at'),
]);
return Inertia::render('admin/raw-materials/Edit', [
'units' => RawMaterialUnit::selectOptions(),
'rawMaterial' => $this->transformRawMaterialForForm($rawMaterial),
]);
}
public function update(RawMaterialRequest $request, RawMaterial $rawMaterial): RedirectResponse
{
$validated = $request->validated();
DB::transaction(function () use ($validated, $rawMaterial): void {
$rawMaterial->name = $validated['name'];
$rawMaterial->unit = $validated['unit'];
$rawMaterial->save();
$submittedPriceIds = collect($validated['prices'])
->pluck('id')
->filter()
->map(fn ($id) => (int) $id)
->all();
$rawMaterial->prices()
->whereNotIn('id', $submittedPriceIds)
->get()
->each(fn (RawMaterialPrice $price) => $price->delete());
foreach ($validated['prices'] as $priceData) {
if (! empty($priceData['id'])) {
$price = $rawMaterial->prices()->findOrFail($priceData['id']);
$price->variant = $priceData['variant'];
$price->price = $priceData['price'];
$price->stock = $priceData['stock'];
$price->save();
continue;
}
$this->createPrice($rawMaterial, $priceData);
}
});
Inertia::flash('success', 'Bahan baku berhasil diperbarui.');
return redirect()->route('admin.master.raw-materials.index');
}
public function toggleStatus(Request $request, RawMaterial $rawMaterial): RedirectResponse
{
$validated = $request->validate([
'is_active' => ['required', 'boolean'],
]);
$rawMaterial->is_active = $validated['is_active'];
$rawMaterial->save();
Inertia::flash('success', 'Status bahan baku berhasil diperbarui.');
return back();
}
public function destroy(RawMaterial $rawMaterial): RedirectResponse
{
DB::transaction(function () use ($rawMaterial): void {
$rawMaterial->prices()->delete();
$rawMaterial->delete();
});
Inertia::flash('success', 'Bahan baku berhasil dihapus.');
return redirect()->route('admin.master.raw-materials.index');
}
private function applySorting(Builder $query, string $sort, string $direction): void
{
if (in_array($sort, ['name', 'unit', 'is_active'], true)) {
$query->orderBy($sort, $direction);
return;
}
$query->latest();
}
/**
* @param array<string, mixed> $priceData
*/
private function createPrice(RawMaterial $rawMaterial, array $priceData): RawMaterialPrice
{
return $rawMaterial->prices()->create([
'variant' => $priceData['variant'],
'price' => $priceData['price'],
'stock' => $priceData['stock'],
]);
}
/**
* @return list<array<string, mixed>>
*/
private function flattenRawMaterialForTable(RawMaterial $rawMaterial, int $materialIndex, int $firstItem): array
{
$prices = $rawMaterial->prices->isNotEmpty()
? $rawMaterial->prices
: collect([null]);
$variantCount = $prices->count();
return $prices
->values()
->map(function ($price, int $variantIndex) use ($rawMaterial, $materialIndex, $firstItem, $variantCount) {
return [
'row_id' => $price
? "{$rawMaterial->id}-{$price->id}"
: "{$rawMaterial->id}-empty",
'raw_material_id' => $rawMaterial->id,
'raw_material_name' => $rawMaterial->name,
'unit' => $rawMaterial->unit->value,
'unit_label' => $rawMaterial->unit->label(),
'unit_abbreviation' => $rawMaterial->unit->abbreviation(),
'is_active' => $rawMaterial->is_active,
'is_first_variant' => $variantIndex === 0,
'variant_count' => $variantCount,
'raw_material_row_number' => $firstItem + $materialIndex,
'price_id' => $price?->id,
'variant' => $price?->variant,
'stock' => $price?->stock,
'stock_formatted' => $price ? $this->formatStock($price->stock, $rawMaterial->unit) : null,
'price' => $price?->price,
'price_formatted' => $price ? $this->formatPrice($price->price) : null,
];
})
->all();
}
/**
* @return array<string, mixed>
*/
private function transformRawMaterialForForm(RawMaterial $rawMaterial): array
{
return [
'id' => $rawMaterial->id,
'name' => $rawMaterial->name,
'unit' => $rawMaterial->unit->value,
'prices' => $rawMaterial->prices
->map(fn (RawMaterialPrice $price) => [
'id' => $price->id,
'variant' => $price->variant,
'price' => (string) (int) $price->price,
'stock' => $this->formatStockInput($price->stock),
])
->values()
->all(),
];
}
private function formatPrice(float|string $price): string
{
return 'Rp '.number_format((float) $price, 0, ',', '.');
}
private function formatStock(float|string $stock, RawMaterialUnit $unit): string
{
$formatted = rtrim(rtrim(number_format((float) $stock, 4, ',', '.'), '0'), ',');
return "{$formatted} {$unit->abbreviation()}";
}
private function formatStockInput(float|string $stock): string
{
return rtrim(rtrim(number_format((float) $stock, 4, '.', ''), '0'), '.');
}
}

View File

@ -1,6 +1,6 @@
<?php
namespace App\Http\Requests\Admin;
namespace App\Http\Requests\Admin\Master;
use App\Enums\Permission;
use Illuminate\Foundation\Http\FormRequest;

View File

@ -1,6 +1,6 @@
<?php
namespace App\Http\Requests\Admin;
namespace App\Http\Requests\Admin\Master;
use App\Enums\Permission;
use App\Enums\PriceType;

View File

@ -1,12 +1,11 @@
<?php
namespace App\Http\Requests\Admin;
namespace App\Http\Requests\Admin\Master;
use App\Enums\Permission;
use App\Enums\RawMaterialUnit;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class RawMaterialRequest extends FormRequest
{

View File

@ -14,7 +14,7 @@
use Illuminate\Database\Eloquent\SoftDeletes;
#[Guarded(['id'])]
#[Appends(['base_salary_formatted', 'join_date_formatted', 'resign_date_formatted'])]
#[Appends(['base_salary_formatted', 'join_date_formatted', 'resign_date_formatted', 'join_date_input', 'employment_status_label'])]
class Employee extends Model
{
use SoftDeletes;
@ -73,6 +73,20 @@ public function resignDateFormatted(): Attribute
);
}
public function joinDateInput(): Attribute
{
return Attribute::make(
get: fn () => $this->join_date?->format('Y-m-d'),
);
}
public function employmentStatusLabel(): Attribute
{
return Attribute::make(
get: fn () => $this->employment_status?->label(),
);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);

View File

@ -3,11 +3,14 @@
namespace App\Models;
use App\Enums\PriceType;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Guarded(['id'])]
#[Appends(['price_formatted', 'price_input', 'type_label'])]
class ProductPrice extends Model
{
protected function casts(): array
@ -18,6 +21,27 @@ protected function casts(): array
];
}
public function priceFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format((float) $this->price, 0, ',', '.'),
);
}
public function priceInput(): Attribute
{
return Attribute::make(
get: fn () => (string) (int) $this->price,
);
}
public function typeLabel(): Attribute
{
return Attribute::make(
get: fn () => $this->type->label(),
);
}
public function variant(): BelongsTo
{
return $this->belongsTo(ProductVariant::class, 'variant_id');

View File

@ -3,14 +3,17 @@
namespace App\Models;
use App\Enums\RawMaterialUnit;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Guarded(['id'])]
#[Appends(['unit_label', 'unit_abbreviation'])]
class RawMaterial extends Model
{
use SoftDeletes;
@ -35,6 +38,20 @@ public function inactive(Builder $query): void
$query->where('is_active', false);
}
public function unitLabel(): Attribute
{
return Attribute::make(
get: fn () => $this->unit->label(),
);
}
public function unitAbbreviation(): Attribute
{
return Attribute::make(
get: fn () => $this->unit->abbreviation(),
);
}
public function prices(): HasMany
{
return $this->hasMany(RawMaterialPrice::class);

View File

@ -2,12 +2,15 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Guarded(['id'])]
#[Appends(['price_formatted', 'stock_formatted', 'price_input', 'stock_input'])]
class RawMaterialPrice extends Model
{
use SoftDeletes;
@ -20,6 +23,38 @@ protected function casts(): array
];
}
public function priceFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format((float) $this->price, 0, ',', '.'),
);
}
public function stockFormatted(): Attribute
{
return Attribute::make(
get: function () {
$formatted = rtrim(rtrim(number_format((float) $this->stock, 4, ',', '.'), '0'), ',');
return "{$formatted} {$this->rawMaterial->unit->abbreviation()}";
},
);
}
public function priceInput(): Attribute
{
return Attribute::make(
get: fn () => (string) (int) $this->price,
);
}
public function stockInput(): Attribute
{
return Attribute::make(
get: fn () => rtrim(rtrim(number_format((float) $this->stock, 4, '.', ''), '0'), '.'),
);
}
public function rawMaterial(): BelongsTo
{
return $this->belongsTo(RawMaterial::class);

View File

@ -2,10 +2,13 @@
namespace App\Models;
use App\Enums\Role;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
@ -15,6 +18,7 @@
#[Guarded(['id'])]
#[Hidden(['password', 'remember_token'])]
#[Appends(['role_label'])]
class User extends Authenticatable
{
use HasFactory, HasRoles, Notifiable, SoftDeletes;
@ -40,6 +44,17 @@ public function inactive(Builder $query): void
$query->where('is_active', false);
}
public function roleLabel(): Attribute
{
return Attribute::make(
get: function () {
$role = $this->roles->first();
return $role ? Role::from($role->name)->label() : null;
},
);
}
public function profile(): HasOne
{
return $this->hasOne(UserProfile::class);

View File

@ -12,7 +12,7 @@
use Illuminate\Database\Eloquent\SoftDeletes;
#[Guarded(['id'])]
#[Appends(['birth_date_formatted'])]
#[Appends(['birth_date_formatted', 'birth_date_input', 'gender_label'])]
class UserProfile extends Model
{
use SoftDeletes;
@ -32,6 +32,20 @@ protected function birthDateFormatted(): Attribute
);
}
protected function birthDateInput(): Attribute
{
return Attribute::make(
get: fn () => $this->birth_date?->format('Y-m-d'),
);
}
protected function genderLabel(): Attribute
{
return Attribute::make(
get: fn () => $this->gender?->label(),
);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);

View File

@ -0,0 +1,172 @@
<?php
namespace App\Services\Hr;
use App\Models\Employee;
use App\Models\User;
use App\Models\UserProfile;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
class EmployeeService
{
/**
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
*/
public function paginateForIndex(array $tableQuery, string $employmentStatus): LengthAwarePaginator
{
$query = User::query()
->with(['profile', 'employee', 'roles'])
->whereHas('employee')
->when($tableQuery['search'] !== '', function ($query) use ($tableQuery): void {
$search = $tableQuery['search'];
$query->where(function ($query) use ($search): void {
$query->where('email', 'like', "%{$search}%")
->orWhere('username', 'like', "%{$search}%")
->orWhereHas('profile', function ($query) use ($search): void {
$query->where('full_name', 'like', "%{$search}%")
->orWhere('phone_number', 'like', "%{$search}%");
});
});
})
->when(
$employmentStatus !== '',
fn ($query) => $query->whereHas('employee', fn ($query) => $query->where('employment_status', $employmentStatus))
);
$this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']);
return $query
->paginate(10)
->withQueryString();
}
/**
* @param array<string, mixed> $validated
*/
public function create(array $validated): void
{
DB::transaction(function () use ($validated): void {
$user = User::create([
'email' => $validated['email'],
'username' => $validated['username'],
'password' => Hash::make(config('auth.password_default')),
]);
UserProfile::create([
'user_id' => $user->id,
'full_name' => $validated['full_name'],
'phone_number' => $validated['phone_number'],
'gender' => $validated['gender'],
'birth_date' => $validated['birth_date'],
'address' => $validated['address'],
]);
Employee::create([
'user_id' => $user->id,
'join_date' => $validated['join_date'],
'employment_status' => $validated['employment_status'],
'base_salary' => $validated['base_salary'],
]);
$user->syncRoles([$validated['role']]);
});
}
/**
* @param array<string, mixed> $validated
*/
public function update(User $user, array $validated): void
{
$employee = $user->employee;
DB::transaction(function () use ($validated, $user, $employee): void {
$user->email = $validated['email'];
$user->username = $validated['username'];
$user->save();
$profile = $user->profile;
$profile->full_name = $validated['full_name'];
$profile->phone_number = $validated['phone_number'];
$profile->gender = $validated['gender'];
$profile->birth_date = $validated['birth_date'];
$profile->address = $validated['address'];
$profile->save();
$employee->join_date = $validated['join_date'];
$employee->employment_status = $validated['employment_status'];
$employee->base_salary = $validated['base_salary'];
$employee->save();
$user->syncRoles([$validated['role']]);
});
}
public function toggleStatus(User $user, bool $isActive): void
{
$user->is_active = $isActive;
$user->save();
if (! $isActive) {
DB::table('sessions')->where('user_id', $user->id)->delete();
}
}
public function resetPassword(User $user): void
{
$user->password = config('auth.password_default');
$user->save();
DB::table('sessions')->where('user_id', $user->id)->delete();
}
public function delete(User $user): void
{
DB::transaction(function () use ($user): void {
$user->employee?->delete();
$user->profile?->delete();
$user->delete();
});
}
private function applySorting(Builder $query, string $sort, string $direction): void
{
$employeeSorts = [
'join_date',
'base_salary',
'employment_status',
];
if (in_array($sort, $employeeSorts, true)) {
$query->orderBy(
Employee::select($sort)
->whereColumn('employees.user_id', 'users.id')
->limit(1),
$direction
);
return;
}
if ($sort === 'full_name') {
$query->orderBy(
UserProfile::select('full_name')
->whereColumn('user_profiles.user_id', 'users.id')
->limit(1),
$direction
);
return;
}
if ($sort === 'email') {
$query->orderBy('email', $direction);
return;
}
$query->latest();
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Services\Master;
use App\Models\Category;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
class CategoryService
{
/**
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
*/
public function paginateForIndex(array $tableQuery): LengthAwarePaginator
{
$query = Category::query()
->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
$search = $tableQuery['search'];
$query->where(function (Builder $query) use ($search): void {
$query->where('name', 'like', "%{$search}%");
});
});
$this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']);
return $query
->paginate(10)
->withQueryString();
}
/**
* @param array<string, mixed> $validated
*/
public function create(array $validated): void
{
Category::create($validated);
}
/**
* @param array<string, mixed> $validated
*/
public function update(Category $category, array $validated): void
{
$category->name = $validated['name'];
$category->save();
}
public function delete(Category $category): void
{
$category->delete();
}
private function applySorting(Builder $query, string $sort, string $direction): void
{
if (in_array($sort, ['name', 'slug'], true)) {
$query->orderBy($sort, $direction);
return;
}
$query->latest();
}
}

View File

@ -0,0 +1,181 @@
<?php
namespace App\Services\Master;
use App\Models\Category;
use App\Models\Product;
use App\Models\ProductPrice;
use App\Models\ProductVariant;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
class ProductService
{
/**
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
*/
public function paginateForIndex(array $tableQuery, string $isActive): LengthAwarePaginator
{
$query = Product::query()
->with([
'categories',
'variants' => fn ($query) => $query
->with(['prices' => fn ($query) => $query->orderBy('type')])
->orderBy('created_at'),
])
->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
$search = $tableQuery['search'];
$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, $tableQuery['sort'], $tableQuery['direction']);
return $query
->paginate(10)
->withQueryString();
}
/**
* @return list<array{value: int, label: string}>
*/
public 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> $validated
*/
public function create(array $validated): void
{
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);
}
});
}
/**
* @param array<string, mixed> $validated
*/
public function update(Product $product, array $validated): void
{
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);
}
});
}
public function toggleStatus(Product $product, bool $isActive): void
{
$product->is_active = $isActive;
$product->save();
}
public function delete(Product $product): void
{
DB::transaction(function () use ($product): void {
$product->variants()->delete();
$product->categories()->detach();
$product->delete();
});
}
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();
}
/**
* @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'],
],
);
}
}
}

View File

@ -0,0 +1,133 @@
<?php
namespace App\Services\Master;
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
class RawMaterialService
{
/**
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
*/
public function paginateForIndex(array $tableQuery, string $isActive): LengthAwarePaginator
{
$query = RawMaterial::query()
->with([
'prices' => fn ($query) => $query
->orderBy('created_at')
->with('rawMaterial:id,unit'),
])
->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
$search = $tableQuery['search'];
$query->where(function (Builder $query) use ($search): void {
$query->where('name', 'like', "%{$search}%")
->orWhereHas('prices', fn (Builder $query) => $query->where('variant', 'like', "%{$search}%"));
});
})
->when(
$isActive !== '',
fn (Builder $query) => $query->where('is_active', $isActive === '1')
);
$this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']);
return $query
->paginate(10)
->withQueryString();
}
/**
* @param array<string, mixed> $validated
*/
public function create(array $validated): void
{
DB::transaction(function () use ($validated): void {
$rawMaterial = RawMaterial::create([
'name' => $validated['name'],
'unit' => $validated['unit'],
]);
foreach ($validated['prices'] as $priceData) {
$this->createPrice($rawMaterial, $priceData);
}
});
}
/**
* @param array<string, mixed> $validated
*/
public function update(RawMaterial $rawMaterial, array $validated): void
{
DB::transaction(function () use ($validated, $rawMaterial): void {
$rawMaterial->name = $validated['name'];
$rawMaterial->unit = $validated['unit'];
$rawMaterial->save();
$submittedPriceIds = collect($validated['prices'])
->pluck('id')
->filter()
->map(fn ($id) => (int) $id)
->all();
$rawMaterial->prices()
->whereNotIn('id', $submittedPriceIds)
->get()
->each(fn (RawMaterialPrice $price) => $price->delete());
foreach ($validated['prices'] as $priceData) {
if (! empty($priceData['id'])) {
$price = $rawMaterial->prices()->findOrFail($priceData['id']);
$price->variant = $priceData['variant'];
$price->price = $priceData['price'];
$price->stock = $priceData['stock'];
$price->save();
continue;
}
$this->createPrice($rawMaterial, $priceData);
}
});
}
public function toggleStatus(RawMaterial $rawMaterial, bool $isActive): void
{
$rawMaterial->is_active = $isActive;
$rawMaterial->save();
}
public function delete(RawMaterial $rawMaterial): void
{
DB::transaction(function () use ($rawMaterial): void {
$rawMaterial->prices()->delete();
$rawMaterial->delete();
});
}
private function applySorting(Builder $query, string $sort, string $direction): void
{
if (in_array($sort, ['name', 'unit', 'is_active'], true)) {
$query->orderBy($sort, $direction);
return;
}
$query->latest();
}
/**
* @param array<string, mixed> $priceData
*/
private function createPrice(RawMaterial $rawMaterial, array $priceData): RawMaterialPrice
{
return $rawMaterial->prices()->create([
'variant' => $priceData['variant'],
'price' => $priceData['price'],
'stock' => $priceData['stock'],
]);
}
}

View File

@ -1,34 +1,17 @@
import type { ColumnDef } from '@tanstack/vue-table';
import { h } from 'vue';
import { DataTableColumnHeader } from '@/components/data-table';
import DataTableActions from '@/components/hr/employees/data-table-actions.vue';
import EmployeeStatusToggle from '@/components/hr/employees/employee-status-toggle.vue';
import DataTableActions from '@/components/admin/hr/employees/data-table-actions.vue';
import EmployeeStatusToggle from '@/components/admin/hr/employees/employee-status-toggle.vue';
import { Badge } from '@/components/ui/badge';
import type { EmployeeListItem } from '@/types/employee';
function formatDate(value: string | null): string {
if (!value) {
return '-';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
}
export const columns: ColumnDef<EmployeeListItem>[] = [
{
accessorKey: 'full_name',
accessorKey: 'profile.full_name',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Nama', column: 'full_name' }),
cell: ({ row }) => row.original.profile?.full_name ?? '-',
},
{
id: 'account',
@ -49,62 +32,63 @@ export const columns: ColumnDef<EmployeeListItem>[] = [
: '-',
},
{
accessorKey: 'phone_number',
accessorKey: 'profile.phone_number',
enableSorting: false,
header: () => h(DataTableColumnHeader, { title: 'Telepon', column: 'phone_number' }),
cell: ({ row }) => row.original.phone_number ?? '-',
cell: ({ row }) => row.original.profile?.phone_number ?? '-',
},
{
accessorKey: 'gender_label',
accessorKey: 'profile.gender_label',
enableSorting: false,
header: () => h(DataTableColumnHeader, { title: 'Jenis Kelamin', column: 'gender' }),
cell: ({ row }) => row.original.gender_label ?? '-',
cell: ({ row }) => row.original.profile?.gender_label ?? '-',
},
{
accessorKey: 'birth_date',
accessorKey: 'profile.birth_date_formatted',
enableSorting: false,
header: () => h(DataTableColumnHeader, { title: 'Tanggal Lahir', column: 'birth_date' }),
cell: ({ row }) => formatDate(row.original.birth_date),
cell: ({ row }) => row.original.profile?.birth_date_formatted ?? '-',
},
{
accessorKey: 'address',
accessorKey: 'profile.address',
enableSorting: false,
header: () => h(DataTableColumnHeader, { title: 'Alamat', column: 'address' }),
cell: ({ row }) => h(
'span',
{ class: 'block max-w-[200px] truncate', title: row.original.address ?? undefined },
row.original.address ?? '-',
{ class: 'block max-w-[200px] truncate', title: row.original.profile?.address ?? undefined },
row.original.profile?.address ?? '-',
),
},
{
id: 'employment_dates',
accessorKey: 'join_date',
accessorKey: 'employee.join_date_formatted',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Masa Kepegawaian', column: 'join_date' }),
cell: ({ row }) => h('div', { class: 'space-y-0.5 text-sm' }, [
h('div', {}, [
h('span', { class: 'text-muted-foreground' }, 'Bergabung: '),
formatDate(row.original.join_date),
row.original.employee?.join_date_formatted ?? '-',
]),
h('div', {}, [
h('span', { class: 'text-muted-foreground' }, 'Resign: '),
row.original.resign_date ?? '-',
row.original.employee?.resign_date_formatted ?? '-',
]),
]),
},
{
accessorKey: 'employment_status_label',
accessorKey: 'employee.employment_status_label',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Status Kepegawaian', column: 'employment_status' }),
cell: ({ row }) => row.original.employee?.employment_status_label ?? '-',
},
{
accessorKey: 'base_salary_formatted',
accessorKey: 'employee.base_salary_formatted',
enableSorting: true,
header: () => h(DataTableColumnHeader, {
title: 'Gaji Pokok',
column: 'base_salary',
}),
cell: ({ row }) => row.original.base_salary_formatted,
cell: ({ row }) => row.original.employee?.base_salary_formatted ?? '-',
},
{
id: 'status_toggle',

View File

@ -94,7 +94,7 @@ function resetPassword() {
v-if="can('employees.reset-password')"
v-model:open="resetPasswordConfirmOpen"
title="Reset kata sandi pegawai?"
:description="`Kata sandi ${employee.full_name ?? 'pegawai'} akan direset ke kata sandi default sistem. Pengguna akan otomatis logout dari semua sesi aktif.`"
:description="`Kata sandi ${employee.profile?.full_name ?? 'pegawai'} akan direset ke kata sandi default sistem. Pengguna akan otomatis logout dari semua sesi aktif.`"
confirm-label="Reset Kata Sandi" cancel-label="Batal" :loading="resetPasswordProcessing"
@confirm="resetPassword" />
@ -102,6 +102,6 @@ function resetPassword() {
v-if="can('employees.delete')"
v-model:open="deleteConfirmOpen"
title="Hapus pegawai?"
:description="`Data pegawai ${employee.full_name ?? ''} akan dihapus secara permanen beserta akun pengguna terkait. Tindakan ini tidak dapat dibatalkan.`"
:description="`Data pegawai ${employee.profile?.full_name ?? ''} akan dihapus secara permanen beserta akun pengguna terkait. Tindakan ini tidak dapat dibatalkan.`"
confirm-label="Hapus" cancel-label="Batal" destructive :loading="deleteProcessing" @confirm="destroyEmployee" />
</template>

View File

@ -1,6 +1,6 @@
import type { ColumnDef } from '@tanstack/vue-table';
import { h } from 'vue';
import DataTableActions from '@/components/categories/data-table-actions.vue';
import DataTableActions from '@/components/admin/master/categories/data-table-actions.vue';
import { DataTableColumnHeader } from '@/components/data-table';
import type { CategoryListItem } from '@/types/category';

View File

@ -1,8 +1,8 @@
<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 DataTableActions from '@/components/admin/master/products/data-table-actions.vue';
import ProductStatusToggle from '@/components/admin/master/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';
@ -20,7 +20,6 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { groupProductRows } from '@/lib/products';
import type {
DataTableFilterDef,
DataTablePagination,
@ -29,14 +28,14 @@ import type {
import {
PRICE_TYPES,
PRICE_TYPE_LABELS,
type ProductTableRow,
type ProductListItem,
} from '@/types/product';
const props = defineProps<{
rows: ProductTableRow[];
products: ProductListItem[];
firstItem?: number;
pagination?: DataTablePagination;
paginationLinks?: DataTablePaginationLink[];
paginationDisplayedCount?: number;
filterDefs?: DataTableFilterDef[];
filterValues?: Record<string, string>;
}>();
@ -48,9 +47,7 @@ const emit = defineEmits<{
'filters-reset': [];
}>();
const productGroups = computed(() => groupProductRows(props.rows));
const showingCount = computed(() => props.paginationDisplayedCount ?? productGroups.value.length);
const showingCount = computed(() => props.products.length);
const paginationSummary = computed(() => {
if (!props.pagination) {
@ -66,50 +63,34 @@ const paginationSummary = computed(() => {
return `Menampilkan ${showingCount.value} produk dari ${total}`;
});
function formatStock(value: number | null): string {
if (value === null) {
return '-';
}
function formatStock(value: number): string {
return value.toLocaleString('id-ID');
}
function groupHeaderRow(group: ReturnType<typeof groupProductRows>[number]): ProductTableRow {
return group.variants[0];
function rowNumber(index: number): number {
return (props.firstItem ?? 1) + index;
}
</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')"
/>
<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 v-if="products.length" class="space-y-4">
<div v-for="(product, index) in products" :key="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 }}
{{ rowNumber(index) }}
</span>
<div class="min-w-0 space-y-2">
<h3 class="font-medium leading-tight">
{{ group.product_name }}
{{ 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"
>
<div v-if="product.categories.length" class="flex flex-wrap gap-1">
<Badge v-for="category in product.categories" :key="category.id" variant="outline">
{{ category.name }}
</Badge>
</div>
@ -120,8 +101,8 @@ function groupHeaderRow(group: ReturnType<typeof groupProductRows>[number]): Pro
</div>
<div class="flex shrink-0 items-center justify-end gap-2 sm:pt-0.5">
<ProductStatusToggle :row="groupHeaderRow(group)" />
<DataTableActions :row="groupHeaderRow(group)" />
<ProductStatusToggle :product="product" />
<DataTableActions :product="product" />
</div>
</div>
@ -134,29 +115,23 @@ function groupHeaderRow(group: ReturnType<typeof groupProductRows>[number]): Pro
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="variant in group.variants"
:key="variant.row_id"
>
<TableRow v-if="!product.variants.length" :key="`${product.id}-empty`">
<TableCell colspan="3" class="text-muted-foreground">
Belum ada varian
</TableCell>
</TableRow>
<TableRow v-for="variant in product.variants" :key="variant.id">
<TableCell class="font-medium">
{{ variant.variant_name ?? '-' }}
{{ 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"
>
<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>
@ -176,10 +151,7 @@ function groupHeaderRow(group: ReturnType<typeof groupProductRows>[number]): Pro
</div>
</div>
<div
v-else
class="rounded-md border px-6 py-10"
>
<div v-else class="rounded-md border px-6 py-10">
<Empty>
<EmptyHeader>
<EmptyTitle>Data tidak ditemukan</EmptyTitle>
@ -190,26 +162,15 @@ function groupHeaderRow(group: ReturnType<typeof groupProductRows>[number]): Pro
</Empty>
</div>
<div
v-if="pagination"
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<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
>
<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>

View File

@ -7,10 +7,10 @@ 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';
import type { ProductListItem } from '@/types/product';
const props = defineProps<{
row: ProductTableRow;
product: ProductListItem;
}>();
const { can } = useCan();
@ -21,7 +21,7 @@ const deleteProcessing = ref(false);
function destroyProduct() {
deleteProcessing.value = true;
router.delete(`/admin/master/products/${props.row.product_id}`, {
router.delete(`/admin/master/products/${props.product.id}`, {
onSuccess: () => {
deleteConfirmOpen.value = false;
},
@ -40,7 +40,7 @@ function destroyProduct() {
<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`">
<Link :href="`/admin/master/products/${product.id}/edit`">
<Pencil class="size-4" />
<span class="sr-only">Ubah</span>
</Link>
@ -69,7 +69,7 @@ function destroyProduct() {
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.`"
:description="`Produk ${product.name} akan dihapus beserta seluruh variannya. Tindakan ini tidak dapat dibatalkan.`"
confirm-label="Hapus"
cancel-label="Batal"
destructive

View File

@ -5,19 +5,19 @@ 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';
import type { ProductListItem } from '@/types/product';
const props = defineProps<{
row: ProductTableRow;
product: ProductListItem;
}>();
const { can } = useCan();
const isActive = ref(props.row.is_active);
const isActive = ref(props.product.is_active);
const processing = ref(false);
watch(
() => props.row.is_active,
() => props.product.is_active,
(value) => {
isActive.value = value;
},
@ -32,7 +32,7 @@ function toggleStatus(checked: boolean) {
isActive.value = checked;
processing.value = true;
router.patch(`/admin/master/products/${props.row.product_id}/toggle-status`, {
router.patch(`/admin/master/products/${props.product.id}/toggle-status`, {
is_active: checked,
}, {
preserveScroll: true,

View File

@ -1,8 +1,8 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { computed } from 'vue';
import DataTableActions from '@/components/raw-materials/data-table-actions.vue';
import RawMaterialStatusToggle from '@/components/raw-materials/raw-material-status-toggle.vue';
import DataTableActions from '@/components/admin/master/raw-materials/data-table-actions.vue';
import RawMaterialStatusToggle from '@/components/admin/master/raw-materials/raw-material-status-toggle.vue';
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@ -20,19 +20,18 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { groupRawMaterialRows } from '@/lib/raw-materials';
import type {
DataTableFilterDef,
DataTablePagination,
DataTablePaginationLink,
} from '@/types/data-table';
import type { RawMaterialTableRow } from '@/types/raw-material';
import type { RawMaterialListItem } from '@/types/raw-material';
const props = defineProps<{
rows: RawMaterialTableRow[];
materials: RawMaterialListItem[];
firstItem?: number;
pagination?: DataTablePagination;
paginationLinks?: DataTablePaginationLink[];
paginationDisplayedCount?: number;
filterDefs?: DataTableFilterDef[];
filterValues?: Record<string, string>;
}>();
@ -44,9 +43,7 @@ const emit = defineEmits<{
'filters-reset': [];
}>();
const materialGroups = computed(() => groupRawMaterialRows(props.rows));
const showingCount = computed(() => props.paginationDisplayedCount ?? materialGroups.value.length);
const showingCount = computed(() => props.materials.length);
const paginationSummary = computed(() => {
if (!props.pagination) {
@ -62,47 +59,39 @@ const paginationSummary = computed(() => {
return `Menampilkan ${showingCount.value} bahan baku dari ${total}`;
});
function groupHeaderRow(group: ReturnType<typeof groupRawMaterialRows>[number]): RawMaterialTableRow {
return group.variants[0];
function rowNumber(index: number): number {
return (props.firstItem ?? 1) + index;
}
</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')"
/>
<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="materialGroups.length" class="space-y-4">
<div
v-for="group in materialGroups"
:key="group.raw_material_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 v-if="materials.length" class="space-y-4">
<div v-for="(material, index) in materials" :key="material.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.raw_material_row_number }}
{{ rowNumber(index) }}
</span>
<div class="min-w-0 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<h3 class="font-medium leading-tight">
{{ group.raw_material_name }}
{{ material.name }}
</h3>
<Badge variant="secondary">
{{ group.unit_label }}
{{ material.unit_label }}
</Badge>
</div>
</div>
</div>
<div class="flex shrink-0 items-center justify-end gap-2 sm:pt-0.5">
<RawMaterialStatusToggle :row="groupHeaderRow(group)" />
<DataTableActions :row="groupHeaderRow(group)" />
<RawMaterialStatusToggle :material="material" />
<DataTableActions :material="material" />
</div>
</div>
@ -115,18 +104,20 @@ function groupHeaderRow(group: ReturnType<typeof groupRawMaterialRows>[number]):
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="variant in group.variants"
:key="variant.row_id"
>
<TableRow v-if="!material.prices.length" :key="`${material.id}-empty`">
<TableCell colspan="3" class="text-muted-foreground">
Belum ada varian
</TableCell>
</TableRow>
<TableRow v-for="price in material.prices" :key="price.id">
<TableCell class="font-medium">
{{ variant.variant ?? '-' }}
{{ price.variant }}
</TableCell>
<TableCell class="tabular-nums">
{{ variant.stock_formatted ?? '-' }}
{{ price.stock_formatted }}
</TableCell>
<TableCell class="tabular-nums">
{{ variant.price_formatted ?? '-' }}
{{ price.price_formatted }}
</TableCell>
</TableRow>
</TableBody>
@ -134,10 +125,7 @@ function groupHeaderRow(group: ReturnType<typeof groupRawMaterialRows>[number]):
</div>
</div>
<div
v-else
class="rounded-md border px-6 py-10"
>
<div v-else class="rounded-md border px-6 py-10">
<Empty>
<EmptyHeader>
<EmptyTitle>Data tidak ditemukan</EmptyTitle>
@ -148,26 +136,15 @@ function groupHeaderRow(group: ReturnType<typeof groupRawMaterialRows>[number]):
</Empty>
</div>
<div
v-if="pagination"
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<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
>
<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>

View File

@ -7,10 +7,10 @@ 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 { RawMaterialTableRow } from '@/types/raw-material';
import type { RawMaterialListItem } from '@/types/raw-material';
const props = defineProps<{
row: RawMaterialTableRow;
material: RawMaterialListItem;
}>();
const { can } = useCan();
@ -21,7 +21,7 @@ const deleteProcessing = ref(false);
function destroyRawMaterial() {
deleteProcessing.value = true;
router.delete(`/admin/master/raw-materials/${props.row.raw_material_id}`, {
router.delete(`/admin/master/raw-materials/${props.material.id}`, {
onSuccess: () => {
deleteConfirmOpen.value = false;
},
@ -40,7 +40,7 @@ function destroyRawMaterial() {
<Tooltip v-if="can('raw-materials.update')">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8" as-child>
<Link :href="`/admin/master/raw-materials/${row.raw_material_id}/edit`">
<Link :href="`/admin/master/raw-materials/${material.id}/edit`">
<Pencil class="size-4" />
<span class="sr-only">Ubah</span>
</Link>
@ -69,7 +69,7 @@ function destroyRawMaterial() {
v-if="can('raw-materials.delete')"
v-model:open="deleteConfirmOpen"
title="Hapus bahan baku?"
:description="`Bahan baku ${row.raw_material_name} akan dihapus beserta seluruh variannya. Tindakan ini tidak dapat dibatalkan.`"
:description="`Bahan baku ${material.name} akan dihapus beserta seluruh variannya. Tindakan ini tidak dapat dibatalkan.`"
confirm-label="Hapus"
cancel-label="Batal"
destructive

View File

@ -5,19 +5,19 @@ import { toast } from 'vue-sonner';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { useCan } from '@/composables/useCan';
import type { RawMaterialTableRow } from '@/types/raw-material';
import type { RawMaterialListItem } from '@/types/raw-material';
const props = defineProps<{
row: RawMaterialTableRow;
material: RawMaterialListItem;
}>();
const { can } = useCan();
const isActive = ref(props.row.is_active);
const isActive = ref(props.material.is_active);
const processing = ref(false);
watch(
() => props.row.is_active,
() => props.material.is_active,
(value) => {
isActive.value = value;
},
@ -32,7 +32,7 @@ function toggleStatus(checked: boolean) {
isActive.value = checked;
processing.value = true;
router.patch(`/admin/master/raw-materials/${props.row.raw_material_id}/toggle-status`, {
router.patch(`/admin/master/raw-materials/${props.material.id}/toggle-status`, {
is_active: checked,
}, {
preserveScroll: true,

View File

@ -1,128 +0,0 @@
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

@ -1,28 +0,0 @@
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

@ -1,30 +0,0 @@
import type { RawMaterialTableGroup, RawMaterialTableRow } from '@/types/raw-material';
export function groupRawMaterialRows(rows: RawMaterialTableRow[]): RawMaterialTableGroup[] {
const groups: RawMaterialTableGroup[] = [];
const indexByMaterialId = new Map<number, number>();
for (const row of rows) {
const existingIndex = indexByMaterialId.get(row.raw_material_id);
if (existingIndex === undefined) {
indexByMaterialId.set(row.raw_material_id, groups.length);
groups.push({
raw_material_id: row.raw_material_id,
raw_material_row_number: row.raw_material_row_number,
raw_material_name: row.raw_material_name,
unit: row.unit,
unit_label: row.unit_label,
unit_abbreviation: row.unit_abbreviation,
is_active: row.is_active,
variants: [row],
});
continue;
}
groups[existingIndex].variants.push(row);
}
return groups;
}

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { ArrowLeft } from '@lucide/vue';
import EmployeeForm from '@/components/hr/EmployeeForm.vue';
import EmployeeForm from '@/components/admin/hr/EmployeeForm.vue';
import { Button } from '@/components/ui/button';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { EnumOption } from '@/types/employee';
@ -33,13 +33,7 @@ defineProps<{
</Button>
</div>
<EmployeeForm
submit-url="/admin/hr/employees"
method="post"
submit-label="Simpan"
:genders="genders"
:employment-statuses="employmentStatuses"
:roles="roles"
/>
<EmployeeForm submit-url="/admin/hr/employees" method="post" submit-label="Simpan" :genders="genders"
:employment-statuses="employmentStatuses" :roles="roles" />
</AdminLayout>
</template>

View File

@ -2,13 +2,13 @@
import { Head, Link } from '@inertiajs/vue3';
import { ArrowLeft } from '@lucide/vue';
import { computed } from 'vue';
import EmployeeForm from '@/components/hr/EmployeeForm.vue';
import EmployeeForm from '@/components/admin/hr/EmployeeForm.vue';
import { Button } from '@/components/ui/button';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { EmployeeEditItem, EnumOption } from '@/types/employee';
import type { EmployeeListItem, EnumOption } from '@/types/employee';
const props = defineProps<{
employee: EmployeeEditItem;
employee: EmployeeListItem;
genders: EnumOption[];
employmentStatuses: EnumOption[];
roles: EnumOption[];
@ -17,15 +17,15 @@ const props = defineProps<{
const initialData = computed(() => ({
email: props.employee.email ?? '',
username: props.employee.username ?? '',
full_name: props.employee.full_name ?? '',
phone_number: props.employee.phone_number ?? '',
gender: props.employee.gender ?? '',
birth_date: props.employee.birth_date ?? '',
address: props.employee.address ?? '',
join_date: props.employee.join_date ?? '',
employment_status: props.employee.employment_status ?? 'full_time',
base_salary: props.employee.base_salary != null ? String(props.employee.base_salary) : '',
role: props.employee.role ?? '',
full_name: props.employee.profile?.full_name ?? '',
phone_number: props.employee.profile?.phone_number ?? '',
gender: props.employee.profile?.gender ?? '',
birth_date: props.employee.profile?.birth_date_input ?? '',
address: props.employee.profile?.address ?? '',
join_date: props.employee.employee?.join_date_input ?? '',
employment_status: props.employee.employee?.employment_status ?? 'full_time',
base_salary: props.employee.employee?.base_salary != null ? String(props.employee.employee.base_salary) : '',
role: props.employee.roles?.[0]?.name ?? '',
}));
</script>
@ -50,10 +50,7 @@ const initialData = computed(() => ({
</div>
<EmployeeForm :submit-url="`/admin/hr/employees/${employee.id}`" method="put" submit-label="Perbarui"
:initial-data="initialData" :employee-id="employee.id" :employee-name="employee.full_name"
:genders="genders"
:employment-statuses="employmentStatuses"
:roles="roles"
/>
:initial-data="initialData" :employee-id="employee.id" :employee-name="employee.profile?.full_name"
:genders="genders" :employment-statuses="employmentStatuses" :roles="roles" />
</AdminLayout>
</template>

View File

@ -2,11 +2,11 @@
import { Head, Link } from '@inertiajs/vue3';
import { Plus } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import { columns } from '@/components/admin/hr/employees/columns';
import { DataTable } from '@/components/data-table';
import { columns } from '@/components/hr/employees/columns';
import { Button } from '@/components/ui/button';
import { useCan } from '@/composables/useCan';
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, DataTableSort } from '@/types/data-table';
@ -99,19 +99,10 @@ watch(
<Card class="min-w-0">
<CardContent class="min-w-0">
<DataTable
v-model:search="search"
:columns="columns"
:data="employees.data"
:pagination="pagination"
:pagination-links="employees.links"
:sort="currentSort"
:filter-defs="filterDefs"
:filter-values="filterValues"
@sort-change="setSort"
@filter-change="setFilter"
@filters-reset="resetFilters"
/>
<DataTable v-model:search="search" :columns="columns" :data="employees.data" :pagination="pagination"
:pagination-links="employees.links" :sort="currentSort" :filter-defs="filterDefs"
:filter-values="filterValues" @sort-change="setSort" @filter-change="setFilter"
@filters-reset="resetFilters" />
</CardContent>
</Card>
</AdminLayout>

View File

@ -2,8 +2,8 @@
import { Head } from '@inertiajs/vue3';
import { Plus } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import CategoryFormModal from '@/components/categories/CategoryFormModal.vue';
import { createColumns } from '@/components/categories/columns';
import CategoryFormModal from '@/components/admin/master/categories/CategoryFormModal.vue';
import { createColumns } from '@/components/admin/master/categories/columns';
import { DataTable } from '@/components/data-table';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { ArrowLeft } from '@lucide/vue';
import ProductForm from '@/components/products/ProductForm.vue';
import ProductForm from '@/components/admin/master/products/ProductForm.vue';
import { Button } from '@/components/ui/button';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { CategoryOption, EnumOption } from '@/types/product';
@ -32,11 +32,6 @@ defineProps<{
</Button>
</div>
<ProductForm
submit-url="/admin/master/products"
method="post"
submit-label="Simpan"
:categories="categories"
/>
<ProductForm submit-url="/admin/master/products" method="post" submit-label="Simpan" :categories="categories" />
</AdminLayout>
</template>

View File

@ -2,13 +2,18 @@
import { Head, Link } from '@inertiajs/vue3';
import { ArrowLeft } from '@lucide/vue';
import { computed } from 'vue';
import ProductForm from '@/components/products/ProductForm.vue';
import ProductForm from '@/components/admin/master/products/ProductForm.vue';
import { Button } from '@/components/ui/button';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { CategoryOption, EnumOption, ProductEditItem } from '@/types/product';
import {
PRICE_TYPES,
type CategoryOption,
type EnumOption,
type ProductListItem,
} from '@/types/product';
const props = defineProps<{
product: ProductEditItem;
product: ProductListItem & { description?: string | null };
categories: CategoryOption[];
priceTypes: EnumOption[];
}>();
@ -16,8 +21,18 @@ const props = defineProps<{
const initialData = computed(() => ({
name: props.product.name ?? '',
description: props.product.description ?? '',
category_ids: props.product.category_ids ?? [],
variants: props.product.variants ?? [],
category_ids: props.product.categories?.map((category) => category.id) ?? [],
variants: (props.product.variants ?? []).map((variant) => ({
id: variant.id,
name: variant.name,
stock: variant.stock,
prices: Object.fromEntries(
PRICE_TYPES.map((type) => [
type,
variant.prices.find((price) => price.type === type)?.price_input ?? '',
]),
),
})),
}));
</script>
@ -41,12 +56,7 @@ const initialData = computed(() => ({
</Button>
</div>
<ProductForm
:submit-url="`/admin/master/products/${product.id}`"
method="put"
submit-label="Perbarui"
:initial-data="initialData"
:categories="categories"
/>
<ProductForm :submit-url="`/admin/master/products/${product.id}`" method="put" submit-label="Perbarui"
:initial-data="initialData" :categories="categories" />
</AdminLayout>
</template>

View File

@ -2,18 +2,17 @@
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 ProductGroupedTable from '@/components/admin/master/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';
import type { PaginatedProducts } from '@/types/product';
const props = defineProps<{
rows: ProductTableRow[];
pagination: ProductPagination;
products: PaginatedProducts;
filters: {
search: string;
sort?: string;
@ -50,14 +49,16 @@ const filterValues = computed(() => ({
}));
const tablePagination = computed(() => ({
currentPage: props.pagination.current_page,
perPage: props.pagination.per_page,
lastPage: props.pagination.last_page,
total: props.pagination.total,
currentPage: props.products.current_page,
perPage: props.products.per_page,
lastPage: props.products.last_page,
total: props.products.total,
}));
const productCountOnPage = computed(() => (
new Set(props.rows.map((row) => row.product_id)).size
const firstItem = computed(() => (
props.products.data.length > 0
? (props.products.current_page - 1) * props.products.per_page + 1
: 0
));
watch(search, (value) => {
@ -94,17 +95,9 @@ watch(
<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"
/>
<ProductGroupedTable v-model:search="search" :products="products.data" :first-item="firstItem"
:pagination="tablePagination" :pagination-links="products.links" :filter-defs="filterDefs"
:filter-values="filterValues" @filter-change="setFilter" @filters-reset="resetFilters" />
</CardContent>
</Card>
</AdminLayout>

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { ArrowLeft } from '@lucide/vue';
import RawMaterialForm from '@/components/raw-materials/RawMaterialForm.vue';
import RawMaterialForm from '@/components/admin/master/raw-materials/RawMaterialForm.vue';
import { Button } from '@/components/ui/button';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { EnumOption } from '@/types/raw-material';
@ -31,11 +31,6 @@ defineProps<{
</Button>
</div>
<RawMaterialForm
submit-url="/admin/master/raw-materials"
method="post"
submit-label="Simpan"
:units="units"
/>
<RawMaterialForm submit-url="/admin/master/raw-materials" method="post" submit-label="Simpan" :units="units" />
</AdminLayout>
</template>

View File

@ -2,20 +2,25 @@
import { Head, Link } from '@inertiajs/vue3';
import { ArrowLeft } from '@lucide/vue';
import { computed } from 'vue';
import RawMaterialForm from '@/components/raw-materials/RawMaterialForm.vue';
import RawMaterialForm from '@/components/admin/master/raw-materials/RawMaterialForm.vue';
import { Button } from '@/components/ui/button';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { EnumOption, RawMaterialEditItem } from '@/types/raw-material';
import type { EnumOption, RawMaterialListItem } from '@/types/raw-material';
const props = defineProps<{
rawMaterial: RawMaterialEditItem;
rawMaterial: RawMaterialListItem;
units: EnumOption[];
}>();
const initialData = computed(() => ({
name: props.rawMaterial.name ?? '',
unit: props.rawMaterial.unit ?? '',
prices: props.rawMaterial.prices ?? [],
prices: (props.rawMaterial.prices ?? []).map((price) => ({
id: price.id,
variant: price.variant,
price: price.price_input,
stock: price.stock_input,
})),
}));
</script>
@ -39,12 +44,7 @@ const initialData = computed(() => ({
</Button>
</div>
<RawMaterialForm
:submit-url="`/admin/master/raw-materials/${rawMaterial.id}`"
method="put"
submit-label="Perbarui"
:initial-data="initialData"
:units="units"
/>
<RawMaterialForm :submit-url="`/admin/master/raw-materials/${rawMaterial.id}`" method="put"
submit-label="Perbarui" :initial-data="initialData" :units="units" />
</AdminLayout>
</template>

View File

@ -2,18 +2,17 @@
import { Head, Link } from '@inertiajs/vue3';
import { Plus } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import RawMaterialGroupedTable from '@/components/raw-materials/RawMaterialGroupedTable.vue';
import RawMaterialGroupedTable from '@/components/admin/master/raw-materials/RawMaterialGroupedTable.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 { RawMaterialPagination, RawMaterialTableRow } from '@/types/raw-material';
import type { PaginatedRawMaterials } from '@/types/raw-material';
const props = defineProps<{
rows: RawMaterialTableRow[];
pagination: RawMaterialPagination;
rawMaterials: PaginatedRawMaterials;
filters: {
search: string;
sort?: string;
@ -50,14 +49,16 @@ const filterValues = computed(() => ({
}));
const tablePagination = computed(() => ({
currentPage: props.pagination.current_page,
perPage: props.pagination.per_page,
lastPage: props.pagination.last_page,
total: props.pagination.total,
currentPage: props.rawMaterials.current_page,
perPage: props.rawMaterials.per_page,
lastPage: props.rawMaterials.last_page,
total: props.rawMaterials.total,
}));
const materialCountOnPage = computed(() => (
new Set(props.rows.map((row) => row.raw_material_id)).size
const firstItem = computed(() => (
props.rawMaterials.data.length > 0
? (props.rawMaterials.current_page - 1) * props.rawMaterials.per_page + 1
: 0
));
watch(search, (value) => {
@ -94,17 +95,9 @@ watch(
<Card class="min-w-0">
<CardContent class="min-w-0">
<RawMaterialGroupedTable
v-model:search="search"
:rows="rows"
:pagination="tablePagination"
:pagination-links="pagination.links"
:pagination-displayed-count="materialCountOnPage"
:filter-defs="filterDefs"
:filter-values="filterValues"
@filter-change="setFilter"
@filters-reset="resetFilters"
/>
<RawMaterialGroupedTable v-model:search="search" :materials="rawMaterials.data" :first-item="firstItem"
:pagination="tablePagination" :pagination-links="rawMaterials.links" :filter-defs="filterDefs"
:filter-values="filterValues" @filter-change="setFilter" @filters-reset="resetFilters" />
</CardContent>
</Card>
</AdminLayout>

View File

@ -3,40 +3,39 @@ export type EnumOption = {
label: string;
};
export type EmployeeEditItem = {
id: number;
email: string | null;
username: string | null;
full_name: string | null;
phone_number: string | null;
gender: string | null;
birth_date: string | null;
address: string | null;
join_date: string | null;
employment_status: string | null;
base_salary: number | null;
role: string | null;
};
export type EmployeeListItem = {
id: number;
join_date: string | null;
resign_date: string | null;
employment_status: string;
employment_status_label: string;
base_salary: number;
base_salary_formatted: string;
email: string | null;
username: string | null;
is_active: boolean;
export type EmployeeProfile = {
full_name: string | null;
phone_number: string | null;
gender: string | null;
gender_label: string | null;
birth_date: string | null;
birth_date_formatted: string | null;
birth_date_input: string | null;
address: string | null;
role: string | null;
};
export type EmployeeRecord = {
join_date_formatted: string | null;
resign_date_formatted: string | null;
join_date_input: string | null;
employment_status: string | null;
employment_status_label: string | null;
base_salary: number | null;
base_salary_formatted: string;
};
export type EmployeeRole = {
name: string;
};
export type EmployeeListItem = {
id: number;
email: string | null;
username: string | null;
is_active: boolean;
role_label: string | null;
profile: EmployeeProfile;
employee: EmployeeRecord;
roles: EmployeeRole[];
};
export type EmployeeFormData = {

View File

@ -13,6 +13,7 @@ export type ProductPriceItem = {
type_label: string;
price: string;
price_formatted: string;
price_input: string;
};
export type ProductCategoryItem = {
@ -20,28 +21,19 @@ export type ProductCategoryItem = {
name: string;
};
export type ProductTableGroup = {
product_id: number;
product_row_number: number;
product_name: string;
categories: ProductCategoryItem[];
is_active: boolean;
variants: ProductTableRow[];
export type ProductVariantItem = {
id: number;
name: string;
stock: number;
prices: ProductPriceItem[];
};
export type ProductTableRow = {
row_id: string;
product_id: number;
product_name: string;
categories: ProductCategoryItem[];
export type ProductListItem = {
id: number;
name: string;
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[];
categories: ProductCategoryItem[];
variants: ProductVariantItem[];
};
export type ProductVariantFormItem = {
@ -52,19 +44,6 @@ export type ProductVariantFormItem = {
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;
@ -87,7 +66,8 @@ export type ProductFilters = {
is_active?: string;
};
export type ProductPagination = {
export type PaginatedProducts = {
data: ProductListItem[];
current_page: number;
last_page: number;
per_page: number;

View File

@ -3,34 +3,25 @@ export type EnumOption = {
label: string;
};
export type RawMaterialTableGroup = {
raw_material_id: number;
raw_material_row_number: number;
raw_material_name: string;
unit: string;
unit_label: string;
unit_abbreviation: string;
is_active: boolean;
variants: RawMaterialTableRow[];
export type RawMaterialPrice = {
id: number;
variant: string;
stock: string;
stock_formatted: string;
price: string;
price_formatted: string;
price_input: string;
stock_input: string;
};
export type RawMaterialTableRow = {
row_id: string;
raw_material_id: number;
raw_material_name: string;
export type RawMaterialListItem = {
id: number;
name: string;
unit: string;
unit_label: string;
unit_abbreviation: string;
is_active: boolean;
is_first_variant: boolean;
variant_count: number;
raw_material_row_number: number;
price_id: number | null;
variant: string | null;
stock: string | null;
stock_formatted: string | null;
price: string | null;
price_formatted: string | null;
prices: RawMaterialPrice[];
};
export type RawMaterialPriceFormItem = {
@ -41,18 +32,6 @@ export type RawMaterialPriceFormItem = {
stock: string;
};
export type RawMaterialEditItem = {
id: number;
name: string;
unit: string;
prices: Array<{
id: number;
variant: string;
price: string;
stock: string;
}>;
};
export type RawMaterialFormData = {
name: string;
unit: string;
@ -64,7 +43,8 @@ export type RawMaterialFormData = {
}>;
};
export type RawMaterialPagination = {
export type PaginatedRawMaterials = {
data: RawMaterialListItem[];
current_page: number;
last_page: number;
per_page: number;

View File

@ -1,11 +1,11 @@
<?php
use App\Enums\Permission;
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\Admin\RawMaterialController;
use App\Http\Controllers\Admin\Master\CategoryController;
use App\Http\Controllers\Admin\Master\ProductController;
use App\Http\Controllers\Admin\Master\RawMaterialController;
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\Auth\LogoutController;
use Illuminate\Support\Facades\Route;
@ -110,33 +110,33 @@
->group(function () {
Route::post('{user}/reset-password', [EmployeeController::class, 'resetPassword'])
->middleware('permission:'.Permission::EMPLOYEES_RESET_PASSWORD->value)
->name('employees.reset-password');
->name('reset-password');
Route::patch('{user}/toggle-status', [EmployeeController::class, 'toggleStatus'])
->middleware('permission:'.Permission::EMPLOYEES_TOGGLE_STATUS->value)
->name('employees.toggle-status');
->name('toggle-status');
Route::get('create', [EmployeeController::class, 'create'])
->middleware('permission:'.Permission::EMPLOYEES_CREATE->value)
->name('employees.create');
->name('create');
Route::post('employees', [EmployeeController::class, 'store'])
->middleware('permission:'.Permission::EMPLOYEES_CREATE->value)
->name('employees.store');
->name('store');
Route::get('{user}/edit', [EmployeeController::class, 'edit'])
->middleware('permission:'.Permission::EMPLOYEES_UPDATE->value)
->name('employees.edit');
->name('edit');
Route::put('{user}', [EmployeeController::class, 'update'])
->middleware('permission:'.Permission::EMPLOYEES_UPDATE->value)
->name('employees.update');
->name('update');
Route::delete('{user}', [EmployeeController::class, 'destroy'])
->middleware('permission:'.Permission::EMPLOYEES_DELETE->value)
->name('employees.destroy');
->name('destroy');
Route::get('/', [EmployeeController::class, 'index'])->name('employees.index');
Route::get('/', [EmployeeController::class, 'index'])->name('index');
});
});
});