Artikelbild: nur noch an einer Stelle, hochkant, und beim Import beruecksichtigt
Das alte Bild in der Chargen-Karte entfaellt - seit dem neuen Bild neben den Kennfeldern stand es doppelt auf der Seite. Der Rahmen ist jetzt hochkant (3:4) statt quadratisch. Lebensmittelverpackungen sind fast immer hoeher als breit; im Quadrat blieben links und rechts leere Streifen. Beim Import wurde image_url komplett verworfen, obwohl das JSON-Backup sie enthaelt - eingelesene Artikel blieben deshalb dauerhaft ohne Bild. Die Adresse wird jetzt uebernommen (bei bestehenden Artikeln nur ergaenzend, nie ueberschreibend); die lokale Kopie holt der Bildabruf beim ersten Ansehen. Bewusst nicht waehrend des Imports: Ein Backup mit 200 Artikeln wuerde sonst 200 Downloads in einer Anfrage abarbeiten. Ausserdem holen jetzt auch Artikelliste, Ein- und Auslagern ihr Vorschaubild aus der eigenen Datenbank statt direkt von Open Food Facts. Einzige Ausnahme bleibt der OFF-Vorschlag beim Scannen: Dort existiert der Artikel noch nicht. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -339,6 +339,17 @@ def _get_or_create_group(db: Session, name: str | None) -> Group | None:
|
|||||||
return group
|
return group
|
||||||
|
|
||||||
|
|
||||||
|
def _bildadresse_ergaenzen(product: Product, row: dict) -> None:
|
||||||
|
"""Fehlende Bildadresse aus dem Backup nachtragen – vorhandene bleibt.
|
||||||
|
|
||||||
|
Nur ergaenzend: Ein Import soll Daten hinzufuegen, nicht stillschweigend
|
||||||
|
etwas ueberschreiben, das jemand von Hand gesetzt hat.
|
||||||
|
"""
|
||||||
|
adresse = (row.get("image_url") or "").strip()
|
||||||
|
if adresse and not product.image_url:
|
||||||
|
product.image_url = adresse
|
||||||
|
|
||||||
|
|
||||||
def _get_or_create_product(db: Session, row: dict, created: list[str]) -> Product:
|
def _get_or_create_product(db: Session, row: dict, created: list[str]) -> Product:
|
||||||
"""Sucht per Barcode, sonst per Name; legt das Produkt sonst an."""
|
"""Sucht per Barcode, sonst per Name; legt das Produkt sonst an."""
|
||||||
barcode = (row.get("barcode") or "").strip() or None
|
barcode = (row.get("barcode") or "").strip() or None
|
||||||
@@ -346,10 +357,12 @@ def _get_or_create_product(db: Session, row: dict, created: list[str]) -> Produc
|
|||||||
if barcode:
|
if barcode:
|
||||||
product = db.query(Product).filter(Product.barcode == barcode).first()
|
product = db.query(Product).filter(Product.barcode == barcode).first()
|
||||||
if product:
|
if product:
|
||||||
|
_bildadresse_ergaenzen(product, row)
|
||||||
return product
|
return product
|
||||||
if name:
|
if name:
|
||||||
product = db.query(Product).filter(Product.name == name).first()
|
product = db.query(Product).filter(Product.name == name).first()
|
||||||
if product:
|
if product:
|
||||||
|
_bildadresse_ergaenzen(product, row)
|
||||||
return product
|
return product
|
||||||
if not name:
|
if not name:
|
||||||
raise ValueError("Produktname fehlt")
|
raise ValueError("Produktname fehlt")
|
||||||
@@ -363,6 +376,10 @@ def _get_or_create_product(db: Session, row: dict, created: list[str]) -> Produc
|
|||||||
barcode=barcode,
|
barcode=barcode,
|
||||||
name=name,
|
name=name,
|
||||||
brand=(row.get("marke") or "").strip() or None,
|
brand=(row.get("marke") or "").strip() or None,
|
||||||
|
# Die Bildadresse stand zwar immer im JSON-Backup, wurde beim Einlesen
|
||||||
|
# aber verworfen - eingelesene Artikel blieben deshalb dauerhaft ohne
|
||||||
|
# Bild. Die lokale Kopie holt sich der Bildabruf beim ersten Ansehen.
|
||||||
|
image_url=(row.get("image_url") or "").strip() or None,
|
||||||
base_unit=BASE_OF_KIND[unit.kind],
|
base_unit=BASE_OF_KIND[unit.kind],
|
||||||
display_unit_id=unit.id,
|
display_unit_id=unit.id,
|
||||||
package_size=_num(row.get("packungsgroesse")),
|
package_size=_num(row.get("packungsgroesse")),
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { authorizedObjectUrl } from "../api";
|
import { authorizedObjectUrl } from "../api";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Artikelbild aus der eigenen Datenbank.
|
* Lädt das Artikelbild aus der eigenen Datenbank.
|
||||||
*
|
|
||||||
* Rendert nichts, solange kein Bild vorliegt – ein Platzhalter für einen
|
|
||||||
* Artikel, der nie ein Bild bekommen wird, wäre nur Lärm.
|
|
||||||
*
|
*
|
||||||
* Die Objekt-URL wird beim Verlassen wieder freigegeben, sonst hält der Browser
|
* Die Objekt-URL wird beim Verlassen wieder freigegeben, sonst hält der Browser
|
||||||
* jedes angesehene Bild bis zum Neuladen der Seite im Speicher.
|
* jedes angesehene Bild bis zum Neuladen der Seite im Speicher.
|
||||||
*/
|
*/
|
||||||
export default function ProduktBild({ productId, alt, className = "" }) {
|
function useBildUrl(productId) {
|
||||||
const [url, setUrl] = useState(null);
|
const [url, setUrl] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -37,6 +35,31 @@ export default function ProduktBild({ productId, alt, className = "" }) {
|
|||||||
};
|
};
|
||||||
}, [productId]);
|
}, [productId]);
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grosses Artikelbild für die Artikelseite.
|
||||||
|
*
|
||||||
|
* Rendert nichts, solange kein Bild vorliegt – ein leerer Rahmen neben einem
|
||||||
|
* Artikel, der nie ein Bild bekommen wird, wäre nur Lärm.
|
||||||
|
*/
|
||||||
|
export default function ProduktBild({ productId, alt, className = "" }) {
|
||||||
|
const url = useBildUrl(productId);
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
return <img className={`produkt-bild ${className}`.trim()} src={url} alt={alt || ""} />;
|
return <img className={`produkt-bild ${className}`.trim()} src={url} alt={alt || ""} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kleines Vorschaubild für Listen und Auswahlen.
|
||||||
|
*
|
||||||
|
* Hier gibt es sehr wohl einen Platzhalter: In einer Tabellenspalte müssen alle
|
||||||
|
* Zeilen gleich hoch bleiben, sonst springt die Liste.
|
||||||
|
*/
|
||||||
|
export function ProduktThumb({ productId, alt }) {
|
||||||
|
const url = useBildUrl(productId);
|
||||||
|
if (!url) {
|
||||||
|
return <span className="thumb thumb-fallback"><Icon name="box" size={16} /></span>;
|
||||||
|
}
|
||||||
|
return <img className="thumb" src={url} alt={alt || ""} />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
|
|||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { useAuth } from "../auth";
|
import { useAuth } from "../auth";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
|
import { ProduktThumb } from "../components/ProduktBild";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { suggestionToProduct } from "../offUtils";
|
import { suggestionToProduct } from "../offUtils";
|
||||||
import { asTree } from "../categoryTree";
|
import { asTree } from "../categoryTree";
|
||||||
@@ -277,6 +278,9 @@ export default function CheckIn() {
|
|||||||
|
|
||||||
{suggestion && (
|
{suggestion && (
|
||||||
<div className="suggestion">
|
<div className="suggestion">
|
||||||
|
{/* Hier ausnahmsweise direkt von Open Food Facts: Der Artikel
|
||||||
|
existiert noch gar nicht, es gibt also nichts Lokales. Ab
|
||||||
|
dem Anlegen kommt das Bild aus der eigenen Datenbank. */}
|
||||||
{suggestion.image_url
|
{suggestion.image_url
|
||||||
? <img className="thumb" src={suggestion.image_url} alt="" />
|
? <img className="thumb" src={suggestion.image_url} alt="" />
|
||||||
: <span className="thumb thumb-fallback"><Icon name="box" size={16} /></span>}
|
: <span className="thumb thumb-fallback"><Icon name="box" size={16} /></span>}
|
||||||
@@ -354,9 +358,7 @@ export default function CheckIn() {
|
|||||||
{product && (
|
{product && (
|
||||||
<form className="card form-narrow" onSubmit={submit}>
|
<form className="card form-narrow" onSubmit={submit}>
|
||||||
<div className="selected-product">
|
<div className="selected-product">
|
||||||
{product.image_url
|
<ProduktThumb productId={product.id} alt={product.name} />
|
||||||
? <img className="thumb" src={product.image_url} alt="" />
|
|
||||||
: <span className="thumb thumb-fallback"><Icon name="box" size={16} /></span>}
|
|
||||||
<div className="info">
|
<div className="info">
|
||||||
<div className="title">{product.name}</div>
|
<div className="title">{product.name}</div>
|
||||||
<div className="muted small">Bestand: {fmt(product.stock / (product.unit_factor || 1))} {product.unit_name}</div>
|
<div className="muted small">Bestand: {fmt(product.stock / (product.unit_factor || 1))} {product.unit_name}</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
|
import { ProduktThumb } from "../components/ProduktBild";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { useSettings } from "../settings";
|
import { useSettings } from "../settings";
|
||||||
import { buildUnitOptions, fmt, isExpired, unitShort } from "../units";
|
import { buildUnitOptions, fmt, isExpired, unitShort } from "../units";
|
||||||
@@ -143,9 +144,7 @@ export default function CheckOut() {
|
|||||||
{product && (
|
{product && (
|
||||||
<form className="card form-narrow" onSubmit={submit}>
|
<form className="card form-narrow" onSubmit={submit}>
|
||||||
<div className="selected-product">
|
<div className="selected-product">
|
||||||
{product.image_url
|
<ProduktThumb productId={product.id} alt={product.name} />
|
||||||
? <img className="thumb" src={product.image_url} alt="" />
|
|
||||||
: <span className="thumb thumb-fallback"><Icon name="box" size={16} /></span>}
|
|
||||||
<div className="info">
|
<div className="info">
|
||||||
<div className="title">{product.name}</div>
|
<div className="title">{product.name}</div>
|
||||||
<div className="muted small">
|
<div className="muted small">
|
||||||
|
|||||||
@@ -400,7 +400,6 @@ export default function ProductForm() {
|
|||||||
baseShort={baseShort}
|
baseShort={baseShort}
|
||||||
warnDays={warnDays}
|
warnDays={warnDays}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
imageUrl={form.image_url}
|
|
||||||
onChanged={async () => {
|
onChanged={async () => {
|
||||||
setLots(await api.listLots(id));
|
setLots(await api.listLots(id));
|
||||||
setProduct(await api.getProduct(id));
|
setProduct(await api.getProduct(id));
|
||||||
@@ -438,7 +437,7 @@ export default function ProductForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Chargen-Karte mit Bearbeiten/Löschen. Bearbeitet wird in der Produkteinheit. */
|
/** Chargen-Karte mit Bearbeiten/Löschen. Bearbeitet wird in der Produkteinheit. */
|
||||||
function LotsCard({ product, lots, baseShort, warnDays, isAdmin, imageUrl, onChanged, onError }) {
|
function LotsCard({ product, lots, baseShort, warnDays, isAdmin, onChanged, onError }) {
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const { formatBestBefore } = useSettings();
|
const { formatBestBefore } = useSettings();
|
||||||
@@ -532,7 +531,6 @@ function LotsCard({ product, lots, baseShort, warnDays, isAdmin, imageUrl, onCha
|
|||||||
Dieses Produkt hat abgelaufene Chargen im Bestand.
|
Dieses Produkt hat abgelaufene Chargen im Bestand.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{imageUrl && <img className="product-img" src={imageUrl} alt="" />}
|
|
||||||
<div className="table-wrap">
|
<div className="table-wrap">
|
||||||
<table className="table">
|
<table className="table">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
|
|||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { useAuth } from "../auth";
|
import { useAuth } from "../auth";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
|
import { ProduktThumb } from "../components/ProduktBild";
|
||||||
import { fmt, unitShort } from "../units";
|
import { fmt, unitShort } from "../units";
|
||||||
import { asTree } from "../categoryTree";
|
import { asTree } from "../categoryTree";
|
||||||
|
|
||||||
@@ -95,9 +96,7 @@ export default function Products() {
|
|||||||
return (
|
return (
|
||||||
<tr key={p.id}>
|
<tr key={p.id}>
|
||||||
<td className="thumb-cell">
|
<td className="thumb-cell">
|
||||||
{p.image_url
|
<ProduktThumb productId={p.id} alt={p.name} />
|
||||||
? <img className="thumb" src={p.image_url} alt="" />
|
|
||||||
: <span className="thumb thumb-fallback"><Icon name="box" size={16} /></span>}
|
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Name" className="strong">
|
<td data-label="Name" className="strong">
|
||||||
<span className="cell-row">
|
<span className="cell-row">
|
||||||
|
|||||||
@@ -194,9 +194,11 @@ input::placeholder { color: var(--muted); opacity: 0.7; }
|
|||||||
drueckt ein langer Produktname die Eingaben aus der Karte heraus. */
|
drueckt ein langer Produktname die Eingaben aus der Karte heraus. */
|
||||||
.produkt-kopf { display: flex; gap: var(--sp-4); align-items: flex-start; }
|
.produkt-kopf { display: flex; gap: var(--sp-4); align-items: flex-start; }
|
||||||
.produkt-kopf-felder { flex: 1 1 auto; min-width: 0; }
|
.produkt-kopf-felder { flex: 1 1 auto; min-width: 0; }
|
||||||
|
/* Hochkant: Lebensmittelverpackungen sind fast immer hoeher als breit -
|
||||||
|
in einem quadratischen Rahmen blieben links und rechts leere Streifen. */
|
||||||
.produkt-bild {
|
.produkt-bild {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 132px; height: 132px;
|
width: 132px; aspect-ratio: 3 / 4;
|
||||||
object-fit: contain; /* Verpackungen nie beschneiden */
|
object-fit: contain; /* Verpackungen nie beschneiden */
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -205,8 +207,7 @@ input::placeholder { color: var(--muted); opacity: 0.7; }
|
|||||||
}
|
}
|
||||||
/* Auf schmalen Fenstern haben Bild und Felder nebeneinander keinen Platz mehr. */
|
/* Auf schmalen Fenstern haben Bild und Felder nebeneinander keinen Platz mehr. */
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.produkt-kopf { flex-direction: column-reverse; }
|
.produkt-kopf { flex-direction: column-reverse; align-items: center; }
|
||||||
.produkt-bild { width: 100%; height: 160px; }
|
|
||||||
}
|
}
|
||||||
.field-inline { display: flex; gap: var(--sp-2); align-items: flex-end; margin-bottom: var(--sp-4); }
|
.field-inline { display: flex; gap: var(--sp-2); align-items: flex-end; margin-bottom: var(--sp-4); }
|
||||||
/* Labels in einer Inline-Reihe duerfen keinen eigenen Abstand haben, sonst
|
/* Labels in einer Inline-Reihe duerfen keinen eigenen Abstand haben, sonst
|
||||||
@@ -307,7 +308,6 @@ td select { width: auto; min-width: 0; max-width: 100%; }
|
|||||||
border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--surface-2);
|
border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--surface-2);
|
||||||
}
|
}
|
||||||
.thumb-fallback { display: grid; place-items: center; color: var(--muted); }
|
.thumb-fallback { display: grid; place-items: center; color: var(--muted); }
|
||||||
.product-img { max-width: 150px; border-radius: var(--radius); border: 1px solid var(--border); margin-bottom: var(--sp-3); }
|
|
||||||
|
|
||||||
/* ---------- Badges / pills ---------- */
|
/* ---------- Badges / pills ---------- */
|
||||||
.badge {
|
.badge {
|
||||||
|
|||||||
Reference in New Issue
Block a user