Add purchase management features including Purchase and PurchaseItem models, PurchaseController for CRUD operations, and corresponding request validation. Implement UI components for creating, editing, and listing purchases, along with permissions in the Permission and Role enums. Enhance sidebar navigation for purchases and integrate media handling for purchase items.

This commit is contained in:
Yoga Pangestu 2026-06-12 00:09:20 +07:00
parent f01a8a11a4
commit 04e57c0dd1
24 changed files with 1674 additions and 7 deletions

View File

@ -55,6 +55,11 @@ enum Permission: string
case RAW_MATERIALS_DELETE = 'raw-materials.delete';
case RAW_MATERIALS_TOGGLE_STATUS = 'raw-materials.toggle-status';
case PURCHASES_VIEW = 'purchases.view';
case PURCHASES_CREATE = 'purchases.create';
case PURCHASES_UPDATE = 'purchases.update';
case PURCHASES_DELETE = 'purchases.delete';
case CASH_VIEW = 'cash.view';
case CASH_DEPOSIT = 'cash.deposit';
case CASH_UPDATE = 'cash.update';
@ -130,6 +135,11 @@ public function label(): string
self::RAW_MATERIALS_DELETE => 'Hapus Bahan Baku',
self::RAW_MATERIALS_TOGGLE_STATUS => 'Ubah Status Bahan Baku',
self::PURCHASES_VIEW => 'Lihat Belanja',
self::PURCHASES_CREATE => 'Catat Belanja',
self::PURCHASES_UPDATE => 'Ubah Belanja',
self::PURCHASES_DELETE => 'Hapus Belanja',
self::CASH_VIEW => 'Lihat Kas',
self::CASH_DEPOSIT => 'Setor Kas',
self::CASH_UPDATE => 'Ubah Setor Kas',
@ -177,6 +187,8 @@ public function group(): string
self::PRODUCTS_DELETE, self::PRODUCTS_TOGGLE_STATUS => 'Produk',
self::RAW_MATERIALS_VIEW, self::RAW_MATERIALS_CREATE, self::RAW_MATERIALS_UPDATE,
self::RAW_MATERIALS_DELETE, self::RAW_MATERIALS_TOGGLE_STATUS => 'Bahan Baku',
self::PURCHASES_VIEW, self::PURCHASES_CREATE, self::PURCHASES_UPDATE,
self::PURCHASES_DELETE => 'Belanja',
self::CASH_VIEW, self::CASH_DEPOSIT, self::CASH_UPDATE, self::CASH_DELETE => 'Kas',
self::EXPENSES_VIEW, self::EXPENSES_CREATE, self::EXPENSES_UPDATE,
self::EXPENSES_DELETE => 'Pengeluaran',

View File

@ -74,6 +74,10 @@ public function permissions(): array
Permission::RAW_MATERIALS_UPDATE,
Permission::RAW_MATERIALS_DELETE,
Permission::RAW_MATERIALS_TOGGLE_STATUS,
Permission::PURCHASES_VIEW,
Permission::PURCHASES_CREATE,
Permission::PURCHASES_UPDATE,
Permission::PURCHASES_DELETE,
Permission::CASH_VIEW,
Permission::CASH_DEPOSIT,
Permission::CASH_UPDATE,
@ -166,6 +170,10 @@ public function permissions(): array
Permission::RAW_MATERIALS_UPDATE,
Permission::RAW_MATERIALS_DELETE,
Permission::RAW_MATERIALS_TOGGLE_STATUS,
Permission::PURCHASES_VIEW,
Permission::PURCHASES_CREATE,
Permission::PURCHASES_UPDATE,
Permission::PURCHASES_DELETE,
],
self::MARKETING => [
Permission::DASHBOARD_VIEW,

View File

@ -0,0 +1,76 @@
<?php
namespace App\Http\Controllers\Admin\Manage;
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\PurchaseRequest;
use App\Models\Purchase;
use App\Services\Manage\PurchaseService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class PurchaseController extends Controller
{
use ParsesDataTableQuery;
public function __construct(
private readonly PurchaseService $purchaseService,
) {}
public function index(Request $request): Response
{
$tableQuery = $this->parseDataTableQuery($request);
return Inertia::render('admin/manage/purchases/Index', [
'purchases' => $this->purchaseService->paginateForIndex($tableQuery),
'filters' => $this->dataTableFilters($tableQuery),
]);
}
public function create(): Response
{
return Inertia::render('admin/manage/purchases/Create', [
'suppliers' => $this->purchaseService->supplierOptions(),
'catalog' => $this->purchaseService->catalogItems(),
]);
}
public function store(PurchaseRequest $request): RedirectResponse
{
$this->purchaseService->create($request->validated(), $request->user());
Inertia::flash('success', 'Belanja berhasil ditambahkan.');
return redirect()->route('admin.manage.purchases.index');
}
public function edit(Purchase $purchase): Response
{
return Inertia::render('admin/manage/purchases/Edit', [
'purchase' => $this->purchaseService->findForEdit($purchase),
'suppliers' => $this->purchaseService->supplierOptions(),
'catalog' => $this->purchaseService->catalogItems($purchase),
]);
}
public function update(PurchaseRequest $request, Purchase $purchase): RedirectResponse
{
$this->purchaseService->update($purchase, $request->validated());
Inertia::flash('success', 'Belanja berhasil diperbarui.');
return redirect()->route('admin.manage.purchases.index');
}
public function destroy(Purchase $purchase): RedirectResponse
{
$this->purchaseService->delete($purchase);
Inertia::flash('success', 'Belanja berhasil dihapus.');
return redirect()->route('admin.manage.purchases.index');
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use App\Enums\Permission;
use App\Http\Requests\Concerns\ValidatesMediaUploads;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class PurchaseRequest extends FormRequest
{
use ValidatesMediaUploads;
public function authorize(): bool
{
$permission = $this->isMethod('POST')
? Permission::PURCHASES_CREATE
: Permission::PURCHASES_UPDATE;
return $this->user()?->can($permission->value) ?? false;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'supplier_id' => ['required', 'integer', Rule::exists('suppliers', 'id')->whereNull('deleted_at')],
'discount' => ['nullable', 'integer', 'min:0'],
'notes' => ['nullable', 'string', 'max:100'],
'items' => ['required', 'array', 'min:1'],
'items.*.raw_material_price_id' => [
'required',
'integer',
Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'),
],
'items.*.quantity' => ['required', 'numeric', 'gt:0'],
...$this->photoRules(),
];
}
}

88
app/Models/Purchase.php Normal file
View File

@ -0,0 +1,88 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasModuleMedia;
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\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\MediaLibrary\HasMedia;
#[Guarded(['id'])]
#[Appends([
'subtotal_formatted',
'discount_formatted',
'total_formatted',
'created_at_formatted',
])]
class Purchase extends Model implements HasMedia
{
use HasModuleMedia;
use SoftDeletes;
public static function mediaModuleName(): string
{
return 'purchase';
}
public function registerMediaCollections(): void
{
$this->addMediaCollection('photos');
}
protected function casts(): array
{
return [
'subtotal' => 'integer',
'discount' => 'integer',
'total' => 'integer',
];
}
public function subtotalFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->subtotal, 0, ',', '.'),
);
}
public function discountFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->discount, 0, ',', '.'),
);
}
public function totalFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->total, 0, ',', '.'),
);
}
public function createdAtFormatted(): Attribute
{
return Attribute::make(
get: fn () => $this->created_at?->translatedFormat('l, d F Y H:i'),
);
}
public function supplier(): BelongsTo
{
return $this->belongsTo(Supplier::class);
}
public function createdBy(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_id');
}
public function items(): HasMany
{
return $this->hasMany(PurchaseItem::class);
}
}

