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() { function submit() {
form.post('/auth/login', { form.post('/auth/login', {
onError: (errors) => { onError: (errors: any) => {
const message = if (errors.system) {
errors.login ?? toast.error(errors.system);
errors.password ?? }
'Gagal masuk. Periksa kembali data Anda.';
toast.error(message);
}, },
}); });
} }

View File

@ -66,8 +66,10 @@ function onAppearanceChange(value: AcceptableValue) {
function submit() { function submit() {
form.put(update.url(), { form.put(update.url(), {
onError: () => { onError: (errors: any) => {
toast.error('Gagal menyimpan tampilan.'); 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 { InputGroup, InputGroupInput, InputGroupAddon, InputGroupButton } from '@/components/ui/input-group';
import AccountLayout from '@/layouts/AccountLayout.vue'; import AccountLayout from '@/layouts/AccountLayout.vue';
import { formErrors } from '@/lib/form'; import { formErrors } from '@/lib/form';
import { update } from '@/routes/admin/account/password';
import type { PasswordFormData } from '@/types/account'; import type { PasswordFormData } from '@/types/account';
import { update } from '@/routes/admin/account/password';
const form = useForm<PasswordFormData>({ const form = useForm<PasswordFormData>({
current_password: '', current_password: '',
@ -34,8 +34,10 @@ function submit() {
onSuccess: () => { onSuccess: () => {
form.reset(); form.reset();
}, },
onError: () => { onError: (errors: any) => {
toast.error('Gagal memperbarui kata sandi. Periksa kembali formulir.'); 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 AccountLayout from '@/layouts/AccountLayout.vue';
import { FIELD_LIMITS } from '@/lib/field-limits'; import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form'; import { formErrors } from '@/lib/form';
import { update } from '@/routes/admin/account/profile';
import type { import type {
EnumOption, EnumOption,
ProfileFormData, ProfileFormData,
ProfileUser, ProfileUser,
} from '@/types/account'; } from '@/types/account';
import { update } from '@/routes/admin/account/profile';
const props = defineProps<{ const props = defineProps<{
user: ProfileUser; user: ProfileUser;
@ -45,8 +45,10 @@ const form = useForm<ProfileFormData>({
function submit() { function submit() {
form.put(update.url(), { form.put(update.url(), {
preserveScroll: true, preserveScroll: true,
onError: () => { onError: (errors: any) => {
toast.error('Gagal menyimpan profil. Periksa kembali formulir.'); if (errors.system) {
toast.error(errors.system);
}
}, },
}); });
} }
@ -76,8 +78,8 @@ function submit() {
<FieldLabel for="username" required> <FieldLabel for="username" required>
Username Username
</FieldLabel> </FieldLabel>
<Input id="username" v-model="form.username" type="text" placeholder="Contoh: johndoe" <Input id="username" v-model="form.username" type="text"
:maxlength="FIELD_LIMITS.username" /> placeholder="Contoh: johndoe" :maxlength="FIELD_LIMITS.username" />
<FieldError :errors="formErrors(form, 'username')" /> <FieldError :errors="formErrors(form, 'username')" />
</Field> </Field>
@ -126,8 +128,8 @@ function submit() {
<FieldLabel for="address"> <FieldLabel for="address">
Alamat Alamat
</FieldLabel> </FieldLabel>
<Textarea id="address" v-model="form.address" placeholder="Contoh: Jl. Sudirman No. 123, Jakarta" <Textarea id="address" v-model="form.address"
rows="3" /> placeholder="Contoh: Jl. Sudirman No. 123, Jakarta" rows="3" />
<FieldError :errors="formErrors(form, 'address')" /> <FieldError :errors="formErrors(form, 'address')" />
</Field> </Field>
</FieldGroup> </FieldGroup>

View File

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

View File

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

View File

@ -21,8 +21,8 @@ import {
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { FIELD_LIMITS } from '@/lib/field-limits'; import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form'; import { formErrors } from '@/lib/form';
import type { EmployeeAdvanceListItem } from '@/types/employee-advance';
import { reject } from '@/routes/admin/finance/employee_advances'; import { reject } from '@/routes/admin/finance/employee_advances';
import type { EmployeeAdvanceListItem } from '@/types/employee-advance';
const open = defineModel<boolean>('open', { default: false }); const open = defineModel<boolean>('open', { default: false });
@ -55,8 +55,10 @@ function submit() {
onSuccess: () => { onSuccess: () => {
open.value = false; open.value = false;
}, },
onError: () => { onError: (errors: any) => {
toast.error('Gagal menolak kasbon. Periksa kembali formulir.'); 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 { RowApproveAction, RowDeleteAction, RowEditAction, RowPayAction, RowRejectAction } from '@/components/button';
import ConfirmDialog from '@/components/ConfirmDialog.vue'; import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
import type { EmployeeAdvanceListItem } from '@/types/employee-advance';
import { destroy, approve, pay } from '@/routes/admin/finance/employee_advances'; import { destroy, approve, pay } from '@/routes/admin/finance/employee_advances';
import type { EmployeeAdvanceListItem } from '@/types/employee-advance';
const props = defineProps<{ const props = defineProps<{
employeeAdvance: EmployeeAdvanceListItem; employeeAdvance: EmployeeAdvanceListItem;
@ -52,8 +52,10 @@ function approveEmployeeAdvance() {
onSuccess: () => { onSuccess: () => {
approveConfirmOpen.value = false; approveConfirmOpen.value = false;
}, },
onError: (errors) => { onError: (errors: any) => {
toast.error(errors.amount || errors.employee_advance || 'Gagal menyetujui kasbon.'); if (errors.system) {
toast.error(errors.system);
}
}, },
onFinish: () => { onFinish: () => {
approveProcessing.value = false; approveProcessing.value = false;
@ -69,8 +71,10 @@ function payEmployeeAdvance() {
onSuccess: () => { onSuccess: () => {
payConfirmOpen.value = false; payConfirmOpen.value = false;
}, },
onError: (errors) => { onError: (errors: any) => {
toast.error(errors.amount || errors.employee_advance || 'Gagal melunasi kasbon.'); if (errors.system) {
toast.error(errors.system);
}
}, },
onFinish: () => { onFinish: () => {
payProcessing.value = false; payProcessing.value = false;

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { router, usePage } from '@inertiajs/vue3'; import { router, usePage } from '@inertiajs/vue3';
import { computed, ref } from 'vue'; import { ref } from 'vue';
import { toast } from 'vue-sonner'; import { toast } from 'vue-sonner';
import { import {
AlertDialog, AlertDialog,
@ -35,8 +35,10 @@ function resetPassword() {
onSuccess: () => { onSuccess: () => {
open.value = false; open.value = false;
}, },
onError: () => { onError: (errors: any) => {
toast.error('Gagal mereset kata sandi.'); if (errors.system) {
toast.error(errors.system);
}
}, },
onFinish: () => { onFinish: () => {
processing.value = false; processing.value = false;
@ -58,7 +60,9 @@ function resetPassword() {
<AlertDialogDescription> <AlertDialogDescription>
Kata sandi Kata sandi
<span class="text-foreground font-medium">{{ employeeName ?? 'pegawai' }}</span> <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. semua sesi aktif.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
@ -66,10 +70,7 @@ function resetPassword() {
<AlertDialogCancel :disabled="processing"> <AlertDialogCancel :disabled="processing">
Batal Batal
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction :disabled="processing" @click.prevent="resetPassword">
:disabled="processing"
@click.prevent="resetPassword"
>
{{ processing ? 'Memproses...' : 'Reset Kata Sandi' }} {{ processing ? 'Memproses...' : 'Reset Kata Sandi' }}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>

View File

@ -6,8 +6,8 @@ import { toast } from 'vue-sonner';
import { RowDeleteAction, RowEditAction, RowStatusAction } from '@/components/button'; import { RowDeleteAction, RowEditAction, RowStatusAction } from '@/components/button';
import ConfirmDialog from '@/components/ConfirmDialog.vue'; import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
import type { EmployeeListItem } from '@/types/employee';
import { edit, destroy, reset_password } from '@/routes/admin/hr/employees'; import { edit, destroy, reset_password } from '@/routes/admin/hr/employees';
import type { EmployeeListItem } from '@/types/employee';
const props = defineProps<{ const props = defineProps<{
employee: EmployeeListItem; employee: EmployeeListItem;
@ -28,8 +28,10 @@ function resetPassword() {
onSuccess: () => { onSuccess: () => {
resetPasswordConfirmOpen.value = false; resetPasswordConfirmOpen.value = false;
}, },
onError: () => { onError: (errors: any) => {
toast.error('Gagal mereset kata sandi.'); if (errors.system) {
toast.error(errors.system);
}
}, },
onFinish: () => { onFinish: () => {
resetPasswordProcessing.value = false; resetPasswordProcessing.value = false;
@ -50,9 +52,7 @@ function resetPassword() {
error-message="Gagal menghapus pegawai." /> error-message="Gagal menghapus pegawai." />
</div> </div>
<ConfirmDialog <ConfirmDialog v-if="can('employees.reset_password')" v-model:open="resetPasswordConfirmOpen"
v-if="can('employees.reset_password')"
v-model:open="resetPasswordConfirmOpen"
title="Reset kata sandi pegawai?" 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.`" :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" 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 { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
import type { EmployeeListItem } from '@/types/employee';
import { toggle_status } from '@/routes/admin/hr/employees'; import { toggle_status } from '@/routes/admin/hr/employees';
import type { EmployeeListItem } from '@/types/employee';
const props = defineProps<{ const props = defineProps<{
employee: EmployeeListItem; employee: EmployeeListItem;
@ -37,9 +37,12 @@ function toggleStatus(checked: boolean) {
is_active: checked, is_active: checked,
}, { }, {
preserveScroll: true, preserveScroll: true,
onError: () => { onError: (errors: any) => {
isActive.value = previous; isActive.value = previous;
toast.error('Gagal memperbarui status pegawai.');
if (errors.system) {
toast.error(errors.system);
}
}, },
onFinish: () => { onFinish: () => {
processing.value = false; processing.value = false;
@ -50,11 +53,8 @@ function toggleStatus(checked: boolean) {
<template> <template>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Switch <Switch :model-value="isActive" :disabled="processing || !can('employees.toggle_status')"
:model-value="isActive" @update:model-value="toggleStatus" />
:disabled="processing || !can('employees.toggle_status')"
@update:model-value="toggleStatus"
/>
<Badge :variant="isActive ? 'default' : 'secondary'"> <Badge :variant="isActive ? 'default' : 'secondary'">
{{ isActive ? 'Aktif' : 'Nonaktif' }} {{ isActive ? 'Aktif' : 'Nonaktif' }}
</Badge> </Badge>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -131,8 +131,10 @@ function buildFormData(): FormData {
function submit() { function submit() {
const options = { const options = {
forceFormData: true, forceFormData: true,
onError: () => { onError: (errors: any) => {
toast.error('Gagal menyimpan data. Periksa kembali formulir.'); 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 { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
import type { ProductListItem } from '@/types/product';
import { toggle_status } from '@/routes/admin/master/products'; import { toggle_status } from '@/routes/admin/master/products';
import type { ProductListItem } from '@/types/product';
const props = defineProps<{ const props = defineProps<{
product: ProductListItem; product: ProductListItem;
@ -37,9 +37,12 @@ function toggleStatus(checked: boolean) {
is_active: checked, is_active: checked,
}, { }, {
preserveScroll: true, preserveScroll: true,
onError: () => { onError: (errors: any) => {
isActive.value = previous; isActive.value = previous;
toast.error('Gagal memperbarui status produk.');
if (errors.system) {
toast.error(errors.system);
}
}, },
onFinish: () => { onFinish: () => {
processing.value = false; processing.value = false;

View File

@ -187,8 +187,10 @@ function buildFormData(): FormData {
function submit() { function submit() {
const options = { const options = {
forceFormData: true, forceFormData: true,
onError: () => { onError: (errors: any) => {
toast.error('Gagal menyimpan data. Periksa kembali formulir.'); 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 { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
import type { RawMaterialListItem } from '@/types/raw-material';
import { toggle_status } from '@/routes/admin/master/raw_materials'; import { toggle_status } from '@/routes/admin/master/raw_materials';
import type { RawMaterialListItem } from '@/types/raw-material';
const props = defineProps<{ const props = defineProps<{
material: RawMaterialListItem; material: RawMaterialListItem;
@ -37,9 +37,12 @@ function toggleStatus(checked: boolean) {
is_active: checked, is_active: checked,
}, { }, {
preserveScroll: true, preserveScroll: true,
onError: () => { onError: (errors: any) => {
isActive.value = previous; isActive.value = previous;
toast.error('Gagal memperbarui status bahan baku.');
if (errors.system) {
toast.error(errors.system);
}
}, },
onFinish: () => { onFinish: () => {
processing.value = false; processing.value = false;

View File

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

View File

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

View File

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

View File

@ -15,12 +15,12 @@ import {
import { MarketplacePlatform as MarketplacePlatformConst } from '@/constants/marketplace-platform'; import { MarketplacePlatform as MarketplacePlatformConst } from '@/constants/marketplace-platform';
import { formErrors } from '@/lib/form'; import { formErrors } from '@/lib/form';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { update } from '@/routes/admin/system/settings/marketplace';
import type { import type {
MarketplacePlatform, MarketplacePlatform,
MarketplaceSettingsData, MarketplaceSettingsData,
} from '@/types/setting'; } from '@/types/setting';
import MarketplaceFeeField from './MarketplaceFeeField.vue'; import MarketplaceFeeField from './MarketplaceFeeField.vue';
import { update } from '@/routes/admin/system/settings/marketplace';
const props = defineProps<{ const props = defineProps<{
data: MarketplaceSettingsData; data: MarketplaceSettingsData;
@ -67,8 +67,10 @@ function feeErrors(key: string): string[] {
function submit() { function submit() {
form.put(update.url(), { form.put(update.url(), {
preserveScroll: true, preserveScroll: true,
onError: () => { onError: (errors: any) => {
toast.error('Gagal menyimpan pengaturan marketplace.'); 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 { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from '@/components/ui/field';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { formErrors } from '@/lib/form'; import { formErrors } from '@/lib/form';
import type { SocialMediaSettingsData } from '@/types/setting';
import { update } from '@/routes/admin/system/settings/social_media'; import { update } from '@/routes/admin/system/settings/social_media';
import type { SocialMediaSettingsData } from '@/types/setting';
const props = defineProps<{ const props = defineProps<{
data: SocialMediaSettingsData; data: SocialMediaSettingsData;
@ -23,8 +23,10 @@ const form = useForm({
function submit() { function submit() {
form.put(update.url(), { form.put(update.url(), {
preserveScroll: true, preserveScroll: true,
onError: () => { onError: (errors: any) => {
toast.error('Gagal menyimpan pengaturan media sosial.'); 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 { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { formErrors } from '@/lib/form'; import { formErrors } from '@/lib/form';
import { update } from '@/routes/admin/system/settings/system';
import { createMediaUploadState } from '@/types/media'; import { createMediaUploadState } from '@/types/media';
import type { MediaUploadState } from '@/types/media'; import type { MediaUploadState } from '@/types/media';
import type { SystemSettingsData } from '@/types/setting'; import type { SystemSettingsData } from '@/types/setting';
import { update } from '@/routes/admin/system/settings/system';
const props = defineProps<{ const props = defineProps<{
data: SystemSettingsData; data: SystemSettingsData;
@ -61,8 +61,10 @@ function submit() {
form.transform(() => payload).put(update.url(), { form.transform(() => payload).put(update.url(), {
preserveScroll: true, preserveScroll: true,
onError: () => { onError: (errors: any) => {
toast.error('Gagal menyimpan pengaturan sistem.'); if (errors.system) {
toast.error(errors.system);
}
}, },
}); });
} }