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:
parent
586ed448c7
commit
dc335d4dab
@ -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" />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -1 +1,2 @@
|
||||
export { default as ImageUploadField } from './ImageUploadField.vue';
|
||||
export { default as MultipleImageUploadField } from './MultipleImageUploadField.vue';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user