View File

@ -0,0 +1,81 @@
<?php
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([
'quantity_formatted',
'quantity_input',
'unit_price_formatted',
'subtotal_formatted',
'unit_abbreviation',
])]
class PurchaseItem extends Model
{
use SoftDeletes;
protected function casts(): array
{
return [
'quantity' => 'decimal:4',
'unit_price' => 'integer',
'subtotal' => 'integer',
];
}
public function quantityFormatted(): Attribute
{
return Attribute::make(
get: function () {
$formatted = rtrim(rtrim(number_format((float) $this->quantity, 4, ',', '.'), '0'), ',');
return "{$formatted} {$this->rawMaterialPrice?->rawMaterial?->unit?->abbreviation()}";
},
);
}
public function quantityInput(): Attribute
{
return Attribute::make(
get: fn () => rtrim(rtrim(number_format((float) $this->quantity, 4, '.', ''), '0'), '.'),
);
}
public function unitPriceFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->unit_price, 0, ',', '.'),
);
}
public function subtotalFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->subtotal, 0, ',', '.'),
);
}
public function unitAbbreviation(): Attribute
{
return Attribute::make(
get: fn () => $this->rawMaterialPrice?->rawMaterial?->unit?->abbreviation(),
);
}
public function purchase(): BelongsTo
{
return $this->belongsTo(Purchase::class);
}
public function rawMaterialPrice(): BelongsTo
{
return $this->belongsTo(RawMaterialPrice::class);
}
}

View File

@ -4,10 +4,16 @@
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Guarded(['id'])]
class Supplier extends Model
{
use SoftDeletes;
public function purchases(): HasMany
{
return $this->hasMany(Purchase::class);
}
}

View File

@ -75,4 +75,9 @@ public function expenses(): HasMany
{
return $this->hasMany(Expense::class, 'created_by_id');
}
public function purchases(): HasMany
{
return $this->hasMany(Purchase::class, 'created_by_id');
}
}

View File

