feat: add harga_modal price type and related logic for improved stock management and pricing accuracy
This commit is contained in:
parent
07e9a4ab18
commit
5e71277056
@ -15,6 +15,7 @@ enum PriceType: string
|
|||||||
case ECER = 'ecer';
|
case ECER = 'ecer';
|
||||||
case TIKTOK = 'tiktok';
|
case TIKTOK = 'tiktok';
|
||||||
case SHOPEE = 'shopee';
|
case SHOPEE = 'shopee';
|
||||||
|
case HARGA_MODAL = 'harga_modal';
|
||||||
|
|
||||||
public function label(): string
|
public function label(): string
|
||||||
{
|
{
|
||||||
@ -26,6 +27,7 @@ public function label(): string
|
|||||||
self::ECER => 'Eceran',
|
self::ECER => 'Eceran',
|
||||||
self::TIKTOK => 'TikTok',
|
self::TIKTOK => 'TikTok',
|
||||||
self::SHOPEE => 'Shopee',
|
self::SHOPEE => 'Shopee',
|
||||||
|
self::HARGA_MODAL => 'Harga Modal',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -43,21 +43,4 @@ public function verify(StockVerifyRequest $request, Cutting $cutting): RedirectR
|
|||||||
|
|
||||||
return redirect()->route('admin.manage.stocks.index');
|
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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Http\Middleware;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Enums\CuttingStatus;
|
||||||
use App\Enums\EmployeeAdvanceStatus;
|
use App\Enums\EmployeeAdvanceStatus;
|
||||||
use App\Enums\LeaveRequestStatus;
|
use App\Enums\LeaveRequestStatus;
|
||||||
|
use App\Models\Cutting;
|
||||||
use App\Models\EmployeeAdvance;
|
use App\Models\EmployeeAdvance;
|
||||||
use App\Models\LeaveRequest;
|
use App\Models\LeaveRequest;
|
||||||
use App\Services\System\Setting\SystemService;
|
use App\Services\System\Setting\SystemService;
|
||||||
@ -70,6 +72,7 @@ public function share(Request $request): array
|
|||||||
'vapidPublicKey' => config('webpush.vapid.public_key'),
|
'vapidPublicKey' => config('webpush.vapid.public_key'),
|
||||||
'pendingLeaveRequests' => fn () => $this->pendingLeaveRequests($request),
|
'pendingLeaveRequests' => fn () => $this->pendingLeaveRequests($request),
|
||||||
'pendingEmployeeAdvances' => fn () => $this->pendingEmployeeAdvances($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)
|
->where('status', EmployeeAdvanceStatus::PENDING)
|
||||||
->count();
|
->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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,7 +32,7 @@ public function rules(): array
|
|||||||
'result_prices.*.product_variant_id' => ['required_with:result_prices', 'integer', 'exists:product_variants,id'],
|
'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' => ['required_with:result_prices', 'array', 'min:1'],
|
||||||
'result_prices.*.prices.*.type' => ['required', Rule::enum(PriceType::class)],
|
'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'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
use App\Enums\CuttingStatus;
|
use App\Enums\CuttingStatus;
|
||||||
use App\Models\Cutting;
|
use App\Models\Cutting;
|
||||||
use App\Models\CuttingResultPrice;
|
use App\Models\CuttingResultPrice;
|
||||||
|
use App\Models\ProductPrice;
|
||||||
use App\Models\ProductVariant;
|
use App\Models\ProductVariant;
|
||||||
use App\Models\RawMaterialPrice;
|
use App\Models\RawMaterialPrice;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@ -101,6 +102,7 @@ public function verify(
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->applyProductStockOnVerify($cutting);
|
$this->applyProductStockOnVerify($cutting);
|
||||||
|
|
||||||
$this->storeResultPrices($cutting, $resultPrices ?? []);
|
$this->storeResultPrices($cutting, $resultPrices ?? []);
|
||||||
|
|
||||||
if ($verificationNote !== null && trim($verificationNote) !== '') {
|
if ($verificationNote !== null && trim($verificationNote) !== '') {
|
||||||
@ -190,18 +192,49 @@ private function deductRemainingMaterialStock(Cutting $cutting): void
|
|||||||
*/
|
*/
|
||||||
private function storeResultPrices(Cutting $cutting, array $resultPrices): 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 ($resultPrices as $resultData) {
|
||||||
foreach ($resultData['prices'] as $priceData) {
|
foreach ($resultData['prices'] as $priceData) {
|
||||||
if ((int) $priceData['price'] > 0) {
|
if ((int) $priceData['price'] > 0) {
|
||||||
CuttingResultPrice::query()->create([
|
// Store/update to cutting_result_prices
|
||||||
'cutting_id' => $cutting->id,
|
CuttingResultPrice::query()->updateOrCreate(
|
||||||
'product_variant_id' => $resultData['product_variant_id'],
|
[
|
||||||
'price_type' => $priceData['type'],
|
'cutting_id' => $cutting->id,
|
||||||
'price' => (int) $priceData['price'],
|
'product_variant_id' => $resultData['product_variant_id'],
|
||||||
'cost_per_unit' => $costPerUnit,
|
'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'],
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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");
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -24,7 +24,7 @@ interface MenuItem {
|
|||||||
href: string;
|
href: string;
|
||||||
icon: any;
|
icon: any;
|
||||||
permission?: string;
|
permission?: string;
|
||||||
badgeKey?: 'pendingLeaveRequests' | 'pendingEmployeeAdvances';
|
badgeKey?: 'pendingLeaveRequests' | 'pendingEmployeeAdvances' | 'pendingCuttings';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MenuGroup {
|
interface MenuGroup {
|
||||||
@ -54,7 +54,7 @@ const menuGroups: MenuGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ title: 'Belanja', href: '/admin/manage/purchases', icon: ShoppingBag, permission: 'purchases.view' },
|
{ title: 'Belanja', href: '/admin/manage/purchases', icon: ShoppingBag, permission: 'purchases.view' },
|
||||||
{ title: 'Cutting', href: '/admin/manage/cuttings', icon: Scissors, permission: 'cuttings.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' },
|
{ title: 'Pesanan', href: '/admin/manage/orders', icon: ShoppingCart, permission: 'orders.view' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -18,9 +18,6 @@ defineProps<{
|
|||||||
<h2 class="text-2xl font-bold tracking-tight">
|
<h2 class="text-2xl font-bold tracking-tight">
|
||||||
Verifikasi Stok
|
Verifikasi Stok
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
Verifikasi hasil cutting untuk menambahkan stok produk ke toko.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -10,15 +10,6 @@ defineProps<{
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="cuttings.length" class="space-y-4">
|
<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">
|
<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"
|
<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">
|
class="flex flex-col justify-between overflow-hidden border bg-card/50 py-0 gap-0">
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { router, useForm } from '@inertiajs/vue3';
|
import { router, useForm } from '@inertiajs/vue3';
|
||||||
import { Check, X } from '@lucide/vue';
|
import { Check, Copy } from '@lucide/vue';
|
||||||
import { ref, watch } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -16,12 +14,11 @@ import {
|
|||||||
import {
|
import {
|
||||||
Field,
|
Field,
|
||||||
FieldError,
|
FieldError,
|
||||||
FieldGroup,
|
|
||||||
FieldLabel,
|
FieldLabel,
|
||||||
FieldSet,
|
|
||||||
} from '@/components/ui/field';
|
} from '@/components/ui/field';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import {
|
import {
|
||||||
@ -44,17 +41,19 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const { can } = useCan();
|
const { can } = useCan();
|
||||||
|
|
||||||
const rejectDialogOpen = ref(false);
|
|
||||||
const verifyDialogOpen = ref(false);
|
const verifyDialogOpen = ref(false);
|
||||||
const allMatches = ref(true);
|
const allMatches = ref(true);
|
||||||
|
const useSamePrice = ref(true);
|
||||||
|
|
||||||
function buildEmptyPrices(): Record<string, string> {
|
function buildEmptyPrices(): Record<string, string> {
|
||||||
return Object.fromEntries(PRICE_TYPES.map((type) => [type, '']));
|
return Object.fromEntries(PRICE_TYPES.map((type) => [type, '']));
|
||||||
}
|
}
|
||||||
|
|
||||||
const rejectForm = useForm({
|
interface VariantPriceRow {
|
||||||
reason: '',
|
product_variant_id: number;
|
||||||
});
|
name: string;
|
||||||
|
prices: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
const verifyForm = useForm({
|
const verifyForm = useForm({
|
||||||
verification_note: '',
|
verification_note: '',
|
||||||
@ -66,17 +65,15 @@ const verifyForm = useForm({
|
|||||||
cutting_reject: res.cutting_reject,
|
cutting_reject: res.cutting_reject,
|
||||||
original_warehouse_stock: res.warehouse_stock,
|
original_warehouse_stock: res.warehouse_stock,
|
||||||
original_cutting_reject: res.cutting_reject,
|
original_cutting_reject: res.cutting_reject,
|
||||||
prices: buildEmptyPrices(),
|
|
||||||
})),
|
})),
|
||||||
result_prices: [] as Array<{
|
variant_prices: [] as VariantPriceRow[],
|
||||||
product_variant_id: number;
|
shared_prices: buildEmptyPrices(),
|
||||||
prices: Array<{ type: string; price: number }>;
|
|
||||||
}>,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(verifyDialogOpen, (isOpen) => {
|
watch(verifyDialogOpen, (isOpen) => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
allMatches.value = true;
|
allMatches.value = true;
|
||||||
|
useSamePrice.value = true;
|
||||||
verifyForm.verification_note = '';
|
verifyForm.verification_note = '';
|
||||||
verifyForm.results = props.cutting.results.map(res => ({
|
verifyForm.results = props.cutting.results.map(res => ({
|
||||||
product_variant_id: res.product_variant?.id || 0,
|
product_variant_id: res.product_variant?.id || 0,
|
||||||
@ -86,9 +83,13 @@ watch(verifyDialogOpen, (isOpen) => {
|
|||||||
cutting_reject: res.cutting_reject,
|
cutting_reject: res.cutting_reject,
|
||||||
original_warehouse_stock: res.warehouse_stock,
|
original_warehouse_stock: res.warehouse_stock,
|
||||||
original_cutting_reject: res.cutting_reject,
|
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(),
|
prices: buildEmptyPrices(),
|
||||||
}));
|
}));
|
||||||
verifyForm.result_prices = [];
|
verifyForm.shared_prices = buildEmptyPrices();
|
||||||
verifyForm.clearErrors();
|
verifyForm.clearErrors();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -117,24 +118,55 @@ function setAllMatches(value: boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setResultPrice(resultIndex: number, type: string, value: string) {
|
function toggleUseSamePrice(checked: boolean) {
|
||||||
const result = verifyForm.results[resultIndex];
|
useSamePrice.value = checked;
|
||||||
if (!result) {
|
|
||||||
|
if (!checked || !verifyForm.variant_prices[0]) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
result.prices = {
|
const source = verifyForm.variant_prices[0].prices;
|
||||||
...result.prices,
|
verifyForm.variant_prices = verifyForm.variant_prices.map((row) => ({
|
||||||
[type]: value,
|
...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() {
|
function buildResultPricesPayload() {
|
||||||
return verifyForm.results.map((result) => ({
|
return verifyForm.variant_prices.map((row) => ({
|
||||||
product_variant_id: result.product_variant_id,
|
product_variant_id: row.product_variant_id,
|
||||||
prices: PRICE_TYPES.map((type) => ({
|
prices: PRICE_TYPES.map((type) => ({
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-wrap items-center justify-end gap-1">
|
<Tooltip v-if="can('cuttings.verify')">
|
||||||
<Tooltip v-if="can('cuttings.verify')">
|
<TooltipTrigger as-child>
|
||||||
<TooltipTrigger as-child>
|
<Button
|
||||||
<Button
|
size="sm"
|
||||||
size="sm"
|
variant="ghost"
|
||||||
variant="ghost"
|
class="text-green-600 hover:text-green-700"
|
||||||
class="text-green-600 hover:text-green-700"
|
@click="verifyDialogOpen = true"
|
||||||
@click="verifyDialogOpen = true"
|
>
|
||||||
>
|
<Check class="size-3.5" />
|
||||||
<Check class="size-3.5" />
|
<span class="sr-only">Verifikasi</span>
|
||||||
<span class="sr-only">Verifikasi</span>
|
</Button>
|
||||||
</Button>
|
</TooltipTrigger>
|
||||||
</TooltipTrigger>
|
<TooltipContent>Verifikasi & Tambah Stok</TooltipContent>
|
||||||
<TooltipContent>Verifikasi & Tambah Stok</TooltipContent>
|
</Tooltip>
|
||||||
</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>
|
|
||||||
|
|
||||||
<Dialog v-model:open="verifyDialogOpen">
|
<Dialog v-model:open="verifyDialogOpen">
|
||||||
<DialogContent class="sm:max-w-2xl">
|
<DialogContent class="sm:max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Verifikasi Hasil Cutting</DialogTitle>
|
<DialogTitle>Verifikasi Hasil Cutting</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@ -287,7 +225,7 @@ watch(rejectDialogOpen, (isOpen) => {
|
|||||||
<form @submit.prevent="submitVerify">
|
<form @submit.prevent="submitVerify">
|
||||||
<div class="space-y-4 max-h-[60vh] overflow-y-auto scrollbar-thin px-1 py-1">
|
<div class="space-y-4 max-h-[60vh] overflow-y-auto scrollbar-thin px-1 py-1">
|
||||||
<p class="text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
|
|
||||||
<div class="flex items-center justify-between rounded-lg border p-3">
|
<div class="flex items-center justify-between rounded-lg border p-3">
|
||||||
@ -301,68 +239,101 @@ watch(rejectDialogOpen, (isOpen) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-2">
|
||||||
<div v-for="(result, index) in verifyForm.results" :key="result.product_variant_id" class="p-3 border rounded-lg space-y-3">
|
<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="font-medium text-sm">
|
<div class="flex-1 min-w-0">
|
||||||
{{ result.name }}
|
<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>
|
||||||
<div class="grid grid-cols-3 gap-2 items-center text-xs">
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
<div>
|
<div class="text-center">
|
||||||
<span class="text-muted-foreground block">Hasil Potong:</span>
|
<label :for="`stock-good-${index}`" class="text-[10px] text-muted-foreground block">Bagus</label>
|
||||||
<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>
|
|
||||||
<Input
|
<Input
|
||||||
:id="`stock-good-${index}`"
|
:id="`stock-good-${index}`"
|
||||||
type="number"
|
type="number"
|
||||||
v-model.number="result.warehouse_stock"
|
v-model.number="result.warehouse_stock"
|
||||||
min="0"
|
min="0"
|
||||||
:max="result.cutting_result"
|
: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"
|
:disabled="allMatches"
|
||||||
@input="result.cutting_reject = result.cutting_result - result.warehouse_stock"
|
@input="result.cutting_reject = result.cutting_result - result.warehouse_stock"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="text-center">
|
||||||
<label :for="`stock-reject-${index}`" class="text-muted-foreground block mb-0.5">Stok Reject (diterima):</label>
|
<label :for="`stock-reject-${index}`" class="text-[10px] text-muted-foreground block">Reject</label>
|
||||||
<Input
|
<Input
|
||||||
:id="`stock-reject-${index}`"
|
:id="`stock-reject-${index}`"
|
||||||
type="number"
|
type="number"
|
||||||
v-model.number="result.cutting_reject"
|
v-model.number="result.cutting_reject"
|
||||||
min="0"
|
min="0"
|
||||||
:max="result.cutting_result"
|
: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"
|
:disabled="allMatches"
|
||||||
@input="result.warehouse_stock = result.cutting_result - result.cutting_reject"
|
@input="result.warehouse_stock = result.cutting_result - result.cutting_reject"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="grid gap-2 sm:grid-cols-2">
|
||||||
<Field v-for="type in PRICE_TYPES" :key="`${result.product_variant_id}-${type}`">
|
<Field v-for="type in PRICE_TYPES" :key="`${row.product_variant_id}-${type}`">
|
||||||
<FieldLabel class="text-xs" :for="`stock-price-${index}-${type}`">
|
<FieldLabel class="text-xs" :for="`variant-price-${index}-${type}`">
|
||||||
{{ PRICE_TYPE_LABELS[type] }}
|
{{ PRICE_TYPE_LABELS[type] }}
|
||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
<RupiahInput
|
<RupiahInput
|
||||||
:id="`stock-price-${index}-${type}`"
|
:id="`variant-price-${index}-${type}`"
|
||||||
:model-value="result.prices[type]"
|
:model-value="row.prices[type]"
|
||||||
@update:model-value="setResultPrice(index, type, $event)"
|
@update:model-value="setVariantPrice(index, type, $event)"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -82,6 +82,7 @@ export type PaginatedProducts = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PRICE_TYPES = [
|
export const PRICE_TYPES = [
|
||||||
|
'harga_modal',
|
||||||
'distributor',
|
'distributor',
|
||||||
'agent',
|
'agent',
|
||||||
'sub_agent',
|
'sub_agent',
|
||||||
@ -94,6 +95,7 @@ export const PRICE_TYPES = [
|
|||||||
export type PriceType = (typeof PRICE_TYPES)[number];
|
export type PriceType = (typeof PRICE_TYPES)[number];
|
||||||
|
|
||||||
export const PRICE_TYPE_LABELS: Record<PriceType, string> = {
|
export const PRICE_TYPE_LABELS: Record<PriceType, string> = {
|
||||||
|
harga_modal: 'Harga Modal',
|
||||||
distributor: 'Distributor',
|
distributor: 'Distributor',
|
||||||
agent: 'Agen',
|
agent: 'Agen',
|
||||||
sub_agent: 'Sub Agen',
|
sub_agent: 'Sub Agen',
|
||||||
|
|||||||
@ -305,10 +305,6 @@
|
|||||||
Route::post('{cutting}/verify', [StockController::class, 'verify'])
|
Route::post('{cutting}/verify', [StockController::class, 'verify'])
|
||||||
->middleware('permission:'.Permission::CUTTINGS_VERIFY->value)
|
->middleware('permission:'.Permission::CUTTINGS_VERIFY->value)
|
||||||
->name('verify');
|
->name('verify');
|
||||||
|
|
||||||
Route::post('{cutting}/reject', [StockController::class, 'reject'])
|
|
||||||
->middleware('permission:'.Permission::CUTTINGS_REJECT->value)
|
|
||||||
->name('reject');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user