feat: add material_result field to CuttingMaterial and CuttingMaterialCombination; update validation rules and service logic to handle new material result calculations; enhance frontend components for displaying material results in cutting management
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (8.3) (push) Waiting to run
tests / ci (8.4) (push) Waiting to run
tests / ci (8.5) (push) Waiting to run

This commit is contained in:
Yoga Pangestu 2026-07-04 23:14:37 +07:00
parent a3ff10d6e1
commit 15e2b7ec5b
88 changed files with 824 additions and 496 deletions

View File

@ -26,6 +26,7 @@ public function rules(): array
Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'), Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'),
], ],
'materials.*.material_usage' => ['required', 'numeric', 'decimal:0,4', 'gt:0'], 'materials.*.material_usage' => ['required', 'numeric', 'decimal:0,4', 'gt:0'],
'material_result' => ['nullable', 'integer', 'gte:0'],
]; ];
} }
} }

View File

@ -25,6 +25,7 @@ public function rules(): array
Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'), Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'),
], ],
'material_usage' => ['required', 'numeric', 'decimal:0,4', 'gt:0'], 'material_usage' => ['required', 'numeric', 'decimal:0,4', 'gt:0'],
'material_result' => ['nullable', 'integer', 'gte:0'],
]; ];
} }
} }

View File

@ -42,7 +42,9 @@ public function rules(): array
Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'), Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'),
]; ];
$rules['materials.*.material_usage'] = ['required', 'numeric', 'decimal:0,4', 'gt:0']; $rules['materials.*.material_usage'] = ['required', 'numeric', 'decimal:0,4', 'gt:0'];
$rules['materials.*.combination_id'] = ['nullable', 'integer', 'exists:cutting_material_combinations,id']; $rules['materials.*.material_result'] = ['nullable', 'integer', 'min:0'];
$rules['materials.*.combination_id'] = ['nullable', 'integer'];
$rules['materials.*.combination_material_result'] = ['nullable', 'integer', 'min:0'];
$rules['results'] = ['required', 'array', 'min:1']; $rules['results'] = ['required', 'array', 'min:1'];
$rules['results.*.product_variant_id'] = [ $rules['results.*.product_variant_id'] = [
@ -73,6 +75,8 @@ public function attributes(): array
'materials' => 'Bahan Baku', 'materials' => 'Bahan Baku',
'materials.*.raw_material_price_id' => 'Bahan Baku', 'materials.*.raw_material_price_id' => 'Bahan Baku',
'materials.*.material_usage' => 'Pemakaian', 'materials.*.material_usage' => 'Pemakaian',
'materials.*.material_result' => 'Hasil',
'materials.*.combination_material_result' => 'Hasil Kombinasi',
'results' => 'Hasil Produk', 'results' => 'Hasil Produk',
'results.*.product_variant_id' => 'Varian Produk', 'results.*.product_variant_id' => 'Varian Produk',
'results.*.cutting_result' => 'Hasil', 'results.*.cutting_result' => 'Hasil',

View File

@ -113,6 +113,11 @@ public function materials(): HasMany
return $this->hasMany(CuttingMaterial::class); return $this->hasMany(CuttingMaterial::class);
} }
public function combinations(): HasMany
{
return $this->hasMany(CuttingMaterialCombination::class);
}
public function resultPrices(): HasMany public function resultPrices(): HasMany
{ {
return $this->hasMany(CuttingResultPrice::class); return $this->hasMany(CuttingResultPrice::class);

View File

@ -27,6 +27,7 @@ protected function casts(): array
{ {
return [ return [
'material_usage' => 'decimal:4', 'material_usage' => 'decimal:4',
'material_result' => 'integer',
]; ];
} }

View File

@ -15,6 +15,13 @@ class CuttingMaterialCombination extends Model
{ {
use HasFactory, InteractsWithActivityLog, SoftDeletes; use HasFactory, InteractsWithActivityLog, SoftDeletes;
protected function casts(): array
{
return [
'material_result' => 'integer',
];
}
public function cutting(): BelongsTo public function cutting(): BelongsTo
{ {
return $this->belongsTo(Cutting::class); return $this->belongsTo(Cutting::class);

View File

@ -301,6 +301,10 @@ public function syncDraftMaterial(array $validated, User $user): array
]); ]);
} }
$materialResult = array_key_exists('material_result', $validated) && $validated['material_result'] !== null
? (int) $validated['material_result']
: null;
$item = CuttingMaterial::query()->updateOrCreate( $item = CuttingMaterial::query()->updateOrCreate(
[ [
'user_id' => $user->id, 'user_id' => $user->id,
@ -309,6 +313,7 @@ public function syncDraftMaterial(array $validated, User $user): array
], ],
[ [
'material_usage' => $materialUsage, 'material_usage' => $materialUsage,
'material_result' => $materialResult,
], ],
); );
@ -409,9 +414,14 @@ public function removeDraftResult(User $user, ProductVariant $productVariant): v
*/ */
public function syncDraftCombination(array $validated, User $user): array public function syncDraftCombination(array $validated, User $user): array
{ {
$materialResult = array_key_exists('material_result', $validated) && $validated['material_result'] !== null
? (int) $validated['material_result']
: null;
$combination = CuttingMaterialCombination::create([ $combination = CuttingMaterialCombination::create([
'user_id' => $user->id, 'user_id' => $user->id,
'cutting_id' => null, 'cutting_id' => null,
'material_result' => $materialResult,
]); ]);
$items = []; $items = [];
@ -558,7 +568,7 @@ public function update(Cutting $cutting, array $validated): void
$this->runInTransaction( $this->runInTransaction(
function () use ($cutting, $validated): void { function () use ($cutting, $validated): void {
$cutting->load(['materials.rawMaterialPrice.rawMaterial', 'results']); $cutting->load(['materials.rawMaterialPrice.rawMaterial', 'results', 'materials.combination']);
if ($cutting->status === CuttingStatus::IN_PROGRESS) { if ($cutting->status === CuttingStatus::IN_PROGRESS) {
$this->reverseTotalMaterialStock($cutting); $this->reverseTotalMaterialStock($cutting);
@ -568,6 +578,7 @@ function () use ($cutting, $validated): void {
$cutting->materials()->delete(); $cutting->materials()->delete();
$cutting->results()->delete(); $cutting->results()->delete();
$cutting->combinations()->delete();
$materials = $this->buildMaterials($validated['materials']); $materials = $this->buildMaterials($validated['materials']);
$results = $this->buildResults($validated['results']); $results = $this->buildResults($validated['results']);
@ -583,8 +594,41 @@ function () use ($cutting, $validated): void {
$this->syncImages($cutting, $validated); $this->syncImages($cutting, $validated);
// Group materials by combination_id to create combinations
$combinationGroups = [];
foreach ($materials as $materialData) { foreach ($materials as $materialData) {
$cutting->materials()->create($materialData); $combinationId = $materialData['combination_id'];
if ($combinationId !== null) {
if (!isset($combinationGroups[$combinationId])) {
$combinationGroups[$combinationId] = [
'materials' => [],
'material_result' => $materialData['combination_material_result'] ?? null,
];
}
$combinationGroups[$combinationId]['materials'][] = $materialData;
}
}
// Create combinations and update combination_id for materials
$combinationIdMap = [];
foreach ($combinationGroups as $oldCombinationId => $group) {
$combination = $cutting->combinations()->create([
'material_result' => $group['material_result'],
]);
$combinationIdMap[$oldCombinationId] = $combination->id;
}
foreach ($materials as $materialData) {
$newCombinationId = $materialData['combination_id'] !== null
? $combinationIdMap[$materialData['combination_id']] ?? null
: null;
$cutting->materials()->create([
'raw_material_price_id' => $materialData['raw_material_price_id'],
'material_usage' => $materialData['material_usage'],
'material_result' => $materialData['material_result'],
'combination_id' => $newCombinationId,
]);
} }
foreach ($results as $resultData) { foreach ($results as $resultData) {
@ -756,10 +800,16 @@ private function buildMaterials(array $materials): array
]); ]);
} }
$materialResult = array_key_exists('material_result', $itemData) && $itemData['material_result'] !== null
? (int) $itemData['material_result']
: null;
return [ return [
'raw_material_price_id' => $price->id, 'raw_material_price_id' => $price->id,
'material_usage' => $materialUsage, 'material_usage' => $materialUsage,
'material_result' => $materialResult,
'combination_id' => $itemData['combination_id'] ?? null, 'combination_id' => $itemData['combination_id'] ?? null,
'combination_material_result' => $itemData['combination_material_result'] ?? null,
]; ];
}) })
->all(); ->all();
@ -909,6 +959,12 @@ private function breakMaterialCircularReference(CuttingMaterial $material): void
{ {
$price = $material->rawMaterialPrice; $price = $material->rawMaterialPrice;
// Always set these attributes regardless of price
$material->setAttribute('material_result', $material->material_result);
$material->setAttribute('material_result_input', $material->material_result);
$material->setAttribute('combination_id', $material->combination_id);
$material->setAttribute('combination_material_result', $material->combination?->material_result);
if ($price) { if ($price) {
$rawMaterial = $price->rawMaterial; $rawMaterial = $price->rawMaterial;
@ -929,7 +985,6 @@ private function breakMaterialCircularReference(CuttingMaterial $material): void
$price->unsetRelation('rawMaterial'); $price->unsetRelation('rawMaterial');
} }
$material->setAttribute('combination_id', $material->combination_id);
$material->unsetRelation('rawMaterialPrice'); $material->unsetRelation('rawMaterialPrice');
$material->unsetRelation('combination'); $material->unsetRelation('combination');
} }
@ -963,6 +1018,7 @@ private function presentDraftMaterial(CuttingMaterial $item): array
{ {
$price = $item->rawMaterialPrice; $price = $item->rawMaterialPrice;
$rawMaterial = $price?->rawMaterial; $rawMaterial = $price?->rawMaterial;
$combination = $item->combination;
return [ return [
'raw_material_price_id' => $item->raw_material_price_id, 'raw_material_price_id' => $item->raw_material_price_id,
@ -972,8 +1028,10 @@ private function presentDraftMaterial(CuttingMaterial $item): array
'unit_abbreviation' => $rawMaterial?->unit?->abbreviation() ?? '', 'unit_abbreviation' => $rawMaterial?->unit?->abbreviation() ?? '',
'stock_input' => $price?->stock_input ?? '', 'stock_input' => $price?->stock_input ?? '',
'material_usage' => $this->formatQuantityInput((float) $item->material_usage), 'material_usage' => $this->formatQuantityInput((float) $item->material_usage),
'material_result' => $item->material_result !== null ? (int) $item->material_result : null,
'images' => $price ? MediaPresenter::collection($price, 'images') : [], 'images' => $price ? MediaPresenter::collection($price, 'images') : [],
'combination_id' => $item->combination_id, 'combination_id' => $item->combination_id,
'combination_material_result' => $combination?->material_result,
]; ];
} }

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('cutting_materials', function (Blueprint $table) {
$table->decimal('material_result', 18, 2)->nullable()->after('material_usage');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('cutting_materials', function (Blueprint $table) {
$table->dropColumn('material_result');
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('cutting_materials', function (Blueprint $table) {
$table->integer('material_result')->nullable()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('cutting_materials', function (Blueprint $table) {
$table->decimal('material_result', 18, 2)->nullable()->change();
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('cutting_material_combinations', function (Blueprint $table) {
$table->integer('material_result')->nullable()->after('cutting_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('cutting_material_combinations', function (Blueprint $table) {
$table->dropColumn('material_result');
});
}
};

View File

@ -4,7 +4,6 @@ import type { ColumnDef } from '@tanstack/vue-table';
import { FlexRender, getCoreRowModel, useVueTable } from '@tanstack/vue-table'; import { FlexRender, getCoreRowModel, useVueTable } from '@tanstack/vue-table';
import { computed, provide } from 'vue'; import { computed, provide } from 'vue';
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue'; import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
import { buildPaginationSummary } from '@/lib/grouped-table';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Table, Table,
@ -15,6 +14,7 @@ import {
TableRow, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
import TableEmpty from '@/components/ui/table/TableEmpty.vue'; import TableEmpty from '@/components/ui/table/TableEmpty.vue';
import { buildPaginationSummary } from '@/lib/grouped-table';
import type { import type {
DataTableFilterDef, DataTableFilterDef,
DataTablePagination, DataTablePagination,

View File

@ -11,6 +11,7 @@ export function useCan() {
if (Array.isArray(permission)) { if (Array.isArray(permission)) {
return permission.some((p) => permissions.value.includes(p)); return permission.some((p) => permissions.value.includes(p));
} }
return permissions.value.includes(permission); return permissions.value.includes(permission);
} }

View File

@ -26,8 +26,10 @@ export function useDestroy({ url, preserveScroll = true, errorMessage, onSuccess
onError: (errors) => { onError: (errors) => {
if (onError) { if (onError) {
const result = onError(errors); const result = onError(errors);
if (typeof result === 'string') { if (typeof result === 'string') {
toast.error(result); toast.error(result);
return; return;
} }
} }

View File

@ -1,4 +1,5 @@
import { computed, type MaybeRefOrGetter, toValue } from 'vue'; import { computed, toValue } from 'vue';
import type {MaybeRefOrGetter} from 'vue';
import { buildPaginationSummary } from '@/lib/grouped-table'; import { buildPaginationSummary } from '@/lib/grouped-table';
import type { DataTablePagination } from '@/types/data-table'; import type { DataTablePagination } from '@/types/data-table';

View File

@ -1,5 +1,5 @@
import type { Component } from 'vue';
import { Check, RotateCcw, Scissors, X } from '@lucide/vue'; import { Check, RotateCcw, Scissors, X } from '@lucide/vue';
import type { Component } from 'vue';
import type { BadgeVariant } from '@/lib/badge-variant'; import type { BadgeVariant } from '@/lib/badge-variant';
export const CuttingStatus = { export const CuttingStatus = {

View File

@ -1,5 +1,5 @@
import type { Component } from 'vue';
import { Check, Send, X } from '@lucide/vue'; import { Check, Send, X } from '@lucide/vue';
import type { Component } from 'vue';
import type { BadgeVariant } from '@/lib/badge-variant'; import type { BadgeVariant } from '@/lib/badge-variant';
export const OrderStatus = { export const OrderStatus = {

View File

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue';
import { Home, Settings2, Share2, ShoppingBag, Users } from '@lucide/vue'; import { Home, Settings2, Share2, ShoppingBag, Users } from '@lucide/vue';
import { SettingSection as SettingSectionConst } from '@/constants/setting-section'; import { computed } from 'vue';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
import { SettingSection as SettingSectionConst } from '@/constants/setting-section';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { SettingSection } from '@/types/setting'; import type { SettingSection } from '@/types/setting';
@ -23,6 +23,7 @@ const filteredNavItems = computed(() => {
if (hasRole('admin-toko')) { if (hasRole('admin-toko')) {
return navItems.filter((item) => item.key === SettingSectionConst.MARKETPLACE); return navItems.filter((item) => item.key === SettingSectionConst.MARKETPLACE);
} }
return navItems; return navItems;
}); });
</script> </script>

View File

@ -212,6 +212,7 @@ const donutSegmentSelector = Donut.selectors.segment;
function channelTooltip(arc: any) { function channelTooltip(arc: any) {
const d = arc.data as typeof props.orderStats.by_channel[number]; const d = arc.data as typeof props.orderStats.by_channel[number];
return `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.2"> return `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.2">
<p style="margin:0;display:flex;align-items:center;gap:0.5rem"> <p style="margin:0;display:flex;align-items:center;gap:0.5rem">
<span class="size-2.5 rounded-full" style="background-color: ${channelChartConfig.value[d.channel]?.color ?? channelColors[0]}"></span> <span class="size-2.5 rounded-full" style="background-color: ${channelChartConfig.value[d.channel]?.color ?? channelColors[0]}"></span>
@ -223,6 +224,7 @@ function channelTooltip(arc: any) {
function paymentTooltip(arc: any) { function paymentTooltip(arc: any) {
const d = arc.data as typeof props.orderStats.by_payment_type[number]; const d = arc.data as typeof props.orderStats.by_payment_type[number];
return `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.2"> return `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.2">
<p style="margin:0;display:flex;align-items:center;gap:0.5rem"> <p style="margin:0;display:flex;align-items:center;gap:0.5rem">
<span class="size-2.5 rounded-full" style="background-color: ${paymentChartConfig.value[d.payment_type]?.color ?? paymentColors[0]}"></span> <span class="size-2.5 rounded-full" style="background-color: ${paymentChartConfig.value[d.payment_type]?.color ?? paymentColors[0]}"></span>
@ -235,6 +237,7 @@ function paymentTooltip(arc: any) {
function marketingTooltip(arc: any) { function marketingTooltip(arc: any) {
const d = arc.data as typeof props.orderStats.by_marketing[number]; const d = arc.data as typeof props.orderStats.by_marketing[number];
const idx = props.orderStats.by_marketing.indexOf(d); const idx = props.orderStats.by_marketing.indexOf(d);
return `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.2"> return `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.2">
<p style="margin:0;display:flex;align-items:center;gap:0.5rem"> <p style="margin:0;display:flex;align-items:center;gap:0.5rem">
<span class="size-2.5 rounded-full" style="background-color: ${marketingColors[idx % marketingColors.length]}"></span> <span class="size-2.5 rounded-full" style="background-color: ${marketingColors[idx % marketingColors.length]}"></span>
@ -246,6 +249,7 @@ function marketingTooltip(arc: any) {
function statusTooltip(arc: any) { function statusTooltip(arc: any) {
const d = arc.data as typeof props.orderStats.by_status[number]; const d = arc.data as typeof props.orderStats.by_status[number];
return `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.2"> return `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.2">
<p style="margin:0;display:flex;align-items:center;gap:0.5rem"> <p style="margin:0;display:flex;align-items:center;gap:0.5rem">
<span class="size-2.5 rounded-full" style="background-color: ${statusChartConfig.value[d.status]?.color ?? statusColors[0]}"></span> <span class="size-2.5 rounded-full" style="background-color: ${statusChartConfig.value[d.status]?.color ?? statusColors[0]}"></span>

View File

@ -1,7 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import type {
ChartConfig,
} from "@/components/ui/chart"
import { TrendingUp } from "@lucide/vue" import { TrendingUp } from "@lucide/vue"
import { CurveType } from "@unovis/ts" import { CurveType } from "@unovis/ts"
@ -14,6 +11,9 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card" } from "@/components/ui/card"
import type {
ChartConfig,
} from "@/components/ui/chart"
import { import {
ChartContainer, ChartContainer,
ChartCrosshair, ChartCrosshair,

View File

@ -17,9 +17,9 @@ import { AppearanceMode as AppearanceModeConst } from '@/constants/appearance-mo
import AccountLayout from '@/layouts/AccountLayout.vue'; import AccountLayout from '@/layouts/AccountLayout.vue';
import { formErrors } from '@/lib/form'; import { formErrors } from '@/lib/form';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { update } from '@/routes/admin/account/appearance';
import type { AppearanceFormData, AppearanceMode } from '@/types/account'; import type { AppearanceFormData, AppearanceMode } from '@/types/account';
import { update } from '@/routes/admin/account/appearance';
const props = defineProps<{ const props = defineProps<{
appearance: AppearanceMode; appearance: AppearanceMode;

View File

@ -2,8 +2,8 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { RowDeleteAction, RowEditAction } from '@/components/button'; import { RowDeleteAction, RowEditAction } from '@/components/button';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
import type { CashTransactionListItem } from '@/types/cash';
import { destroy } from '@/routes/admin/finance/cash/transactions'; import { destroy } from '@/routes/admin/finance/cash/transactions';
import type { CashTransactionListItem } from '@/types/cash';
const props = defineProps<{ const props = defineProps<{
transaction: CashTransactionListItem; transaction: CashTransactionListItem;

View File

@ -1,4 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { HandCoins } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import CreateButton from '@/components/button/CreateButton.vue'; import CreateButton from '@/components/button/CreateButton.vue';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -9,19 +12,16 @@ import {
} from '@/composables/useDataTableQuery'; } from '@/composables/useDataTableQuery';
import { EmployeeAdvanceStatus } from '@/constants/employee-advance-status'; import { EmployeeAdvanceStatus } from '@/constants/employee-advance-status';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { index } from '@/routes/admin/finance/employee_advances';
import type { DataTableFilterDef, DataTableSort } from '@/types/data-table'; import type { DataTableFilterDef, DataTableSort } from '@/types/data-table';
import type { import type {
EmployeeAdvanceListItem, EmployeeAdvanceListItem,
EmployeeAdvancePageProps, EmployeeAdvancePageProps,
} from '@/types/employee-advance'; } from '@/types/employee-advance';
import { Head } from '@inertiajs/vue3';
import { HandCoins } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import EmployeeAdvanceFormModal from './form/EmployeeAdvanceFormModal.vue'; import EmployeeAdvanceFormModal from './form/EmployeeAdvanceFormModal.vue';
import RejectEmployeeAdvanceModal from './form/RejectEmployeeAdvanceModal.vue'; import RejectEmployeeAdvanceModal from './form/RejectEmployeeAdvanceModal.vue';
import { createColumns } from './table/columns'; import { createColumns } from './table/columns';
import { index } from '@/routes/admin/finance/employee_advances';
const props = defineProps<EmployeeAdvancePageProps>(); const props = defineProps<EmployeeAdvancePageProps>();

View File

@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import CreateButton from '@/components/button/CreateButton.vue'; import CreateButton from '@/components/button/CreateButton.vue';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
@ -8,14 +10,12 @@ import {
useDataTableQuerySync, useDataTableQuerySync,
} from '@/composables/useDataTableQuery'; } from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { index } from '@/routes/admin/finance/expenses';
import type { DataTableSort } from '@/types/data-table'; import type { DataTableSort } from '@/types/data-table';
import type { ExpenseListItem, PaginatedExpenses } from '@/types/expense'; import type { ExpenseListItem, PaginatedExpenses } from '@/types/expense';
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import ExpenseFormModal from './form/ExpenseFormModal.vue'; import ExpenseFormModal from './form/ExpenseFormModal.vue';
import { createColumns } from './table/columns'; import { createColumns } from './table/columns';
import { index } from '@/routes/admin/finance/expenses';
const props = defineProps<{ const props = defineProps<{
expenses: PaginatedExpenses; expenses: PaginatedExpenses;

View File

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { RowDeleteAction, RowEditAction } from '@/components/button'; import { RowDeleteAction, RowEditAction } from '@/components/button';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
import type { ExpenseListItem } from '@/types/expense';
import { destroy } from '@/routes/admin/finance/expenses'; import { destroy } from '@/routes/admin/finance/expenses';
import type { ExpenseListItem } from '@/types/expense';
defineProps<{ defineProps<{
expense: ExpenseListItem; expense: ExpenseListItem;

View File

@ -22,12 +22,12 @@ import {
useDataTableQuerySync, useDataTableQuerySync,
} from '@/composables/useDataTableQuery'; } from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { index } from '@/routes/admin/finance/payroll';
import type { DataTableSort } from '@/types/data-table'; import type { DataTableSort } from '@/types/data-table';
import type { PayrollListItem, PayrollPageProps } from '@/types/payroll'; import type { PayrollListItem, PayrollPageProps } from '@/types/payroll';
import PayrollAdjustmentModal from './form/PayrollAdjustmentModal.vue'; import PayrollAdjustmentModal from './form/PayrollAdjustmentModal.vue';
import { createColumns } from './table/columns'; import { createColumns } from './table/columns';
import { index } from '@/routes/admin/finance/payroll';
const props = defineProps<PayrollPageProps>(); const props = defineProps<PayrollPageProps>();

View File

@ -7,13 +7,13 @@ import FullCalendar from '@fullcalendar/vue3';
import { router } from '@inertiajs/vue3'; import { router } from '@inertiajs/vue3';
import { ChevronLeft, ChevronRight } from '@lucide/vue'; import { ChevronLeft, ChevronRight } from '@lucide/vue';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import AttendanceDetailDialog from './AttendanceDetailDialog.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { DatePicker } from '@/components/ui/date-picker'; import { DatePicker } from '@/components/ui/date-picker';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { AttendanceListItem, CalendarRange } from '@/types/attendance';
import { index } from '@/routes/admin/hr/attendances'; import { index } from '@/routes/admin/hr/attendances';
import type { AttendanceListItem, CalendarRange } from '@/types/attendance';
import AttendanceDetailDialog from './AttendanceDetailDialog.vue';
const props = defineProps<{ const props = defineProps<{
attendances: AttendanceListItem[]; attendances: AttendanceListItem[];
@ -147,6 +147,7 @@ function scrollToToday() {
requestAnimationFrame(() => { requestAnimationFrame(() => {
const container = calendarRef.value?.$el?.closest('.overflow-x-auto'); const container = calendarRef.value?.$el?.closest('.overflow-x-auto');
const todayEl = container?.querySelector('.fc-day-today'); const todayEl = container?.querySelector('.fc-day-today');
if (!container || !todayEl) { if (!container || !todayEl) {
return; return;
} }

View File

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { RowDeleteAction } from '@/components/button'; import { RowDeleteAction } from '@/components/button';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
import type { AttendanceListItem } from '@/types/attendance';
import { destroy } from '@/routes/admin/hr/attendances'; import { destroy } from '@/routes/admin/hr/attendances';
import type { AttendanceListItem } from '@/types/attendance';
defineProps<{ defineProps<{
attendance: AttendanceListItem; attendance: AttendanceListItem;

View File

@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import BackButton from '@/components/button/BackButton.vue'; import BackButton from '@/components/button/BackButton.vue';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import type { EnumOption } from '@/types/employee';
import { Head } from '@inertiajs/vue3';
import EmployeeForm from './form/EmployeeForm.vue';
import { index, store } from '@/routes/admin/hr/employees'; import { index, store } from '@/routes/admin/hr/employees';
import type { EnumOption } from '@/types/employee';
import EmployeeForm from './form/EmployeeForm.vue';
defineProps<{ defineProps<{
genders: EnumOption[]; genders: EnumOption[];

View File

@ -1,12 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import BackButton from '@/components/button/BackButton.vue';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { EmployeeListItem, EnumOption } from '@/types/employee';
import type { MediaItem } from '@/types/media';
import { Head } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import { computed } from 'vue'; import { computed } from 'vue';
import EmployeeForm from './form/EmployeeForm.vue'; import BackButton from '@/components/button/BackButton.vue';
import AdminLayout from '@/layouts/AdminLayout.vue';
import { index, update } from '@/routes/admin/hr/employees'; import { index, update } from '@/routes/admin/hr/employees';
import type { EmployeeListItem, EnumOption } from '@/types/employee';
import type { MediaItem } from '@/types/media';
import EmployeeForm from './form/EmployeeForm.vue';
const props = defineProps<{ const props = defineProps<{
employee: EmployeeListItem; employee: EmployeeListItem;

View File

@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import BackButton from '@/components/button/BackButton.vue'; import BackButton from '@/components/button/BackButton.vue';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { index, store } from '@/routes/admin/manage/cuttings';
import type { import type {
CuttingMaterialCartItem, CuttingMaterialCartItem,
CuttingProductCatalogItem, CuttingProductCatalogItem,
@ -9,9 +11,7 @@ import type {
} from '@/types/cutting'; } from '@/types/cutting';
import type { CategoryOption } from '@/types/product'; import type { CategoryOption } from '@/types/product';
import type { EnumOption } from '@/types/raw-material'; import type { EnumOption } from '@/types/raw-material';
import { Head } from '@inertiajs/vue3';
import CuttingPosForm from './form/CuttingPosForm.vue'; import CuttingPosForm from './form/CuttingPosForm.vue';
import { index, store } from '@/routes/admin/manage/cuttings';
defineProps<{ defineProps<{
rawMaterialCatalog: CuttingRawMaterialCatalogItem[]; rawMaterialCatalog: CuttingRawMaterialCatalogItem[];

View File

@ -1,6 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed } from 'vue';
import BackButton from '@/components/button/BackButton.vue'; import BackButton from '@/components/button/BackButton.vue';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { index, update } from '@/routes/admin/manage/cuttings';
import type { import type {
CuttingEditItem, CuttingEditItem,
CuttingProductCatalogItem, CuttingProductCatalogItem,
@ -8,10 +11,7 @@ import type {
} from '@/types/cutting'; } from '@/types/cutting';
import type { CategoryOption } from '@/types/product'; import type { CategoryOption } from '@/types/product';
import type { EnumOption } from '@/types/raw-material'; import type { EnumOption } from '@/types/raw-material';
import { Head } from '@inertiajs/vue3';
import { computed } from 'vue';
import CuttingPosForm from './form/CuttingPosForm.vue'; import CuttingPosForm from './form/CuttingPosForm.vue';
import { index, update } from '@/routes/admin/manage/cuttings';
const props = defineProps<{ const props = defineProps<{
cutting: CuttingEditItem; cutting: CuttingEditItem;
@ -34,8 +34,10 @@ const initialData = computed(() => ({
unit_abbreviation: item.unit_abbreviation ?? '', unit_abbreviation: item.unit_abbreviation ?? '',
stock_input: item.stock_input ?? '', stock_input: item.stock_input ?? '',
material_usage: item.material_usage_input, material_usage: item.material_usage_input,
material_result: item.material_result_input !== null && item.material_result_input !== undefined ? String(item.material_result_input) : null,
images: item.images ?? [], images: item.images ?? [],
combination_id: item.combination_id ?? null, combination_id: item.combination_id ?? null,
combination_material_result: item.combination_material_result ?? null,
})), })),
results: props.cutting.results.map((item) => ({ results: props.cutting.results.map((item) => ({
product_variant_id: item.product_variant_id, product_variant_id: item.product_variant_id,
@ -51,12 +53,11 @@ const initialData = computed(() => ({
</script> </script>
<template> <template>
<Head title="Ubah Cutting" /> <Head title="Ubah Cutting" />
<AdminLayout> <AdminLayout>
<div <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"
>
<div class="space-y-1"> <div class="space-y-1">
<h2 class="text-2xl font-bold tracking-tight">Ubah Cutting</h2> <h2 class="text-2xl font-bold tracking-tight">Ubah Cutting</h2>
</div> </div>
@ -64,15 +65,8 @@ const initialData = computed(() => ({
<BackButton :href="index.url()" /> <BackButton :href="index.url()" />
</div> </div>
<CuttingPosForm <CuttingPosForm :raw-material-catalog="rawMaterialCatalog" :product-catalog="productCatalog"
:raw-material-catalog="rawMaterialCatalog" :categories="categories" :units="units" :initial-data="initialData"
:product-catalog="productCatalog" :submit-url="update.url(props.cutting.id)" method="put" submit-label="Perbarui" />
:categories="categories"
:units="units"
:initial-data="initialData"
:submit-url="update.url(props.cutting.id)"
method="put"
submit-label="Perbarui"
/>
</AdminLayout> </AdminLayout>
</template> </template>

View File

@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import CreateButton from '@/components/button/CreateButton.vue'; import CreateButton from '@/components/button/CreateButton.vue';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
@ -7,12 +9,10 @@ import {
useDataTableQuerySync, useDataTableQuerySync,
} from '@/composables/useDataTableQuery'; } from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { index, create } from '@/routes/admin/manage/cuttings';
import type { CuttingListItem, PaginatedCuttings } from '@/types/cutting'; import type { CuttingListItem, PaginatedCuttings } from '@/types/cutting';
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import CuttingGroupedTable from './table/CuttingGroupedTable.vue'; import CuttingGroupedTable from './table/CuttingGroupedTable.vue';
import CuttingInProgressSection from './table/CuttingInProgressSection.vue'; import CuttingInProgressSection from './table/CuttingInProgressSection.vue';
import { index, create } from '@/routes/admin/manage/cuttings';
const props = defineProps<{ const props = defineProps<{
cuttings: PaginatedCuttings; cuttings: PaginatedCuttings;

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
Table, Table,
@ -10,7 +11,6 @@ import {
TableRow, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
import { cuttingStatusBadgeVariant } from '@/constants/cutting-status'; import { cuttingStatusBadgeVariant } from '@/constants/cutting-status';
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
import type { CuttingListItem } from '@/types/cutting'; import type { CuttingListItem } from '@/types/cutting';
const props = defineProps<{ const props = defineProps<{
@ -41,6 +41,7 @@ const groupedMaterials = computed<GroupedMaterials[]>(() => {
if (!combinationGroups[item.combination_id]) { if (!combinationGroups[item.combination_id]) {
combinationGroups[item.combination_id] = []; combinationGroups[item.combination_id] = [];
} }
combinationGroups[item.combination_id].push(item); combinationGroups[item.combination_id].push(item);
} else { } else {
nonCombinationItems.push(item); nonCombinationItems.push(item);
@ -195,6 +196,9 @@ const groupedResults = computed<GroupedResults[]>(() => {
<TableHead class="text-right" <TableHead class="text-right"
>Pemakaian</TableHead >Pemakaian</TableHead
> >
<TableHead class="text-right"
>Hasil</TableHead
>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@ -206,7 +210,7 @@ const groupedResults = computed<GroupedResults[]>(() => {
class="bg-muted/20 hover:bg-muted/20" class="bg-muted/20 hover:bg-muted/20"
> >
<TableCell <TableCell
colspan="3" colspan="4"
class="font-semibold" class="font-semibold"
> >
<template v-if="group.isCombination"> <template v-if="group.isCombination">
@ -217,6 +221,13 @@ const groupedResults = computed<GroupedResults[]>(() => {
> >
{{ group.items.length }} bahan {{ group.items.length }} bahan
</Badge> </Badge>
<Badge
v-if="group.items[0]?.combination_material_result !== null && group.items[0]?.combination_material_result !== undefined"
variant="secondary"
class="ml-2 font-normal"
>
Hasil: {{ group.items[0].combination_material_result }} pcs
</Badge>
</template> </template>
<template v-else> <template v-else>
{{ group.name }} {{ group.name }}
@ -250,6 +261,19 @@ const groupedResults = computed<GroupedResults[]>(() => {
material.material_usage_formatted material.material_usage_formatted
}} }}
</TableCell> </TableCell>
<TableCell
class="text-right tabular-nums"
>
<template v-if="group.isCombination && material.combination_material_result !== null && material.combination_material_result !== undefined">
{{ material.combination_material_result }} pcs
</template>
<template v-else-if="!group.isCombination && material.material_result !== null && material.material_result !== undefined">
{{ material.material_result }} pcs
</template>
<template v-else>
-
</template>
</TableCell>
</TableRow> </TableRow>
</template> </template>
</TableBody> </TableBody>

View File

@ -3,9 +3,10 @@ import { Layers, Plus, Search, X } from '@lucide/vue';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { toast } from 'vue-sonner'; import { toast } from 'vue-sonner';
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue'; import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
import { DecimalInput } from '@/components/form/decimal-input';
import { NumberInput } from '@/components/form/number-input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { DecimalInput } from '@/components/form/decimal-input';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -13,6 +14,7 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { apiFetch } from '@/lib/api'; import { apiFetch } from '@/lib/api';
import draft_combinations from '@/routes/admin/manage/cuttings/draft_combinations'; import draft_combinations from '@/routes/admin/manage/cuttings/draft_combinations';
import type { CuttingMaterialCartItem, CuttingRawMaterialCatalogItem } from '@/types/cutting'; import type { CuttingMaterialCartItem, CuttingRawMaterialCatalogItem } from '@/types/cutting';
@ -48,6 +50,7 @@ const emit = defineEmits<{
const search = ref(''); const search = ref('');
const selectedMaterials = ref<SelectedMaterial[]>([]); const selectedMaterials = ref<SelectedMaterial[]>([]);
const combinationResult = ref<string>('');
const loading = ref(false); const loading = ref(false);
const filteredRawMaterials = computed(() => { const filteredRawMaterials = computed(() => {
@ -101,6 +104,7 @@ function removeSelected(priceId: number) {
function resetForm() { function resetForm() {
search.value = ''; search.value = '';
selectedMaterials.value = []; selectedMaterials.value = [];
combinationResult.value = '';
} }
function initFromVariant() { function initFromVariant() {
@ -123,6 +127,7 @@ function initFromVariant() {
async function submit() { async function submit() {
if (selectedMaterials.value.length < 2) { if (selectedMaterials.value.length < 2) {
toast.error('Pilih minimal 2 bahan baku untuk dikombinasikan.'); toast.error('Pilih minimal 2 bahan baku untuk dikombinasikan.');
return; return;
} }
@ -136,6 +141,7 @@ async function submit() {
raw_material_price_id: item.raw_material_price_id, raw_material_price_id: item.raw_material_price_id,
material_usage: item.material_usage, material_usage: item.material_usage,
})), })),
material_result: combinationResult.value ? Number(combinationResult.value) : null,
}), }),
}); });
@ -173,12 +179,9 @@ watch(open, (value) => {
<div v-if="selectedMaterials.length > 0" class="rounded-lg border p-3 space-y-2"> <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> <p class="text-sm font-medium">Bahan Baku Terpilih ({{ selectedMaterials.length }})</p>
<div class="space-y-2"> <div class="space-y-2">
<div <div v-for="item in selectedMaterials" :key="item.raw_material_price_id"
v-for="item in selectedMaterials"
:key="item.raw_material_price_id"
class="flex items-center gap-2 rounded-md border p-2" class="flex items-center gap-2 rounded-md border p-2"
:class="item.is_initial ? 'border-primary bg-primary/5' : ''" :class="item.is_initial ? 'border-primary bg-primary/5' : ''">
>
<PosCatalogVariantThumb :items="item.images" /> <PosCatalogVariantThumb :items="item.images" />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium"> <p class="truncate text-sm font-medium">
@ -190,20 +193,11 @@ watch(open, (value) => {
</div> </div>
<div class="flex items-center gap-1" @click.stop> <div class="flex items-center gap-1" @click.stop>
<Label class="text-xs whitespace-nowrap">Pemakaian:</Label> <Label class="text-xs whitespace-nowrap">Pemakaian:</Label>
<DecimalInput <DecimalInput v-model="item.material_usage" class="h-7 w-20" />
v-model="item.material_usage"
class="h-7 w-20"
/>
<span class="text-xs text-muted-foreground">{{ item.unit_abbreviation }}</span> <span class="text-xs text-muted-foreground">{{ item.unit_abbreviation }}</span>
</div> </div>
<Button <Button v-if="!item.is_initial" type="button" variant="ghost" size="icon-sm"
v-if="!item.is_initial" class="text-destructive" @click="removeSelected(item.raw_material_price_id)">
type="button"
variant="ghost"
size="icon-sm"
class="text-destructive"
@click="removeSelected(item.raw_material_price_id)"
>
<X class="size-3.5" /> <X class="size-3.5" />
</Button> </Button>
</div> </div>
@ -212,19 +206,18 @@ watch(open, (value) => {
<div class="relative"> <div class="relative">
<Search class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" /> <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" /> <Input v-model="search" placeholder="Cari bahan baku lain untuk dikombinasikan..."
class="pl-9" />
</div> </div>
<div v-if="filteredRawMaterials.length === 0" class="py-8 text-center text-sm text-muted-foreground"> <div v-if="filteredRawMaterials.length === 0"
class="py-8 text-center text-sm text-muted-foreground">
Tidak ada bahan baku ditemukan. Tidak ada bahan baku ditemukan.
</div> </div>
<div v-else class="space-y-3"> <div v-else class="space-y-3">
<div <div v-for="rawMaterial in filteredRawMaterials" :key="rawMaterial.id"
v-for="rawMaterial in filteredRawMaterials" class="rounded-lg border p-3">
:key="rawMaterial.id"
class="rounded-lg border p-3"
>
<div class="mb-2 flex items-center gap-2"> <div class="mb-2 flex items-center gap-2">
<p class="text-sm font-medium">{{ rawMaterial.name }}</p> <p class="text-sm font-medium">{{ rawMaterial.name }}</p>
<Badge variant="outline" class="text-xs"> <Badge variant="outline" class="text-xs">
@ -233,15 +226,11 @@ watch(open, (value) => {
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<div <div v-for="price in rawMaterial.prices" :key="price.id"
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="flex items-center gap-2.5 rounded-md px-2 py-1.5 transition-all duration-200"
:class="[ :class="[
isSelected(price.id) ? 'border-2 border-primary bg-primary/5' : 'cursor-pointer hover:bg-muted/30', isSelected(price.id) ? 'border-2 border-primary bg-primary/5' : 'cursor-pointer hover:bg-muted/30',
]" ]" @click="!isSelected(price.id) && toggleVariant(rawMaterial, price)">
@click="!isSelected(price.id) && toggleVariant(rawMaterial, price)"
>
<PosCatalogVariantThumb :items="price.images" /> <PosCatalogVariantThumb :items="price.images" />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="truncate text-sm">{{ price.variant }}</p> <p class="truncate text-sm">{{ price.variant }}</p>
@ -249,14 +238,8 @@ watch(open, (value) => {
Stok: {{ price.stock_formatted }} Stok: {{ price.stock_formatted }}
</p> </p>
</div> </div>
<Button <Button v-if="!isSelected(price.id)" type="button" variant="outline" size="icon-sm"
v-if="!isSelected(price.id)" class="shrink-0" @click.stop="toggleVariant(rawMaterial, price)">
type="button"
variant="outline"
size="icon-sm"
class="shrink-0"
@click.stop="toggleVariant(rawMaterial, price)"
>
<Plus class="size-3.5" /> <Plus class="size-3.5" />
</Button> </Button>
<Badge v-else variant="secondary" class="text-xs"> <Badge v-else variant="secondary" class="text-xs">
@ -270,18 +253,21 @@ watch(open, (value) => {
</div> </div>
<div class="flex items-center justify-between border-t pt-4"> <div class="flex items-center justify-between border-t pt-4">
<p class="text-sm text-muted-foreground"> <div class="flex items-center gap-3">
{{ selectedMaterials.length }} bahan dipilih <div class="flex items-center gap-2">
</p> <Label class="text-sm whitespace-nowrap">Hasil:</Label>
<NumberInput v-model="combinationResult" class="h-8 w-20" placeholder="0" />
<span class="text-xs text-muted-foreground">pcs</span>
</div>
<p class="text-sm text-muted-foreground">
{{ selectedMaterials.length }} bahan dipilih
</p>
</div>
<div class="flex gap-2"> <div class="flex gap-2">
<Button type="button" variant="outline" :disabled="loading" @click="open = false"> <Button type="button" variant="outline" :disabled="loading" @click="open = false">
Batal Batal
</Button> </Button>
<Button <Button type="button" :disabled="loading || selectedMaterials.length < 2" @click="submit">
type="button"
:disabled="loading || selectedMaterials.length < 2"
@click="submit"
>
<Layers class="size-4 mr-1" /> <Layers class="size-4 mr-1" />
{{ loading ? 'Menyimpan...' : 'Simpan Kombinasi' }} {{ loading ? 'Menyimpan...' : 'Simpan Kombinasi' }}
</Button> </Button>

View File

@ -99,8 +99,8 @@ const {
decreaseResultQty, decreaseResultQty,
syncMaterialField, syncMaterialField,
syncResultField, syncResultField,
syncDraftCombination,
removeCombination, removeCombination,
syncCombinationResult,
} = useCuttingPosCart({ } = useCuttingPosCart({
rawMaterialCatalog: rawMaterialCatalogState, rawMaterialCatalog: rawMaterialCatalogState,
productCatalog: productCatalogState, productCatalog: productCatalogState,
@ -144,6 +144,14 @@ function buildFormData(): FormData {
appendRootPhotosToFormData(formData, imageState.value); appendRootPhotosToFormData(formData, imageState.value);
if (props.method === 'put') { if (props.method === 'put') {
// Build combination results map
const combinationResults: Record<number, number | null> = {};
materialCart.value.forEach(item => {
if (item.combination_id && item.combination_material_result !== undefined) {
combinationResults[item.combination_id] = item.combination_material_result;
}
});
materialCart.value.forEach((item, index) => { materialCart.value.forEach((item, index) => {
formData.append( formData.append(
`materials[${index}][raw_material_price_id]`, `materials[${index}][raw_material_price_id]`,
@ -153,11 +161,29 @@ function buildFormData(): FormData {
`materials[${index}][material_usage]`, `materials[${index}][material_usage]`,
item.material_usage, item.material_usage,
); );
if (item.material_result !== undefined && item.material_result !== null) {
formData.append(
`materials[${index}][material_result]`,
item.material_result,
);
}
if (item.combination_id) { if (item.combination_id) {
formData.append( formData.append(
`materials[${index}][combination_id]`, `materials[${index}][combination_id]`,
String(item.combination_id), String(item.combination_id),
); );
// Add combination_material_result
const combinationResult = combinationResults[item.combination_id];
if (combinationResult !== undefined && combinationResult !== null) {
formData.append(
`materials[${index}][combination_material_result]`,
String(combinationResult),
);
}
} }
}); });
resultCart.value.forEach((item, index) => { resultCart.value.forEach((item, index) => {
@ -251,8 +277,8 @@ function onProductCreated(product: CuttingProductCatalogItem) {
<div class="space-y-4"> <div class="space-y-4">
<CuttingPosMaterialCatalogPanel v-model:material-search="materialSearch" <CuttingPosMaterialCatalogPanel v-model:material-search="materialSearch"
:filtered-raw-materials="filteredRawMaterials" :raw-material-catalog="rawMaterialCatalogState" :filtered-raw-materials="filteredRawMaterials" :raw-material-catalog="rawMaterialCatalogState"
:material-cart="materialCart" :get-material-cart-item="getMaterialCartItem" :material-cart="materialCart" :get-material-cart-item="getMaterialCartItem" :units="units"
:units="units" @add-material="addMaterial" @decrease-material-qty="decreaseMaterialQty" @add-material="addMaterial" @decrease-material-qty="decreaseMaterialQty"
@raw-material-created="onRawMaterialCreated" @combination-created="onCombinationCreated" /> @raw-material-created="onRawMaterialCreated" @combination-created="onCombinationCreated" />
<CuttingPosResultCatalogPanel v-model:product-search="productSearch" :filtered-products="filteredProducts" <CuttingPosResultCatalogPanel v-model:product-search="productSearch" :filtered-products="filteredProducts"
@ -266,7 +292,7 @@ function onProductCreated(product: CuttingProductCatalogItem) {
@open-detail="cartDetailOpen = true" @remove-material="removeMaterial" @open-detail="cartDetailOpen = true" @remove-material="removeMaterial"
@sync-material-field="syncMaterialField" @remove-result="removeResult" @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" /> @remove-combination="removeCombination" @sync-combination-result="syncCombinationResult" />
</div> </div>
<CuttingPosCartDetailDialog v-model:open="cartDetailOpen" :material-cart="materialCart" :result-cart="resultCart" <CuttingPosCartDetailDialog v-model:open="cartDetailOpen" :material-cart="materialCart" :result-cart="resultCart"

View File

@ -2,6 +2,7 @@
import { Layers, Trash2 } from '@lucide/vue'; import { Layers, Trash2 } from '@lucide/vue';
import { computed } from 'vue'; import { computed } from 'vue';
import { DecimalInput } from '@/components/form/decimal-input'; import { DecimalInput } from '@/components/form/decimal-input';
import { NumberInput } from '@/components/form/number-input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@ -22,6 +23,7 @@ const emit = defineEmits<{
remove: [index: number]; remove: [index: number];
'sync-field': [index: number]; 'sync-field': [index: number];
'remove-combination': [combinationId: number]; 'remove-combination': [combinationId: number];
'sync-combination-result': [combinationId: number, result: number | null];
}>(); }>();
type MaterialGroup = { type MaterialGroup = {
@ -39,6 +41,7 @@ const materialGroups = computed<MaterialGroup[]>(() => {
if (!combinationMap.has(item.combination_id)) { if (!combinationMap.has(item.combination_id)) {
combinationMap.set(item.combination_id, []); combinationMap.set(item.combination_id, []);
} }
combinationMap.get(item.combination_id)!.push({ item, index }); combinationMap.get(item.combination_id)!.push({ item, index });
} else { } else {
singleItems.push({ item, index }); singleItems.push({ item, index });
@ -64,25 +67,40 @@ const materialGroups = computed<MaterialGroup[]>(() => {
return groups; return groups;
}); });
function getCombinationResult(combinationId: number | undefined): number | null {
if (!combinationId) {
return null;
}
const item = props.materialCart.find(i => i.combination_id === combinationId);
return item?.combination_material_result ?? null;
}
function setCombinationResult(combinationId: number | undefined, value: string) {
if (!combinationId) {
return;
}
const result = value ? Number(value) : null;
emit('sync-combination-result', combinationId, result);
}
</script> </script>
<template> <template>
<div class="space-y-2"> <div class="space-y-2">
<p class="text-sm font-medium">Bahan Baku</p> <p class="text-sm font-medium">Bahan Baku</p>
<div <div v-if="materialCart.length === 0"
v-if="materialCart.length === 0" class="rounded-lg border border-dashed px-4 py-6 text-center text-sm text-muted-foreground">
class="rounded-lg border border-dashed px-4 py-6 text-center text-sm text-muted-foreground"
>
Belum ada bahan baku dipilih. Belum ada bahan baku dipilih.
</div> </div>
<div v-else class="scrollbar-thin max-h-80 space-y-3 overflow-y-auto overscroll-y-contain"> <div v-else class="scrollbar-thin max-h-80 space-y-3 overflow-y-auto overscroll-y-contain">
<template v-for="group in materialGroups" :key="group.combinationId ?? `single-${group.items[0]?.index}`"> <template v-for="group in materialGroups" :key="group.combinationId ?? `single-${group.items[0]?.index}`">
<div <div v-if="group.type === 'combination'"
v-if="group.type === 'combination'" class="rounded-lg border-2 border-dashed border-primary/30 p-3">
class="rounded-lg border-2 border-dashed border-primary/30 p-3"
>
<div class="mb-2 flex items-center justify-between"> <div class="mb-2 flex items-center justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Layers class="size-4 text-primary" /> <Layers class="size-4 text-primary" />
@ -91,23 +109,27 @@ const materialGroups = computed<MaterialGroup[]>(() => {
{{ group.items.length }} bahan {{ group.items.length }} bahan
</Badge> </Badge>
</div> </div>
<Button <div class="flex items-center gap-1.5">
type="button" <div class="flex items-center gap-1" @click.stop>
variant="ghost" <NumberInput
size="icon" :model-value="getCombinationResult(group.combinationId)"
class="size-7 shrink-0 text-destructive hover:text-destructive" class="h-7 w-16"
@click="group.combinationId && emit('remove-combination', group.combinationId)" placeholder="Hasil"
> @update:model-value="setCombinationResult(group.combinationId, $event)"
<Trash2 class="size-3.5" /> />
</Button> <span class="text-[10px] text-muted-foreground">pcs</span>
</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> </div>
<div class="space-y-2"> <div class="space-y-2">
<div <div v-for="{ item, index } in group.items" :key="item.raw_material_price_id"
v-for="{ item, index } in group.items" class="rounded-md border bg-background p-2.5">
: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="mb-1.5 flex items-start justify-between gap-2">
<div class="min-w-0"> <div class="min-w-0">
<p class="truncate text-sm font-medium"> <p class="truncate text-sm font-medium">
@ -117,39 +139,30 @@ const materialGroups = computed<MaterialGroup[]>(() => {
{{ item.variant }} · Stok {{ item.stock_input }} {{ item.unit_abbreviation }} {{ item.variant }} · Stok {{ item.stock_input }} {{ item.unit_abbreviation }}
</p> </p>
</div> </div>
<Button <Button type="button" variant="ghost" size="icon"
type="button"
variant="ghost"
size="icon"
class="size-6 shrink-0 text-destructive hover:text-destructive" class="size-6 shrink-0 text-destructive hover:text-destructive"
@click="emit('remove', index)" @click="emit('remove', index)">
>
<Trash2 class="size-3" /> <Trash2 class="size-3" />
</Button> </Button>
</div> </div>
<Field :data-invalid="formErrors(form, `materials.${index}.material_usage`).length > 0 ? 'true' : undefined"> <Field
:data-invalid="formErrors(form, `materials.${index}.material_usage`).length > 0 ? 'true' : undefined">
<FieldLabel class="text-xs"> <FieldLabel class="text-xs">
Pemakaian ({{ item.unit_abbreviation }}) Pemakaian ({{ item.unit_abbreviation }})
</FieldLabel> </FieldLabel>
<DecimalInput <DecimalInput v-model="item.material_usage" class="h-7"
v-model="item.material_usage"
class="h-7"
:aria-invalid="formErrors(form, `materials.${index}.material_usage`).length > 0" :aria-invalid="formErrors(form, `materials.${index}.material_usage`).length > 0"
@change="emit('sync-field', index)" @change="emit('sync-field', index)" />
/> <FieldError :errors="formErrors(form, `materials.${index}.material_usage`)"
<FieldError :errors="formErrors(form, `materials.${index}.material_usage`)" class="text-[10px] mt-0.5 leading-tight" /> class="text-[10px] mt-0.5 leading-tight" />
</Field> </Field>
</div> </div>
</div> </div>
</div> </div>
<div <div v-else v-for="{ item, index } in group.items" :key="item.raw_material_price_id"
v-else class="rounded-lg border p-3">
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="mb-2 flex items-start justify-between gap-2">
<div class="min-w-0"> <div class="min-w-0">
<p class="truncate text-sm font-medium"> <p class="truncate text-sm font-medium">
@ -159,29 +172,37 @@ const materialGroups = computed<MaterialGroup[]>(() => {
{{ item.variant }} · Stok {{ item.stock_input }} {{ item.unit_abbreviation }} {{ item.variant }} · Stok {{ item.stock_input }} {{ item.unit_abbreviation }}
</p> </p>
</div> </div>
<Button <Button type="button" variant="ghost" size="icon"
type="button"
variant="ghost"
size="icon"
class="size-7 shrink-0 text-destructive hover:text-destructive" class="size-7 shrink-0 text-destructive hover:text-destructive"
@click="emit('remove', index)" @click="emit('remove', index)">
>
<Trash2 class="size-3.5" /> <Trash2 class="size-3.5" />
</Button> </Button>
</div> </div>
<Field :data-invalid="formErrors(form, `materials.${index}.material_usage`).length > 0 ? 'true' : undefined"> <div class="grid grid-cols-2 gap-2">
<FieldLabel class="text-xs"> <Field
Pemakaian ({{ item.unit_abbreviation }}) :data-invalid="formErrors(form, `materials.${index}.material_usage`).length > 0 ? 'true' : undefined">
</FieldLabel> <FieldLabel class="text-xs">
<DecimalInput Pemakaian ({{ item.unit_abbreviation }})
v-model="item.material_usage" </FieldLabel>
class="h-8" <DecimalInput v-model="item.material_usage" class="h-8"
:aria-invalid="formErrors(form, `materials.${index}.material_usage`).length > 0" :aria-invalid="formErrors(form, `materials.${index}.material_usage`).length > 0"
@change="emit('sync-field', index)" @change="emit('sync-field', index)" />
/> <FieldError :errors="formErrors(form, `materials.${index}.material_usage`)"
<FieldError :errors="formErrors(form, `materials.${index}.material_usage`)" class="text-[10px] mt-0.5 leading-tight" /> class="text-[10px] mt-0.5 leading-tight" />
</Field> </Field>
<Field
:data-invalid="formErrors(form, `materials.${index}.material_result`).length > 0 ? 'true' : undefined">
<FieldLabel class="text-xs">
Hasil (pcs)
</FieldLabel>
<NumberInput v-model="item.material_result" class="h-8"
:aria-invalid="formErrors(form, `materials.${index}.material_result`).length > 0"
@change="emit('sync-field', index)" />
<FieldError :errors="formErrors(form, `materials.${index}.material_result`)"
class="text-[10px] mt-0.5 leading-tight" />
</Field>
</div>
</div> </div>
</template> </template>
</div> </div>

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue';
import { Check, Minus, Plus, Search } from '@lucide/vue'; import { Check, Minus, Plus, Search } from '@lucide/vue';
import { ref } from 'vue';
import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue'; import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue';
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue'; import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -15,8 +15,8 @@ import { Input } from '@/components/ui/input';
import { getFirstCoverImage } from '@/lib/catalog-cover'; import { getFirstCoverImage } from '@/lib/catalog-cover';
import type { CuttingProductCatalogItem, CuttingResultCartItem } from '@/types/cutting'; import type { CuttingProductCatalogItem, CuttingResultCartItem } from '@/types/cutting';
import type { CategoryOption } from '@/types/product'; import type { CategoryOption } from '@/types/product';
import type { CuttingCatalogVariant } from './useCuttingPosCart';
import QuickCreateProductModal from './QuickCreateProductModal.vue'; import QuickCreateProductModal from './QuickCreateProductModal.vue';
import type { CuttingCatalogVariant } from './useCuttingPosCart';
defineProps<{ defineProps<{
filteredProducts: CuttingProductCatalogItem[]; filteredProducts: CuttingProductCatalogItem[];

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Save, Scissors } from '@lucide/vue'; import { Save, Scissors } from '@lucide/vue';
import MediaDropzone from '@/components/media/MediaDropzone.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -13,15 +14,15 @@ import {
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { FIELD_LIMITS } from '@/lib/field-limits'; import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors, type FormWithErrors } from '@/lib/form'; import { formErrors } from '@/lib/form';
import type { FormWithErrors } from '@/lib/form';
import type { import type {
CuttingMaterialCartItem, CuttingMaterialCartItem,
CuttingResultCartItem, CuttingResultCartItem,
} from '@/types/cutting'; } from '@/types/cutting';
import type { MediaUploadState } from '@/types/media';
import CuttingPosMaterialSummaryItems from './CuttingPosMaterialSummaryItems.vue'; import CuttingPosMaterialSummaryItems from './CuttingPosMaterialSummaryItems.vue';
import CuttingPosResultSummaryItems from './CuttingPosResultSummaryItems.vue'; import CuttingPosResultSummaryItems from './CuttingPosResultSummaryItems.vue';
import MediaDropzone from '@/components/media/MediaDropzone.vue';
import type { MediaUploadState } from '@/types/media';
defineProps<{ defineProps<{
form: FormWithErrors & { description: string; processing?: boolean }; form: FormWithErrors & { description: string; processing?: boolean };
@ -42,6 +43,7 @@ const emit = defineEmits<{
'sync-result-totals': [item: CuttingResultCartItem]; 'sync-result-totals': [item: CuttingResultCartItem];
'sync-result-field': [index: number]; 'sync-result-field': [index: number];
'remove-combination': [combinationId: number]; 'remove-combination': [combinationId: number];
'sync-combination-result': [combinationId: number, result: number | null];
}>(); }>();
const imageState = defineModel<MediaUploadState>('imageState', { const imageState = defineModel<MediaUploadState>('imageState', {
@ -52,27 +54,19 @@ const imageState = defineModel<MediaUploadState>('imageState', {
<template> <template>
<Card class="h-fit xl:sticky xl:top-4"> <Card class="h-fit xl:sticky xl:top-4">
<CardHeader class="pb-3"> <CardHeader class="pb-3">
<CardTitle <CardTitle class="flex items-center justify-between gap-2 text-base">
class="flex items-center justify-between gap-2 text-base"
>
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<Scissors class="size-4" /> <Scissors class="size-4" />
Ringkasan Cutting Ringkasan Cutting
</span> </span>
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<button <button v-if="materialCart.length > 0 || resultCart.length > 0" type="button"
v-if="materialCart.length > 0 || resultCart.length > 0"
type="button"
class="text-xs font-normal text-primary underline underline-offset-2 hover:text-primary/80" class="text-xs font-normal text-primary underline underline-offset-2 hover:text-primary/80"
@click="emit('open-detail')" @click="emit('open-detail')">
>
Lihat Detail Lihat Detail
</button> </button>
<Badge <Badge v-if="!isCreateMode && totalResultPieces > 0" variant="secondary"
v-if="!isCreateMode && totalResultPieces > 0" class="font-semibold tabular-nums">
variant="secondary"
class="font-semibold tabular-nums"
>
Total: {{ totalResultPieces }} pcs Total: {{ totalResultPieces }} pcs
</Badge> </Badge>
</span> </span>
@ -83,66 +77,39 @@ const imageState = defineModel<MediaUploadState>('imageState', {
<FieldGroup> <FieldGroup>
<FieldSet class="grid gap-4"> <FieldSet class="grid gap-4">
<Field> <Field>
<FieldLabel for="description" <FieldLabel for="description">Keterangan</FieldLabel>
>Keterangan</FieldLabel <Textarea id="description" v-model="form.description" placeholder="Masukkan keterangan"
> rows="2" :maxlength="FIELD_LIMITS.description" />
<Textarea <FieldError :errors="formErrors(form, 'description')" />
id="description"
v-model="form.description"
placeholder="Masukkan keterangan"
rows="2"
:maxlength="FIELD_LIMITS.description"
/>
<FieldError
:errors="formErrors(form, 'description')"
/>
</Field> </Field>
<MediaDropzone <MediaDropzone id="cutting-images" v-model="imageState" label="Foto Cutting" :max-files="10"
id="cutting-images" :errors="formErrors(form, 'images')" />
v-model="imageState"
label="Foto Cutting"
:max-files="10"
:errors="formErrors(form, 'images')"
/>
<CuttingPosMaterialSummaryItems <CuttingPosMaterialSummaryItems :form="form" :material-cart="materialCart"
:form="form" @remove="emit('remove-material', $event)" @sync-field="emit('sync-material-field', $event)"
:material-cart="materialCart"
@remove="emit('remove-material', $event)"
@sync-field="emit('sync-material-field', $event)"
@remove-combination="emit('remove-combination', $event)" @remove-combination="emit('remove-combination', $event)"
/> @sync-combination-result="(combinationId, result) => emit('sync-combination-result', combinationId, result)" />
<Separator /> <Separator />
<CuttingPosResultSummaryItems <CuttingPosResultSummaryItems :form="form" :result-cart="resultCart"
:form="form" :total-result-pieces="totalResultPieces" :is-create-mode="isCreateMode"
:result-cart="resultCart" @remove="emit('remove-result', $event)" @sync-totals="emit('sync-result-totals', $event)"
:total-result-pieces="totalResultPieces" @sync-field="emit('sync-result-field', $event)" />
:is-create-mode="isCreateMode"
@remove="emit('remove-result', $event)"
@sync-totals="emit('sync-result-totals', $event)"
@sync-field="emit('sync-result-field', $event)"
/>
<Button <Button type="submit" class="w-full" :disabled="form.processing ||
type="submit" isUploading ||
class="w-full" materialCart.length === 0 ||
:disabled=" resultCart.length === 0
form.processing || ">
isUploading ||
materialCart.length === 0 ||
resultCart.length === 0
"
>
<Save class="size-4" /> <Save class="size-4" />
{{ {{
isUploading isUploading
? 'Mengunggah...' ? 'Mengunggah...'
: form.processing : form.processing
? 'Menyimpan...' ? 'Menyimpan...'
: submitLabel : submitLabel
}} }}
</Button> </Button>
</FieldSet> </FieldSet>

View File

@ -152,24 +152,27 @@ export function useCuttingPosCart(options: {
rawMaterial: CuttingRawMaterialCatalogItem, rawMaterial: CuttingRawMaterialCatalogItem,
price: CuttingCatalogPrice, price: CuttingCatalogPrice,
materialUsage: string, materialUsage: string,
materialResult?: string | null,
) { ) {
const { item } = await apiFetch<{ item: CuttingMaterialCartItem }>(draft_materials.store.url(), { const { item } = await apiFetch<{ item: CuttingMaterialCartItem }>(draft_materials.store.url(), {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
raw_material_price_id: price.id, raw_material_price_id: price.id,
material_usage: materialUsage, material_usage: materialUsage,
material_result: materialResult ?? null,
}), }),
}); });
upsertMaterialCartItem(item); upsertMaterialCartItem(item);
} }
async function syncDraftMaterialById(priceId: number, materialUsage: string) { async function syncDraftMaterialById(priceId: number, materialUsage: string, materialResult?: string | null) {
const { item } = await apiFetch<{ item: CuttingMaterialCartItem }>(draft_materials.store.url(), { const { item } = await apiFetch<{ item: CuttingMaterialCartItem }>(draft_materials.store.url(), {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
raw_material_price_id: priceId, raw_material_price_id: priceId,
material_usage: materialUsage, material_usage: materialUsage,
material_result: materialResult ?? null,
}), }),
}); });
@ -248,6 +251,15 @@ export function useCuttingPosCart(options: {
} }
} }
async function syncCombinationResult(combinationId: number, result: number | null) {
// Update local state for all items in this combination
materialCart.value.forEach(item => {
if (item.combination_id === combinationId) {
item.combination_material_result = result;
}
});
}
function getMaterialCartItem(priceId: number): CuttingMaterialCartItem | undefined { function getMaterialCartItem(priceId: number): CuttingMaterialCartItem | undefined {
return materialCart.value.find((item) => item.raw_material_price_id === priceId); return materialCart.value.find((item) => item.raw_material_price_id === priceId);
} }
@ -346,6 +358,7 @@ export function useCuttingPosCart(options: {
unit_abbreviation: rawMaterial.unit_abbreviation, unit_abbreviation: rawMaterial.unit_abbreviation,
stock_input: price.stock_input, stock_input: price.stock_input,
material_usage: defaultUsage, material_usage: defaultUsage,
material_result: null,
images: price.images ?? [], images: price.images ?? [],
}); });
} }
@ -462,7 +475,7 @@ export function useCuttingPosCart(options: {
const item = materialCart.value[index]; const item = materialCart.value[index];
try { try {
await syncDraftMaterialById(item.raw_material_price_id, item.material_usage); await syncDraftMaterialById(item.raw_material_price_id, item.material_usage, item.material_result);
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal memperbarui item.'); toast.error(error instanceof Error ? error.message : 'Gagal memperbarui item.');
} }
@ -513,5 +526,6 @@ export function useCuttingPosCart(options: {
syncResultField, syncResultField,
syncDraftCombination, syncDraftCombination,
removeCombination, removeCombination,
syncCombinationResult,
}; };
} }

View File

@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Check } from '@lucide/vue'; import { Check } from '@lucide/vue';
import { computed } from 'vue'; import { computed } from 'vue';
import DataTableActions from './data-table-actions.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { import {
@ -11,6 +10,7 @@ import {
EmptyTitle, EmptyTitle,
} from '@/components/ui/empty'; } from '@/components/ui/empty';
import type { CuttingListItem } from '@/types/cutting'; import type { CuttingListItem } from '@/types/cutting';
import DataTableActions from './data-table-actions.vue';
function formatMaterialUsage(mat: any): string { function formatMaterialUsage(mat: any): string {
return String(parseFloat(Number(mat.material_usage ?? 0).toFixed(2))); return String(parseFloat(Number(mat.material_usage ?? 0).toFixed(2)));
@ -57,6 +57,7 @@ function getMaterialGroups(materials: any[]): MaterialGroup[] {
if (!combinationMap[mat.combination_id]) { if (!combinationMap[mat.combination_id]) {
combinationMap[mat.combination_id] = []; combinationMap[mat.combination_id] = [];
} }
combinationMap[mat.combination_id].push(mat); combinationMap[mat.combination_id].push(mat);
} else { } else {
singleItems.push(mat); singleItems.push(mat);
@ -130,6 +131,9 @@ const totalCompleted = computed(() => props.cuttings.length);
<div v-for="(group, gi) in getMaterialGroups(cutting.materials)" :key="gi" class="mt-1"> <div v-for="(group, gi) in getMaterialGroups(cutting.materials)" :key="gi" class="mt-1">
<div v-if="group.type === 'combination'" class="mb-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> <span class="text-primary font-medium text-[11px]">{{ group.name }} ({{ group.items.length }} bahan)</span>
<span v-if="group.items[0]?.combination_material_result !== null && group.items[0]?.combination_material_result !== undefined" class="text-primary font-medium text-[11px] ml-1">
- Hasil: {{ group.items[0].combination_material_result }} pcs
</span>
<ul class="list-disc pl-4 space-y-0.5 text-muted-foreground"> <ul class="list-disc pl-4 space-y-0.5 text-muted-foreground">
<li v-for="mat in group.items" :key="mat.id"> <li v-for="mat in group.items" :key="mat.id">
{{ mat.raw_material_name }} ({{ mat.variant }}) - {{ formatMaterialUsage(mat) }} {{ getMaterialUnit(mat) }} {{ mat.raw_material_name }} ({{ mat.variant }}) - {{ formatMaterialUsage(mat) }} {{ getMaterialUnit(mat) }}
@ -139,6 +143,9 @@ const totalCompleted = computed(() => props.cuttings.length);
<ul v-else class="list-disc pl-4 space-y-0.5 text-muted-foreground"> <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"> <li v-for="mat in group.items" :key="mat.id">
{{ mat.raw_material_name }} ({{ mat.variant }}) - {{ formatMaterialUsage(mat) }} {{ getMaterialUnit(mat) }} {{ mat.raw_material_name }} ({{ mat.variant }}) - {{ formatMaterialUsage(mat) }} {{ getMaterialUnit(mat) }}
<template v-if="mat.material_result !== null && mat.material_result !== undefined">
{{ mat.material_result }} pcs
</template>
</li> </li>
</ul> </ul>
</div> </div>

View File

@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import DataTableActions from './data-table-actions.vue';
import { DataTableEmpty } from '@/components/data-table'; import { DataTableEmpty } from '@/components/data-table';
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue'; import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
import GroupedTableFooter from '@/components/data-table/GroupedTableFooter.vue'; import GroupedTableFooter from '@/components/data-table/GroupedTableFooter.vue';
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
Table, Table,
@ -15,13 +15,13 @@ import {
} from '@/components/ui/table'; } from '@/components/ui/table';
import { usePaginationSummary } from '@/composables/usePaginationSummary'; import { usePaginationSummary } from '@/composables/usePaginationSummary';
import { cuttingStatusBadgeVariant } from '@/constants/cutting-status'; import { cuttingStatusBadgeVariant } from '@/constants/cutting-status';
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
import { groupedTableRowNumber } from '@/lib/grouped-table'; import { groupedTableRowNumber } from '@/lib/grouped-table';
import type { CuttingListItem } from '@/types/cutting'; import type { CuttingListItem } from '@/types/cutting';
import type { import type {
DataTablePagination, DataTablePagination,
DataTablePaginationLink, DataTablePaginationLink,
} from '@/types/data-table'; } from '@/types/data-table';
import DataTableActions from './data-table-actions.vue';
const props = defineProps<{ const props = defineProps<{
cuttings: CuttingListItem[]; cuttings: CuttingListItem[];
@ -71,6 +71,7 @@ function getGroupedMaterials(materials: any[]): GroupedCuttingMaterials[] {
if (!combinationGroups[item.combination_id]) { if (!combinationGroups[item.combination_id]) {
combinationGroups[item.combination_id] = []; combinationGroups[item.combination_id] = [];
} }
combinationGroups[item.combination_id].push(item); combinationGroups[item.combination_id].push(item);
} else { } else {
nonCombinationItems.push(item); nonCombinationItems.push(item);
@ -145,24 +146,14 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
<template> <template>
<div class="space-y-4"> <div class="space-y-4">
<DataTableToolbar <DataTableToolbar v-model:search="search" @filters-reset="emit('filters-reset')" />
v-model:search="search"
@filters-reset="emit('filters-reset')"
/>
<div v-if="cuttings.length" class="space-y-4"> <div v-if="cuttings.length" class="space-y-4">
<div <div v-for="(cutting, index) in cuttings" :key="cutting.id" class="overflow-hidden rounded-md border">
v-for="(cutting, index) in cuttings"
:key="cutting.id"
class="overflow-hidden rounded-md border"
>
<div <div
class="flex flex-col gap-3 border-b bg-muted/30 px-4 py-3 sm:flex-row sm:items-start sm:justify-between" class="flex flex-col gap-3 border-b bg-muted/30 px-4 py-3 sm:flex-row sm:items-start sm:justify-between">
>
<div class="flex min-w-0 items-start gap-3"> <div class="flex min-w-0 items-start gap-3">
<span <span class="w-8 shrink-0 pt-0.5 text-center text-sm text-muted-foreground tabular-nums">
class="w-8 shrink-0 pt-0.5 text-center text-sm text-muted-foreground tabular-nums"
>
{{ rowNumber(index) }} {{ rowNumber(index) }}
</span> </span>
<div class="min-w-0 space-y-2"> <div class="min-w-0 space-y-2">
@ -170,19 +161,14 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
<h3 class="leading-tight font-medium"> <h3 class="leading-tight font-medium">
Cutting #{{ cutting.id }} Cutting #{{ cutting.id }}
</h3> </h3>
<Badge <Badge :variant="cuttingStatusBadgeVariant(
:variant=" cutting.status,
cuttingStatusBadgeVariant( )
cutting.status, ">
)
"
>
{{ cutting.status_label }} {{ cutting.status_label }}
</Badge> </Badge>
</div> </div>
<div <div class="space-y-1 text-sm text-muted-foreground">
class="space-y-1 text-sm text-muted-foreground"
>
<p>{{ cutting.created_at_formatted }}</p> <p>{{ cutting.created_at_formatted }}</p>
<p> <p>
Oleh Oleh
@ -193,72 +179,48 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
}} }}
</p> </p>
</div> </div>
<p <p v-if="cutting.description" class="text-sm text-muted-foreground">
v-if="cutting.description"
class="text-sm text-muted-foreground"
>
{{ cutting.description }} {{ cutting.description }}
</p> </p>
<div <div v-if="
v-if=" cutting.images && cutting.images.length > 0
cutting.images && cutting.images.length > 0 " class="flex flex-wrap gap-2 pt-1">
" <MediaThumbnailCell :items="cutting.images" :max-visible="10" />
class="flex flex-wrap gap-2 pt-1"
>
<MediaThumbnailCell
:items="cutting.images"
:max-visible="10"
/>
</div> </div>
<div class="flex flex-wrap gap-x-4 gap-y-1 text-sm"> <div class="flex flex-wrap gap-x-4 gap-y-1 text-sm">
<span <span>Total Hasil Cutting
>Total Hasil Cutting <strong class="text-primary">{{
<strong class="text-primary" cutting.total_result_pieces ?? 0
>{{ }}
cutting.total_result_pieces ?? 0 pcs</strong></span>
}} <span v-if="
pcs</strong cutting.total_material_usage_summary_formatted
></span ">Total Pemakaian Bahan
>
<span
v-if="
cutting.total_material_usage_summary_formatted
"
>Total Pemakaian Bahan
<strong class="text-primary">{{ <strong class="text-primary">{{
cutting.total_material_usage_summary_formatted cutting.total_material_usage_summary_formatted
}}</strong></span }}</strong></span>
> <span>Biaya Bahan
<span
>Biaya Bahan
<strong class="text-primary">{{ <strong class="text-primary">{{
cutting.total_material_cost_formatted ?? cutting.total_material_cost_formatted ??
'Rp 0' 'Rp 0'
}}</strong></span }}</strong></span>
> <span>Biaya per Produk
<span
>Biaya per Produk
<strong class="text-primary">{{ <strong class="text-primary">{{
cutting.material_cost_per_product_formatted ?? cutting.material_cost_per_product_formatted ??
'Rp 0' 'Rp 0'
}}</strong></span }}</strong></span>
>
</div> </div>
</div> </div>
</div> </div>
<div <div class="flex shrink-0 items-center justify-end gap-2 sm:pt-0.5">
class="flex shrink-0 items-center justify-end gap-2 sm:pt-0.5"
>
<DataTableActions :cutting="cutting" /> <DataTableActions :cutting="cutting" />
</div> </div>
</div> </div>
<div class="space-y-6 p-4"> <div class="space-y-6 p-4">
<div class="space-y-2"> <div class="space-y-2">
<h4 <h4 class="text-sm font-semibold tracking-tight text-foreground">
class="text-sm font-semibold tracking-tight text-foreground"
>
Bahan Baku Bahan Baku
</h4> </h4>
<div class="rounded-md border"> <div class="rounded-md border">
@ -268,75 +230,62 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
<TableHead>Varian</TableHead> <TableHead>Varian</TableHead>
<TableHead>Foto</TableHead> <TableHead>Foto</TableHead>
<TableHead>Pemakaian</TableHead> <TableHead>Pemakaian</TableHead>
<TableHead>Hasil</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
<TableRow <TableRow v-if="!cutting.materials.length" :key="`${cutting.id}-material-empty`">
v-if="!cutting.materials.length" <TableCell colspan="4" class="text-muted-foreground">
:key="`${cutting.id}-material-empty`"
>
<TableCell
colspan="3"
class="text-muted-foreground"
>
Belum ada bahan baku Belum ada bahan baku
</TableCell> </TableCell>
</TableRow> </TableRow>
<template <template v-else v-for="group in getGroupedMaterials(
v-else cutting.materials,
v-for="group in getGroupedMaterials( )" :key="group.rawMaterialId">
cutting.materials, <TableRow class="bg-muted/20 hover:bg-muted/20">
)" <TableCell colspan="4" class="font-semibold text-foreground">
:key="group.rawMaterialId"
>
<TableRow
class="bg-muted/20 hover:bg-muted/20"
>
<TableCell
colspan="3"
class="font-semibold text-foreground"
>
<template v-if="group.isCombination"> <template v-if="group.isCombination">
<span class="text-primary">{{ group.rawMaterialName }}</span> <span class="text-primary">{{ group.rawMaterialName }}</span>
<Badge <Badge variant="default" class="ml-2 font-normal">
variant="default"
class="ml-2 font-normal"
>
{{ group.items.length }} bahan {{ group.items.length }} bahan
</Badge> </Badge>
<Badge
v-if="group.items[0]?.combination_material_result !== null && group.items[0]?.combination_material_result !== undefined"
variant="secondary" class="ml-2 font-normal">
Hasil: {{ group.items[0].combination_material_result }} pcs
</Badge>
</template> </template>
<template v-else> <template v-else>
{{ group.rawMaterialName }} {{ group.rawMaterialName }}
<Badge <Badge v-if="group.unitLabel" variant="secondary"
v-if="group.unitLabel" class="ml-2 font-normal">
variant="secondary"
class="ml-2 font-normal"
>
{{ group.unitLabel }} {{ group.unitLabel }}
</Badge> </Badge>
</template> </template>
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow <TableRow v-for="material in group.items" :key="material.id">
v-for="material in group.items"
:key="material.id"
>
<TableCell class="pl-6 font-medium"> <TableCell class="pl-6 font-medium">
{{ material.variant }} {{ material.variant }}
</TableCell> </TableCell>
<TableCell> <TableCell>
<MediaThumbnailCell <MediaThumbnailCell :items="material.images ?? []
:items=" " :max-visible="1" />
material.images ?? []
"
:max-visible="1"
/>
</TableCell> </TableCell>
<TableCell class="tabular-nums"> <TableCell class="tabular-nums">
{{ {{
material.material_usage_formatted material.material_usage_formatted
}} }}
</TableCell> </TableCell>
<TableCell class="tabular-nums">
<template
v-if="!group.isCombination && material.material_result !== null && material.material_result !== undefined">
{{ material.material_result }} pcs
</template>
<template v-else>
-
</template>
</TableCell>
</TableRow> </TableRow>
</template> </template>
</TableBody> </TableBody>
@ -345,9 +294,7 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<h4 <h4 class="text-sm font-semibold tracking-tight text-foreground">
class="text-sm font-semibold tracking-tight text-foreground"
>
Hasil Produk Hasil Produk
</h4> </h4>
<div class="rounded-md border"> <div class="rounded-md border">
@ -362,51 +309,29 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
<TableRow <TableRow v-if="!cutting.results.length" :key="`${cutting.id}-result-empty`">
v-if="!cutting.results.length" <TableCell colspan="5" class="text-muted-foreground">
:key="`${cutting.id}-result-empty`"
>
<TableCell
colspan="5"
class="text-muted-foreground"
>
Belum ada hasil produk Belum ada hasil produk
</TableCell> </TableCell>
</TableRow> </TableRow>
<template <template v-else v-for="group in getGroupedResults(
v-else cutting.results,
v-for="group in getGroupedResults( )" :key="group.productId">
cutting.results, <TableRow class="bg-muted/20 hover:bg-muted/20">
)" <TableCell colspan="5" class="font-semibold text-foreground">
:key="group.productId"
>
<TableRow
class="bg-muted/20 hover:bg-muted/20"
>
<TableCell
colspan="5"
class="font-semibold text-foreground"
>
{{ group.productName }} {{ group.productName }}
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow <TableRow v-for="result in group.items" :key="result.id">
v-for="result in group.items"
:key="result.id"
>
<TableCell class="pl-6 font-medium"> <TableCell class="pl-6 font-medium">
{{ {{
result.product_variant?.name result.product_variant?.name
}} }}
</TableCell> </TableCell>
<TableCell> <TableCell>
<MediaThumbnailCell <MediaThumbnailCell :items="result.product_variant
:items=" ?.images ?? []
result.product_variant " :max-visible="1" />
?.images ?? []
"
:max-visible="1"
/>
</TableCell> </TableCell>
<TableCell class="tabular-nums"> <TableCell class="tabular-nums">
{{ result.cutting_result }} pcs {{ result.cutting_result }} pcs
@ -430,15 +355,8 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
</div> </div>
</div> </div>
<DataTableEmpty <DataTableEmpty v-else description="Silakan lakukan pencarian untuk menemukan data yang Anda cari." />
v-else
description="Silakan lakukan pencarian untuk menemukan data yang Anda cari."
/>
<GroupedTableFooter <GroupedTableFooter :summary="paginationSummary" :pagination="pagination" :pagination-links="paginationLinks" />
:summary="paginationSummary"
:pagination="pagination"
:pagination-links="paginationLinks"
/>
</div> </div>
</template> </template>

View File

@ -48,6 +48,7 @@ function getMaterialGroups(materials: any[]): MaterialGroup[] {
if (!combinationMap[mat.combination_id]) { if (!combinationMap[mat.combination_id]) {
combinationMap[mat.combination_id] = []; combinationMap[mat.combination_id] = [];
} }
combinationMap[mat.combination_id].push(mat); combinationMap[mat.combination_id].push(mat);
} else { } else {
singleItems.push(mat); singleItems.push(mat);
@ -102,6 +103,9 @@ defineProps<{
<div v-for="(group, gi) in getMaterialGroups(cutting.materials)" :key="gi" class="mt-1"> <div v-for="(group, gi) in getMaterialGroups(cutting.materials)" :key="gi" class="mt-1">
<div v-if="group.type === 'combination'" class="mb-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> <span class="text-primary font-medium text-[11px]">{{ group.name }} ({{ group.items.length }} bahan)</span>
<span v-if="group.items[0]?.combination_material_result !== null && group.items[0]?.combination_material_result !== undefined" class="text-primary font-medium text-[11px] ml-1">
- Hasil: {{ group.items[0].combination_material_result }} pcs
</span>
<ul class="list-disc pl-4 space-y-0.5 text-muted-foreground"> <ul class="list-disc pl-4 space-y-0.5 text-muted-foreground">
<li v-for="mat in group.items" :key="mat.id"> <li v-for="mat in group.items" :key="mat.id">
{{ mat.raw_material_name }} ({{ mat.variant }}) - {{ formatMaterialUsage(mat) }} {{ getMaterialUnit(mat) }} {{ mat.raw_material_name }} ({{ mat.variant }}) - {{ formatMaterialUsage(mat) }} {{ getMaterialUnit(mat) }}
@ -111,6 +115,9 @@ defineProps<{
<ul v-else class="list-disc pl-4 space-y-0.5 text-muted-foreground"> <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"> <li v-for="mat in group.items" :key="mat.id">
{{ mat.raw_material_name }} ({{ mat.variant }}) - {{ formatMaterialUsage(mat) }} {{ getMaterialUnit(mat) }} {{ mat.raw_material_name }} ({{ mat.variant }}) - {{ formatMaterialUsage(mat) }} {{ getMaterialUnit(mat) }}
<template v-if="mat.material_result !== null && mat.material_result !== undefined">
{{ mat.material_result }} pcs
</template>
</li> </li>
</ul> </ul>
</div> </div>

View File

@ -1,16 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { ref } from 'vue';
import BackButton from '@/components/button/BackButton.vue'; import BackButton from '@/components/button/BackButton.vue';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { index, store } from '@/routes/admin/manage/orders';
import type { import type {
EnumOption, EnumOption,
OrderCartItem, OrderCartItem,
OrderCatalogItem, OrderCatalogItem,
SelectOption, SelectOption,
} from '@/types/order'; } from '@/types/order';
import { Head } from '@inertiajs/vue3';
import { ref } from 'vue';
import OrderPosForm from './form/OrderPosForm.vue'; import OrderPosForm from './form/OrderPosForm.vue';
import { index, store } from '@/routes/admin/manage/orders';
const props = defineProps<{ const props = defineProps<{
customers: SelectOption[]; customers: SelectOption[];

View File

@ -1,16 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed, ref } from 'vue';
import BackButton from '@/components/button/BackButton.vue'; import BackButton from '@/components/button/BackButton.vue';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { index, update } from '@/routes/admin/manage/orders';
import type { import type {
EnumOption, EnumOption,
OrderCatalogItem, OrderCatalogItem,
OrderEditItem, OrderEditItem,
SelectOption, SelectOption,
} from '@/types/order'; } from '@/types/order';
import { Head } from '@inertiajs/vue3';
import { computed, ref } from 'vue';
import OrderPosForm from './form/OrderPosForm.vue'; import OrderPosForm from './form/OrderPosForm.vue';
import { index, update } from '@/routes/admin/manage/orders';
const props = defineProps<{ const props = defineProps<{
order: OrderEditItem; order: OrderEditItem;

View File

@ -1,5 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, usePage } from '@inertiajs/vue3';
import { computed, onMounted, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import CreateButton from '@/components/button/CreateButton.vue'; import CreateButton from '@/components/button/CreateButton.vue';
import ThermalPrinterConnectButton from '@/components/order/ThermalPrinterConnectButton.vue';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
import { import {
@ -12,12 +16,8 @@ import {
} from '@/composables/useThermalPrinter'; } from '@/composables/useThermalPrinter';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import type { PaperSize } from '@/lib/thermal-printer/types'; import type { PaperSize } from '@/lib/thermal-printer/types';
import type { PaginatedOrders } from '@/types/order';
import { Head, usePage } from '@inertiajs/vue3';
import { computed, onMounted, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import { index, create } from '@/routes/admin/manage/orders'; import { index, create } from '@/routes/admin/manage/orders';
import ThermalPrinterConnectButton from '@/components/order/ThermalPrinterConnectButton.vue'; import type { PaginatedOrders } from '@/types/order';
import OrderGroupedTable from './table/OrderGroupedTable.vue'; import OrderGroupedTable from './table/OrderGroupedTable.vue';
const props = defineProps<{ const props = defineProps<{

View File

@ -11,8 +11,8 @@ import {
} from '@lucide/vue'; } from '@lucide/vue';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { toast } from 'vue-sonner'; import { toast } from 'vue-sonner';
import BackButton from '@/components/button/BackButton.vue';
import { RowDeleteAction, RowDetailAction, RowEditAction, RowStatusAction } from '@/components/button'; import { RowDeleteAction, RowDetailAction, RowEditAction, RowStatusAction } from '@/components/button';
import BackButton from '@/components/button/BackButton.vue';
import ConfirmDialog from '@/components/ConfirmDialog.vue'; import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';

View File

@ -1,4 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Save } from '@lucide/vue';
import { reactive, ref } from 'vue';
import { toast } from 'vue-sonner';
import { PhoneNumberInput } from '@/components/form/phone-number-input'; import { PhoneNumberInput } from '@/components/form/phone-number-input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@ -19,9 +22,6 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { apiFetch } from '@/lib/api'; import { apiFetch } from '@/lib/api';
import { FIELD_LIMITS } from '@/lib/field-limits'; import { FIELD_LIMITS } from '@/lib/field-limits';
import { Save } from '@lucide/vue';
import { reactive, ref } from 'vue';
import { toast } from 'vue-sonner';
import { store } from '@/routes/api/master/customers'; import { store } from '@/routes/api/master/customers';
const open = defineModel<boolean>('open', { default: false }); const open = defineModel<boolean>('open', { default: false });

View File

@ -1,4 +1,5 @@
import { computed, ref, watch, type MaybeRefOrGetter, toValue } from 'vue'; import { computed, ref, watch, toValue } from 'vue';
import type {MaybeRefOrGetter} from 'vue';
import { toast } from 'vue-sonner'; import { toast } from 'vue-sonner';
import { OrderChannel } from '@/constants/order-channel'; import { OrderChannel } from '@/constants/order-channel';
import { StockQuality } from '@/constants/stock-quality'; import { StockQuality } from '@/constants/stock-quality';

View File

@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import DataTableActions from './data-table-actions.vue';
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue'; import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
import GroupedTableFooter from '@/components/data-table/GroupedTableFooter.vue'; import GroupedTableFooter from '@/components/data-table/GroupedTableFooter.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@ -26,6 +25,7 @@ import type {
DataTablePaginationLink, DataTablePaginationLink,
} from '@/types/data-table'; } from '@/types/data-table';
import type { OrderListItem } from '@/types/order'; import type { OrderListItem } from '@/types/order';
import DataTableActions from './data-table-actions.vue';
const props = defineProps<{ const props = defineProps<{
orders: OrderListItem[]; orders: OrderListItem[];

View File

@ -1,14 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import BackButton from '@/components/button/BackButton.vue'; import BackButton from '@/components/button/BackButton.vue';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { index, store } from '@/routes/admin/manage/purchases';
import type { import type {
PurchaseCartItem, PurchaseCartItem,
PurchaseCatalogItem, PurchaseCatalogItem,
SelectOption, SelectOption,
} from '@/types/purchase'; } from '@/types/purchase';
import { Head } from '@inertiajs/vue3';
import PurchasePosForm from './form/PurchasePosForm.vue'; import PurchasePosForm from './form/PurchasePosForm.vue';
import { index, store } from '@/routes/admin/manage/purchases';
defineProps<{ defineProps<{
suppliers: SelectOption[]; suppliers: SelectOption[];

View File

@ -1,15 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed } from 'vue';
import BackButton from '@/components/button/BackButton.vue'; import BackButton from '@/components/button/BackButton.vue';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { index, update } from '@/routes/admin/manage/purchases';
import type { import type {
PurchaseCatalogItem, PurchaseCatalogItem,
PurchaseEditItem, PurchaseEditItem,
SelectOption, SelectOption,
} from '@/types/purchase'; } from '@/types/purchase';
import { Head } from '@inertiajs/vue3';
import { computed } from 'vue';
import PurchasePosForm from './form/PurchasePosForm.vue'; import PurchasePosForm from './form/PurchasePosForm.vue';
import { index, update } from '@/routes/admin/manage/purchases';
const props = defineProps<{ const props = defineProps<{
purchase: PurchaseEditItem; purchase: PurchaseEditItem;

View File

@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import CreateButton from '@/components/button/CreateButton.vue'; import CreateButton from '@/components/button/CreateButton.vue';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
@ -7,10 +9,8 @@ import {
useDataTableQuerySync, useDataTableQuerySync,
} from '@/composables/useDataTableQuery'; } from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import type { PaginatedPurchases } from '@/types/purchase';
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import { index, create } from '@/routes/admin/manage/purchases'; import { index, create } from '@/routes/admin/manage/purchases';
import type { PaginatedPurchases } from '@/types/purchase';
import PurchaseGroupedTable from './table/PurchaseGroupedTable.vue'; import PurchaseGroupedTable from './table/PurchaseGroupedTable.vue';
const props = defineProps<{ const props = defineProps<{

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue';
import { Save } from '@lucide/vue'; import { Save } from '@lucide/vue';
import { computed } from 'vue';
import { RupiahInput } from '@/components/form/rupiah-input'; import { RupiahInput } from '@/components/form/rupiah-input';
import MediaDropzone from '@/components/media/MediaDropzone.vue'; import MediaDropzone from '@/components/media/MediaDropzone.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -12,7 +12,8 @@ import {
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { FIELD_LIMITS } from '@/lib/field-limits'; import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors, type FormWithErrors } from '@/lib/form'; import { formErrors } from '@/lib/form';
import type {FormWithErrors} from '@/lib/form';
import { formatRupiah } from '@/lib/rupiah'; import { formatRupiah } from '@/lib/rupiah';
import type { MediaUploadState } from '@/types/media'; import type { MediaUploadState } from '@/types/media';

View File

@ -12,7 +12,8 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { formErrors, type FormWithErrors } from '@/lib/form'; import { formErrors } from '@/lib/form';
import type {FormWithErrors} from '@/lib/form';
import type { SelectOption } from '@/types/purchase'; import type { SelectOption } from '@/types/purchase';
defineProps<{ defineProps<{

View File

@ -1,4 +1,5 @@
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 { toast } from 'vue-sonner';
import { apiFetch } from '@/lib/api'; import { apiFetch } from '@/lib/api';
import { store as syncDraftRoute, destroy as destroyDraftRoute } from '@/routes/admin/manage/purchases/draft_items'; import { store as syncDraftRoute, destroy as destroyDraftRoute } from '@/routes/admin/manage/purchases/draft_items';

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import OwnerVerificationRowActions from '@/components/owner-verification/OwnerVerificationRowActions.vue';
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue'; import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
import OwnerVerificationRowActions from '@/components/owner-verification/OwnerVerificationRowActions.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
Table, Table,

View File

@ -1,12 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import BackButton from '@/components/button/BackButton.vue';
import { Head, router } from '@inertiajs/vue3'; import { Head, router } from '@inertiajs/vue3';
import { Loader2, Send } from '@lucide/vue'; import { Loader2, Send } from '@lucide/vue';
import { ref } from 'vue'; import { ref } from 'vue';
import BackButton from '@/components/button/BackButton.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import type { CatalogProduct } from '@/types/stok-opname';
import { index, submit } from '@/routes/admin/manage/stok-opnames'; import { index, submit } from '@/routes/admin/manage/stok-opnames';
import type { CatalogProduct } from '@/types/stok-opname';
import StokOpnameAutoSaveStatus from './form/StokOpnameAutoSaveStatus.vue'; import StokOpnameAutoSaveStatus from './form/StokOpnameAutoSaveStatus.vue';
import StokOpnameInfoSection from './form/StokOpnameInfoSection.vue'; import StokOpnameInfoSection from './form/StokOpnameInfoSection.vue';
import StokOpnameProductTable from './form/StokOpnameProductTable.vue'; import StokOpnameProductTable from './form/StokOpnameProductTable.vue';

View File

@ -1,14 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import BackButton from '@/components/button/BackButton.vue';
import { Badge } from '@/components/ui/badge';
import { Head, router } from '@inertiajs/vue3'; import { Head, router } from '@inertiajs/vue3';
import { Loader2, Send } from '@lucide/vue'; import { Loader2, Send } from '@lucide/vue';
import { ref } from 'vue'; import { ref } from 'vue';
import BackButton from '@/components/button/BackButton.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import AdminLayout from '@/layouts/AdminLayout.vue';
import { stokOpnameStatusBadgeVariant } from '@/constants/stok-opname-status'; import { stokOpnameStatusBadgeVariant } from '@/constants/stok-opname-status';
import type { CatalogProduct, StokOpnameDetail } from '@/types/stok-opname'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { index, submit } from '@/routes/admin/manage/stok-opnames'; import { index, submit } from '@/routes/admin/manage/stok-opnames';
import type { CatalogProduct, StokOpnameDetail } from '@/types/stok-opname';
import StokOpnameAutoSaveStatus from './form/StokOpnameAutoSaveStatus.vue'; import StokOpnameAutoSaveStatus from './form/StokOpnameAutoSaveStatus.vue';
import StokOpnameInfoSection from './form/StokOpnameInfoSection.vue'; import StokOpnameInfoSection from './form/StokOpnameInfoSection.vue';
import StokOpnameProductTable from './form/StokOpnameProductTable.vue'; import StokOpnameProductTable from './form/StokOpnameProductTable.vue';

View File

@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, router } from '@inertiajs/vue3';
import { ref, watch } from 'vue';
import CreateButton from '@/components/button/CreateButton.vue'; import CreateButton from '@/components/button/CreateButton.vue';
import ConfirmDialog from '@/components/ConfirmDialog.vue'; import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
@ -8,10 +10,8 @@ import {
useDataTableQuerySync, useDataTableQuerySync,
} from '@/composables/useDataTableQuery'; } from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import type { PaginatedStokOpnames, StokOpnameListItem } from '@/types/stok-opname';
import { Head, router } from '@inertiajs/vue3';
import { ref, watch } from 'vue';
import { index, create, edit, submit, verify, reject } from '@/routes/admin/manage/stok-opnames'; import { index, create, edit, submit, verify, reject } from '@/routes/admin/manage/stok-opnames';
import type { PaginatedStokOpnames, StokOpnameListItem } from '@/types/stok-opname';
import StokOpnameGroupedTable from './table/StokOpnameGroupedTable.vue'; import StokOpnameGroupedTable from './table/StokOpnameGroupedTable.vue';
const props = defineProps<{ const props = defineProps<{
@ -68,19 +68,28 @@ function openRejectDialog(item: StokOpnameListItem) {
} }
function confirmSubmit() { function confirmSubmit() {
if (!selectedStokOpname.value) return; if (!selectedStokOpname.value) {
return;
}
router.post(submit.url(selectedStokOpname.value.id)); router.post(submit.url(selectedStokOpname.value.id));
submitDialogOpen.value = false; submitDialogOpen.value = false;
} }
function confirmVerify() { function confirmVerify() {
if (!selectedStokOpname.value) return; if (!selectedStokOpname.value) {
return;
}
router.post(verify.url(selectedStokOpname.value.id)); router.post(verify.url(selectedStokOpname.value.id));
verifyDialogOpen.value = false; verifyDialogOpen.value = false;
} }
function confirmReject() { function confirmReject() {
if (!selectedStokOpname.value || !rejectReason.value.trim()) return; if (!selectedStokOpname.value || !rejectReason.value.trim()) {
return;
}
router.post(reject.url(selectedStokOpname.value.id), { router.post(reject.url(selectedStokOpname.value.id), {
reason: rejectReason.value, reason: rejectReason.value,
}); });

View File

@ -1,5 +1,6 @@
import { useDebounceFn } from '@vueuse/core'; import { useDebounceFn } from '@vueuse/core';
import { computed, ref, watch, type MaybeRefOrGetter, toValue } from 'vue'; import { computed, ref, watch, toValue } from 'vue';
import type {MaybeRefOrGetter} from 'vue';
import { apiFetch } from '@/lib/api'; import { apiFetch } from '@/lib/api';
import { auto_save } from '@/routes/admin/manage/stok-opnames'; import { auto_save } from '@/routes/admin/manage/stok-opnames';
import type { import type {

View File

@ -14,14 +14,14 @@ import {
} from '@/components/ui/table'; } from '@/components/ui/table';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
import { usePaginationSummary } from '@/composables/usePaginationSummary'; import { usePaginationSummary } from '@/composables/usePaginationSummary';
import { cn } from '@/lib/utils'; import { stokOpnameStatusBadgeVariant } from '@/constants/stok-opname-status';
import { groupedTableRowNumber } from '@/lib/grouped-table'; import { groupedTableRowNumber } from '@/lib/grouped-table';
import { import {
stokOpnameDifference, stokOpnameDifference,
stokOpnameDifferenceClass, stokOpnameDifferenceClass,
stokOpnameDifferenceText, stokOpnameDifferenceText,
} from '@/lib/stok-opname-display'; } from '@/lib/stok-opname-display';
import { stokOpnameStatusBadgeVariant } from '@/constants/stok-opname-status'; import { cn } from '@/lib/utils';
import type { import type {
DataTablePagination, DataTablePagination,
DataTablePaginationLink, DataTablePaginationLink,

View File

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { RowDeleteAction, RowEditAction, RowSubmitAction, RowApproveAction, RowRejectAction } from '@/components/button'; import { RowDeleteAction, RowEditAction, RowSubmitAction, RowApproveAction, RowRejectAction } from '@/components/button';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
import type { StokOpnameListItem } from '@/types/stok-opname';
import { destroy } from '@/routes/admin/manage/stok-opnames'; import { destroy } from '@/routes/admin/manage/stok-opnames';
import type { StokOpnameListItem } from '@/types/stok-opname';
defineProps<{ defineProps<{
stokOpname: StokOpnameListItem; stokOpname: StokOpnameListItem;

View File

@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import CreateButton from '@/components/button/CreateButton.vue'; import CreateButton from '@/components/button/CreateButton.vue';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
@ -8,13 +10,11 @@ import {
useDataTableQuerySync, useDataTableQuerySync,
} from '@/composables/useDataTableQuery'; } from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { index } from '@/routes/admin/master/categories';
import type { CategoryListItem, PaginatedCategories } from '@/types/category'; import type { CategoryListItem, PaginatedCategories } from '@/types/category';
import type { DataTableSort } from '@/types/data-table'; import type { DataTableSort } from '@/types/data-table';
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import CategoryFormModal from './form/CategoryFormModal.vue'; import CategoryFormModal from './form/CategoryFormModal.vue';
import { createColumns } from './table/columns'; import { createColumns } from './table/columns';
import { index } from '@/routes/admin/master/categories';
const props = defineProps<{ const props = defineProps<{
categories: PaginatedCategories; categories: PaginatedCategories;

View File

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { RowDeleteAction, RowEditAction } from '@/components/button'; import { RowDeleteAction, RowEditAction } from '@/components/button';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
import type { CategoryListItem } from '@/types/category';
import { destroy } from '@/routes/admin/master/categories'; import { destroy } from '@/routes/admin/master/categories';
import type { CategoryListItem } from '@/types/category';
defineProps<{ defineProps<{
category: CategoryListItem; category: CategoryListItem;

View File

@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import CreateButton from '@/components/button/CreateButton.vue'; import CreateButton from '@/components/button/CreateButton.vue';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
@ -8,13 +10,11 @@ import {
useDataTableQuerySync, useDataTableQuerySync,
} from '@/composables/useDataTableQuery'; } from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { index } from '@/routes/admin/master/customers';
import type { CustomerListItem, PaginatedCustomers } from '@/types/customer'; import type { CustomerListItem, PaginatedCustomers } from '@/types/customer';
import type { DataTableSort } from '@/types/data-table'; import type { DataTableSort } from '@/types/data-table';
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import CustomerFormModal from './form/CustomerFormModal.vue'; import CustomerFormModal from './form/CustomerFormModal.vue';
import { createColumns } from './table/columns'; import { createColumns } from './table/columns';
import { index } from '@/routes/admin/master/customers';
const props = defineProps<{ const props = defineProps<{
customers: PaginatedCustomers; customers: PaginatedCustomers;

View File

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { RowDeleteAction, RowEditAction } from '@/components/button'; import { RowDeleteAction, RowEditAction } from '@/components/button';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
import type { CustomerListItem } from '@/types/customer';
import { destroy } from '@/routes/admin/master/customers'; import { destroy } from '@/routes/admin/master/customers';
import type { CustomerListItem } from '@/types/customer';
defineProps<{ defineProps<{
customer: CustomerListItem; customer: CustomerListItem;

View File

@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import BackButton from '@/components/button/BackButton.vue'; import BackButton from '@/components/button/BackButton.vue';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import type { CategoryOption } from '@/types/product';
import { Head } from '@inertiajs/vue3';
import ProductForm from './form/ProductForm.vue';
import { index, store } from '@/routes/admin/master/products'; import { index, store } from '@/routes/admin/master/products';
import type { CategoryOption } from '@/types/product';
import ProductForm from './form/ProductForm.vue';
defineProps<{ defineProps<{
categories: CategoryOption[]; categories: CategoryOption[];

View File

@ -1,11 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import BackButton from '@/components/button/BackButton.vue';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { CategoryOption, ProductListItem } from '@/types/product';
import { Head } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import { computed } from 'vue'; import { computed } from 'vue';
import ProductForm from './form/ProductForm.vue'; import BackButton from '@/components/button/BackButton.vue';
import AdminLayout from '@/layouts/AdminLayout.vue';
import { index, update } from '@/routes/admin/master/products'; import { index, update } from '@/routes/admin/master/products';
import type { CategoryOption, ProductListItem } from '@/types/product';
import ProductForm from './form/ProductForm.vue';
const props = defineProps<{ const props = defineProps<{
product: ProductListItem & { description?: string | null }; product: ProductListItem & { description?: string | null };

View File

@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import CreateButton from '@/components/button/CreateButton.vue'; import CreateButton from '@/components/button/CreateButton.vue';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
@ -9,11 +11,9 @@ import {
import { ActiveStatus } from '@/constants/active-status'; import { ActiveStatus } from '@/constants/active-status';
import { StockStatus } from '@/constants/stock-status'; import { StockStatus } from '@/constants/stock-status';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { index, create } from '@/routes/admin/master/products';
import type { DataTableFilterDef } from '@/types/data-table'; import type { DataTableFilterDef } from '@/types/data-table';
import type { CategoryOption, PaginatedProducts } from '@/types/product'; import type { CategoryOption, PaginatedProducts } from '@/types/product';
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import { index, create } from '@/routes/admin/master/products';
import ProductGroupedTable from './table/ProductGroupedTable.vue'; import ProductGroupedTable from './table/ProductGroupedTable.vue';
const props = defineProps<{ const props = defineProps<{

View File

@ -100,6 +100,7 @@ const {
prices[price.type] = String(price.price); prices[price.type] = String(price.price);
} }
}); });
return { return {
client_id: createClientId(), client_id: createClientId(),
id: variant.id, id: variant.id,
@ -120,7 +121,10 @@ function copyPrices(variantPrices: Record<string, string>) {
} }
function pastePrices(clientId: string) { function pastePrices(clientId: string) {
if (!copiedPrices.value) return; if (!copiedPrices.value) {
return;
}
setVariantField(clientId, 'prices', { ...copiedPrices.value }); setVariantField(clientId, 'prices', { ...copiedPrices.value });
} }
@ -164,11 +168,13 @@ function buildFormData(): FormData {
formData.append(`variants[${index}][name]`, variant.name.trim()); formData.append(`variants[${index}][name]`, variant.name.trim());
formData.append(`variants[${index}][stock]`, String(Number.parseInt(String(variant.stock), 10) || 0)); formData.append(`variants[${index}][stock]`, String(Number.parseInt(String(variant.stock), 10) || 0));
formData.append(`variants[${index}][retail_stock]`, String(Number.parseInt(String(variant.retail_stock), 10) || 0)); formData.append(`variants[${index}][retail_stock]`, String(Number.parseInt(String(variant.retail_stock), 10) || 0));
if (variant.prices && showPrices) { if (variant.prices && showPrices) {
Object.entries(variant.prices as Record<string, string>).forEach(([type, value]) => { Object.entries(variant.prices as Record<string, string>).forEach(([type, value]) => {
formData.append(`variants[${index}][prices][${type}]`, String(Number.parseInt(value, 10) || 0)); formData.append(`variants[${index}][prices][${type}]`, String(Number.parseInt(value, 10) || 0));
}); });
} }
appendMediaToFormData(formData, `variants[${index}]`, variant.media); appendMediaToFormData(formData, `variants[${index}]`, variant.media);
}, props.method); }, props.method);

View File

@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3'; import { Head, Link } from '@inertiajs/vue3';
import BackButton from '@/components/button/BackButton.vue';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { index, store } from '@/routes/admin/master/raw_materials';
import type { EnumOption } from '@/types/raw-material'; import type { EnumOption } from '@/types/raw-material';
import RawMaterialForm from './form/RawMaterialForm.vue'; import RawMaterialForm from './form/RawMaterialForm.vue';
import BackButton from '@/components/button/BackButton.vue';
import { index, store } from '@/routes/admin/master/raw_materials';
defineProps<{ defineProps<{
units: EnumOption[]; units: EnumOption[];

View File

@ -1,11 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import BackButton from '@/components/button/BackButton.vue';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { EnumOption, RawMaterialListItem } from '@/types/raw-material';
import { Head } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import { computed } from 'vue'; import { computed } from 'vue';
import RawMaterialForm from './form/RawMaterialForm.vue'; import BackButton from '@/components/button/BackButton.vue';
import AdminLayout from '@/layouts/AdminLayout.vue';
import { index, update } from '@/routes/admin/master/raw_materials'; import { index, update } from '@/routes/admin/master/raw_materials';
import type { EnumOption, RawMaterialListItem } from '@/types/raw-material';
import RawMaterialForm from './form/RawMaterialForm.vue';
const props = defineProps<{ const props = defineProps<{
rawMaterial: RawMaterialListItem; rawMaterial: RawMaterialListItem;

View File

@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import CreateButton from '@/components/button/CreateButton.vue'; import CreateButton from '@/components/button/CreateButton.vue';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
@ -9,11 +11,9 @@ import {
import { ActiveStatus } from '@/constants/active-status'; import { ActiveStatus } from '@/constants/active-status';
import { StockStatus } from '@/constants/stock-status'; import { StockStatus } from '@/constants/stock-status';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { index, create } from '@/routes/admin/master/raw_materials';
import type { DataTableFilterDef } from '@/types/data-table'; import type { DataTableFilterDef } from '@/types/data-table';
import type { PaginatedRawMaterials } from '@/types/raw-material'; import type { PaginatedRawMaterials } from '@/types/raw-material';
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import { index, create } from '@/routes/admin/master/raw_materials';
import RawMaterialGroupedTable from './table/RawMaterialGroupedTable.vue'; import RawMaterialGroupedTable from './table/RawMaterialGroupedTable.vue';
const props = defineProps<{ const props = defineProps<{

View File

@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { import {
Field, Field,
FieldError, FieldError,
@ -14,9 +15,9 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { FIELD_LIMITS } from '@/lib/field-limits'; import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors, type FormWithErrors } from '@/lib/form'; import { formErrors } from '@/lib/form';
import type {FormWithErrors} from '@/lib/form';
import type { EnumOption } from '@/types/raw-material'; import type { EnumOption } from '@/types/raw-material';
defineProps<{ defineProps<{

View File

@ -7,7 +7,8 @@ import {
FieldGroup, FieldGroup,
FieldLabel, FieldLabel,
} from '@/components/ui/field'; } from '@/components/ui/field';
import { formErrors, type FormWithErrors } from '@/lib/form'; import { formErrors } from '@/lib/form';
import type {FormWithErrors} from '@/lib/form';
import type { RawMaterialPriceFormItem } from '@/types/raw-material'; import type { RawMaterialPriceFormItem } from '@/types/raw-material';
defineProps<{ defineProps<{

View File

@ -2,8 +2,8 @@
import { RowDeleteAction, RowEditAction } from '@/components/button'; import { RowDeleteAction, RowEditAction } from '@/components/button';
import OwnerVerificationRowActions from '@/components/owner-verification/OwnerVerificationRowActions.vue'; import OwnerVerificationRowActions from '@/components/owner-verification/OwnerVerificationRowActions.vue';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
import type { RawMaterialListItem } from '@/types/raw-material';
import { edit, destroy } from '@/routes/admin/master/raw_materials'; import { edit, destroy } from '@/routes/admin/master/raw_materials';
import type { RawMaterialListItem } from '@/types/raw-material';
defineProps<{ defineProps<{
material: RawMaterialListItem; material: RawMaterialListItem;

View File

@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import CreateButton from '@/components/button/CreateButton.vue'; import CreateButton from '@/components/button/CreateButton.vue';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
@ -8,13 +10,11 @@ import {
useDataTableQuerySync, useDataTableQuerySync,
} from '@/composables/useDataTableQuery'; } from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { index } from '@/routes/admin/master/suppliers';
import type { DataTableSort } from '@/types/data-table'; import type { DataTableSort } from '@/types/data-table';
import type { PaginatedSuppliers, SupplierListItem } from '@/types/supplier'; import type { PaginatedSuppliers, SupplierListItem } from '@/types/supplier';
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import SupplierFormModal from './form/SupplierFormModal.vue'; import SupplierFormModal from './form/SupplierFormModal.vue';
import { createColumns } from './table/columns'; import { createColumns } from './table/columns';
import { index } from '@/routes/admin/master/suppliers';
const props = defineProps<{ const props = defineProps<{
suppliers: PaginatedSuppliers; suppliers: PaginatedSuppliers;

View File

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { RowDeleteAction, RowEditAction } from '@/components/button'; import { RowDeleteAction, RowEditAction } from '@/components/button';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
import type { SupplierListItem } from '@/types/supplier';
import { destroy } from '@/routes/admin/master/suppliers'; import { destroy } from '@/routes/admin/master/suppliers';
import type { SupplierListItem } from '@/types/supplier';
defineProps<{ defineProps<{
supplier: SupplierListItem; supplier: SupplierListItem;

View File

@ -5,11 +5,11 @@ import { DataTable } from '@/components/data-table';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { useDataTableQuery, useDataTableQuerySync } from '@/composables/useDataTableQuery'; import { useDataTableQuery, useDataTableQuerySync } from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { index } from '@/routes/admin/system/activity_logs';
import type { ActivityLogListItem, PaginatedActivityLogs, SelectOption } from '@/types/activity-log'; import type { ActivityLogListItem, PaginatedActivityLogs, SelectOption } from '@/types/activity-log';
import type { DataTableSort } from '@/types/data-table'; import type { DataTableSort } from '@/types/data-table';
import ActivityLogDetailModal from './table/ActivityLogDetailModal.vue'; import ActivityLogDetailModal from './table/ActivityLogDetailModal.vue';
import { createColumns } from './table/columns'; import { createColumns } from './table/columns';
import { index } from '@/routes/admin/system/activity_logs';
const props = defineProps<{ const props = defineProps<{
activityLogs: PaginatedActivityLogs; activityLogs: PaginatedActivityLogs;

View File

@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3'; import { Head, Link } from '@inertiajs/vue3';
import AdminLayout from '@/layouts/AdminLayout.vue';
import RoleForm from './form/RoleForm.vue';
import BackButton from '@/components/button/BackButton.vue'; import BackButton from '@/components/button/BackButton.vue';
import AdminLayout from '@/layouts/AdminLayout.vue';
import { index, store } from '@/routes/admin/system/roles'; import { index, store } from '@/routes/admin/system/roles';
import RoleForm from './form/RoleForm.vue';
interface PermissionOption { interface PermissionOption {
value: string; value: string;

View File

@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import BackButton from '@/components/button/BackButton.vue'; import BackButton from '@/components/button/BackButton.vue';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { Head } from '@inertiajs/vue3';
import RoleForm from './form/RoleForm.vue';
import { index, update } from '@/routes/admin/system/roles'; import { index, update } from '@/routes/admin/system/roles';
import RoleForm from './form/RoleForm.vue';
interface PermissionOption { interface PermissionOption {
value: string; value: string;

View File

@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import CreateButton from '@/components/button/CreateButton.vue'; import CreateButton from '@/components/button/CreateButton.vue';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
@ -8,10 +10,8 @@ import {
useDataTableQuerySync, useDataTableQuerySync,
} from '@/composables/useDataTableQuery'; } from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import type { DataTableSort } from '@/types/data-table';
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import { index, create } from '@/routes/admin/system/roles'; import { index, create } from '@/routes/admin/system/roles';
import type { DataTableSort } from '@/types/data-table';
import type { RoleListItem } from './table/columns'; import type { RoleListItem } from './table/columns';
import { createColumns } from './table/columns'; import { createColumns } from './table/columns';

View File

@ -2,8 +2,8 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { RowDeleteAction, RowEditAction } from '@/components/button'; import { RowDeleteAction, RowEditAction } from '@/components/button';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
import type { RoleListItem } from './columns';
import { edit, destroy } from '@/routes/admin/system/roles'; import { edit, destroy } from '@/routes/admin/system/roles';
import type { RoleListItem } from './columns';
const props = defineProps<{ const props = defineProps<{
role: RoleListItem; role: RoleListItem;

View File

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import { ref } from 'vue'; import { ref } from 'vue';
import { SettingSection as SettingSectionConst } from '@/constants/setting-section';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
import { SettingSection as SettingSectionConst } from '@/constants/setting-section';
import SettingLayout from '@/layouts/SettingLayout.vue'; import SettingLayout from '@/layouts/SettingLayout.vue';
import type { import type {
HomepageSettingsData, HomepageSettingsData,

View File

@ -1,5 +1,5 @@
import type { MediaItem } from '@/types/media';
import type { Paginated } from '@/types/common'; import type { Paginated } from '@/types/common';
import type { MediaItem } from '@/types/media';
import type { ProductListItem } from '@/types/product'; import type { ProductListItem } from '@/types/product';
import type { RawMaterialListItem } from '@/types/raw-material'; import type { RawMaterialListItem } from '@/types/raw-material';
@ -14,6 +14,8 @@ export type CuttingStatusAction = {
export type CuttingMaterialListItem = { export type CuttingMaterialListItem = {
id: number; id: number;
material_usage_formatted: string; material_usage_formatted: string;
material_result?: number | null;
combination_material_result?: number | null;
variant?: string; variant?: string;
raw_material_id?: number; raw_material_id?: number;
raw_material_name?: string; raw_material_name?: string;
@ -118,8 +120,10 @@ export type CuttingMaterialCartItem = {
unit_abbreviation: string; unit_abbreviation: string;
stock_input: string; stock_input: string;
material_usage: string; material_usage: string;
material_result?: string | null;
images?: MediaItem[]; images?: MediaItem[];
combination_id?: number | null; combination_id?: number | null;
combination_material_result?: number | null;
}; };
export type CuttingResultCartItem = { export type CuttingResultCartItem = {
@ -143,6 +147,8 @@ export type CuttingEditItem = {
materials: Array<{ materials: Array<{
raw_material_price_id: number; raw_material_price_id: number;
material_usage_input: string; material_usage_input: string;
material_result_input?: number | null;
combination_material_result?: number | null;
variant?: string; variant?: string;
stock_input?: string; stock_input?: string;
images?: MediaItem[]; images?: MediaItem[];

View File

@ -1,5 +1,5 @@
import type { MediaItem } from '@/types/media';
import type { EnumOption, Paginated, SelectOption } from '@/types/common'; import type { EnumOption, Paginated, SelectOption } from '@/types/common';
import type { MediaItem } from '@/types/media';
import type { ProductListItem } from '@/types/product'; import type { ProductListItem } from '@/types/product';
export type { EnumOption, SelectOption } from '@/types/common'; export type { EnumOption, SelectOption } from '@/types/common';

View File

@ -1,5 +1,5 @@
import type { Paginated } from '@/types/common';
import type { MediaItem } from '@/types/media'; import type { MediaItem } from '@/types/media';
import type { Paginated, SelectOption } from '@/types/common';
import type { RawMaterialListItem } from '@/types/raw-material'; import type { RawMaterialListItem } from '@/types/raw-material';
export type { SelectOption } from '@/types/common'; export type { SelectOption } from '@/types/common';

View File

@ -192,6 +192,61 @@ function setupDraftItems(User $user): array
expect($cutting->results)->toHaveCount(1); expect($cutting->results)->toHaveCount(1);
}); });
test('draft material can have material_result', function () {
$user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_CREATE);
$draft = setupDraftItems($user);
$this->actingAs($user)
->postJson(route('admin.manage.cuttings.draft_materials.store'), [
'raw_material_price_id' => $draft['price']->id,
'material_usage' => 5,
'material_result' => 10,
])
->assertOk()
->assertJsonPath('item.material_result', 10);
$this->assertDatabaseHas('cutting_materials', [
'raw_material_price_id' => $draft['price']->id,
'material_result' => 10,
]);
});
test('material_result is preserved when creating cutting from drafts', function () {
$user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_CREATE);
$draft = setupDraftItems($user);
// Create draft material with material_result
$this->actingAs($user)
->postJson(route('admin.manage.cuttings.draft_materials.store'), [
'raw_material_price_id' => $draft['price']->id,
'material_usage' => 5,
'material_result' => 10,
])
->assertOk();
// Create draft result
$this->actingAs($user)
->postJson(route('admin.manage.cuttings.draft_results.store'), [
'product_variant_id' => $draft['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 result',
])
->assertRedirect(route('admin.manage.cuttings.index'));
$cutting = Cutting::where('description', 'Cutting with result')->first();
expect($cutting->materials->first()->material_result)->toBe(10);
});
test('guest cannot create a cutting', function () { test('guest cannot create a cutting', function () {
$this->post(route('admin.manage.cuttings.store'), [ $this->post(route('admin.manage.cuttings.store'), [
'description' => 'Test', 'description' => 'Test',
@ -328,6 +383,40 @@ function setupDraftItems(User $user): array
expect($cutting->fresh()->description)->toBe('Updated description'); expect($cutting->fresh()->description)->toBe('Updated description');
}); });
test('update can save material_result for single materials', function () {
$user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_UPDATE);
$cutting = createCuttingWithMaterialsAndResults($user);
$rawMaterial = RawMaterial::factory()->create();
$price = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->create(['product_id' => $product->id]);
$this->actingAs($user)
->put(route('admin.manage.cuttings.update', $cutting), [
'description' => 'With material result',
'sewing_cost' => 0,
'other_cost' => 0,
'materials' => [
['raw_material_price_id' => $price->id, 'material_usage' => 10, 'material_result' => 25],
],
'results' => [
[
'product_variant_id' => $variant->id,
'cutting_result' => 20,
'sample' => 15,
'original_outside_sample' => 5,
],
],
])
->assertRedirect(route('admin.manage.cuttings.index'));
$cutting->refresh();
expect($cutting->materials->first()->material_result)->toBe(25);
});
test('guest cannot update a cutting', function () { test('guest cannot update a cutting', function () {
$cutting = createCuttingWithMaterialsAndResults(); $cutting = createCuttingWithMaterialsAndResults();
@ -518,6 +607,27 @@ function setupDraftItems(User $user): array
$this->assertDatabaseCount('cutting_materials', 2); $this->assertDatabaseCount('cutting_materials', 2);
}); });
test('draft combination can have material_result', 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],
],
'material_result' => 15,
])
->assertOk();
$combination = CuttingMaterialCombination::first();
expect($combination->material_result)->toBe(15);
});
test('draft combination requires at least 2 materials', function () { test('draft combination requires at least 2 materials', function () {
$user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_CREATE); $user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_CREATE);
@ -628,7 +738,7 @@ function setupDraftItems(User $user): array
// ─── Update with Combination ──────────────────────────────── // ─── Update with Combination ────────────────────────────────
describe('Cutting Update with Combination', function () { describe('Cutting Update with Combination', function () {
test('cutting can be updated preserving combination_id', function () { test('cutting can be updated with combination materials', function () {
$user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_UPDATE); $user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_UPDATE);
$cutting = createCuttingWithMaterialsAndResults($user); $cutting = createCuttingWithMaterialsAndResults($user);
@ -637,11 +747,6 @@ function setupDraftItems(User $user): array
$price1 = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]); $price1 = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]);
$price2 = 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(); $product = Product::factory()->create();
$variant = ProductVariant::factory()->create(['product_id' => $product->id]); $variant = ProductVariant::factory()->create(['product_id' => $product->id]);
@ -651,8 +756,8 @@ function setupDraftItems(User $user): array
'sewing_cost' => 0, 'sewing_cost' => 0,
'other_cost' => 0, 'other_cost' => 0,
'materials' => [ 'materials' => [
['raw_material_price_id' => $price1->id, 'material_usage' => 3, 'combination_id' => $combination->id], ['raw_material_price_id' => $price1->id, 'material_usage' => 3, 'combination_id' => 999],
['raw_material_price_id' => $price2->id, 'material_usage' => 2, 'combination_id' => $combination->id], ['raw_material_price_id' => $price2->id, 'material_usage' => 2, 'combination_id' => 999],
], ],
'results' => [ 'results' => [
[ [
@ -668,7 +773,47 @@ function setupDraftItems(User $user): array
$cutting->refresh(); $cutting->refresh();
expect($cutting->description)->toBe('Updated with combination'); expect($cutting->description)->toBe('Updated with combination');
expect($cutting->materials)->toHaveCount(2); expect($cutting->materials)->toHaveCount(2);
expect($cutting->materials->first()->combination_id)->toBe($combination->id); // Both materials should have the same combination_id
expect($cutting->materials->first()->combination_id)->not->toBeNull();
expect($cutting->materials->first()->combination_id)->toBe($cutting->materials->last()->combination_id);
});
test('update can save combination_material_result', 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]);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->create(['product_id' => $product->id]);
$this->actingAs($user)
->put(route('admin.manage.cuttings.update', $cutting), [
'description' => 'Combination with result',
'sewing_cost' => 0,
'other_cost' => 0,
'materials' => [
['raw_material_price_id' => $price1->id, 'material_usage' => 3, 'combination_id' => 999, 'combination_material_result' => 20],
['raw_material_price_id' => $price2->id, 'material_usage' => 2, 'combination_id' => 999, 'combination_material_result' => 20],
],
'results' => [
[
'product_variant_id' => $variant->id,
'cutting_result' => 10,
'sample' => 8,
'original_outside_sample' => 2,
],
],
])
->assertRedirect(route('admin.manage.cuttings.index'));
$cutting->refresh();
$combination = $cutting->combinations()->first();
expect($combination)->not->toBeNull();
expect($combination->material_result)->toBe(20);
}); });
}); });
@ -755,6 +900,17 @@ function setupDraftItems(User $user): array
expect($material->trashed())->toBeTrue(); expect($material->trashed())->toBeTrue();
}); });
test('cutting material has material_result cast to integer', function () {
$cutting = Cutting::factory()->create();
$material = CuttingMaterial::factory()->create([
'cutting_id' => $cutting->id,
'material_result' => 10,
]);
expect($material->material_result)->toBeInt();
expect($material->material_result)->toBe(10);
});
}); });
// ─── CuttingResult Model ─────────────────────────────────── // ─── CuttingResult Model ───────────────────────────────────
@ -842,4 +998,13 @@ function setupDraftItems(User $user): array
expect($combination->cutting_id)->toBeNull(); expect($combination->cutting_id)->toBeNull();
expect($combination->user_id)->toBe($user->id); expect($combination->user_id)->toBe($user->id);
}); });
test('combination has material_result cast to integer', function () {
$combination = CuttingMaterialCombination::factory()->create([
'material_result' => 15,
]);
expect($combination->material_result)->toBeInt();
expect($combination->material_result)->toBe(15);
});
}); });