@ -0,0 +1,295 @@
<?php
namespace App\Services\Manage;
use App\Models\Purchase;
use App\Models\PurchaseItem;
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice;
use App\Models\Supplier;
use App\Models\User;
use App\Services\Media\MediaService;
use App\Support\Media\MediaPresenter;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class PurchaseService
{
private const MAX_PHOTOS_FILES = 1;
public function __construct(
private readonly MediaService $mediaService,
) {}
/**
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
*/
public function paginateForIndex(array $tableQuery): LengthAwarePaginator
{
$query = Purchase::query()
->with([
'supplier:id,name',
'createdBy.profile',
'items.rawMaterialPrice.rawMaterial:id,name,unit',
'media',
])
->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
$search = $tableQuery['search'];
$query->where(function (Builder $query) use ($search): void {
$query->where('notes', 'like', "%{$search}%")
->orWhereHas('supplier', fn (Builder $query) => $query->where('name', 'like', "%{$search}%"))
->orWhereHas('items.rawMaterialPrice', function (Builder $query) use ($search): void {
$query->where('variant', 'like', "%{$search}%")
->orWhereHas('rawMaterial', fn (Builder $query) => $query->where('name', 'like', "%{$search}%"));
});
});
});
$this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']);
return $query
->paginate(10)
->withQueryString()
->through(function (Purchase $purchase) {
$purchase->setAttribute(
'photos',
MediaPresenter::first($purchase, 'photos'),
);
return $purchase;
});
}
/**
* @return list<array{value: int, label: string}>
*/
public function supplierOptions(): array
{
return Supplier::query()
->orderBy('name')
->get(['id', 'name'])
->map(fn (Supplier $supplier) => [
'value' => $supplier->id,
'label' => $supplier->name,
])
->all();
}
/**
* @return Collection<int, RawMaterial>
*/
public function catalogItems(?Purchase $purchase = null): Collection
{
$purchasePriceIds = $purchase
? $purchase->items()->pluck('raw_material_price_id')->all()
: [];
return RawMaterial::query()
->with([
'prices' => fn ($query) => $query
->orderBy('created_at')
->with(['rawMaterial:id,unit', 'media']),
])
->where(function (Builder $query) use ($purchasePriceIds): void {
$query->active();
if ($purchasePriceIds !== []) {
$query->orWhereHas(
'prices',
fn (Builder $query) => $query->whereIn('id', $purchasePriceIds),
);
}
})
->orderBy('name')
->get()
->each(function (RawMaterial $rawMaterial): void {
$rawMaterial->prices->each(function (RawMaterialPrice $price): void {
$price->setAttribute(
'images',
MediaPresenter::collection($price, 'images'),
);
});
});
}
public function findForEdit(Purchase $purchase): Purchase
{
$purchase->load([
'items.rawMaterialPrice.rawMaterial:id,name,unit',
'media',
]);
$purchase->setAttribute(
'photos',
MediaPresenter::first($purchase, 'photos'),
);
$purchase->items->each(function (PurchaseItem $item): void {
$price = $item->rawMaterialPrice;
if ($price) {
$price->setAttribute(
'images',
MediaPresenter::collection($price, 'images'),
);
}
});
return $purchase;
}
/**
* @param array<string, mixed> $validated
*/
public function create(array $validated, User $user): Purchase
{
return DB::transaction(function () use ($validated, $user): Purchase {
$lineItems = $this->buildLineItems($validated['items']);
$subtotal = array_sum(array_column($lineItems, 'subtotal'));
$discount = (int) ($validated['discount'] ?? 0);
$total = max($subtotal - $discount, 0);
$purchase = Purchase::create([
'supplier_id' => $validated['supplier_id'],
'created_by_id' => $user->id,
'subtotal' => $subtotal,
'discount' => $discount,
'total' => $total,
'notes' => $validated['notes'] ?? null,
]);
foreach ($lineItems as $itemData) {
$purchaseItem = $purchase->items()->create($itemData);
$this->incrementStock($purchaseItem);
}
$this->syncPhotos($purchase, $validated);
return $purchase;
});
}
/**
* @param array<string, mixed> $validated
*/
public function update(Purchase $purchase, array $validated): void
{
DB::transaction(function () use ($purchase, $validated): void {
$purchase->load('items');
foreach ($purchase->items as $item) {
$this->decrementStock($item);
}
$purchase->items()->delete();
$lineItems = $this->buildLineItems($validated['items']);
$subtotal = array_sum(array_column($lineItems, 'subtotal'));
$discount = (int) ($validated['discount'] ?? 0);
$total = max($subtotal - $discount, 0);
$purchase->supplier_id = $validated['supplier_id'];
$purchase->subtotal = $subtotal;
$purchase->discount = $discount;
$purchase->total = $total;
$purchase->notes = $validated['notes'] ?? null;
$purchase->save();
foreach ($lineItems as $itemData) {
$purchaseItem = $purchase->items()->create($itemData);
$this->incrementStock($purchaseItem);
}
$this->syncPhotos($purchase, $validated);
});
}
public function delete(Purchase $purchase): void
{
DB::transaction(function () use ($purchase): void {
$purchase->load('items');
foreach ($purchase->items as $item) {
$this->decrementStock($item);
}
$purchase->clearMediaCollection('photos');
$purchase->items()->delete();
$purchase->delete();
});
}
/**
* @param list<array{raw_material_price_id: int, quantity: float|int|string}> $items
* @return list<array{raw_material_price_id: int, quantity: float, unit_price: int, subtotal: int}>
*/
private function buildLineItems(array $items): array
{
return collect($items)
->map(function (array $itemData, int $index) {
$price = RawMaterialPrice::query()->find($itemData['raw_material_price_id']);
if ($price === null) {
throw ValidationException::withMessages([
"items.{$index}.raw_material_price_id" => 'Bahan baku tidak ditemukan.',
]);
}
$quantity = (float) $itemData['quantity'];
$unitPrice = (int) $price->price;
$subtotal = (int) round($quantity * $unitPrice);
return [
'raw_material_price_id' => $price->id,
'quantity' => $quantity,
'unit_price' => $unitPrice,
'subtotal' => $subtotal,
];
})
->all();
}
/**
* @param array<string, mixed> $validated
*/
private function syncPhotos(Purchase $purchase, array $validated): void
{
$this->mediaService->syncCollection(
$purchase,
'photos',
$validated['photos'] ?? null,
$validated['remove_media_ids'] ?? null,
self::MAX_PHOTOS_FILES,
required: false,
errorKey: 'photos',
);
}
private function incrementStock(PurchaseItem $item): void
{
RawMaterialPrice::query()
->whereKey($item->raw_material_price_id)
->increment('stock', $item->quantity);
}
private function decrementStock(PurchaseItem $item): void
{
RawMaterialPrice::query()
->whereKey($item->raw_material_price_id)
->decrement('stock', $item->quantity);
}
private function applySorting(Builder $query, string $sort, string $direction): void
{
if (in_array($sort, ['created_at', 'total', 'discount', 'subtotal'], true)) {
$query->orderBy($sort, $direction);
return;
}
$query->latest();
}
}

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('purchases', function (Blueprint $table) {
$table->id();
$table->foreignId('supplier_id')->constrained()->cascadeOnDelete();
$table->foreignId('created_by_id')->constrained('users')->cascadeOnDelete();
$table->unsignedBigInteger('subtotal');
$table->unsignedBigInteger('discount')->default(0);
$table->unsignedBigInteger('total');
$table->string('notes', 100)->nullable();
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('purchases');
}
};

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('purchase_items', function (Blueprint $table) {
$table->id();
$table->foreignId('purchase_id')->constrained()->cascadeOnDelete();
$table->foreignId('raw_material_price_id')->constrained()->cascadeOnDelete();
$table->decimal('quantity', 18, 4);
$table->unsignedBigInteger('unit_price');
$table->unsignedBigInteger('subtotal');
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('purchase_items');
}
};

