Compare commits

...

3 Commits

Author SHA1 Message Date
Allan Carr
d90acf4b89 IO-3431 Prettier Run
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-01-12 13:27:23 -08:00
Allan Carr
68dd7f33ab IO-3431 Add Tags to Images and Documents
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-01-12 13:25:26 -08:00
Allan Carr
9b62633ba6 IO-3431 Fix Document in Drawer from Production Board
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-01-08 11:59:38 -08:00
5 changed files with 48 additions and 64 deletions

View File

@@ -1,13 +1,21 @@
import { Carousel } from "antd";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import { GenerateThumbUrl } from "../jobs-documents-gallery/job-documents.utility";
import { fetchImgproxyThumbnails } from "../jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.component";
import UpsellComponent, { upsellEnum } from "../upsell/upsell.component";
import CardTemplate from "./job-detail-cards.template.component";
export default function JobDetailCardsDocumentsComponent({ loading, data, bodyshop }) {
const { t } = useTranslation();
const hasMediaAccess = HasFeatureAccess({ bodyshop, featureName: "media" });
const [thumbnails, setThumbnails] = useState([]);
useEffect(() => {
if (data?.id) {
fetchImgproxyThumbnails({ setStateCallback: setThumbnails, jobId: data.id, imagesOnly: true });
}
}, [data?.id]);
if (!data)
return (
@@ -22,18 +30,19 @@ export default function JobDetailCardsDocumentsComponent({ loading, data, bodysh
title={t("jobs.labels.cards.documents")}
extraLink={`/manage/jobs/${data.id}?tab=documents`}
>
{!hasMediaAccess && (
<UpsellComponent disableMask upsell={upsellEnum().media.general}>
{data.documents.length > 0 ? (
{!hasMediaAccess && <UpsellComponent disableMask upsell={upsellEnum().media.general} />}
{hasMediaAccess && (
<>
{thumbnails.length > 0 ? (
<Carousel autoplay>
{data.documents.map((item) => (
<img key={item.id} src={GenerateThumbUrl(item)} alt={item.name} />
{thumbnails.map((item) => (
<img key={item.id} src={item.src} alt={item.filename} />
))}
</Carousel>
) : (
<div>{t("documents.errors.nodocuments")}</div>
)}
</UpsellComponent>
</>
)}
</CardTemplate>
);

View File

@@ -1,75 +1,24 @@
import { useEffect, useMemo, useState, useCallback } from "react";
import axios from "axios";
import { useEffect, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import LocalMediaGrid from "../jobs-documents-local-gallery/local-media-grid.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import { fetchImgproxyThumbnails } from "./jobs-documents-imgproxy-gallery.component";
function JobsDocumentImgproxyGalleryExternal({ jobId, externalMediaState, context = "chat" }) {
const [galleryImages, setgalleryImages] = externalMediaState;
const [rawMedia, setRawMedia] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation();
const fetchThumbnails = useCallback(async () => {
const result = await axios.post("/media/imgproxy/thumbnails", { jobid: jobId });
return result.data;
}, [jobId]);
await fetchImgproxyThumbnails({ setStateCallback: setgalleryImages, jobId, imagesOnly: true });
}, [jobId, setgalleryImages]);
useEffect(() => {
if (!jobId) return;
setIsLoading(true);
fetchThumbnails()
.then(setRawMedia)
.catch(console.error)
.finally(() => setIsLoading(false));
fetchThumbnails().finally(() => setIsLoading(false));
}, [jobId, fetchThumbnails]);
const documents = useMemo(() => {
return rawMedia
.filter((v) => v.type?.startsWith("image"))
.map((v) => ({
src: v.thumbnailUrl,
thumbnail: v.thumbnailUrl,
fullsize: v.originalUrl,
width: 225,
height: 225,
thumbnailWidth: 225,
thumbnailHeight: 225,
caption: v.key,
filename: v.key,
// additional properties if needed
key: v.key,
id: v.id,
type: v.type,
size: v.size,
extension: v.extension
}));
}, [rawMedia]);
useEffect(() => {
const prevSelection = new Map(galleryImages.map((p) => [p.filename, p.isSelected]));
const nextImages = documents.map((d) => ({ ...d, isSelected: prevSelection.get(d.filename) || false }));
// Micro-optimization: if array length and each filename + selection flag match, skip creating a new array.
if (galleryImages.length === nextImages.length) {
let identical = true;
for (let i = 0; i < nextImages.length; i++) {
if (
galleryImages[i].filename !== nextImages[i].filename ||
galleryImages[i].isSelected !== nextImages[i].isSelected
) {
identical = false;
break;
}
}
if (identical) {
setIsLoading(false); // ensure loading stops even on no-change
return;
}
}
setgalleryImages(nextImages);
setIsLoading(false); // stop loading after transform regardless of emptiness
}, [documents, setgalleryImages, galleryImages, jobId]);
const handleToggle = useCallback(
(idx) => {
setgalleryImages((imgs) => imgs.map((g, gIdx) => (gIdx === idx ? { ...g, isSelected: !g.isSelected } : g)));

View File

@@ -67,6 +67,7 @@ export function JobsDocumentsLocalGallery({
src: val.thumbnail,
height: val.thumbnailHeight,
width: val.thumbnailWidth,
tags: [{ value: val.filename, title: val.filename }],
...(val.optimized && { src: val.optimized, fullsize: val.src })
});
if (val.optimized) optimized = true;

View File

@@ -1,4 +1,5 @@
import { Checkbox } from "antd";
import { Tag } from "antd";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
/**
@@ -187,6 +188,30 @@ export function LocalMediaGrid({
transition: "opacity .25s ease"
}}
/>
{img.tags && img.tags.length > 0 && (
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
padding: "2px 6px",
borderRadius: "0 0 4px 4px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
display: "flex",
flexWrap: "wrap",
gap: "4px"
}}
>
{img.tags.map((tag, tagIdx) => (
<Tag key={tagIdx} variant="outlined" color="gold" style={{ opacity: 0.8 }}>
{tag.value || tag.title}
</Tag>
))}
</div>
)}
</>
);
})()}

View File

@@ -199,7 +199,7 @@ export function ProductionListDetail({ bodyshop, jobs, setPrintCenterContext, te
{!bodyshop.uselocalmediaserver && (
<>
<div style={{ height: "8px" }} />
<JobDetailCardsDocumentsComponent loading={loading} data={data ? data.jobs_by_pk : null} />
<JobDetailCardsDocumentsComponent loading={loading} data={data ? data.jobs_by_pk : null} bodyshop={bodyshop} />
</>
)}
</div>