From c8c5e658e2205df969be003798436ff8e41600ae Mon Sep 17 00:00:00 2001 From: boxswapper Date: Thu, 12 Nov 2026 13:57:23 -0700 Subject: [PATCH] updated quality for ore --- README.md | 30 +++++-- trade-hub/backend/app/config.py | 1 + trade-hub/backend/app/db.py | 23 +++++ trade-hub/backend/app/inventory.py | 32 ++++++- trade-hub/backend/app/loader.py | 68 +++++++++++++++ trade-hub/backend/app/models.py | 8 ++ trade-hub/backend/app/schemas.py | 9 ++ trade-hub/backend/tests/test_available.py | 36 ++++++-- trade-hub/backend/tests/test_inventory.py | 100 ++++++++++++++++++++-- trade-hub/frontend/src/app.js | 82 ++++++++++++++++-- trade-hub/frontend/src/index.html | 2 + trade-hub/frontend/src/style.css | 10 +++ 12 files changed, 376 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 0aa9a58..f17d4c8 100644 --- a/README.md +++ b/README.md @@ -119,18 +119,29 @@ The inventory view can be filtered by category and searched by name/class name, and sorted by `name` (default), `category`, `quantity` (high to low) or `updated` (most recently changed first). -Inventory rows store `(user_id, class_name, quantity, notes, visibility)`. -Adding an item that is already present increments its quantity; quantity, notes -and visibility can then be edited or the row removed. Every added item starts -`private`; the Visibility toggle switches it to `available` and back. Responses -are enriched live from the catalog. If a catalog item disappears after a +Inventory rows store `(user_id, class_name, quantity, notes, visibility, +quality)`. Adding an item that is already present increments its quantity; +quantity, notes, visibility and quality can then be edited or the row removed. +Every added item starts `private`; the Visibility toggle switches it to +`available` and back. Responses are enriched live from the catalog. + +`quality` is an optional mining/refining value from **1 to 1000**. It is only +accepted for commodities that are actually mineable — ores, minerals and gems +(and their refined/raw/pure variants). Gases, organics, processed goods, crafted +alloys and salvage are excluded. The catalog exposes this as +`quality_applicable`; the UI shows a Quality input only for those commodities. +`null` means the owner has not recorded a quality. On `PATCH`, sending +`"quality": null` clears it, while omitting the field leaves it unchanged. Each +inventory row holds one quality (the schema keeps one row per commodity, so +different quality lots are not separated). If a catalog item disappears after a dataset refresh, the row is reported with `available: false` instead of being silently deleted (`available` is about the catalog, `visibility` is the owner's classification). The **Available** tab (backed by `GET /api/v1/available`) is public, like the catalog: it lists every row marked `available` across all accounts with the -item's catalog details, quantity, owner username and last update. Private rows +item's catalog details, quantity, quality (when applicable), owner username and +last update. Private rows never appear there and inventory notes are never exposed. Clicking an item name in either **My Inventory** or **Available** opens the same @@ -188,7 +199,8 @@ so no PostgreSQL server is required for the suite. Production uses PostgreSQL. Commodities are deduplicated to one catalog entry per name (the 1 SCU container variant is canonical); quantity lives on the inventory row, so 1 SCU, 8 SCU and 32 SCU crates of the same commodity share a single entry. Refined and unrefined -materials stay separate (`Iron` vs `Iron (Ore)`). +materials stay separate (`Iron` vs `Iron (Ore)`). Quality applies to both the +refined and raw/ore entries and is user-recorded per inventory row. Tables are created on startup with an idempotent `create_all` and a connection retry loop, so a fresh volume bootstraps itself and a slow PostgreSQL start does @@ -212,8 +224,8 @@ GET /api/v1/auth/me -> {user} GET /api/v1/me/inventory?category=&search=&sort=&page=&page_size= GET /api/v1/me/inventory/summary -POST /api/v1/me/inventory {class_name, quantity?, notes?} -PATCH /api/v1/me/inventory/{class_name} {quantity?, notes?, visibility?} +POST /api/v1/me/inventory {class_name, quantity?, notes?, quality?} +PATCH /api/v1/me/inventory/{class_name} {quantity?, notes?, visibility?, quality?} DELETE /api/v1/me/inventory/{class_name} GET /api/v1/available?category=&search=&sort=&page=&page_size= diff --git a/trade-hub/backend/app/config.py b/trade-hub/backend/app/config.py index 80a7ffe..ea03b64 100644 --- a/trade-hub/backend/app/config.py +++ b/trade-hub/backend/app/config.py @@ -55,3 +55,4 @@ LOGIN_WINDOW_SECONDS = int(os.environ.get("LOGIN_WINDOW_SECONDS", "900")) # --- Input limits --------------------------------------------------------- MAX_NOTES_LENGTH = 1000 MAX_QUANTITY = 1_000_000 +MAX_QUALITY = 1000 diff --git a/trade-hub/backend/app/db.py b/trade-hub/backend/app/db.py index d5bf787..bb53da5 100644 --- a/trade-hub/backend/app/db.py +++ b/trade-hub/backend/app/db.py @@ -83,6 +83,29 @@ def _upgrade_schema(engine) -> None: """ ) ) + conn.execute( + text( + "ALTER TABLE inventory_entries ADD COLUMN IF NOT EXISTS " + "quality INTEGER" + ) + ) + conn.execute( + text( + """ + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'ck_inventory_quality_range' + ) THEN + ALTER TABLE inventory_entries + ADD CONSTRAINT ck_inventory_quality_range + CHECK (quality IS NULL OR (quality >= 1 AND quality <= 1000)); + END IF; + END $$ + """ + ) + ) def init_db(wait_seconds: int = 0, interval: float = 1.0) -> None: diff --git a/trade-hub/backend/app/inventory.py b/trade-hub/backend/app/inventory.py index df500d9..4214973 100644 --- a/trade-hub/backend/app/inventory.py +++ b/trade-hub/backend/app/inventory.py @@ -46,6 +46,8 @@ def enrich_entry(entry: InventoryEntry) -> dict: "manufacturer": None, "quantity": entry.quantity, "notes": entry.notes, + "quality": entry.quality, + "quality_applicable": False, "visibility": entry.visibility, "available": False, "created_at": entry.created_at, @@ -61,6 +63,8 @@ def enrich_entry(entry: InventoryEntry) -> dict: "manufacturer": item["manufacturer"], "quantity": entry.quantity, "notes": entry.notes, + "quality": entry.quality, + "quality_applicable": item.get("quality_applicable", False), "visibility": entry.visibility, "available": True, "created_at": entry.created_at, @@ -68,6 +72,14 @@ def enrich_entry(entry: InventoryEntry) -> dict: } +def _validate_quality(item: dict, quality: int | None) -> None: + if quality is not None and not item.get("quality_applicable", False): + raise HTTPException( + status_code=422, + detail=f"Quality does not apply to '{item['name']}'", + ) + + def _find(db: DbSession, user: User, class_name: str) -> InventoryEntry | None: return db.execute( select(InventoryEntry).where( @@ -178,10 +190,12 @@ def add_to_inventory( db: DbSession = Depends(get_db), ): class_name = payload.class_name.strip() - if class_name not in catalog.store.by_class_name: + item = catalog.store.by_class_name.get(class_name) + if item is None: raise HTTPException( status_code=404, detail=f"Catalog item '{class_name}' not found" ) + _validate_quality(item, payload.quality) entry = _find(db, user, class_name) created = entry is None @@ -192,12 +206,15 @@ def add_to_inventory( class_name=class_name, quantity=payload.quantity, notes=payload.notes, + quality=payload.quality, ) db.add(entry) else: entry.quantity += payload.quantity if payload.notes: entry.notes = payload.notes + if payload.quality is not None: + entry.quality = payload.quality try: db.commit() @@ -210,6 +227,8 @@ def add_to_inventory( entry.quantity += payload.quantity if payload.notes: entry.notes = payload.notes + if payload.quality is not None: + entry.quality = payload.quality db.commit() created = False @@ -235,6 +254,17 @@ def update_inventory_entry( entry.notes = payload.notes if payload.visibility is not None: entry.visibility = payload.visibility + if "quality" in payload.model_fields_set: + # Explicit null clears the value; omitted keeps the existing one. + if payload.quality is not None: + item = catalog.store.by_class_name.get(entry.class_name) + if item is None: + raise HTTPException( + status_code=422, + detail=f"Quality does not apply to '{entry.class_name}'", + ) + _validate_quality(item, payload.quality) + entry.quality = payload.quality db.commit() db.refresh(entry) diff --git a/trade-hub/backend/app/loader.py b/trade-hub/backend/app/loader.py index 90235ef..ccac13a 100644 --- a/trade-hub/backend/app/loader.py +++ b/trade-hub/backend/app/loader.py @@ -17,6 +17,7 @@ Real schemas this loader consumes: """ import json import os +import re from app import config @@ -27,6 +28,68 @@ CATEGORY_SHIP_ITEMS = "ship-items" CATEGORY_FPS_ITEMS = "fps-items" CATEGORY_COMMODITIES = "commodities" +# Commodities that can carry a mining/refining quality value (1-1000). These are +# the ores, minerals and gems that players actually mine (and their refined +# counterparts); crafted alloys, gases, organics, processed goods and junk +# by-products are deliberately excluded. Keep in sync with the Ore quality table +# at https://starcitizen.tools/Ore_quality. +QUALITY_MATERIALS = frozenset( + name.lower() + for name in ( + "Agricium", + "Aluminum", + "Aphorite", + "Aslarite", + "Beradom", + "Beryl", + "Bexalite", + "Borase", + "Carinite", + "Copper", + "Corundum", + "Diamond", + "Dolivine", + "Feynmaline", + "Glacosite", + "Gold", + "Hadanite", + "Hephaestanite", + "Iron", + "Jaclium", + "Janalite", + "Laranite", + "Lindinium", + "Ouratite", + "Pressurized Ice", + "Quantainium", + "Quartz", + "Riccite", + "Sadaryx", + "Saldynium", + "Savrilium", + "Silicon", + "Stileron", + "Taranite", + "Tin", + "Titanium", + "Torite", + "Tungsten", + ) +) + +# Variant suffixes are presentation labels, not a different material: "Iron +# (Ore)" and "Iron" share a quality value, as do "(Raw)", "(Pure)" and "(R)". +_QUALITY_VARIANT = re.compile(r"\s*\((?:Ore|Raw|Pure|R|\d+\s*SCU)\)\s*$", re.IGNORECASE) +_RAW_PREFIX = re.compile(r"^Raw\s+", re.IGNORECASE) + + +def _quality_applicable(name, category): + if category != CATEGORY_COMMODITIES: + return False + base_name = _QUALITY_VARIANT.sub("", name or "").strip() + base_name = _RAW_PREFIX.sub("", base_name).strip().lower() + return base_name in QUALITY_MATERIALS + def _load_json(data_dir, filename): path = os.path.join(data_dir, filename) @@ -80,6 +143,7 @@ def _normalize_ship(ship): "classification": "Ship", "description": ship.get("Description") or ship.get("DescriptionText") or "", "tags": tags, + "quality_applicable": False, }, { "career": ship.get("Career") or "", "role": ship.get("Role") or "", @@ -111,6 +175,10 @@ def _normalize_item(item, category): "classification": item.get("classification") or "", "description": std.get("Description") or std.get("DescriptionText") or "", "tags": tags, + "quality_applicable": _quality_applicable( + _resolve_name(std.get("Name"), item.get("name"), class_name=class_name), + category, + ), } if category == CATEGORY_COMMODITIES: summary["size"] = None diff --git a/trade-hub/backend/app/models.py b/trade-hub/backend/app/models.py index 7b4a2b2..e26fae0 100644 --- a/trade-hub/backend/app/models.py +++ b/trade-hub/backend/app/models.py @@ -78,6 +78,10 @@ class InventoryEntry(Base): "visibility IN ('private', 'available')", name="ck_inventory_visibility", ), + CheckConstraint( + "quality IS NULL OR (quality >= 1 AND quality <= 1000)", + name="ck_inventory_quality_range", + ), ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) @@ -87,6 +91,10 @@ class InventoryEntry(Base): class_name: Mapped[str] = mapped_column(String(255), nullable=False) quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1) notes: Mapped[str] = mapped_column(Text, nullable=False, default="") + # Mining/refining quality (1-1000) for commodities where it applies. NULL + # means "not specified"; non-quality commodities must keep this NULL. The + # applicability itself is catalog data, not stored here. + quality: Mapped[int | None] = mapped_column(Integer, nullable=True) # "private" (default): only the owner sees the row. "available": the owner # is offering the item. Unrelated to the response flag `available`, which # only reports whether the catalog still contains the referenced item. diff --git a/trade-hub/backend/app/schemas.py b/trade-hub/backend/app/schemas.py index e347af5..e7234af 100644 --- a/trade-hub/backend/app/schemas.py +++ b/trade-hub/backend/app/schemas.py @@ -33,12 +33,15 @@ class InventoryAddRequest(BaseModel): class_name: str = Field(min_length=1, max_length=255) quantity: int = Field(default=1, ge=1, le=config.MAX_QUANTITY) notes: str = Field(default="", max_length=config.MAX_NOTES_LENGTH) + quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY) class InventoryPatchRequest(BaseModel): quantity: int | None = Field(default=None, ge=1, le=config.MAX_QUANTITY) notes: str | None = Field(default=None, max_length=config.MAX_NOTES_LENGTH) visibility: Literal["private", "available"] | None = None + # Explicit null clears the quality; omitting the field leaves it unchanged. + quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY) class InventoryEntryOut(BaseModel): @@ -51,6 +54,10 @@ class InventoryEntryOut(BaseModel): manufacturer: dict | None quantity: int notes: str + # Mining/refining quality (1-1000); only meaningful when + # `quality_applicable` is true. None means the owner has not set it. + quality: int | None = None + quality_applicable: bool = False # Owner-chosen classification. Unrelated to `available` below. visibility: Literal["private", "available"] # False when the catalog no longer contains the referenced item (e.g. after @@ -84,6 +91,8 @@ class AvailableItemOut(BaseModel): grade: Any | None = None manufacturer: dict | None quantity: int + quality: int | None = None + quality_applicable: bool = False # Public listing: only the owner's display name, never notes. username: str updated_at: datetime diff --git a/trade-hub/backend/tests/test_available.py b/trade-hub/backend/tests/test_available.py index 249e9cc..f938dbc 100644 --- a/trade-hub/backend/tests/test_available.py +++ b/trade-hub/backend/tests/test_available.py @@ -7,6 +7,7 @@ PASSWORD = "correct horse battery staple" CARROCK = "ANVL_Carrack" RIFLE = "BEHR_Rifle_Ballistic_01" IRON_ORE = "Carryable_TBO_FL_1SCU_Commodity_Metal_Ore_Iron" +CONSTRUCTION = "Carryable_TBO_FL_1SCU_Commodity_ProcessedGoods_ConstructionMaterials" def authed_client(username: str) -> TestClient: @@ -18,11 +19,17 @@ def authed_client(username: str) -> TestClient: return client -def add(client: TestClient, class_name: str, quantity: int = 1, notes: str = ""): - resp = client.post( - "/api/v1/me/inventory", - json={"class_name": class_name, "quantity": quantity, "notes": notes}, - ) +def add( + client: TestClient, + class_name: str, + quantity: int = 1, + notes: str = "", + quality: int | None = None, +): + payload = {"class_name": class_name, "quantity": quantity, "notes": notes} + if quality is not None: + payload["quality"] = quality + resp = client.post("/api/v1/me/inventory", json=payload) assert resp.status_code in (200, 201), resp.text return resp @@ -128,3 +135,22 @@ def test_commodity_offers_are_listed_with_owner(): assert item["category"] == "commodities" assert item["quantity"] == 12 assert item["username"] == "alice" + + +def test_commodity_quality_is_listed_publicly(): + alice = authed_client("alice") + offer(alice, IRON_ORE, quantity=12) + add(alice, IRON_ORE, quality=875) + + item = public_items()["items"][0] + assert item["quality"] == 875 + assert item["quality_applicable"] is True + + +def test_non_quality_commodity_is_listed_without_quality(): + alice = authed_client("alice") + offer(alice, CONSTRUCTION) + + item = public_items()["items"][0] + assert item["quality"] is None + assert item["quality_applicable"] is False diff --git a/trade-hub/backend/tests/test_inventory.py b/trade-hub/backend/tests/test_inventory.py index bf8cd2b..173aec8 100644 --- a/trade-hub/backend/tests/test_inventory.py +++ b/trade-hub/backend/tests/test_inventory.py @@ -7,6 +7,8 @@ PASSWORD = "correct horse battery staple" CARROCK = "ANVL_Carrack" RIFLE = "BEHR_Rifle_Ballistic_01" IRON_ORE = "Carryable_TBO_FL_1SCU_Commodity_Metal_Ore_Iron" +IRON = "Carryable_TBO_FL_1SCU_Commodity_Metal_Iron" +CONSTRUCTION = "Carryable_TBO_FL_1SCU_Commodity_ProcessedGoods_ConstructionMaterials" def authed_client(username: str = "alice") -> TestClient: @@ -18,11 +20,17 @@ def authed_client(username: str = "alice") -> TestClient: return client -def add(client: TestClient, class_name: str, quantity: int = 1, notes: str = ""): - return client.post( - "/api/v1/me/inventory", - json={"class_name": class_name, "quantity": quantity, "notes": notes}, - ) +def add( + client: TestClient, + class_name: str, + quantity: int = 1, + notes: str = "", + quality: int | None = None, +): + payload = {"class_name": class_name, "quantity": quantity, "notes": notes} + if quality is not None: + payload["quality"] = quality + return client.post("/api/v1/me/inventory", json=payload) def test_new_account_starts_with_empty_inventory(): @@ -264,3 +272,85 @@ def test_add_commodity_uses_catalog_name_and_quantity(): assert entry["quantity"] == 12 assert entry["available"] is True assert entry["visibility"] == "private" + + +def test_catalog_marks_only_mineable_commodities_as_quality_applicable(): + client = TestClient(app) + items = client.get( + "/api/v1/items", params={"category": "commodities", "page_size": 100} + ).json()["items"] + flags = {item["class_name"]: item["quality_applicable"] for item in items} + assert flags[IRON] is True + assert flags[IRON_ORE] is True + assert flags[CONSTRUCTION] is False + assert flags["Carryable_TBO_FL_1SCU_Commodity_Metal_RMC"] is False + + ships = client.get("/api/v1/items", params={"category": "ships"}).json()["items"] + assert all(item["quality_applicable"] is False for item in ships) + + +def test_quality_can_be_set_and_cleared_on_a_quality_commodity(): + client = authed_client() + assert add(client, IRON_ORE).status_code == 201 + + resp = client.patch(f"/api/v1/me/inventory/{IRON_ORE}", json={"quality": 750}) + assert resp.status_code == 200 + body = resp.json() + assert body["quality"] == 750 + assert body["quality_applicable"] is True + + cleared = client.patch( + f"/api/v1/me/inventory/{IRON_ORE}", json={"quality": None} + ) + assert cleared.status_code == 200 + assert cleared.json()["quality"] is None + + +def test_add_persists_quality_and_keeps_it_when_quantity_is_incremented(): + client = authed_client() + first = add(client, IRON, quality=900) + assert first.status_code == 201 + assert first.json()["entry"]["quality"] == 900 + + second = add(client, IRON, quantity=2) + assert second.status_code == 200 + entry = second.json()["entry"] + assert entry["quality"] == 900 + assert entry["quantity"] == 3 + + replaced = add(client, IRON, quality=400) + assert replaced.json()["entry"]["quality"] == 400 + + +def test_quality_is_rejected_for_non_mineable_commodities(): + client = authed_client() + assert add(client, CONSTRUCTION).status_code == 201 + + resp = client.patch( + f"/api/v1/me/inventory/{CONSTRUCTION}", json={"quality": 500} + ) + assert resp.status_code == 422 + assert "does not apply" in resp.json()["detail"] + + add_resp = add(client, CONSTRUCTION, quality=500) + assert add_resp.status_code == 422 + + +def test_quality_is_rejected_for_ships(): + client = authed_client() + assert add(client, CARROCK).status_code == 201 + resp = client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quality": 100}) + assert resp.status_code == 422 + + +def test_quality_range_is_enforced(): + client = authed_client() + assert add(client, IRON_ORE, quality=1).status_code == 201 + assert add(client, IRON, quality=1000).status_code == 201 + assert add(client, IRON_ORE, quality=0).status_code == 422 + assert add(client, IRON_ORE, quality=1001).status_code == 422 + + assert add(client, IRON, quantity=1).status_code == 200 + assert client.patch( + f"/api/v1/me/inventory/{IRON}", json={"quality": 1001} + ).status_code == 422 diff --git a/trade-hub/frontend/src/app.js b/trade-hub/frontend/src/app.js index 42b0600..952959a 100644 --- a/trade-hub/frontend/src/app.js +++ b/trade-hub/frontend/src/app.js @@ -361,6 +361,10 @@ async function openModal(class_name) {
+ ${item.quality_applicable ? ` + + + ` : ""} @@ -368,7 +372,16 @@ async function openModal(class_name) { `; document.getElementById("add-to-inventory").addEventListener("click", () => { const qty = Math.max(1, Number(document.getElementById("add-qty").value) || 1); - addToInventory(item.class_name, qty); + let quality = null; + const qualityInput = document.getElementById("add-quality"); + if (qualityInput && qualityInput.value.trim() !== "") { + quality = Number(qualityInput.value); + if (!Number.isInteger(quality) || quality < 1 || quality > 1000) { + showToast("Quality must be between 1 and 1000", true); + return; + } + } + addToInventory(item.class_name, qty, quality); }); } catch (err) { els["modal-body"].innerHTML = ``; @@ -503,6 +516,48 @@ function itemNameCell(entry) { return cell; } +function qualityCell(entry, editable) { + const cell = document.createElement("td"); + if (!entry.quality_applicable) { + // Keep a recorded quality visible even if the catalog item disappeared. + cell.innerHTML = + entry.quality == null + ? '—' + : escapeHtml(String(entry.quality)); + return cell; + } + if (!editable) { + cell.textContent = entry.quality == null ? "—" : String(entry.quality); + return cell; + } + const input = document.createElement("input"); + input.type = "number"; + input.className = "inv-quality"; + input.min = "1"; + input.max = "1000"; + input.placeholder = "—"; + input.value = entry.quality == null ? "" : String(entry.quality); + input.setAttribute("aria-label", `Quality of ${entry.name}`); + input.addEventListener("change", () => { + const raw = input.value.trim(); + if (raw === "") { + entry.quality = null; + patchEntry(entry.class_name, { quality: null }); + return; + } + const value = Number(raw); + if (!Number.isInteger(value) || value < 1 || value > 1000) { + input.value = entry.quality == null ? "" : String(entry.quality); + showToast("Quality must be between 1 and 1000", true); + return; + } + entry.quality = value; + patchEntry(entry.class_name, { quality: value }); + }); + cell.appendChild(input); + return cell; +} + function renderInventory(listing) { const tbody = els["inv-tbody"]; tbody.innerHTML = ""; @@ -567,7 +622,15 @@ function renderInventory(listing) { visibilityCell.appendChild(visibility); applyVisibility(visibility, entry.visibility); - row.append(nameCell, categoryCell, qtyCell, notesCell, visibilityCell, actionCell); + row.append( + nameCell, + categoryCell, + qtyCell, + qualityCell(entry, true), + notesCell, + visibilityCell, + actionCell + ); tbody.appendChild(row); } } @@ -633,15 +696,17 @@ async function deleteEntry(class_name, name) { } } -async function addToInventory(class_name, quantity = 1) { +async function addToInventory(class_name, quantity = 1, quality = null) { if (!state.user) { openAuth("login", "Log in to add items to your inventory."); return; } + const body = { class_name, quantity }; + if (quality !== null) body.quality = quality; try { const data = await apiFetch("/api/v1/me/inventory", { method: "POST", - body: { class_name, quantity }, + body, }); showToast( data.created @@ -718,7 +783,14 @@ function renderAvailable(listing) { updatedCell.className = "avail-updated"; updatedCell.textContent = formatDate(entry.updated_at); - row.append(nameCell, categoryCell, qtyCell, userCell, updatedCell); + row.append( + nameCell, + categoryCell, + qtyCell, + qualityCell(entry, false), + userCell, + updatedCell + ); tbody.appendChild(row); } } diff --git a/trade-hub/frontend/src/index.html b/trade-hub/frontend/src/index.html index 85cb8ab..06287ce 100644 --- a/trade-hub/frontend/src/index.html +++ b/trade-hub/frontend/src/index.html @@ -90,6 +90,7 @@ Item Category Qty + Quality Notes Visibility @@ -129,6 +130,7 @@ Item Category Qty + Quality Offered by Updated diff --git a/trade-hub/frontend/src/style.css b/trade-hub/frontend/src/style.css index 9894ce8..0d23a3d 100644 --- a/trade-hub/frontend/src/style.css +++ b/trade-hub/frontend/src/style.css @@ -429,6 +429,16 @@ main { border-radius: 6px; padding: 0.35rem 0.5rem; } +.inv-quality { + width: 5rem; + background: var(--bg-panel); + border: 1px solid var(--border); + color: var(--text); + border-radius: 6px; + padding: 0.35rem 0.5rem; +} +.inv-quality-na { color: var(--text-dim); } +.inv-quality:focus { outline: none; border-color: var(--accent); } .inv-notes { width: 100%; min-width: 12rem;