Mehrere EAN-Codes je Produkt und je Gruppe (mit Notiz) + Gruppen umbenennen

Barcodes:
- Neue Tabelle barcodes (code, note, product_id ODER group_id). Damit lassen sich
  einem Produkt mehrere Codes geben und einer Gruppe beliebig viele Marken
  zuordnen (z.B. alle Mehlmarken zu "Mehl"). Je Code eine Notiz wie
  "Mehl bei Aldi".
- Lookup loest jetzt auch Alias-Codes auf; ist ein Code einer Gruppe zugeordnet,
  liefert die API group_id/group_name zurueck. Beim Anlegen aus einem Scan wird
  diese Gruppe automatisch gesetzt (Vorrang vor dem Kategorie-Rateversuch).
- Endpunkte: POST/DELETE /products/{id}/barcodes und /groups/{id}/barcodes.
- Web: wiederverwendbare BarcodeList-Komponente, eingebunden im Produktformular
  und auf der Gruppen-Seite.

Gruppen:
- Namen lassen sich jetzt direkt in der Tabelle aendern (PATCH war vorhanden,
  nur die Oberflaeche fehlte).

UI-Fix: Auswahlfelder in Tabellen richten sich nach ihrem Text statt nach der
Zellenbreite (Gruppen-/Benutzer-Dropdowns waren abgeschnitten).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-22 14:48:14 +02:00
parent 8079d2d3f0
commit de6c8632a1
12 changed files with 341 additions and 48 deletions

View File

@@ -7,8 +7,8 @@ from datetime import date
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from .models import Lot, Product
from .schemas import ProductOut
from .models import Barcode, Lot, Product
from .schemas import BarcodeOut, ProductOut
from .services.conversion import KIND_OF_BASE, display_unit_info
from .services.stock import current_stock
@@ -26,6 +26,10 @@ def product_to_out(db: Session, product: Product) -> ProductOut:
)
.count()
)
out.barcodes = [
BarcodeOut.model_validate(b)
for b in db.query(Barcode).filter(Barcode.product_id == product.id).order_by(Barcode.id).all()
]
out.kind = KIND_OF_BASE[product.base_unit].value
name, factor = display_unit_info(product)
out.unit_name = name

View File

