feat: add CuttingMaterialCombination management with store and destroy functionalities; implement validation for combination requests; enhance CuttingService to handle draft combinations; update frontend components for combination handling and display

This commit is contained in:
Yoga Pangestu 2026-07-04 21:54:01 +07:00
parent b52b9ce08c
commit ba3f7ef9f9
23 changed files with 1267 additions and 118 deletions

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers\Admin\Manage\Cutting;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\CuttingDraftCombinationRequest;
use App\Http\Requests\Admin\Manage\CuttingDraftMaterialRequest;
use App\Http\Requests\Admin\Manage\CuttingDraftResultRequest;
use App\Http\Requests\Admin\Manage\CuttingQuickCreateProductRequest;
@ -47,6 +48,20 @@ public function destroyResult(Request $request, ProductVariant $productVariant):
return response()->json(['ok' => true]);
}
public function storeCombination(CuttingDraftCombinationRequest $request): JsonResponse
{
$items = $this->cuttingService->syncDraftCombination($request->validated(), $request->user());
return response()->json(['items' => $items]);
}
public function destroyCombination(Request $request, int $combinationId): JsonResponse
{
$this->cuttingService->removeDraftCombination($request->user(), $combinationId);
return response()->json(['ok' => true]);
}
public function quickCreateRawMaterial(CuttingQuickCreateRawMaterialRequest $request): JsonResponse
{
$rawMaterial = $this->cuttingService->quickCreateRawMaterial($request->validated());

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use App\Enums\Permission;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class CuttingDraftCombinationRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can(Permission::CUTTINGS_CREATE->value) ?? false;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'materials' => ['required', 'array', 'min:2'],
'materials.*.raw_material_price_id' => [
'required',
'integer',
Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'),
],
'materials.*.material_usage' => ['required', 'numeric', 'decimal:0,4', 'gt:0'],
];
}
}

View File

@ -39,10 +39,10 @@ public function rules(): array
$rules['materials.*.raw_material_price_id'] = [
'required',
'integer',
'distinct',
Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'),
];
$rules['materials.*.material_usage'] = ['required', 'numeric', 'decimal:0,4', 'gt:0'];
$rules['materials.*.combination_id'] = ['nullable', 'integer', 'exists:cutting_material_combinations,id'];
$rules['results'] = ['required', 'array', 'min:1'];
$rules['results.*.product_variant_id'] = [

View File

@ -88,6 +88,11 @@ public function rawMaterialPrice(): BelongsTo
return $this->belongsTo(RawMaterialPrice::class);
}
public function combination(): BelongsTo
{
return $this->belongsTo(CuttingMaterialCombination::class, 'combination_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);

View File

@ -0,0 +1,32 @@
<?php
namespace App\Models;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Guarded(['id'])]
class CuttingMaterialCombination extends Model
{
use HasFactory, InteractsWithActivityLog, SoftDeletes;
public function cutting(): BelongsTo
{
return $this->belongsTo(Cutting::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function materials(): HasMany
{
return $this->hasMany(CuttingMaterial::class, 'combination_id');
}
}

View File

@ -7,6 +7,7 @@
use App\Enums\Role;
use App\Models\Cutting;
use App\Models\CuttingMaterial;
use App\Models\CuttingMaterialCombination;
use App\Models\CuttingResult;
use App\Models\CuttingResultPrice;
use App\Models\Product;
@ -42,6 +43,7 @@ public function paginateForIndex(array $tableQuery, User $user): LengthAwarePagi
'rejection.rejectedBy.profile',
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
'materials.rawMaterialPrice.media',
'materials.combination',
'results.productVariant.product:id,name',
'results.productVariant.media',
])
@ -85,6 +87,7 @@ public function getInProgressCuttings(User $user): Collection
'rejection.rejectedBy.profile',
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
'materials.rawMaterialPrice.media',
'materials.combination',
'results.productVariant.product:id,name',
'results.productVariant.media',
])
@ -109,6 +112,7 @@ public function getCompletedCuttings(User $user): Collection
'rejection.rejectedBy.profile',
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
'materials.rawMaterialPrice.media',
'materials.combination',
'results.productVariant.product:id,name',
'results.productVariant.media',
])
@ -197,6 +201,7 @@ public function findForEdit(Cutting $cutting): Cutting
'media',
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
'materials.rawMaterialPrice.media',
'materials.combination',
'results.productVariant.product:id,name',
'results.productVariant.media',
'rejection.rejectedBy.profile',
@ -215,6 +220,7 @@ public function findForShare(Cutting $cutting): Cutting
'createdBy.profile',
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
'materials.rawMaterialPrice.media',
'materials.combination',
'results.productVariant.product:id,name',
'results.productVariant.media',
]);
@ -256,6 +262,7 @@ public function draftMaterialsForUser(User $user): array
->with([
'rawMaterialPrice.rawMaterial:id,name,unit',
'rawMaterialPrice.media',
'combination',
])
->get()
->map(function (CuttingMaterial $item) {
@ -398,6 +405,71 @@ public function removeDraftResult(User $user, ProductVariant $productVariant): v
->delete();
}
/**
* @return list<array>
*/
public function syncDraftCombination(array $validated, User $user): array
{
$combination = CuttingMaterialCombination::create([
'user_id' => $user->id,
'cutting_id' => null,
]);
$items = [];
foreach ($validated['materials'] as $index => $materialData) {
$price = RawMaterialPrice::query()
->with('rawMaterial')
->findOrFail($materialData['raw_material_price_id']);
$materialUsage = round((float) $materialData['material_usage'], 2);
if ($materialUsage <= 0) {
throw ValidationException::withMessages([
"materials.{$index}.material_usage" => 'Pemakaian bahan harus lebih dari 0.',
]);
}
$item = CuttingMaterial::create([
'user_id' => $user->id,
'cutting_id' => null,
'raw_material_price_id' => $price->id,
'combination_id' => $combination->id,
'material_usage' => $materialUsage,
]);
$item->load([
'rawMaterialPrice.rawMaterial:id,name,unit',
'rawMaterialPrice.media',
]);
$items[] = $this->presentDraftMaterial($item);
$this->breakMaterialCircularReference($item);
}
return $items;
}
public function removeDraftCombination(User $user, int $combinationId): void
{
$combination = CuttingMaterialCombination::query()
->whereNull('cutting_id')
->where('user_id', $user->id)
->where('id', $combinationId)
->first();
if ($combination === null) {
return;
}
// Hard delete associated materials first (cascadeOnDelete only works with hard delete)
CuttingMaterial::query()
->where('combination_id', $combination->id)
->forceDelete();
$combination->forceDelete();
}
public function create(array $validated, User $user): Cutting
{
$cutting = $this->runInTransaction(
@ -434,6 +506,19 @@ function () use ($validated, $user): Cutting {
$this->syncImages($cutting, $validated);
// Transfer draft combinations to cutting
$draftCombinations = CuttingMaterialCombination::query()
->whereNull('cutting_id')
->where('user_id', $user->id)
->lockForUpdate()
->get();
foreach ($draftCombinations as $combination) {
$combination->cutting_id = $cutting->id;
$combination->user_id = null;
$combination->save();
}
foreach ($draftMaterials as $material) {
$material->cutting_id = $cutting->id;
$material->user_id = null;
@ -675,6 +760,7 @@ private function buildMaterials(array $materials): array
return [
'raw_material_price_id' => $price->id,
'material_usage' => $materialUsage,
'combination_id' => $itemData['combination_id'] ?? null,
];
})
->all();
@ -844,7 +930,9 @@ private function breakMaterialCircularReference(CuttingMaterial $material): void
$price->unsetRelation('rawMaterial');
}
$material->setAttribute('combination_id', $material->combination_id);
$material->unsetRelation('rawMaterialPrice');
$material->unsetRelation('combination');
}
private function applySorting(Builder $query, string $sort, string $direction): void
@ -886,6 +974,7 @@ private function presentDraftMaterial(CuttingMaterial $item): array
'stock_input' => $price?->stock_input ?? '',
'material_usage' => $this->formatQuantityInput((float) $item->material_usage),
'images' => $price ? MediaPresenter::collection($price, 'images') : [],
'combination_id' => $item->combination_id,
];
}