8
package-lock.json generated
View File

@ -21,7 +21,7 @@
"clsx": "^2.1.1",
"concurrently": "^9.0.1",
"laravel-vite-plugin": "^3.1",
"reka-ui": "^2.9.9",
"reka-ui": "^2.9.10",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.1.1",
"typescript": "^5.2.2",
@ -8922,9 +8922,9 @@
}
},
"node_modules/reka-ui": {
"version": "2.9.9",
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.9.9.tgz",
"integrity": "sha512-/e+hdF9vP8E2kPrKR4RdgMQQsfpCr8l436Zn8GRWM3jKT9EG1lOO/UFMGBVEnrMLOVoJSjjmIFrej4tMOb+6qQ==",
"version": "2.9.10",
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.9.10.tgz",
"integrity": "sha512-yuvZVTp4fWH2G3qk+ze/x6YYlyc2Xl1d+eMUlIYrKqzTowBKteoDoN17fitURmqSUck3mc7JbcYgp49DnGu2EQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.6.13",

View File

@ -49,7 +49,7 @@
"clsx": "^2.1.1",
"concurrently": "^9.0.1",
"laravel-vite-plugin": "^3.1",
"reka-ui": "^2.9.9",
"reka-ui": "^2.9.10",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.1.1",
"typescript": "^5.2.2",

View File

@ -52,6 +52,8 @@ @theme inline {
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
--font-heading:
var(--font-sans);
}
:root {

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import { Banknote, CalendarDays, Clock, FolderTree, Layers, LayoutDashboard, Package, Receipt, Settings2, User, UserCheck, Users, Wallet, WalletCards } from '@lucide/vue';
import { Banknote, CalendarDays, Clock, FolderTree, Layers, LayoutDashboard, Package, Receipt, Settings2, ShoppingBag, User, UserCheck, Users, Wallet, WalletCards } from '@lucide/vue';
import { computed } from 'vue';
import {
Sidebar,
@ -29,6 +29,8 @@ const isProductsActive = computed(() => page.url.startsWith('/admin/master/produ
const isRawMaterialsActive = computed(() => page.url.startsWith('/admin/master/raw-materials'));
const isSuppliersActive = computed(() => page.url.startsWith('/admin/master/suppliers'));
const isCustomersActive = computed(() => page.url.startsWith('/admin/master/customers'));
const isPurchasesActive = computed(() => page.url.startsWith('/admin/manage/purchases'));
const showManageMenu = computed(() => can('purchases.view'));
const isCashActive = computed(() => page.url.startsWith('/admin/finance/cash'));
const isExpensesActive = computed(() => page.url.startsWith('/admin/finance/expenses'));
const isEmployeeAdvancesActive = computed(() => page.url.startsWith('/admin/finance/employee-advances'));
@ -122,6 +124,21 @@ const showSystemMenu = computed(() => can('settings.view'));
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup v-if="showManageMenu">
<SidebarGroupLabel>Kelola</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem v-if="can('purchases.view')">
<SidebarMenuButton as-child tooltip="Belanja" :is-active="isPurchasesActive">
<Link href="/admin/manage/purchases">
<ShoppingBag />
<span>Belanja</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup v-if="showFinanceMenu">
<SidebarGroupLabel>Keuangan</SidebarGroupLabel>
<SidebarGroupContent>

View File

@ -0,0 +1,168 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { computed } from 'vue';
import DataTableActions from '@/components/admin/manage/purchases/data-table-actions.vue';
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
import { Button } from '@/components/ui/button';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/components/ui/empty';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type {
DataTablePagination,
DataTablePaginationLink,
} from '@/types/data-table';
import type { PurchaseListItem } from '@/types/purchase';
const props = defineProps<{
purchases: PurchaseListItem[];
firstItem?: number;
pagination?: DataTablePagination;
paginationLinks?: DataTablePaginationLink[];
}>();
const search = defineModel<string>('search', { default: '' });
const emit = defineEmits<{
'filters-reset': [];
}>();
const showingCount = computed(() => props.purchases.length);
const paginationSummary = computed(() => {
if (!props.pagination) {
return null;
}
const { total } = props.pagination;
if (total === 0) {
return 'Menampilkan 0 belanja';
}
return `Menampilkan ${showingCount.value} belanja dari ${total}`;
});
function rowNumber(index: number): number {
return (props.firstItem ?? 1) + index;
}
</script>
<template>
<div class="space-y-4">
<DataTableToolbar v-model:search="search" @filters-reset="emit('filters-reset')" />
<div v-if="purchases.length" class="space-y-4">
<div v-for="(purchase, index) in purchases" :key="purchase.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">
{{ rowNumber(index) }}
</span>
<div class="min-w-0 space-y-2">
<h3 class="font-medium leading-tight">
{{ purchase.supplier?.name }}
</h3>
<div class="text-muted-foreground space-y-1 text-sm">
<p>{{ purchase.created_at_formatted }}</p>
<p>Oleh {{ purchase.created_by?.profile?.full_name ?? purchase.created_by?.username }}
</p>
</div>
<div class="flex flex-wrap gap-x-4 gap-y-1 text-sm">
<span>Subtotal <strong class="text-foreground">{{ purchase.subtotal_formatted
}}</strong></span>
<span>Diskon <strong class="text-foreground">{{ purchase.discount_formatted
}}</strong></span>
<span>Total <strong class="text-primary">{{ purchase.total_formatted }}</strong></span>
</div>
<p v-if="purchase.notes" class="text-muted-foreground text-sm">
{{ purchase.notes }}
</p>
</div>
</div>
<div class="flex shrink-0 items-center justify-end gap-2 sm:pt-0.5">
<MediaThumbnailCell :items="purchase.photos ? [purchase.photos] : []" />
<DataTableActions :purchase="purchase" />
</div>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Bahan Baku</TableHead>
<TableHead>Varian</TableHead>
<TableHead>Jumlah</TableHead>
<TableHead>Harga Satuan</TableHead>
<TableHead>Subtotal</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="!purchase.items.length" :key="`${purchase.id}-empty`">
<TableCell colspan="5" class="text-muted-foreground">
Belum ada item
</TableCell>
</TableRow>
<TableRow v-for="item in purchase.items" :key="item.id">
<TableCell class="font-medium">
{{ item.raw_material_price?.raw_material?.name }}
</TableCell>
<TableCell>
{{ item.raw_material_price?.variant }}
</TableCell>
<TableCell class="tabular-nums">
{{ item.quantity_formatted }}
</TableCell>
<TableCell class="tabular-nums">
{{ item.unit_price_formatted }}
</TableCell>
<TableCell class="tabular-nums font-medium">
{{ item.subtotal_formatted }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
<div v-else class="rounded-md border px-6 py-10">
<Empty>
<EmptyHeader>
<EmptyTitle>Data tidak ditemukan</EmptyTitle>
<EmptyDescription>
Silakan lakukan pencarian untuk menemukan data yang Anda cari.
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
<div v-if="pagination" class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-muted-foreground text-sm">
{{ paginationSummary }}
</p>
<div v-if="paginationLinks?.length && pagination.lastPage > 1"
class="flex flex-wrap items-center justify-center gap-1 sm:justify-end">
<Button v-for="link in paginationLinks" :key="`${link.label}-${link.url}`" variant="outline" size="sm"
:disabled="!link.url || link.active" as-child>
<Link v-if="link.url" :href="link.url" preserve-scroll>
<span v-html="link.label" />
</Link>
<span v-else v-html="link.label" />
</Button>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,424 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Minus, Plus, Save, Search, ShoppingCart, Trash2 } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import MediaImageUpload from '@/components/media/MediaImageUpload.vue';
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/components/ui/empty';
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { RupiahInput } from '@/components/ui/rupiah-input';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Textarea } from '@/components/ui/textarea';
import { formatRupiah, parseRupiah } from '@/lib/rupiah';
import type { MediaItem } from '@/types/media';
import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media';
import type { PurchaseCartItem, PurchaseCatalogItem, SelectOption } from '@/types/purchase';
const props = defineProps<{
suppliers: SelectOption[];
catalog: PurchaseCatalogItem[];
initialData?: {
supplier_id: string;
discount: string;
notes: string;
items: PurchaseCartItem[];
photos?: MediaItem | null;
};
submitUrl: string;
method: 'post' | 'put';
submitLabel: string;
}>();
const search = ref('');
const cart = ref<PurchaseCartItem[]>([]);
const form = useForm({
supplier_id: '',
discount: '',
notes: '',
media: createMediaUploadState(),
});
function populateForm() {
if (!props.initialData) {
return;
}
form.supplier_id = props.initialData.supplier_id;
form.discount = props.initialData.discount;
form.notes = props.initialData.notes;
form.media = createMediaUploadState(props.initialData.photos ? [props.initialData.photos] : []);
cart.value = props.initialData.items.map((item) => ({ ...item }));
}
watch(
() => props.initialData,
() => {
populateForm();
},
{ immediate: true },
);
type CatalogPrice = {
id: number;
variant: string;
price_input: string;
price_formatted: string;
images?: PurchaseCatalogItem['prices'][number]['images'];
};
const filteredCatalog = computed(() => {
const keyword = search.value.trim().toLowerCase();
if (!keyword) {
return props.catalog;
}
return props.catalog.filter((rawMaterial) =>
rawMaterial.name.toLowerCase().includes(keyword)
|| rawMaterial.prices.some((price) => price.variant.toLowerCase().includes(keyword)),
);
});
const subtotal = computed(() =>
cart.value.reduce((sum, item) => sum + lineSubtotal(item), 0),
);
const discountAmount = computed(() => Number(parseRupiah(form.discount)) || 0);
const total = computed(() => Math.max(subtotal.value - discountAmount.value, 0));
function lineSubtotal(item: PurchaseCartItem): number {
const quantity = Number(item.quantity) || 0;
return Math.round(quantity * item.unit_price);
}
function addToCart(rawMaterial: PurchaseCatalogItem, price: CatalogPrice) {
const existing = cart.value.find(
(item) => item.raw_material_price_id === price.id,
);
if (existing) {
const currentQty = Number(existing.quantity) || 0;
existing.quantity = String(currentQty + 1);
return;
}
cart.value.push({
raw_material_price_id: price.id,
raw_material_name: rawMaterial.name,
variant: price.variant,
unit_abbreviation: rawMaterial.unit_abbreviation,
quantity: '1',
unit_price: Number(price.price_input),
images: price.images ?? [],
});
}
function removeFromCart(index: number) {
cart.value.splice(index, 1);
}
function adjustQuantity(index: number, delta: number) {
const item = cart.value[index];
const nextQty = (Number(item.quantity) || 0) + delta;
if (nextQty <= 0) {
removeFromCart(index);
return;
}
item.quantity = String(nextQty);
}
function buildFormData(): FormData {
const formData = new FormData();
if (props.method === 'put') {
formData.append('_method', 'PUT');
}
formData.append('supplier_id', form.supplier_id);
formData.append('discount', parseRupiah(form.discount));
formData.append('notes', form.notes);
cart.value.forEach((item, index) => {
formData.append(`items[${index}][raw_material_price_id]`, String(item.raw_material_price_id));
formData.append(`items[${index}][quantity]`, item.quantity);
});
appendRootPhotosToFormData(formData, form.media);
return formData;
}
function formError(key: string): string | undefined {
return (form.errors as Record<string, string>)[key];
}
function submit() {
if (cart.value.length === 0) {
toast.error('Tambahkan minimal satu bahan baku ke keranjang.');
return;
}
if (!form.supplier_id) {
toast.error('Pilih supplier terlebih dahulu.');
return;
}
const payload = buildFormData();
form.transform(() => payload).post(props.submitUrl, {
forceFormData: true,
onError: () => {
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
},
});
}
</script>
<template>
<div class="grid gap-4 xl:grid-cols-[1fr_380px]">
<Card class="min-w-0">
<CardHeader class="pb-3">
<CardTitle class="text-base">Pilih Bahan Baku</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="relative">
<Search class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="search" placeholder="Cari bahan baku..." class="pl-9" />
</div>
<div v-if="filteredCatalog.length === 0" class="py-12 text-center text-sm text-muted-foreground">
<Empty>
<EmptyHeader>
<EmptyTitle>Tidak ada bahan baku ditemukan</EmptyTitle>
<EmptyDescription>
Silakan lakukan pencarian untuk menemukan bahan baku yang Anda cari.
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
<div v-else class="space-y-4">
<div v-for="rawMaterial in filteredCatalog" :key="rawMaterial.id"
class="overflow-hidden rounded-md border">
<div class="border-b bg-muted/30 px-4 py-3">
<div class="flex flex-wrap items-center gap-2">
<h3 class="font-medium leading-tight">
{{ rawMaterial.name }}
</h3>
<Badge variant="secondary">
{{ rawMaterial.unit_label }}
</Badge>
</div>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Varian</TableHead>
<TableHead>Foto</TableHead>
<TableHead>Harga</TableHead>
<TableHead class="w-24 text-right" />
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="!rawMaterial.prices.length" :key="`${rawMaterial.id}-empty`">
<TableCell colspan="4" class="text-muted-foreground">
Belum ada varian
</TableCell>
</TableRow>
<TableRow v-for="price in rawMaterial.prices" :key="price.id"
class="cursor-pointer hover:bg-muted/30" @click="addToCart(rawMaterial, price)">
<TableCell class="font-medium">
{{ price.variant }}
</TableCell>
<TableCell>
<MediaThumbnailCell :items="price.images ?? []" />
</TableCell>
<TableCell class="tabular-nums">
{{ price.price_formatted }}
</TableCell>
<TableCell class="text-right">
<Button type="button" variant="outline" size="sm"
@click.stop="addToCart(rawMaterial, price)">
<Plus class="size-3.5" />
Tambah
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
<Card class="h-fit xl:sticky xl:top-4">
<CardHeader class="pb-3">
<CardTitle class="flex items-center gap-2 text-base">
<ShoppingCart class="size-4" />
Ringkasan Belanja
</CardTitle>
</CardHeader>
<CardContent>
<form @submit.prevent="submit">
<FieldGroup>
<FieldSet class="grid gap-4">
<Field>
<FieldLabel for="supplier" required>Supplier</FieldLabel>
<Select v-model="form.supplier_id">
<SelectTrigger id="supplier" class="w-full">
<SelectValue placeholder="Pilih supplier" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="supplier in suppliers" :key="supplier.value"
:value="String(supplier.value)">
{{ supplier.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FieldError :errors="form.errors.supplier_id ? [form.errors.supplier_id] : []" />
</Field>
<div v-if="cart.length === 0"
class="rounded-lg border border-dashed text-center text-sm text-muted-foreground">
<Empty>
<EmptyHeader>
<EmptyTitle>Keranjang masih kosong</EmptyTitle>
<EmptyDescription>
Pilih varian bahan baku di sebelah kiri untuk menambahkan ke keranjang.
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
<div v-else class="space-y-3">
<div v-for="(item, index) in cart" :key="item.raw_material_price_id"
class="rounded-lg border p-3">
<div class="flex gap-3">
<div class="min-w-0 flex-1 space-y-2">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium">
{{ item.raw_material_name }}
</p>
<p class="truncate text-xs text-muted-foreground">
{{ item.variant }}
</p>
</div>
<Button type="button" variant="ghost" size="icon"
class="text-destructive hover:text-destructive size-7 shrink-0"
@click="removeFromCart(index)">
<Trash2 class="size-3.5" />
</Button>
</div>
<Field>
<FieldLabel class="text-xs">Jumlah ({{ item.unit_abbreviation }})
</FieldLabel>
<div class="flex items-center gap-1">
<Button type="button" variant="outline" size="icon"
class="size-8 shrink-0" @click="adjustQuantity(index, -1)">
<Minus class="size-3.5" />
</Button>
<Input v-model="item.quantity" type="number" min="0.0001" step="any"
class="h-8 text-center" />
<Button type="button" variant="outline" size="icon"
class="size-8 shrink-0" @click="adjustQuantity(index, 1)">
<Plus class="size-3.5" />
</Button>
</div>
</Field>
<p class="text-xs text-muted-foreground">
Harga satuan Rp {{ formatRupiah(item.unit_price) }}
</p>
<p class="text-right text-sm font-medium">
Rp {{ formatRupiah(lineSubtotal(item)) }}
</p>
</div>
</div>
</div>
</div>
<Separator />
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-muted-foreground">Subtotal</span>
<span class="font-medium">Rp {{ formatRupiah(subtotal) }}</span>
</div>
<Field>
<FieldLabel for="discount">Diskon</FieldLabel>
<RupiahInput id="discount" v-model="form.discount" placeholder="0" />
<FieldError :errors="form.errors.discount ? [form.errors.discount] : []" />
</Field>
<div class="flex justify-between text-base font-semibold">
<span>Total</span>
<span class="text-primary">Rp {{ formatRupiah(total) }}</span>
</div>
</div>
<Field>
<FieldLabel for="notes">Keterangan</FieldLabel>
<Textarea id="notes" v-model="form.notes" placeholder="Contoh: Belanja batch 2"
rows="2" />
<FieldError :errors="form.errors.notes ? [form.errors.notes] : []" />
</Field>
<MediaImageUpload id="purchase-photos" v-model="form.media" label="Bukti Transaksi"
:errors="formError('photos') ? [formError('photos')!] : []" />
<Button type="submit" class="w-full" :disabled="form.processing || cart.length === 0">
<Save class="size-4" />
{{ form.processing ? 'Menyimpan...' : submitLabel }}
</Button>
</FieldSet>
</FieldGroup>
</form>
</CardContent>
</Card>
</div>
</template>

View File

@ -0,0 +1,80 @@
<script setup lang="ts">
import { Link, router } from '@inertiajs/vue3';
import { Pencil, Trash2 } from '@lucide/vue';
import { ref } from 'vue';
import { toast } from 'vue-sonner';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useCan } from '@/composables/useCan';
import type { PurchaseListItem } from '@/types/purchase';
const props = defineProps<{
purchase: PurchaseListItem;
}>();
const { can } = useCan();
const deleteConfirmOpen = ref(false);
const deleteProcessing = ref(false);
function destroyPurchase() {
deleteProcessing.value = true;
router.delete(`/admin/manage/purchases/${props.purchase.id}`, {
preserveScroll: true,
onSuccess: () => {
deleteConfirmOpen.value = false;
},
onError: () => {
toast.error('Gagal menghapus belanja.');
},
onFinish: () => {
deleteProcessing.value = false;
},
});
}
</script>
<template>
<div class="flex items-center justify-end gap-1">
<Tooltip v-if="can('purchases.update')">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8" as-child>
<Link :href="`/admin/manage/purchases/${purchase.id}/edit`">
<Pencil class="size-4" />
<span class="sr-only">Ubah</span>
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>Ubah</TooltipContent>
</Tooltip>
<Tooltip v-if="can('purchases.delete')">
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="text-destructive hover:text-destructive size-8"
@click="deleteConfirmOpen = true"
>
<Trash2 class="size-4" />
<span class="sr-only">Hapus</span>
</Button>
</TooltipTrigger>
<TooltipContent>Hapus</TooltipContent>
</Tooltip>
</div>
<ConfirmDialog
v-if="can('purchases.delete')"
v-model:open="deleteConfirmOpen"
title="Hapus belanja?"
:description="`Belanja ${purchase.total_formatted} dari ${purchase.supplier_name} akan dihapus. Stok bahan baku akan dikurangi.`"
confirm-label="Hapus"
cancel-label="Batal"
destructive
:loading="deleteProcessing"
@confirm="destroyPurchase"
/>
</template>

View File

@ -35,7 +35,6 @@ function openPreview(url: string) {
+{{ hiddenCount }}
</span>
</div>
<span v-else class="text-muted-foreground">-</span>
<MediaPreviewDialog v-model:open="previewOpen" :url="previewUrl" />
</template>

View File

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

View File

@ -0,0 +1,61 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { ArrowLeft } from '@lucide/vue';
import { computed } from 'vue';
import PurchasePosForm from '@/components/admin/manage/purchases/PurchasePosForm.vue';
import { Button } from '@/components/ui/button';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { PurchaseCatalogItem, PurchaseEditItem, SelectOption } from '@/types/purchase';
const props = defineProps<{
purchase: PurchaseEditItem;
suppliers: SelectOption[];
catalog: PurchaseCatalogItem[];
}>();
const initialData = computed(() => ({
supplier_id: String(props.purchase.supplier_id),
discount: String(props.purchase.discount),
notes: props.purchase.notes ?? '',
items: props.purchase.items.map((item) => ({
raw_material_price_id: item.raw_material_price_id,
raw_material_name: item.raw_material_name,
variant: item.variant_name,
unit_abbreviation: item.unit_abbreviation,
quantity: item.quantity_input,
unit_price: item.unit_price,
thumb_url: item.raw_material_price?.images?.[0]?.thumb_url ?? null,
})),
photos: props.purchase.photos ?? null,
}));
</script>
<template>
<Head title="Ubah Belanja" />
<AdminLayout title="Ubah Belanja">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<h2 class="text-2xl font-bold tracking-tight">
Ubah Belanja
</h2>
</div>
<Button variant="outline" as-child class="shrink-0 self-start sm:self-center">
<Link href="/admin/manage/purchases">
<ArrowLeft class="size-4" />
Kembali
</Link>
</Button>
</div>
<PurchasePosForm
:suppliers="suppliers"
:catalog="catalog"
:initial-data="initialData"
:submit-url="`/admin/manage/purchases/${purchase.id}`"
method="put"
submit-label="Perbarui"
/>
</AdminLayout>
</template>

View File

@ -0,0 +1,89 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { Plus } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import PurchaseGroupedTable from '@/components/admin/manage/purchases/PurchaseGroupedTable.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 { PaginatedPurchases } from '@/types/purchase';
const props = defineProps<{
purchases: PaginatedPurchases;
filters: {
search: string;
sort?: string;
direction?: 'asc' | 'desc';
};
}>();
const { can } = useCan();
const search = ref(props.filters.search ?? '');
const { setSearch, resetFilters, syncFromServer } = useDataTableQuery({
url: '/admin/manage/purchases',
initial: { ...props.filters },
});
useDataTableQuerySync(() => props.filters, syncFromServer);
const tablePagination = computed(() => ({
currentPage: props.purchases.current_page,
perPage: props.purchases.per_page,
lastPage: props.purchases.last_page,
total: props.purchases.total,
}));
const firstItem = computed(() => (
props.purchases.data.length > 0
? (props.purchases.current_page - 1) * props.purchases.per_page + 1
: 0
));
watch(search, (value) => {
setSearch(value);
});
watch(
() => props.filters.search,
(value) => {
search.value = value ?? '';
},
);
</script>
<template>
<Head title="Belanja" />
<AdminLayout>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<h2 class="text-2xl font-bold tracking-tight">
Belanja
</h2>
</div>
<Button v-if="can('purchases.create')" as-child class="shrink-0 self-start sm:self-center">
<Link href="/admin/manage/purchases/create">
<Plus class="size-4" />
Tambah
</Link>
</Button>
</div>
<Card class="min-w-0">
<CardContent class="min-w-0">
<PurchaseGroupedTable
v-model:search="search"
:purchases="purchases.data"
:first-item="firstItem"
:pagination="tablePagination"
:pagination-links="purchases.links"
@filters-reset="resetFilters"
/>
</CardContent>
</Card>
</AdminLayout>
</template>

View File

@ -0,0 +1,80 @@
import type { MediaItem } from '@/types/media';
import type { RawMaterialListItem } from '@/types/raw-material';
export type SelectOption = {
value: number;
label: string;
};
export type PurchaseItemListItem = {
id: number;
raw_material_name: string;
variant_name: string;
quantity_formatted: string;
unit_price_formatted: string;
subtotal_formatted: string;
};
export type PurchaseListItem = {
id: number;
supplier_name: string;
subtotal_formatted: string;
discount_formatted: string;
total_formatted: string;
notes: string | null;
created_at_formatted: string;
created_by_name: string;
photos?: MediaItem | null;
items: PurchaseItemListItem[];
};
export type PurchaseCartItem = {
raw_material_price_id: number;
raw_material_name: string;
variant: string;
unit_abbreviation: string;
quantity: string;
unit_price: number;
images?: MediaItem[];
};
export type PurchaseFormData = {
supplier_id: string;
discount: string;
notes: string;
items: PurchaseCartItem[];
};
export type PurchaseEditItem = {
id: number;
supplier_id: number;
discount: number;
notes: string | null;
photos?: MediaItem | null;
items: Array<{
raw_material_price_id: number;
raw_material_name: string;
variant_name: string;
unit_abbreviation: string;
quantity_input: string;
unit_price: number;
raw_material_price?: {
images?: MediaItem[];
};
}>;
};
export type PaginatedPurchases = {
data: PurchaseListItem[];
current_page: number;
last_page: number;
per_page: number;
total: number;
links: Array<{
url: string | null;
label: string;
active: boolean;
}>;
};
export type PurchaseCatalogItem = RawMaterialListItem;

View File

@ -13,6 +13,7 @@
use App\Http\Controllers\Admin\Hr\AttendanceController;
use App\Http\Controllers\Admin\Hr\EmployeeController;
use App\Http\Controllers\Admin\Hr\LeaveRequestController;
use App\Http\Controllers\Admin\Manage\PurchaseController;
use App\Http\Controllers\Admin\Master\CategoryController;
use App\Http\Controllers\Admin\Master\CustomerController;
use App\Http\Controllers\Admin\Master\ProductController;
@ -154,6 +155,34 @@
});
Route::prefix('manage')->name('manage.')->group(function () {
Route::prefix('purchases')->name('purchases.')
->middleware('permission:'.Permission::PURCHASES_VIEW->value)
->group(function () {
Route::get('/', [PurchaseController::class, 'index'])->name('index');
Route::get('create', [PurchaseController::class, 'create'])
->middleware('permission:'.Permission::PURCHASES_CREATE->value)
->name('create');
Route::post('/', [PurchaseController::class, 'store'])
->middleware('permission:'.Permission::PURCHASES_CREATE->value)
->name('store');
Route::get('{purchase}/edit', [PurchaseController::class, 'edit'])
->middleware('permission:'.Permission::PURCHASES_UPDATE->value)
->name('edit');
Route::put('{purchase}', [PurchaseController::class, 'update'])
->middleware('permission:'.Permission::PURCHASES_UPDATE->value)
->name('update');
Route::delete('{purchase}', [PurchaseController::class, 'destroy'])
->middleware('permission:'.Permission::PURCHASES_DELETE->value)
->name('destroy');
});
});
Route::prefix('system')->name('system.')->group(function () {
Route::prefix('setting')->name('setting.')
->middleware('permission:'.Permission::SETTINGS_VIEW->value)