feat: add harga_modal price type and related logic for improved stock management and pricing accuracy

This commit is contained in:
Yoga Pangestu 2026-06-21 14:08:53 +07:00
parent 07e9a4ab18
commit 5e71277056
14 changed files with 274 additions and 214 deletions

View File

@ -15,6 +15,7 @@ enum PriceType: string
case ECER = 'ecer';
case TIKTOK = 'tiktok';
case SHOPEE = 'shopee';
case HARGA_MODAL = 'harga_modal';
public function label(): string
{
@ -26,6 +27,7 @@ public function label(): string
self::ECER => 'Eceran',
self::TIKTOK => 'TikTok',
self::SHOPEE => 'Shopee',
self::HARGA_MODAL => 'Harga Modal',
};
}

View File

@ -43,21 +43,4 @@ public function verify(StockVerifyRequest $request, Cutting $cutting): RedirectR
return redirect()->route('admin.manage.stocks.index');
}
public function reject(Request $request, Cutting $cutting): RedirectResponse
{
$request->validate([
'reason' => ['required', 'string', 'max:500'],
]);
$this->stockService->reject(
$cutting,
$request->user(),
$request->validated('reason'),
);
$this->flashSuccess('Cutting berhasil ditolak dan dikembalikan ke proses.');
return redirect()->route('admin.manage.stocks.index');
}
}

View File

@ -2,8 +2,10 @@
namespace App\Http\Middleware;
use App\Enums\CuttingStatus;
use App\Enums\EmployeeAdvanceStatus;
use App\Enums\LeaveRequestStatus;
use App\Models\Cutting;
use App\Models\EmployeeAdvance;
use App\Models\LeaveRequest;
use App\Services\System\Setting\SystemService;
@ -70,6 +72,7 @@ public function share(Request $request): array
'vapidPublicKey' => config('webpush.vapid.public_key'),
'pendingLeaveRequests' => fn () => $this->pendingLeaveRequests($request),
'pendingEmployeeAdvances' => fn () => $this->pendingEmployeeAdvances($request),
'pendingCuttings' => fn () => $this->pendingCuttings($request),
];
}
@ -98,4 +101,17 @@ private function pendingEmployeeAdvances(Request $request): int
->where('status', EmployeeAdvanceStatus::PENDING)
->count();
}
private function pendingCuttings(Request $request): int
{
$user = $request->user();
if ($user === null || ! $user->can('cuttings.view')) {
return 0;
}
return Cutting::query()
->where('status', CuttingStatus::COMPLETED)
->count();
}
}

View File

@ -32,7 +32,7 @@ public function rules(): array
'result_prices.*.product_variant_id' => ['required_with:result_prices', 'integer', 'exists:product_variants,id'],
'result_prices.*.prices' => ['required_with:result_prices', 'array', 'min:1'],
'result_prices.*.prices.*.type' => ['required', Rule::enum(PriceType::class)],
'result_prices.*.prices.*.price' => ['required', 'integer', 'gt:0'],
'result_prices.*.prices.*.price' => ['required', 'integer', 'min:0'],
];
}

View File

@ -5,6 +5,7 @@
use App\Enums\CuttingStatus;
use App\Models\Cutting;
use App\Models\CuttingResultPrice;
use App\Models\ProductPrice;
use App\Models\ProductVariant;
use App\Models\RawMaterialPrice;
use App\Models\User;
@ -101,6 +102,7 @@ public function verify(
}
$this->applyProductStockOnVerify($cutting);
$this->storeResultPrices($cutting, $resultPrices ?? []);
if ($verificationNote !== null && trim($verificationNote) !== '') {
@ -190,18 +192,49 @@ private function deductRemainingMaterialStock(Cutting $cutting): void
*/
private function storeResultPrices(Cutting $cutting, array $resultPrices): void
{
$costPerUnit = (int) ($cutting->cost_per_unit ?? 0);
// Extract harga_modal from the first variant's prices and set as cost_per_unit
$costPerUnit = 0;
foreach ($resultPrices as $resultData) {
foreach ($resultData['prices'] as $priceData) {
if ($priceData['type'] === 'harga_modal' && (int) $priceData['price'] > 0) {
$costPerUnit = (int) $priceData['price'];
break 2;
}
}
}
if ($costPerUnit > 0) {
$cutting->cost_per_unit = $costPerUnit;
} else {
$costPerUnit = (int) ($cutting->cost_per_unit ?? 0);
}
foreach ($resultPrices as $resultData) {
foreach ($resultData['prices'] as $priceData) {
if ((int) $priceData['price'] > 0) {
CuttingResultPrice::query()->create([
'cutting_id' => $cutting->id,
'product_variant_id' => $resultData['product_variant_id'],
'price_type' => $priceData['type'],
'price' => (int) $priceData['price'],
'cost_per_unit' => $costPerUnit,
]);
// Store/update to cutting_result_prices
CuttingResultPrice::query()->updateOrCreate(
[
'cutting_id' => $cutting->id,
'product_variant_id' => $resultData['product_variant_id'],
'price_type' => $priceData['type'],
],
[
'price' => (int) $priceData['price'],
'cost_per_unit' => $costPerUnit,
],
);
// Store/update in product_prices
ProductPrice::query()->updateOrCreate(
[
'variant_id' => $resultData['product_variant_id'],
'type' => $priceData['type'],
],
[
'price' => (int) $priceData['price'],
],
);
}
}
}

View File

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::statement("ALTER TABLE cutting_result_prices MODIFY COLUMN price_type ENUM('distributor', 'agent', 'sub_agent', 'grosir', 'ecer', 'tiktok', 'shopee', 'harga_modal') NOT NULL");
}
/**
* Reverse the migrations.
*/
public function down(): void
{
DB::statement("ALTER TABLE cutting_result_prices MODIFY COLUMN price_type ENUM('distributor', 'agent', 'sub_agent', 'grosir', 'ecer', 'tiktok', 'shopee') NOT NULL");
}
};

