refactor: enhance error handling in form submissions across various Vue components to improve user feedback and maintainability
This commit is contained in:
parent
58ba7512a5
commit
6219f5032d
@ -33,13 +33,10 @@ const form = useForm({
|
||||
|
||||
function submit() {
|
||||
form.post('/auth/login', {
|
||||
onError: (errors) => {
|
||||
const message =
|
||||
errors.login ??
|
||||
errors.password ??
|
||||
'Gagal masuk. Periksa kembali data Anda.';
|
||||
|
||||
toast.error(message);
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -66,8 +66,10 @@ function onAppearanceChange(value: AcceptableValue) {
|
||||
|
||||
function submit() {
|
||||
form.put(update.url(), {
|
||||
onError: () => {
|
||||
toast.error('Gagal menyimpan tampilan.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -15,9 +15,9 @@ import {
|
||||
import { InputGroup, InputGroupInput, InputGroupAddon, InputGroupButton } from '@/components/ui/input-group';
|
||||
import AccountLayout from '@/layouts/AccountLayout.vue';
|
||||
import { formErrors } from '@/lib/form';
|
||||
import { update } from '@/routes/admin/account/password';
|
||||
import type { PasswordFormData } from '@/types/account';
|
||||
|
||||
import { update } from '@/routes/admin/account/password';
|
||||
|
||||
const form = useForm<PasswordFormData>({
|
||||
current_password: '',
|
||||
@ -34,8 +34,10 @@ function submit() {
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Gagal memperbarui kata sandi. Periksa kembali formulir.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -19,13 +19,13 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import AccountLayout from '@/layouts/AccountLayout.vue';
|
||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||
import { formErrors } from '@/lib/form';
|
||||
import { update } from '@/routes/admin/account/profile';
|
||||
import type {
|
||||
EnumOption,
|
||||
ProfileFormData,
|
||||
ProfileUser,
|
||||
} from '@/types/account';
|
||||
|
||||
import { update } from '@/routes/admin/account/profile';
|
||||
|
||||
const props = defineProps<{
|
||||
user: ProfileUser;
|
||||
@ -45,8 +45,10 @@ const form = useForm<ProfileFormData>({
|
||||
function submit() {
|
||||
form.put(update.url(), {
|
||||
preserveScroll: true,
|
||||
onError: () => {
|
||||
toast.error('Gagal menyimpan profil. Periksa kembali formulir.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -76,8 +78,8 @@ function submit() {
|
||||
<FieldLabel for="username" required>
|
||||
Username
|
||||
</FieldLabel>
|
||||
<Input id="username" v-model="form.username" type="text" placeholder="Contoh: johndoe"
|
||||
:maxlength="FIELD_LIMITS.username" />
|
||||
<Input id="username" v-model="form.username" type="text"
|
||||
placeholder="Contoh: johndoe" :maxlength="FIELD_LIMITS.username" />
|
||||
<FieldError :errors="formErrors(form, 'username')" />
|
||||
</Field>
|
||||
|
||||
@ -126,8 +128,8 @@ function submit() {
|
||||
<FieldLabel for="address">
|
||||
Alamat
|
||||
</FieldLabel>
|
||||
<Textarea id="address" v-model="form.address" placeholder="Contoh: Jl. Sudirman No. 123, Jakarta"
|
||||
rows="3" />
|
||||
<Textarea id="address" v-model="form.address"
|
||||
placeholder="Contoh: Jl. Sudirman No. 123, Jakarta" rows="3" />
|
||||
<FieldError :errors="formErrors(form, 'address')" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
@ -26,11 +26,11 @@ import { CashTransactionType } from '@/constants/cash-transaction-type';
|
||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||
import { formErrors } from '@/lib/form';
|
||||
import { parseRupiah } from '@/lib/rupiah';
|
||||
import { withdraw, deposit } from '@/routes/admin/finance/cash';
|
||||
import { update } from '@/routes/admin/finance/cash/transactions';
|
||||
import type { CashTransactionListItem } from '@/types/cash';
|
||||
import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media';
|
||||
import type { MediaUploadState } from '@/types/media';
|
||||
import { withdraw, deposit } from '@/routes/admin/finance/cash';
|
||||
import { update } from '@/routes/admin/finance/cash/transactions';
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
@ -105,10 +105,10 @@ function submit() {
|
||||
onSuccess: () => {
|
||||
open.value = false;
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(isEditing.value
|
||||
? 'Gagal memperbarui transaksi kas. Periksa kembali formulir.'
|
||||
: 'Gagal mencatat transaksi kas. Periksa kembali formulir.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -25,8 +25,8 @@ import { useFormDialog } from '@/composables/useFormDialog';
|
||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||
import { formErrors } from '@/lib/form';
|
||||
import { parseRupiah } from '@/lib/rupiah';
|
||||
import type { EmployeeAdvanceFormData, EmployeeAdvanceListItem } from '@/types/employee-advance';
|
||||
import { store, update } from '@/routes/admin/finance/employee_advances';
|
||||
import type { EmployeeAdvanceFormData, EmployeeAdvanceListItem } from '@/types/employee-advance';
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
@ -89,8 +89,10 @@ function submit() {
|
||||
onSuccess: () => {
|
||||
open.value = false;
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -21,8 +21,8 @@ import {
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||
import { formErrors } from '@/lib/form';
|
||||
import type { EmployeeAdvanceListItem } from '@/types/employee-advance';
|
||||
import { reject } from '@/routes/admin/finance/employee_advances';
|
||||
import type { EmployeeAdvanceListItem } from '@/types/employee-advance';
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
@ -55,8 +55,10 @@ function submit() {
|
||||
onSuccess: () => {
|
||||
open.value = false;
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Gagal menolak kasbon. Periksa kembali formulir.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -5,8 +5,8 @@ import { toast } from 'vue-sonner';
|
||||
import { RowApproveAction, RowDeleteAction, RowEditAction, RowPayAction, RowRejectAction } from '@/components/button';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import type { EmployeeAdvanceListItem } from '@/types/employee-advance';
|
||||
import { destroy, approve, pay } from '@/routes/admin/finance/employee_advances';
|
||||
import type { EmployeeAdvanceListItem } from '@/types/employee-advance';
|
||||
|
||||
const props = defineProps<{
|
||||
employeeAdvance: EmployeeAdvanceListItem;
|
||||
@ -52,8 +52,10 @@ function approveEmployeeAdvance() {
|
||||
onSuccess: () => {
|
||||
approveConfirmOpen.value = false;
|
||||
},
|
||||
onError: (errors) => {
|
||||
toast.error(errors.amount || errors.employee_advance || 'Gagal menyetujui kasbon.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
onFinish: () => {
|
||||
approveProcessing.value = false;
|
||||
@ -69,8 +71,10 @@ function payEmployeeAdvance() {
|
||||
onSuccess: () => {
|
||||
payConfirmOpen.value = false;
|
||||
},
|
||||
onError: (errors) => {
|
||||
toast.error(errors.amount || errors.employee_advance || 'Gagal melunasi kasbon.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
onFinish: () => {
|
||||
payProcessing.value = false;
|
||||
|
||||
@ -25,11 +25,11 @@ import { useFormDialog } from '@/composables/useFormDialog';
|
||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||
import { formErrors } from '@/lib/form';
|
||||
import { parseRupiah } from '@/lib/rupiah';
|
||||
import { store, update } from '@/routes/admin/finance/expenses';
|
||||
import type { ExpenseListItem } from '@/types/expense';
|
||||
import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media';
|
||||
import type { MediaUploadState } from '@/types/media';
|
||||
|
||||
import { store, update } from '@/routes/admin/finance/expenses';
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
@ -95,8 +95,10 @@ function submit() {
|
||||
onSuccess: () => {
|
||||
open.value = false;
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -4,7 +4,6 @@ import { Save } from '@lucide/vue';
|
||||
import { ref, watch } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { RowDeleteAction, RowEditAction } from '@/components/button';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@ -26,8 +25,8 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||
import { formErrors } from '@/lib/form';
|
||||
import { parseRupiah } from '@/lib/rupiah';
|
||||
import type { PayrollAdjustmentFormData, PayrollAdjustmentItem, PayrollListItem, SelectOption } from '@/types/payroll';
|
||||
import { store, update, destroy } from '@/routes/admin/finance/payroll/adjustments';
|
||||
import type { PayrollAdjustmentFormData, PayrollAdjustmentItem, PayrollListItem, SelectOption } from '@/types/payroll';
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
@ -86,8 +85,10 @@ function submit() {
|
||||
onSuccess: () => {
|
||||
cancelEdit();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Gagal memperbarui penyesuaian.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
@ -96,8 +97,10 @@ function submit() {
|
||||
onSuccess: () => {
|
||||
resetForm();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Gagal menambahkan penyesuaian.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -136,7 +139,8 @@ function submit() {
|
||||
tooltip-class="z-210" @click="startEdit(adj)" />
|
||||
<RowDeleteAction :action-url="destroy.url(adj.id)" tooltip="Hapus"
|
||||
button-class="hover:bg-destructive/10" tooltip-class="z-210"
|
||||
title="Hapus penyesuaian?" :description="`Penyesuaian ${adj.type_label} ${adj.amount_formatted} akan dihapus.`"
|
||||
title="Hapus penyesuaian?"
|
||||
:description="`Penyesuaian ${adj.type_label} ${adj.amount_formatted} akan dihapus.`"
|
||||
error-message="Gagal menghapus penyesuaian."
|
||||
:on-success="() => toast.success('Penyesuaian berhasil dihapus.')" />
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { Camera, Loader2 } from '@lucide/vue';
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
@ -9,12 +13,8 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { useGeolocation } from '@/composables/useGeolocation';
|
||||
import { useWebcamCapture } from '@/composables/useWebcamCapture';
|
||||
import type { AttendanceCaptureFormData } from '@/types/attendance';
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { Camera, Loader2 } from '@lucide/vue';
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { check_in, check_out } from '@/routes/admin/hr/attendances';
|
||||
import type { AttendanceCaptureFormData } from '@/types/attendance';
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
@ -124,8 +124,10 @@ function submitAttendance() {
|
||||
onSuccess: () => {
|
||||
open.value = false;
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Gagal menyimpan presensi.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -162,69 +164,37 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div v-if="!previewPhoto" class="space-y-2">
|
||||
<p class="text-sm font-medium">Kamera</p>
|
||||
<div
|
||||
class="relative aspect-4/3 overflow-hidden rounded-lg border bg-muted"
|
||||
>
|
||||
<video
|
||||
ref="videoRef"
|
||||
class="size-full scale-x-[-1] object-cover"
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
/>
|
||||
<div class="relative aspect-4/3 overflow-hidden rounded-lg border bg-muted">
|
||||
<video ref="videoRef" class="size-full scale-x-[-1] object-cover" autoplay muted playsinline />
|
||||
|
||||
<div
|
||||
v-if="cameraLoading"
|
||||
class="absolute inset-0 flex items-center justify-center bg-background/80"
|
||||
>
|
||||
<Loader2
|
||||
class="size-8 animate-spin text-muted-foreground"
|
||||
/>
|
||||
<div v-if="cameraLoading"
|
||||
class="absolute inset-0 flex items-center justify-center bg-background/80">
|
||||
<Loader2 class="size-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<p class="text-sm font-medium">Hasil Foto</p>
|
||||
<div
|
||||
class="relative aspect-4/3 overflow-hidden rounded-lg border bg-muted"
|
||||
>
|
||||
<div class="relative aspect-4/3 overflow-hidden rounded-lg border bg-muted">
|
||||
<img :src="previewPhoto" alt="Hasil foto presensi" class="size-full object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
v-if="previewPhoto"
|
||||
type="button"
|
||||
variant="outline"
|
||||
@click="retakePhoto"
|
||||
>
|
||||
<Button v-if="previewPhoto" type="button" variant="outline" @click="retakePhoto">
|
||||
Ambil Ulang
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="!previewPhoto"
|
||||
type="button"
|
||||
:disabled="!cameraReady || capturing"
|
||||
@click="capturePhoto"
|
||||
>
|
||||
<Button v-if="!previewPhoto" type="button" :disabled="!cameraReady || capturing" @click="capturePhoto">
|
||||
<Loader2 v-if="capturing" class="size-4 animate-spin" />
|
||||
<Camera v-else class="size-4" />
|
||||
Ambil Foto
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-else
|
||||
type="button"
|
||||
:disabled="form.processing"
|
||||
@click="submitAttendance"
|
||||
>
|
||||
<Loader2
|
||||
v-if="form.processing"
|
||||
class="size-4 animate-spin"
|
||||
/>
|
||||
<Button v-else type="button" :disabled="form.processing" @click="submitAttendance">
|
||||
<Loader2 v-if="form.processing" class="size-4 animate-spin" />
|
||||
{{ submitLabel }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@ -70,8 +70,10 @@ function submit() {
|
||||
}));
|
||||
|
||||
const options = {
|
||||
onError: () => {
|
||||
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { router, usePage } from '@inertiajs/vue3';
|
||||
import { computed, ref } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import {
|
||||
AlertDialog,
|
||||
@ -35,8 +35,10 @@ function resetPassword() {
|
||||
onSuccess: () => {
|
||||
open.value = false;
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Gagal mereset kata sandi.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
onFinish: () => {
|
||||
processing.value = false;
|
||||
@ -58,7 +60,9 @@ function resetPassword() {
|
||||
<AlertDialogDescription>
|
||||
Kata sandi
|
||||
<span class="text-foreground font-medium">{{ employeeName ?? 'pegawai' }}</span>
|
||||
akan direset ke kata sandi default sistem (<span class="font-mono bg-muted px-1.5 py-0.5 rounded text-foreground font-medium">{{ defaultPassword }}</span>). Pengguna akan otomatis logout dari
|
||||
akan direset ke kata sandi default sistem (<span
|
||||
class="font-mono bg-muted px-1.5 py-0.5 rounded text-foreground font-medium">{{ defaultPassword
|
||||
}}</span>). Pengguna akan otomatis logout dari
|
||||
semua sesi aktif.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
@ -66,10 +70,7 @@ function resetPassword() {
|
||||
<AlertDialogCancel :disabled="processing">
|
||||
Batal
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
:disabled="processing"
|
||||
@click.prevent="resetPassword"
|
||||
>
|
||||
<AlertDialogAction :disabled="processing" @click.prevent="resetPassword">
|
||||
{{ processing ? 'Memproses...' : 'Reset Kata Sandi' }}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
||||
@ -6,8 +6,8 @@ import { toast } from 'vue-sonner';
|
||||
import { RowDeleteAction, RowEditAction, RowStatusAction } from '@/components/button';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import type { EmployeeListItem } from '@/types/employee';
|
||||
import { edit, destroy, reset_password } from '@/routes/admin/hr/employees';
|
||||
import type { EmployeeListItem } from '@/types/employee';
|
||||
|
||||
const props = defineProps<{
|
||||
employee: EmployeeListItem;
|
||||
@ -28,8 +28,10 @@ function resetPassword() {
|
||||
onSuccess: () => {
|
||||
resetPasswordConfirmOpen.value = false;
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Gagal mereset kata sandi.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
onFinish: () => {
|
||||
resetPasswordProcessing.value = false;
|
||||
@ -50,9 +52,7 @@ function resetPassword() {
|
||||
error-message="Gagal menghapus pegawai." />
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
v-if="can('employees.reset_password')"
|
||||
v-model:open="resetPasswordConfirmOpen"
|
||||
<ConfirmDialog v-if="can('employees.reset_password')" v-model:open="resetPasswordConfirmOpen"
|
||||
title="Reset kata sandi pegawai?"
|
||||
:description="`Kata sandi ${employee.profile?.full_name ?? 'pegawai'} akan direset ke kata sandi default sistem (${defaultPassword}). Pengguna akan otomatis logout dari semua sesi aktif.`"
|
||||
confirm-label="Reset Kata Sandi" cancel-label="Batal" :loading="resetPasswordProcessing"
|
||||
|
||||
@ -5,8 +5,8 @@ import { toast } from 'vue-sonner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import type { EmployeeListItem } from '@/types/employee';
|
||||
import { toggle_status } from '@/routes/admin/hr/employees';
|
||||
import type { EmployeeListItem } from '@/types/employee';
|
||||
|
||||
const props = defineProps<{
|
||||
employee: EmployeeListItem;
|
||||
@ -37,9 +37,12 @@ function toggleStatus(checked: boolean) {
|
||||
is_active: checked,
|
||||
}, {
|
||||
preserveScroll: true,
|
||||
onError: () => {
|
||||
onError: (errors: any) => {
|
||||
isActive.value = previous;
|
||||
toast.error('Gagal memperbarui status pegawai.');
|
||||
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
onFinish: () => {
|
||||
processing.value = false;
|
||||
@ -50,11 +53,8 @@ function toggleStatus(checked: boolean) {
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch
|
||||
:model-value="isActive"
|
||||
:disabled="processing || !can('employees.toggle_status')"
|
||||
@update:model-value="toggleStatus"
|
||||
/>
|
||||
<Switch :model-value="isActive" :disabled="processing || !can('employees.toggle_status')"
|
||||
@update:model-value="toggleStatus" />
|
||||
<Badge :variant="isActive ? 'default' : 'secondary'">
|
||||
{{ isActive ? 'Aktif' : 'Nonaktif' }}
|
||||
</Badge>
|
||||
|
||||
@ -21,8 +21,8 @@ import {
|
||||
} from '@/components/ui/field';
|
||||
import { useFormDialog } from '@/composables/useFormDialog';
|
||||
import { formErrors } from '@/lib/form';
|
||||
import type { LeaveRequestFormData, LeaveRequestListItem } from '@/types/leave-request';
|
||||
import { store, update } from '@/routes/admin/hr/leave_requests';
|
||||
import type { LeaveRequestFormData, LeaveRequestListItem } from '@/types/leave-request';
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
@ -66,8 +66,10 @@ function submit() {
|
||||
onSuccess: () => {
|
||||
open.value = false;
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -21,8 +21,8 @@ import {
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||
import { formErrors } from '@/lib/form';
|
||||
import type { LeaveRequestListItem } from '@/types/leave-request';
|
||||
import { reject } from '@/routes/admin/hr/leave_requests';
|
||||
import type { LeaveRequestListItem } from '@/types/leave-request';
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
@ -55,8 +55,10 @@ function submit() {
|
||||
onSuccess: () => {
|
||||
open.value = false;
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Gagal menolak pengajuan cuti. Periksa kembali formulir.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -74,8 +76,9 @@ function submit() {
|
||||
<FieldSet class="grid gap-4">
|
||||
<Field>
|
||||
<FieldLabel for="reject-reason" required>Alasan Penolakan</FieldLabel>
|
||||
<Textarea id="reject-reason" v-model="form.reason" placeholder="Contoh: Kuota cuti untuk bulan ini sudah habis"
|
||||
rows="3" autofocus :maxlength="FIELD_LIMITS.reason" />
|
||||
<Textarea id="reject-reason" v-model="form.reason"
|
||||
placeholder="Contoh: Kuota cuti untuk bulan ini sudah habis" rows="3" autofocus
|
||||
:maxlength="FIELD_LIMITS.reason" />
|
||||
<FieldError :errors="formErrors(form, 'reason')" />
|
||||
</Field>
|
||||
</FieldSet>
|
||||
|
||||
@ -5,8 +5,8 @@ import { toast } from 'vue-sonner';
|
||||
import { RowApproveAction, RowDeleteAction, RowEditAction, RowRejectAction } from '@/components/button';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import type { LeaveRequestListItem } from '@/types/leave-request';
|
||||
import { destroy, approve } from '@/routes/admin/hr/leave_requests';
|
||||
import type { LeaveRequestListItem } from '@/types/leave-request';
|
||||
|
||||
const props = defineProps<{
|
||||
leaveRequest: LeaveRequestListItem;
|
||||
@ -50,8 +50,10 @@ function approveLeaveRequest() {
|
||||
onSuccess: () => {
|
||||
approveConfirmOpen.value = false;
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Gagal menyetujui pengajuan cuti.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
onFinish: () => {
|
||||
approveProcessing.value = false;
|
||||
|
||||
@ -36,6 +36,8 @@ import { getFirstCoverImage } from '@/lib/catalog-cover';
|
||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||
import { formErrors } from '@/lib/form';
|
||||
import { formatRupiah, parseRupiah } from '@/lib/rupiah';
|
||||
import draft_materials from '@/routes/admin/manage/cuttings/draft_materials';
|
||||
import draft_results from '@/routes/admin/manage/cuttings/draft_results';
|
||||
import type {
|
||||
CuttingMaterialCartItem,
|
||||
CuttingProductCatalogItem,
|
||||
@ -44,8 +46,6 @@ import type {
|
||||
} from '@/types/cutting';
|
||||
import PosCatalogCard from '../../shared/PosCatalogCard.vue';
|
||||
import PosCatalogVariantThumb from '../../shared/PosCatalogVariantThumb.vue';
|
||||
import draft_materials from '@/routes/admin/manage/cuttings/draft_materials';
|
||||
import draft_results from '@/routes/admin/manage/cuttings/draft_results';
|
||||
|
||||
const props = defineProps<{
|
||||
rawMaterialCatalog: CuttingRawMaterialCatalogItem[];
|
||||
@ -127,17 +127,6 @@ function materialLineCost(item: CuttingMaterialCartItem): number {
|
||||
return Math.round(usage * catalogPrice.price);
|
||||
}
|
||||
|
||||
const sewingCostAmount = computed(
|
||||
() => Number.parseInt(parseRupiah(form.sewing_cost), 10) || 0,
|
||||
);
|
||||
const otherCostAmount = computed(
|
||||
() => Number.parseInt(parseRupiah(form.other_cost), 10) || 0,
|
||||
);
|
||||
|
||||
const totalProductionCost = computed(
|
||||
() => totalMaterialCost.value + sewingCostAmount.value + otherCostAmount.value,
|
||||
);
|
||||
|
||||
const estimatedCostPerUnit = computed(() => {
|
||||
if (totalResultPieces.value <= 0) {
|
||||
return 0;
|
||||
@ -212,7 +201,7 @@ const filteredProducts = computed(() => {
|
||||
return props.productCatalog.filter(
|
||||
(product) =>
|
||||
product.name.toLowerCase().includes(keyword) ||
|
||||
product.variants.some((variant) =>
|
||||
product.variants.some((variant: any) =>
|
||||
variant.name.toLowerCase().includes(keyword),
|
||||
),
|
||||
);
|
||||
@ -594,13 +583,10 @@ function submit() {
|
||||
|
||||
if (props.method === 'put') {
|
||||
form.transform(() => payload).put(props.submitUrl, {
|
||||
onError: (errors) => {
|
||||
const message = Object.values(errors)[0];
|
||||
toast.error(
|
||||
typeof message === 'string'
|
||||
? message
|
||||
: 'Gagal menyimpan data. Periksa kembali formulir.',
|
||||
);
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@ -608,13 +594,10 @@ function submit() {
|
||||
}
|
||||
|
||||
form.transform(() => payload).post(props.submitUrl, {
|
||||
onError: (errors) => {
|
||||
const message = Object.values(errors)[0];
|
||||
toast.error(
|
||||
typeof message === 'string'
|
||||
? message
|
||||
: 'Gagal menyimpan data. Periksa kembali formulir.',
|
||||
);
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -630,26 +613,15 @@ function submit() {
|
||||
<CardContent>
|
||||
<div class="space-y-4">
|
||||
<div class="relative">
|
||||
<Search
|
||||
class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
v-model="materialSearch"
|
||||
placeholder="Cari bahan baku..."
|
||||
class="pl-9"
|
||||
/>
|
||||
<Search class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input v-model="materialSearch" placeholder="Cari bahan baku..." class="pl-9" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="filteredRawMaterials.length === 0"
|
||||
class="py-8"
|
||||
>
|
||||
<div v-if="filteredRawMaterials.length === 0" class="py-8">
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle
|
||||
>Tidak ada bahan baku
|
||||
ditemukan</EmptyTitle
|
||||
>
|
||||
<EmptyTitle>Tidak ada bahan baku
|
||||
ditemukan</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Silakan lakukan pencarian untuk
|
||||
menemukan bahan baku.
|
||||
@ -659,97 +631,60 @@ function submit() {
|
||||
</div>
|
||||
|
||||
<div v-else class="columns-1 gap-4 sm:columns-2">
|
||||
<PosCatalogCard
|
||||
v-for="rawMaterial in filteredRawMaterials"
|
||||
:key="rawMaterial.id"
|
||||
:title="rawMaterial.name"
|
||||
:cover-image="
|
||||
getFirstCoverImage(rawMaterial.prices)
|
||||
"
|
||||
>
|
||||
<PosCatalogCard v-for="rawMaterial in filteredRawMaterials" :key="rawMaterial.id"
|
||||
:title="rawMaterial.name" :cover-image="getFirstCoverImage(rawMaterial.prices)
|
||||
">
|
||||
<template #header-extra>
|
||||
<Badge variant="secondary" class="mt-1.5">
|
||||
{{ rawMaterial.unit_label }}
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<p
|
||||
v-if="!rawMaterial.prices.length"
|
||||
class="px-3 py-4 text-sm text-muted-foreground"
|
||||
>
|
||||
<p v-if="!rawMaterial.prices.length" class="px-3 py-4 text-sm text-muted-foreground">
|
||||
Belum ada varian
|
||||
</p>
|
||||
<div
|
||||
v-for="price in rawMaterial.prices"
|
||||
:key="price.id"
|
||||
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200"
|
||||
:class="[
|
||||
<div v-for="price in rawMaterial.prices" :key="price.id"
|
||||
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200" :class="[
|
||||
'cursor-pointer hover:bg-muted/30',
|
||||
getMaterialCartItem(price.id)
|
||||
? 'mx-1 my-0.5 rounded-md border-2 border-primary bg-primary/5'
|
||||
: '',
|
||||
]"
|
||||
@click="
|
||||
]" @click="
|
||||
!getMaterialCartItem(price.id) &&
|
||||
addMaterial(rawMaterial, price)
|
||||
"
|
||||
>
|
||||
<PosCatalogVariantThumb
|
||||
:items="price.images"
|
||||
/>
|
||||
">
|
||||
<PosCatalogVariantThumb :items="price.images" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium">
|
||||
{{ price.variant }}
|
||||
</p>
|
||||
<p
|
||||
class="text-xs text-muted-foreground tabular-nums"
|
||||
>
|
||||
<p class="text-xs text-muted-foreground tabular-nums">
|
||||
Stok: {{ price.stock_formatted }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="getMaterialCartItem(price.id)"
|
||||
class="flex shrink-0 items-center gap-1.5"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
@click.stop="
|
||||
decreaseMaterialQty(price.id)
|
||||
"
|
||||
>
|
||||
<div v-if="getMaterialCartItem(price.id)"
|
||||
class="flex shrink-0 items-center gap-1.5">
|
||||
<Button type="button" variant="outline" size="icon-sm" @click.stop="
|
||||
decreaseMaterialQty(price.id)
|
||||
">
|
||||
<Minus class="size-3.5" />
|
||||
</Button>
|
||||
<span
|
||||
class="min-w-[1.25rem] text-center text-xs font-semibold tabular-nums"
|
||||
>
|
||||
<span class="min-w-[1.25rem] text-center text-xs font-semibold tabular-nums">
|
||||
{{
|
||||
getMaterialCartItem(price.id)!
|
||||
.material_usage
|
||||
}}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
@click.stop="
|
||||
addMaterial(rawMaterial, price)
|
||||
"
|
||||
>
|
||||
<Button type="button" variant="outline" size="icon-sm" @click.stop="
|
||||
addMaterial(rawMaterial, price)
|
||||
">
|
||||
<Plus class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
v-else
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
class="shrink-0"
|
||||
<Button v-else type="button" variant="outline" size="icon-sm" class="shrink-0"
|
||||
@click.stop="
|
||||
addMaterial(rawMaterial, price)
|
||||
"
|
||||
>
|
||||
">
|
||||
<Plus class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@ -766,22 +701,14 @@ function submit() {
|
||||
<CardContent>
|
||||
<div class="space-y-4">
|
||||
<div class="relative">
|
||||
<Search
|
||||
class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
v-model="productSearch"
|
||||
placeholder="Cari produk..."
|
||||
class="pl-9"
|
||||
/>
|
||||
<Search class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input v-model="productSearch" placeholder="Cari produk..." class="pl-9" />
|
||||
</div>
|
||||
|
||||
<div v-if="filteredProducts.length === 0" class="py-8">
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle
|
||||
>Tidak ada produk ditemukan</EmptyTitle
|
||||
>
|
||||
<EmptyTitle>Tidak ada produk ditemukan</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Silakan lakukan pencarian untuk
|
||||
menemukan produk.
|
||||
@ -791,94 +718,55 @@ function submit() {
|
||||
</div>
|
||||
|
||||
<div v-else class="columns-1 gap-4 sm:columns-2">
|
||||
<PosCatalogCard
|
||||
v-for="product in filteredProducts"
|
||||
:key="product.id"
|
||||
:title="product.name"
|
||||
:cover-image="
|
||||
getFirstCoverImage(product.variants)
|
||||
"
|
||||
>
|
||||
<p
|
||||
v-if="!product.variants.length"
|
||||
class="px-3 py-4 text-sm text-muted-foreground"
|
||||
>
|
||||
<PosCatalogCard v-for="product in filteredProducts" :key="product.id" :title="product.name"
|
||||
:cover-image="getFirstCoverImage(product.variants)
|
||||
">
|
||||
<p v-if="!product.variants.length" class="px-3 py-4 text-sm text-muted-foreground">
|
||||
Belum ada varian
|
||||
</p>
|
||||
<div
|
||||
v-for="variant in product.variants"
|
||||
:key="variant.id"
|
||||
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200"
|
||||
:class="[
|
||||
<div v-for="variant in product.variants" :key="variant.id"
|
||||
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200" :class="[
|
||||
'cursor-pointer hover:bg-muted/30',
|
||||
getResultCartItem(variant.id)
|
||||
? 'mx-1 my-0.5 rounded-md border-2 border-primary bg-primary/5'
|
||||
: '',
|
||||
]"
|
||||
@click="
|
||||
]" @click="
|
||||
!getResultCartItem(variant.id) &&
|
||||
addResult(product, variant)
|
||||
"
|
||||
>
|
||||
<PosCatalogVariantThumb
|
||||
:items="variant.images"
|
||||
/>
|
||||
">
|
||||
<PosCatalogVariantThumb :items="variant.images" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium">
|
||||
{{ variant.name }}
|
||||
</p>
|
||||
<p
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
<span class="tabular-nums"
|
||||
>Stok:
|
||||
{{ variant.stock }} pcs</span
|
||||
>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
<span class="tabular-nums">Stok:
|
||||
{{ variant.stock }} pcs</span>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="getResultCartItem(variant.id)"
|
||||
class="flex shrink-0 items-center gap-1.5"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
@click.stop="
|
||||
decreaseResultQty(variant.id)
|
||||
"
|
||||
>
|
||||
<div v-if="getResultCartItem(variant.id)"
|
||||
class="flex shrink-0 items-center gap-1.5">
|
||||
<Button type="button" variant="outline" size="icon-sm" @click.stop="
|
||||
decreaseResultQty(variant.id)
|
||||
">
|
||||
<Minus class="size-3.5" />
|
||||
</Button>
|
||||
<span
|
||||
class="min-w-[1.25rem] text-center text-xs font-semibold tabular-nums"
|
||||
>
|
||||
<span class="min-w-[1.25rem] text-center text-xs font-semibold tabular-nums">
|
||||
{{
|
||||
getResultCartItem(variant.id)!
|
||||
.cutting_result
|
||||
}}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
@click.stop="
|
||||
addResult(product, variant)
|
||||
"
|
||||
>
|
||||
<Button type="button" variant="outline" size="icon-sm" @click.stop="
|
||||
addResult(product, variant)
|
||||
">
|
||||
<Plus class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
v-else
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
class="shrink-0"
|
||||
<Button v-else type="button" variant="outline" size="icon-sm" class="shrink-0"
|
||||
@click.stop="
|
||||
addResult(product, variant)
|
||||
"
|
||||
>
|
||||
">
|
||||
<Plus class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@ -897,19 +785,12 @@ function submit() {
|
||||
Ringkasan Cutting
|
||||
</span>
|
||||
<span class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="materialCart.length > 0 || resultCart.length > 0"
|
||||
type="button"
|
||||
<button v-if="materialCart.length > 0 || resultCart.length > 0" type="button"
|
||||
class="text-xs font-normal text-primary underline underline-offset-2 hover:text-primary/80"
|
||||
@click="cartDetailOpen = true"
|
||||
>
|
||||
@click="cartDetailOpen = true">
|
||||
Lihat Detail
|
||||
</button>
|
||||
<Badge
|
||||
v-if="totalResultPieces > 0"
|
||||
variant="secondary"
|
||||
class="tabular-nums font-semibold"
|
||||
>
|
||||
<Badge v-if="totalResultPieces > 0" variant="secondary" class="tabular-nums font-semibold">
|
||||
Total: {{ totalResultPieces }} pcs
|
||||
</Badge>
|
||||
</span>
|
||||
@ -920,61 +801,39 @@ function submit() {
|
||||
<FieldGroup>
|
||||
<FieldSet class="grid gap-4">
|
||||
<Field>
|
||||
<FieldLabel for="description"
|
||||
>Keterangan</FieldLabel
|
||||
>
|
||||
<Textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
placeholder="Contoh: Cutting batch pagi"
|
||||
rows="2"
|
||||
:maxlength="FIELD_LIMITS.description"
|
||||
/>
|
||||
<FieldError
|
||||
:errors="formErrors(form, 'description')"
|
||||
/>
|
||||
<FieldLabel for="description">Keterangan</FieldLabel>
|
||||
<Textarea id="description" v-model="form.description"
|
||||
placeholder="Contoh: Cutting batch pagi" rows="2"
|
||||
:maxlength="FIELD_LIMITS.description" />
|
||||
<FieldError :errors="formErrors(form, 'description')" />
|
||||
</Field>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium">Bahan Baku</p>
|
||||
|
||||
<div
|
||||
v-if="materialCart.length === 0"
|
||||
class="rounded-lg border border-dashed px-4 py-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
<div v-if="materialCart.length === 0"
|
||||
class="rounded-lg border border-dashed px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
Belum ada bahan baku dipilih.
|
||||
</div>
|
||||
|
||||
<div v-else class="scrollbar-thin max-h-80 space-y-3 overflow-y-auto overscroll-y-contain">
|
||||
<div
|
||||
v-for="(item, index) in materialCart"
|
||||
:key="item.raw_material_price_id"
|
||||
class="rounded-lg border p-3"
|
||||
>
|
||||
<div
|
||||
class="mb-2 flex items-start justify-between gap-2"
|
||||
>
|
||||
<div v-else
|
||||
class="scrollbar-thin max-h-80 space-y-3 overflow-y-auto overscroll-y-contain">
|
||||
<div v-for="(item, index) in materialCart" :key="item.raw_material_price_id"
|
||||
class="rounded-lg border p-3">
|
||||
<div class="mb-2 flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<p
|
||||
class="truncate text-sm font-medium"
|
||||
>
|
||||
<p class="truncate text-sm font-medium">
|
||||
{{ item.raw_material_name }}
|
||||
</p>
|
||||
<p
|
||||
class="truncate text-xs text-muted-foreground"
|
||||
>
|
||||
<p class="truncate text-xs text-muted-foreground">
|
||||
{{ item.variant }} · Stok
|
||||
{{ item.stock_input }}
|
||||
{{ item.unit_abbreviation }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
<Button type="button" variant="ghost" size="icon"
|
||||
class="size-7 shrink-0 text-destructive hover:text-destructive"
|
||||
@click="removeMaterial(index)"
|
||||
>
|
||||
@click="removeMaterial(index)">
|
||||
<Trash2 class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@ -984,33 +843,23 @@ function submit() {
|
||||
<FieldLabel class="text-xs">
|
||||
Pemakaian ({{ item.unit_abbreviation }})
|
||||
</FieldLabel>
|
||||
<DecimalInput
|
||||
v-model="
|
||||
item.material_usage
|
||||
"
|
||||
class="h-8"
|
||||
@change="
|
||||
<DecimalInput v-model="item.material_usage
|
||||
" class="h-8" @change="
|
||||
syncMaterialField(
|
||||
index,
|
||||
)
|
||||
"
|
||||
/>
|
||||
" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel class="text-xs">
|
||||
Sisa ({{ item.unit_abbreviation }})
|
||||
</FieldLabel>
|
||||
<DecimalInput
|
||||
v-model="
|
||||
item.remaining_material
|
||||
"
|
||||
class="h-8"
|
||||
@change="
|
||||
<DecimalInput v-model="item.remaining_material
|
||||
" class="h-8" @change="
|
||||
syncMaterialField(
|
||||
index,
|
||||
)
|
||||
"
|
||||
/>
|
||||
" />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
@ -1022,111 +871,68 @@ function submit() {
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-sm font-medium">Hasil Produk</p>
|
||||
<Badge
|
||||
v-if="totalResultPieces > 0"
|
||||
variant="outline"
|
||||
class="tabular-nums text-xs"
|
||||
>
|
||||
<Badge v-if="totalResultPieces > 0" variant="outline" class="tabular-nums text-xs">
|
||||
{{ totalResultPieces }} pcs
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="resultCart.length === 0"
|
||||
class="rounded-lg border border-dashed px-4 py-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
<div v-if="resultCart.length === 0"
|
||||
class="rounded-lg border border-dashed px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
Belum ada produk hasil dipilih.
|
||||
</div>
|
||||
|
||||
<div v-else class="scrollbar-thin max-h-80 space-y-3 overflow-y-auto overscroll-y-contain">
|
||||
<div
|
||||
v-for="(item, index) in resultCart"
|
||||
:key="item.product_variant_id"
|
||||
class="rounded-lg border p-3"
|
||||
>
|
||||
<div
|
||||
class="mb-2 flex items-start justify-between gap-2"
|
||||
>
|
||||
<div v-else
|
||||
class="scrollbar-thin max-h-80 space-y-3 overflow-y-auto overscroll-y-contain">
|
||||
<div v-for="(item, index) in resultCart" :key="item.product_variant_id"
|
||||
class="rounded-lg border p-3">
|
||||
<div class="mb-2 flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<p
|
||||
class="truncate text-sm font-medium"
|
||||
>
|
||||
<p class="truncate text-sm font-medium">
|
||||
{{ item.product_name }}
|
||||
</p>
|
||||
<p
|
||||
class="truncate text-xs text-muted-foreground"
|
||||
>
|
||||
<p class="truncate text-xs text-muted-foreground">
|
||||
{{ item.variant_name }} ·
|
||||
Stok {{ item.stock }} pcs
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
<Button type="button" variant="ghost" size="icon"
|
||||
class="size-7 shrink-0 text-destructive hover:text-destructive"
|
||||
@click="removeResult(index)"
|
||||
>
|
||||
@click="removeResult(index)">
|
||||
<Trash2 class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<Field>
|
||||
<FieldLabel class="text-xs"
|
||||
>Hasil</FieldLabel
|
||||
>
|
||||
<NumberInput
|
||||
v-model="
|
||||
item.cutting_result
|
||||
"
|
||||
class="h-8"
|
||||
@change="
|
||||
<FieldLabel class="text-xs">Hasil</FieldLabel>
|
||||
<NumberInput v-model="item.cutting_result
|
||||
" class="h-8" @change="
|
||||
syncResultTotals(item);
|
||||
syncResultField(index)
|
||||
"
|
||||
/>
|
||||
syncResultField(index)
|
||||
" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel class="text-xs"
|
||||
>Bagus</FieldLabel
|
||||
>
|
||||
<NumberInput
|
||||
v-model="
|
||||
item.warehouse_stock
|
||||
"
|
||||
class="h-8"
|
||||
@change="
|
||||
<FieldLabel class="text-xs">Bagus</FieldLabel>
|
||||
<NumberInput v-model="item.warehouse_stock
|
||||
" class="h-8" @change="
|
||||
syncResultTotals(item);
|
||||
syncResultField(index)
|
||||
"
|
||||
/>
|
||||
syncResultField(index)
|
||||
" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel class="text-xs"
|
||||
>Reject</FieldLabel
|
||||
>
|
||||
<NumberInput
|
||||
v-model="
|
||||
item.cutting_reject
|
||||
"
|
||||
class="h-8"
|
||||
/>
|
||||
<FieldLabel class="text-xs">Reject</FieldLabel>
|
||||
<NumberInput v-model="item.cutting_reject
|
||||
" class="h-8" />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
class="w-full"
|
||||
:disabled="
|
||||
form.processing ||
|
||||
materialCart.length === 0 ||
|
||||
resultCart.length === 0
|
||||
"
|
||||
>
|
||||
<Button type="submit" class="w-full" :disabled="form.processing ||
|
||||
materialCart.length === 0 ||
|
||||
resultCart.length === 0
|
||||
">
|
||||
<Save class="size-4" />
|
||||
{{
|
||||
form.processing
|
||||
@ -1158,11 +964,8 @@ function submit() {
|
||||
<span class="text-muted-foreground">Bahan Baku</span>
|
||||
<span class="font-medium tabular-nums">{{ formatRupiah(totalMaterialCost) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="item in materialCart"
|
||||
:key="`cost-${item.raw_material_price_id}`"
|
||||
class="flex items-center justify-between gap-3 text-xs"
|
||||
>
|
||||
<div v-for="item in materialCart" :key="`cost-${item.raw_material_price_id}`"
|
||||
class="flex items-center justify-between gap-3 text-xs">
|
||||
<span class="truncate text-muted-foreground">
|
||||
{{ item.raw_material_name }} ({{ item.variant }})
|
||||
</span>
|
||||
@ -1176,10 +979,8 @@ function submit() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="totalResultPieces > 0"
|
||||
class="flex items-center justify-between rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 font-semibold"
|
||||
>
|
||||
<div v-if="totalResultPieces > 0"
|
||||
class="flex items-center justify-between rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 font-semibold">
|
||||
<span>Harga Modal / pcs</span>
|
||||
<span class="tabular-nums">{{ formatRupiah(estimatedCostPerUnit) }}</span>
|
||||
</div>
|
||||
@ -1189,18 +990,10 @@ function submit() {
|
||||
</div>
|
||||
|
||||
<DialogFooter class="gap-2 sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@click="costModalOpen = false"
|
||||
>
|
||||
<Button type="button" variant="outline" @click="costModalOpen = false">
|
||||
Tutup
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
:disabled="form.processing"
|
||||
@click="confirmSubmit"
|
||||
>
|
||||
<Button type="button" :disabled="form.processing" @click="confirmSubmit">
|
||||
{{ form.processing ? 'Menyimpan...' : submitLabel }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@ -1216,11 +1009,8 @@ function submit() {
|
||||
<div v-if="materialCart.length > 0">
|
||||
<p class="mb-2 text-xs font-medium text-muted-foreground">Bahan Baku</p>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="item in materialCart"
|
||||
:key="`detail-mat-${item.raw_material_price_id}`"
|
||||
class="rounded-lg border p-3"
|
||||
>
|
||||
<div v-for="item in materialCart" :key="`detail-mat-${item.raw_material_price_id}`"
|
||||
class="rounded-lg border p-3">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium">{{ item.raw_material_name }}</p>
|
||||
@ -1234,7 +1024,8 @@ function submit() {
|
||||
</div>
|
||||
<div class="mt-1.5 flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span>Sisa: {{ item.remaining_material }} {{ item.unit_abbreviation }}</span>
|
||||
<span class="font-medium text-foreground">{{ formatRupiah(materialLineCost(item)) }}</span>
|
||||
<span class="font-medium text-foreground">{{ formatRupiah(materialLineCost(item))
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1242,11 +1033,8 @@ function submit() {
|
||||
<div v-if="resultCart.length > 0">
|
||||
<p class="mb-2 text-xs font-medium text-muted-foreground">Hasil Produk</p>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="item in resultCart"
|
||||
:key="`detail-res-${item.product_variant_id}`"
|
||||
class="rounded-lg border p-3"
|
||||
>
|
||||
<div v-for="item in resultCart" :key="`detail-res-${item.product_variant_id}`"
|
||||
class="rounded-lg border p-3">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium">{{ item.product_name }}</p>
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, router, useForm } from '@inertiajs/vue3';
|
||||
import { router, useForm } from '@inertiajs/vue3';
|
||||
import { Check, RotateCcw, Scissors, X } from '@lucide/vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { RowDeleteAction, RowEditAction, RowStatusAction } from '@/components/button';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@ -27,15 +28,14 @@ import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import { CuttingStatus } from '@/constants/cutting-status';
|
||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||
import { parseRupiah } from '@/lib/rupiah';
|
||||
import { edit, destroy, transition_status } from '@/routes/admin/manage/cuttings';
|
||||
import type { CuttingListItem, CuttingStatusAction } from '@/types/cutting';
|
||||
import {
|
||||
PRICE_TYPES,
|
||||
PRICE_TYPE_LABELS,
|
||||
} from '@/types/product';
|
||||
import type { CuttingListItem, CuttingStatusAction } from '@/types/cutting';
|
||||
import { edit, destroy, transition_status } from '@/routes/admin/manage/cuttings';
|
||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||
|
||||
const props = defineProps<{
|
||||
cutting: CuttingListItem;
|
||||
@ -122,6 +122,7 @@ function setAllMatches(value: boolean) {
|
||||
}
|
||||
function setResultPrice(resultIndex: number, type: string, value: string) {
|
||||
const result = verifyForm.results[resultIndex];
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
@ -203,20 +204,16 @@ function submitVerify() {
|
||||
result_prices: buildResultPricesPayload(),
|
||||
}))
|
||||
.post(transition_status.url(props.cutting.id), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
verifyDialogOpen.value = false;
|
||||
},
|
||||
onError: (errors) => {
|
||||
const message = Object.values(errors)[0];
|
||||
|
||||
toast.error(
|
||||
typeof message === 'string'
|
||||
? message
|
||||
: 'Gagal memverifikasi cutting.',
|
||||
);
|
||||
},
|
||||
});
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
verifyDialogOpen.value = false;
|
||||
},
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function transitionStatus() {
|
||||
@ -237,14 +234,10 @@ function transitionStatus() {
|
||||
statusConfirmOpen.value = false;
|
||||
pendingAction.value = null;
|
||||
},
|
||||
onError: (errors) => {
|
||||
const message = Object.values(errors)[0];
|
||||
|
||||
toast.error(
|
||||
typeof message === 'string'
|
||||
? message
|
||||
: 'Gagal memperbarui status cutting.',
|
||||
);
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
onFinish: () => {
|
||||
statusProcessing.value = false;
|
||||
@ -259,14 +252,10 @@ function submitReject() {
|
||||
onSuccess: () => {
|
||||
rejectDialogOpen.value = false;
|
||||
},
|
||||
onError: (errors) => {
|
||||
const message = Object.values(errors)[0];
|
||||
|
||||
toast.error(
|
||||
typeof message === 'string'
|
||||
? message
|
||||
: 'Gagal menolak cutting.',
|
||||
);
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -298,9 +287,8 @@ function actionIcon(status: string) {
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center justify-end gap-1">
|
||||
<template v-for="action in availableActions" :key="action.status">
|
||||
<RowStatusAction v-if="canPerformAction(action)" :size="'sm'"
|
||||
:icon="actionIcon(action.status)" :label="action.label" :destructive="action.destructive"
|
||||
@click="openStatusConfirm(action)" />
|
||||
<RowStatusAction v-if="canPerformAction(action)" :size="'sm'" :icon="actionIcon(action.status)"
|
||||
:label="action.label" :destructive="action.destructive" @click="openStatusConfirm(action)" />
|
||||
</template>
|
||||
|
||||
<RowEditAction v-if="canEdit" :href="edit.url(cutting.id)" />
|
||||
@ -311,22 +299,12 @@ function actionIcon(status: string) {
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="statusConfirmOpen"
|
||||
:title="
|
||||
pendingAction
|
||||
? `${pendingAction.label} cutting?`
|
||||
: 'Ubah status cutting?'
|
||||
"
|
||||
:description="
|
||||
pendingAction ? statusConfirmDescription(pendingAction) : ''
|
||||
"
|
||||
:confirm-label="pendingAction?.label ?? 'Konfirmasi'"
|
||||
cancel-label="Batal"
|
||||
:destructive="pendingAction?.destructive ?? false"
|
||||
:loading="statusProcessing"
|
||||
@confirm="transitionStatus"
|
||||
/>
|
||||
<ConfirmDialog v-model:open="statusConfirmOpen" :title="pendingAction
|
||||
? `${pendingAction.label} cutting?`
|
||||
: 'Ubah status cutting?'
|
||||
" :description="pendingAction ? statusConfirmDescription(pendingAction) : ''
|
||||
" :confirm-label="pendingAction?.label ?? 'Konfirmasi'" cancel-label="Batal"
|
||||
:destructive="pendingAction?.destructive ?? false" :loading="statusProcessing" @confirm="transitionStatus" />
|
||||
|
||||
<Dialog v-model:open="rejectDialogOpen">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
@ -338,42 +316,24 @@ function actionIcon(status: string) {
|
||||
<FieldGroup>
|
||||
<FieldSet class="grid gap-4">
|
||||
<Field>
|
||||
<FieldLabel for="cutting-reject-reason" required
|
||||
>Alasan Penolakan</FieldLabel
|
||||
>
|
||||
<Textarea
|
||||
id="cutting-reject-reason"
|
||||
v-model="rejectForm.reason"
|
||||
placeholder="Contoh: Jumlah barang yang diterima tidak sesuai"
|
||||
rows="3"
|
||||
autofocus
|
||||
:maxlength="FIELD_LIMITS.reason"
|
||||
/>
|
||||
<FieldError
|
||||
:errors="
|
||||
rejectForm.errors.reason
|
||||
? [rejectForm.errors.reason]
|
||||
: []
|
||||
"
|
||||
/>
|
||||
<FieldLabel for="cutting-reject-reason" required>Alasan Penolakan</FieldLabel>
|
||||
<Textarea id="cutting-reject-reason" v-model="rejectForm.reason"
|
||||
placeholder="Contoh: Jumlah barang yang diterima tidak sesuai" rows="3" autofocus
|
||||
:maxlength="FIELD_LIMITS.reason" />
|
||||
<FieldError :errors="rejectForm.errors.reason
|
||||
? [rejectForm.errors.reason]
|
||||
: []
|
||||
" />
|
||||
</Field>
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter class="mt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="rejectForm.processing"
|
||||
@click="rejectDialogOpen = false"
|
||||
>
|
||||
<Button type="button" variant="outline" :disabled="rejectForm.processing"
|
||||
@click="rejectDialogOpen = false">
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
:disabled="rejectForm.processing"
|
||||
>
|
||||
<Button type="submit" variant="destructive" :disabled="rejectForm.processing">
|
||||
{{ rejectForm.processing ? 'Menyimpan...' : 'Tolak' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@ -397,15 +357,12 @@ function actionIcon(status: string) {
|
||||
<Label for="all-matches" class="text-sm font-medium">
|
||||
Semua sesuai dengan data cutting
|
||||
</Label>
|
||||
<Switch
|
||||
id="all-matches"
|
||||
:model-value="allMatches"
|
||||
@update:model-value="setAllMatches"
|
||||
/>
|
||||
<Switch id="all-matches" :model-value="allMatches" @update:model-value="setAllMatches" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div v-for="(result, index) in verifyForm.results" :key="result.product_variant_id" class="p-3 border rounded-lg space-y-3">
|
||||
<div v-for="(result, index) in verifyForm.results" :key="result.product_variant_id"
|
||||
class="p-3 border rounded-lg space-y-3">
|
||||
<div class="font-medium text-sm">
|
||||
{{ result.name }}
|
||||
</div>
|
||||
@ -430,30 +387,21 @@ function actionIcon(status: string) {
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 items-end text-xs">
|
||||
<div>
|
||||
<label :for="`good-${index}`" class="text-muted-foreground block mb-0.5">Stok Bagus (diterima):</label>
|
||||
<Input
|
||||
:id="`good-${index}`"
|
||||
type="number"
|
||||
v-model.number="result.warehouse_stock"
|
||||
min="0"
|
||||
:max="result.cutting_result"
|
||||
class="h-8 w-full px-2 text-xs"
|
||||
<label :for="`good-${index}`" class="text-muted-foreground block mb-0.5">Stok Bagus
|
||||
(diterima):</label>
|
||||
<Input :id="`good-${index}`" type="number" v-model.number="result.warehouse_stock"
|
||||
min="0" :max="result.cutting_result" class="h-8 w-full px-2 text-xs"
|
||||
:disabled="allMatches"
|
||||
@input="result.cutting_reject = result.cutting_result - result.warehouse_stock"
|
||||
/>
|
||||
@input="result.cutting_reject = result.cutting_result - result.warehouse_stock" />
|
||||
</div>
|
||||
<div>
|
||||
<label :for="`reject-${index}`" class="text-muted-foreground block mb-0.5">Stok Reject (diterima):</label>
|
||||
<Input
|
||||
:id="`reject-${index}`"
|
||||
type="number"
|
||||
v-model.number="result.cutting_reject"
|
||||
min="0"
|
||||
:max="result.cutting_result"
|
||||
class="h-8 w-full px-2 text-xs"
|
||||
<label :for="`reject-${index}`" class="text-muted-foreground block mb-0.5">Stok
|
||||
Reject
|
||||
(diterima):</label>
|
||||
<Input :id="`reject-${index}`" type="number" v-model.number="result.cutting_reject"
|
||||
min="0" :max="result.cutting_result" class="h-8 w-full px-2 text-xs"
|
||||
:disabled="allMatches"
|
||||
@input="result.warehouse_stock = result.cutting_result - result.cutting_reject"
|
||||
/>
|
||||
@input="result.warehouse_stock = result.cutting_result - result.cutting_reject" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -462,47 +410,30 @@ function actionIcon(status: string) {
|
||||
<FieldLabel class="text-xs" :for="`price-${index}-${type}`">
|
||||
{{ PRICE_TYPE_LABELS[type] }}
|
||||
</FieldLabel>
|
||||
<RupiahInput
|
||||
:id="`price-${index}-${type}`"
|
||||
:model-value="result.prices[type]"
|
||||
@update:model-value="setResultPrice(index, type, $event)"
|
||||
/>
|
||||
<RupiahInput :id="`price-${index}-${type}`" :model-value="result.prices[type]"
|
||||
@update:model-value="setResultPrice(index, type, $event)" />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FieldError
|
||||
:errors="verifyForm.errors.result_prices ? [verifyForm.errors.result_prices] : []"
|
||||
/>
|
||||
<FieldError :errors="verifyForm.errors.result_prices ? [verifyForm.errors.result_prices] : []" />
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="verification-note">Catatan Verifikasi</FieldLabel>
|
||||
<Textarea
|
||||
id="verification-note"
|
||||
v-model="verifyForm.verification_note"
|
||||
placeholder="Contoh: Terdapat 1 barang cacat jahitan saat dihitung di toko"
|
||||
rows="3"
|
||||
/>
|
||||
<Textarea id="verification-note" v-model="verifyForm.verification_note"
|
||||
placeholder="Contoh: Terdapat 1 barang cacat jahitan saat dihitung di toko" rows="3" />
|
||||
<FieldError
|
||||
:errors="verifyForm.errors.verification_note ? [verifyForm.errors.verification_note] : []"
|
||||
/>
|
||||
:errors="verifyForm.errors.verification_note ? [verifyForm.errors.verification_note] : []" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="mt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="verifyForm.processing"
|
||||
@click="verifyDialogOpen = false"
|
||||
>
|
||||
<Button type="button" variant="outline" :disabled="verifyForm.processing"
|
||||
@click="verifyDialogOpen = false">
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="verifyForm.processing"
|
||||
>
|
||||
<Button type="submit" :disabled="verifyForm.processing">
|
||||
{{ verifyForm.processing ? 'Menyimpan...' : 'Verifikasi' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@ -16,7 +16,6 @@ import {
|
||||
} from '@lucide/vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import OrderPrintButton from './form/OrderPrintButton.vue';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@ -36,8 +35,9 @@ import { MarketplaceFeeValueType } from '@/constants/marketplace-fee-value-type'
|
||||
import { OrderStatus } from '@/constants/order-status';
|
||||
import { StockQuality } from '@/constants/stock-quality';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import type { OrderDetail, OrderStatusAction } from '@/types/order';
|
||||
import { index, edit, destroy, transition_status } from '@/routes/admin/manage/orders';
|
||||
import type { OrderDetail, OrderStatusAction } from '@/types/order';
|
||||
import OrderPrintButton from './form/OrderPrintButton.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
order: OrderDetail;
|
||||
@ -59,15 +59,30 @@ const { open: deleteConfirmOpen, processing: deleteProcessing, destroy: destroyO
|
||||
});
|
||||
|
||||
function statusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
if (status === OrderStatus.COMPLETED) return 'default';
|
||||
if (status === OrderStatus.CANCELLED) return 'destructive';
|
||||
if (status === OrderStatus.PROCESSING) return 'secondary';
|
||||
if (status === OrderStatus.COMPLETED) {
|
||||
return 'default';
|
||||
}
|
||||
|
||||
if (status === OrderStatus.CANCELLED) {
|
||||
return 'destructive';
|
||||
}
|
||||
|
||||
if (status === OrderStatus.PROCESSING) {
|
||||
return 'secondary';
|
||||
}
|
||||
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
function actionIcon(status: string) {
|
||||
if (status === OrderStatus.PROCESSING) return Send;
|
||||
if (status === OrderStatus.COMPLETED) return Check;
|
||||
if (status === OrderStatus.PROCESSING) {
|
||||
return Send;
|
||||
}
|
||||
|
||||
if (status === OrderStatus.COMPLETED) {
|
||||
return Check;
|
||||
}
|
||||
|
||||
return X;
|
||||
}
|
||||
|
||||
@ -79,9 +94,11 @@ function statusConfirmDescription(action: OrderStatusAction): string {
|
||||
if (action.status === OrderStatus.PROCESSING) {
|
||||
return `Pesanan ${props.order.order_number} akan dikirim dan diproses.`;
|
||||
}
|
||||
|
||||
if (action.status === OrderStatus.COMPLETED) {
|
||||
return `Pesanan ${props.order.order_number} akan ditandai selesai.`;
|
||||
}
|
||||
|
||||
return `Pesanan ${props.order.order_number} akan dibatalkan. Stok produk akan dikembalikan.`;
|
||||
}
|
||||
|
||||
@ -91,7 +108,9 @@ function openStatusConfirm(action: OrderStatusAction) {
|
||||
}
|
||||
|
||||
function transitionStatus() {
|
||||
if (!pendingAction.value) return;
|
||||
if (!pendingAction.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
statusProcessing.value = true;
|
||||
|
||||
@ -102,9 +121,10 @@ function transitionStatus() {
|
||||
statusConfirmOpen.value = false;
|
||||
pendingAction.value = null;
|
||||
},
|
||||
onError: (errors) => {
|
||||
const message = Object.values(errors)[0];
|
||||
toast.error(typeof message === 'string' ? message : 'Gagal memperbarui status pesanan.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
onFinish: () => {
|
||||
statusProcessing.value = false;
|
||||
@ -176,7 +196,9 @@ function formatFeeRule(fee: { scope: string; value_type: string; value: number }
|
||||
}
|
||||
|
||||
const feeEntries = computed(() => {
|
||||
if (!snapshot.value) return [];
|
||||
if (!snapshot.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(snapshot.value.fees)
|
||||
.filter(([, rule]) => rule.value > 0)
|
||||
@ -191,6 +213,7 @@ const feeEntries = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<Head :title="`Detail Pesanan ${order.order_number}`" />
|
||||
|
||||
<AdminLayout>
|
||||
@ -208,13 +231,10 @@ const feeEntries = computed(() => {
|
||||
<OrderPrintButton :order="order as any" />
|
||||
|
||||
<template v-for="action in order.available_actions" :key="action.status">
|
||||
<Button
|
||||
v-if="canPerformAction(action)"
|
||||
size="sm"
|
||||
<Button v-if="canPerformAction(action)" size="sm"
|
||||
:variant="action.destructive ? 'outline' : 'default'"
|
||||
:class="action.destructive ? 'text-destructive hover:text-destructive' : ''"
|
||||
@click="openStatusConfirm(action)"
|
||||
>
|
||||
@click="openStatusConfirm(action)">
|
||||
<component :is="actionIcon(action.status)" class="size-3.5" />
|
||||
{{ action.label }}
|
||||
</Button>
|
||||
@ -227,13 +247,8 @@ const feeEntries = computed(() => {
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="canDelete"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="text-destructive hover:text-destructive"
|
||||
@click="deleteConfirmOpen = true"
|
||||
>
|
||||
<Button v-if="canDelete" variant="outline" size="sm" class="text-destructive hover:text-destructive"
|
||||
@click="deleteConfirmOpen = true">
|
||||
<Trash2 class="size-3.5" />
|
||||
Hapus
|
||||
</Button>
|
||||
@ -420,7 +435,8 @@ const feeEntries = computed(() => {
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">Total Potongan</span>
|
||||
<span class="tabular-nums text-destructive">-{{ formatRp(snapshot.total_fee_amount) }}</span>
|
||||
<span class="tabular-nums text-destructive">-{{ formatRp(snapshot.total_fee_amount)
|
||||
}}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div class="flex items-center justify-between">
|
||||
@ -481,11 +497,8 @@ const feeEntries = computed(() => {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="row in summaryRows"
|
||||
:key="row.label"
|
||||
class="flex items-center justify-between text-sm"
|
||||
>
|
||||
<div v-for="row in summaryRows" :key="row.label"
|
||||
class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ row.label }}</span>
|
||||
<span class="tabular-nums">{{ row.value }}</span>
|
||||
</div>
|
||||
@ -533,26 +546,13 @@ const feeEntries = computed(() => {
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="statusConfirmOpen"
|
||||
<ConfirmDialog v-model:open="statusConfirmOpen"
|
||||
:title="pendingAction ? `${pendingAction.label} pesanan?` : 'Ubah status pesanan?'"
|
||||
:description="pendingAction ? statusConfirmDescription(pendingAction) : ''"
|
||||
:confirm-label="pendingAction?.label ?? 'Konfirmasi'"
|
||||
cancel-label="Batal"
|
||||
:destructive="pendingAction?.destructive ?? false"
|
||||
:loading="statusProcessing"
|
||||
@confirm="transitionStatus"
|
||||
/>
|
||||
:confirm-label="pendingAction?.label ?? 'Konfirmasi'" cancel-label="Batal"
|
||||
:destructive="pendingAction?.destructive ?? false" :loading="statusProcessing" @confirm="transitionStatus" />
|
||||
|
||||
<ConfirmDialog
|
||||
v-if="canDelete"
|
||||
v-model:open="deleteConfirmOpen"
|
||||
title="Hapus pesanan?"
|
||||
<ConfirmDialog v-if="canDelete" v-model:open="deleteConfirmOpen" title="Hapus pesanan?"
|
||||
:description="`Pesanan ${order.order_number} akan dihapus.${order.is_editable ? ' Stok produk akan dikembalikan.' : ''}`"
|
||||
confirm-label="Hapus"
|
||||
cancel-label="Batal"
|
||||
destructive
|
||||
:loading="deleteProcessing"
|
||||
@confirm="destroyOrder"
|
||||
/>
|
||||
confirm-label="Hapus" cancel-label="Batal" destructive :loading="deleteProcessing" @confirm="destroyOrder" />
|
||||
</template>
|
||||
|
||||
@ -48,6 +48,7 @@ import { apiFetch } from '@/lib/api';
|
||||
import { getFirstCoverImage } from '@/lib/catalog-cover';
|
||||
import { formErrors } from '@/lib/form';
|
||||
import { formatRupiah, parseRupiah } from '@/lib/rupiah';
|
||||
import { store as syncDraftRoute, resync_prices as resyncPricesRoute, destroy as destroyDraftRoute } from '@/routes/admin/manage/orders/draft_items';
|
||||
import type { Auth } from '@/types/auth';
|
||||
import { STOCK_QUALITY_OPTIONS } from '@/types/order';
|
||||
import type { EnumOption, OrderCartItem, OrderCatalogItem, SelectOption } from '@/types/order';
|
||||
@ -55,7 +56,6 @@ import type { ProductPriceItem, ProductVariantItem } from '@/types/product';
|
||||
import PosCatalogCard from '../../shared/PosCatalogCard.vue';
|
||||
import PosCatalogVariantThumb from '../../shared/PosCatalogVariantThumb.vue';
|
||||
import CustomerQuickCreateModal from './CustomerQuickCreateModal.vue';
|
||||
import { store as syncDraftRoute, resync_prices as resyncPricesRoute, destroy as destroyDraftRoute } from '@/routes/admin/manage/orders/draft_items';
|
||||
|
||||
const props = defineProps<{
|
||||
customers: SelectOption[];
|
||||
@ -490,9 +490,10 @@ function submit() {
|
||||
|
||||
form.transform(() => payload).post(props.submitUrl, {
|
||||
forceFormData: true,
|
||||
onError: (errors) => {
|
||||
const firstError = Object.values(errors)[0];
|
||||
toast.error(firstError || 'Gagal menyimpan data. Periksa kembali formulir.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -769,7 +770,8 @@ function submit() {
|
||||
<p class="truncate text-xs text-muted-foreground">
|
||||
{{ item.variant_name }}
|
||||
·
|
||||
{{ item.stock_quality_label ?? (item.stock_quality === StockQuality.REJECT
|
||||
{{ item.stock_quality_label ?? (item.stock_quality ===
|
||||
StockQuality.REJECT
|
||||
? 'Reject' : 'Bagus') }}
|
||||
</p>
|
||||
</div>
|
||||
@ -849,7 +851,8 @@ function submit() {
|
||||
<div v-if="printAfterSave" class="mt-2 flex items-center gap-2 pl-1">
|
||||
<span class="text-xs text-muted-foreground">Ukuran kertas:</span>
|
||||
<div class="flex gap-1.5">
|
||||
<button v-for="size in ([PaperSize.MM_58, PaperSize.MM_80] as const)" :key="size" type="button"
|
||||
<button v-for="size in ([PaperSize.MM_58, PaperSize.MM_80] as const)"
|
||||
:key="size" type="button"
|
||||
class="inline-flex h-7 items-center rounded-md border px-2.5 text-xs font-medium transition-colors cursor-pointer"
|
||||
:class="selectedPaperSize === size
|
||||
? 'border-primary bg-primary/5 text-primary'
|
||||
@ -887,7 +890,8 @@ function submit() {
|
||||
<p class="truncate text-sm font-medium">{{ item.product_name }}</p>
|
||||
<p class="truncate text-xs text-muted-foreground">
|
||||
{{ item.variant_name }}
|
||||
· {{ item.stock_quality_label ?? (item.stock_quality === StockQuality.REJECT ? 'Reject' : 'Bagus')
|
||||
· {{ item.stock_quality_label ?? (item.stock_quality === StockQuality.REJECT ? 'Reject'
|
||||
: 'Bagus')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -3,14 +3,14 @@ import { router } from '@inertiajs/vue3';
|
||||
import { Check, Send, X } from '@lucide/vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import OrderPrintButton from '../form/OrderPrintButton.vue';
|
||||
import { RowDeleteAction, RowDetailAction, RowEditAction, RowStatusAction } from '@/components/button';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import { OrderStatus } from '@/constants/order-status';
|
||||
import type { OrderListItem, OrderStatusAction } from '@/types/order';
|
||||
import { show, edit, destroy, transition_status } from '@/routes/admin/manage/orders';
|
||||
import type { OrderListItem, OrderStatusAction } from '@/types/order';
|
||||
import OrderPrintButton from '../form/OrderPrintButton.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
order: OrderListItem;
|
||||
@ -63,10 +63,10 @@ function transitionStatus() {
|
||||
statusConfirmOpen.value = false;
|
||||
pendingAction.value = null;
|
||||
},
|
||||
onError: (errors) => {
|
||||
const message = Object.values(errors)[0];
|
||||
|
||||
toast.error(typeof message === 'string' ? message : 'Gagal memperbarui status pesanan.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
onFinish: () => {
|
||||
statusProcessing.value = false;
|
||||
@ -92,17 +92,12 @@ function actionIcon(status: string) {
|
||||
<OrderPrintButton :order="order" />
|
||||
|
||||
<template v-for="action in availableActions" :key="action.status">
|
||||
<RowStatusAction v-if="action.icon_only && canPerformAction(action)"
|
||||
:icon="actionIcon(action.status)" :label="action.label" :destructive="action.destructive"
|
||||
@click="openStatusConfirm(action)" />
|
||||
<RowStatusAction v-if="action.icon_only && canPerformAction(action)" :icon="actionIcon(action.status)"
|
||||
:label="action.label" :destructive="action.destructive" @click="openStatusConfirm(action)" />
|
||||
|
||||
<Button
|
||||
v-else-if="canPerformAction(action)"
|
||||
size="sm"
|
||||
:variant="action.destructive ? 'outline' : 'default'"
|
||||
<Button v-else-if="canPerformAction(action)" size="sm" :variant="action.destructive ? 'outline' : 'default'"
|
||||
:class="action.destructive ? 'text-destructive hover:text-destructive' : ''"
|
||||
@click="openStatusConfirm(action)"
|
||||
>
|
||||
@click="openStatusConfirm(action)">
|
||||
<component :is="actionIcon(action.status)" class="size-3.5" />
|
||||
{{ action.label }}
|
||||
</Button>
|
||||
@ -119,14 +114,9 @@ function actionIcon(status: string) {
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="statusConfirmOpen"
|
||||
<ConfirmDialog v-model:open="statusConfirmOpen"
|
||||
:title="pendingAction ? `${pendingAction.label} pesanan?` : 'Ubah status pesanan?'"
|
||||
:description="pendingAction ? statusConfirmDescription(pendingAction) : ''"
|
||||
:confirm-label="pendingAction?.label ?? 'Konfirmasi'"
|
||||
cancel-label="Batal"
|
||||
:destructive="pendingAction?.destructive ?? false"
|
||||
:loading="statusProcessing"
|
||||
@confirm="transitionStatus"
|
||||
/>
|
||||
:confirm-label="pendingAction?.label ?? 'Konfirmasi'" cancel-label="Batal"
|
||||
:destructive="pendingAction?.destructive ?? false" :loading="statusProcessing" @confirm="transitionStatus" />
|
||||
</template>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { router, useForm } from '@inertiajs/vue3';
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { X } from '@lucide/vue';
|
||||
import { ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
@ -19,8 +19,8 @@ import {
|
||||
} from '@/components/ui/field';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import type { CuttingListItem } from '@/types/cutting';
|
||||
import { approve, reject } from '@/routes/admin/manage/owner_verifications';
|
||||
import type { CuttingListItem } from '@/types/cutting';
|
||||
|
||||
const props = defineProps<{
|
||||
cutting: CuttingListItem;
|
||||
@ -39,27 +39,29 @@ const rejectForm = useForm({
|
||||
function submitApprove() {
|
||||
approveForm
|
||||
.post(approve.url(props.cutting.id), {
|
||||
preserveScroll: true,
|
||||
onError: (errors) => {
|
||||
const message = Object.values(errors)[0];
|
||||
toast.error(message ?? 'Gagal menyetujui verifikasi.');
|
||||
},
|
||||
});
|
||||
preserveScroll: true,
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function submitReject() {
|
||||
rejectForm
|
||||
.post(reject.url(props.cutting.id), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
rejectDialogOpen.value = false;
|
||||
rejectForm.reset();
|
||||
},
|
||||
onError: (errors) => {
|
||||
const message = Object.values(errors)[0];
|
||||
toast.error(message ?? 'Gagal menolak verifikasi.');
|
||||
},
|
||||
});
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
rejectDialogOpen.value = false;
|
||||
rejectForm.reset();
|
||||
},
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -91,32 +93,17 @@ function submitReject() {
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="reject-reason">Alasan Penolakan</FieldLabel>
|
||||
<Textarea
|
||||
id="reject-reason"
|
||||
v-model="rejectForm.reason"
|
||||
placeholder="Jelaskan alasan penolakan..."
|
||||
rows="3"
|
||||
/>
|
||||
<FieldError
|
||||
:errors="rejectForm.errors.reason ? [rejectForm.errors.reason] : []"
|
||||
/>
|
||||
<Textarea id="reject-reason" v-model="rejectForm.reason" placeholder="Jelaskan alasan penolakan..."
|
||||
rows="3" />
|
||||
<FieldError :errors="rejectForm.errors.reason ? [rejectForm.errors.reason] : []" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@click="rejectDialogOpen = false"
|
||||
>
|
||||
<Button type="button" variant="outline" @click="rejectDialogOpen = false">
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
:disabled="rejectForm.processing"
|
||||
@click="submitReject"
|
||||
>
|
||||
<Button type="button" variant="destructive" :disabled="rejectForm.processing" @click="submitReject">
|
||||
<X class="size-4" />
|
||||
Tolak
|
||||
</Button>
|
||||
|
||||
@ -44,12 +44,12 @@ import { getFirstCoverImage } from '@/lib/catalog-cover';
|
||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||
import { formErrors } from '@/lib/form';
|
||||
import { formatRupiah, parseRupiah } from '@/lib/rupiah';
|
||||
import { store as syncDraftRoute, destroy as destroyDraftRoute } from '@/routes/admin/manage/purchases/draft_items';
|
||||
import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media';
|
||||
import type { MediaItem, MediaUploadState } from '@/types/media';
|
||||
import type { PurchaseCartItem, PurchaseCatalogItem, SelectOption } from '@/types/purchase';
|
||||
import PosCatalogCard from '../../shared/PosCatalogCard.vue';
|
||||
import PosCatalogVariantThumb from '../../shared/PosCatalogVariantThumb.vue';
|
||||
import { store as syncDraftRoute, destroy as destroyDraftRoute } from '@/routes/admin/manage/purchases/draft_items';
|
||||
const props = defineProps<{
|
||||
suppliers: SelectOption[];
|
||||
catalog: PurchaseCatalogItem[];
|
||||
@ -323,8 +323,10 @@ function submit() {
|
||||
|
||||
form.transform(() => payload).post(props.submitUrl, {
|
||||
forceFormData: true,
|
||||
onError: () => {
|
||||
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { router, useForm } from '@inertiajs/vue3';
|
||||
import { Check } from '@lucide/vue';
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { ref, watch } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { RowApproveAction } from '@/components/button';
|
||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
@ -22,20 +22,14 @@ import { Label } from '@/components/ui/label';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||
import { parseRupiah } from '@/lib/rupiah';
|
||||
import { verify } from '@/routes/admin/manage/stocks';
|
||||
import type { CuttingListItem } from '@/types/cutting';
|
||||
import {
|
||||
PRICE_TYPES,
|
||||
PRICE_TYPE_LABELS,
|
||||
} from '@/types/product';
|
||||
import type { CuttingListItem } from '@/types/cutting';
|
||||
import { verify } from '@/routes/admin/manage/stocks';
|
||||
|
||||
const props = defineProps<{
|
||||
cutting: CuttingListItem;
|
||||
@ -148,20 +142,16 @@ function submitVerify() {
|
||||
result_prices: buildResultPricesPayload(),
|
||||
}))
|
||||
.post(verify.url(props.cutting.id), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
verifyDialogOpen.value = false;
|
||||
},
|
||||
onError: (errors) => {
|
||||
const message = Object.values(errors)[0];
|
||||
|
||||
toast.error(
|
||||
typeof message === 'string'
|
||||
? message
|
||||
: 'Gagal memverifikasi cutting.',
|
||||
);
|
||||
},
|
||||
});
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
verifyDialogOpen.value = false;
|
||||
},
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -178,55 +168,44 @@ function submitVerify() {
|
||||
<form @submit.prevent="submitVerify">
|
||||
<div class="space-y-4 max-h-[60vh] overflow-y-auto scrollbar-thin px-1 py-1">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Verifikasi jumlah produk yang diterima dan tentukan harga jual. Stok akan ditambahkan setelah verifikasi.
|
||||
Verifikasi jumlah produk yang diterima dan tentukan harga jual. Stok akan ditambahkan setelah
|
||||
verifikasi.
|
||||
</p>
|
||||
|
||||
<div class="flex items-center justify-between rounded-lg border p-3">
|
||||
<Label for="stock-all-matches" class="text-sm font-medium">
|
||||
Semua sesuai dengan data cutting
|
||||
</Label>
|
||||
<Switch
|
||||
id="stock-all-matches"
|
||||
:model-value="allMatches"
|
||||
@update:model-value="setAllMatches"
|
||||
/>
|
||||
<Switch id="stock-all-matches" :model-value="allMatches" @update:model-value="setAllMatches" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div v-for="(result, index) in verifyForm.results" :key="result.product_variant_id" class="flex items-center gap-3 rounded-lg border p-3">
|
||||
<div v-for="(result, index) in verifyForm.results" :key="result.product_variant_id"
|
||||
class="flex items-center gap-3 rounded-lg border p-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-sm truncate">{{ result.name }}</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ result.cutting_result }} pcs
|
||||
<span class="text-[10px]">({{ result.original_warehouse_stock }} bagus, {{ result.original_cutting_reject }} reject)</span>
|
||||
<span class="text-[10px]">({{ result.original_warehouse_stock }} bagus, {{
|
||||
result.original_cutting_reject }} reject)</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class="text-center">
|
||||
<label :for="`stock-good-${index}`" class="text-[10px] text-muted-foreground block">Bagus</label>
|
||||
<Input
|
||||
:id="`stock-good-${index}`"
|
||||
type="number"
|
||||
v-model.number="result.warehouse_stock"
|
||||
min="0"
|
||||
:max="result.cutting_result"
|
||||
class="h-7 w-16 px-1.5 text-xs text-center"
|
||||
:disabled="allMatches"
|
||||
@input="result.cutting_reject = result.cutting_result - result.warehouse_stock"
|
||||
/>
|
||||
<label :for="`stock-good-${index}`"
|
||||
class="text-[10px] text-muted-foreground block">Bagus</label>
|
||||
<Input :id="`stock-good-${index}`" type="number"
|
||||
v-model.number="result.warehouse_stock" min="0" :max="result.cutting_result"
|
||||
class="h-7 w-16 px-1.5 text-xs text-center" :disabled="allMatches"
|
||||
@input="result.cutting_reject = result.cutting_result - result.warehouse_stock" />
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<label :for="`stock-reject-${index}`" class="text-[10px] text-muted-foreground block">Reject</label>
|
||||
<Input
|
||||
:id="`stock-reject-${index}`"
|
||||
type="number"
|
||||
v-model.number="result.cutting_reject"
|
||||
min="0"
|
||||
:max="result.cutting_result"
|
||||
class="h-7 w-16 px-1.5 text-xs text-center"
|
||||
:disabled="allMatches"
|
||||
@input="result.warehouse_stock = result.cutting_result - result.cutting_reject"
|
||||
/>
|
||||
<label :for="`stock-reject-${index}`"
|
||||
class="text-[10px] text-muted-foreground block">Reject</label>
|
||||
<Input :id="`stock-reject-${index}`" type="number"
|
||||
v-model.number="result.cutting_reject" min="0" :max="result.cutting_result"
|
||||
class="h-7 w-16 px-1.5 text-xs text-center" :disabled="allMatches"
|
||||
@input="result.warehouse_stock = result.cutting_result - result.cutting_reject" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -241,46 +220,29 @@ function submitVerify() {
|
||||
<FieldLabel class="text-xs" :for="`shared-price-${type}`">
|
||||
{{ PRICE_TYPE_LABELS[type] }}
|
||||
</FieldLabel>
|
||||
<RupiahInput
|
||||
:id="`shared-price-${type}`"
|
||||
:model-value="verifyForm.shared_prices[type]"
|
||||
@update:model-value="setSharedPrice(type, $event)"
|
||||
/>
|
||||
<RupiahInput :id="`shared-price-${type}`" :model-value="verifyForm.shared_prices[type]"
|
||||
@update:model-value="setSharedPrice(type, $event)" />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FieldError
|
||||
:errors="verifyForm.errors.result_prices ? [verifyForm.errors.result_prices] : []"
|
||||
/>
|
||||
<FieldError :errors="verifyForm.errors.result_prices ? [verifyForm.errors.result_prices] : []" />
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="stock-verification-note">Catatan Verifikasi</FieldLabel>
|
||||
<Textarea
|
||||
id="stock-verification-note"
|
||||
v-model="verifyForm.verification_note"
|
||||
placeholder="Contoh: Terdapat 1 barang cacat jahitan saat dihitung di toko"
|
||||
rows="3"
|
||||
/>
|
||||
<Textarea id="stock-verification-note" v-model="verifyForm.verification_note"
|
||||
placeholder="Contoh: Terdapat 1 barang cacat jahitan saat dihitung di toko" rows="3" />
|
||||
<FieldError
|
||||
:errors="verifyForm.errors.verification_note ? [verifyForm.errors.verification_note] : []"
|
||||
/>
|
||||
:errors="verifyForm.errors.verification_note ? [verifyForm.errors.verification_note] : []" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="mt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="verifyForm.processing"
|
||||
@click="verifyDialogOpen = false"
|
||||
>
|
||||
<Button type="button" variant="outline" :disabled="verifyForm.processing"
|
||||
@click="verifyDialogOpen = false">
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="verifyForm.processing"
|
||||
>
|
||||
<Button type="submit" :disabled="verifyForm.processing">
|
||||
{{ verifyForm.processing ? 'Menyimpan...' : 'Verifikasi & Tambah Stok' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@ -22,8 +22,8 @@ import { Input } from '@/components/ui/input';
|
||||
import { useFormDialog } from '@/composables/useFormDialog';
|
||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||
import { formErrors } from '@/lib/form';
|
||||
import type { CategoryFormData, CategoryListItem } from '@/types/category';
|
||||
import { store, update } from '@/routes/admin/master/categories';
|
||||
import type { CategoryFormData, CategoryListItem } from '@/types/category';
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
@ -65,8 +65,10 @@ function submit() {
|
||||
onSuccess: () => {
|
||||
open.value = false;
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -24,8 +24,8 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { useFormDialog } from '@/composables/useFormDialog';
|
||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||
import { formErrors } from '@/lib/form';
|
||||
import type { CustomerFormData, CustomerListItem } from '@/types/customer';
|
||||
import { store, update } from '@/routes/admin/master/customers';
|
||||
import type { CustomerFormData, CustomerListItem } from '@/types/customer';
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
@ -71,8 +71,10 @@ function submit() {
|
||||
onSuccess: () => {
|
||||
open.value = false;
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -131,8 +131,10 @@ function buildFormData(): FormData {
|
||||
function submit() {
|
||||
const options = {
|
||||
forceFormData: true,
|
||||
onError: () => {
|
||||
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -5,8 +5,8 @@ import { toast } from 'vue-sonner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import type { ProductListItem } from '@/types/product';
|
||||
import { toggle_status } from '@/routes/admin/master/products';
|
||||
import type { ProductListItem } from '@/types/product';
|
||||
|
||||
const props = defineProps<{
|
||||
product: ProductListItem;
|
||||
@ -37,9 +37,12 @@ function toggleStatus(checked: boolean) {
|
||||
is_active: checked,
|
||||
}, {
|
||||
preserveScroll: true,
|
||||
onError: () => {
|
||||
onError: (errors: any) => {
|
||||
isActive.value = previous;
|
||||
toast.error('Gagal memperbarui status produk.');
|
||||
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
onFinish: () => {
|
||||
processing.value = false;
|
||||
|
||||
@ -187,8 +187,10 @@ function buildFormData(): FormData {
|
||||
function submit() {
|
||||
const options = {
|
||||
forceFormData: true,
|
||||
onError: () => {
|
||||
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -5,8 +5,8 @@ import { toast } from 'vue-sonner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import type { RawMaterialListItem } from '@/types/raw-material';
|
||||
import { toggle_status } from '@/routes/admin/master/raw_materials';
|
||||
import type { RawMaterialListItem } from '@/types/raw-material';
|
||||
|
||||
const props = defineProps<{
|
||||
material: RawMaterialListItem;
|
||||
@ -37,9 +37,12 @@ function toggleStatus(checked: boolean) {
|
||||
is_active: checked,
|
||||
}, {
|
||||
preserveScroll: true,
|
||||
onError: () => {
|
||||
onError: (errors: any) => {
|
||||
isActive.value = previous;
|
||||
toast.error('Gagal memperbarui status bahan baku.');
|
||||
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
onFinish: () => {
|
||||
processing.value = false;
|
||||
|
||||
@ -24,8 +24,8 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { useFormDialog } from '@/composables/useFormDialog';
|
||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||
import { formErrors } from '@/lib/form';
|
||||
import type { SupplierFormData, SupplierListItem } from '@/types/supplier';
|
||||
import { store, update } from '@/routes/admin/master/suppliers';
|
||||
import type { SupplierFormData, SupplierListItem } from '@/types/supplier';
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
@ -71,8 +71,10 @@ function submit() {
|
||||
onSuccess: () => {
|
||||
open.value = false;
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@ -98,8 +100,8 @@ function submit() {
|
||||
<FieldSet class="grid gap-4">
|
||||
<Field>
|
||||
<FieldLabel for="supplier-name" required>Nama</FieldLabel>
|
||||
<Input id="supplier-name" v-model="form.name" type="text" placeholder="Contoh: PT Sumber Makmur"
|
||||
autofocus :maxlength="FIELD_LIMITS.name" />
|
||||
<Input id="supplier-name" v-model="form.name" type="text"
|
||||
placeholder="Contoh: PT Sumber Makmur" autofocus :maxlength="FIELD_LIMITS.name" />
|
||||
<FieldError :errors="formErrors(form, 'name')" />
|
||||
</Field>
|
||||
<Field>
|
||||
|
||||
@ -96,8 +96,10 @@ function toggleGroupAll(groupName: string, checked: boolean) {
|
||||
|
||||
function submit() {
|
||||
form[props.method](props.submitUrl, {
|
||||
onError: () => {
|
||||
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -164,7 +166,7 @@ function submit() {
|
||||
" />
|
||||
<span class="text-sm">{{
|
||||
perm.label
|
||||
}}</span>
|
||||
}}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -6,10 +6,10 @@ import { toast } from 'vue-sonner';
|
||||
import MediaDropzone from '@/components/media/MediaDropzone.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { update } from '@/routes/admin/system/settings/homepage';
|
||||
import { createMediaUploadState } from '@/types/media';
|
||||
import type { MediaUploadState } from '@/types/media';
|
||||
import type { HomepageSettingsData } from '@/types/setting';
|
||||
import { update } from '@/routes/admin/system/settings/homepage';
|
||||
|
||||
const props = defineProps<{
|
||||
data: HomepageSettingsData;
|
||||
@ -49,8 +49,10 @@ function submit() {
|
||||
heroImage.value = createMediaUploadState();
|
||||
aboutImage.value = createMediaUploadState();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Gagal menyimpan pengaturan homepage.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
onFinish: () => {
|
||||
isSubmitting.value = false;
|
||||
@ -68,8 +70,7 @@ function submit() {
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<MediaDropzone id="hero_image" label="Foto Hero" :max-files="1" v-model="heroImage" />
|
||||
|
||||
<MediaDropzone id="about_image" label="Foto Tentang Kami" :max-files="1"
|
||||
v-model="aboutImage" />
|
||||
<MediaDropzone id="about_image" label="Foto Tentang Kami" :max-files="1" v-model="aboutImage" />
|
||||
|
||||
<div class="sm:col-span-2">
|
||||
<MediaDropzone id="gallery_images" label="Koleksi Lookbook" :max-files="10"
|
||||
|
||||
@ -8,8 +8,8 @@ import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { formErrors } from '@/lib/form';
|
||||
import type { HrSettingsData } from '@/types/setting';
|
||||
import { update } from '@/routes/admin/system/settings/hr';
|
||||
import type { HrSettingsData } from '@/types/setting';
|
||||
|
||||
const props = defineProps<{
|
||||
data: HrSettingsData;
|
||||
@ -25,8 +25,10 @@ const form = useForm({
|
||||
function submit() {
|
||||
form.put(update.url(), {
|
||||
preserveScroll: true,
|
||||
onError: () => {
|
||||
toast.error('Gagal menyimpan pengaturan HR.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -15,12 +15,12 @@ import {
|
||||
import { MarketplacePlatform as MarketplacePlatformConst } from '@/constants/marketplace-platform';
|
||||
import { formErrors } from '@/lib/form';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { update } from '@/routes/admin/system/settings/marketplace';
|
||||
import type {
|
||||
MarketplacePlatform,
|
||||
MarketplaceSettingsData,
|
||||
} from '@/types/setting';
|
||||
import MarketplaceFeeField from './MarketplaceFeeField.vue';
|
||||
import { update } from '@/routes/admin/system/settings/marketplace';
|
||||
|
||||
const props = defineProps<{
|
||||
data: MarketplaceSettingsData;
|
||||
@ -67,8 +67,10 @@ function feeErrors(key: string): string[] {
|
||||
function submit() {
|
||||
form.put(update.url(), {
|
||||
preserveScroll: true,
|
||||
onError: () => {
|
||||
toast.error('Gagal menyimpan pengaturan marketplace.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -7,8 +7,8 @@ import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { formErrors } from '@/lib/form';
|
||||
import type { SocialMediaSettingsData } from '@/types/setting';
|
||||
import { update } from '@/routes/admin/system/settings/social_media';
|
||||
import type { SocialMediaSettingsData } from '@/types/setting';
|
||||
|
||||
const props = defineProps<{
|
||||
data: SocialMediaSettingsData;
|
||||
@ -23,8 +23,10 @@ const form = useForm({
|
||||
function submit() {
|
||||
form.put(update.url(), {
|
||||
preserveScroll: true,
|
||||
onError: () => {
|
||||
toast.error('Gagal menyimpan pengaturan media sosial.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -11,10 +11,10 @@ import { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from '@/component
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { formErrors } from '@/lib/form';
|
||||
import { update } from '@/routes/admin/system/settings/system';
|
||||
import { createMediaUploadState } from '@/types/media';
|
||||
import type { MediaUploadState } from '@/types/media';
|
||||
import type { SystemSettingsData } from '@/types/setting';
|
||||
import { update } from '@/routes/admin/system/settings/system';
|
||||
|
||||
const props = defineProps<{
|
||||
data: SystemSettingsData;
|
||||
@ -61,8 +61,10 @@ function submit() {
|
||||
|
||||
form.transform(() => payload).put(update.url(), {
|
||||
preserveScroll: true,
|
||||
onError: () => {
|
||||
toast.error('Gagal menyimpan pengaturan sistem.');
|
||||
onError: (errors: any) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user