refactor: replace legacy image upload components with new MediaDropzone component across various admin forms
This commit is contained in:
parent
66bdd86b4f
commit
5986ec75a3
26
package-lock.json
generated
26
package-lock.json
generated
@ -25,6 +25,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
|
"dropzone-vue": "^0.1.11",
|
||||||
"laravel-vite-plugin": "^3.1",
|
"laravel-vite-plugin": "^3.1",
|
||||||
"reka-ui": "^2.9.10",
|
"reka-ui": "^2.9.10",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
@ -6176,6 +6177,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/core-js-compat": {
|
||||||
"version": "3.49.0",
|
"version": "3.49.0",
|
||||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz",
|
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz",
|
||||||
@ -6954,6 +6967,19 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
|||||||
@ -53,6 +53,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
|
"dropzone-vue": "^0.1.11",
|
||||||
"laravel-vite-plugin": "^3.1",
|
"laravel-vite-plugin": "^3.1",
|
||||||
"reka-ui": "^2.9.10",
|
"reka-ui": "^2.9.10",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
|
|||||||
633
resources/js/components/media/MediaDropzone.vue
Normal file
633
resources/js/components/media/MediaDropzone.vue
Normal 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(', ') }} — 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(', ') }} — 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>
|
||||||
@ -3,8 +3,8 @@ import { useForm } from '@inertiajs/vue3';
|
|||||||
import { Save } from '@lucide/vue';
|
import { Save } from '@lucide/vue';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import ImageUploadField from '@/components/form/image-upload-field/ImageUploadField.vue';
|
|
||||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||||
|
import MediaDropzone from '@/components/media/MediaDropzone.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -26,8 +26,9 @@ import { CashTransactionType } from '@/constants/cash-transaction-type';
|
|||||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||||
import { formErrors } from '@/lib/form';
|
import { formErrors } from '@/lib/form';
|
||||||
import { parseRupiah } from '@/lib/rupiah';
|
import { parseRupiah } from '@/lib/rupiah';
|
||||||
import type { CashTransactionFormData, CashTransactionListItem } from '@/types/cash';
|
import type { CashTransactionListItem } from '@/types/cash';
|
||||||
import { appendPhotosToFormData } from '@/types/media';
|
import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media';
|
||||||
|
import type { MediaUploadState } from '@/types/media';
|
||||||
|
|
||||||
const open = defineModel<boolean>('open', { default: false });
|
const open = defineModel<boolean>('open', { default: false });
|
||||||
|
|
||||||
@ -46,29 +47,16 @@ const currentMode = computed(() => {
|
|||||||
});
|
});
|
||||||
const isWithdrawal = computed(() => currentMode.value === CashTransactionType.WITHDRAWAL);
|
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({
|
||||||
|
|
||||||
const form = useForm<CashTransactionFormData>({
|
|
||||||
amount: '',
|
amount: '',
|
||||||
description: '',
|
description: '',
|
||||||
photos: [],
|
|
||||||
remove_media_ids: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const photoFile = computed<File | null>({
|
|
||||||
get: () => form.photos[0] ?? null,
|
|
||||||
set: (file) => {
|
|
||||||
form.photos = file ? [file] : [];
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
form.reset();
|
form.reset();
|
||||||
form.photos = [];
|
photoState.value = createMediaUploadState();
|
||||||
form.remove_media_ids = [];
|
|
||||||
existingPhotoId.value = null;
|
|
||||||
form.clearErrors();
|
form.clearErrors();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,7 +69,7 @@ function populateForm(transaction: CashTransactionListItem | null | undefined) {
|
|||||||
|
|
||||||
form.amount = String(transaction.amount);
|
form.amount = String(transaction.amount);
|
||||||
form.description = transaction.description;
|
form.description = transaction.description;
|
||||||
existingPhotoId.value = transaction.photos?.[0]?.id ?? null;
|
photoState.value = createMediaUploadState(transaction.photos ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
useFormDialog({
|
useFormDialog({
|
||||||
@ -101,13 +89,7 @@ function buildFormData(forUpdate: boolean): FormData {
|
|||||||
formData.append('amount', parseRupiah(form.amount));
|
formData.append('amount', parseRupiah(form.amount));
|
||||||
formData.append('description', form.description);
|
formData.append('description', form.description);
|
||||||
|
|
||||||
const removeMediaIds = [...form.remove_media_ids];
|
appendRootPhotosToFormData(formData, photoState.value);
|
||||||
|
|
||||||
if (form.photos.length > 0 && existingPhotoId.value !== null) {
|
|
||||||
removeMediaIds.push(existingPhotoId.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
appendPhotosToFormData(formData, form.photos, removeMediaIds);
|
|
||||||
|
|
||||||
return formData;
|
return formData;
|
||||||
}
|
}
|
||||||
@ -173,8 +155,8 @@ const placeholder = computed(() =>
|
|||||||
rows="3" :maxlength="FIELD_LIMITS.description" />
|
rows="3" :maxlength="FIELD_LIMITS.description" />
|
||||||
<FieldError :errors="formErrors(form, 'description')" />
|
<FieldError :errors="formErrors(form, 'description')" />
|
||||||
</Field>
|
</Field>
|
||||||
<ImageUploadField id="cash-photos" v-model="photoFile" label="Foto Bukti" required
|
<MediaDropzone id="cash-photos" v-model="photoState" label="Foto Bukti" required :max-files="1"
|
||||||
:current-url="currentPhotoUrl" :errors="formErrors(form, 'photos')" />
|
:errors="formErrors(form, 'photos')" />
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
|
|
||||||
|
|||||||
@ -3,8 +3,8 @@ import { useForm } from '@inertiajs/vue3';
|
|||||||
import { Save } from '@lucide/vue';
|
import { Save } from '@lucide/vue';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import { ImageUploadField } from '@/components/form/image-upload-field';
|
|
||||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||||
|
import MediaDropzone from '@/components/media/MediaDropzone.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -25,8 +25,9 @@ import { useFormDialog } from '@/composables/useFormDialog';
|
|||||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||||
import { formErrors } from '@/lib/form';
|
import { formErrors } from '@/lib/form';
|
||||||
import { parseRupiah } from '@/lib/rupiah';
|
import { parseRupiah } from '@/lib/rupiah';
|
||||||
import type { ExpenseFormData, ExpenseListItem } from '@/types/expense';
|
import type { ExpenseListItem } from '@/types/expense';
|
||||||
import { appendPhotosToFormData } from '@/types/media';
|
import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media';
|
||||||
|
import type { MediaUploadState } from '@/types/media';
|
||||||
|
|
||||||
const open = defineModel<boolean>('open', { default: false });
|
const open = defineModel<boolean>('open', { default: false });
|
||||||
|
|
||||||
@ -36,29 +37,16 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const isEditing = computed(() => props.expense != null);
|
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({
|
||||||
|
|
||||||
const form = useForm<ExpenseFormData>({
|
|
||||||
amount: '',
|
amount: '',
|
||||||
description: '',
|
description: '',
|
||||||
photos: [],
|
|
||||||
remove_media_ids: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const photoFile = computed<File | null>({
|
|
||||||
get: () => form.photos[0] ?? null,
|
|
||||||
set: (file) => {
|
|
||||||
form.photos = file ? [file] : [];
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
form.reset();
|
form.reset();
|
||||||
form.photos = [];
|
photoState.value = createMediaUploadState();
|
||||||
form.remove_media_ids = [];
|
|
||||||
existingPhotoId.value = null;
|
|
||||||
form.clearErrors();
|
form.clearErrors();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,7 +59,7 @@ function populateForm(expense: ExpenseListItem | null | undefined) {
|
|||||||
|
|
||||||
form.amount = String(expense.amount);
|
form.amount = String(expense.amount);
|
||||||
form.description = expense.description;
|
form.description = expense.description;
|
||||||
existingPhotoId.value = expense.photos?.[0]?.id ?? null;
|
photoState.value = createMediaUploadState(expense.photos ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
useFormDialog({
|
useFormDialog({
|
||||||
@ -91,13 +79,7 @@ function buildFormData(forUpdate: boolean): FormData {
|
|||||||
formData.append('amount', parseRupiah(form.amount));
|
formData.append('amount', parseRupiah(form.amount));
|
||||||
formData.append('description', form.description);
|
formData.append('description', form.description);
|
||||||
|
|
||||||
const removeMediaIds = [...form.remove_media_ids];
|
appendRootPhotosToFormData(formData, photoState.value);
|
||||||
|
|
||||||
if (form.photos.length > 0 && existingPhotoId.value !== null) {
|
|
||||||
removeMediaIds.push(existingPhotoId.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
appendPhotosToFormData(formData, form.photos, removeMediaIds);
|
|
||||||
|
|
||||||
return formData;
|
return formData;
|
||||||
}
|
}
|
||||||
@ -148,8 +130,8 @@ function submit() {
|
|||||||
:maxlength="FIELD_LIMITS.description" />
|
:maxlength="FIELD_LIMITS.description" />
|
||||||
<FieldError :errors="formErrors(form, 'description')" />
|
<FieldError :errors="formErrors(form, 'description')" />
|
||||||
</Field>
|
</Field>
|
||||||
<ImageUploadField id="expense-photos" v-model="photoFile" label="Foto Bukti" required
|
<MediaDropzone id="expense-photos" v-model="photoState" label="Foto Bukti" required
|
||||||
:current-url="currentPhotoUrl" :errors="formErrors(form, 'photos')" />
|
:max-files="1" :errors="formErrors(form, 'photos')" />
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
|
|
||||||
|
|||||||
@ -3,11 +3,9 @@ import { useForm } from '@inertiajs/vue3';
|
|||||||
import { Minus, Plus, Save, Search, ShoppingCart, Trash2 } from '@lucide/vue';
|
import { Minus, Plus, Save, Search, ShoppingCart, Trash2 } from '@lucide/vue';
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import PosCatalogCard from '../../shared/PosCatalogCard.vue';
|
|
||||||
import PosCatalogVariantThumb from '../../shared/PosCatalogVariantThumb.vue';
|
|
||||||
import { DecimalInput } from '@/components/form/decimal-input';
|
import { DecimalInput } from '@/components/form/decimal-input';
|
||||||
import { ImageUploadField } from '@/components/form/image-upload-field';
|
|
||||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||||
|
import MediaDropzone from '@/components/media/MediaDropzone.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
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 { FIELD_LIMITS } from '@/lib/field-limits';
|
||||||
import { formErrors } from '@/lib/form';
|
import { formErrors } from '@/lib/form';
|
||||||
import { formatRupiah, parseRupiah } from '@/lib/rupiah';
|
import { formatRupiah, parseRupiah } from '@/lib/rupiah';
|
||||||
import { appendPhotosToFormData } from '@/types/media';
|
import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media';
|
||||||
import type { MediaItem } from '@/types/media';
|
import type { MediaItem, MediaUploadState } from '@/types/media';
|
||||||
import type { PurchaseCartItem, PurchaseCatalogItem, SelectOption } from '@/types/purchase';
|
import type { PurchaseCartItem, PurchaseCatalogItem, SelectOption } from '@/types/purchase';
|
||||||
|
import PosCatalogCard from '../../shared/PosCatalogCard.vue';
|
||||||
|
import PosCatalogVariantThumb from '../../shared/PosCatalogVariantThumb.vue';
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
suppliers: SelectOption[];
|
suppliers: SelectOption[];
|
||||||
catalog: PurchaseCatalogItem[];
|
catalog: PurchaseCatalogItem[];
|
||||||
@ -70,24 +70,13 @@ const isCreateMode = computed(() => props.method === 'post');
|
|||||||
const search = ref('');
|
const search = ref('');
|
||||||
const cart = ref<PurchaseCartItem[]>([]);
|
const cart = ref<PurchaseCartItem[]>([]);
|
||||||
const cartDetailOpen = ref(false);
|
const cartDetailOpen = ref(false);
|
||||||
const existingPhotoId = ref<number | null>(null);
|
const photoState = ref<MediaUploadState>(createMediaUploadState());
|
||||||
|
|
||||||
const currentPhotoUrl = computed(() => props.initialData?.photos?.url ?? null);
|
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
supplier_id: '',
|
supplier_id: '',
|
||||||
discount: '',
|
discount: '',
|
||||||
shipping_cost: '',
|
shipping_cost: '',
|
||||||
notes: '',
|
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() {
|
function populateForm() {
|
||||||
@ -99,9 +88,9 @@ function populateForm() {
|
|||||||
form.discount = props.initialData.discount;
|
form.discount = props.initialData.discount;
|
||||||
form.shipping_cost = props.initialData.shipping_cost ?? '0';
|
form.shipping_cost = props.initialData.shipping_cost ?? '0';
|
||||||
form.notes = props.initialData.notes;
|
form.notes = props.initialData.notes;
|
||||||
form.photos = [];
|
photoState.value = createMediaUploadState(
|
||||||
form.remove_media_ids = [];
|
props.initialData.photos ? [props.initialData.photos] : [],
|
||||||
existingPhotoId.value = props.initialData.photos?.id ?? null;
|
);
|
||||||
cart.value = props.initialData.items.map((item) => ({ ...item }));
|
cart.value = props.initialData.items.map((item) => ({ ...item }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -311,13 +300,7 @@ function buildFormData(): FormData {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeMediaIds = [...form.remove_media_ids];
|
appendRootPhotosToFormData(formData, photoState.value);
|
||||||
|
|
||||||
if (form.photos.length > 0 && existingPhotoId.value !== null) {
|
|
||||||
removeMediaIds.push(existingPhotoId.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
appendPhotosToFormData(formData, form.photos, removeMediaIds);
|
|
||||||
|
|
||||||
return formData;
|
return formData;
|
||||||
}
|
}
|
||||||
@ -382,12 +365,10 @@ function submit() {
|
|||||||
Belum ada varian
|
Belum ada varian
|
||||||
</p>
|
</p>
|
||||||
<div v-for="price in rawMaterial.prices" :key="price.id"
|
<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="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200" :class="[
|
||||||
:class="[
|
|
||||||
'cursor-pointer hover:bg-muted/30',
|
'cursor-pointer hover:bg-muted/30',
|
||||||
getCartItem(price.id) ? 'border-2 border-primary bg-primary/5 rounded-md mx-1 my-0.5' : ''
|
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" />
|
<PosCatalogVariantThumb :items="price.images" />
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="truncate text-sm font-medium">
|
<p class="truncate text-sm font-medium">
|
||||||
@ -427,12 +408,9 @@ function submit() {
|
|||||||
<ShoppingCart class="size-4" />
|
<ShoppingCart class="size-4" />
|
||||||
Ringkasan Belanja
|
Ringkasan Belanja
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button v-if="cart.length > 0" type="button"
|
||||||
v-if="cart.length > 0"
|
|
||||||
type="button"
|
|
||||||
class="text-xs font-normal text-primary underline underline-offset-2 hover:text-primary/80"
|
class="text-xs font-normal text-primary underline underline-offset-2 hover:text-primary/80"
|
||||||
@click="cartDetailOpen = true"
|
@click="cartDetailOpen = true">
|
||||||
>
|
|
||||||
Lihat Detail
|
Lihat Detail
|
||||||
</button>
|
</button>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@ -551,8 +529,8 @@ function submit() {
|
|||||||
<FieldError :errors="formErrors(form, 'notes')" />
|
<FieldError :errors="formErrors(form, 'notes')" />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<ImageUploadField id="purchase-photos" v-model="photoFile" label="Bukti Transaksi"
|
<MediaDropzone id="purchase-photos" v-model="photoState" label="Bukti Transaksi"
|
||||||
:current-url="currentPhotoUrl" :errors="formErrors(form, 'photos')" />
|
:max-files="1" :errors="formErrors(form, 'photos')" />
|
||||||
|
|
||||||
<Button type="submit" class="w-full" :disabled="form.processing || cart.length === 0">
|
<Button type="submit" class="w-full" :disabled="form.processing || cart.length === 0">
|
||||||
<Save class="size-4" />
|
<Save class="size-4" />
|
||||||
@ -571,11 +549,7 @@ function submit() {
|
|||||||
<DialogTitle>Detail Keranjang</DialogTitle>
|
<DialogTitle>Detail Keranjang</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div class="max-h-96 space-y-3 overflow-y-auto overscroll-y-contain scrollbar-thin">
|
<div class="max-h-96 space-y-3 overflow-y-auto overscroll-y-contain scrollbar-thin">
|
||||||
<div
|
<div v-for="item in cart" :key="`detail-${item.raw_material_price_id}`" class="rounded-lg border p-3">
|
||||||
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="flex items-start justify-between gap-2">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="truncate text-sm font-medium">{{ item.raw_material_name }}</p>
|
<p class="truncate text-sm font-medium">{{ item.raw_material_name }}</p>
|
||||||
|
|||||||
@ -3,8 +3,8 @@ import { useForm } from '@inertiajs/vue3';
|
|||||||
import { Plus, Save, Trash2 } from '@lucide/vue';
|
import { Plus, Save, Trash2 } from '@lucide/vue';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import { MultipleImageUploadField } from '@/components/form/image-upload-field';
|
|
||||||
import { NumberInput } from '@/components/form/number-input';
|
import { NumberInput } from '@/components/form/number-input';
|
||||||
|
import MediaDropzone from '@/components/media/MediaDropzone.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
@ -223,8 +223,8 @@ function submit() {
|
|||||||
</FieldSet>
|
</FieldSet>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<MultipleImageUploadField :id="`variant_images_${variant.client_id}`"
|
<MediaDropzone :id="`variant_images_${variant.client_id}`" v-model="variant.media"
|
||||||
v-model="variant.media" label="Foto Varian" :max-files="5" required
|
label="Foto Varian" :max-files="5" required
|
||||||
:errors="variantErrors(form, variant.client_id, 'images')" />
|
:errors="variantErrors(form, variant.client_id, 'images')" />
|
||||||
</div>
|
</div>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
|
|||||||
@ -4,8 +4,8 @@ import { Copy, Plus, Save, Trash2 } from '@lucide/vue';
|
|||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import { DecimalInput } from '@/components/form/decimal-input';
|
import { DecimalInput } from '@/components/form/decimal-input';
|
||||||
import { MultipleImageUploadField } from '@/components/form/image-upload-field';
|
|
||||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||||
|
import MediaDropzone from '@/components/media/MediaDropzone.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
@ -252,8 +252,7 @@ function submit() {
|
|||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
<RupiahInput :id="`shared_price_${prices[0].client_id}`" :model-value="prices[0].price"
|
<RupiahInput :id="`shared_price_${prices[0].client_id}`" :model-value="prices[0].price"
|
||||||
@update:model-value="setSharedPrice" />
|
@update:model-value="setSharedPrice" />
|
||||||
<FieldError
|
<FieldError :errors="formErrors(form, 'prices.0.price')" />
|
||||||
:errors="formErrors(form, 'prices.0.price')" />
|
|
||||||
</Field>
|
</Field>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -285,8 +284,7 @@ function submit() {
|
|||||||
<Input :id="`variant_${price.client_id}`" :model-value="price.variant" type="text"
|
<Input :id="`variant_${price.client_id}`" :model-value="price.variant" type="text"
|
||||||
placeholder="Contoh: Premium / 40s" :maxlength="FIELD_LIMITS.variantName"
|
placeholder="Contoh: Premium / 40s" :maxlength="FIELD_LIMITS.variantName"
|
||||||
@update:model-value="setPriceField(price.client_id, 'variant', String($event))" />
|
@update:model-value="setPriceField(price.client_id, 'variant', String($event))" />
|
||||||
<FieldError
|
<FieldError :errors="priceErrors(form, price.client_id, 'variant')" />
|
||||||
:errors="priceErrors(form, price.client_id, 'variant')" />
|
|
||||||
</Field>
|
</Field>
|
||||||
<Field v-if="method !== 'put'">
|
<Field v-if="method !== 'put'">
|
||||||
<FieldLabel :for="`stock_${price.client_id}`" required>
|
<FieldLabel :for="`stock_${price.client_id}`" required>
|
||||||
@ -294,8 +292,7 @@ function submit() {
|
|||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
<DecimalInput :id="`stock_${price.client_id}`" :model-value="price.stock"
|
<DecimalInput :id="`stock_${price.client_id}`" :model-value="price.stock"
|
||||||
@update:model-value="setPriceField(price.client_id, 'stock', String($event))" />
|
@update:model-value="setPriceField(price.client_id, 'stock', String($event))" />
|
||||||
<FieldError
|
<FieldError :errors="priceErrors(form, price.client_id, 'stock')" />
|
||||||
:errors="priceErrors(form, price.client_id, 'stock')" />
|
|
||||||
</Field>
|
</Field>
|
||||||
<Field v-if="!useSamePrice || prices.length === 1">
|
<Field v-if="!useSamePrice || prices.length === 1">
|
||||||
<FieldLabel :for="`price_${price.client_id}`" required>
|
<FieldLabel :for="`price_${price.client_id}`" required>
|
||||||
@ -303,13 +300,12 @@ function submit() {
|
|||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
<RupiahInput :id="`price_${price.client_id}`" :model-value="price.price"
|
<RupiahInput :id="`price_${price.client_id}`" :model-value="price.price"
|
||||||
@update:model-value="setPriceValue(price.client_id, $event)" />
|
@update:model-value="setPriceValue(price.client_id, $event)" />
|
||||||
<FieldError
|
<FieldError :errors="priceErrors(form, price.client_id, 'price')" />
|
||||||
:errors="priceErrors(form, price.client_id, 'price')" />
|
|
||||||
</Field>
|
</Field>
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
|
|
||||||
<div class="mt-4">
|
<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
|
label="Foto Varian" :max-files="5" required
|
||||||
:errors="priceErrors(form, price.client_id, 'images')" />
|
:errors="priceErrors(form, price.client_id, 'images')" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,8 +3,7 @@ import { router } from '@inertiajs/vue3';
|
|||||||
import { Save } from '@lucide/vue';
|
import { Save } from '@lucide/vue';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import ImageUploadField from '@/components/form/image-upload-field/ImageUploadField.vue';
|
import MediaDropzone from '@/components/media/MediaDropzone.vue';
|
||||||
import MultipleImageUploadField from '@/components/form/image-upload-field/MultipleImageUploadField.vue';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { createMediaUploadState } from '@/types/media';
|
import { createMediaUploadState } from '@/types/media';
|
||||||
@ -15,8 +14,8 @@ const props = defineProps<{
|
|||||||
data: HomepageSettingsData;
|
data: HomepageSettingsData;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const heroImage = ref<File | null>(null);
|
const heroImage = ref<MediaUploadState>(createMediaUploadState());
|
||||||
const aboutImage = ref<File | null>(null);
|
const aboutImage = ref<MediaUploadState>(createMediaUploadState());
|
||||||
const galleryState = ref<MediaUploadState>(
|
const galleryState = ref<MediaUploadState>(
|
||||||
createMediaUploadState(props.data.gallery_images),
|
createMediaUploadState(props.data.gallery_images),
|
||||||
);
|
);
|
||||||
@ -27,13 +26,13 @@ function submit() {
|
|||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
if (heroImage.value) {
|
heroImage.value.newFiles.forEach((file) => {
|
||||||
formData.append('hero_image', heroImage.value);
|
formData.append('hero_image', file);
|
||||||
}
|
});
|
||||||
|
|
||||||
if (aboutImage.value) {
|
aboutImage.value.newFiles.forEach((file) => {
|
||||||
formData.append('about_image', aboutImage.value);
|
formData.append('about_image', file);
|
||||||
}
|
});
|
||||||
|
|
||||||
galleryState.value.newFiles.forEach((file) => {
|
galleryState.value.newFiles.forEach((file) => {
|
||||||
formData.append('gallery_images[]', file);
|
formData.append('gallery_images[]', file);
|
||||||
@ -46,8 +45,8 @@ function submit() {
|
|||||||
router.put('/admin/system/settings/homepage', formData, {
|
router.put('/admin/system/settings/homepage', formData, {
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
heroImage.value = null;
|
heroImage.value = createMediaUploadState();
|
||||||
aboutImage.value = null;
|
aboutImage.value = createMediaUploadState();
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error('Gagal menyimpan pengaturan homepage.');
|
toast.error('Gagal menyimpan pengaturan homepage.');
|
||||||
@ -65,27 +64,17 @@ function submit() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ImageUploadField id="hero_image" label="Foto Hero"
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
description="Foto utama yang ditampilkan di bagian hero homepage."
|
<MediaDropzone id="hero_image" label="Foto Hero" :max-files="1" v-model="heroImage" />
|
||||||
:current-url="props.data.hero_image_url" v-model="heroImage"
|
|
||||||
preview-class="aspect-[3/4] max-w-xs" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<MediaDropzone id="about_image" label="Foto Tentang Kami" :max-files="1"
|
||||||
<CardContent>
|
v-model="aboutImage" />
|
||||||
<MultipleImageUploadField id="gallery_images" label="Koleksi Lookbook"
|
|
||||||
description="Foto-foto yang ditampilkan di galeri lookbook." :max-files="10"
|
|
||||||
v-model="galleryState" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<div class="sm:col-span-2">
|
||||||
<CardContent>
|
<MediaDropzone id="gallery_images" label="Koleksi Lookbook" :max-files="10"
|
||||||
<ImageUploadField id="about_image" label="Foto Tentang Kami"
|
v-model="galleryState" />
|
||||||
description="Foto yang ditampilkan di bagian tentang kami."
|
</div>
|
||||||
:current-url="props.data.about_image_url" v-model="aboutImage"
|
</div>
|
||||||
preview-class="aspect-video max-w-md" />
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@ -1,15 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useForm } from '@inertiajs/vue3';
|
import { useForm } from '@inertiajs/vue3';
|
||||||
import { Save } from '@lucide/vue';
|
import { Save } from '@lucide/vue';
|
||||||
|
import { ref } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import { ImageUploadField } from '@/components/form/image-upload-field';
|
|
||||||
import { PhoneNumberInput } from '@/components/form/phone-number-input';
|
import { PhoneNumberInput } from '@/components/form/phone-number-input';
|
||||||
|
import MediaDropzone from '@/components/media/MediaDropzone.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from '@/components/ui/field';
|
import { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from '@/components/ui/field';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { formErrors } from '@/lib/form';
|
import { formErrors } from '@/lib/form';
|
||||||
|
import { createMediaUploadState } from '@/types/media';
|
||||||
|
import type { MediaUploadState } from '@/types/media';
|
||||||
import type { SystemSettingsData } from '@/types/setting';
|
import type { SystemSettingsData } from '@/types/setting';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@ -22,11 +25,12 @@ const form = useForm({
|
|||||||
email: props.data.email ?? '',
|
email: props.data.email ?? '',
|
||||||
phone: props.data.phone ?? '',
|
phone: props.data.phone ?? '',
|
||||||
address: props.data.address ?? '',
|
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 {
|
function buildFormData(): FormData {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
@ -36,17 +40,17 @@ function buildFormData(): FormData {
|
|||||||
formData.append('phone', form.phone);
|
formData.append('phone', form.phone);
|
||||||
formData.append('address', form.address);
|
formData.append('address', form.address);
|
||||||
|
|
||||||
if (form.logo) {
|
logoState.value.newFiles.forEach((file) => {
|
||||||
formData.append('logo', form.logo);
|
formData.append('logo', file);
|
||||||
}
|
});
|
||||||
|
|
||||||
if (form.favicon) {
|
faviconState.value.newFiles.forEach((file) => {
|
||||||
formData.append('favicon', form.favicon);
|
formData.append('favicon', file);
|
||||||
}
|
});
|
||||||
|
|
||||||
if (form.login_cover) {
|
loginCoverState.value.newFiles.forEach((file) => {
|
||||||
formData.append('login_cover', form.login_cover);
|
formData.append('login_cover', file);
|
||||||
}
|
});
|
||||||
|
|
||||||
return formData;
|
return formData;
|
||||||
}
|
}
|
||||||
@ -114,17 +118,17 @@ function submit() {
|
|||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
|
|
||||||
<FieldGroup class="grid gap-6 sm:grid-cols-3">
|
<FieldGroup class="grid gap-6 sm:grid-cols-3">
|
||||||
<ImageUploadField id="logo" v-model="form.logo" label="Logo" required
|
<MediaDropzone id="logo" v-model="logoState" label="Logo" required
|
||||||
description="Disarankan PNG transparan, maks. 2 MB." :current-url="data.logo_url"
|
description="Disarankan PNG transparan, maks. 2 MB." :max-files="1"
|
||||||
:errors="formErrors(form, 'logo')" />
|
:errors="formErrors(form, 'logo')" />
|
||||||
|
|
||||||
<ImageUploadField id="favicon" v-model="form.favicon" label="Favicon"
|
<MediaDropzone id="favicon" v-model="faviconState" label="Favicon"
|
||||||
description="PNG/SVG, maks. 1 MB." :current-url="data.favicon_url"
|
description="PNG/SVG, maks. 1 MB." :max-files="1"
|
||||||
:errors="formErrors(form, 'favicon')" />
|
:errors="formErrors(form, 'favicon')" />
|
||||||
|
|
||||||
<ImageUploadField id="login_cover" v-model="form.login_cover" label="Cover Login" required
|
<MediaDropzone id="login_cover" v-model="loginCoverState" label="Cover Login" required
|
||||||
description="Gambar latar halaman login, maks. 5 MB."
|
description="Gambar latar halaman login, maks. 5 MB." :max-files="1"
|
||||||
:current-url="data.login_cover_url" :errors="formErrors(form, 'login_cover')" />
|
:errors="formErrors(form, 'login_cover')" />
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user