refactor: replace legacy image upload components with new MediaDropzone component across various admin forms

This commit is contained in:
Yoga Pangestu 2026-06-22 23:58:51 +07:00
parent 66bdd86b4f
commit 5986ec75a3
10 changed files with 752 additions and 165 deletions

26
package-lock.json generated
View File

@ -25,6 +25,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"concurrently": "^9.0.1",
"dropzone-vue": "^0.1.11",
"laravel-vite-plugin": "^3.1",
"reka-ui": "^2.9.10",
"tailwind-merge": "^3.6.0",
@ -6176,6 +6177,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/core-js": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/core-js-compat": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz",
@ -6954,6 +6967,19 @@
"node": ">=0.10.0"
}
},
"node_modules/dropzone-vue": {
"version": "0.1.11",
"resolved": "https://registry.npmjs.org/dropzone-vue/-/dropzone-vue-0.1.11.tgz",
"integrity": "sha512-+XZfKbE2n8FpEoxSTYl9Us4z4qRcRrX24+/uMQxhh4mQuEV1OFAD7v3X2/10ulrRab6wYf5Nv/8pBYchc1ZQyQ==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"peerDependencies": {
"core-js": "^3.9.1",
"vue": "^3.0.5"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",

View File

@ -53,6 +53,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"concurrently": "^9.0.1",
"dropzone-vue": "^0.1.11",
"laravel-vite-plugin": "^3.1",
"reka-ui": "^2.9.10",
"tailwind-merge": "^3.6.0",

View File

@ -0,0 +1,633 @@
<script setup lang="ts">
import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.vue';
import {
Field,
FieldDescription,
FieldError,
FieldLabel,
} from '@/components/ui/field';
import type { MediaUploadState } from '@/types/media';
import { createMediaUploadState } from '@/types/media';
import DropZone from 'dropzone-vue';
import 'dropzone-vue/dist/dropzone-vue.common.css';
import { computed, ref } from 'vue';
const props = withDefaults(
defineProps<{
id: string;
label: string;
description?: string;
maxFiles?: number;
maxFileSize?: number;
required?: boolean;
errors?: string[];
acceptedFiles?: string[];
}>(),
{
maxFiles: 5,
maxFileSize: 5_000_000,
required: false,
errors: () => [],
acceptedFiles: () => ['image'],
},
);
const state = defineModel<MediaUploadState>({
default: () => createMediaUploadState(),
});
// Dialog
const previewOpen = ref(false);
const previewUrl = ref<string | null>(null);
function openPreview(url: string) {
previewUrl.value = url;
previewOpen.value = true;
}
// New file tracking (dropzone)
type FilePreview = { id: string; file: File; objectUrl: string };
const filePreviews = ref<FilePreview[]>([]);
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function onAddedFile(item: { id: string; file: File }) {
state.value.newFiles.push(item.file);
filePreviews.value.push({
id: item.id,
file: item.file,
objectUrl: URL.createObjectURL(item.file),
});
}
function onRemovedFile(item: { id: string; file: File }) {
const idx = state.value.newFiles.indexOf(item.file);
if (idx !== -1) state.value.newFiles.splice(idx, 1);
const pi = filePreviews.value.findIndex((p) => p.id === item.id);
if (pi !== -1) {
URL.revokeObjectURL(filePreviews.value[pi].objectUrl);
filePreviews.value.splice(pi, 1);
}
}
// Unified normalized entries
type NormalizedEntry = {
key: string;
thumbSrc: string; // src for thumbnail img
isImage: boolean; // false show file icon
previewSrc: string; // src for fullscreen preview
name: string;
sizeLabel: string;
onRemove: () => void;
};
const dropzoneRef = ref<InstanceType<typeof DropZone> | null>(null);
const allPreviews = computed<NormalizedEntry[]>(() => {
const existing: NormalizedEntry[] = state.value.existing.map((item) => ({
key: `existing-${item.id}`,
thumbSrc: item.url, // thumb_url terlalu kecil pixelated; pakai url full
isImage: true,
previewSrc: item.url,
name: item.url.split('/').pop() ?? `image-${item.id}`,
sizeLabel: 'Tersimpan',
onRemove: () => {
const idx = state.value.existing.findIndex((e) => e.id === item.id);
if (idx !== -1) {
state.value.existing.splice(idx, 1);
if (!state.value.removeIds.includes(item.id)) {
state.value.removeIds.push(item.id);
}
}
},
}));
const newFiles: NormalizedEntry[] = filePreviews.value.map((p) => ({
key: `new-${p.id}`,
thumbSrc: p.file.type.startsWith('image/') ? p.objectUrl : '',
isImage: p.file.type.startsWith('image/'),
previewSrc: p.objectUrl,
name: p.file.name,
sizeLabel: formatBytes(p.file.size),
onRemove: () => dropzoneRef.value?.removeFile(p.id),
}));
return [...existing, ...newFiles];
});
// Dropzone picker trigger
function triggerDropzonePicker() {
const form = dropzoneRef.value?.$el as HTMLFormElement | null;
// 1. Try inside the form
let input =
form?.querySelector<HTMLInputElement>('input[type="file"]') ?? null;
// 2. dropzone-vue appends hidden input to <body>
if (!input) {
const all = Array.from(
document.body.querySelectorAll<HTMLInputElement>(
'input[type="file"]',
),
);
input =
all.find(
(el) =>
el.style.display === 'none' || el.hasAttribute('hidden'),
) ??
all[0] ??
null;
}
if (input) {
input.click();
} else {
form?.click();
}
}
</script>
<template>
<div class="media-dropzone-root">
<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="dropzone-wrapper">
<!-- Dropzone form: hidden when any preview exists to prevent layout corruption -->
<div
:class="[
'dz-form-host',
{ 'dz-form-hidden': allPreviews.length > 0 },
]"
>
<DropZone
ref="dropzoneRef"
:max-files="maxFiles"
:max-file-size="maxFileSize"
:accepted-files="acceptedFiles"
:upload-on-drop="false"
:clickable="true"
dropzone-class-name="dz-box"
dropzone-message-class-name="dz-message"
@added-file="onAddedFile"
@removed-file="onRemovedFile"
>
<template #message>
<div class="dz-placeholder">
<svg
xmlns="http://www.w3.org/2000/svg"
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"
/>
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<span class="dz-placeholder-primary"
>Klik atau seret file ke sini</span
>
<span class="dz-placeholder-secondary">
{{ acceptedFiles.join(', ') }} &mdash; Maks.
{{ maxFiles }} file,
{{ formatBytes(maxFileSize) }}/file
</span>
</div>
</template>
</DropZone>
</div>
<!-- Overlay: shown when any preview exists, triggers file picker on click -->
<div
v-if="allPreviews.length > 0"
class="dz-overlay"
@click="triggerDropzonePicker"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<span class="dz-placeholder-primary"
>Klik atau seret file ke sini</span
>
<span class="dz-placeholder-secondary">
{{ acceptedFiles.join(', ') }} &mdash; Maks.
{{ maxFiles }} file, {{ formatBytes(maxFileSize) }}/file
</span>
</div>
</div>
<!-- Preview grid (existing + new) -->
<div v-if="allPreviews.length > 0" :class="['preview-grid', { 'preview-grid--single': maxFiles === 1 }]">
<div
v-for="entry in allPreviews"
:key="entry.key"
class="preview-card"
>
<!-- Thumbnail -->
<div
class="preview-thumb"
@click="openPreview(entry.previewSrc)"
>
<img
v-if="entry.isImage"
:src="entry.thumbSrc"
:alt="entry.name"
/>
<div v-else class="preview-thumb-icon">
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
/>
<polyline points="14 2 14 8 20 8" />
</svg>
</div>
</div>
<!-- Info + Remove -->
<div class="preview-footer">
<div class="preview-info">
<p class="preview-name" :title="entry.name">
{{ entry.name }}
</p>
<p class="preview-size">{{ entry.sizeLabel }}</p>
</div>
<button
type="button"
class="preview-remove"
title="Hapus"
@click="entry.onRemove()"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
</div>
<FieldError :errors="errors" />
<MediaPreviewDialog
v-model:open="previewOpen"
:url="previewUrl"
:title="label"
/>
</Field>
</div>
</template>
<style scoped>
/* === Root containment: prevents MediaDropzone from expanding parent grid/flex === */
.media-dropzone-root {
width: 100%;
min-width: 0;
overflow: hidden;
}
/* === Wrapper === */
.dropzone-wrapper {
position: relative;
width: 100%;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
overflow: hidden;
}
/* Host div that wraps the dropzone <form> */
.dz-form-host {
display: block;
width: 100%;
min-width: 0;
}
/* When previews exist: collapse form out of view (keep in DOM so dropzone JS stays alive) */
.dz-form-hidden {
position: absolute;
visibility: hidden;
pointer-events: none;
width: 100%;
height: 0;
overflow: hidden;
}
/* === Dropzone box (the <form> element) === */
/* Target both our custom class and dropzone-vue's own .dropzone class */
.dropzone-wrapper :deep(.dz-box),
.dropzone-wrapper :deep(.dropzone) {
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
box-sizing: border-box !important;
}
.dropzone-wrapper :deep(.dz-box) {
border: 2px dashed var(--border);
border-radius: var(--radius-lg);
background: color-mix(in oklch, var(--muted) 50%, transparent);
cursor: pointer;
transition:
border-color 0.2s,
background-color 0.2s;
display: flex !important;
flex-direction: column !important;
min-height: 0 !important;
}
.dropzone-wrapper :deep(.dz-box:hover) {
border-color: var(--primary);
background: color-mix(in oklch, var(--muted) 80%, transparent);
}
/* Hide built-in item previews */
.dropzone-wrapper :deep(.dropzone__item--style),
.dropzone-wrapper :deep(.dropzone__item),
.dropzone-wrapper :deep(.dropzone__container),
.dropzone-wrapper :deep(.dropzone__items) {
display: none !important;
}
/* Message slot wrapper */
.dropzone-wrapper :deep(.dz-message) {
flex: 1 !important;
display: flex !important;
padding: 0 !important;
margin: 0 !important;
}
/* === Placeholder inside slot === */
.dz-placeholder {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
width: 100%;
min-width: 0;
cursor: pointer;
overflow: hidden;
}
/* === Overlay (shown when previews exist) === */
.dz-overlay {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
width: 100%;
cursor: pointer;
border: 2px dashed var(--border);
border-radius: var(--radius-lg);
background: color-mix(in oklch, var(--muted) 50%, transparent);
transition:
border-color 0.2s,
background-color 0.2s;
box-sizing: border-box;
}
.dz-overlay:hover {
border-color: var(--primary);
background: color-mix(in oklch, var(--muted) 80%, transparent);
}
.dz-placeholder svg,
.dz-overlay svg {
flex-shrink: 0;
opacity: 0.7;
color: var(--muted-foreground);
}
.dz-placeholder-primary {
font-size: 0.875rem;
font-weight: 500;
color: var(--foreground);
white-space: nowrap;
flex-shrink: 0;
}
.dz-placeholder-secondary {
font-size: 0.75rem;
color: var(--muted-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
flex: 1;
}
/* ---- Preview grid — multiple mode (default) ---- */
.preview-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
margin-top: 0.75rem;
}
@media (min-width: 400px) {
.preview-grid {
grid-template-columns: repeat(4, 1fr);
}
}
@media (min-width: 560px) {
.preview-grid {
grid-template-columns: repeat(5, 1fr);
}
}
@media (min-width: 720px) {
.preview-grid {
grid-template-columns: repeat(6, 1fr);
}
}
.preview-card {
display: flex;
flex-direction: column;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: color-mix(in oklch, var(--card) 90%, transparent);
overflow: hidden;
transition: box-shadow 0.15s;
}
.preview-card:hover {
box-shadow: 0 2px 8px color-mix(in oklch, var(--foreground) 8%, transparent);
}
/* ---- Preview grid — single mode (maxFiles === 1) ---- */
.preview-grid--single {
grid-template-columns: 1fr !important;
}
/* Single: card is horizontal — thumbnail left, info right */
.preview-grid--single .preview-card {
flex-direction: row;
align-items: center;
}
.preview-grid--single .preview-thumb {
width: 56px;
height: 56px;
aspect-ratio: unset;
flex-shrink: 0;
border-radius: 0;
}
.preview-grid--single .preview-footer {
flex: 1;
border-top: none;
border-left: 1px solid var(--border);
padding: 0.4rem 0.6rem;
min-width: 0;
}
/* Thumbnail: square, full-width */
.preview-thumb {
width: 100%;
aspect-ratio: 1 / 1;
overflow: hidden;
background: var(--muted);
cursor: zoom-in;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.preview-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.2s;
}
.preview-thumb:hover img {
transform: scale(1.04);
}
.preview-thumb-icon {
color: var(--muted-foreground);
display: flex;
align-items: center;
justify-content: center;
opacity: 0.5;
}
/* Footer row: info + remove button */
.preview-footer {
display: flex;
align-items: center;
gap: 0.2rem;
padding: 0.3rem 0.35rem 0.3rem 0.45rem;
border-top: 1px solid var(--border);
}
/* Info */
.preview-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.preview-name {
font-size: 0.7rem;
font-weight: 500;
color: var(--foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0;
line-height: 1.3;
}
.preview-size {
font-size: 0.65rem;
color: var(--muted-foreground);
margin: 0;
line-height: 1.2;
}
/* Remove button */
.preview-remove {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
border: none;
background: transparent;
color: var(--muted-foreground);
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
padding: 0;
}
.preview-remove:hover {
background: color-mix(in oklch, var(--destructive) 15%, transparent);
color: var(--destructive);
}
</style>

View File

@ -3,8 +3,8 @@ import { useForm } from '@inertiajs/vue3';
import { Save } from '@lucide/vue';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import ImageUploadField from '@/components/form/image-upload-field/ImageUploadField.vue';
import { RupiahInput } from '@/components/form/rupiah-input';
import MediaDropzone from '@/components/media/MediaDropzone.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
@ -26,8 +26,9 @@ 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 type { CashTransactionFormData, CashTransactionListItem } from '@/types/cash';
import { appendPhotosToFormData } from '@/types/media';
import type { CashTransactionListItem } from '@/types/cash';
import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media';
import type { MediaUploadState } from '@/types/media';
const open = defineModel<boolean>('open', { default: false });
@ -46,29 +47,16 @@ const currentMode = computed(() => {
});
const isWithdrawal = computed(() => currentMode.value === CashTransactionType.WITHDRAWAL);
const existingPhotoId = ref<number | null>(null);
const photoState = ref<MediaUploadState>(createMediaUploadState());
const currentPhotoUrl = computed(() => props.transaction?.photos?.[0]?.url ?? null);
const form = useForm<CashTransactionFormData>({
const form = useForm({
amount: '',
description: '',
photos: [],
remove_media_ids: [],
});
const photoFile = computed<File | null>({
get: () => form.photos[0] ?? null,
set: (file) => {
form.photos = file ? [file] : [];
},
});
function resetForm() {
form.reset();
form.photos = [];
form.remove_media_ids = [];
existingPhotoId.value = null;
photoState.value = createMediaUploadState();
form.clearErrors();
}
@ -81,7 +69,7 @@ function populateForm(transaction: CashTransactionListItem | null | undefined) {
form.amount = String(transaction.amount);
form.description = transaction.description;
existingPhotoId.value = transaction.photos?.[0]?.id ?? null;
photoState.value = createMediaUploadState(transaction.photos ?? []);
}
useFormDialog({
@ -101,13 +89,7 @@ function buildFormData(forUpdate: boolean): FormData {
formData.append('amount', parseRupiah(form.amount));
formData.append('description', form.description);
const removeMediaIds = [...form.remove_media_ids];
if (form.photos.length > 0 && existingPhotoId.value !== null) {
removeMediaIds.push(existingPhotoId.value);
}
appendPhotosToFormData(formData, form.photos, removeMediaIds);
appendRootPhotosToFormData(formData, photoState.value);
return formData;
}
@ -173,8 +155,8 @@ const placeholder = computed(() =>
rows="3" :maxlength="FIELD_LIMITS.description" />
<FieldError :errors="formErrors(form, 'description')" />
</Field>
<ImageUploadField id="cash-photos" v-model="photoFile" label="Foto Bukti" required
:current-url="currentPhotoUrl" :errors="formErrors(form, 'photos')" />
<MediaDropzone id="cash-photos" v-model="photoState" label="Foto Bukti" required :max-files="1"
:errors="formErrors(form, 'photos')" />
</FieldSet>
</FieldGroup>

View File

@ -3,8 +3,8 @@ import { useForm } from '@inertiajs/vue3';
import { Save } from '@lucide/vue';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import { ImageUploadField } from '@/components/form/image-upload-field';
import { RupiahInput } from '@/components/form/rupiah-input';
import MediaDropzone from '@/components/media/MediaDropzone.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
@ -25,8 +25,9 @@ import { useFormDialog } from '@/composables/useFormDialog';
import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form';
import { parseRupiah } from '@/lib/rupiah';
import type { ExpenseFormData, ExpenseListItem } from '@/types/expense';
import { appendPhotosToFormData } from '@/types/media';
import type { ExpenseListItem } from '@/types/expense';
import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media';
import type { MediaUploadState } from '@/types/media';
const open = defineModel<boolean>('open', { default: false });
@ -36,29 +37,16 @@ const props = defineProps<{
const isEditing = computed(() => props.expense != null);
const existingPhotoId = ref<number | null>(null);
const photoState = ref<MediaUploadState>(createMediaUploadState());
const currentPhotoUrl = computed(() => props.expense?.photos?.[0]?.url ?? null);
const form = useForm<ExpenseFormData>({
const form = useForm({
amount: '',
description: '',
photos: [],
remove_media_ids: [],
});
const photoFile = computed<File | null>({
get: () => form.photos[0] ?? null,
set: (file) => {
form.photos = file ? [file] : [];
},
});
function resetForm() {
form.reset();
form.photos = [];
form.remove_media_ids = [];
existingPhotoId.value = null;
photoState.value = createMediaUploadState();
form.clearErrors();
}
@ -71,7 +59,7 @@ function populateForm(expense: ExpenseListItem | null | undefined) {
form.amount = String(expense.amount);
form.description = expense.description;
existingPhotoId.value = expense.photos?.[0]?.id ?? null;
photoState.value = createMediaUploadState(expense.photos ?? []);
}
useFormDialog({
@ -91,13 +79,7 @@ function buildFormData(forUpdate: boolean): FormData {
formData.append('amount', parseRupiah(form.amount));
formData.append('description', form.description);
const removeMediaIds = [...form.remove_media_ids];
if (form.photos.length > 0 && existingPhotoId.value !== null) {
removeMediaIds.push(existingPhotoId.value);
}
appendPhotosToFormData(formData, form.photos, removeMediaIds);
appendRootPhotosToFormData(formData, photoState.value);
return formData;
}
@ -148,8 +130,8 @@ function submit() {
:maxlength="FIELD_LIMITS.description" />
<FieldError :errors="formErrors(form, 'description')" />
</Field>
<ImageUploadField id="expense-photos" v-model="photoFile" label="Foto Bukti" required
:current-url="currentPhotoUrl" :errors="formErrors(form, 'photos')" />
<MediaDropzone id="expense-photos" v-model="photoState" label="Foto Bukti" required
:max-files="1" :errors="formErrors(form, 'photos')" />
</FieldSet>
</FieldGroup>

View File

@ -3,11 +3,9 @@ import { useForm } from '@inertiajs/vue3';
import { Minus, Plus, Save, Search, ShoppingCart, Trash2 } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import PosCatalogCard from '../../shared/PosCatalogCard.vue';
import PosCatalogVariantThumb from '../../shared/PosCatalogVariantThumb.vue';
import { DecimalInput } from '@/components/form/decimal-input';
import { ImageUploadField } from '@/components/form/image-upload-field';
import { RupiahInput } from '@/components/form/rupiah-input';
import MediaDropzone from '@/components/media/MediaDropzone.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -46,9 +44,11 @@ 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 { appendPhotosToFormData } from '@/types/media';
import type { MediaItem } from '@/types/media';
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';
const props = defineProps<{
suppliers: SelectOption[];
catalog: PurchaseCatalogItem[];
@ -70,24 +70,13 @@ const isCreateMode = computed(() => props.method === 'post');
const search = ref('');
const cart = ref<PurchaseCartItem[]>([]);
const cartDetailOpen = ref(false);
const existingPhotoId = ref<number | null>(null);
const currentPhotoUrl = computed(() => props.initialData?.photos?.url ?? null);
const photoState = ref<MediaUploadState>(createMediaUploadState());
const form = useForm({
supplier_id: '',
discount: '',
shipping_cost: '',
notes: '',
photos: [] as File[],
remove_media_ids: [] as number[],
});
const photoFile = computed<File | null>({
get: () => form.photos[0] ?? null,
set: (file) => {
form.photos = file ? [file] : [];
},
});
function populateForm() {
@ -99,9 +88,9 @@ function populateForm() {
form.discount = props.initialData.discount;
form.shipping_cost = props.initialData.shipping_cost ?? '0';
form.notes = props.initialData.notes;
form.photos = [];
form.remove_media_ids = [];
existingPhotoId.value = props.initialData.photos?.id ?? null;
photoState.value = createMediaUploadState(
props.initialData.photos ? [props.initialData.photos] : [],
);
cart.value = props.initialData.items.map((item) => ({ ...item }));
}
@ -311,13 +300,7 @@ function buildFormData(): FormData {
});
}
const removeMediaIds = [...form.remove_media_ids];
if (form.photos.length > 0 && existingPhotoId.value !== null) {
removeMediaIds.push(existingPhotoId.value);
}
appendPhotosToFormData(formData, form.photos, removeMediaIds);
appendRootPhotosToFormData(formData, photoState.value);
return formData;
}
@ -382,12 +365,10 @@ function submit() {
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="[
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200" :class="[
'cursor-pointer hover:bg-muted/30',
getCartItem(price.id) ? 'border-2 border-primary bg-primary/5 rounded-md mx-1 my-0.5' : ''
]"
@click="!getCartItem(price.id) && addToCart(rawMaterial, price)">
]" @click="!getCartItem(price.id) && addToCart(rawMaterial, price)">
<PosCatalogVariantThumb :items="price.images" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">
@ -427,12 +408,9 @@ function submit() {
<ShoppingCart class="size-4" />
Ringkasan Belanja
</span>
<button
v-if="cart.length > 0"
type="button"
<button v-if="cart.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>
</CardTitle>
@ -551,8 +529,8 @@ function submit() {
<FieldError :errors="formErrors(form, 'notes')" />
</Field>
<ImageUploadField id="purchase-photos" v-model="photoFile" label="Bukti Transaksi"
:current-url="currentPhotoUrl" :errors="formErrors(form, 'photos')" />
<MediaDropzone id="purchase-photos" v-model="photoState" label="Bukti Transaksi"
:max-files="1" :errors="formErrors(form, 'photos')" />
<Button type="submit" class="w-full" :disabled="form.processing || cart.length === 0">
<Save class="size-4" />
@ -571,11 +549,7 @@ function submit() {
<DialogTitle>Detail Keranjang</DialogTitle>
</DialogHeader>
<div class="max-h-96 space-y-3 overflow-y-auto overscroll-y-contain scrollbar-thin">
<div
v-for="item in cart"
:key="`detail-${item.raw_material_price_id}`"
class="rounded-lg border p-3"
>
<div v-for="item in cart" :key="`detail-${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>

View File

@ -3,8 +3,8 @@ import { useForm } from '@inertiajs/vue3';
import { Plus, Save, Trash2 } from '@lucide/vue';
import { computed } from 'vue';
import { toast } from 'vue-sonner';
import { MultipleImageUploadField } from '@/components/form/image-upload-field';
import { NumberInput } from '@/components/form/number-input';
import MediaDropzone from '@/components/media/MediaDropzone.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
@ -223,8 +223,8 @@ function submit() {
</FieldSet>
<div>
<MultipleImageUploadField :id="`variant_images_${variant.client_id}`"
v-model="variant.media" label="Foto Varian" :max-files="5" required
<MediaDropzone :id="`variant_images_${variant.client_id}`" v-model="variant.media"
label="Foto Varian" :max-files="5" required
:errors="variantErrors(form, variant.client_id, 'images')" />
</div>
</FieldGroup>

View File

@ -4,8 +4,8 @@ import { Copy, Plus, Save, Trash2 } from '@lucide/vue';
import { ref } from 'vue';
import { toast } from 'vue-sonner';
import { DecimalInput } from '@/components/form/decimal-input';
import { MultipleImageUploadField } from '@/components/form/image-upload-field';
import { RupiahInput } from '@/components/form/rupiah-input';
import MediaDropzone from '@/components/media/MediaDropzone.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
@ -252,8 +252,7 @@ function submit() {
</FieldLabel>
<RupiahInput :id="`shared_price_${prices[0].client_id}`" :model-value="prices[0].price"
@update:model-value="setSharedPrice" />
<FieldError
:errors="formErrors(form, 'prices.0.price')" />
<FieldError :errors="formErrors(form, 'prices.0.price')" />
</Field>
</FieldGroup>
</CardContent>
@ -285,8 +284,7 @@ function submit() {
<Input :id="`variant_${price.client_id}`" :model-value="price.variant" type="text"
placeholder="Contoh: Premium / 40s" :maxlength="FIELD_LIMITS.variantName"
@update:model-value="setPriceField(price.client_id, 'variant', String($event))" />
<FieldError
:errors="priceErrors(form, price.client_id, 'variant')" />
<FieldError :errors="priceErrors(form, price.client_id, 'variant')" />
</Field>
<Field v-if="method !== 'put'">
<FieldLabel :for="`stock_${price.client_id}`" required>
@ -294,8 +292,7 @@ function submit() {
</FieldLabel>
<DecimalInput :id="`stock_${price.client_id}`" :model-value="price.stock"
@update:model-value="setPriceField(price.client_id, 'stock', String($event))" />
<FieldError
:errors="priceErrors(form, price.client_id, 'stock')" />
<FieldError :errors="priceErrors(form, price.client_id, 'stock')" />
</Field>
<Field v-if="!useSamePrice || prices.length === 1">
<FieldLabel :for="`price_${price.client_id}`" required>
@ -303,13 +300,12 @@ function submit() {
</FieldLabel>
<RupiahInput :id="`price_${price.client_id}`" :model-value="price.price"
@update:model-value="setPriceValue(price.client_id, $event)" />
<FieldError
:errors="priceErrors(form, price.client_id, 'price')" />
<FieldError :errors="priceErrors(form, price.client_id, 'price')" />
</Field>
</FieldSet>
<div class="mt-4">
<MultipleImageUploadField :id="`price_images_${price.client_id}`" v-model="price.media"
<MediaDropzone :id="`price_images_${price.client_id}`" v-model="price.media"
label="Foto Varian" :max-files="5" required
:errors="priceErrors(form, price.client_id, 'images')" />
</div>

View File

@ -3,8 +3,7 @@ import { router } from '@inertiajs/vue3';
import { Save } from '@lucide/vue';
import { ref } from 'vue';
import { toast } from 'vue-sonner';
import ImageUploadField from '@/components/form/image-upload-field/ImageUploadField.vue';
import MultipleImageUploadField from '@/components/form/image-upload-field/MultipleImageUploadField.vue';
import MediaDropzone from '@/components/media/MediaDropzone.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { createMediaUploadState } from '@/types/media';
@ -15,8 +14,8 @@ const props = defineProps<{
data: HomepageSettingsData;
}>();
const heroImage = ref<File | null>(null);
const aboutImage = ref<File | null>(null);
const heroImage = ref<MediaUploadState>(createMediaUploadState());
const aboutImage = ref<MediaUploadState>(createMediaUploadState());
const galleryState = ref<MediaUploadState>(
createMediaUploadState(props.data.gallery_images),
);
@ -27,13 +26,13 @@ function submit() {
const formData = new FormData();
if (heroImage.value) {
formData.append('hero_image', heroImage.value);
}
heroImage.value.newFiles.forEach((file) => {
formData.append('hero_image', file);
});
if (aboutImage.value) {
formData.append('about_image', aboutImage.value);
}
aboutImage.value.newFiles.forEach((file) => {
formData.append('about_image', file);
});
galleryState.value.newFiles.forEach((file) => {
formData.append('gallery_images[]', file);
@ -46,8 +45,8 @@ function submit() {
router.put('/admin/system/settings/homepage', formData, {
preserveScroll: true,
onSuccess: () => {
heroImage.value = null;
aboutImage.value = null;
heroImage.value = createMediaUploadState();
aboutImage.value = createMediaUploadState();
},
onError: () => {
toast.error('Gagal menyimpan pengaturan homepage.');
@ -65,27 +64,17 @@ function submit() {
<Card>
<CardContent>
<ImageUploadField id="hero_image" label="Foto Hero"
description="Foto utama yang ditampilkan di bagian hero homepage."
:current-url="props.data.hero_image_url" v-model="heroImage"
preview-class="aspect-[3/4] max-w-xs" />
</CardContent>
</Card>
<div class="grid gap-4 sm:grid-cols-2">
<MediaDropzone id="hero_image" label="Foto Hero" :max-files="1" v-model="heroImage" />
<Card>
<CardContent>
<MultipleImageUploadField id="gallery_images" label="Koleksi Lookbook"
description="Foto-foto yang ditampilkan di galeri lookbook." :max-files="10"
v-model="galleryState" />
</CardContent>
</Card>
<MediaDropzone id="about_image" label="Foto Tentang Kami" :max-files="1"
v-model="aboutImage" />
<Card>
<CardContent>
<ImageUploadField id="about_image" label="Foto Tentang Kami"
description="Foto yang ditampilkan di bagian tentang kami."
:current-url="props.data.about_image_url" v-model="aboutImage"
preview-class="aspect-video max-w-md" />
<div class="sm:col-span-2">
<MediaDropzone id="gallery_images" label="Koleksi Lookbook" :max-files="10"
v-model="galleryState" />
</div>
</div>
</CardContent>
</Card>

View File

@ -1,15 +1,18 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Save } from '@lucide/vue';
import { ref } from 'vue';
import { toast } from 'vue-sonner';
import { ImageUploadField } from '@/components/form/image-upload-field';
import { PhoneNumberInput } from '@/components/form/phone-number-input';
import MediaDropzone from '@/components/media/MediaDropzone.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { formErrors } from '@/lib/form';
import { createMediaUploadState } from '@/types/media';
import type { MediaUploadState } from '@/types/media';
import type { SystemSettingsData } from '@/types/setting';
const props = defineProps<{
@ -22,11 +25,12 @@ const form = useForm({
email: props.data.email ?? '',
phone: props.data.phone ?? '',
address: props.data.address ?? '',
logo: null as File | null,
favicon: null as File | null,
login_cover: null as File | null,
});
const logoState = ref<MediaUploadState>(createMediaUploadState());
const faviconState = ref<MediaUploadState>(createMediaUploadState());
const loginCoverState = ref<MediaUploadState>(createMediaUploadState());
function buildFormData(): FormData {
const formData = new FormData();
@ -36,17 +40,17 @@ function buildFormData(): FormData {
formData.append('phone', form.phone);
formData.append('address', form.address);
if (form.logo) {
formData.append('logo', form.logo);
}
logoState.value.newFiles.forEach((file) => {
formData.append('logo', file);
});
if (form.favicon) {
formData.append('favicon', form.favicon);
}
faviconState.value.newFiles.forEach((file) => {
formData.append('favicon', file);
});
if (form.login_cover) {
formData.append('login_cover', form.login_cover);
}
loginCoverState.value.newFiles.forEach((file) => {
formData.append('login_cover', file);
});
return formData;
}
@ -114,17 +118,17 @@ function submit() {
</FieldGroup>
<FieldGroup class="grid gap-6 sm:grid-cols-3">
<ImageUploadField id="logo" v-model="form.logo" label="Logo" required
description="Disarankan PNG transparan, maks. 2 MB." :current-url="data.logo_url"
<MediaDropzone id="logo" v-model="logoState" label="Logo" required
description="Disarankan PNG transparan, maks. 2 MB." :max-files="1"
:errors="formErrors(form, 'logo')" />
<ImageUploadField id="favicon" v-model="form.favicon" label="Favicon"
description="PNG/SVG, maks. 1 MB." :current-url="data.favicon_url"
<MediaDropzone id="favicon" v-model="faviconState" label="Favicon"
description="PNG/SVG, maks. 1 MB." :max-files="1"
:errors="formErrors(form, 'favicon')" />
<ImageUploadField id="login_cover" v-model="form.login_cover" label="Cover Login" required
description="Gambar latar halaman login, maks. 5 MB."
:current-url="data.login_cover_url" :errors="formErrors(form, 'login_cover')" />
<MediaDropzone id="login_cover" v-model="loginCoverState" label="Cover Login" required
description="Gambar latar halaman login, maks. 5 MB." :max-files="1"
:errors="formErrors(form, 'login_cover')" />
</FieldGroup>
</FieldSet>
</CardContent>