@@ -146,6 +146,28 @@ class Product(Base):
)
class Barcode(Base):
"""Zusätzliche EAN-Codes für ein Produkt ODER eine Gruppe.
Beispiel Gruppe "Mehl": alle Mehl-Marken einscannen; ein Scan ordnet das
Produkt dann automatisch dieser Gruppe zu.
"""
__tablename__ = "barcodes"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
code: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
# Freitext zur Einordnung, z.B. "Mehl bei Aldi"
note: Mapped[str | None] = mapped_column(String(120), nullable=True)
product_id: Mapped[int | None] = mapped_column(
ForeignKey("products.id", ondelete="CASCADE"), nullable=True
)
group_id: Mapped[int | None] = mapped_column(
ForeignKey("groups.id", ondelete="CASCADE"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class Lot(Base):
__tablename__ = "lots"

View File

@@ -3,8 +3,8 @@ from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, require_admin
from ..models import Group, Product, User
from ..schemas import GroupCreate, GroupOut, GroupUpdate
from ..models import Barcode, Group, Product, User
from ..schemas import BarcodeCreate, BarcodeOut, GroupCreate, GroupOut, GroupUpdate
from ..services.conversion import BASE_OF_KIND
from ..services.stock import current_stock
@@ -15,6 +15,10 @@ def _group_to_out(db: Session, group: Group) -> GroupOut:
out = GroupOut.model_validate(group)
products = db.query(Product).filter(Product.group_id == group.id).all()
out.product_count = len(products)
out.barcodes = [
BarcodeOut.model_validate(b)
for b in db.query(Barcode).filter(Barcode.group_id == group.id).order_by(Barcode.id).all()
]
unit = group.min_stock_unit
if unit is not None:
# Bestand nur über Produkte gleicher Art, in der Gruppen-Einheit ausgedrückt.
@@ -82,6 +86,44 @@ def update_group(
return _group_to_out(db, group)
@router.post("/{group_id}/barcodes", response_model=GroupOut, status_code=status.HTTP_201_CREATED)
def add_group_barcode(
group_id: int,
payload: BarcodeCreate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> GroupOut:
"""EAN einer Gruppe zuordnen (z.B. alle Mehl-Marken zur Gruppe "Mehl")."""
group = db.get(Group, group_id)
if group is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
code = payload.code.strip()
if db.query(Barcode).filter(Barcode.code == code).first() or (
db.query(Product).filter(Product.barcode == code).first()
):
raise HTTPException(status.HTTP_409_CONFLICT, "Dieser Code ist bereits vergeben")
db.add(Barcode(code=code, note=(payload.note or None), group_id=group.id))
db.commit()
db.refresh(group)
return _group_to_out(db, group)
@router.delete("/{group_id}/barcodes/{code}", status_code=status.HTTP_204_NO_CONTENT)
def delete_group_barcode(
group_id: int,
code: str,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
entry = (
db.query(Barcode).filter(Barcode.group_id == group_id, Barcode.code == code).first()
)
if entry is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Code nicht gefunden")
db.delete(entry)
db.commit()
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_group(
group_id: int,

View File

@@ -4,9 +4,9 @@ from sqlalchemy.orm import Session
from ..crud import product_to_out
from ..database import get_db
from ..deps import get_current_user, require_admin
from ..models import BaseUnit, Product, User
from ..models import Barcode, BaseUnit, Group, Product, User
from ..off import lookup_barcode
from ..schemas import LookupResult, ProductCreate, ProductOut, ProductUpdate
from ..schemas import BarcodeCreate, LookupResult, ProductCreate, ProductOut, ProductUpdate
from ..services.conversion import ConversionError, resolve_product_unit
router = APIRouter(prefix="/products", tags=["products"])
@@ -32,15 +32,31 @@ def lookup(
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> LookupResult:
"""Barcode: erst lokal, dann Open Food Facts. Nur Vorschlag, legt nichts an."""
existing = db.query(Product).filter(Product.barcode == barcode).first()
"""Barcode auflösen: Produkt (Haupt- oder Alias-Code), Gruppen-Code, sonst OFF."""
code = barcode.strip()
existing = db.query(Product).filter(Product.barcode == code).first()
if existing is None:
alias = db.query(Barcode).filter(Barcode.code == code).first()
if alias is not None and alias.product_id:
existing = db.get(Product, alias.product_id)
if existing:
return LookupResult(found=True, existing_product=product_to_out(db, existing))
suggestion = lookup_barcode(barcode)
# Code kann einer Gruppe zugeordnet sein (z.B. alle Mehl-Marken in "Mehl").
group_id = group_name = None
alias = db.query(Barcode).filter(Barcode.code == code).first()
if alias is not None and alias.group_id:
group = db.get(Group, alias.group_id)
if group is not None:
group_id, group_name = group.id, group.name
suggestion = lookup_barcode(code)
if suggestion:
return LookupResult(found=True, suggestion=suggestion)
return LookupResult(found=False)
return LookupResult(
found=True, suggestion=suggestion, group_id=group_id, group_name=group_name
)
return LookupResult(found=False, group_id=group_id, group_name=group_name)
@router.get("/{product_id}", response_model=ProductOut)
@@ -137,6 +153,46 @@ def update_product(
return product_to_out(db, product)
@router.post("/{product_id}/barcodes", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
def add_product_barcode(
product_id: int,
payload: BarcodeCreate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ProductOut:
"""Weiteren EAN-Code zu einem Produkt hinzufügen."""
product = db.get(Product, product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
code = payload.code.strip()
if db.query(Barcode).filter(Barcode.code == code).first() or (
db.query(Product).filter(Product.barcode == code).first()
):
raise HTTPException(status.HTTP_409_CONFLICT, "Dieser Code ist bereits vergeben")
db.add(Barcode(code=code, note=(payload.note or None), product_id=product.id))
db.commit()
db.refresh(product)
return product_to_out(db, product)
@router.delete("/{product_id}/barcodes/{code}", status_code=status.HTTP_204_NO_CONTENT)
def delete_product_barcode(
product_id: int,
code: str,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
entry = (
db.query(Barcode)
.filter(Barcode.product_id == product_id, Barcode.code == code)
.first()
)
if entry is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Code nicht gefunden")
db.delete(entry)
db.commit()
@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_product(
product_id: int,

View File

@@ -23,6 +23,14 @@ class UnitCreate(BaseModel):
factor: float = Field(gt=0)
# ---- Barcodes ----
class BarcodeOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
code: str
note: str | None = None
# ---- Auth / Users ----
class Token(BaseModel):
access_token: str
@@ -62,6 +70,7 @@ class GroupOut(BaseModel):
stock: float = 0.0 # Bestand in Basiseinheiten
min_stock_unit_name: str | None = None
kind: str | None = None # Art der Mindestbestand-Einheit
barcodes: list[BarcodeOut] = [] # EANs, die dieser Gruppe zugeordnet sind
class GroupCreate(BaseModel):
@@ -153,12 +162,22 @@ class ProductOut(BaseModel):
# Mindestbestand in der erfassten Einheit (für die Anzeige):
min_stock_display: float | None = None
min_stock_unit_label: str = ""
# Zusätzliche EAN-Codes (neben dem Haupt-Barcode):
barcodes: list[BarcodeOut] = []
class LookupResult(BaseModel):
found: bool
existing_product: ProductOut | None = None
suggestion: dict | None = None
# Ist der Code einer Gruppe zugeordnet (z.B. "Mehl"), wird sie hier gemeldet.
group_id: int | None = None
group_name: str | None = None
class BarcodeCreate(BaseModel):
code: str = Field(min_length=4, max_length=64)
note: str | None = Field(default=None, max_length=120)
# ---- Stock movements ----

View File

@@ -104,6 +104,9 @@ export const api = {
createProduct: (body) => request("/products", { method: "POST", body }),
updateProduct: (id, body) => request(`/products/${id}`, { method: "PATCH", body }),
deleteProduct: (id) => request(`/products/${id}`, { method: "DELETE" }),
addProductBarcode: (id, body) => request(`/products/${id}/barcodes`, { method: "POST", body }),
deleteProductBarcode: (id, code) =>
request(`/products/${id}/barcodes/${encodeURIComponent(code)}`, { method: "DELETE" }),
// Bestand
checkIn: (body) => request("/stock/checkin", { method: "POST", body }),
@@ -129,6 +132,9 @@ export const api = {
createGroup: (body) => request("/groups", { method: "POST", body }),
updateGroup: (id, body) => request(`/groups/${id}`, { method: "PATCH", body }),
deleteGroup: (id) => request(`/groups/${id}`, { method: "DELETE" }),
addGroupBarcode: (id, body) => request(`/groups/${id}/barcodes`, { method: "POST", body }),
deleteGroupBarcode: (id, code) =>
request(`/groups/${id}/barcodes/${encodeURIComponent(code)}`, { method: "DELETE" }),
// Export / Import
exportCsv: () => downloadFile("/export/stock.csv", "bestand.csv"),

View File

@@ -0,0 +1,60 @@
import { useState } from "react";
import Icon from "./Icon";
/**
* Liste zusätzlicher EAN-Codes mit Notiz ("Mehl bei Aldi").
* onAdd({ code, note }) und onDelete(code) werden vom Aufrufer bereitgestellt.
*/
export default function BarcodeList({ barcodes = [], onAdd, onDelete, disabled = false, hint }) {
const [code, setCode] = useState("");
const [note, setNote] = useState("");
const [busy, setBusy] = useState(false);
async function add(e) {
e.preventDefault();
if (!code.trim()) return;
setBusy(true);
try {
await onAdd({ code: code.trim(), note: note.trim() || null });
setCode("");
setNote("");
} finally {
setBusy(false);
}
}
return (
<div>
<ul className="simple-list">
{barcodes.map((b) => (
<li key={b.id ?? b.code}>
<span style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
<code className="strong">{b.code}</code>
{b.note && <span className="muted small">{b.note}</span>}
</span>
{!disabled && (
<button className="btn-icon danger" title="Code entfernen"
onClick={() => onDelete(b.code)}>
<Icon name="trash" size={16} />
</button>
)}
</li>
))}
{barcodes.length === 0 && <li className="muted small">Noch keine zusätzlichen Codes.</li>}
</ul>
{!disabled && (
<form className="field-inline" onSubmit={add} style={{ marginTop: "var(--sp-3)" }}>
<input placeholder="EAN scannen oder eingeben" value={code}
onChange={(e) => setCode(e.target.value)} />
<input placeholder="Notiz, z.B. Mehl bei Aldi" value={note}
onChange={(e) => setNote(e.target.value)} />
<button className="btn" disabled={busy || !code.trim()}>
<Icon name="plus" size={16} />Hinzufügen
</button>
</form>
)}
{hint && <p className="muted small">{hint}</p>}
</div>
);
}

View File

@@ -13,6 +13,8 @@ export default function CheckIn() {
const [barcode, setBarcode] = useState("");
const [product, setProduct] = useState(null);
const [suggestion, setSuggestion] = useState(null);
// Gruppe, die diesem Code zugeordnet ist (z.B. alle Mehl-Marken in "Mehl").
const [suggestionGroup, setSuggestionGroup] = useState({ id: "", name: "" });
const [unknownBarcode, setUnknownBarcode] = useState(null);
const [results, setResults] = useState([]);
const [search, setSearch] = useState("");
@@ -60,8 +62,10 @@ export default function CheckIn() {
setInfo(`Produkt erkannt: ${res.existing_product.name}`);
} else if (res.found && res.suggestion) {
setSuggestion(res.suggestion);
setSuggestionGroup({ id: res.group_id ? String(res.group_id) : "", name: res.group_name || "" });
} else {
setUnknownBarcode(barcode.trim());
setSuggestionGroup({ id: res.group_id ? String(res.group_id) : "", name: res.group_name || "" });
}
} catch (err) {
setError(err.message);
@@ -71,7 +75,9 @@ export default function CheckIn() {
async function createFromSuggestion() {
setError(null); setBusy(true);
try {
const payload = suggestionToProduct(suggestion, guessGroup(groups, suggestion));
// Ist der Code einer Gruppe zugeordnet, hat das Vorrang vor dem Kategorie-Rateversuch.
const groupId = suggestionGroup.id || guessGroup(groups, suggestion);
const payload = suggestionToProduct(suggestion, groupId);
const created = await api.createProduct(payload);
selectProduct(created);
setInfo(`Produkt "${created.name}" angelegt.`);
@@ -163,7 +169,10 @@ export default function CheckIn() {
{suggestion.brand ? `${suggestion.brand} · ` : ""}auf Open Food Facts gefunden
{suggestion.quantity_text ? ` · ${suggestion.quantity_text}` : ""}
</div>
<div className="muted small">Noch nicht im Katalog.</div>
<div className="muted small">
Noch nicht im Katalog.
{suggestionGroup.name && ` Gruppe: ${suggestionGroup.name}`}
</div>
</div>
{isAdmin ? (
<button className="btn primary" onClick={createFromSuggestion} disabled={busy}>

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { api } from "../api";
import { useAuth } from "../auth";
import BarcodeList from "../components/BarcodeList";
import Icon from "../components/Icon";
import { fmt } from "../units";
@@ -9,6 +10,7 @@ export default function Groups() {
const [groups, setGroups] = useState([]);
const [units, setUnits] = useState([]);
const [form, setForm] = useState({ name: "", min_stock: "", min_stock_unit_id: "" });
const [selectedId, setSelectedId] = useState(null);
const [error, setError] = useState(null);
async function load() {
@@ -40,6 +42,7 @@ export default function Groups() {
}
async function patch(group, body) {
setError(null);
try {
await api.updateGroup(group.id, body);
load();
@@ -52,18 +55,23 @@ export default function Groups() {
if (!confirm(`Gruppe "${group.name}" löschen? Produkte bleiben erhalten, verlieren aber die Gruppenzuordnung.`)) return;
try {
await api.deleteGroup(group.id);
if (selectedId === group.id) setSelectedId(null);
load();
} catch (err) {
setError(err.message);
}
}
const selected = groups.find((g) => g.id === selectedId) || null;
return (
<div>
<div className="page-head">
<div>
<h1>Gruppen</h1>
<div className="sub">Produkte zusammenfassen (z.B. Nudeln) und einen Gruppen-Mindestbestand mit Einheit setzen</div>
<div className="sub">
Produkte zusammenfassen, Gruppen-Mindestbestand setzen und EAN-Codes hinterlegen
</div>
</div>
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
@@ -79,6 +87,7 @@ export default function Groups() {
<th className="num">Bestand</th>
<th>Mindestbestand</th>
<th>Einheit</th>
<th className="num">EANs</th>
<th></th>
</tr>
</thead>
@@ -87,8 +96,14 @@ export default function Groups() {
const low = g.min_stock != null && g.stock < g.min_stock;
return (
<tr key={g.id}>
<td className="strong">
{g.name}
<td>
{isAdmin ? (
<input defaultValue={g.name} style={{ marginTop: 0, minWidth: 130 }}
onBlur={(e) => {
const v = e.target.value.trim();
if (v && v !== g.name) patch(g, { name: v });
}} />
) : <span className="strong">{g.name}</span>}
{low && <span className="badge warn" style={{ marginLeft: 6 }}>niedrig</span>}
</td>
<td className="num muted">{g.product_count}</td>
@@ -96,7 +111,7 @@ export default function Groups() {
<td>
{isAdmin ? (
<input type="number" step="any" defaultValue={g.min_stock ?? ""}
style={{ maxWidth: 100, marginTop: 0 }}
style={{ marginTop: 0, minWidth: 90 }}
onBlur={(e) => {
const v = e.target.value;
if (v !== String(g.min_stock ?? "")) {
@@ -107,7 +122,7 @@ export default function Groups() {
</td>
<td>
{isAdmin ? (
<select value={g.min_stock_unit_id ?? ""} style={{ maxWidth: 140, marginTop: 0 }}
<select value={g.min_stock_unit_id ?? ""} style={{ marginTop: 0 }}
onChange={(e) => patch(g, {
min_stock_unit_id: e.target.value === "" ? null : Number(e.target.value),
})}>
@@ -116,6 +131,11 @@ export default function Groups() {
</select>
) : (g.min_stock_unit_name || "")}
</td>
<td className="num">
<button className="link-btn" onClick={() => setSelectedId(g.id)}>
{g.barcodes?.length || 0} verwalten
</button>
</td>
<td className="num">
{isAdmin && (
<button className="btn-icon danger" onClick={() => remove(g)} title="Löschen">
@@ -126,18 +146,47 @@ export default function Groups() {
</tr>
);
})}
{groups.length === 0 && <tr><td colSpan={6} className="empty">Noch keine Gruppen.</td></tr>}
{groups.length === 0 && <tr><td colSpan={7} className="empty">Noch keine Gruppen.</td></tr>}
</tbody>
</table>
</div>
</div>
<div>
{selected && (
<section className="card">
<div className="card-head">
<Icon name="search" />
<h2>EAN-Codes: {selected.name}</h2>
</div>
<BarcodeList
barcodes={selected.barcodes || []}
disabled={!isAdmin}
hint="Scannst du einen dieser Codes beim Einlagern, wird das neue Produkt automatisch dieser Gruppe zugeordnet."
onAdd={async (body) => {
try {
await api.addGroupBarcode(selected.id, body);
await load();
} catch (err) { setError(err.message); }
}}
onDelete={async (code) => {
try {
await api.deleteGroupBarcode(selected.id, code);
await load();
} catch (err) { setError(err.message); }
}}
/>
<button className="btn ghost" onClick={() => setSelectedId(null)}>Schließen</button>
</section>
)}
{isAdmin && (
<form className="card" onSubmit={add}>
<div className="card-head"><Icon name="tag" /><h2>Neue Gruppe</h2></div>
<label>
Name
<input placeholder="z.B. Nudeln" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
<input placeholder="z.B. Mehl" value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })} required />
</label>
<div className="row">
<label className="grow">
@@ -156,12 +205,13 @@ export default function Groups() {
</div>
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
<p className="muted small">
Der Gruppen-Bestand summiert nur Produkte, die zur gewählten Einheit passen
(Gewicht/Volumen/Stück). Produkte ordnest du im Produkt-Formular einer Gruppe zu.
Der Gruppen-Bestand summiert nur Produkte, die zur gewählten Einheit passen.
Namen lassen sich in der Tabelle direkt ändern.
</p>
</form>
)}
</div>
</div>
</div>
);
}

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { api } from "../api";
import { useAuth } from "../auth";
import BarcodeList from "../components/BarcodeList";
import Icon from "../components/Icon";
import { guessGroup } from "../offUtils";
import { daysUntil, expiryRowClass, fmt, isExpired, unitShort } from "../units";
@@ -332,6 +333,28 @@ export default function ProductForm() {
{readOnly && <p className="muted small mt-0">Nur Administratoren können Produkte bearbeiten.</p>}
</form>
{!isNew && product && (
<section className="card">
<div className="card-head"><Icon name="search" /><h2>Weitere EAN-Codes</h2></div>
<BarcodeList
barcodes={product.barcodes || []}
disabled={readOnly}
hint="Nützlich, wenn dasselbe Produkt mehrere Codes hat (z.B. Aktionspackung)."
onAdd={async (body) => {
try {
setProduct(await api.addProductBarcode(id, body));
} catch (err) { setError(err.message); }
}}
onDelete={async (code) => {
try {
await api.deleteProductBarcode(id, code);
setProduct(await api.getProduct(id));
} catch (err) { setError(err.message); }
}}
/>
</section>
)}
{!isNew && (
<LotsCard
product={product}

View File

@@ -76,7 +76,7 @@ export default function Users() {
</td>
<td>
<select value={u.role} onChange={(e) => changeRole(u, e.target.value)}
disabled={u.id === me.id} style={{ maxWidth: 150 }}>
disabled={u.id === me.id}>
<option value="user">Benutzer</option>
<option value="admin">Administrator</option>
</select>

View File

@@ -160,6 +160,8 @@ td .field-inline { margin-bottom: 0; flex-wrap: wrap; }
Zahlenfelder duerfen wachsen, Einheiten-Dropdowns behalten ihre Textbreite. */
.field-inline > input { flex: 1 1 auto; min-width: 78px; }
.field-inline > select { flex: 0 0 auto; width: auto; min-width: 132px; }
/* Auswahlfelder in Tabellen richten sich nach ihrem Text statt nach der Zelle. */
td select { width: auto; min-width: 132px; max-width: 100%; }
/* Button-Paare (z.B. Speichern/Abbrechen) gleich breit halten. */
.btn-pair { display: flex; gap: var(--sp-2); flex-wrap: wrap; justify-content: flex-end; }
.btn-pair .btn { flex: 1 1 auto; min-width: 104px; justify-content: center; }