feat: enhance media preview functionality in catalog components by adding support for multiple image URLs and custom titles
This commit is contained in:
parent
3792424d52
commit
f1f9162304
@ -1,16 +1,30 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Package } from '@lucide/vue';
|
import { Package } from '@lucide/vue';
|
||||||
import { ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.vue';
|
import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.vue';
|
||||||
import type { MediaItem } from '@/types/media';
|
import type { MediaItem } from '@/types/media';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
items?: MediaItem[];
|
defineProps<{
|
||||||
|
items?: MediaItem[];
|
||||||
|
customPreview?: boolean;
|
||||||
|
title?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
items: () => [],
|
||||||
|
customPreview: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'click-thumb': [event: MouseEvent];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const previewOpen = ref(false);
|
const previewOpen = ref(false);
|
||||||
const previewUrl = ref<string | null>(null);
|
const previewUrl = ref<string | null>(null);
|
||||||
|
|
||||||
|
const urls = computed(() => props.items?.map((item) => item.url) ?? []);
|
||||||
|
|
||||||
function openPreview() {
|
function openPreview() {
|
||||||
if (!props.items?.length) {
|
if (!props.items?.length) {
|
||||||
return;
|
return;
|
||||||
@ -19,6 +33,14 @@ function openPreview() {
|
|||||||
previewUrl.value = props.items[0].url;
|
previewUrl.value = props.items[0].url;
|
||||||
previewOpen.value = true;
|
previewOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (props.customPreview) {
|
||||||
|
emit('click-thumb', e);
|
||||||
|
} else {
|
||||||
|
openPreview();
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -27,7 +49,7 @@ function openPreview() {
|
|||||||
class="size-10 shrink-0 overflow-hidden rounded border bg-muted/30"
|
class="size-10 shrink-0 overflow-hidden rounded border bg-muted/30"
|
||||||
:class="items?.length ? 'cursor-zoom-in' : 'cursor-default'"
|
:class="items?.length ? 'cursor-zoom-in' : 'cursor-default'"
|
||||||
:disabled="!items?.length"
|
:disabled="!items?.length"
|
||||||
@click.stop="openPreview"
|
@click.stop="handleClick"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="items?.length"
|
v-if="items?.length"
|
||||||
@ -43,5 +65,5 @@ function openPreview() {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<MediaPreviewDialog v-model:open="previewOpen" :url="previewUrl" />
|
<MediaPreviewDialog v-model:open="previewOpen" v-model:url="previewUrl" :urls="urls" :title="title" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { watch } from 'vue';
|
import { ChevronLeft, ChevronRight } from '@lucide/vue';
|
||||||
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -11,28 +13,153 @@ const open = defineModel<boolean>('open', { default: false });
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: [];
|
close: [];
|
||||||
|
'update:url': [url: string];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
defineProps<{
|
const props = withDefaults(
|
||||||
url: string | null;
|
defineProps<{
|
||||||
title?: string;
|
url: string | null;
|
||||||
}>();
|
urls?: string[];
|
||||||
|
title?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
urls: () => [],
|
||||||
|
title: 'Pratinjau Foto',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeUrl = ref<string | null>(props.url);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.url,
|
||||||
|
(newUrl) => {
|
||||||
|
activeUrl.value = newUrl;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
watch(open, (val) => {
|
watch(open, (val) => {
|
||||||
if (!val) {
|
if (!val) {
|
||||||
emit('close');
|
emit('close');
|
||||||
|
} else {
|
||||||
|
activeUrl.value = props.url;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const allUrls = computed(() => {
|
||||||
|
if (props.urls && props.urls.length > 0) {
|
||||||
|
return props.urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.url ? [props.url] : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentIndex = computed(() => {
|
||||||
|
if (!activeUrl.value) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return allUrls.value.indexOf(activeUrl.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasMultiple = computed(() => allUrls.value.length > 1);
|
||||||
|
|
||||||
|
function nextImage() {
|
||||||
|
if (!hasMultiple.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextIdx = (currentIndex.value + 1) % allUrls.value.length;
|
||||||
|
const newUrl = allUrls.value[nextIdx];
|
||||||
|
activeUrl.value = newUrl;
|
||||||
|
emit('update:url', newUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevImage() {
|
||||||
|
if (!hasMultiple.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevIdx = (currentIndex.value - 1 + allUrls.value.length) % allUrls.value.length;
|
||||||
|
const newUrl = allUrls.value[prevIdx];
|
||||||
|
activeUrl.value = newUrl;
|
||||||
|
emit('update:url', newUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (!open.value || !hasMultiple.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ArrowRight') {
|
||||||
|
nextImage();
|
||||||
|
} else if (e.key === 'ArrowLeft') {
|
||||||
|
prevImage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
});
|
||||||
|
|
||||||
|
let touchStartX = 0;
|
||||||
|
let touchEndX = 0;
|
||||||
|
|
||||||
|
function handleTouchStart(e: TouchEvent) {
|
||||||
|
touchStartX = e.changedTouches[0].screenX;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchEnd(e: TouchEvent) {
|
||||||
|
touchEndX = e.changedTouches[0].screenX;
|
||||||
|
const diff = touchStartX - touchEndX;
|
||||||
|
|
||||||
|
if (Math.abs(diff) > 50) {
|
||||||
|
if (diff > 0) {
|
||||||
|
nextImage();
|
||||||
|
} else {
|
||||||
|
prevImage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dialog v-model:open="open">
|
<Dialog v-model:open="open">
|
||||||
<DialogContent class="sm:max-wd p-2 sm:p-4">
|
<DialogContent class="sm:max-w-2xl p-4 overflow-hidden gap-4">
|
||||||
<DialogHeader class="sr-only">
|
<DialogHeader class="pb-2 border-b">
|
||||||
<DialogTitle>{{ title ?? 'Pratinjau Gambar' }}</DialogTitle>
|
<DialogTitle class="text-base font-semibold">{{ title ?? 'Pratinjau Gambar' }}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<img v-if="url" :src="url" :alt="title ?? 'Pratinjau gambar'"
|
|
||||||
class="max-h-[80vh] w-full rounded-md object-contain">
|
<div class="relative flex items-center justify-center min-h-[300px] select-none"
|
||||||
|
@touchstart="handleTouchStart" @touchend="handleTouchEnd">
|
||||||
|
<img v-if="activeUrl" :key="activeUrl" :src="activeUrl" :alt="title ?? 'Pratinjau gambar'"
|
||||||
|
class="max-h-[70vh] w-full rounded-md object-contain transition-all duration-300">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Navigation Bar -->
|
||||||
|
<div v-if="hasMultiple" class="mt-4 flex items-center justify-between gap-4 border-t pt-3">
|
||||||
|
<Button type="button" variant="outline" size="sm" class="flex items-center gap-1 shrink-0"
|
||||||
|
@click.stop="prevImage">
|
||||||
|
<ChevronLeft class="size-4" />
|
||||||
|
Sebelumnya
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap justify-center gap-1.5">
|
||||||
|
<button v-for="(_, idx) in allUrls" :key="idx" type="button"
|
||||||
|
class="size-2 rounded-full transition-all duration-200"
|
||||||
|
:class="idx === currentIndex ? 'bg-primary w-4' : 'bg-muted-foreground/30'"
|
||||||
|
@click="activeUrl = allUrls[idx]; emit('update:url', allUrls[idx]);" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="button" variant="outline" size="sm" class="flex items-center gap-1 shrink-0"
|
||||||
|
@click.stop="nextImage">
|
||||||
|
Berikutnya
|
||||||
|
<ChevronRight class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import type { MediaItem } from '@/types/media';
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
items: MediaItem[];
|
items: MediaItem[];
|
||||||
maxVisible?: number;
|
maxVisible?: number;
|
||||||
|
title?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const previewOpen = ref(false);
|
const previewOpen = ref(false);
|
||||||
@ -118,5 +119,5 @@ function openGallery() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<MediaPreviewDialog v-model:open="previewOpen" :url="previewUrl" @close="onPreviewClose" />
|
<MediaPreviewDialog v-model:open="previewOpen" v-model:url="previewUrl" :urls="items.map(item => item.url)" :title="title" @close="onPreviewClose" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -184,7 +184,7 @@ watch(open, (value) => {
|
|||||||
class="flex flex-col gap-2 rounded-md border p-2 sm:flex-row sm:items-center"
|
class="flex flex-col gap-2 rounded-md border p-2 sm:flex-row sm:items-center"
|
||||||
:class="item.is_initial ? 'border-primary bg-primary/5' : ''">
|
:class="item.is_initial ? 'border-primary bg-primary/5' : ''">
|
||||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||||
<PosCatalogVariantThumb :items="item.images" />
|
<PosCatalogVariantThumb :items="item.images" :title="`${item.raw_material_name} - ${item.variant}`" />
|
||||||
<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">
|
||||||
{{ item.raw_material_name }} - {{ item.variant }}
|
{{ item.raw_material_name }} - {{ item.variant }}
|
||||||
@ -238,7 +238,7 @@ watch(open, (value) => {
|
|||||||
:class="[
|
:class="[
|
||||||
isSelected(price.id) ? 'border-2 border-primary bg-primary/5' : 'cursor-pointer hover:bg-muted/30',
|
isSelected(price.id) ? 'border-2 border-primary bg-primary/5' : 'cursor-pointer hover:bg-muted/30',
|
||||||
]" @click="!isSelected(price.id) && toggleVariant(rawMaterial, price)">
|
]" @click="!isSelected(price.id) && toggleVariant(rawMaterial, price)">
|
||||||
<PosCatalogVariantThumb :items="price.images" />
|
<PosCatalogVariantThumb :items="price.images" :title="`${rawMaterial.name} - ${price.variant}`" />
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="truncate text-sm">{{ price.variant }}</p>
|
<p class="truncate text-sm">{{ price.variant }}</p>
|
||||||
<p class="text-xs tabular-nums text-muted-foreground">
|
<p class="text-xs tabular-nums text-muted-foreground">
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Layers, Minus, Plus, Search } from '@lucide/vue';
|
import { Layers, Minus, Plus, Search } from '@lucide/vue';
|
||||||
import { ref } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue';
|
import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue';
|
||||||
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
|
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
|
||||||
|
import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.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';
|
||||||
@ -27,7 +28,7 @@ import CuttingPosCombinationDialog from './CuttingPosCombinationDialog.vue';
|
|||||||
import QuickCreateRawMaterialModal from './QuickCreateRawMaterialModal.vue';
|
import QuickCreateRawMaterialModal from './QuickCreateRawMaterialModal.vue';
|
||||||
import type { CuttingCatalogPrice } from './useCuttingPosCart';
|
import type { CuttingCatalogPrice } from './useCuttingPosCart';
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
filteredRawMaterials: CuttingRawMaterialCatalogItem[];
|
filteredRawMaterials: CuttingRawMaterialCatalogItem[];
|
||||||
rawMaterialCatalog: CuttingRawMaterialCatalogItem[];
|
rawMaterialCatalog: CuttingRawMaterialCatalogItem[];
|
||||||
materialCart: CuttingMaterialCartItem[];
|
materialCart: CuttingMaterialCartItem[];
|
||||||
@ -35,6 +36,47 @@ defineProps<{
|
|||||||
units: EnumOption[];
|
units: EnumOption[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const previewOpen = ref(false);
|
||||||
|
const previewUrl = ref<string | null>(null);
|
||||||
|
const previewTitle = ref<string>('');
|
||||||
|
|
||||||
|
const allVariantImages = computed(() => {
|
||||||
|
const list: { priceId: number; title: string; url: string }[] = [];
|
||||||
|
props.filteredRawMaterials.forEach((rawMaterial) => {
|
||||||
|
rawMaterial.prices.forEach((price) => {
|
||||||
|
if (price.images && price.images.length > 0) {
|
||||||
|
list.push({
|
||||||
|
priceId: price.id,
|
||||||
|
title: `${rawMaterial.name} - ${price.variant}`,
|
||||||
|
url: price.images[0].url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewUrls = computed(() => allVariantImages.value.map((item) => item.url));
|
||||||
|
|
||||||
|
function handleThumbClick(priceId: number) {
|
||||||
|
const idx = allVariantImages.value.findIndex((item) => item.priceId === priceId);
|
||||||
|
|
||||||
|
if (idx !== -1) {
|
||||||
|
previewUrl.value = allVariantImages.value[idx].url;
|
||||||
|
previewTitle.value = allVariantImages.value[idx].title;
|
||||||
|
previewOpen.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(previewUrl, (newUrl) => {
|
||||||
|
const matched = allVariantImages.value.find((item) => item.url === newUrl);
|
||||||
|
|
||||||
|
if (matched) {
|
||||||
|
previewTitle.value = matched.title;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const materialSearch = defineModel<string>('materialSearch', { required: true });
|
const materialSearch = defineModel<string>('materialSearch', { required: true });
|
||||||
const selectedRawMaterialId = defineModel<string>('selectedRawMaterialId', { required: true });
|
const selectedRawMaterialId = defineModel<string>('selectedRawMaterialId', { required: true });
|
||||||
|
|
||||||
@ -102,67 +144,63 @@ function openCombination(rawMaterial: CuttingRawMaterialCatalogItem, price: Cutt
|
|||||||
|
|
||||||
<div v-else class="columns-1 gap-4 sm:columns-2 xl:columns-3">
|
<div v-else class="columns-1 gap-4 sm:columns-2 xl:columns-3">
|
||||||
<template v-for="rawMaterial in filteredRawMaterials" :key="rawMaterial.id">
|
<template v-for="rawMaterial in filteredRawMaterials" :key="rawMaterial.id">
|
||||||
<PosCatalogCard v-if="rawMaterial.prices.length > 0"
|
<PosCatalogCard v-if="rawMaterial.prices.length > 0" :title="rawMaterial.name"
|
||||||
:title="rawMaterial.name" :cover-image="getFirstCoverImage(rawMaterial.prices)">
|
:cover-image="getFirstCoverImage(rawMaterial.prices)">
|
||||||
<template #header-extra>
|
<template #header-extra>
|
||||||
<Badge variant="secondary" class="mt-1.5">
|
<Badge variant="secondary" class="mt-1.5">
|
||||||
{{ rawMaterial.unit_label }}
|
{{ rawMaterial.unit_label }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<p v-if="!rawMaterial.prices.length" class="px-3 py-4 text-sm text-muted-foreground">
|
<p v-if="!rawMaterial.prices.length" class="px-3 py-4 text-sm text-muted-foreground">
|
||||||
Belum ada varian
|
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="[
|
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200" :class="[
|
||||||
'cursor-pointer hover:bg-muted/30',
|
'cursor-pointer hover:bg-muted/30',
|
||||||
getMaterialCartItem(price.id)
|
getMaterialCartItem(price.id)
|
||||||
? 'mx-1 my-0.5 rounded-md border-2 border-primary bg-primary/5'
|
? 'mx-1 my-0.5 rounded-md border-2 border-primary bg-primary/5'
|
||||||
: '',
|
: '',
|
||||||
]" @click="!getMaterialCartItem(price.id) && emit('add-material', rawMaterial, price)">
|
]" @click="!getMaterialCartItem(price.id) && emit('add-material', rawMaterial, price)">
|
||||||
<PosCatalogVariantThumb :items="price.images" />
|
<PosCatalogVariantThumb :items="price.images" custom-preview
|
||||||
<div class="min-w-0 flex-1">
|
@click-thumb="handleThumbClick(price.id)" />
|
||||||
<p class="truncate text-sm font-medium">
|
<div class="min-w-0 flex-1">
|
||||||
{{ price.variant }}
|
<p class="truncate text-sm font-medium">
|
||||||
</p>
|
{{ price.variant }}
|
||||||
<p class="text-xs tabular-nums text-muted-foreground">
|
</p>
|
||||||
Stok: {{ price.stock_formatted }}
|
<p class="text-xs tabular-nums text-muted-foreground">
|
||||||
</p>
|
Stok: {{ price.stock_formatted }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="getMaterialCartItem(price.id)" class="flex shrink-0 items-center gap-1">
|
||||||
|
<Button type="button" variant="outline" size="icon-sm" title="Kurangi Pemakaian"
|
||||||
|
@click.stop="emit('decrease-material-qty', price.id)">
|
||||||
|
<Minus class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<span class="min-w-5 text-center text-xs font-semibold tabular-nums">
|
||||||
|
{{ getMaterialCartItem(price.id)!.material_usage }}
|
||||||
|
</span>
|
||||||
|
<Button type="button" variant="outline" size="icon-sm" title="Tambah ke Keranjang"
|
||||||
|
@click.stop="emit('add-material', rawMaterial, price)">
|
||||||
|
<Plus class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="default" size="icon-sm" title="Gunakan Kombinasi"
|
||||||
|
@click.stop="openCombination(rawMaterial, price)">
|
||||||
|
<Layers class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex shrink-0 items-center gap-1">
|
||||||
|
<Button type="button" variant="outline" size="icon-sm" title="Tambah ke Keranjang"
|
||||||
|
@click.stop="emit('add-material', rawMaterial, price)">
|
||||||
|
<Plus class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="default" size="icon-sm" title="Gunakan Kombinasi"
|
||||||
|
@click.stop="openCombination(rawMaterial, price)">
|
||||||
|
<Layers class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="getMaterialCartItem(price.id)" class="flex shrink-0 items-center gap-1">
|
</PosCatalogCard>
|
||||||
<Button type="button" variant="outline" size="icon-sm"
|
|
||||||
title="Kurangi Pemakaian"
|
|
||||||
@click.stop="emit('decrease-material-qty', price.id)">
|
|
||||||
<Minus class="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
<span class="min-w-5 text-center text-xs font-semibold tabular-nums">
|
|
||||||
{{ getMaterialCartItem(price.id)!.material_usage }}
|
|
||||||
</span>
|
|
||||||
<Button type="button" variant="outline" size="icon-sm"
|
|
||||||
title="Tambah ke Keranjang"
|
|
||||||
@click.stop="emit('add-material', rawMaterial, price)">
|
|
||||||
<Plus class="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button type="button" variant="default" size="icon-sm"
|
|
||||||
title="Gunakan Kombinasi"
|
|
||||||
@click.stop="openCombination(rawMaterial, price)">
|
|
||||||
<Layers class="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div v-else class="flex shrink-0 items-center gap-1">
|
|
||||||
<Button type="button" variant="outline" size="icon-sm"
|
|
||||||
title="Tambah ke Keranjang"
|
|
||||||
@click.stop="emit('add-material', rawMaterial, price)">
|
|
||||||
<Plus class="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button type="button" variant="default" size="icon-sm"
|
|
||||||
title="Gunakan Kombinasi"
|
|
||||||
@click.stop="openCombination(rawMaterial, price)">
|
|
||||||
<Layers class="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PosCatalogCard>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -175,4 +213,6 @@ function openCombination(rawMaterial: CuttingRawMaterialCatalogItem, price: Cutt
|
|||||||
<CuttingPosCombinationDialog v-model:open="combinationOpen" :raw-material-catalog="rawMaterialCatalog"
|
<CuttingPosCombinationDialog v-model:open="combinationOpen" :raw-material-catalog="rawMaterialCatalog"
|
||||||
:existing-cart-items="materialCart" :initial-variant="combinationInitialVariant"
|
:existing-cart-items="materialCart" :initial-variant="combinationInitialVariant"
|
||||||
@combination-created="emit('combination-created', $event)" />
|
@combination-created="emit('combination-created', $event)" />
|
||||||
|
|
||||||
|
<MediaPreviewDialog v-model:open="previewOpen" v-model:url="previewUrl" :urls="previewUrls" :title="previewTitle" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Check, Minus, Plus, Search } from '@lucide/vue';
|
import { Check, Minus, Plus, Search } from '@lucide/vue';
|
||||||
import { ref } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue';
|
import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue';
|
||||||
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
|
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
|
||||||
|
import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.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 {
|
||||||
@ -18,7 +19,7 @@ import type { CategoryOption } from '@/types/product';
|
|||||||
import QuickCreateProductModal from './QuickCreateProductModal.vue';
|
import QuickCreateProductModal from './QuickCreateProductModal.vue';
|
||||||
import type { CuttingCatalogVariant } from './useCuttingPosCart';
|
import type { CuttingCatalogVariant } from './useCuttingPosCart';
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
filteredProducts: CuttingProductCatalogItem[];
|
filteredProducts: CuttingProductCatalogItem[];
|
||||||
getResultCartItem: (variantId: number) => CuttingResultCartItem | undefined;
|
getResultCartItem: (variantId: number) => CuttingResultCartItem | undefined;
|
||||||
isCreateMode: boolean;
|
isCreateMode: boolean;
|
||||||
@ -26,6 +27,47 @@ defineProps<{
|
|||||||
materialCart: CuttingMaterialCartItem[];
|
materialCart: CuttingMaterialCartItem[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const previewOpen = ref(false);
|
||||||
|
const previewUrl = ref<string | null>(null);
|
||||||
|
const previewTitle = ref<string>('');
|
||||||
|
|
||||||
|
const allVariantImages = computed(() => {
|
||||||
|
const list: { variantId: number; title: string; url: string }[] = [];
|
||||||
|
props.filteredProducts.forEach((product) => {
|
||||||
|
product.variants.forEach((variant) => {
|
||||||
|
if (variant.images && variant.images.length > 0) {
|
||||||
|
list.push({
|
||||||
|
variantId: variant.id,
|
||||||
|
title: `${product.name} - ${variant.name}`,
|
||||||
|
url: variant.images[0].url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewUrls = computed(() => allVariantImages.value.map((item) => item.url));
|
||||||
|
|
||||||
|
function handleThumbClick(variantId: number) {
|
||||||
|
const idx = allVariantImages.value.findIndex((item) => item.variantId === variantId);
|
||||||
|
|
||||||
|
if (idx !== -1) {
|
||||||
|
previewUrl.value = allVariantImages.value[idx].url;
|
||||||
|
previewTitle.value = allVariantImages.value[idx].title;
|
||||||
|
previewOpen.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(previewUrl, (newUrl) => {
|
||||||
|
const matched = allVariantImages.value.find((item) => item.url === newUrl);
|
||||||
|
|
||||||
|
if (matched) {
|
||||||
|
previewTitle.value = matched.title;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const productSearch = defineModel<string>('productSearch', { required: true });
|
const productSearch = defineModel<string>('productSearch', { required: true });
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@ -77,7 +119,8 @@ const quickCreateOpen = ref(false);
|
|||||||
? 'mx-1 my-0.5 rounded-md border-2 border-primary bg-primary/5'
|
? 'mx-1 my-0.5 rounded-md border-2 border-primary bg-primary/5'
|
||||||
: '',
|
: '',
|
||||||
]" @click="!getResultCartItem(variant.id) && emit('add-result', product, variant)">
|
]" @click="!getResultCartItem(variant.id) && emit('add-result', product, variant)">
|
||||||
<PosCatalogVariantThumb :items="variant.images" />
|
<PosCatalogVariantThumb :items="variant.images" custom-preview
|
||||||
|
@click-thumb="handleThumbClick(variant.id)" />
|
||||||
<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">
|
||||||
{{ variant.name }}
|
{{ variant.name }}
|
||||||
@ -124,4 +167,6 @@ const quickCreateOpen = ref(false);
|
|||||||
|
|
||||||
<QuickCreateProductModal v-model:open="quickCreateOpen" :categories="categories" :selected-materials="materialCart"
|
<QuickCreateProductModal v-model:open="quickCreateOpen" :categories="categories" :selected-materials="materialCart"
|
||||||
@created="emit('product-created', $event)" />
|
@created="emit('product-created', $event)" />
|
||||||
|
|
||||||
|
<MediaPreviewDialog v-model:open="previewOpen" v-model:url="previewUrl" :urls="previewUrls" :title="previewTitle" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -265,8 +265,7 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
|
|||||||
{{ material.variant }}
|
{{ material.variant }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<MediaThumbnailCell :items="material.images ?? []
|
<MediaThumbnailCell :items="material.images ?? []" :max-visible="1" :title="`${group.rawMaterialName} - ${material.variant}`" />
|
||||||
" :max-visible="1" />
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="tabular-nums">
|
<TableCell class="tabular-nums">
|
||||||
{{
|
{{
|
||||||
@ -323,9 +322,7 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
|
|||||||
{{ result.product_variant?.name }}
|
{{ result.product_variant?.name }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<MediaThumbnailCell :items="result.product_variant
|
<MediaThumbnailCell :items="result.product_variant?.images ?? []" :max-visible="1" :title="`${group.productName} - ${result.product_variant?.name}`" />
|
||||||
?.images ?? []
|
|
||||||
" :max-visible="1" />
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="tabular-nums">
|
<TableCell class="tabular-nums">
|
||||||
{{ result.cutting_result }} pcs
|
{{ result.cutting_result }} pcs
|
||||||
|
|||||||
@ -101,7 +101,7 @@ const emit = defineEmits<{
|
|||||||
]"
|
]"
|
||||||
@click="getVariantPrice(variant) && !getCartItem(variant.id) && emit('add-to-cart', product, variant)"
|
@click="getVariantPrice(variant) && !getCartItem(variant.id) && emit('add-to-cart', product, variant)"
|
||||||
>
|
>
|
||||||
<PosCatalogVariantThumb :items="variant.images" />
|
<PosCatalogVariantThumb :items="variant.images" :title="`${product.name} - ${variant.name}`" />
|
||||||
<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">
|
||||||
{{ variant.name }}
|
{{ variant.name }}
|
||||||
|
|||||||
@ -91,7 +91,7 @@ const emit = defineEmits<{
|
|||||||
]"
|
]"
|
||||||
@click="!getCartItem(price.id) && emit('add-to-cart', rawMaterial, price)"
|
@click="!getCartItem(price.id) && emit('add-to-cart', rawMaterial, price)"
|
||||||
>
|
>
|
||||||
<PosCatalogVariantThumb :items="price.images" />
|
<PosCatalogVariantThumb :items="price.images" :title="`${rawMaterial.name} - ${price.variant}`" />
|
||||||
<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">
|
||||||
{{ price.variant }}
|
{{ price.variant }}
|
||||||
|
|||||||
@ -148,7 +148,7 @@ function openVerificationDetail(requestId: number | undefined) {
|
|||||||
{{ variant.name }}
|
{{ variant.name }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<MediaThumbnailCell :items="variant.images ?? []" :max-visible="1" />
|
<MediaThumbnailCell :items="variant.images ?? []" :max-visible="1" :title="`${product.name} - ${variant.name}`" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="tabular-nums">
|
<TableCell class="tabular-nums">
|
||||||
{{ variant.stock_formatted }}
|
{{ variant.stock_formatted }}
|
||||||
|
|||||||
@ -130,7 +130,7 @@ function openVerificationDetail(requestId: number | undefined) {
|
|||||||
{{ price.variant }}
|
{{ price.variant }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<MediaThumbnailCell :items="price.images ?? []" :max-visible="1" />
|
<MediaThumbnailCell :items="price.images ?? []" :max-visible="1" :title="`${material.name} - ${price.variant}`" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="tabular-nums">
|
<TableCell class="tabular-nums">
|
||||||
{{ price.stock_formatted }}
|
{{ price.stock_formatted }}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user