Refactor PurchasePosForm to replace MediaImageUpload with ImageUploadField for improved photo upload handling. Update form data structure to accommodate new photo logic and streamline code. Introduce MultipleImageUploadField for variant images in ProductForm and RawMaterialForm, enhancing user experience with better image management.

This commit is contained in:
Yoga Pangestu 2026-06-12 16:50:44 +07:00
parent 586ed448c7
commit dc335d4dab
6 changed files with 182 additions and 169 deletions

View File

@ -5,7 +5,6 @@ import { computed, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import PosCatalogCard from '@/components/admin/manage/PosCatalogCard.vue';
import PosCatalogVariantThumb from '@/components/admin/manage/PosCatalogVariantThumb.vue';
import MediaImageUpload from '@/components/media/MediaImageUpload.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -22,6 +21,7 @@ import {
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import { ImageUploadField } from '@/components/ui/image-upload-field';
import { Input } from '@/components/ui/input';
import { RupiahInput } from '@/components/ui/rupiah-input';
import {
@ -38,7 +38,6 @@ import { getFirstCoverImage } from '@/lib/catalog-cover';
import { formErrors } from '@/lib/form';
import { formatRupiah, parseRupiah } from '@/lib/rupiah';
import type { MediaItem } from '@/types/media';
import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media';
import type { PurchaseCartItem, PurchaseCatalogItem, SelectOption } from '@/types/purchase';
const props = defineProps<{
suppliers: SelectOption[];
@ -57,12 +56,15 @@ const props = defineProps<{
const search = ref('');
const cart = ref<PurchaseCartItem[]>([]);
const existingPhotoId = ref<number | null>(null);
const currentPhotoUrl = computed(() => props.initialData?.photos?.url ?? null);
const form = useForm({
supplier_id: '',
discount: '',
notes: '',
media: createMediaUploadState(),
photo: null as File | null,
});
function populateForm() {
@ -73,7 +75,8 @@ function populateForm() {
form.supplier_id = props.initialData.supplier_id;
form.discount = props.initialData.discount;
form.notes = props.initialData.notes;
form.media = createMediaUploadState(props.initialData.photos ? [props.initialData.photos] : []);
form.photo = null;
existingPhotoId.value = props.initialData.photos?.id ?? null;
cart.value = props.initialData.items.map((item) => ({ ...item }));
}
@ -176,7 +179,13 @@ function buildFormData(): FormData {
formData.append(`items[${index}][quantity]`, item.quantity);
});
appendRootPhotosToFormData(formData, form.media);
if (form.photo) {
formData.append('photos[]', form.photo);
if (existingPhotoId.value) {
formData.append('remove_media_ids[]', String(existingPhotoId.value));
}
}
return formData;
}
@ -378,8 +387,8 @@ function submit() {
<FieldError :errors="formErrors(form, 'notes')" />
</Field>
<MediaImageUpload id="purchase-photos" v-model="form.media" label="Bukti Transaksi"
:errors="formErrors(form, 'photos')" />
<ImageUploadField id="purchase-photos" v-model="form.photo" label="Bukti Transaksi"
:current-url="currentPhotoUrl" :errors="formErrors(form, 'photos')" />
<Button type="submit" class="w-full" :disabled="form.processing || cart.length === 0">
<Save class="size-4" />

View File

@ -3,7 +3,6 @@ import { useForm } from '@inertiajs/vue3';
import { Copy, Plus, Save, Trash2 } from '@lucide/vue';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import MediaImageUpload from '@/components/media/MediaImageUpload.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
@ -13,6 +12,7 @@ import {
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import { MultipleImageUploadField } from '@/components/ui/image-upload-field';
import { Input } from '@/components/ui/input';
import { RupiahInput } from '@/components/ui/rupiah-input';
import { Textarea } from '@/components/ui/textarea';
@ -400,8 +400,8 @@ function submit() {
</FieldSet>
<div>
<MediaImageUpload :id="`variant_images_${variant.client_id}`" v-model="variant.media"
label="Foto Varian" :max-files="5" required
<MultipleImageUploadField :id="`variant_images_${variant.client_id}`"
v-model="variant.media" label="Foto Varian" :max-files="5" required
:errors="variantError(variant.client_id, 'images') ? [variantError(variant.client_id, 'images')!] : []" />
</div>
</FieldGroup>

View File

@ -3,7 +3,6 @@ import { useForm } from '@inertiajs/vue3';
import { Copy, Plus, Save, Trash2 } from '@lucide/vue';
import { ref } from 'vue';
import { toast } from 'vue-sonner';
import MediaImageUpload from '@/components/media/MediaImageUpload.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
@ -13,6 +12,7 @@ import {
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import { MultipleImageUploadField } from '@/components/ui/image-upload-field';
import { Input } from '@/components/ui/input';
import { RupiahInput } from '@/components/ui/rupiah-input';
import {
@ -327,7 +327,7 @@ function submit() {
</FieldSet>
<div class="mt-4">
<MediaImageUpload :id="`price_images_${price.client_id}`" v-model="price.media"
<MultipleImageUploadField :id="`price_images_${price.client_id}`" v-model="price.media"
label="Foto Varian" :max-files="5" required
:errors="priceError(price.client_id, 'images') ? [priceError(price.client_id, 'images')!] : []" />
</div>

View File

@ -1,157 +0,0 @@
<script setup lang="ts">
import { ImagePlus, X } from '@lucide/vue';
import { computed, onBeforeUnmount, ref } from 'vue';
import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.vue';
import { Button } from '@/components/ui/button';
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import {
createMediaUploadState,
mediaCount
} from '@/types/media';
import type { MediaItem, MediaUploadState } from '@/types/media';
const props = withDefaults(
defineProps<{
label?: string;
description?: string;
maxFiles?: number;
required?: boolean;
existing?: MediaItem[];
errors?: string[];
id?: string;
}>(),
{
label: 'Foto',
maxFiles: 1,
required: false,
existing: () => [],
errors: () => [],
id: 'media-upload',
},
);
const state = defineModel<MediaUploadState>({
default: () => createMediaUploadState(),
});
const previewUrls = ref<string[]>([]);
const previewOpen = ref(false);
const previewUrl = ref<string | null>(null);
const remainingSlots = computed(() => props.maxFiles - mediaCount(state.value));
const canAddMore = computed(() => remainingSlots.value > 0);
const visibleExisting = computed(() =>
state.value.existing.filter((item) => !state.value.removeIds.includes(item.id)),
);
function onFileChange(event: Event) {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
if (files.length === 0) {
return;
}
const allowed = files.slice(0, remainingSlots.value);
if (props.maxFiles === 1) {
previewUrls.value.forEach((url) => URL.revokeObjectURL(url));
previewUrls.value = [];
state.value.newFiles = [];
state.value.existing.forEach((item) => {
if (!state.value.removeIds.includes(item.id)) {
state.value.removeIds = [...state.value.removeIds, item.id];
}
});
}
allowed.forEach((file) => {
state.value.newFiles.push(file);
previewUrls.value.push(URL.createObjectURL(file));
});
input.value = '';
}
function removeExisting(id: number) {
if (!state.value.removeIds.includes(id)) {
state.value.removeIds = [...state.value.removeIds, id];
}
}
function removeNew(index: number) {
const url = previewUrls.value[index];
if (url) {
URL.revokeObjectURL(url);
}
previewUrls.value.splice(index, 1);
state.value.newFiles.splice(index, 1);
}
function openPreview(url: string) {
previewUrl.value = url;
previewOpen.value = true;
}
onBeforeUnmount(() => {
previewUrls.value.forEach((url) => URL.revokeObjectURL(url));
});
</script>
<template>
<Field>
<FieldLabel :for="id" :required="required">
{{ label }}
</FieldLabel>
<FieldDescription v-if="description">
{{ description }}
</FieldDescription>
<FieldDescription v-else-if="maxFiles > 1">
Maks. {{ maxFiles }} gambar. Gambar Pertama akan dijadikan sebagai
thumbnail.
</FieldDescription>
<FieldDescription v-else>
Gambar Pertama akan dijadikan sebagai thumbnail.
</FieldDescription>
<div class="flex flex-wrap items-center gap-2">
<button v-for="item in visibleExisting" :key="`existing-${item.id}`" type="button"
class="group relative size-14 overflow-hidden rounded-md border bg-muted/30"
@click="openPreview(item.url)">
<img :src="item.thumb_url" :alt="label" class="size-full object-cover">
<Button type="button" variant="secondary" size="icon"
class="absolute top-0.5 right-0.5 size-5 opacity-0 transition-opacity group-hover:opacity-100"
@click.stop="removeExisting(item.id)">
<X class="size-3" />
</Button>
</button>
<button v-for="(url, index) in previewUrls" :key="`new-${index}`" type="button"
class="group relative size-14 overflow-hidden rounded-md border bg-muted/30" @click="openPreview(url)">
<img :src="url" :alt="label" class="size-full object-cover">
<Button type="button" variant="secondary" size="icon"
class="absolute top-0.5 right-0.5 size-5 opacity-0 transition-opacity group-hover:opacity-100"
@click.stop="removeNew(index)">
<X class="size-3" />
</Button>
</button>
<label v-if="canAddMore" :for="id"
class="flex size-14 cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed text-muted-foreground hover:bg-muted/30">
<ImagePlus class="size-4" />
<span class="text-[10px]">Tambah</span>
<Input :id="id" type="file" accept="image/*" :multiple="maxFiles > 1" class="sr-only"
@change="onFileChange" />
</label>
</div>
<FieldError :errors="errors" />
<MediaPreviewDialog v-model:open="previewOpen" :url="previewUrl" :title="label" />
</Field>
</template>

View File

@ -0,0 +1,160 @@
<script setup lang="ts">
import { ImagePlus, X } from '@lucide/vue';
import { computed, onBeforeUnmount, ref } from 'vue';
import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.vue';
import { Button } from '@/components/ui/button';
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import {
createMediaUploadState,
mediaCount,
} from '@/types/media';
import type { MediaUploadState } from '@/types/media';
const props = withDefaults(
defineProps<{
id: string;
label: string;
description?: string;
maxFiles?: number;
required?: boolean;
errors?: string[];
}>(),
{
maxFiles: 5,
required: false,
errors: () => [],
},
);
const state = defineModel<MediaUploadState>({
default: () => createMediaUploadState(),
});
const previewUrls = ref<string[]>([]);
const previewOpen = ref(false);
const previewUrl = ref<string | null>(null);
const remainingSlots = computed(() => props.maxFiles - mediaCount(state.value));
const canAddMore = computed(() => remainingSlots.value > 0);
const visibleExisting = computed(() =>
state.value.existing.filter((item) => !state.value.removeIds.includes(item.id)),
);
function onFileChange(event: Event) {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
if (files.length === 0) {
return;
}
const allowed = files.slice(0, remainingSlots.value);
allowed.forEach((file) => {
state.value.newFiles.push(file);
previewUrls.value.push(URL.createObjectURL(file));
});
input.value = '';
}
function removeExisting(id: number) {
if (!state.value.removeIds.includes(id)) {
state.value.removeIds = [...state.value.removeIds, id];
}
}
function removeNew(index: number) {
const url = previewUrls.value[index];
if (url) {
URL.revokeObjectURL(url);
}
previewUrls.value.splice(index, 1);
state.value.newFiles.splice(index, 1);
}
function openPreview(url: string) {
previewUrl.value = url;
previewOpen.value = true;
}
onBeforeUnmount(() => {
previewUrls.value.forEach((url) => URL.revokeObjectURL(url));
});
</script>
<template>
<Field>
<FieldLabel :for="id" :required="required">
{{ label }}
</FieldLabel>
<FieldDescription v-if="description">
{{ description }}
</FieldDescription>
<FieldDescription v-else-if="maxFiles > 1">
Maks. {{ maxFiles }} gambar. Gambar pertama akan dijadikan thumbnail.
</FieldDescription>
<div class="space-y-3">
<div v-if="visibleExisting.length > 0 || previewUrls.length > 0" class="flex flex-wrap items-center gap-2">
<button
v-for="item in visibleExisting"
:key="`existing-${item.id}`"
type="button"
class="group relative size-14 overflow-hidden rounded-md border bg-muted/30"
@click="openPreview(item.url)"
>
<img :src="item.thumb_url" :alt="label" class="size-full object-cover">
<Button
type="button"
variant="secondary"
size="icon"
class="absolute top-0.5 right-0.5 size-5 opacity-0 transition-opacity group-hover:opacity-100"
@click.stop="removeExisting(item.id)"
>
<X class="size-3" />
</Button>
</button>
<button
v-for="(url, index) in previewUrls"
:key="`new-${index}`"
type="button"
class="group relative size-14 overflow-hidden rounded-md border bg-muted/30"
@click="openPreview(url)"
>
<img :src="url" :alt="label" class="size-full object-cover">
<Button
type="button"
variant="secondary"
size="icon"
class="absolute top-0.5 right-0.5 size-5 opacity-0 transition-opacity group-hover:opacity-100"
@click.stop="removeNew(index)"
>
<X class="size-3" />
</Button>
</button>
</div>
<div v-if="canAddMore" class="flex items-center gap-2">
<Input
:id="id"
type="file"
accept="image/*"
:multiple="maxFiles > 1"
class="max-w-md cursor-pointer"
@change="onFileChange"
/>
<ImagePlus class="text-muted-foreground size-5 shrink-0" />
</div>
</div>
<FieldError :errors="errors" />
<MediaPreviewDialog v-model:open="previewOpen" :url="previewUrl" :title="label" />
</Field>
</template>

View File

@ -1 +1,2 @@
export { default as ImageUploadField } from './ImageUploadField.vue';
export { default as MultipleImageUploadField } from './MultipleImageUploadField.vue';