feat: enhance media preview components by adding stock information support, improving user experience in managing media items

This commit is contained in:
Yoga Pangestu 2026-07-13 19:43:27 +07:00
parent 62beb309a8
commit bed22579b5
16 changed files with 56 additions and 16 deletions

View File

@ -22,11 +22,13 @@ const props = withDefaults(
urls?: string[]; urls?: string[];
title?: string; title?: string;
titles?: string[]; titles?: string[];
stocks?: string[];
}>(), }>(),
{ {
urls: () => [], urls: () => [],
title: 'Pratinjau Foto', title: 'Pratinjau Foto',
titles: () => [], titles: () => [],
stocks: () => [],
} }
); );
@ -74,6 +76,14 @@ const activeTitle = computed(() => {
return props.title ?? 'Pratinjau Foto'; return props.title ?? 'Pratinjau Foto';
}); });
const activeStock = computed(() => {
if (props.stocks && props.stocks.length > 0 && currentIndex.value >= 0) {
return props.stocks[currentIndex.value] ?? null;
}
return null;
});
function nextImage() { function nextImage() {
if (!hasMultiple.value) { if (!hasMultiple.value) {
return; return;
@ -142,6 +152,7 @@ function handleTouchEnd(e: TouchEvent) {
<DialogContent class="sm:max-w-2xl p-4 overflow-hidden gap-4"> <DialogContent class="sm:max-w-2xl p-4 overflow-hidden gap-4">
<DialogHeader class="pb-2 border-b"> <DialogHeader class="pb-2 border-b">
<DialogTitle class="text-base font-semibold">{{ activeTitle }}</DialogTitle> <DialogTitle class="text-base font-semibold">{{ activeTitle }}</DialogTitle>
<p v-if="activeStock" class="text-sm text-muted-foreground">{{ activeStock }}</p>
</DialogHeader> </DialogHeader>
<div class="relative flex items-center justify-center min-h-[300px] select-none" <div class="relative flex items-center justify-center min-h-[300px] select-none"

View File

@ -15,6 +15,7 @@ const props = defineProps<{
title?: string; title?: string;
allUrls?: string[]; allUrls?: string[];
allTitles?: string[]; allTitles?: string[];
allStocks?: string[];
}>(); }>();
const previewOpen = ref(false); const previewOpen = ref(false);
@ -121,5 +122,5 @@ function openGallery() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<MediaPreviewDialog v-model:open="previewOpen" v-model:url="previewUrl" :urls="allUrls ?? items.map(item => item.url)" :title="title" :titles="allTitles" @close="onPreviewClose" /> <MediaPreviewDialog v-model:open="previewOpen" v-model:url="previewUrl" :urls="allUrls ?? items.map(item => item.url)" :title="title" :titles="allTitles" :stocks="allStocks" @close="onPreviewClose" />
</template> </template>

View File

@ -195,6 +195,7 @@ const groupedResults = computed<GroupedResults[]>(() => {
?.images ?? [] ?.images ?? []
" "
:max-visible="1" :max-visible="1"
:all-stocks="(result.product_variant?.images ?? []).map(() => `${result.cutting_result} pcs`)"
/> />
</TableCell> </TableCell>
<TableCell <TableCell
@ -288,6 +289,7 @@ const groupedResults = computed<GroupedResults[]>(() => {
<MediaThumbnailCell <MediaThumbnailCell
:items="material.images ?? []" :items="material.images ?? []"
:max-visible="1" :max-visible="1"
:all-stocks="(material.images ?? []).map(() => `Pemakaian: ${material.material_usage_formatted}`)"
/> />
</TableCell> </TableCell>
<TableCell <TableCell

View File

@ -38,15 +38,17 @@ const props = defineProps<{
const previewOpen = ref(false); const previewOpen = ref(false);
const previewUrl = ref<string | null>(null); const previewUrl = ref<string | null>(null);
const previewTitle = ref<string>(''); const previewTitle = ref<string>('');
const previewStock = ref<string>('');
const allVariantImages = computed(() => { const allVariantImages = computed(() => {
const list: { priceId: number; title: string; url: string }[] = []; const list: { priceId: number; title: string; stock: string; url: string }[] = [];
props.filteredRawMaterials.forEach((rawMaterial) => { props.filteredRawMaterials.forEach((rawMaterial) => {
rawMaterial.prices.forEach((price) => { rawMaterial.prices.forEach((price) => {
if (price.images && price.images.length > 0) { if (price.images && price.images.length > 0) {
list.push({ list.push({
priceId: price.id, priceId: price.id,
title: `${rawMaterial.name} - ${price.variant}`, title: `${rawMaterial.name} - ${price.variant}`,
stock: `Stok: ${price.stock_formatted}`,
url: price.images[0].url, url: price.images[0].url,
}); });
} }
@ -64,6 +66,7 @@ function handleThumbClick(priceId: number) {
if (idx !== -1) { if (idx !== -1) {
previewUrl.value = allVariantImages.value[idx].url; previewUrl.value = allVariantImages.value[idx].url;
previewTitle.value = allVariantImages.value[idx].title; previewTitle.value = allVariantImages.value[idx].title;
previewStock.value = allVariantImages.value[idx].stock;
previewOpen.value = true; previewOpen.value = true;
} }
} }
@ -73,6 +76,7 @@ watch(previewUrl, (newUrl) => {
if (matched) { if (matched) {
previewTitle.value = matched.title; previewTitle.value = matched.title;
previewStock.value = matched.stock;
} }
}); });
@ -206,5 +210,5 @@ function openCombination(rawMaterial: CuttingRawMaterialCatalogItem, price: Cutt
: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" /> <MediaPreviewDialog v-model:open="previewOpen" v-model:url="previewUrl" :urls="previewUrls" :title="previewTitle" :stocks="previewStock ? [previewStock] : []" />
</template> </template>

View File

@ -138,6 +138,7 @@ function setCombinationResult(combinationId: number | undefined, value: string)
v-if="item.images?.length" v-if="item.images?.length"
:items="item.images" :items="item.images"
:max-visible="1" :max-visible="1"
:all-stocks="item.images.map(() => `Stok: ${item.stock_input} ${item.unit_abbreviation}`)"
/> />
<div class="min-w-0"> <div class="min-w-0">
<p class="truncate text-sm font-medium"> <p class="truncate text-sm font-medium">
@ -180,6 +181,7 @@ function setCombinationResult(combinationId: number | undefined, value: string)
v-if="item.images?.length" v-if="item.images?.length"
:items="item.images" :items="item.images"
:max-visible="1" :max-visible="1"
:all-stocks="item.images.map(() => `Stok: ${item.stock_input} ${item.unit_abbreviation}`)"
/> />
<div class="min-w-0"> <div class="min-w-0">
<p class="truncate text-sm font-medium"> <p class="truncate text-sm font-medium">

View File

@ -30,15 +30,17 @@ const props = defineProps<{
const previewOpen = ref(false); const previewOpen = ref(false);
const previewUrl = ref<string | null>(null); const previewUrl = ref<string | null>(null);
const previewTitle = ref<string>(''); const previewTitle = ref<string>('');
const previewStock = ref<string>('');
const allVariantImages = computed(() => { const allVariantImages = computed(() => {
const list: { variantId: number; title: string; url: string }[] = []; const list: { variantId: number; title: string; stock: string; url: string }[] = [];
props.filteredProducts.forEach((product) => { props.filteredProducts.forEach((product) => {
product.variants.forEach((variant) => { product.variants.forEach((variant) => {
if (variant.images && variant.images.length > 0) { if (variant.images && variant.images.length > 0) {
list.push({ list.push({
variantId: variant.id, variantId: variant.id,
title: `${product.name} - ${variant.name}`, title: `${product.name} - ${variant.name}`,
stock: `Stok: ${variant.stock} pcs`,
url: variant.images[0].url, url: variant.images[0].url,
}); });
} }
@ -56,6 +58,7 @@ function handleThumbClick(variantId: number) {
if (idx !== -1) { if (idx !== -1) {
previewUrl.value = allVariantImages.value[idx].url; previewUrl.value = allVariantImages.value[idx].url;
previewTitle.value = allVariantImages.value[idx].title; previewTitle.value = allVariantImages.value[idx].title;
previewStock.value = allVariantImages.value[idx].stock;
previewOpen.value = true; previewOpen.value = true;
} }
} }
@ -65,6 +68,7 @@ watch(previewUrl, (newUrl) => {
if (matched) { if (matched) {
previewTitle.value = matched.title; previewTitle.value = matched.title;
previewStock.value = matched.stock;
} }
}); });
@ -168,5 +172,5 @@ 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" /> <MediaPreviewDialog v-model:open="previewOpen" v-model:url="previewUrl" :urls="previewUrls" :title="previewTitle" :stocks="previewStock ? [previewStock] : []" />
</template> </template>

View File

@ -50,6 +50,7 @@ const emit = defineEmits<{
v-if="item.images?.length" v-if="item.images?.length"
:items="item.images" :items="item.images"
:max-visible="1" :max-visible="1"
:all-stocks="item.images.map(() => `Stok: ${item.stock} pcs`)"
/> />
<div class="min-w-0"> <div class="min-w-0">
<p class="truncate text-sm font-medium"> <p class="truncate text-sm font-medium">

View File

@ -265,7 +265,7 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
{{ material.variant }} {{ material.variant }}
</TableCell> </TableCell>
<TableCell> <TableCell>
<MediaThumbnailCell :items="material.images ?? []" :max-visible="1" :title="`${group.rawMaterialName} - ${material.variant}`" /> <MediaThumbnailCell :items="material.images ?? []" :max-visible="1" :title="`${group.rawMaterialName} - ${material.variant}`" :all-stocks="(material.images ?? []).map(() => `Pemakaian: ${material.material_usage_formatted}`)" />
</TableCell> </TableCell>
<TableCell class="tabular-nums"> <TableCell class="tabular-nums">
{{ {{
@ -322,7 +322,7 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
{{ result.product_variant?.name }} {{ result.product_variant?.name }}
</TableCell> </TableCell>
<TableCell> <TableCell>
<MediaThumbnailCell :items="result.product_variant?.images ?? []" :max-visible="1" :title="`${group.productName} - ${result.product_variant?.name}`" /> <MediaThumbnailCell :items="result.product_variant?.images ?? []" :max-visible="1" :title="`${group.productName} - ${result.product_variant?.name}`" :all-stocks="(result.product_variant?.images ?? []).map(() => `Hasil: ${result.cutting_result} pcs`)" />
</TableCell> </TableCell>
<TableCell class="tabular-nums"> <TableCell class="tabular-nums">
{{ result.cutting_result }} pcs {{ result.cutting_result }} pcs

View File

@ -201,7 +201,7 @@ function submitVerify() {
<div v-for="material in cutting.materials" :key="material.id" <div v-for="material in cutting.materials" :key="material.id"
class="flex items-center gap-3 rounded-lg border p-3 bg-muted/10"> class="flex items-center gap-3 rounded-lg border p-3 bg-muted/10">
<div class="shrink-0"> <div class="shrink-0">
<MediaThumbnailCell :items="material.raw_material_price?.images ?? []" :max-visible="1" /> <MediaThumbnailCell :items="material.raw_material_price?.images ?? []" :max-visible="1" :all-stocks="(material.raw_material_price?.images ?? []).map(() => `Pemakaian: ${material.material_usage_formatted}`)" />
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="font-medium text-sm truncate"> <p class="font-medium text-sm truncate">

View File

@ -26,6 +26,7 @@ const props = defineProps<{
const previewOpen = ref(false); const previewOpen = ref(false);
const previewUrl = ref<string | null>(null); const previewUrl = ref<string | null>(null);
const previewTitle = ref(''); const previewTitle = ref('');
const previewStock = ref('');
const productSearch = defineModel<string>('search', { required: true }); const productSearch = defineModel<string>('search', { required: true });
@ -40,6 +41,7 @@ function handleThumbClick(variantId: number) {
if (variant && variant.images && variant.images.length > 0) { if (variant && variant.images && variant.images.length > 0) {
previewUrl.value = variant.images[0].url; previewUrl.value = variant.images[0].url;
previewTitle.value = `${product.name} - ${variant.name}`; previewTitle.value = `${product.name} - ${variant.name}`;
previewStock.value = `Stok: ${variant.stock} pcs`;
previewOpen.value = true; previewOpen.value = true;
break; break;
} }
@ -117,5 +119,5 @@ function handleThumbClick(variantId: number) {
</CardContent> </CardContent>
</Card> </Card>
<MediaPreviewDialog v-model:open="previewOpen" v-model:url="previewUrl" :title="previewTitle" /> <MediaPreviewDialog v-model:open="previewOpen" v-model:url="previewUrl" :title="previewTitle" :stocks="previewStock ? [previewStock] : []" />
</template> </template>

View File

@ -200,7 +200,7 @@ function getGroupedMaterials(materials: any[]): GroupedCuttingMaterials[] {
</TableCell> </TableCell>
<TableCell> <TableCell>
<MediaThumbnailCell :items="result.product_variant?.images ?? []" <MediaThumbnailCell :items="result.product_variant?.images ?? []"
:max-visible="1" /> :max-visible="1" :all-stocks="(result.product_variant?.images ?? []).map(() => `${result.cutting_result} pcs`)" />
</TableCell> </TableCell>
<TableCell class="tabular-nums"> <TableCell class="tabular-nums">
{{ result.cutting_result }} pcs {{ result.cutting_result }} pcs

View File

@ -131,7 +131,7 @@ function getGroupedMaterials(materials: any[]): GroupedCuttingMaterials[] {
</TableCell> </TableCell>
<TableCell> <TableCell>
<MediaThumbnailCell :items="result.product_variant?.images ?? []" <MediaThumbnailCell :items="result.product_variant?.images ?? []"
:max-visible="1" /> :max-visible="1" :all-stocks="(result.product_variant?.images ?? []).map(() => `${result.cutting_result} pcs`)" />
</TableCell> </TableCell>
<TableCell class="tabular-nums"> <TableCell class="tabular-nums">
{{ result.cutting_result }} pcs {{ result.cutting_result }} pcs
@ -189,7 +189,7 @@ function getGroupedMaterials(materials: any[]): GroupedCuttingMaterials[] {
{{ material.variant }} {{ material.variant }}
</TableCell> </TableCell>
<TableCell> <TableCell>
<MediaThumbnailCell :items="material.images ?? []" :max-visible="1" /> <MediaThumbnailCell :items="material.images ?? []" :max-visible="1" :all-stocks="(material.images ?? []).map(() => `${material.material_result ?? '-'} pcs`)" />
</TableCell> </TableCell>
<TableCell class="tabular-nums"> <TableCell class="tabular-nums">
<template <template

View File

@ -124,7 +124,7 @@ const showingCount = computed(() => props.cuttings.length);
{{ result.product_variant?.name }} {{ result.product_variant?.name }}
</TableCell> </TableCell>
<TableCell> <TableCell>
<MediaThumbnailCell :items="result.product_variant?.images ?? []" :max-visible="1" /> <MediaThumbnailCell :items="result.product_variant?.images ?? []" :max-visible="1" :all-stocks="(result.product_variant?.images ?? []).map(() => `${result.cutting_result} pcs`)" />
</TableCell> </TableCell>
<TableCell class="tabular-nums"> <TableCell class="tabular-nums">
{{ result.cutting_result }} pcs {{ result.cutting_result }} pcs

View File

@ -221,7 +221,7 @@ function submitVerify() {
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-3 min-w-0"> <div class="flex items-center gap-3 min-w-0">
<div class="shrink-0"> <div class="shrink-0">
<MediaThumbnailCell :items="cutting.results[index]?.product_variant?.images ?? []" :max-visible="1" /> <MediaThumbnailCell :items="cutting.results[index]?.product_variant?.images ?? []" :max-visible="1" :all-stocks="(cutting.results[index]?.product_variant?.images ?? []).map(() => `${result.cutting_result} pcs`)" />
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="font-medium text-sm">{{ result.name }}</p> <p class="font-medium text-sm">{{ result.name }}</p>

View File

@ -67,6 +67,12 @@ function allVariantImageTitles(product: ProductListItem): string[] {
); );
} }
function allVariantImageStocks(product: ProductListItem): string[] {
return product.variants.flatMap((variant) =>
(variant.images ?? []).map(() => `Stok Bagus: ${variant.stock_formatted} | Reject: ${variant.reject_stock_formatted} | Ecer: ${variant.retail_stock_formatted}`)
);
}
const verificationModalOpen = ref(false); const verificationModalOpen = ref(false);
const selectedRequestId = ref<number | null>(null); const selectedRequestId = ref<number | null>(null);
@ -172,7 +178,7 @@ function openEditModal(variant: Variant, product: ProductListItem) {
{{ variant.name }} {{ variant.name }}
</TableCell> </TableCell>
<TableCell> <TableCell>
<MediaThumbnailCell :items="variant.images ?? []" :max-visible="1" :title="`${product.name} - ${variant.name}`" :all-urls="allVariantImageUrls(product)" :all-titles="allVariantImageTitles(product)" /> <MediaThumbnailCell :items="variant.images ?? []" :max-visible="1" :title="`${product.name} - ${variant.name}`" :all-urls="allVariantImageUrls(product)" :all-titles="allVariantImageTitles(product)" :all-stocks="allVariantImageStocks(product)" />
</TableCell> </TableCell>
<TableCell class="tabular-nums"> <TableCell class="tabular-nums">
{{ variant.stock_formatted }} {{ variant.stock_formatted }}

View File

@ -61,6 +61,12 @@ function allVariantImageTitles(material: RawMaterialListItem): string[] {
); );
} }
function allVariantImageStocks(material: RawMaterialListItem): string[] {
return material.prices.flatMap((price) =>
(price.images ?? []).map(() => `Stok: ${price.stock_formatted}`)
);
}
const verificationModalOpen = ref(false); const verificationModalOpen = ref(false);
const selectedRequestId = ref<number | null>(null); const selectedRequestId = ref<number | null>(null);
@ -167,7 +173,8 @@ function openEditModal(price: RawMaterialPrice, material: RawMaterialListItem) {
<MediaThumbnailCell :items="price.images ?? []" :max-visible="1" <MediaThumbnailCell :items="price.images ?? []" :max-visible="1"
:title="`${material.name} - ${price.variant}`" :title="`${material.name} - ${price.variant}`"
:all-urls="allVariantImageUrls(material)" :all-urls="allVariantImageUrls(material)"
:all-titles="allVariantImageTitles(material)" /> :all-titles="allVariantImageTitles(material)"
:all-stocks="allVariantImageStocks(material)" />
</TableCell> </TableCell>
<TableCell class="tabular-nums"> <TableCell class="tabular-nums">
{{ price.stock_formatted }} {{ price.stock_formatted }}