View File

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::statement("ALTER TABLE orders MODIFY COLUMN price_type ENUM('distributor', 'agent', 'sub_agent', 'grosir', 'ecer', 'tiktok', 'shopee', 'harga_modal') NOT NULL");
}
/**
* Reverse the migrations.
*/
public function down(): void
{
DB::statement("ALTER TABLE orders MODIFY COLUMN price_type ENUM('distributor', 'agent', 'sub_agent', 'grosir', 'ecer', 'tiktok', 'shopee') NOT NULL");
}
};

View File

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::statement("ALTER TABLE product_prices MODIFY COLUMN type ENUM('distributor', 'agent', 'sub_agent', 'grosir', 'ecer', 'tiktok', 'shopee', 'harga_modal') NOT NULL");
}
/**
* Reverse the migrations.
*/
public function down(): void
{
DB::statement("ALTER TABLE product_prices MODIFY COLUMN type ENUM('distributor', 'agent', 'sub_agent', 'grosir', 'ecer', 'tiktok', 'shopee') NOT NULL");
}
};

View File

@ -24,7 +24,7 @@ interface MenuItem {
href: string;
icon: any;
permission?: string;
badgeKey?: 'pendingLeaveRequests' | 'pendingEmployeeAdvances';
badgeKey?: 'pendingLeaveRequests' | 'pendingEmployeeAdvances' | 'pendingCuttings';
}
interface MenuGroup {
@ -54,7 +54,7 @@ const menuGroups: MenuGroup[] = [
items: [
{ title: 'Belanja', href: '/admin/manage/purchases', icon: ShoppingBag, permission: 'purchases.view' },
{ title: 'Cutting', href: '/admin/manage/cuttings', icon: Scissors, permission: 'cuttings.view' },
{ title: 'Stok', href: '/admin/manage/stocks', icon: Warehouse, permission: 'cuttings.view' },
{ title: 'Stok', href: '/admin/manage/stocks', icon: Warehouse, permission: 'cuttings.view', badgeKey: 'pendingCuttings' },
{ title: 'Pesanan', href: '/admin/manage/orders', icon: ShoppingCart, permission: 'orders.view' },
],
},

View File

@ -18,9 +18,6 @@ defineProps<{
<h2 class="text-2xl font-bold tracking-tight">
Verifikasi Stok
</h2>
<p class="text-muted-foreground text-sm">
Verifikasi hasil cutting untuk menambahkan stok produk ke toko.
</p>
</div>
</div>

View File

@ -10,15 +10,6 @@ defineProps<{
<template>
<div v-if="cuttings.length" class="space-y-4">
<div class="flex items-center gap-2">
<h3 class="text-lg font-semibold">
Menunggu Verifikasi
</h3>
<span class="bg-primary text-primary-foreground inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium">
{{ cuttings.length }}
</span>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="cutting in cuttings" :key="cutting.id"
class="flex flex-col justify-between overflow-hidden border bg-card/50 py-0 gap-0">

View File

@ -1,10 +1,8 @@
<script setup lang="ts">
import { router, useForm } from '@inertiajs/vue3';
import { Check, X } from '@lucide/vue';
import { Check, Copy } from '@lucide/vue';
import { ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
@ -16,12 +14,11 @@ import {
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import {
@ -44,17 +41,19 @@ const props = defineProps<{
const { can } = useCan();
const rejectDialogOpen = ref(false);
const verifyDialogOpen = ref(false);
const allMatches = ref(true);
const useSamePrice = ref(true);
function buildEmptyPrices(): Record<string, string> {
return Object.fromEntries(PRICE_TYPES.map((type) => [type, '']));
}
const rejectForm = useForm({
reason: '',
});
interface VariantPriceRow {
product_variant_id: number;
name: string;
prices: Record<string, string>;
}
const verifyForm = useForm({
verification_note: '',
@ -66,17 +65,15 @@ const verifyForm = useForm({
cutting_reject: res.cutting_reject,
original_warehouse_stock: res.warehouse_stock,
original_cutting_reject: res.cutting_reject,
prices: buildEmptyPrices(),
})),
result_prices: [] as Array<{
product_variant_id: number;
prices: Array<{ type: string; price: number }>;
}>,
variant_prices: [] as VariantPriceRow[],
shared_prices: buildEmptyPrices(),
});
watch(verifyDialogOpen, (isOpen) => {
if (isOpen) {
allMatches.value = true;
useSamePrice.value = true;
verifyForm.verification_note = '';
verifyForm.results = props.cutting.results.map(res => ({
product_variant_id: res.product_variant?.id || 0,
@ -86,9 +83,13 @@ watch(verifyDialogOpen, (isOpen) => {
cutting_reject: res.cutting_reject,
original_warehouse_stock: res.warehouse_stock,
original_cutting_reject: res.cutting_reject,
}));
verifyForm.variant_prices = props.cutting.results.map(res => ({
product_variant_id: res.product_variant?.id || 0,
name: `${res.product_variant?.product?.name || ''} (${res.product_variant?.name || ''})`,
prices: buildEmptyPrices(),
}));
verifyForm.result_prices = [];
verifyForm.shared_prices = buildEmptyPrices();
verifyForm.clearErrors();
}
});
@ -117,24 +118,55 @@ function setAllMatches(value: boolean) {
}
}
function setResultPrice(resultIndex: number, type: string, value: string) {
const result = verifyForm.results[resultIndex];
if (!result) {
function toggleUseSamePrice(checked: boolean) {
useSamePrice.value = checked;
if (!checked || !verifyForm.variant_prices[0]) {
return;
}
result.prices = {
...result.prices,
[type]: value,
};
const source = verifyForm.variant_prices[0].prices;
verifyForm.variant_prices = verifyForm.variant_prices.map((row) => ({
...row,
prices: { ...source },
}));
}
function setSharedPrice(type: string, value: string) {
verifyForm.shared_prices[type] = value;
verifyForm.variant_prices = verifyForm.variant_prices.map((row) => ({
...row,
prices: { ...row.prices, [type]: value },
}));
}
function setVariantPrice(index: number, type: string, value: string) {
const row = verifyForm.variant_prices[index];
if (!row) {
return;
}
row.prices[type] = value;
}
function applyPriceToAll(index: number) {
const source = verifyForm.variant_prices[index];
if (!source) {
return;
}
verifyForm.variant_prices = verifyForm.variant_prices.map((row) => ({
...row,
prices: { ...source.prices },
}));
}
function buildResultPricesPayload() {
return verifyForm.results.map((result) => ({
product_variant_id: result.product_variant_id,
return verifyForm.variant_prices.map((row) => ({
product_variant_id: row.product_variant_id,
prices: PRICE_TYPES.map((type) => ({
type,
price: Number.parseInt(parseRupiah(result.prices[type] ?? ''), 10) || 0,
price: Number.parseInt(parseRupiah(row.prices[type] ?? ''), 10) || 0,
})),
}));
}
@ -166,120 +198,26 @@ function submitVerify() {
},
});
}
function submitReject() {
rejectForm.post(`/admin/manage/stocks/${props.cutting.id}/reject`, {
preserveScroll: true,
onSuccess: () => {
rejectDialogOpen.value = false;
},
onError: (errors) => {
const message = Object.values(errors)[0];
toast.error(
typeof message === 'string'
? message
: 'Gagal menolak cutting.',
);
},
});
}
watch(rejectDialogOpen, (isOpen) => {
if (!isOpen) {
rejectForm.reset();
rejectForm.clearErrors();
}
});
</script>
<template>
<div class="flex flex-wrap items-center justify-end gap-1">
<Tooltip v-if="can('cuttings.verify')">
<TooltipTrigger as-child>
<Button
size="sm"
variant="ghost"
class="text-green-600 hover:text-green-700"
@click="verifyDialogOpen = true"
>
<Check class="size-3.5" />
<span class="sr-only">Verifikasi</span>
</Button>
</TooltipTrigger>
<TooltipContent>Verifikasi & Tambah Stok</TooltipContent>
</Tooltip>
<Tooltip v-if="can('cuttings.reject')">
<TooltipTrigger as-child>
<Button
size="sm"
variant="ghost"
class="text-destructive hover:text-destructive"
@click="rejectDialogOpen = true"
>
<X class="size-3.5" />
<span class="sr-only">Tolak</span>
</Button>
</TooltipTrigger>
<TooltipContent>Tolak Verifikasi</TooltipContent>
</Tooltip>
</div>
<Dialog v-model:open="rejectDialogOpen">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Tolak Verifikasi Cutting</DialogTitle>
</DialogHeader>
<form @submit.prevent="submitReject">
<FieldGroup>
<FieldSet class="grid gap-4">
<Field>
<FieldLabel for="stock-reject-reason" required
>Alasan Penolakan</FieldLabel
>
<Textarea
id="stock-reject-reason"
v-model="rejectForm.reason"
placeholder="Contoh: Jumlah barang yang diterima tidak sesuai"
rows="3"
autofocus
/>
<FieldError
:errors="
rejectForm.errors.reason
? [rejectForm.errors.reason]
: []
"
/>
</Field>
</FieldSet>
</FieldGroup>
<DialogFooter class="mt-6">
<Button
type="button"
variant="outline"
:disabled="rejectForm.processing"
@click="rejectDialogOpen = false"
>
Batal
</Button>
<Button
type="submit"
variant="destructive"
:disabled="rejectForm.processing"
>
{{ rejectForm.processing ? 'Menyimpan...' : 'Tolak' }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Tooltip v-if="can('cuttings.verify')">
<TooltipTrigger as-child>
<Button
size="sm"
variant="ghost"
class="text-green-600 hover:text-green-700"
@click="verifyDialogOpen = true"
>
<Check class="size-3.5" />
<span class="sr-only">Verifikasi</span>
</Button>
</TooltipTrigger>
<TooltipContent>Verifikasi & Tambah Stok</TooltipContent>
</Tooltip>
<Dialog v-model:open="verifyDialogOpen">
<DialogContent class="sm:max-w-2xl">
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Verifikasi Hasil Cutting</DialogTitle>
</DialogHeader>
@ -287,7 +225,7 @@ watch(rejectDialogOpen, (isOpen) => {
<form @submit.prevent="submitVerify">
<div class="space-y-4 max-h-[60vh] overflow-y-auto scrollbar-thin px-1 py-1">
<p class="text-sm text-muted-foreground">
Verifikasi jumlah produk yang diterima di toko dan tentukan harga jual per varian. Stok akan ditambahkan setelah verifikasi.
Verifikasi jumlah produk yang diterima dan tentukan harga jual. Stok akan ditambahkan setelah verifikasi.
</p>
<div class="flex items-center justify-between rounded-lg border p-3">
@ -301,68 +239,101 @@ watch(rejectDialogOpen, (isOpen) => {
/>
</div>
<div class="space-y-3">
<div v-for="(result, index) in verifyForm.results" :key="result.product_variant_id" class="p-3 border rounded-lg space-y-3">
<div class="font-medium text-sm">
{{ result.name }}
<div class="space-y-2">
<div v-for="(result, index) in verifyForm.results" :key="result.product_variant_id" class="flex items-center gap-3 rounded-lg border p-3">
<div class="flex-1 min-w-0">
<p class="font-medium text-sm truncate">{{ result.name }}</p>
<p class="text-xs text-muted-foreground">
{{ result.cutting_result }} pcs
<span class="text-[10px]">({{ result.original_warehouse_stock }} bagus, {{ result.original_cutting_reject }} reject)</span>
</p>
</div>
<div class="grid grid-cols-3 gap-2 items-center text-xs">
<div>
<span class="text-muted-foreground block">Hasil Potong:</span>
<span class="font-semibold">{{ result.cutting_result }} pcs</span>
</div>
<div>
<span class="text-muted-foreground block mb-0.5">Data cutting:</span>
<span class="font-medium tabular-nums">
{{ result.original_warehouse_stock }} bagus ·
{{ result.original_cutting_reject }} reject
</span>
</div>
<div>
<span class="text-muted-foreground block">Stok Reject:</span>
<Badge variant="secondary" class="font-semibold">
{{ result.cutting_reject }} pcs
</Badge>
</div>
</div>
<div class="grid grid-cols-2 gap-2 items-end text-xs">
<div>
<label :for="`stock-good-${index}`" class="text-muted-foreground block mb-0.5">Stok Bagus (diterima):</label>
<div class="flex items-center gap-2 shrink-0">
<div class="text-center">
<label :for="`stock-good-${index}`" class="text-[10px] text-muted-foreground block">Bagus</label>
<Input
:id="`stock-good-${index}`"
type="number"
v-model.number="result.warehouse_stock"
min="0"
:max="result.cutting_result"
class="h-8 w-full px-2 text-xs"
class="h-7 w-16 px-1.5 text-xs text-center"
:disabled="allMatches"
@input="result.cutting_reject = result.cutting_result - result.warehouse_stock"
/>
</div>
<div>
<label :for="`stock-reject-${index}`" class="text-muted-foreground block mb-0.5">Stok Reject (diterima):</label>
<div class="text-center">
<label :for="`stock-reject-${index}`" class="text-[10px] text-muted-foreground block">Reject</label>
<Input
:id="`stock-reject-${index}`"
type="number"
v-model.number="result.cutting_reject"
min="0"
:max="result.cutting_result"
class="h-8 w-full px-2 text-xs"
class="h-7 w-16 px-1.5 text-xs text-center"
:disabled="allMatches"
@input="result.warehouse_stock = result.cutting_result - result.cutting_reject"
/>
</div>
</div>
</div>
</div>
<Separator />
<div v-if="verifyForm.variant_prices.length > 1" class="flex items-center justify-between rounded-lg border p-3">
<Label for="use-same-price" class="text-sm font-medium">
Gunakan harga yang sama untuk semua varian
</Label>
<Switch
id="use-same-price"
:model-value="useSamePrice"
@update:model-value="toggleUseSamePrice"
/>
</div>
<div v-if="useSamePrice" class="space-y-2">
<p class="text-sm font-medium">Harga</p>
<div class="grid gap-2 sm:grid-cols-2">
<Field v-for="type in PRICE_TYPES" :key="`shared-${type}`">
<FieldLabel class="text-xs" :for="`shared-price-${type}`">
{{ PRICE_TYPE_LABELS[type] }}
</FieldLabel>
<RupiahInput
:id="`shared-price-${type}`"
:model-value="verifyForm.shared_prices[type]"
@update:model-value="setSharedPrice(type, $event)"
/>
</Field>
</div>
</div>
<div v-else class="space-y-3">
<p class="text-sm font-medium">Harga Per Varian</p>
<div v-for="(row, index) in verifyForm.variant_prices" :key="row.product_variant_id" class="space-y-2 rounded-lg border p-3">
<div class="flex items-center justify-between">
<p class="text-sm font-medium truncate">{{ row.name }}</p>
<Button
v-if="verifyForm.variant_prices.length > 1"
type="button"
variant="outline"
size="sm"
class="shrink-0"
@click="applyPriceToAll(index)"
>
<Copy class="size-3" />
Terapkan ke Semua
</Button>
</div>
<div class="grid gap-2 sm:grid-cols-2">
<Field v-for="type in PRICE_TYPES" :key="`${result.product_variant_id}-${type}`">
<FieldLabel class="text-xs" :for="`stock-price-${index}-${type}`">
<Field v-for="type in PRICE_TYPES" :key="`${row.product_variant_id}-${type}`">
<FieldLabel class="text-xs" :for="`variant-price-${index}-${type}`">
{{ PRICE_TYPE_LABELS[type] }}
</FieldLabel>
<RupiahInput
:id="`stock-price-${index}-${type}`"
:model-value="result.prices[type]"
@update:model-value="setResultPrice(index, type, $event)"
:id="`variant-price-${index}-${type}`"
:model-value="row.prices[type]"
@update:model-value="setVariantPrice(index, type, $event)"
/>
</Field>
</div>

View File

@ -82,6 +82,7 @@ export type PaginatedProducts = {
};
export const PRICE_TYPES = [
'harga_modal',
'distributor',
'agent',
'sub_agent',
@ -94,6 +95,7 @@ export const PRICE_TYPES = [
export type PriceType = (typeof PRICE_TYPES)[number];
export const PRICE_TYPE_LABELS: Record<PriceType, string> = {
harga_modal: 'Harga Modal',
distributor: 'Distributor',
agent: 'Agen',
sub_agent: 'Sub Agen',

View File

@ -305,10 +305,6 @@
Route::post('{cutting}/verify', [StockController::class, 'verify'])
->middleware('permission:'.Permission::CUTTINGS_VERIFY->value)
->name('verify');
Route::post('{cutting}/reject', [StockController::class, 'reject'])
->middleware('permission:'.Permission::CUTTINGS_REJECT->value)
->name('reject');
});
});