View File

@ -0,0 +1,24 @@
<?php
namespace Database\Factories;
use App\Models\Cutting;
use App\Models\CuttingMaterialCombination;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<CuttingMaterialCombination>
*/
class CuttingMaterialCombinationFactory extends Factory
{
protected $model = CuttingMaterialCombination::class;
public function definition(): array
{
return [
'cutting_id' => Cutting::factory(),
'user_id' => null,
];
}
}

View File

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('cutting_material_combinations', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->nullable()->constrained()->cascadeOnDelete();
$table->foreignId('cutting_id')->nullable()->constrained()->cascadeOnDelete();
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('cutting_material_combinations');
}
};

View File

@ -0,0 +1,22 @@
<?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::table('cutting_materials', function (Blueprint $table): void {
$table->foreignId('combination_id')->nullable()->after('raw_material_price_id')->constrained('cutting_material_combinations')->cascadeOnDelete();
});
}
public function down(): void
{
Schema::table('cutting_materials', function (Blueprint $table): void {
$table->dropConstrainedForeignId('combination_id');
});
}
};

View File

@ -35,6 +35,7 @@ const initialData = computed(() => ({
stock_input: item.stock_input ?? '',
material_usage: item.material_usage_input,
images: item.images ?? [],
combination_id: item.combination_id ?? null,
})),
results: props.cutting.results.map((item) => ({
product_variant_id: item.product_variant_id,

View File

@ -22,6 +22,7 @@ interface GroupedMaterials {
name: string;
unitLabel?: string;
items: typeof props.cutting.materials;
isCombination?: boolean;
}
interface GroupedResults {
@ -31,9 +32,37 @@ interface GroupedResults {
}
const groupedMaterials = computed<GroupedMaterials[]>(() => {
const groups: Record<number, GroupedMaterials> = {};
// First, group by combination_id
const combinationGroups: Record<number, typeof props.cutting.materials> = {};
const nonCombinationItems: typeof props.cutting.materials = [];
props.cutting.materials.forEach((item) => {
if (item.combination_id) {
if (!combinationGroups[item.combination_id]) {
combinationGroups[item.combination_id] = [];
}
combinationGroups[item.combination_id].push(item);
} else {
nonCombinationItems.push(item);
}
});
const result: GroupedMaterials[] = [];
// Add combination groups
Object.entries(combinationGroups).forEach(([combinationId, items]) => {
result.push({
key: Number(combinationId) * -1,
name: 'Kombinasi',
items,
isCombination: true,
});
});
// Group non-combination items by raw material
const groups: Record<number, GroupedMaterials> = {};
nonCombinationItems.forEach((item) => {
const key = item.raw_material_id ?? 0;
const name = item.raw_material_name || 'Bahan Baku Tidak Diketahui';
@ -49,7 +78,9 @@ const groupedMaterials = computed<GroupedMaterials[]>(() => {
groups[key].items.push(item);
});
return Object.values(groups);
result.push(...Object.values(groups));
return result;
});
const groupedResults = computed<GroupedResults[]>(() => {
@ -178,14 +209,25 @@ const groupedResults = computed<GroupedResults[]>(() => {
colspan="3"
class="font-semibold"
>
{{ group.name }}
<Badge
v-if="group.unitLabel"
variant="secondary"
class="ml-2 font-normal"
>
{{ group.unitLabel }}
</Badge>
<template v-if="group.isCombination">
<span class="text-primary">{{ group.name }}</span>
<Badge
variant="default"
class="ml-2 font-normal"
>
{{ group.items.length }} bahan
</Badge>
</template>
<template v-else>
{{ group.name }}
<Badge
v-if="group.unitLabel"
variant="secondary"
class="ml-2 font-normal"
>
{{ group.unitLabel }}
</Badge>
</template>
</TableCell>
</TableRow>
<TableRow

View File

@ -0,0 +1,292 @@
<script setup lang="ts">
import { Layers, Plus, Search, X } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { DecimalInput } from '@/components/form/decimal-input';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { apiFetch } from '@/lib/api';
import draft_combinations from '@/routes/admin/manage/cuttings/draft_combinations';
import type { CuttingMaterialCartItem, CuttingRawMaterialCatalogItem } from '@/types/cutting';
export type CuttingCatalogPrice = CuttingRawMaterialCatalogItem['prices'][number];
type SelectedMaterial = {
raw_material_price_id: number;
raw_material_name: string;
variant: string;
unit: string;
unit_abbreviation: string;
stock_input: string;
material_usage: string;
images?: CuttingCatalogPrice['images'];
is_initial?: boolean;
};
const props = defineProps<{
rawMaterialCatalog: CuttingRawMaterialCatalogItem[];
existingCartItems: CuttingMaterialCartItem[];
initialVariant?: {
rawMaterial: CuttingRawMaterialCatalogItem;
price: CuttingCatalogPrice;
} | null;
}>();
const open = defineModel<boolean>('open', { required: true });
const emit = defineEmits<{
'combination-created': [items: CuttingMaterialCartItem[]];
}>();
const search = ref('');
const selectedMaterials = ref<SelectedMaterial[]>([]);
const loading = ref(false);
const filteredRawMaterials = computed(() => {
const keyword = search.value.trim().toLowerCase();
const catalog = props.rawMaterialCatalog;
if (!keyword) {
return catalog;
}
return catalog.filter(
(rawMaterial) =>
rawMaterial.name.toLowerCase().includes(keyword)
|| rawMaterial.prices.some((price) => price.variant.toLowerCase().includes(keyword)),
);
});
function isSelected(priceId: number): boolean {
return selectedMaterials.value.some((item) => item.raw_material_price_id === priceId);
}
function toggleVariant(rawMaterial: CuttingRawMaterialCatalogItem, price: CuttingCatalogPrice) {
const index = selectedMaterials.value.findIndex(
(item) => item.raw_material_price_id === price.id,
);
if (index !== -1) {
if (!selectedMaterials.value[index].is_initial) {
selectedMaterials.value.splice(index, 1);
}
} else {
selectedMaterials.value.push({
raw_material_price_id: price.id,
raw_material_name: rawMaterial.name,
variant: price.variant,
unit: rawMaterial.unit,
unit_abbreviation: rawMaterial.unit_abbreviation,
stock_input: price.stock_input,
material_usage: '1',
images: price.images,
});
}
}
function removeSelected(priceId: number) {
selectedMaterials.value = selectedMaterials.value.filter(
(item) => item.raw_material_price_id !== priceId || item.is_initial,
);
}
function resetForm() {
search.value = '';
selectedMaterials.value = [];
}
function initFromVariant() {
if (props.initialVariant) {
const { rawMaterial, price } = props.initialVariant;
selectedMaterials.value = [{
raw_material_price_id: price.id,
raw_material_name: rawMaterial.name,
variant: price.variant,
unit: rawMaterial.unit,
unit_abbreviation: rawMaterial.unit_abbreviation,
stock_input: price.stock_input,
material_usage: '1',
images: price.images,
is_initial: true,
}];
}
}
async function submit() {
if (selectedMaterials.value.length < 2) {
toast.error('Pilih minimal 2 bahan baku untuk dikombinasikan.');
return;
}
loading.value = true;
try {
const data = await apiFetch<{ items: CuttingMaterialCartItem[] }>(draft_combinations.store.url(), {
method: 'POST',
body: JSON.stringify({
materials: selectedMaterials.value.map((item) => ({
raw_material_price_id: item.raw_material_price_id,
material_usage: item.material_usage,
})),
}),
});
toast.success('Kombinasi bahan baku berhasil ditambahkan.');
emit('combination-created', data.items);
open.value = false;
resetForm();
} catch (error) {
if (error instanceof Error) {
toast.error(error.message);
}
} finally {
loading.value = false;
}
}
watch(open, (value) => {
if (value) {
initFromVariant();
} else {
resetForm();
}
});
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="flex max-h-[90vh] flex-col sm:max-w-4xl">
<DialogHeader>
<DialogTitle>Kombinasi Bahan Baku</DialogTitle>
</DialogHeader>
<div class="scrollbar-thin flex-1 overflow-y-auto pr-1">
<div class="space-y-4">
<div v-if="selectedMaterials.length > 0" class="rounded-lg border p-3 space-y-2">
<p class="text-sm font-medium">Bahan Baku Terpilih ({{ selectedMaterials.length }})</p>
<div class="space-y-2">
<div
v-for="item in selectedMaterials"
:key="item.raw_material_price_id"
class="flex items-center gap-2 rounded-md border p-2"
:class="item.is_initial ? 'border-primary bg-primary/5' : ''"
>
<PosCatalogVariantThumb :items="item.images" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">
{{ item.raw_material_name }} - {{ item.variant }}
</p>
<Badge v-if="item.is_initial" variant="secondary" class="text-xs mt-0.5">
Bahan awal
</Badge>
</div>
<div class="flex items-center gap-1" @click.stop>
<Label class="text-xs whitespace-nowrap">Pemakaian:</Label>
<DecimalInput
v-model="item.material_usage"
class="h-7 w-20"
/>
<span class="text-xs text-muted-foreground">{{ item.unit_abbreviation }}</span>
</div>
<Button
v-if="!item.is_initial"
type="button"
variant="ghost"
size="icon-sm"
class="text-destructive"
@click="removeSelected(item.raw_material_price_id)"
>
<X class="size-3.5" />
</Button>
</div>
</div>
</div>
<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 lain untuk dikombinasikan..." class="pl-9" />
</div>
<div v-if="filteredRawMaterials.length === 0" class="py-8 text-center text-sm text-muted-foreground">
Tidak ada bahan baku ditemukan.
</div>
<div v-else class="space-y-3">
<div
v-for="rawMaterial in filteredRawMaterials"
:key="rawMaterial.id"
class="rounded-lg border p-3"
>
<div class="mb-2 flex items-center gap-2">
<p class="text-sm font-medium">{{ rawMaterial.name }}</p>
<Badge variant="outline" class="text-xs">
{{ rawMaterial.unit_label }}
</Badge>
</div>
<div class="space-y-1">
<div
v-for="price in rawMaterial.prices"
:key="price.id"
class="flex items-center gap-2.5 rounded-md px-2 py-1.5 transition-all duration-200"
:class="[
isSelected(price.id) ? 'border-2 border-primary bg-primary/5' : 'cursor-pointer hover:bg-muted/30',
]"
@click="!isSelected(price.id) && toggleVariant(rawMaterial, price)"
>
<PosCatalogVariantThumb :items="price.images" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm">{{ price.variant }}</p>
<p class="text-xs tabular-nums text-muted-foreground">
Stok: {{ price.stock_formatted }}
</p>
</div>
<Button
v-if="!isSelected(price.id)"
type="button"
variant="outline"
size="icon-sm"
class="shrink-0"
@click.stop="toggleVariant(rawMaterial, price)"
>
<Plus class="size-3.5" />
</Button>
<Badge v-else variant="secondary" class="text-xs">
Terpilih
</Badge>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-between border-t pt-4">
<p class="text-sm text-muted-foreground">
{{ selectedMaterials.length }} bahan dipilih
</p>
<div class="flex gap-2">
<Button type="button" variant="outline" :disabled="loading" @click="open = false">
Batal
</Button>
<Button
type="button"
:disabled="loading || selectedMaterials.length < 2"
@click="submit"
>
<Layers class="size-4 mr-1" />
{{ loading ? 'Menyimpan...' : 'Simpan Kombinasi' }}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</template>

View File

@ -99,6 +99,8 @@ const {
decreaseResultQty,
syncMaterialField,
syncResultField,
syncDraftCombination,
removeCombination,
} = useCuttingPosCart({
rawMaterialCatalog: rawMaterialCatalogState,
productCatalog: productCatalogState,
@ -151,6 +153,12 @@ function buildFormData(): FormData {
`materials[${index}][material_usage]`,
item.material_usage,
);
if (item.combination_id) {
formData.append(
`materials[${index}][combination_id]`,
String(item.combination_id),
);
}
});
resultCart.value.forEach((item, index) => {
formData.append(
@ -217,6 +225,14 @@ function onRawMaterialCreated(rawMaterial: CuttingRawMaterialCatalogItem) {
}
}
function onCombinationCreated(items: CuttingMaterialCartItem[]) {
for (const item of items) {
// Combination items use raw_material_price_id + combination_id as unique key
// Same variant can exist in multiple combinations
materialCart.value.push({ ...item });
}
}
function onProductCreated(product: CuttingProductCatalogItem) {
// Add to catalog
productCatalogState.value.push(product);
@ -234,9 +250,10 @@ function onProductCreated(product: CuttingProductCatalogItem) {
<div class="grid gap-4 xl:grid-cols-[1fr_400px]">
<div class="space-y-4">
<CuttingPosMaterialCatalogPanel v-model:material-search="materialSearch"
:filtered-raw-materials="filteredRawMaterials" :get-material-cart-item="getMaterialCartItem"
:filtered-raw-materials="filteredRawMaterials" :raw-material-catalog="rawMaterialCatalogState"
:material-cart="materialCart" :get-material-cart-item="getMaterialCartItem"
:units="units" @add-material="addMaterial" @decrease-material-qty="decreaseMaterialQty"
@raw-material-created="onRawMaterialCreated" />
@raw-material-created="onRawMaterialCreated" @combination-created="onCombinationCreated" />
<CuttingPosResultCatalogPanel v-model:product-search="productSearch" :filtered-products="filteredProducts"
:get-result-cart-item="getResultCartItem" @add-result="addResult" :is-create-mode="isCreateMode"
@ -248,7 +265,8 @@ function onProductCreated(product: CuttingProductCatalogItem) {
@submit="submit" :is-create-mode="isCreateMode" :is-uploading="isUploading"
@open-detail="cartDetailOpen = true" @remove-material="removeMaterial"
@sync-material-field="syncMaterialField" @remove-result="removeResult"
@sync-result-totals="syncResultTotals" @sync-result-field="syncResultField" />
@sync-result-totals="syncResultTotals" @sync-result-field="syncResultField"
@remove-combination="removeCombination" />
</div>
<CuttingPosCartDetailDialog v-model:open="cartDetailOpen" :material-cart="materialCart" :result-cart="resultCart"

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Layers, Minus, Plus, Search } from '@lucide/vue';
import { ref } from 'vue';
import { Minus, Plus, Search } from '@lucide/vue';
import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue';
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
import { Badge } from '@/components/ui/badge';
@ -16,11 +16,14 @@ import { Input } from '@/components/ui/input';
import { getFirstCoverImage } from '@/lib/catalog-cover';
import type { CuttingMaterialCartItem, CuttingRawMaterialCatalogItem } from '@/types/cutting';
import type { EnumOption } from '@/types/raw-material';
import type { CuttingCatalogPrice } from './useCuttingPosCart';
import CuttingPosCombinationDialog from './CuttingPosCombinationDialog.vue';
import QuickCreateRawMaterialModal from './QuickCreateRawMaterialModal.vue';
import type { CuttingCatalogPrice } from './useCuttingPosCart';
defineProps<{
filteredRawMaterials: CuttingRawMaterialCatalogItem[];
rawMaterialCatalog: CuttingRawMaterialCatalogItem[];
materialCart: CuttingMaterialCartItem[];
getMaterialCartItem: (priceId: number) => CuttingMaterialCartItem | undefined;
units: EnumOption[];
}>();
@ -31,9 +34,20 @@ const emit = defineEmits<{
'add-material': [rawMaterial: CuttingRawMaterialCatalogItem, price: CuttingCatalogPrice];
'decrease-material-qty': [priceId: number];
'raw-material-created': [rawMaterial: CuttingRawMaterialCatalogItem];
'combination-created': [items: CuttingMaterialCartItem[]];
}>();
const quickCreateOpen = ref(false);
const combinationOpen = ref(false);
const combinationInitialVariant = ref<{
rawMaterial: CuttingRawMaterialCatalogItem;
price: CuttingCatalogPrice;
} | null>(null);
function openCombination(rawMaterial: CuttingRawMaterialCatalogItem, price: CuttingCatalogPrice) {
combinationInitialVariant.value = { rawMaterial, price };
combinationOpen.value = true;
}
</script>
<template>
@ -64,12 +78,8 @@ const quickCreateOpen = ref(false);
</div>
<div v-else class="columns-1 gap-4 sm:columns-2">
<PosCatalogCard
v-for="rawMaterial in filteredRawMaterials"
:key="rawMaterial.id"
:title="rawMaterial.name"
:cover-image="getFirstCoverImage(rawMaterial.prices)"
>
<PosCatalogCard v-for="rawMaterial in filteredRawMaterials" :key="rawMaterial.id"
:title="rawMaterial.name" :cover-image="getFirstCoverImage(rawMaterial.prices)">
<template #header-extra>
<Badge variant="secondary" class="mt-1.5">
{{ rawMaterial.unit_label }}
@ -79,18 +89,13 @@ const quickCreateOpen = ref(false);
<p v-if="!rawMaterial.prices.length" class="px-3 py-4 text-sm text-muted-foreground">
Belum ada varian
</p>
<div
v-for="price in rawMaterial.prices"
:key="price.id"
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200"
:class="[
<div v-for="price in rawMaterial.prices" :key="price.id"
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200" :class="[
'cursor-pointer hover:bg-muted/30',
getMaterialCartItem(price.id)
? 'mx-1 my-0.5 rounded-md border-2 border-primary bg-primary/5'
: '',
]"
@click="!getMaterialCartItem(price.id) && emit('add-material', rawMaterial, price)"
>
]" @click="!getMaterialCartItem(price.id) && emit('add-material', rawMaterial, price)">
<PosCatalogVariantThumb :items="price.images" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">
@ -101,36 +106,46 @@ const quickCreateOpen = ref(false);
</p>
</div>
<div v-if="getMaterialCartItem(price.id)" class="flex shrink-0 items-center gap-1.5">
<Button
type="button"
variant="outline"
size="icon-sm"
@click.stop="emit('decrease-material-qty', price.id)"
>
<Button type="button" variant="outline" size="icon-sm"
@click.stop="emit('decrease-material-qty', price.id)">
<Minus class="size-3.5" />
</Button>
<span class="min-w-5 text-center text-xs font-semibold tabular-nums">
{{ getMaterialCartItem(price.id)!.material_usage }}
</span>
<Button
type="button"
variant="outline"
size="icon-sm"
@click.stop="emit('add-material', rawMaterial, price)"
>
<Plus class="size-3.5" />
</Button>
<div class="flex shrink-0 items-center gap-1.5">
<div class="flex flex-col items-center gap-0.5">
<Button type="button" variant="outline" size="icon-sm"
@click.stop="emit('add-material', rawMaterial, price)">
<Plus class="size-3.5" />
</Button>
<span class="text-[9px] text-muted-foreground leading-none">Keranjang</span>
</div>
<div class="flex flex-col items-center gap-0.5">
<Button type="button" variant="default" size="icon-sm"
@click.stop="openCombination(rawMaterial, price)">
<Layers class="size-3.5" />
</Button>
<span class="text-[9px] text-muted-foreground leading-none">Kombinasi</span>
</div>
</div>
</div>
<div v-else class="flex shrink-0 items-center gap-1.5">
<div class="flex flex-col items-center gap-0.5">
<Button type="button" variant="outline" size="icon-sm"
@click.stop="emit('add-material', rawMaterial, price)">
<Plus class="size-3.5" />
</Button>
<span class="text-[9px] text-muted-foreground leading-none">Keranjang</span>
</div>
<div class="flex flex-col items-center gap-0.5">
<Button type="button" variant="default" size="icon-sm"
@click.stop="openCombination(rawMaterial, price)">
<Layers class="size-3.5" />
</Button>
<span class="text-[9px] text-muted-foreground leading-none">Kombinasi</span>
</div>
</div>
<Button
v-else
type="button"
variant="outline"
size="icon-sm"
class="shrink-0"
@click.stop="emit('add-material', rawMaterial, price)"
>
<Plus class="size-3.5" />
</Button>
</div>
</PosCatalogCard>
</div>
@ -138,9 +153,10 @@ const quickCreateOpen = ref(false);
</CardContent>
</Card>
<QuickCreateRawMaterialModal
v-model:open="quickCreateOpen"
:units="units"
@created="emit('raw-material-created', $event)"
/>
<QuickCreateRawMaterialModal v-model:open="quickCreateOpen" :units="units"
@created="emit('raw-material-created', $event)" />
<CuttingPosCombinationDialog v-model:open="combinationOpen" :raw-material-catalog="rawMaterialCatalog"
:existing-cart-items="materialCart" :initial-variant="combinationInitialVariant"
@combination-created="emit('combination-created', $event)" />
</template>

View File

@ -1,6 +1,8 @@
<script setup lang="ts">
import { Trash2 } from '@lucide/vue';
import { Layers, Trash2 } from '@lucide/vue';
import { computed } from 'vue';
import { DecimalInput } from '@/components/form/decimal-input';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Field,
@ -11,7 +13,7 @@ import { formErrors } from '@/lib/form';
import type { FormWithErrors } from '@/lib/form';
import type { CuttingMaterialCartItem } from '@/types/cutting';
defineProps<{
const props = defineProps<{
form: FormWithErrors;
materialCart: CuttingMaterialCartItem[];
}>();
@ -19,7 +21,49 @@ defineProps<{
const emit = defineEmits<{
remove: [index: number];
'sync-field': [index: number];
'remove-combination': [combinationId: number];
}>();
type MaterialGroup = {
type: 'combination' | 'single';
combinationId?: number;
items: Array<{ item: CuttingMaterialCartItem; index: number }>;
};
const materialGroups = computed<MaterialGroup[]>(() => {
const combinationMap = new Map<number, Array<{ item: CuttingMaterialCartItem; index: number }>>();
const singleItems: Array<{ item: CuttingMaterialCartItem; index: number }> = [];
props.materialCart.forEach((item, index) => {
if (item.combination_id) {
if (!combinationMap.has(item.combination_id)) {
combinationMap.set(item.combination_id, []);
}
combinationMap.get(item.combination_id)!.push({ item, index });
} else {
singleItems.push({ item, index });
}
});
const groups: MaterialGroup[] = [];
for (const [combinationId, items] of combinationMap) {
groups.push({
type: 'combination',
combinationId,
items,
});
}
if (singleItems.length > 0) {
groups.push({
type: 'single',
items: singleItems,
});
}
return groups;
});
</script>
<template>
@ -34,44 +78,112 @@ const emit = defineEmits<{
</div>
<div v-else class="scrollbar-thin max-h-80 space-y-3 overflow-y-auto overscroll-y-contain">
<div
v-for="(item, index) in materialCart"
:key="item.raw_material_price_id"
class="rounded-lg border p-3"
>
<div class="mb-2 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 }} · Stok {{ item.stock_input }} {{ item.unit_abbreviation }}
</p>
<template v-for="group in materialGroups" :key="group.combinationId ?? `single-${group.items[0]?.index}`">
<div
v-if="group.type === 'combination'"
class="rounded-lg border-2 border-dashed border-primary/30 p-3"
>
<div class="mb-2 flex items-center justify-between">
<div class="flex items-center gap-2">
<Layers class="size-4 text-primary" />
<span class="text-sm font-medium text-primary">Kombinasi</span>
<Badge variant="secondary" class="text-xs">
{{ group.items.length }} bahan
</Badge>
</div>
<Button
type="button"
variant="ghost"
size="icon"
class="size-7 shrink-0 text-destructive hover:text-destructive"
@click="group.combinationId && emit('remove-combination', group.combinationId)"
>
<Trash2 class="size-3.5" />
</Button>
</div>
<div class="space-y-2">
<div
v-for="{ item, index } in group.items"
:key="item.raw_material_price_id"
class="rounded-md border bg-background p-2.5"
>
<div class="mb-1.5 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 }} · Stok {{ item.stock_input }} {{ item.unit_abbreviation }}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
class="size-6 shrink-0 text-destructive hover:text-destructive"
@click="emit('remove', index)"
>
<Trash2 class="size-3" />
</Button>
</div>
<Field :data-invalid="formErrors(form, `materials.${index}.material_usage`).length > 0 ? 'true' : undefined">
<FieldLabel class="text-xs">
Pemakaian ({{ item.unit_abbreviation }})
</FieldLabel>
<DecimalInput
v-model="item.material_usage"
class="h-7"
:aria-invalid="formErrors(form, `materials.${index}.material_usage`).length > 0"
@change="emit('sync-field', index)"
/>
<FieldError :errors="formErrors(form, `materials.${index}.material_usage`)" class="text-[10px] mt-0.5 leading-tight" />
</Field>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
class="size-7 shrink-0 text-destructive hover:text-destructive"
@click="emit('remove', index)"
>
<Trash2 class="size-3.5" />
</Button>
</div>
<Field :data-invalid="formErrors(form, `materials.${index}.material_usage`).length > 0 ? 'true' : undefined">
<FieldLabel class="text-xs">
Pemakaian ({{ item.unit_abbreviation }})
</FieldLabel>
<DecimalInput
v-model="item.material_usage"
class="h-8"
:aria-invalid="formErrors(form, `materials.${index}.material_usage`).length > 0"
@change="emit('sync-field', index)"
/>
<FieldError :errors="formErrors(form, `materials.${index}.material_usage`)" class="text-[10px] mt-0.5 leading-tight" />
</Field>
</div>
<div
v-else
v-for="{ item, index } in group.items"
:key="item.raw_material_price_id"
class="rounded-lg border p-3"
>
<div class="mb-2 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 }} · Stok {{ item.stock_input }} {{ item.unit_abbreviation }}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
class="size-7 shrink-0 text-destructive hover:text-destructive"
@click="emit('remove', index)"
>
<Trash2 class="size-3.5" />
</Button>
</div>
<Field :data-invalid="formErrors(form, `materials.${index}.material_usage`).length > 0 ? 'true' : undefined">
<FieldLabel class="text-xs">
Pemakaian ({{ item.unit_abbreviation }})
</FieldLabel>
<DecimalInput
v-model="item.material_usage"
class="h-8"
:aria-invalid="formErrors(form, `materials.${index}.material_usage`).length > 0"
@change="emit('sync-field', index)"
/>
<FieldError :errors="formErrors(form, `materials.${index}.material_usage`)" class="text-[10px] mt-0.5 leading-tight" />
</Field>
</div>
</template>
</div>
</div>
</template>

View File

@ -41,6 +41,7 @@ const emit = defineEmits<{
'remove-result': [index: number];
'sync-result-totals': [item: CuttingResultCartItem];
'sync-result-field': [index: number];
'remove-combination': [combinationId: number];
}>();
const imageState = defineModel<MediaUploadState>('imageState', {
@ -110,6 +111,7 @@ const imageState = defineModel<MediaUploadState>('imageState', {
:material-cart="materialCart"
@remove="emit('remove-material', $event)"
@sync-field="emit('sync-material-field', $event)"
@remove-combination="emit('remove-combination', $event)"
/>
<Separator />

View File

@ -1,6 +1,8 @@
import { computed, ref, type MaybeRefOrGetter, toValue } from 'vue';
import { computed, ref, toValue } from 'vue';
import type { MaybeRefOrGetter } from 'vue';
import { toast } from 'vue-sonner';
import { apiFetch } from '@/lib/api';
import draft_combinations from '@/routes/admin/manage/cuttings/draft_combinations';
import draft_materials from '@/routes/admin/manage/cuttings/draft_materials';
import draft_results from '@/routes/admin/manage/cuttings/draft_results';
import type {
@ -213,6 +215,39 @@ export function useCuttingPosCart(options: {
upsertResultCartItem(item);
}
async function syncDraftCombination(
name: string | null,
materials: Array<{ raw_material_price_id: number; material_usage: string }>,
) {
const { items } = await apiFetch<{ items: CuttingMaterialCartItem[] }>(draft_combinations.store.url(), {
method: 'POST',
body: JSON.stringify({
name,
materials,
}),
});
for (const item of items) {
upsertMaterialCartItem(item);
}
return items;
}
async function removeCombination(combinationId: number) {
try {
await apiFetch(draft_combinations.destroy.url(combinationId), {
method: 'DELETE',
});
materialCart.value = materialCart.value.filter(
(item) => item.combination_id !== combinationId,
);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal menghapus kombinasi dari keranjang.');
}
}
function getMaterialCartItem(priceId: number): CuttingMaterialCartItem | undefined {
return materialCart.value.find((item) => item.raw_material_price_id === priceId);
}
@ -476,5 +511,7 @@ export function useCuttingPosCart(options: {
decreaseResultQty,
syncMaterialField,
syncResultField,
syncDraftCombination,
removeCombination,
};
}

View File

@ -42,6 +42,40 @@ function getGroupedMaterialUsage(materials: any[]): MaterialUsageGroup[] {
}));
}
interface MaterialGroup {
type: 'combination' | 'single';
name: string;
items: any[];
}
function getMaterialGroups(materials: any[]): MaterialGroup[] {
const combinationMap: Record<number, any[]> = {};
const singleItems: any[] = [];
materials.forEach((mat) => {
if (mat.combination_id) {
if (!combinationMap[mat.combination_id]) {
combinationMap[mat.combination_id] = [];
}
combinationMap[mat.combination_id].push(mat);
} else {
singleItems.push(mat);
}
});
const result: MaterialGroup[] = [];
Object.entries(combinationMap).forEach(([, items]) => {
result.push({ type: 'combination', name: 'Kombinasi', items });
});
if (singleItems.length > 0) {
result.push({ type: 'single', name: '', items: singleItems });
}
return result;
}
const props = defineProps<{
cuttings: CuttingListItem[];
}>();
@ -92,11 +126,25 @@ const totalCompleted = computed(() => props.cuttings.length);
<div class="space-y-1">
<span class="font-medium text-foreground">Bahan Baku:</span>
<ul class="list-disc pl-4 space-y-0.5 text-muted-foreground">
<li v-for="mat in cutting.materials" :key="mat.id">
{{ mat.raw_material_name }} ({{ mat.variant }}) - {{ formatMaterialUsage(mat) }} {{ getMaterialUnit(mat) }}
</li>
<li v-if="!cutting.materials.length">Belum ada bahan baku</li>
<template v-if="cutting.materials.length">
<div v-for="(group, gi) in getMaterialGroups(cutting.materials)" :key="gi" class="mt-1">
<div v-if="group.type === 'combination'" class="mb-1">
<span class="text-primary font-medium text-[11px]">{{ group.name }} ({{ group.items.length }} bahan)</span>
<ul class="list-disc pl-4 space-y-0.5 text-muted-foreground">
<li v-for="mat in group.items" :key="mat.id">
{{ mat.raw_material_name }} ({{ mat.variant }}) - {{ formatMaterialUsage(mat) }} {{ getMaterialUnit(mat) }}
</li>
</ul>
</div>
<ul v-else class="list-disc pl-4 space-y-0.5 text-muted-foreground">
<li v-for="mat in group.items" :key="mat.id">
{{ mat.raw_material_name }} ({{ mat.variant }}) - {{ formatMaterialUsage(mat) }} {{ getMaterialUnit(mat) }}
</li>
</ul>
</div>
</template>
<ul v-else class="list-disc pl-4 space-y-0.5 text-muted-foreground">
<li>Belum ada bahan baku</li>
</ul>
</div>

View File

@ -52,6 +52,7 @@ interface GroupedCuttingMaterials {
rawMaterialName: string;
unitLabel?: string;
items: any[];
isCombination?: boolean;
}
interface GroupedCuttingResults {
@ -61,9 +62,42 @@ interface GroupedCuttingResults {
}
function getGroupedMaterials(materials: any[]): GroupedCuttingMaterials[] {
const groups: Record<number, GroupedCuttingMaterials> = {};
// First, group by combination_id
const combinationGroups: Record<number, any[]> = {};
const nonCombinationItems: any[] = [];
materials.forEach((item) => {
if (item.combination_id) {
if (!combinationGroups[item.combination_id]) {
combinationGroups[item.combination_id] = [];
}
combinationGroups[item.combination_id].push(item);
} else {
nonCombinationItems.push(item);
}
});
const result: GroupedCuttingMaterials[] = [];
// Add combination groups
Object.entries(combinationGroups).forEach(([combinationId, items]) => {
const firstItem = items[0];
const rawMaterialName = firstItem.raw_material_name || 'Bahan Baku Tidak Diketahui';
const unitLabel = firstItem.raw_material_unit_label;
result.push({
rawMaterialId: Number(combinationId) * -1, // Negative ID to avoid collision
rawMaterialName: `Kombinasi`,
unitLabel,
items,
isCombination: true,
});
});
// Group non-combination items by raw material
const groups: Record<number, GroupedCuttingMaterials> = {};
nonCombinationItems.forEach((item) => {
const rawMaterialId = item.raw_material_id ?? 0;
const rawMaterialName =
item.raw_material_name || 'Bahan Baku Tidak Diketahui';
@ -81,7 +115,9 @@ function getGroupedMaterials(materials: any[]): GroupedCuttingMaterials[] {
groups[rawMaterialId].items.push(item);
});
return Object.values(groups);
result.push(...Object.values(groups));
return result;
}
function getGroupedResults(results: any[]): GroupedCuttingResults[] {
@ -260,14 +296,25 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
colspan="3"
class="font-semibold text-foreground"
>
{{ group.rawMaterialName }}
<Badge
v-if="group.unitLabel"
variant="secondary"
class="ml-2 font-normal"
>
{{ group.unitLabel }}
</Badge>
<template v-if="group.isCombination">
<span class="text-primary">{{ group.rawMaterialName }}</span>
<Badge
variant="default"
class="ml-2 font-normal"
>
{{ group.items.length }} bahan
</Badge>
</template>
<template v-else>
{{ group.rawMaterialName }}
<Badge
v-if="group.unitLabel"
variant="secondary"
class="ml-2 font-normal"
>
{{ group.unitLabel }}
</Badge>
</template>
</TableCell>
</TableRow>
<TableRow

View File

@ -33,6 +33,40 @@ function getGroupedMaterialUsage(materials: any[]): MaterialUsageGroup[] {
}));
}
interface MaterialGroup {
type: 'combination' | 'single';
name: string;
items: any[];
}
function getMaterialGroups(materials: any[]): MaterialGroup[] {
const combinationMap: Record<number, any[]> = {};
const singleItems: any[] = [];
materials.forEach((mat) => {
if (mat.combination_id) {
if (!combinationMap[mat.combination_id]) {
combinationMap[mat.combination_id] = [];
}
combinationMap[mat.combination_id].push(mat);
} else {
singleItems.push(mat);
}
});
const result: MaterialGroup[] = [];
Object.entries(combinationMap).forEach(([, items]) => {
result.push({ type: 'combination', name: 'Kombinasi', items });
});
if (singleItems.length > 0) {
result.push({ type: 'single', name: '', items: singleItems });
}
return result;
}
defineProps<{
cuttings: CuttingListItem[];
}>();
@ -64,12 +98,25 @@ defineProps<{
<div class="space-y-1">
<span class="font-medium text-foreground">Bahan Baku:</span>
<ul class="list-disc pl-4 space-y-0.5 text-muted-foreground">
<li v-for="mat in cutting.materials" :key="mat.id">
{{ mat.raw_material_name }} ({{
mat.variant }}) - {{ formatMaterialUsage(mat) }} {{ getMaterialUnit(mat) }}
</li>
<li v-if="!cutting.materials.length">Belum ada bahan baku</li>
<template v-if="cutting.materials.length">
<div v-for="(group, gi) in getMaterialGroups(cutting.materials)" :key="gi" class="mt-1">
<div v-if="group.type === 'combination'" class="mb-1">
<span class="text-primary font-medium text-[11px]">{{ group.name }} ({{ group.items.length }} bahan)</span>
<ul class="list-disc pl-4 space-y-0.5 text-muted-foreground">
<li v-for="mat in group.items" :key="mat.id">
{{ mat.raw_material_name }} ({{ mat.variant }}) - {{ formatMaterialUsage(mat) }} {{ getMaterialUnit(mat) }}
</li>
</ul>
</div>
<ul v-else class="list-disc pl-4 space-y-0.5 text-muted-foreground">
<li v-for="mat in group.items" :key="mat.id">
{{ mat.raw_material_name }} ({{ mat.variant }}) - {{ formatMaterialUsage(mat) }} {{ getMaterialUnit(mat) }}
</li>
</ul>
</div>
</template>
<ul v-else class="list-disc pl-4 space-y-0.5 text-muted-foreground">
<li>Belum ada bahan baku</li>
</ul>
</div>

View File

@ -19,6 +19,7 @@ export type CuttingMaterialListItem = {
raw_material_name?: string;
raw_material_unit_label?: string;
images?: MediaItem[];
combination_id?: number | null;
raw_material_price?: {
id: number;
variant: string;
@ -118,6 +119,7 @@ export type CuttingMaterialCartItem = {
stock_input: string;
material_usage: string;
images?: MediaItem[];
combination_id?: number | null;
};
export type CuttingResultCartItem = {
@ -147,6 +149,7 @@ export type CuttingEditItem = {
unit?: string;
unit_abbreviation?: string;
raw_material_name?: string;
combination_id?: number | null;
raw_material_price?: {
variant: string;
stock_input?: string;

View File

@ -354,6 +354,14 @@
->middleware('permission:'.Permission::CUTTINGS_CREATE->value)
->name('draft_results.destroy');
Route::post('draft-combinations', [CuttingDraftItemController::class, 'storeCombination'])
->middleware('permission:'.Permission::CUTTINGS_CREATE->value)
->name('draft_combinations.store');
Route::delete('draft-combinations/{combinationId}', [CuttingDraftItemController::class, 'destroyCombination'])
->middleware('permission:'.Permission::CUTTINGS_CREATE->value)
->name('draft_combinations.destroy');
Route::post('quick-create-raw-material', [CuttingDraftItemController::class, 'quickCreateRawMaterial'])
->middleware('permission:'.Permission::CUTTINGS_CREATE->value)
->name('quick_create_raw_material');

View File

@ -4,6 +4,7 @@
use App\Enums\Permission as PermissionEnum;
use App\Models\Cutting;
use App\Models\CuttingMaterial;
use App\Models\CuttingMaterialCombination;
use App\Models\CuttingResult;
use App\Models\Product;
use App\Models\ProductVariant;
@ -493,6 +494,184 @@ function setupDraftItems(User $user): array
});
});
// ─── Draft Combination ──────────────────────────────────────
describe('Draft Combination', function () {
test('authenticated user with permission can create draft combination', function () {
$user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_CREATE);
$rawMaterial = RawMaterial::factory()->create();
$price1 = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]);
$price2 = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]);
$this->actingAs($user)
->postJson(route('admin.manage.cuttings.draft_combinations.store'), [
'materials' => [
['raw_material_price_id' => $price1->id, 'material_usage' => 3],
['raw_material_price_id' => $price2->id, 'material_usage' => 2],
],
])
->assertOk()
->assertJsonStructure(['items' => [['raw_material_price_id', 'combination_id']]]);
$this->assertDatabaseCount('cutting_material_combinations', 1);
$this->assertDatabaseCount('cutting_materials', 2);
});
test('draft combination requires at least 2 materials', function () {
$user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_CREATE);
$rawMaterial = RawMaterial::factory()->create();
$price = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]);
$this->actingAs($user)
->postJson(route('admin.manage.cuttings.draft_combinations.store'), [
'materials' => [
['raw_material_price_id' => $price->id, 'material_usage' => 3],
],
])
->assertUnprocessable();
});
test('authenticated user can delete draft combination', function () {
$user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_CREATE);
$rawMaterial = RawMaterial::factory()->create();
$price1 = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]);
$price2 = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]);
$this->actingAs($user)
->postJson(route('admin.manage.cuttings.draft_combinations.store'), [
'materials' => [
['raw_material_price_id' => $price1->id, 'material_usage' => 3],
['raw_material_price_id' => $price2->id, 'material_usage' => 2],
],
])
->assertOk();
$combination = CuttingMaterialCombination::first();
$this->actingAs($user)
->deleteJson(route('admin.manage.cuttings.draft_combinations.destroy', $combination->id))
->assertOk();
$this->assertDatabaseCount('cutting_material_combinations', 0);
$this->assertDatabaseCount('cutting_materials', 0);
});
test('user without permission cannot create draft combination', function () {
$user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW);
$rawMaterial = RawMaterial::factory()->create();
$price1 = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]);
$price2 = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]);
$this->actingAs($user)
->postJson(route('admin.manage.cuttings.draft_combinations.store'), [
'materials' => [
['raw_material_price_id' => $price1->id, 'material_usage' => 3],
['raw_material_price_id' => $price2->id, 'material_usage' => 2],
],
])
->assertForbidden();
});
});
// ─── Store with Combination ─────────────────────────────────
describe('Cutting Store with Combination', function () {
test('cutting can be created with combination materials', function () {
$user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_CREATE);
$rawMaterial = RawMaterial::factory()->create();
$price1 = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]);
$price2 = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->create(['product_id' => $product->id]);
// Create draft combination
$this->actingAs($user)
->postJson(route('admin.manage.cuttings.draft_combinations.store'), [
'materials' => [
['raw_material_price_id' => $price1->id, 'material_usage' => 3],
['raw_material_price_id' => $price2->id, 'material_usage' => 2],
],
])
->assertOk();
// Create draft result
$this->actingAs($user)
->postJson(route('admin.manage.cuttings.draft_results.store'), [
'product_variant_id' => $variant->id,
'cutting_result' => 10,
'sample' => 8,
'original_outside_sample' => 2,
])
->assertOk();
// Store cutting
$this->actingAs($user)
->post(route('admin.manage.cuttings.store'), [
'description' => 'Cutting with combination',
])
->assertRedirect(route('admin.manage.cuttings.index'));
$cutting = Cutting::where('description', 'Cutting with combination')->first();
expect($cutting)->not->toBeNull();
expect($cutting->materials)->toHaveCount(2);
expect($cutting->materials->first()->combination_id)->not->toBeNull();
expect($cutting->materials->first()->combination_id)->toBe($cutting->materials->last()->combination_id);
});
});
// ─── Update with Combination ────────────────────────────────
describe('Cutting Update with Combination', function () {
test('cutting can be updated preserving combination_id', function () {
$user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_UPDATE);
$cutting = createCuttingWithMaterialsAndResults($user);
$rawMaterial = RawMaterial::factory()->create();
$price1 = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]);
$price2 = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]);
$combination = CuttingMaterialCombination::factory()->create([
'cutting_id' => $cutting->id,
'user_id' => null,
]);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->create(['product_id' => $product->id]);
$this->actingAs($user)
->put(route('admin.manage.cuttings.update', $cutting), [
'description' => 'Updated with combination',
'sewing_cost' => 0,
'other_cost' => 0,
'materials' => [
['raw_material_price_id' => $price1->id, 'material_usage' => 3, 'combination_id' => $combination->id],
['raw_material_price_id' => $price2->id, 'material_usage' => 2, 'combination_id' => $combination->id],
],
'results' => [
[
'product_variant_id' => $variant->id,
'cutting_result' => 10,
'sample' => 8,
'original_outside_sample' => 2,
],
],
])
->assertRedirect(route('admin.manage.cuttings.index'));
$cutting->refresh();
expect($cutting->description)->toBe('Updated with combination');
expect($cutting->materials)->toHaveCount(2);
expect($cutting->materials->first()->combination_id)->toBe($combination->id);
});
});
// ─── Cutting Model ────────────────────────────────────────
describe('Cutting Model', function () {
@ -612,3 +791,55 @@ function setupDraftItems(User $user): array
expect($result->trashed())->toBeTrue();
});
});
// ─── CuttingMaterialCombination Model ────────────────────────
describe('CuttingMaterialCombination Model', function () {
test('combination uses soft deletes', function () {
$combination = CuttingMaterialCombination::factory()->create();
$combination->delete();
expect($combination->trashed())->toBeTrue();
});
test('combination belongs to cutting', function () {
$cutting = Cutting::factory()->create();
$combination = CuttingMaterialCombination::factory()->create([
'cutting_id' => $cutting->id,
]);
expect($combination->cutting)->not->toBeNull();
expect($combination->cutting->id)->toBe($cutting->id);
});
test('combination has many materials', function () {
$combination = CuttingMaterialCombination::factory()->create();
$rawMaterial = RawMaterial::factory()->create();
$price1 = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]);
$price2 = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]);
CuttingMaterial::factory()->create([
'combination_id' => $combination->id,
'raw_material_price_id' => $price1->id,
]);
CuttingMaterial::factory()->create([
'combination_id' => $combination->id,
'raw_material_price_id' => $price2->id,
]);
expect($combination->materials)->toHaveCount(2);
});
test('combination can be draft (cutting_id null)', function () {
$user = User::factory()->create();
$combination = CuttingMaterialCombination::factory()->create([
'user_id' => $user->id,
'cutting_id' => null,
]);
expect($combination->cutting_id)->toBeNull();
expect($combination->user_id)->toBe($user->id);
});
});