refactor: enhance error handling in form submissions across various Vue components to improve user feedback and maintainability

This commit is contained in:
Yoga Pangestu 2026-06-25 21:54:42 +07:00
parent 58ba7512a5
commit 6219f5032d
39 changed files with 534 additions and 846 deletions

View File

@ -33,13 +33,10 @@ const form = useForm({
function submit() {
form.post('/auth/login', {
onError: (errors) => {
const message =
errors.login ??
errors.password ??
'Gagal masuk. Periksa kembali data Anda.';
toast.error(message);
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
});
}

View File

@ -66,8 +66,10 @@ function onAppearanceChange(value: AcceptableValue) {
function submit() {
form.put(update.url(), {
onError: () => {
toast.error('Gagal menyimpan tampilan.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
});
}

View File

@ -15,9 +15,9 @@ import {
import { InputGroup, InputGroupInput, InputGroupAddon, InputGroupButton } from '@/components/ui/input-group';
import AccountLayout from '@/layouts/AccountLayout.vue';
import { formErrors } from '@/lib/form';
import { update } from '@/routes/admin/account/password';
import type { PasswordFormData } from '@/types/account';
import { update } from '@/routes/admin/account/password';
const form = useForm<PasswordFormData>({
current_password: '',
@ -34,8 +34,10 @@ function submit() {
onSuccess: () => {
form.reset();
},
onError: () => {
toast.error('Gagal memperbarui kata sandi. Periksa kembali formulir.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
});
}

View File

@ -19,13 +19,13 @@ import { Textarea } from '@/components/ui/textarea';
import AccountLayout from '@/layouts/AccountLayout.vue';
import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form';
import { update } from '@/routes/admin/account/profile';
import type {
EnumOption,
ProfileFormData,
ProfileUser,
} from '@/types/account';
import { update } from '@/routes/admin/account/profile';
const props = defineProps<{
user: ProfileUser;
@ -45,8 +45,10 @@ const form = useForm<ProfileFormData>({
function submit() {
form.put(update.url(), {
preserveScroll: true,
onError: () => {
toast.error('Gagal menyimpan profil. Periksa kembali formulir.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
});
}
@ -76,8 +78,8 @@ function submit() {
<FieldLabel for="username" required>
Username
</FieldLabel>
<Input id="username" v-model="form.username" type="text" placeholder="Contoh: johndoe"
:maxlength="FIELD_LIMITS.username" />
<Input id="username" v-model="form.username" type="text"
placeholder="Contoh: johndoe" :maxlength="FIELD_LIMITS.username" />
<FieldError :errors="formErrors(form, 'username')" />
</Field>
@ -126,8 +128,8 @@ function submit() {
<FieldLabel for="address">
Alamat
</FieldLabel>
<Textarea id="address" v-model="form.address" placeholder="Contoh: Jl. Sudirman No. 123, Jakarta"
rows="3" />
<Textarea id="address" v-model="form.address"
placeholder="Contoh: Jl. Sudirman No. 123, Jakarta" rows="3" />
<FieldError :errors="formErrors(form, 'address')" />
</Field>
</FieldGroup>

View File

@ -26,11 +26,11 @@ import { CashTransactionType } from '@/constants/cash-transaction-type';
import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form';
import { parseRupiah } from '@/lib/rupiah';
import { withdraw, deposit } from '@/routes/admin/finance/cash';
import { update } from '@/routes/admin/finance/cash/transactions';
import type { CashTransactionListItem } from '@/types/cash';
import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media';
import type { MediaUploadState } from '@/types/media';
import { withdraw, deposit } from '@/routes/admin/finance/cash';
import { update } from '@/routes/admin/finance/cash/transactions';
const open = defineModel<boolean>('open', { default: false });
@ -105,10 +105,10 @@ function submit() {
onSuccess: () => {
open.value = false;
},
onError: () => {
toast.error(isEditing.value
? 'Gagal memperbarui transaksi kas. Periksa kembali formulir.'
: 'Gagal mencatat transaksi kas. Periksa kembali formulir.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
};

View File

@ -25,8 +25,8 @@ import { useFormDialog } from '@/composables/useFormDialog';
import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form';
import { parseRupiah } from '@/lib/rupiah';
import type { EmployeeAdvanceFormData, EmployeeAdvanceListItem } from '@/types/employee-advance';
import { store, update } from '@/routes/admin/finance/employee_advances';
import type { EmployeeAdvanceFormData, EmployeeAdvanceListItem } from '@/types/employee-advance';
const open = defineModel<boolean>('open', { default: false });
@ -89,8 +89,10 @@ function submit() {
onSuccess: () => {
open.value = false;
},
onError: () => {
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
};

View File

@ -21,8 +21,8 @@ import {
import { Textarea } from '@/components/ui/textarea';
import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form';
import type { EmployeeAdvanceListItem } from '@/types/employee-advance';
import { reject } from '@/routes/admin/finance/employee_advances';
import type { EmployeeAdvanceListItem } from '@/types/employee-advance';
const open = defineModel<boolean>('open', { default: false });
@ -55,8 +55,10 @@ function submit() {
onSuccess: () => {
open.value = false;
},
onError: () => {
toast.error('Gagal menolak kasbon. Periksa kembali formulir.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
});
}

View File

@ -5,8 +5,8 @@ import { toast } from 'vue-sonner';
import { RowApproveAction, RowDeleteAction, RowEditAction, RowPayAction, RowRejectAction } from '@/components/button';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { useCan } from '@/composables/useCan';
import type { EmployeeAdvanceListItem } from '@/types/employee-advance';
import { destroy, approve, pay } from '@/routes/admin/finance/employee_advances';
import type { EmployeeAdvanceListItem } from '@/types/employee-advance';
const props = defineProps<{
employeeAdvance: EmployeeAdvanceListItem;
@ -52,8 +52,10 @@ function approveEmployeeAdvance() {
onSuccess: () => {
approveConfirmOpen.value = false;
},
onError: (errors) => {
toast.error(errors.amount || errors.employee_advance || 'Gagal menyetujui kasbon.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
onFinish: () => {
approveProcessing.value = false;
@ -69,8 +71,10 @@ function payEmployeeAdvance() {
onSuccess: () => {
payConfirmOpen.value = false;
},
onError: (errors) => {
toast.error(errors.amount || errors.employee_advance || 'Gagal melunasi kasbon.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
onFinish: () => {
payProcessing.value = false;

View File

@ -25,11 +25,11 @@ import { useFormDialog } from '@/composables/useFormDialog';
import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form';
import { parseRupiah } from '@/lib/rupiah';
import { store, update } from '@/routes/admin/finance/expenses';
import type { ExpenseListItem } from '@/types/expense';
import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media';
import type { MediaUploadState } from '@/types/media';
import { store, update } from '@/routes/admin/finance/expenses';
const open = defineModel<boolean>('open', { default: false });
@ -95,8 +95,10 @@ function submit() {
onSuccess: () => {
open.value = false;
},
onError: () => {
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
};

View File

@ -4,7 +4,6 @@ import { Save } from '@lucide/vue';
import { ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import { RowDeleteAction, RowEditAction } from '@/components/button';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { RupiahInput } from '@/components/form/rupiah-input';
import { Button } from '@/components/ui/button';
import {
@ -26,8 +25,8 @@ import { Textarea } from '@/components/ui/textarea';
import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form';
import { parseRupiah } from '@/lib/rupiah';
import type { PayrollAdjustmentFormData, PayrollAdjustmentItem, PayrollListItem, SelectOption } from '@/types/payroll';
import { store, update, destroy } from '@/routes/admin/finance/payroll/adjustments';
import type { PayrollAdjustmentFormData, PayrollAdjustmentItem, PayrollListItem, SelectOption } from '@/types/payroll';
const open = defineModel<boolean>('open', { default: false });
@ -86,8 +85,10 @@ function submit() {
onSuccess: () => {
cancelEdit();
},
onError: () => {
toast.error('Gagal memperbarui penyesuaian.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
});
} else {
@ -96,8 +97,10 @@ function submit() {
onSuccess: () => {
resetForm();
},
onError: () => {
toast.error('Gagal menambahkan penyesuaian.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
});
}
@ -136,7 +139,8 @@ function submit() {
tooltip-class="z-210" @click="startEdit(adj)" />
<RowDeleteAction :action-url="destroy.url(adj.id)" tooltip="Hapus"
button-class="hover:bg-destructive/10" tooltip-class="z-210"
title="Hapus penyesuaian?" :description="`Penyesuaian ${adj.type_label} ${adj.amount_formatted} akan dihapus.`"
title="Hapus penyesuaian?"
:description="`Penyesuaian ${adj.type_label} ${adj.amount_formatted} akan dihapus.`"
error-message="Gagal menghapus penyesuaian."
:on-success="() => toast.success('Penyesuaian berhasil dihapus.')" />
</div>

View File

@ -1,4 +1,8 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Camera, Loader2 } from '@lucide/vue';
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
@ -9,12 +13,8 @@ import {
} from '@/components/ui/dialog';
import { useGeolocation } from '@/composables/useGeolocation';
import { useWebcamCapture } from '@/composables/useWebcamCapture';
import type { AttendanceCaptureFormData } from '@/types/attendance';
import { useForm } from '@inertiajs/vue3';
import { Camera, Loader2 } from '@lucide/vue';
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import { check_in, check_out } from '@/routes/admin/hr/attendances';
import type { AttendanceCaptureFormData } from '@/types/attendance';
const open = defineModel<boolean>('open', { default: false });
@ -124,8 +124,10 @@ function submitAttendance() {
onSuccess: () => {
open.value = false;
},
onError: () => {
toast.error('Gagal menyimpan presensi.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
});
}
@ -162,69 +164,37 @@ onBeforeUnmount(() => {
<div v-if="!previewPhoto" class="space-y-2">
<p class="text-sm font-medium">Kamera</p>
<div
class="relative aspect-4/3 overflow-hidden rounded-lg border bg-muted"
>
<video
ref="videoRef"
class="size-full scale-x-[-1] object-cover"
autoplay
muted
playsinline
/>
<div class="relative aspect-4/3 overflow-hidden rounded-lg border bg-muted">
<video ref="videoRef" class="size-full scale-x-[-1] object-cover" autoplay muted playsinline />
<div
v-if="cameraLoading"
class="absolute inset-0 flex items-center justify-center bg-background/80"
>
<Loader2
class="size-8 animate-spin text-muted-foreground"
/>
<div v-if="cameraLoading"
class="absolute inset-0 flex items-center justify-center bg-background/80">
<Loader2 class="size-8 animate-spin text-muted-foreground" />
</div>
</div>
</div>
<div v-else class="space-y-2">
<p class="text-sm font-medium">Hasil Foto</p>
<div
class="relative aspect-4/3 overflow-hidden rounded-lg border bg-muted"
>
<div class="relative aspect-4/3 overflow-hidden rounded-lg border bg-muted">
<img :src="previewPhoto" alt="Hasil foto presensi" class="size-full object-cover" />
</div>
</div>
</div>
<DialogFooter>
<Button
v-if="previewPhoto"
type="button"
variant="outline"
@click="retakePhoto"
>
<Button v-if="previewPhoto" type="button" variant="outline" @click="retakePhoto">
Ambil Ulang
</Button>
<Button
v-if="!previewPhoto"
type="button"
:disabled="!cameraReady || capturing"
@click="capturePhoto"
>
<Button v-if="!previewPhoto" type="button" :disabled="!cameraReady || capturing" @click="capturePhoto">
<Loader2 v-if="capturing" class="size-4 animate-spin" />
<Camera v-else class="size-4" />
Ambil Foto
</Button>
<Button
v-else
type="button"
:disabled="form.processing"
@click="submitAttendance"
>
<Loader2
v-if="form.processing"
class="size-4 animate-spin"
/>
<Button v-else type="button" :disabled="form.processing" @click="submitAttendance">
<Loader2 v-if="form.processing" class="size-4 animate-spin" />
{{ submitLabel }}
</Button>
</DialogFooter>

View File

@ -70,8 +70,10 @@ function submit() {
}));
const options = {
onError: () => {
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
};

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { router, usePage } from '@inertiajs/vue3';
import { computed, ref } from 'vue';
import { ref } from 'vue';
import { toast } from 'vue-sonner';
import {
AlertDialog,
@ -35,8 +35,10 @@ function resetPassword() {
onSuccess: () => {
open.value = false;
},
onError: () => {
toast.error('Gagal mereset kata sandi.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
onFinish: () => {
processing.value = false;
@ -58,7 +60,9 @@ function resetPassword() {
<AlertDialogDescription>
Kata sandi
<span class="text-foreground font-medium">{{ employeeName ?? 'pegawai' }}</span>
akan direset ke kata sandi default sistem (<span class="font-mono bg-muted px-1.5 py-0.5 rounded text-foreground font-medium">{{ defaultPassword }}</span>). Pengguna akan otomatis logout dari
akan direset ke kata sandi default sistem (<span
class="font-mono bg-muted px-1.5 py-0.5 rounded text-foreground font-medium">{{ defaultPassword
}}</span>). Pengguna akan otomatis logout dari
semua sesi aktif.
</AlertDialogDescription>
</AlertDialogHeader>
@ -66,10 +70,7 @@ function resetPassword() {
<AlertDialogCancel :disabled="processing">
Batal
</AlertDialogCancel>
<AlertDialogAction
:disabled="processing"
@click.prevent="resetPassword"
>
<AlertDialogAction :disabled="processing" @click.prevent="resetPassword">
{{ processing ? 'Memproses...' : 'Reset Kata Sandi' }}
</AlertDialogAction>
</AlertDialogFooter>

View File

@ -6,8 +6,8 @@ import { toast } from 'vue-sonner';
import { RowDeleteAction, RowEditAction, RowStatusAction } from '@/components/button';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { useCan } from '@/composables/useCan';
import type { EmployeeListItem } from '@/types/employee';
import { edit, destroy, reset_password } from '@/routes/admin/hr/employees';
import type { EmployeeListItem } from '@/types/employee';
const props = defineProps<{
employee: EmployeeListItem;
@ -28,8 +28,10 @@ function resetPassword() {
onSuccess: () => {
resetPasswordConfirmOpen.value = false;
},
onError: () => {
toast.error('Gagal mereset kata sandi.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
onFinish: () => {
resetPasswordProcessing.value = false;
@ -50,9 +52,7 @@ function resetPassword() {
error-message="Gagal menghapus pegawai." />
</div>
<ConfirmDialog
v-if="can('employees.reset_password')"
v-model:open="resetPasswordConfirmOpen"
<ConfirmDialog v-if="can('employees.reset_password')" v-model:open="resetPasswordConfirmOpen"
title="Reset kata sandi pegawai?"
:description="`Kata sandi ${employee.profile?.full_name ?? 'pegawai'} akan direset ke kata sandi default sistem (${defaultPassword}). Pengguna akan otomatis logout dari semua sesi aktif.`"
confirm-label="Reset Kata Sandi" cancel-label="Batal" :loading="resetPasswordProcessing"

View File

@ -5,8 +5,8 @@ import { toast } from 'vue-sonner';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { useCan } from '@/composables/useCan';
import type { EmployeeListItem } from '@/types/employee';
import { toggle_status } from '@/routes/admin/hr/employees';
import type { EmployeeListItem } from '@/types/employee';
const props = defineProps<{
employee: EmployeeListItem;
@ -37,9 +37,12 @@ function toggleStatus(checked: boolean) {
is_active: checked,
}, {
preserveScroll: true,
onError: () => {
onError: (errors: any) => {
isActive.value = previous;
toast.error('Gagal memperbarui status pegawai.');
if (errors.system) {
toast.error(errors.system);
}
},
onFinish: () => {
processing.value = false;
@ -50,11 +53,8 @@ function toggleStatus(checked: boolean) {
<template>
<div class="flex items-center gap-2">
<Switch
:model-value="isActive"
:disabled="processing || !can('employees.toggle_status')"
@update:model-value="toggleStatus"
/>
<Switch :model-value="isActive" :disabled="processing || !can('employees.toggle_status')"
@update:model-value="toggleStatus" />
<Badge :variant="isActive ? 'default' : 'secondary'">
{{ isActive ? 'Aktif' : 'Nonaktif' }}
</Badge>

View File

@ -21,8 +21,8 @@ import {
} from '@/components/ui/field';
import { useFormDialog } from '@/composables/useFormDialog';
import { formErrors } from '@/lib/form';
import type { LeaveRequestFormData, LeaveRequestListItem } from '@/types/leave-request';
import { store, update } from '@/routes/admin/hr/leave_requests';
import type { LeaveRequestFormData, LeaveRequestListItem } from '@/types/leave-request';
const open = defineModel<boolean>('open', { default: false });
@ -66,8 +66,10 @@ function submit() {
onSuccess: () => {
open.value = false;
},
onError: () => {
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
};

View File

@ -21,8 +21,8 @@ import {
import { Textarea } from '@/components/ui/textarea';
import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form';
import type { LeaveRequestListItem } from '@/types/leave-request';
import { reject } from '@/routes/admin/hr/leave_requests';
import type { LeaveRequestListItem } from '@/types/leave-request';
const open = defineModel<boolean>('open', { default: false });
@ -55,8 +55,10 @@ function submit() {
onSuccess: () => {
open.value = false;
},
onError: () => {
toast.error('Gagal menolak pengajuan cuti. Periksa kembali formulir.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
});
}
@ -74,8 +76,9 @@ function submit() {
<FieldSet class="grid gap-4">
<Field>
<FieldLabel for="reject-reason" required>Alasan Penolakan</FieldLabel>
<Textarea id="reject-reason" v-model="form.reason" placeholder="Contoh: Kuota cuti untuk bulan ini sudah habis"
rows="3" autofocus :maxlength="FIELD_LIMITS.reason" />
<Textarea id="reject-reason" v-model="form.reason"
placeholder="Contoh: Kuota cuti untuk bulan ini sudah habis" rows="3" autofocus
:maxlength="FIELD_LIMITS.reason" />
<FieldError :errors="formErrors(form, 'reason')" />
</Field>
</FieldSet>

View File

@ -5,8 +5,8 @@ import { toast } from 'vue-sonner';
import { RowApproveAction, RowDeleteAction, RowEditAction, RowRejectAction } from '@/components/button';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { useCan } from '@/composables/useCan';
import type { LeaveRequestListItem } from '@/types/leave-request';
import { destroy, approve } from '@/routes/admin/hr/leave_requests';
import type { LeaveRequestListItem } from '@/types/leave-request';
const props = defineProps<{
leaveRequest: LeaveRequestListItem;
@ -50,8 +50,10 @@ function approveLeaveRequest() {
onSuccess: () => {
approveConfirmOpen.value = false;
},
onError: () => {
toast.error('Gagal menyetujui pengajuan cuti.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
onFinish: () => {
approveProcessing.value = false;

View File

@ -36,6 +36,8 @@ import { getFirstCoverImage } from '@/lib/catalog-cover';
import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form';
import { formatRupiah, parseRupiah } from '@/lib/rupiah';
import draft_materials from '@/routes/admin/manage/cuttings/draft_materials';
import draft_results from '@/routes/admin/manage/cuttings/draft_results';
import type {
CuttingMaterialCartItem,
CuttingProductCatalogItem,
@ -44,8 +46,6 @@ import type {
} from '@/types/cutting';
import PosCatalogCard from '../../shared/PosCatalogCard.vue';
import PosCatalogVariantThumb from '../../shared/PosCatalogVariantThumb.vue';
import draft_materials from '@/routes/admin/manage/cuttings/draft_materials';
import draft_results from '@/routes/admin/manage/cuttings/draft_results';
const props = defineProps<{
rawMaterialCatalog: CuttingRawMaterialCatalogItem[];
@ -127,17 +127,6 @@ function materialLineCost(item: CuttingMaterialCartItem): number {
return Math.round(usage * catalogPrice.price);
}
const sewingCostAmount = computed(
() => Number.parseInt(parseRupiah(form.sewing_cost), 10) || 0,
);
const otherCostAmount = computed(
() => Number.parseInt(parseRupiah(form.other_cost), 10) || 0,
);
const totalProductionCost = computed(
() => totalMaterialCost.value + sewingCostAmount.value + otherCostAmount.value,
);
const estimatedCostPerUnit = computed(() => {
if (totalResultPieces.value <= 0) {
return 0;
@ -212,7 +201,7 @@ const filteredProducts = computed(() => {
return props.productCatalog.filter(
(product) =>
product.name.toLowerCase().includes(keyword) ||
product.variants.some((variant) =>
product.variants.some((variant: any) =>
variant.name.toLowerCase().includes(keyword),
),
);
@ -594,13 +583,10 @@ function submit() {
if (props.method === 'put') {
form.transform(() => payload).put(props.submitUrl, {
onError: (errors) => {
const message = Object.values(errors)[0];
toast.error(
typeof message === 'string'
? message
: 'Gagal menyimpan data. Periksa kembali formulir.',
);
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
});
@ -608,13 +594,10 @@ function submit() {
}
form.transform(() => payload).post(props.submitUrl, {
onError: (errors) => {
const message = Object.values(errors)[0];
toast.error(
typeof message === 'string'
? message
: 'Gagal menyimpan data. Periksa kembali formulir.',
);
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
});
}
@ -630,26 +613,15 @@ function submit() {
<CardContent>
<div class="space-y-4">
<div class="relative">
<Search
class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
v-model="materialSearch"
placeholder="Cari bahan baku..."
class="pl-9"
/>
<Search class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="materialSearch" placeholder="Cari bahan baku..." class="pl-9" />
</div>
<div
v-if="filteredRawMaterials.length === 0"
class="py-8"
>
<div v-if="filteredRawMaterials.length === 0" class="py-8">
<Empty>
<EmptyHeader>
<EmptyTitle
>Tidak ada bahan baku
ditemukan</EmptyTitle
>
<EmptyTitle>Tidak ada bahan baku
ditemukan</EmptyTitle>
<EmptyDescription>
Silakan lakukan pencarian untuk
menemukan bahan baku.
@ -659,97 +631,60 @@ function submit() {
</div>
<div v-else class="columns-1 gap-4 sm:columns-2">
<PosCatalogCard
v-for="rawMaterial in filteredRawMaterials"
:key="rawMaterial.id"
:title="rawMaterial.name"
:cover-image="
getFirstCoverImage(rawMaterial.prices)
"
>
<PosCatalogCard v-for="rawMaterial in filteredRawMaterials" :key="rawMaterial.id"
:title="rawMaterial.name" :cover-image="getFirstCoverImage(rawMaterial.prices)
">
<template #header-extra>
<Badge variant="secondary" class="mt-1.5">
{{ rawMaterial.unit_label }}
</Badge>
</template>
<p
v-if="!rawMaterial.prices.length"
class="px-3 py-4 text-sm text-muted-foreground"
>
<p v-if="!rawMaterial.prices.length" class="px-3 py-4 text-sm text-muted-foreground">
Belum ada varian
</p>
<div
v-for="price in rawMaterial.prices"
:key="price.id"
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200"
:class="[
<div v-for="price in rawMaterial.prices" :key="price.id"
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200" :class="[
'cursor-pointer hover:bg-muted/30',
getMaterialCartItem(price.id)
? 'mx-1 my-0.5 rounded-md border-2 border-primary bg-primary/5'
: '',
]"
@click="
]" @click="
!getMaterialCartItem(price.id) &&
addMaterial(rawMaterial, price)
"
>
<PosCatalogVariantThumb
:items="price.images"
/>
">
<PosCatalogVariantThumb :items="price.images" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">
{{ price.variant }}
</p>
<p
class="text-xs text-muted-foreground tabular-nums"
>
<p class="text-xs text-muted-foreground tabular-nums">
Stok: {{ price.stock_formatted }}
</p>
</div>
<div
v-if="getMaterialCartItem(price.id)"
class="flex shrink-0 items-center gap-1.5"
>
<Button
type="button"
variant="outline"
size="icon-sm"
@click.stop="
decreaseMaterialQty(price.id)
"
>
<div v-if="getMaterialCartItem(price.id)"
class="flex shrink-0 items-center gap-1.5">
<Button type="button" variant="outline" size="icon-sm" @click.stop="
decreaseMaterialQty(price.id)
">
<Minus class="size-3.5" />
</Button>
<span
class="min-w-[1.25rem] text-center text-xs font-semibold tabular-nums"
>
<span class="min-w-[1.25rem] text-center text-xs font-semibold tabular-nums">
{{
getMaterialCartItem(price.id)!
.material_usage
}}
</span>
<Button
type="button"
variant="outline"
size="icon-sm"
@click.stop="
addMaterial(rawMaterial, price)
"
>
<Button type="button" variant="outline" size="icon-sm" @click.stop="
addMaterial(rawMaterial, price)
">
<Plus class="size-3.5" />
</Button>
</div>
<Button
v-else
type="button"
variant="outline"
size="icon-sm"
class="shrink-0"
<Button v-else type="button" variant="outline" size="icon-sm" class="shrink-0"
@click.stop="
addMaterial(rawMaterial, price)
"
>
">
<Plus class="size-3.5" />
</Button>
</div>
@ -766,22 +701,14 @@ function submit() {
<CardContent>
<div class="space-y-4">
<div class="relative">
<Search
class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
v-model="productSearch"
placeholder="Cari produk..."
class="pl-9"
/>
<Search class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="productSearch" placeholder="Cari produk..." class="pl-9" />
</div>
<div v-if="filteredProducts.length === 0" class="py-8">
<Empty>
<EmptyHeader>
<EmptyTitle
>Tidak ada produk ditemukan</EmptyTitle
>
<EmptyTitle>Tidak ada produk ditemukan</EmptyTitle>
<EmptyDescription>
Silakan lakukan pencarian untuk
menemukan produk.
@ -791,94 +718,55 @@ function submit() {
</div>
<div v-else class="columns-1 gap-4 sm:columns-2">
<PosCatalogCard
v-for="product in filteredProducts"
:key="product.id"
:title="product.name"
:cover-image="
getFirstCoverImage(product.variants)
"
>
<p
v-if="!product.variants.length"
class="px-3 py-4 text-sm text-muted-foreground"
>
<PosCatalogCard v-for="product in filteredProducts" :key="product.id" :title="product.name"
:cover-image="getFirstCoverImage(product.variants)
">
<p v-if="!product.variants.length" class="px-3 py-4 text-sm text-muted-foreground">
Belum ada varian
</p>
<div
v-for="variant in product.variants"
:key="variant.id"
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200"
:class="[
<div v-for="variant in product.variants" :key="variant.id"
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200" :class="[
'cursor-pointer hover:bg-muted/30',
getResultCartItem(variant.id)
? 'mx-1 my-0.5 rounded-md border-2 border-primary bg-primary/5'
: '',
]"
@click="
]" @click="
!getResultCartItem(variant.id) &&
addResult(product, variant)
"
>
<PosCatalogVariantThumb
:items="variant.images"
/>
">
<PosCatalogVariantThumb :items="variant.images" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">
{{ variant.name }}
</p>
<p
class="text-xs text-muted-foreground"
>
<span class="tabular-nums"
>Stok:
{{ variant.stock }} pcs</span
>
<p class="text-xs text-muted-foreground">
<span class="tabular-nums">Stok:
{{ variant.stock }} pcs</span>
</p>
</div>
<div
v-if="getResultCartItem(variant.id)"
class="flex shrink-0 items-center gap-1.5"
>
<Button
type="button"
variant="outline"
size="icon-sm"
@click.stop="
decreaseResultQty(variant.id)
"
>
<div v-if="getResultCartItem(variant.id)"
class="flex shrink-0 items-center gap-1.5">
<Button type="button" variant="outline" size="icon-sm" @click.stop="
decreaseResultQty(variant.id)
">
<Minus class="size-3.5" />
</Button>
<span
class="min-w-[1.25rem] text-center text-xs font-semibold tabular-nums"
>
<span class="min-w-[1.25rem] text-center text-xs font-semibold tabular-nums">
{{
getResultCartItem(variant.id)!
.cutting_result
}}
</span>
<Button
type="button"
variant="outline"
size="icon-sm"
@click.stop="
addResult(product, variant)
"
>
<Button type="button" variant="outline" size="icon-sm" @click.stop="
addResult(product, variant)
">
<Plus class="size-3.5" />
</Button>
</div>
<Button
v-else
type="button"
variant="outline"
size="icon-sm"
class="shrink-0"
<Button v-else type="button" variant="outline" size="icon-sm" class="shrink-0"
@click.stop="
addResult(product, variant)
"
>
">
<Plus class="size-3.5" />
</Button>
</div>
@ -897,19 +785,12 @@ function submit() {
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="cartDetailOpen = true"
>
@click="cartDetailOpen = true">
Lihat Detail
</button>
<Badge
v-if="totalResultPieces > 0"
variant="secondary"
class="tabular-nums font-semibold"
>
<Badge v-if="totalResultPieces > 0" variant="secondary" class="tabular-nums font-semibold">
Total: {{ totalResultPieces }} pcs
</Badge>
</span>
@ -920,61 +801,39 @@ function submit() {
<FieldGroup>
<FieldSet class="grid gap-4">
<Field>
<FieldLabel for="description"
>Keterangan</FieldLabel
>
<Textarea
id="description"
v-model="form.description"
placeholder="Contoh: Cutting batch pagi"
rows="2"
:maxlength="FIELD_LIMITS.description"
/>
<FieldError
:errors="formErrors(form, 'description')"
/>
<FieldLabel for="description">Keterangan</FieldLabel>
<Textarea id="description" v-model="form.description"
placeholder="Contoh: Cutting batch pagi" rows="2"
:maxlength="FIELD_LIMITS.description" />
<FieldError :errors="formErrors(form, 'description')" />
</Field>
<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">
<div
v-for="(item, index) in materialCart"
:key="item.raw_material_price_id"
class="rounded-lg border p-3"
>
<div
class="mb-2 flex items-start justify-between gap-2"
>
<div v-else
class="scrollbar-thin max-h-80 space-y-3 overflow-y-auto overscroll-y-contain">
<div v-for="(item, index) in materialCart" :key="item.raw_material_price_id"
class="rounded-lg border p-3">
<div class="mb-2 flex items-start justify-between gap-2">
<div class="min-w-0">
<p
class="truncate text-sm font-medium"
>
<p class="truncate text-sm font-medium">
{{ item.raw_material_name }}
</p>
<p
class="truncate text-xs text-muted-foreground"
>
<p class="truncate text-xs text-muted-foreground">
{{ item.variant }} · Stok
{{ item.stock_input }}
{{ item.unit_abbreviation }}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
<Button type="button" variant="ghost" size="icon"
class="size-7 shrink-0 text-destructive hover:text-destructive"
@click="removeMaterial(index)"
>
@click="removeMaterial(index)">
<Trash2 class="size-3.5" />
</Button>
</div>
@ -984,33 +843,23 @@ function submit() {
<FieldLabel class="text-xs">
Pemakaian ({{ item.unit_abbreviation }})
</FieldLabel>
<DecimalInput
v-model="
item.material_usage
"
class="h-8"
@change="
<DecimalInput v-model="item.material_usage
" class="h-8" @change="
syncMaterialField(
index,
)
"
/>
" />
</Field>
<Field>
<FieldLabel class="text-xs">
Sisa ({{ item.unit_abbreviation }})
</FieldLabel>
<DecimalInput
v-model="
item.remaining_material
"
class="h-8"
@change="
<DecimalInput v-model="item.remaining_material
" class="h-8" @change="
syncMaterialField(
index,
)
"
/>
" />
</Field>
</div>
</div>
@ -1022,111 +871,68 @@ function submit() {
<div class="space-y-2">
<div class="flex items-center justify-between gap-2">
<p class="text-sm font-medium">Hasil Produk</p>
<Badge
v-if="totalResultPieces > 0"
variant="outline"
class="tabular-nums text-xs"
>
<Badge v-if="totalResultPieces > 0" variant="outline" class="tabular-nums text-xs">
{{ totalResultPieces }} pcs
</Badge>
</div>
<div
v-if="resultCart.length === 0"
class="rounded-lg border border-dashed px-4 py-6 text-center text-sm text-muted-foreground"
>
<div v-if="resultCart.length === 0"
class="rounded-lg border border-dashed px-4 py-6 text-center text-sm text-muted-foreground">
Belum ada produk hasil dipilih.
</div>
<div v-else class="scrollbar-thin max-h-80 space-y-3 overflow-y-auto overscroll-y-contain">
<div
v-for="(item, index) in resultCart"
:key="item.product_variant_id"
class="rounded-lg border p-3"
>
<div
class="mb-2 flex items-start justify-between gap-2"
>
<div v-else
class="scrollbar-thin max-h-80 space-y-3 overflow-y-auto overscroll-y-contain">
<div v-for="(item, index) in resultCart" :key="item.product_variant_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"
>
<p class="truncate text-sm font-medium">
{{ item.product_name }}
</p>
<p
class="truncate text-xs text-muted-foreground"
>
<p class="truncate text-xs text-muted-foreground">
{{ item.variant_name }} ·
Stok {{ item.stock }} pcs
</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="removeResult(index)"
>
@click="removeResult(index)">
<Trash2 class="size-3.5" />
</Button>
</div>
<div class="grid grid-cols-3 gap-2">
<Field>
<FieldLabel class="text-xs"
>Hasil</FieldLabel
>
<NumberInput
v-model="
item.cutting_result
"
class="h-8"
@change="
<FieldLabel class="text-xs">Hasil</FieldLabel>
<NumberInput v-model="item.cutting_result
" class="h-8" @change="
syncResultTotals(item);
syncResultField(index)
"
/>
syncResultField(index)
" />
</Field>
<Field>
<FieldLabel class="text-xs"
>Bagus</FieldLabel
>
<NumberInput
v-model="
item.warehouse_stock
"
class="h-8"
@change="
<FieldLabel class="text-xs">Bagus</FieldLabel>
<NumberInput v-model="item.warehouse_stock
" class="h-8" @change="
syncResultTotals(item);
syncResultField(index)
"
/>
syncResultField(index)
" />
</Field>
<Field>
<FieldLabel class="text-xs"
>Reject</FieldLabel
>
<NumberInput
v-model="
item.cutting_reject
"
class="h-8"
/>
<FieldLabel class="text-xs">Reject</FieldLabel>
<NumberInput v-model="item.cutting_reject
" class="h-8" />
</Field>
</div>
</div>
</div>
</div>
<Button
type="submit"
class="w-full"
:disabled="
form.processing ||
materialCart.length === 0 ||
resultCart.length === 0
"
>
<Button type="submit" class="w-full" :disabled="form.processing ||
materialCart.length === 0 ||
resultCart.length === 0
">
<Save class="size-4" />
{{
form.processing
@ -1158,11 +964,8 @@ function submit() {
<span class="text-muted-foreground">Bahan Baku</span>
<span class="font-medium tabular-nums">{{ formatRupiah(totalMaterialCost) }}</span>
</div>
<div
v-for="item in materialCart"
:key="`cost-${item.raw_material_price_id}`"
class="flex items-center justify-between gap-3 text-xs"
>
<div v-for="item in materialCart" :key="`cost-${item.raw_material_price_id}`"
class="flex items-center justify-between gap-3 text-xs">
<span class="truncate text-muted-foreground">
{{ item.raw_material_name }} ({{ item.variant }})
</span>
@ -1176,10 +979,8 @@ function submit() {
</div>
</div>
<div
v-if="totalResultPieces > 0"
class="flex items-center justify-between rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 font-semibold"
>
<div v-if="totalResultPieces > 0"
class="flex items-center justify-between rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 font-semibold">
<span>Harga Modal / pcs</span>
<span class="tabular-nums">{{ formatRupiah(estimatedCostPerUnit) }}</span>
</div>
@ -1189,18 +990,10 @@ function submit() {
</div>
<DialogFooter class="gap-2 sm:justify-end">
<Button
type="button"
variant="outline"
@click="costModalOpen = false"
>
<Button type="button" variant="outline" @click="costModalOpen = false">
Tutup
</Button>
<Button
type="button"
:disabled="form.processing"
@click="confirmSubmit"
>
<Button type="button" :disabled="form.processing" @click="confirmSubmit">
{{ form.processing ? 'Menyimpan...' : submitLabel }}
</Button>
</DialogFooter>
@ -1216,11 +1009,8 @@ function submit() {
<div v-if="materialCart.length > 0">
<p class="mb-2 text-xs font-medium text-muted-foreground">Bahan Baku</p>
<div class="space-y-2">
<div
v-for="item in materialCart"
:key="`detail-mat-${item.raw_material_price_id}`"
class="rounded-lg border p-3"
>
<div v-for="item in materialCart" :key="`detail-mat-${item.raw_material_price_id}`"
class="rounded-lg border p-3">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium">{{ item.raw_material_name }}</p>
@ -1234,7 +1024,8 @@ function submit() {
</div>
<div class="mt-1.5 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>Sisa: {{ item.remaining_material }} {{ item.unit_abbreviation }}</span>
<span class="font-medium text-foreground">{{ formatRupiah(materialLineCost(item)) }}</span>
<span class="font-medium text-foreground">{{ formatRupiah(materialLineCost(item))
}}</span>
</div>
</div>
</div>
@ -1242,11 +1033,8 @@ function submit() {
<div v-if="resultCart.length > 0">
<p class="mb-2 text-xs font-medium text-muted-foreground">Hasil Produk</p>
<div class="space-y-2">
<div
v-for="item in resultCart"
:key="`detail-res-${item.product_variant_id}`"
class="rounded-lg border p-3"
>
<div v-for="item in resultCart" :key="`detail-res-${item.product_variant_id}`"
class="rounded-lg border p-3">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium">{{ item.product_name }}</p>

View File

@ -1,10 +1,11 @@
<script setup lang="ts">
import { Link, router, useForm } from '@inertiajs/vue3';
import { router, useForm } from '@inertiajs/vue3';
import { Check, RotateCcw, Scissors, X } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import { RowDeleteAction, RowEditAction, RowStatusAction } from '@/components/button';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { RupiahInput } from '@/components/form/rupiah-input';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@ -27,15 +28,14 @@ import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { useCan } from '@/composables/useCan';
import { CuttingStatus } from '@/constants/cutting-status';
import { RupiahInput } from '@/components/form/rupiah-input';
import { FIELD_LIMITS } from '@/lib/field-limits';
import { parseRupiah } from '@/lib/rupiah';
import { edit, destroy, transition_status } from '@/routes/admin/manage/cuttings';
import type { CuttingListItem, CuttingStatusAction } from '@/types/cutting';
import {
PRICE_TYPES,
PRICE_TYPE_LABELS,
} from '@/types/product';
import type { CuttingListItem, CuttingStatusAction } from '@/types/cutting';
import { edit, destroy, transition_status } from '@/routes/admin/manage/cuttings';
import { FIELD_LIMITS } from '@/lib/field-limits';
const props = defineProps<{
cutting: CuttingListItem;
@ -122,6 +122,7 @@ function setAllMatches(value: boolean) {
}
function setResultPrice(resultIndex: number, type: string, value: string) {
const result = verifyForm.results[resultIndex];
if (!result) {
return;
}
@ -203,20 +204,16 @@ function submitVerify() {
result_prices: buildResultPricesPayload(),
}))
.post(transition_status.url(props.cutting.id), {
preserveScroll: true,
onSuccess: () => {
verifyDialogOpen.value = false;
},
onError: (errors) => {
const message = Object.values(errors)[0];
toast.error(
typeof message === 'string'
? message
: 'Gagal memverifikasi cutting.',
);
},
});
preserveScroll: true,
onSuccess: () => {
verifyDialogOpen.value = false;
},
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
});
}
function transitionStatus() {
@ -237,14 +234,10 @@ function transitionStatus() {
statusConfirmOpen.value = false;
pendingAction.value = null;
},
onError: (errors) => {
const message = Object.values(errors)[0];
toast.error(
typeof message === 'string'
? message
: 'Gagal memperbarui status cutting.',
);
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
onFinish: () => {
statusProcessing.value = false;
@ -259,14 +252,10 @@ function submitReject() {
onSuccess: () => {
rejectDialogOpen.value = false;
},
onError: (errors) => {
const message = Object.values(errors)[0];
toast.error(
typeof message === 'string'
? message
: 'Gagal menolak cutting.',
);
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
});
}
@ -298,9 +287,8 @@ function actionIcon(status: string) {
<template>
<div class="flex flex-wrap items-center justify-end gap-1">
<template v-for="action in availableActions" :key="action.status">
<RowStatusAction v-if="canPerformAction(action)" :size="'sm'"
:icon="actionIcon(action.status)" :label="action.label" :destructive="action.destructive"
@click="openStatusConfirm(action)" />
<RowStatusAction v-if="canPerformAction(action)" :size="'sm'" :icon="actionIcon(action.status)"
:label="action.label" :destructive="action.destructive" @click="openStatusConfirm(action)" />
</template>
<RowEditAction v-if="canEdit" :href="edit.url(cutting.id)" />
@ -311,22 +299,12 @@ function actionIcon(status: string) {
</template>
</div>
<ConfirmDialog
v-model:open="statusConfirmOpen"
:title="
pendingAction
? `${pendingAction.label} cutting?`
: 'Ubah status cutting?'
"
:description="
pendingAction ? statusConfirmDescription(pendingAction) : ''
"
:confirm-label="pendingAction?.label ?? 'Konfirmasi'"
cancel-label="Batal"
:destructive="pendingAction?.destructive ?? false"
:loading="statusProcessing"
@confirm="transitionStatus"
/>
<ConfirmDialog v-model:open="statusConfirmOpen" :title="pendingAction
? `${pendingAction.label} cutting?`
: 'Ubah status cutting?'
" :description="pendingAction ? statusConfirmDescription(pendingAction) : ''
" :confirm-label="pendingAction?.label ?? 'Konfirmasi'" cancel-label="Batal"
:destructive="pendingAction?.destructive ?? false" :loading="statusProcessing" @confirm="transitionStatus" />
<Dialog v-model:open="rejectDialogOpen">
<DialogContent class="sm:max-w-md">
@ -338,42 +316,24 @@ function actionIcon(status: string) {
<FieldGroup>
<FieldSet class="grid gap-4">
<Field>
<FieldLabel for="cutting-reject-reason" required
>Alasan Penolakan</FieldLabel
>
<Textarea
id="cutting-reject-reason"
v-model="rejectForm.reason"
placeholder="Contoh: Jumlah barang yang diterima tidak sesuai"
rows="3"
autofocus
:maxlength="FIELD_LIMITS.reason"
/>
<FieldError
:errors="
rejectForm.errors.reason
? [rejectForm.errors.reason]
: []
"
/>
<FieldLabel for="cutting-reject-reason" required>Alasan Penolakan</FieldLabel>
<Textarea id="cutting-reject-reason" v-model="rejectForm.reason"
placeholder="Contoh: Jumlah barang yang diterima tidak sesuai" rows="3" autofocus
:maxlength="FIELD_LIMITS.reason" />
<FieldError :errors="rejectForm.errors.reason
? [rejectForm.errors.reason]
: []
" />
</Field>
</FieldSet>
</FieldGroup>
<DialogFooter class="mt-6">
<Button
type="button"
variant="outline"
:disabled="rejectForm.processing"
@click="rejectDialogOpen = false"
>
<Button type="button" variant="outline" :disabled="rejectForm.processing"
@click="rejectDialogOpen = false">
Batal
</Button>
<Button
type="submit"
variant="destructive"
:disabled="rejectForm.processing"
>
<Button type="submit" variant="destructive" :disabled="rejectForm.processing">
{{ rejectForm.processing ? 'Menyimpan...' : 'Tolak' }}
</Button>
</DialogFooter>
@ -397,15 +357,12 @@ function actionIcon(status: string) {
<Label for="all-matches" class="text-sm font-medium">
Semua sesuai dengan data cutting
</Label>
<Switch
id="all-matches"
:model-value="allMatches"
@update:model-value="setAllMatches"
/>
<Switch id="all-matches" :model-value="allMatches" @update:model-value="setAllMatches" />
</div>
<div class="space-y-3">
<div v-for="(result, index) in verifyForm.results" :key="result.product_variant_id" class="p-3 border rounded-lg space-y-3">
<div v-for="(result, index) in verifyForm.results" :key="result.product_variant_id"
class="p-3 border rounded-lg space-y-3">
<div class="font-medium text-sm">
{{ result.name }}
</div>
@ -430,30 +387,21 @@ function actionIcon(status: string) {
</div>
<div class="grid grid-cols-2 gap-2 items-end text-xs">
<div>
<label :for="`good-${index}`" class="text-muted-foreground block mb-0.5">Stok Bagus (diterima):</label>
<Input
:id="`good-${index}`"
type="number"
v-model.number="result.warehouse_stock"
min="0"
:max="result.cutting_result"
class="h-8 w-full px-2 text-xs"
<label :for="`good-${index}`" class="text-muted-foreground block mb-0.5">Stok Bagus
(diterima):</label>
<Input :id="`good-${index}`" type="number" v-model.number="result.warehouse_stock"
min="0" :max="result.cutting_result" class="h-8 w-full px-2 text-xs"
:disabled="allMatches"
@input="result.cutting_reject = result.cutting_result - result.warehouse_stock"
/>
@input="result.cutting_reject = result.cutting_result - result.warehouse_stock" />
</div>
<div>
<label :for="`reject-${index}`" class="text-muted-foreground block mb-0.5">Stok Reject (diterima):</label>
<Input
:id="`reject-${index}`"
type="number"
v-model.number="result.cutting_reject"
min="0"
:max="result.cutting_result"
class="h-8 w-full px-2 text-xs"
<label :for="`reject-${index}`" class="text-muted-foreground block mb-0.5">Stok
Reject
(diterima):</label>
<Input :id="`reject-${index}`" type="number" v-model.number="result.cutting_reject"
min="0" :max="result.cutting_result" class="h-8 w-full px-2 text-xs"
:disabled="allMatches"
@input="result.warehouse_stock = result.cutting_result - result.cutting_reject"
/>
@input="result.warehouse_stock = result.cutting_result - result.cutting_reject" />
</div>
</div>
@ -462,47 +410,30 @@ function actionIcon(status: string) {
<FieldLabel class="text-xs" :for="`price-${index}-${type}`">
{{ PRICE_TYPE_LABELS[type] }}
</FieldLabel>
<RupiahInput
:id="`price-${index}-${type}`"
:model-value="result.prices[type]"
@update:model-value="setResultPrice(index, type, $event)"
/>
<RupiahInput :id="`price-${index}-${type}`" :model-value="result.prices[type]"
@update:model-value="setResultPrice(index, type, $event)" />
</Field>
</div>
</div>
</div>
<FieldError
:errors="verifyForm.errors.result_prices ? [verifyForm.errors.result_prices] : []"
/>
<FieldError :errors="verifyForm.errors.result_prices ? [verifyForm.errors.result_prices] : []" />
<Field>
<FieldLabel for="verification-note">Catatan Verifikasi</FieldLabel>
<Textarea
id="verification-note"
v-model="verifyForm.verification_note"
placeholder="Contoh: Terdapat 1 barang cacat jahitan saat dihitung di toko"
rows="3"
/>
<Textarea id="verification-note" v-model="verifyForm.verification_note"
placeholder="Contoh: Terdapat 1 barang cacat jahitan saat dihitung di toko" rows="3" />
<FieldError
:errors="verifyForm.errors.verification_note ? [verifyForm.errors.verification_note] : []"
/>
:errors="verifyForm.errors.verification_note ? [verifyForm.errors.verification_note] : []" />
</Field>
</div>
<DialogFooter class="mt-6">
<Button
type="button"
variant="outline"
:disabled="verifyForm.processing"
@click="verifyDialogOpen = false"
>
<Button type="button" variant="outline" :disabled="verifyForm.processing"
@click="verifyDialogOpen = false">
Batal
</Button>
<Button
type="submit"
:disabled="verifyForm.processing"
>
<Button type="submit" :disabled="verifyForm.processing">
{{ verifyForm.processing ? 'Menyimpan...' : 'Verifikasi' }}
</Button>
</DialogFooter>

View File

@ -16,7 +16,6 @@ import {
} from '@lucide/vue';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import OrderPrintButton from './form/OrderPrintButton.vue';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -36,8 +35,9 @@ import { MarketplaceFeeValueType } from '@/constants/marketplace-fee-value-type'
import { OrderStatus } from '@/constants/order-status';
import { StockQuality } from '@/constants/stock-quality';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { OrderDetail, OrderStatusAction } from '@/types/order';
import { index, edit, destroy, transition_status } from '@/routes/admin/manage/orders';
import type { OrderDetail, OrderStatusAction } from '@/types/order';
import OrderPrintButton from './form/OrderPrintButton.vue';
const props = defineProps<{
order: OrderDetail;
@ -59,15 +59,30 @@ const { open: deleteConfirmOpen, processing: deleteProcessing, destroy: destroyO
});
function statusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
if (status === OrderStatus.COMPLETED) return 'default';
if (status === OrderStatus.CANCELLED) return 'destructive';
if (status === OrderStatus.PROCESSING) return 'secondary';
if (status === OrderStatus.COMPLETED) {
return 'default';
}
if (status === OrderStatus.CANCELLED) {
return 'destructive';
}
if (status === OrderStatus.PROCESSING) {
return 'secondary';
}
return 'outline';
}
function actionIcon(status: string) {
if (status === OrderStatus.PROCESSING) return Send;
if (status === OrderStatus.COMPLETED) return Check;
if (status === OrderStatus.PROCESSING) {
return Send;
}
if (status === OrderStatus.COMPLETED) {
return Check;
}
return X;
}
@ -79,9 +94,11 @@ function statusConfirmDescription(action: OrderStatusAction): string {
if (action.status === OrderStatus.PROCESSING) {
return `Pesanan ${props.order.order_number} akan dikirim dan diproses.`;
}
if (action.status === OrderStatus.COMPLETED) {
return `Pesanan ${props.order.order_number} akan ditandai selesai.`;
}
return `Pesanan ${props.order.order_number} akan dibatalkan. Stok produk akan dikembalikan.`;
}
@ -91,7 +108,9 @@ function openStatusConfirm(action: OrderStatusAction) {
}
function transitionStatus() {
if (!pendingAction.value) return;
if (!pendingAction.value) {
return;
}
statusProcessing.value = true;
@ -102,9 +121,10 @@ function transitionStatus() {
statusConfirmOpen.value = false;
pendingAction.value = null;
},
onError: (errors) => {
const message = Object.values(errors)[0];
toast.error(typeof message === 'string' ? message : 'Gagal memperbarui status pesanan.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
onFinish: () => {
statusProcessing.value = false;
@ -176,7 +196,9 @@ function formatFeeRule(fee: { scope: string; value_type: string; value: number }
}
const feeEntries = computed(() => {
if (!snapshot.value) return [];
if (!snapshot.value) {
return [];
}
return Object.entries(snapshot.value.fees)
.filter(([, rule]) => rule.value > 0)
@ -191,6 +213,7 @@ const feeEntries = computed(() => {
</script>
<template>
<Head :title="`Detail Pesanan ${order.order_number}`" />
<AdminLayout>
@ -208,13 +231,10 @@ const feeEntries = computed(() => {
<OrderPrintButton :order="order as any" />
<template v-for="action in order.available_actions" :key="action.status">
<Button
v-if="canPerformAction(action)"
size="sm"
<Button v-if="canPerformAction(action)" size="sm"
:variant="action.destructive ? 'outline' : 'default'"
:class="action.destructive ? 'text-destructive hover:text-destructive' : ''"
@click="openStatusConfirm(action)"
>
@click="openStatusConfirm(action)">
<component :is="actionIcon(action.status)" class="size-3.5" />
{{ action.label }}
</Button>
@ -227,13 +247,8 @@ const feeEntries = computed(() => {
</Link>
</Button>
<Button
v-if="canDelete"
variant="outline"
size="sm"
class="text-destructive hover:text-destructive"
@click="deleteConfirmOpen = true"
>
<Button v-if="canDelete" variant="outline" size="sm" class="text-destructive hover:text-destructive"
@click="deleteConfirmOpen = true">
<Trash2 class="size-3.5" />
Hapus
</Button>
@ -420,7 +435,8 @@ const feeEntries = computed(() => {
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">Total Potongan</span>
<span class="tabular-nums text-destructive">-{{ formatRp(snapshot.total_fee_amount) }}</span>
<span class="tabular-nums text-destructive">-{{ formatRp(snapshot.total_fee_amount)
}}</span>
</div>
<Separator />
<div class="flex items-center justify-between">
@ -481,11 +497,8 @@ const feeEntries = computed(() => {
</CardHeader>
<CardContent>
<div class="space-y-2">
<div
v-for="row in summaryRows"
:key="row.label"
class="flex items-center justify-between text-sm"
>
<div v-for="row in summaryRows" :key="row.label"
class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">{{ row.label }}</span>
<span class="tabular-nums">{{ row.value }}</span>
</div>
@ -533,26 +546,13 @@ const feeEntries = computed(() => {
</div>
</AdminLayout>
<ConfirmDialog
v-model:open="statusConfirmOpen"
<ConfirmDialog v-model:open="statusConfirmOpen"
:title="pendingAction ? `${pendingAction.label} pesanan?` : 'Ubah status pesanan?'"
:description="pendingAction ? statusConfirmDescription(pendingAction) : ''"
:confirm-label="pendingAction?.label ?? 'Konfirmasi'"
cancel-label="Batal"
:destructive="pendingAction?.destructive ?? false"
:loading="statusProcessing"
@confirm="transitionStatus"
/>
:confirm-label="pendingAction?.label ?? 'Konfirmasi'" cancel-label="Batal"
:destructive="pendingAction?.destructive ?? false" :loading="statusProcessing" @confirm="transitionStatus" />
<ConfirmDialog
v-if="canDelete"
v-model:open="deleteConfirmOpen"
title="Hapus pesanan?"
<ConfirmDialog v-if="canDelete" v-model:open="deleteConfirmOpen" title="Hapus pesanan?"
:description="`Pesanan ${order.order_number} akan dihapus.${order.is_editable ? ' Stok produk akan dikembalikan.' : ''}`"
confirm-label="Hapus"
cancel-label="Batal"
destructive
:loading="deleteProcessing"
@confirm="destroyOrder"
/>
confirm-label="Hapus" cancel-label="Batal" destructive :loading="deleteProcessing" @confirm="destroyOrder" />
</template>

View File

@ -48,6 +48,7 @@ import { apiFetch } from '@/lib/api';
import { getFirstCoverImage } from '@/lib/catalog-cover';
import { formErrors } from '@/lib/form';
import { formatRupiah, parseRupiah } from '@/lib/rupiah';
import { store as syncDraftRoute, resync_prices as resyncPricesRoute, destroy as destroyDraftRoute } from '@/routes/admin/manage/orders/draft_items';
import type { Auth } from '@/types/auth';
import { STOCK_QUALITY_OPTIONS } from '@/types/order';
import type { EnumOption, OrderCartItem, OrderCatalogItem, SelectOption } from '@/types/order';
@ -55,7 +56,6 @@ import type { ProductPriceItem, ProductVariantItem } from '@/types/product';
import PosCatalogCard from '../../shared/PosCatalogCard.vue';
import PosCatalogVariantThumb from '../../shared/PosCatalogVariantThumb.vue';
import CustomerQuickCreateModal from './CustomerQuickCreateModal.vue';
import { store as syncDraftRoute, resync_prices as resyncPricesRoute, destroy as destroyDraftRoute } from '@/routes/admin/manage/orders/draft_items';
const props = defineProps<{
customers: SelectOption[];
@ -490,9 +490,10 @@ function submit() {
form.transform(() => payload).post(props.submitUrl, {
forceFormData: true,
onError: (errors) => {
const firstError = Object.values(errors)[0];
toast.error(firstError || 'Gagal menyimpan data. Periksa kembali formulir.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
});
}
@ -769,7 +770,8 @@ function submit() {
<p class="truncate text-xs text-muted-foreground">
{{ item.variant_name }}
·
{{ item.stock_quality_label ?? (item.stock_quality === StockQuality.REJECT
{{ item.stock_quality_label ?? (item.stock_quality ===
StockQuality.REJECT
? 'Reject' : 'Bagus') }}
</p>
</div>
@ -849,7 +851,8 @@ function submit() {
<div v-if="printAfterSave" class="mt-2 flex items-center gap-2 pl-1">
<span class="text-xs text-muted-foreground">Ukuran kertas:</span>
<div class="flex gap-1.5">
<button v-for="size in ([PaperSize.MM_58, PaperSize.MM_80] as const)" :key="size" type="button"
<button v-for="size in ([PaperSize.MM_58, PaperSize.MM_80] as const)"
:key="size" type="button"
class="inline-flex h-7 items-center rounded-md border px-2.5 text-xs font-medium transition-colors cursor-pointer"
:class="selectedPaperSize === size
? 'border-primary bg-primary/5 text-primary'
@ -887,7 +890,8 @@ function submit() {
<p class="truncate text-sm font-medium">{{ item.product_name }}</p>
<p class="truncate text-xs text-muted-foreground">
{{ item.variant_name }}
· {{ item.stock_quality_label ?? (item.stock_quality === StockQuality.REJECT ? 'Reject' : 'Bagus')
· {{ item.stock_quality_label ?? (item.stock_quality === StockQuality.REJECT ? 'Reject'
: 'Bagus')
}}
</p>
</div>

View File

@ -3,14 +3,14 @@ import { router } from '@inertiajs/vue3';
import { Check, Send, X } from '@lucide/vue';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import OrderPrintButton from '../form/OrderPrintButton.vue';
import { RowDeleteAction, RowDetailAction, RowEditAction, RowStatusAction } from '@/components/button';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { Button } from '@/components/ui/button';
import { useCan } from '@/composables/useCan';
import { OrderStatus } from '@/constants/order-status';
import type { OrderListItem, OrderStatusAction } from '@/types/order';
import { show, edit, destroy, transition_status } from '@/routes/admin/manage/orders';
import type { OrderListItem, OrderStatusAction } from '@/types/order';
import OrderPrintButton from '../form/OrderPrintButton.vue';
const props = defineProps<{
order: OrderListItem;
@ -63,10 +63,10 @@ function transitionStatus() {
statusConfirmOpen.value = false;
pendingAction.value = null;
},
onError: (errors) => {
const message = Object.values(errors)[0];
toast.error(typeof message === 'string' ? message : 'Gagal memperbarui status pesanan.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
onFinish: () => {
statusProcessing.value = false;
@ -92,17 +92,12 @@ function actionIcon(status: string) {
<OrderPrintButton :order="order" />
<template v-for="action in availableActions" :key="action.status">
<RowStatusAction v-if="action.icon_only && canPerformAction(action)"
:icon="actionIcon(action.status)" :label="action.label" :destructive="action.destructive"
@click="openStatusConfirm(action)" />
<RowStatusAction v-if="action.icon_only && canPerformAction(action)" :icon="actionIcon(action.status)"
:label="action.label" :destructive="action.destructive" @click="openStatusConfirm(action)" />
<Button
v-else-if="canPerformAction(action)"
size="sm"
:variant="action.destructive ? 'outline' : 'default'"
<Button v-else-if="canPerformAction(action)" size="sm" :variant="action.destructive ? 'outline' : 'default'"
:class="action.destructive ? 'text-destructive hover:text-destructive' : ''"
@click="openStatusConfirm(action)"
>
@click="openStatusConfirm(action)">
<component :is="actionIcon(action.status)" class="size-3.5" />
{{ action.label }}
</Button>
@ -119,14 +114,9 @@ function actionIcon(status: string) {
</template>
</div>
<ConfirmDialog
v-model:open="statusConfirmOpen"
<ConfirmDialog v-model:open="statusConfirmOpen"
:title="pendingAction ? `${pendingAction.label} pesanan?` : 'Ubah status pesanan?'"
:description="pendingAction ? statusConfirmDescription(pendingAction) : ''"
:confirm-label="pendingAction?.label ?? 'Konfirmasi'"
cancel-label="Batal"
:destructive="pendingAction?.destructive ?? false"
:loading="statusProcessing"
@confirm="transitionStatus"
/>
:confirm-label="pendingAction?.label ?? 'Konfirmasi'" cancel-label="Batal"
:destructive="pendingAction?.destructive ?? false" :loading="statusProcessing" @confirm="transitionStatus" />
</template>

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { router, useForm } from '@inertiajs/vue3';
import { useForm } from '@inertiajs/vue3';
import { X } from '@lucide/vue';
import { ref } from 'vue';
import { toast } from 'vue-sonner';
@ -19,8 +19,8 @@ import {
} from '@/components/ui/field';
import { Textarea } from '@/components/ui/textarea';
import { useCan } from '@/composables/useCan';
import type { CuttingListItem } from '@/types/cutting';
import { approve, reject } from '@/routes/admin/manage/owner_verifications';
import type { CuttingListItem } from '@/types/cutting';
const props = defineProps<{
cutting: CuttingListItem;
@ -39,27 +39,29 @@ const rejectForm = useForm({
function submitApprove() {
approveForm
.post(approve.url(props.cutting.id), {
preserveScroll: true,
onError: (errors) => {
const message = Object.values(errors)[0];
toast.error(message ?? 'Gagal menyetujui verifikasi.');
},
});
preserveScroll: true,
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
});
}
function submitReject() {
rejectForm
.post(reject.url(props.cutting.id), {
preserveScroll: true,
onSuccess: () => {
rejectDialogOpen.value = false;
rejectForm.reset();
},
onError: (errors) => {
const message = Object.values(errors)[0];
toast.error(message ?? 'Gagal menolak verifikasi.');
},
});
preserveScroll: true,
onSuccess: () => {
rejectDialogOpen.value = false;
rejectForm.reset();
},
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
});
}
</script>
@ -91,32 +93,17 @@ function submitReject() {
<Field>
<FieldLabel for="reject-reason">Alasan Penolakan</FieldLabel>
<Textarea
id="reject-reason"
v-model="rejectForm.reason"
placeholder="Jelaskan alasan penolakan..."
rows="3"
/>
<FieldError
:errors="rejectForm.errors.reason ? [rejectForm.errors.reason] : []"
/>
<Textarea id="reject-reason" v-model="rejectForm.reason" placeholder="Jelaskan alasan penolakan..."
rows="3" />
<FieldError :errors="rejectForm.errors.reason ? [rejectForm.errors.reason] : []" />
</Field>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
@click="rejectDialogOpen = false"
>
<Button type="button" variant="outline" @click="rejectDialogOpen = false">
Batal
</Button>
<Button
type="button"
variant="destructive"
:disabled="rejectForm.processing"
@click="submitReject"
>
<Button type="button" variant="destructive" :disabled="rejectForm.processing" @click="submitReject">
<X class="size-4" />
Tolak
</Button>

View File

@ -44,12 +44,12 @@ import { getFirstCoverImage } from '@/lib/catalog-cover';
import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form';
import { formatRupiah, parseRupiah } from '@/lib/rupiah';
import { store as syncDraftRoute, destroy as destroyDraftRoute } from '@/routes/admin/manage/purchases/draft_items';
import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media';
import type { MediaItem, MediaUploadState } from '@/types/media';
import type { PurchaseCartItem, PurchaseCatalogItem, SelectOption } from '@/types/purchase';
import PosCatalogCard from '../../shared/PosCatalogCard.vue';
import PosCatalogVariantThumb from '../../shared/PosCatalogVariantThumb.vue';
import { store as syncDraftRoute, destroy as destroyDraftRoute } from '@/routes/admin/manage/purchases/draft_items';
const props = defineProps<{
suppliers: SelectOption[];
catalog: PurchaseCatalogItem[];
@ -323,8 +323,10 @@ function submit() {
form.transform(() => payload).post(props.submitUrl, {
forceFormData: true,
onError: () => {
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
});
}

View File

@ -1,9 +1,9 @@
<script setup lang="ts">
import { router, useForm } from '@inertiajs/vue3';
import { Check } from '@lucide/vue';
import { useForm } from '@inertiajs/vue3';
import { ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import { RowApproveAction } from '@/components/button';
import { RupiahInput } from '@/components/form/rupiah-input';
import { Button } from '@/components/ui/button';
import {
Dialog,
@ -22,20 +22,14 @@ import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useCan } from '@/composables/useCan';
import { RupiahInput } from '@/components/form/rupiah-input';
import { parseRupiah } from '@/lib/rupiah';
import { verify } from '@/routes/admin/manage/stocks';
import type { CuttingListItem } from '@/types/cutting';
import {
PRICE_TYPES,
PRICE_TYPE_LABELS,
} from '@/types/product';
import type { CuttingListItem } from '@/types/cutting';
import { verify } from '@/routes/admin/manage/stocks';
const props = defineProps<{
cutting: CuttingListItem;
@ -148,20 +142,16 @@ function submitVerify() {
result_prices: buildResultPricesPayload(),
}))
.post(verify.url(props.cutting.id), {
preserveScroll: true,
onSuccess: () => {
verifyDialogOpen.value = false;
},
onError: (errors) => {
const message = Object.values(errors)[0];
toast.error(
typeof message === 'string'
? message
: 'Gagal memverifikasi cutting.',
);
},
});
preserveScroll: true,
onSuccess: () => {
verifyDialogOpen.value = false;
},
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
});
}
</script>
@ -178,55 +168,44 @@ function submitVerify() {
<form @submit.prevent="submitVerify">
<div class="space-y-4 max-h-[60vh] overflow-y-auto scrollbar-thin px-1 py-1">
<p class="text-sm text-muted-foreground">
Verifikasi jumlah produk yang diterima dan tentukan harga jual. Stok akan ditambahkan setelah verifikasi.
Verifikasi jumlah produk yang diterima dan tentukan harga jual. Stok akan ditambahkan setelah
verifikasi.
</p>
<div class="flex items-center justify-between rounded-lg border p-3">
<Label for="stock-all-matches" class="text-sm font-medium">
Semua sesuai dengan data cutting
</Label>
<Switch
id="stock-all-matches"
:model-value="allMatches"
@update:model-value="setAllMatches"
/>
<Switch id="stock-all-matches" :model-value="allMatches" @update:model-value="setAllMatches" />
</div>
<div class="space-y-2">
<div v-for="(result, index) in verifyForm.results" :key="result.product_variant_id" class="flex items-center gap-3 rounded-lg border p-3">
<div v-for="(result, index) in verifyForm.results" :key="result.product_variant_id"
class="flex items-center gap-3 rounded-lg border p-3">
<div class="flex-1 min-w-0">
<p class="font-medium text-sm truncate">{{ result.name }}</p>
<p class="text-xs text-muted-foreground">
{{ result.cutting_result }} pcs
<span class="text-[10px]">({{ result.original_warehouse_stock }} bagus, {{ result.original_cutting_reject }} reject)</span>
<span class="text-[10px]">({{ result.original_warehouse_stock }} bagus, {{
result.original_cutting_reject }} reject)</span>
</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<div class="text-center">
<label :for="`stock-good-${index}`" class="text-[10px] text-muted-foreground block">Bagus</label>
<Input
:id="`stock-good-${index}`"
type="number"
v-model.number="result.warehouse_stock"
min="0"
:max="result.cutting_result"
class="h-7 w-16 px-1.5 text-xs text-center"
:disabled="allMatches"
@input="result.cutting_reject = result.cutting_result - result.warehouse_stock"
/>
<label :for="`stock-good-${index}`"
class="text-[10px] text-muted-foreground block">Bagus</label>
<Input :id="`stock-good-${index}`" type="number"
v-model.number="result.warehouse_stock" min="0" :max="result.cutting_result"
class="h-7 w-16 px-1.5 text-xs text-center" :disabled="allMatches"
@input="result.cutting_reject = result.cutting_result - result.warehouse_stock" />
</div>
<div class="text-center">
<label :for="`stock-reject-${index}`" class="text-[10px] text-muted-foreground block">Reject</label>
<Input
:id="`stock-reject-${index}`"
type="number"
v-model.number="result.cutting_reject"
min="0"
:max="result.cutting_result"
class="h-7 w-16 px-1.5 text-xs text-center"
:disabled="allMatches"
@input="result.warehouse_stock = result.cutting_result - result.cutting_reject"
/>
<label :for="`stock-reject-${index}`"
class="text-[10px] text-muted-foreground block">Reject</label>
<Input :id="`stock-reject-${index}`" type="number"
v-model.number="result.cutting_reject" min="0" :max="result.cutting_result"
class="h-7 w-16 px-1.5 text-xs text-center" :disabled="allMatches"
@input="result.warehouse_stock = result.cutting_result - result.cutting_reject" />
</div>
</div>
</div>
@ -241,46 +220,29 @@ function submitVerify() {
<FieldLabel class="text-xs" :for="`shared-price-${type}`">
{{ PRICE_TYPE_LABELS[type] }}
</FieldLabel>
<RupiahInput
:id="`shared-price-${type}`"
:model-value="verifyForm.shared_prices[type]"
@update:model-value="setSharedPrice(type, $event)"
/>
<RupiahInput :id="`shared-price-${type}`" :model-value="verifyForm.shared_prices[type]"
@update:model-value="setSharedPrice(type, $event)" />
</Field>
</div>
</div>
<FieldError
:errors="verifyForm.errors.result_prices ? [verifyForm.errors.result_prices] : []"
/>
<FieldError :errors="verifyForm.errors.result_prices ? [verifyForm.errors.result_prices] : []" />
<Field>
<FieldLabel for="stock-verification-note">Catatan Verifikasi</FieldLabel>
<Textarea
id="stock-verification-note"
v-model="verifyForm.verification_note"
placeholder="Contoh: Terdapat 1 barang cacat jahitan saat dihitung di toko"
rows="3"
/>
<Textarea id="stock-verification-note" v-model="verifyForm.verification_note"
placeholder="Contoh: Terdapat 1 barang cacat jahitan saat dihitung di toko" rows="3" />
<FieldError
:errors="verifyForm.errors.verification_note ? [verifyForm.errors.verification_note] : []"
/>
:errors="verifyForm.errors.verification_note ? [verifyForm.errors.verification_note] : []" />
</Field>
</div>
<DialogFooter class="mt-6">
<Button
type="button"
variant="outline"
:disabled="verifyForm.processing"
@click="verifyDialogOpen = false"
>
<Button type="button" variant="outline" :disabled="verifyForm.processing"
@click="verifyDialogOpen = false">
Batal
</Button>
<Button
type="submit"
:disabled="verifyForm.processing"
>
<Button type="submit" :disabled="verifyForm.processing">
{{ verifyForm.processing ? 'Menyimpan...' : 'Verifikasi & Tambah Stok' }}
</Button>
</DialogFooter>

View File

@ -22,8 +22,8 @@ import { Input } from '@/components/ui/input';
import { useFormDialog } from '@/composables/useFormDialog';
import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form';
import type { CategoryFormData, CategoryListItem } from '@/types/category';
import { store, update } from '@/routes/admin/master/categories';
import type { CategoryFormData, CategoryListItem } from '@/types/category';
const open = defineModel<boolean>('open', { default: false });
@ -65,8 +65,10 @@ function submit() {
onSuccess: () => {
open.value = false;
},
onError: () => {
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
};

View File

@ -24,8 +24,8 @@ import { Textarea } from '@/components/ui/textarea';
import { useFormDialog } from '@/composables/useFormDialog';
import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form';
import type { CustomerFormData, CustomerListItem } from '@/types/customer';
import { store, update } from '@/routes/admin/master/customers';
import type { CustomerFormData, CustomerListItem } from '@/types/customer';
const open = defineModel<boolean>('open', { default: false });
@ -71,8 +71,10 @@ function submit() {
onSuccess: () => {
open.value = false;
},
onError: () => {
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
};

View File

@ -131,8 +131,10 @@ function buildFormData(): FormData {
function submit() {
const options = {
forceFormData: true,
onError: () => {
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
};

View File

@ -5,8 +5,8 @@ import { toast } from 'vue-sonner';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { useCan } from '@/composables/useCan';
import type { ProductListItem } from '@/types/product';
import { toggle_status } from '@/routes/admin/master/products';
import type { ProductListItem } from '@/types/product';
const props = defineProps<{
product: ProductListItem;
@ -37,9 +37,12 @@ function toggleStatus(checked: boolean) {
is_active: checked,
}, {
preserveScroll: true,
onError: () => {
onError: (errors: any) => {
isActive.value = previous;
toast.error('Gagal memperbarui status produk.');
if (errors.system) {
toast.error(errors.system);
}
},
onFinish: () => {
processing.value = false;

View File

@ -187,8 +187,10 @@ function buildFormData(): FormData {
function submit() {
const options = {
forceFormData: true,
onError: () => {
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
};

View File

@ -5,8 +5,8 @@ import { toast } from 'vue-sonner';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { useCan } from '@/composables/useCan';
import type { RawMaterialListItem } from '@/types/raw-material';
import { toggle_status } from '@/routes/admin/master/raw_materials';
import type { RawMaterialListItem } from '@/types/raw-material';
const props = defineProps<{
material: RawMaterialListItem;
@ -37,9 +37,12 @@ function toggleStatus(checked: boolean) {
is_active: checked,
}, {
preserveScroll: true,
onError: () => {
onError: (errors: any) => {
isActive.value = previous;
toast.error('Gagal memperbarui status bahan baku.');
if (errors.system) {
toast.error(errors.system);
}
},
onFinish: () => {
processing.value = false;

View File

@ -24,8 +24,8 @@ import { Textarea } from '@/components/ui/textarea';
import { useFormDialog } from '@/composables/useFormDialog';
import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form';
import type { SupplierFormData, SupplierListItem } from '@/types/supplier';
import { store, update } from '@/routes/admin/master/suppliers';
import type { SupplierFormData, SupplierListItem } from '@/types/supplier';
const open = defineModel<boolean>('open', { default: false });
@ -71,8 +71,10 @@ function submit() {
onSuccess: () => {
open.value = false;
},
onError: () => {
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
};
@ -98,8 +100,8 @@ function submit() {
<FieldSet class="grid gap-4">
<Field>
<FieldLabel for="supplier-name" required>Nama</FieldLabel>
<Input id="supplier-name" v-model="form.name" type="text" placeholder="Contoh: PT Sumber Makmur"
autofocus :maxlength="FIELD_LIMITS.name" />
<Input id="supplier-name" v-model="form.name" type="text"
placeholder="Contoh: PT Sumber Makmur" autofocus :maxlength="FIELD_LIMITS.name" />
<FieldError :errors="formErrors(form, 'name')" />
</Field>
<Field>

View File

@ -96,8 +96,10 @@ function toggleGroupAll(groupName: string, checked: boolean) {
function submit() {
form[props.method](props.submitUrl, {
onError: () => {
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
});
}
@ -164,7 +166,7 @@ function submit() {
" />
<span class="text-sm">{{
perm.label
}}</span>
}}</span>
</label>
</div>
</div>

View File

@ -6,10 +6,10 @@ import { toast } from 'vue-sonner';
import MediaDropzone from '@/components/media/MediaDropzone.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { update } from '@/routes/admin/system/settings/homepage';
import { createMediaUploadState } from '@/types/media';
import type { MediaUploadState } from '@/types/media';
import type { HomepageSettingsData } from '@/types/setting';
import { update } from '@/routes/admin/system/settings/homepage';
const props = defineProps<{
data: HomepageSettingsData;
@ -49,8 +49,10 @@ function submit() {
heroImage.value = createMediaUploadState();
aboutImage.value = createMediaUploadState();
},
onError: () => {
toast.error('Gagal menyimpan pengaturan homepage.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
onFinish: () => {
isSubmitting.value = false;
@ -68,8 +70,7 @@ function submit() {
<div class="grid gap-4 sm:grid-cols-2">
<MediaDropzone id="hero_image" label="Foto Hero" :max-files="1" v-model="heroImage" />
<MediaDropzone id="about_image" label="Foto Tentang Kami" :max-files="1"
v-model="aboutImage" />
<MediaDropzone id="about_image" label="Foto Tentang Kami" :max-files="1" v-model="aboutImage" />
<div class="sm:col-span-2">
<MediaDropzone id="gallery_images" label="Koleksi Lookbook" :max-files="10"

View File

@ -8,8 +8,8 @@ import { Card, CardContent } from '@/components/ui/card';
import { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { formErrors } from '@/lib/form';
import type { HrSettingsData } from '@/types/setting';
import { update } from '@/routes/admin/system/settings/hr';
import type { HrSettingsData } from '@/types/setting';
const props = defineProps<{
data: HrSettingsData;
@ -25,8 +25,10 @@ const form = useForm({
function submit() {
form.put(update.url(), {
preserveScroll: true,
onError: () => {
toast.error('Gagal menyimpan pengaturan HR.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
});
}

View File

@ -15,12 +15,12 @@ import {
import { MarketplacePlatform as MarketplacePlatformConst } from '@/constants/marketplace-platform';
import { formErrors } from '@/lib/form';
import { cn } from '@/lib/utils';
import { update } from '@/routes/admin/system/settings/marketplace';
import type {
MarketplacePlatform,
MarketplaceSettingsData,
} from '@/types/setting';
import MarketplaceFeeField from './MarketplaceFeeField.vue';
import { update } from '@/routes/admin/system/settings/marketplace';
const props = defineProps<{
data: MarketplaceSettingsData;
@ -67,8 +67,10 @@ function feeErrors(key: string): string[] {
function submit() {
form.put(update.url(), {
preserveScroll: true,
onError: () => {
toast.error('Gagal menyimpan pengaturan marketplace.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
});
}

View File

@ -7,8 +7,8 @@ import { Card, CardContent } from '@/components/ui/card';
import { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { formErrors } from '@/lib/form';
import type { SocialMediaSettingsData } from '@/types/setting';
import { update } from '@/routes/admin/system/settings/social_media';
import type { SocialMediaSettingsData } from '@/types/setting';
const props = defineProps<{
data: SocialMediaSettingsData;
@ -23,8 +23,10 @@ const form = useForm({
function submit() {
form.put(update.url(), {
preserveScroll: true,
onError: () => {
toast.error('Gagal menyimpan pengaturan media sosial.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
});
}

View File

@ -11,10 +11,10 @@ import { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from '@/component
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { formErrors } from '@/lib/form';
import { update } from '@/routes/admin/system/settings/system';
import { createMediaUploadState } from '@/types/media';
import type { MediaUploadState } from '@/types/media';
import type { SystemSettingsData } from '@/types/setting';
import { update } from '@/routes/admin/system/settings/system';
const props = defineProps<{
data: SystemSettingsData;
@ -61,8 +61,10 @@ function submit() {
form.transform(() => payload).put(update.url(), {
preserveScroll: true,
onError: () => {
toast.error('Gagal menyimpan pengaturan sistem.');
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
}
},
});
}