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'),
],
'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'),
],
'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'),
];
$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.*.product_variant_id'] = [
@ -73,6 +75,8 @@ public function attributes(): array
'materials' => 'Bahan Baku',
'materials.*.raw_material_price_id' => 'Bahan Baku',
'materials.*.material_usage' => 'Pemakaian',
'materials.*.material_result' => 'Hasil',
'materials.*.combination_material_result' => 'Hasil Kombinasi',
'results' => 'Hasil Produk',
'results.*.product_variant_id' => 'Varian Produk',
'results.*.cutting_result' => 'Hasil',

View File

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

View File

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

View File

@ -15,6 +15,13 @@ class CuttingMaterialCombination extends Model
{
use HasFactory, InteractsWithActivityLog, SoftDeletes;
protected function casts(): array
{
return [
'material_result' => 'integer',
];
}
public function cutting(): BelongsTo
{
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(
[
'user_id' => $user->id,
@ -309,6 +313,7 @@ public function syncDraftMaterial(array $validated, User $user): array
],
[
'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
{
$materialResult = array_key_exists('material_result', $validated) && $validated['material_result'] !== null
? (int) $validated['material_result']
: null;
$combination = CuttingMaterialCombination::create([
'user_id' => $user->id,
'cutting_id' => null,
'material_result' => $materialResult,
]);
$items = [];
@ -558,7 +568,7 @@ public function update(Cutting $cutting, array $validated): void
$this->runInTransaction(
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) {
$this->reverseTotalMaterialStock($cutting);
@ -568,6 +578,7 @@ function () use ($cutting, $validated): void {
$cutting->materials()->delete();
$cutting->results()->delete();
$cutting->combinations()->delete();
$materials = $this->buildMaterials($validated['materials']);
$results = $this->buildResults($validated['results']);
@ -583,8 +594,41 @@ function () use ($cutting, $validated): void {
$this->syncImages($cutting, $validated);
// Group materials by combination_id to create combinations
$combinationGroups = [];
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) {
@ -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 [
'raw_material_price_id' => $price->id,
'material_usage' => $materialUsage,
'material_result' => $materialResult,
'combination_id' => $itemData['combination_id'] ?? null,
'combination_material_result' => $itemData['combination_material_result'] ?? null,
];
})
->all();
@ -909,6 +959,12 @@ private function breakMaterialCircularReference(CuttingMaterial $material): void
{
$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) {
$rawMaterial = $price->rawMaterial;
@ -929,7 +985,6 @@ private function breakMaterialCircularReference(CuttingMaterial $material): void
$price->unsetRelation('rawMaterial');
}
$material->setAttribute('combination_id', $material->combination_id);
$material->unsetRelation('rawMaterialPrice');
$material->unsetRelation('combination');
}
@ -963,6 +1018,7 @@ private function presentDraftMaterial(CuttingMaterial $item): array
{
$price = $item->rawMaterialPrice;
$rawMaterial = $price?->rawMaterial;
$combination = $item->combination;
return [
'raw_material_price_id' => $item->raw_material_price_id,
@ -972,8 +1028,10 @@ private function presentDraftMaterial(CuttingMaterial $item): array
'unit_abbreviation' => $rawMaterial?->unit?->abbreviation() ?? '',
'stock_input' => $price?->stock_input ?? '',
'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') : [],
'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 { computed, provide } from 'vue';
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
import { buildPaginationSummary } from '@/lib/grouped-table';
import { Button } from '@/components/ui/button';
import {
Table,
@ -15,6 +14,7 @@ import {
TableRow,
} from '@/components/ui/table';
import TableEmpty from '@/components/ui/table/TableEmpty.vue';
import { buildPaginationSummary } from '@/lib/grouped-table';
import type {
DataTableFilterDef,
DataTablePagination,

View File

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

View File

@ -26,8 +26,10 @@ export function useDestroy({ url, preserveScroll = true, errorMessage, onSuccess
onError: (errors) => {
if (onError) {
const result = onError(errors);
if (typeof result === 'string') {
toast.error(result);
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 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 type { Component } from 'vue';
import type { BadgeVariant } from '@/lib/badge-variant';
export const CuttingStatus = {

View File

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

View File

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

View File

@ -212,6 +212,7 @@ const donutSegmentSelector = Donut.selectors.segment;
function channelTooltip(arc: any) {
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">
<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>
@ -223,6 +224,7 @@ function channelTooltip(arc: any) {
function paymentTooltip(arc: any) {
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">
<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>
@ -235,6 +237,7 @@ function paymentTooltip(arc: any) {
function marketingTooltip(arc: any) {
const d = arc.data as typeof props.orderStats.by_marketing[number];
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">
<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>
@ -246,6 +249,7 @@ function marketingTooltip(arc: any) {
function statusTooltip(arc: any) {
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">
<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>

View File

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

View File

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

View File

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

View File

@ -1,4 +1,7 @@
<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 { DataTable } from '@/components/data-table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -9,19 +12,16 @@ import {
} from '@/composables/useDataTableQuery';
import { EmployeeAdvanceStatus } from '@/constants/employee-advance-status';
import AdminLayout from '@/layouts/AdminLayout.vue';
import { index } from '@/routes/admin/finance/employee_advances';
import type { DataTableFilterDef, DataTableSort } from '@/types/data-table';
import type {
EmployeeAdvanceListItem,
EmployeeAdvancePageProps,
} 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 RejectEmployeeAdvanceModal from './form/RejectEmployeeAdvanceModal.vue';
import { createColumns } from './table/columns';
import { index } from '@/routes/admin/finance/employee_advances';
const props = defineProps<EmployeeAdvancePageProps>();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,10 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import BackButton from '@/components/button/BackButton.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 type { EnumOption } from '@/types/employee';
import EmployeeForm from './form/EmployeeForm.vue';
defineProps<{
genders: EnumOption[];

View File

@ -1,12 +1,12 @@
<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 { 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 type { EmployeeListItem, EnumOption } from '@/types/employee';
import type { MediaItem } from '@/types/media';
import EmployeeForm from './form/EmployeeForm.vue';
const props = defineProps<{
employee: EmployeeListItem;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -152,24 +152,27 @@ export function useCuttingPosCart(options: {
rawMaterial: CuttingRawMaterialCatalogItem,
price: CuttingCatalogPrice,
materialUsage: string,
materialResult?: string | null,
) {
const { item } = await apiFetch<{ item: CuttingMaterialCartItem }>(draft_materials.store.url(), {
method: 'POST',
body: JSON.stringify({
raw_material_price_id: price.id,
material_usage: materialUsage,
material_result: materialResult ?? null,
}),
});
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(), {
method: 'POST',
body: JSON.stringify({
raw_material_price_id: priceId,
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 {
return materialCart.value.find((item) => item.raw_material_price_id === priceId);
}
@ -346,6 +358,7 @@ export function useCuttingPosCart(options: {
unit_abbreviation: rawMaterial.unit_abbreviation,
stock_input: price.stock_input,
material_usage: defaultUsage,
material_result: null,
images: price.images ?? [],
});
}
@ -462,7 +475,7 @@ export function useCuttingPosCart(options: {
const item = materialCart.value[index];
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) {
toast.error(error instanceof Error ? error.message : 'Gagal memperbarui item.');
}
@ -513,5 +526,6 @@ export function useCuttingPosCart(options: {
syncResultField,
syncDraftCombination,
removeCombination,
syncCombinationResult,
};
}

View File

@ -1,7 +1,6 @@
<script setup lang="ts">
import { Check } from '@lucide/vue';
import { computed } from 'vue';
import DataTableActions from './data-table-actions.vue';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import {
@ -11,6 +10,7 @@ import {
EmptyTitle,
} from '@/components/ui/empty';
import type { CuttingListItem } from '@/types/cutting';
import DataTableActions from './data-table-actions.vue';
function formatMaterialUsage(mat: any): string {
return String(parseFloat(Number(mat.material_usage ?? 0).toFixed(2)));
@ -57,6 +57,7 @@ function getMaterialGroups(materials: any[]): MaterialGroup[] {
if (!combinationMap[mat.combination_id]) {
combinationMap[mat.combination_id] = [];
}
combinationMap[mat.combination_id].push(mat);
} else {
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-if="group.type === 'combination'" class="mb-1">
<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">
<li v-for="mat in group.items" :key="mat.id">
{{ 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">
<li v-for="mat in group.items" :key="mat.id">
{{ 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>
</ul>
</div>

View File

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

View File

@ -48,6 +48,7 @@ function getMaterialGroups(materials: any[]): MaterialGroup[] {
if (!combinationMap[mat.combination_id]) {
combinationMap[mat.combination_id] = [];
}
combinationMap[mat.combination_id].push(mat);
} else {
singleItems.push(mat);
@ -102,6 +103,9 @@ defineProps<{
<div v-for="(group, gi) in getMaterialGroups(cutting.materials)" :key="gi" class="mt-1">
<div v-if="group.type === 'combination'" class="mb-1">
<span class="text-primary font-medium text-[11px]">{{ group.name }} ({{ group.items.length }} bahan)</span>
<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">
<li v-for="mat in group.items" :key="mat.id">
{{ 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">
<li v-for="mat in group.items" :key="mat.id">
{{ 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>
</ul>
</div>

View File

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

View File

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

View File

@ -1,5 +1,9 @@
<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 ThermalPrinterConnectButton from '@/components/order/ThermalPrinterConnectButton.vue';
import { Card, CardContent } from '@/components/ui/card';
import { useCan } from '@/composables/useCan';
import {
@ -12,12 +16,8 @@ import {
} from '@/composables/useThermalPrinter';
import AdminLayout from '@/layouts/AdminLayout.vue';
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 ThermalPrinterConnectButton from '@/components/order/ThermalPrinterConnectButton.vue';
import type { PaginatedOrders } from '@/types/order';
import OrderGroupedTable from './table/OrderGroupedTable.vue';
const props = defineProps<{

View File

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

View File

@ -1,4 +1,7 @@
<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 { Button } from '@/components/ui/button';
import {
@ -19,9 +22,6 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { apiFetch } from '@/lib/api';
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';
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 { OrderChannel } from '@/constants/order-channel';
import { StockQuality } from '@/constants/stock-quality';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,7 +12,8 @@ import {
SelectTrigger,
SelectValue,
} 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';
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 { apiFetch } from '@/lib/api';
import { store as syncDraftRoute, destroy as destroyDraftRoute } from '@/routes/admin/manage/purchases/draft_items';

View File

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

View File

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

View File

@ -1,14 +1,14 @@
<script setup lang="ts">
import BackButton from '@/components/button/BackButton.vue';
import { Badge } from '@/components/ui/badge';
import { Head, router } from '@inertiajs/vue3';
import { Loader2, Send } from '@lucide/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 AdminLayout from '@/layouts/AdminLayout.vue';
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 type { CatalogProduct, StokOpnameDetail } from '@/types/stok-opname';
import StokOpnameAutoSaveStatus from './form/StokOpnameAutoSaveStatus.vue';
import StokOpnameInfoSection from './form/StokOpnameInfoSection.vue';
import StokOpnameProductTable from './form/StokOpnameProductTable.vue';

View File

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

View File

@ -1,5 +1,6 @@
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 { auto_save } from '@/routes/admin/manage/stok-opnames';
import type {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,10 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import BackButton from '@/components/button/BackButton.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 type { CategoryOption } from '@/types/product';
import ProductForm from './form/ProductForm.vue';
defineProps<{
categories: CategoryOption[];

View File

@ -1,11 +1,11 @@
<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 { 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 type { CategoryOption, ProductListItem } from '@/types/product';
import ProductForm from './form/ProductForm.vue';
const props = defineProps<{
product: ProductListItem & { description?: string | null };

View File

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

View File

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

View File

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

View File

@ -1,11 +1,11 @@
<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 { 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 type { EnumOption, RawMaterialListItem } from '@/types/raw-material';
import RawMaterialForm from './form/RawMaterialForm.vue';
const props = defineProps<{
rawMaterial: RawMaterialListItem;

View File

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

View File

@ -1,4 +1,5 @@
<script setup lang="ts">
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Field,
FieldError,
@ -14,9 +15,9 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
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';
defineProps<{

View File

@ -7,7 +7,8 @@ import {
FieldGroup,
FieldLabel,
} 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';
defineProps<{

View File

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

View File

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

View File

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

View File

@ -5,11 +5,11 @@ import { DataTable } from '@/components/data-table';
import { Card, CardContent } from '@/components/ui/card';
import { useDataTableQuery, useDataTableQuerySync } from '@/composables/useDataTableQuery';
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 { DataTableSort } from '@/types/data-table';
import ActivityLogDetailModal from './table/ActivityLogDetailModal.vue';
import { createColumns } from './table/columns';
import { index } from '@/routes/admin/system/activity_logs';
const props = defineProps<{
activityLogs: PaginatedActivityLogs;

View File

@ -1,9 +1,9 @@
<script setup lang="ts">
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 AdminLayout from '@/layouts/AdminLayout.vue';
import { index, store } from '@/routes/admin/system/roles';
import RoleForm from './form/RoleForm.vue';
interface PermissionOption {
value: string;

View File

@ -1,9 +1,9 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import BackButton from '@/components/button/BackButton.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 RoleForm from './form/RoleForm.vue';
interface PermissionOption {
value: string;

View File

@ -1,4 +1,6 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import CreateButton from '@/components/button/CreateButton.vue';
import { DataTable } from '@/components/data-table';
import { Card, CardContent } from '@/components/ui/card';
@ -8,10 +10,8 @@ import {
useDataTableQuerySync,
} from '@/composables/useDataTableQuery';
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 type { DataTableSort } from '@/types/data-table';
import type { RoleListItem } from './table/columns';
import { createColumns } from './table/columns';

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import type { MediaItem } from '@/types/media';
import type { EnumOption, Paginated, SelectOption } from '@/types/common';
import type { MediaItem } from '@/types/media';
import type { ProductListItem } from '@/types/product';
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 { Paginated, SelectOption } from '@/types/common';
import type { RawMaterialListItem } from '@/types/raw-material';
export type { SelectOption } from '@/types/common';

View File

@ -192,6 +192,61 @@ function setupDraftItems(User $user): array
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 () {
$this->post(route('admin.manage.cuttings.store'), [
'description' => 'Test',
@ -328,6 +383,40 @@ function setupDraftItems(User $user): array
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 () {
$cutting = createCuttingWithMaterialsAndResults();
@ -518,6 +607,27 @@ function setupDraftItems(User $user): array
$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 () {
$user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_CREATE);
@ -628,7 +738,7 @@ function setupDraftItems(User $user): array
// ─── Update with Combination ────────────────────────────────
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);
$cutting = createCuttingWithMaterialsAndResults($user);
@ -637,11 +747,6 @@ function setupDraftItems(User $user): array
$price1 = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]);
$price2 = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]);
$combination = CuttingMaterialCombination::factory()->create([
'cutting_id' => $cutting->id,
'user_id' => null,
]);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->create(['product_id' => $product->id]);
@ -651,8 +756,8 @@ function setupDraftItems(User $user): array
'sewing_cost' => 0,
'other_cost' => 0,
'materials' => [
['raw_material_price_id' => $price1->id, 'material_usage' => 3, 'combination_id' => $combination->id],
['raw_material_price_id' => $price2->id, 'material_usage' => 2, 'combination_id' => $combination->id],
['raw_material_price_id' => $price1->id, 'material_usage' => 3, 'combination_id' => 999],
['raw_material_price_id' => $price2->id, 'material_usage' => 2, 'combination_id' => 999],
],
'results' => [
[
@ -668,7 +773,47 @@ function setupDraftItems(User $user): array
$cutting->refresh();
expect($cutting->description)->toBe('Updated with combination');
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();
});
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 ───────────────────────────────────
@ -842,4 +998,13 @@ function setupDraftItems(User $user): array
expect($combination->cutting_id)->toBeNull();
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);
});
});