Compare commits

..

2 Commits

Author SHA1 Message Date
boxswapper
632aee934f order button 2026-11-12 19:41:50 -07:00
boxswapper
3be92f55fb order button 2026-11-12 19:41:10 -07:00
9 changed files with 856 additions and 10 deletions

View File

@@ -9,10 +9,12 @@ RMC, construction materials, …) are browsable with search, manufacturer / type
size filters, pagination, and a detail view per item. size filters, pagination, and a detail view per item.
Users can optionally create an account and build a **personal inventory**: items Users can optionally create an account and build a **personal inventory**: items
from the catalog are added to their own list with a quantity and notes. The from the catalog are added to their own list with a quantity and notes. Logged-in
catalog itself stays immutable read-only reference data; personal inventories users can also place **orders** for any catalog item; orders go onto a public
live in PostgreSQL and reference catalog items by `class_name` only, so the board that every visitor can see. The catalog itself stays immutable read-only
dataset can be replaced wholesale without touching user data. reference data; personal inventories and orders live in PostgreSQL and reference
catalog items by `class_name` only, so the dataset can be replaced wholesale
without touching user data.
## Stack ## Stack
@@ -144,8 +146,18 @@ item's catalog details, quantity, quality (when applicable), owner username and
last update. Private rows last update. Private rows
never appear there and inventory notes are never exposed. never appear there and inventory notes are never exposed.
Clicking an item name in either **My Inventory** or **Available** opens the same The **Orders** tab (backed by `GET /api/v1/orders`) is also public. A logged-in
catalog detail view used by the catalog grid. user clicks any item to open its detail view, then fills in the **Request this
item** form there with a quantity, optional notes, and — for mineable
commodities — an optional quality. Setting up the order publishes the request
on the public board.
Orders show the item, quantity, quality, notes, requesting username, status
(`open`/`fulfilled`) and last update. Notes on orders are public by design;
inventory notes remain private. Only the owner can edit, mark fulfilled/reopen,
or remove their own order.
Clicking an item name in **My Inventory**, **Available** or **Orders** opens the
same catalog detail view used by the catalog grid.
### Security: read before exposing this publicly ### Security: read before exposing this publicly
@@ -186,7 +198,7 @@ curl -fsS -b cookies.txt http://localhost:8080/api/v1/me/inventory
cd trade-hub/backend cd trade-hub/backend
python3 -m venv .venv python3 -m venv .venv
.venv/bin/pip install -r requirements.txt -r requirements-dev.txt .venv/bin/pip install -r requirements.txt -r requirements-dev.txt
.venv/bin/python -m pytest tests/ -q # 65 tests, no database needed .venv/bin/python -m pytest tests/ -q # 83 tests, no database needed
DATA_DIR=./data/samples .venv/bin/uvicorn main:app --port 8000 DATA_DIR=./data/samples .venv/bin/uvicorn main:app --port 8000
``` ```
@@ -229,12 +241,19 @@ PATCH /api/v1/me/inventory/{class_name} {quantity?, notes?, visibility?, qual
DELETE /api/v1/me/inventory/{class_name} DELETE /api/v1/me/inventory/{class_name}
GET /api/v1/available?category=&search=&sort=&page=&page_size= GET /api/v1/available?category=&search=&sort=&page=&page_size=
GET /api/v1/orders?category=&search=&status=&sort=&page=&page_size=
POST /api/v1/orders {class_name, quantity?, notes?, quality?}
PATCH /api/v1/orders/{id} {quantity?, notes?, status?, quality?}
DELETE /api/v1/orders/{id}
``` ```
`/api/v1/me/*` requires a valid session cookie and returns `401` otherwise. `/api/v1/me/*` requires a valid session cookie and returns `401` otherwise.
`/api/v1/available` is public and read-only; it accepts `sort=name|category|quantity|updated` `/api/v1/available` and `GET /api/v1/orders` are public and read-only. Both
(default `updated`). Catalog `category` values are `ships`, `ship-items`, accept `sort=name|category|quantity|updated` (default `updated`); orders also
`fps-items` and `commodities`. accept `status=open|fulfilled`. Creating or changing an order requires a
session; only its owner may update or delete it. Catalog `category` values are
`ships`, `ship-items`, `fps-items` and `commodities`.
### Configuration ### Configuration

View File

@@ -15,6 +15,7 @@ from app.available import router as available_router
from app.catalog import store from app.catalog import store
from app.db import db_healthy, init_db from app.db import db_healthy, init_db
from app.inventory import router as inventory_router from app.inventory import router as inventory_router
from app.orders import router as orders_router
@asynccontextmanager @asynccontextmanager
@@ -47,6 +48,7 @@ if config.CORS_ORIGINS:
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(inventory_router) app.include_router(inventory_router)
app.include_router(available_router) app.include_router(available_router)
app.include_router(orders_router)
VALID_CATEGORIES = {"ships", "ship-items", "fps-items", "commodities"} VALID_CATEGORIES = {"ships", "ship-items", "fps-items", "commodities"}

View File

@@ -49,6 +49,9 @@ class User(Base):
inventory: Mapped[list["InventoryEntry"]] = relationship( inventory: Mapped[list["InventoryEntry"]] = relationship(
back_populates="user", cascade="all, delete-orphan" back_populates="user", cascade="all, delete-orphan"
) )
orders: Mapped[list["Order"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
class UserSession(Base): class UserSession(Base):
@@ -109,3 +112,46 @@ class InventoryEntry(Base):
) )
user: Mapped[User] = relationship(back_populates="inventory") user: Mapped[User] = relationship(back_populates="inventory")
class Order(Base):
"""A public request for a catalog item placed by a logged-in user.
Orders are intentionally visible to everyone (see app/orders.py). They
reference catalog items by `class_name` only, exactly like inventory rows,
so a catalog refresh cannot break them.
"""
__tablename__ = "orders"
__table_args__ = (
CheckConstraint("quantity >= 1", name="ck_orders_quantity_positive"),
CheckConstraint(
"quality IS NULL OR (quality >= 1 AND quality <= 1000)",
name="ck_orders_quality_range",
),
CheckConstraint(
"status IN ('open', 'fulfilled')",
name="ck_orders_status",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
class_name: Mapped[str] = mapped_column(String(255), nullable=False)
quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
# Mining/refining quality (1-1000); only valid for quality-applicable ores.
quality: Mapped[int | None] = mapped_column(Integer, nullable=True)
notes: Mapped[str] = mapped_column(Text, nullable=False, default="")
status: Mapped[str] = mapped_column(
String(16), nullable=False, default="open", server_default="open"
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=utcnow
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow
)
user: Mapped[User] = relationship(back_populates="orders")

View File

@@ -0,0 +1,209 @@
"""Public order board.
Any logged-in user can place an order (a request) for a catalog item. Every
order is visible to everyone, with the requester's display name, quantity,
notes, and — for quality-applicable ores — the requested quality. Orders are
read-only for the public; only the owner can edit or remove their own order.
Like inventory rows, orders reference catalog items by `class_name` only, so a
dataset refresh cannot break them.
"""
import math
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy import select
from sqlalchemy.orm import Session as DbSession
from app import catalog
from app.db import get_db
from app.deps import get_current_user, require_same_origin
from app.inventory import SORT_KEYS
from app.models import Order, User
from app.schemas import OrderCreateRequest, OrderOut, OrderPage, OrderPatchRequest
router = APIRouter(prefix="/api/v1/orders", tags=["orders"])
MAX_PAGE_SIZE = 100
def enrich_order(order: Order, username: str) -> dict:
item = catalog.store.by_class_name.get(order.class_name)
if item is None:
item_data = {
"name": order.class_name.replace("_", " "),
"category": None,
"type": None,
"size": None,
"grade": None,
"manufacturer": None,
"quality_applicable": False,
"available": False,
}
else:
item_data = {
"name": item["name"],
"category": item["category"],
"type": item["type"],
"size": item["size"],
"grade": item["grade"],
"manufacturer": item["manufacturer"],
"quality_applicable": item.get("quality_applicable", False),
"available": True,
}
return {
"id": order.id,
"class_name": order.class_name,
**item_data,
"quantity": order.quantity,
"notes": order.notes,
"quality": order.quality,
"username": username,
"status": order.status,
"created_at": order.created_at,
"updated_at": order.updated_at,
}
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']}'",
)
@router.get("", response_model=OrderPage)
def list_orders(
category: str | None = None,
search: str | None = None,
status: str | None = Query(default=None, pattern="^(open|fulfilled)$"),
sort: str = Query(default="updated", pattern="^(name|category|quantity|updated)$"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=25, ge=1),
db: DbSession = Depends(get_db),
):
page_size = min(page_size, MAX_PAGE_SIZE)
rows = db.execute(
select(Order, User.username).join(User, User.id == Order.user_id)
).all()
items = [enrich_order(order, username) for order, username in rows]
if status is not None:
items = [i for i in items if i["status"] == status]
if category is not None and category.strip():
wanted = category.strip()
items = [i for i in items if i["category"] == wanted]
if search is not None and search.strip():
needle = search.strip().lower()
items = [
i
for i in items
if needle in i["name"].lower()
or needle in i["class_name"].lower()
or needle in i["username"].lower()
]
items.sort(key=SORT_KEYS[sort])
total = len(items)
total_pages = max(1, math.ceil(total / page_size))
start = (page - 1) * page_size
return {
"total": total,
"page": page,
"page_size": page_size,
"total_pages": total_pages,
"items": items[start : start + page_size],
}
@router.post(
"",
response_model=OrderOut,
status_code=201,
dependencies=[Depends(require_same_origin)],
)
def create_order(
payload: OrderCreateRequest,
user: User = Depends(get_current_user),
db: DbSession = Depends(get_db),
):
class_name = payload.class_name.strip()
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)
order = Order(
user_id=user.id,
class_name=class_name,
quantity=payload.quantity,
notes=payload.notes,
quality=payload.quality,
)
db.add(order)
db.commit()
db.refresh(order)
return enrich_order(order, user.username)
def _own_order(db: DbSession, user: User, order_id: int) -> Order:
order = db.get(Order, order_id)
if order is None or order.user_id != user.id:
# Do not reveal whether somebody else's order exists.
raise HTTPException(status_code=404, detail="Order not found")
return order
@router.patch(
"/{order_id}",
response_model=OrderOut,
dependencies=[Depends(require_same_origin)],
)
def update_order(
order_id: int,
payload: OrderPatchRequest,
user: User = Depends(get_current_user),
db: DbSession = Depends(get_db),
):
order = _own_order(db, user, order_id)
if payload.quantity is not None:
order.quantity = payload.quantity
if payload.notes is not None:
order.notes = payload.notes
if payload.status is not None:
order.status = payload.status
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(order.class_name)
if item is None:
raise HTTPException(
status_code=422,
detail=f"Quality does not apply to '{order.class_name}'",
)
_validate_quality(item, payload.quality)
order.quality = payload.quality
db.commit()
db.refresh(order)
return enrich_order(order, user.username)
@router.delete(
"/{order_id}",
status_code=204,
dependencies=[Depends(require_same_origin)],
)
def delete_order(
order_id: int,
user: User = Depends(get_current_user),
db: DbSession = Depends(get_db),
):
order = _own_order(db, user, order_id)
db.delete(order)
db.commit()
return Response(status_code=204)

View File

@@ -109,3 +109,48 @@ class AvailablePage(BaseModel):
class InventoryAddResponse(BaseModel): class InventoryAddResponse(BaseModel):
entry: InventoryEntryOut entry: InventoryEntryOut
created: bool created: bool
class OrderCreateRequest(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 OrderPatchRequest(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)
status: Literal["open", "fulfilled"] | 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 OrderOut(BaseModel):
id: int
class_name: str
name: str
category: str | None
type: str | None
size: int | None
grade: Any | None = None
manufacturer: dict | None
quantity: int
notes: str
quality: int | None = None
quality_applicable: bool = False
# Public listing: the requesting user's display name.
username: str
status: Literal["open", "fulfilled"]
# False when the catalog no longer contains the referenced item.
available: bool
created_at: datetime
updated_at: datetime
class OrderPage(BaseModel):
total: int
page: int
page_size: int
total_pages: int
items: list[OrderOut]

View File

@@ -0,0 +1,176 @@
"""Public order board: authenticated creation, public read, owner-only edits."""
from fastapi.testclient import TestClient
from app.api import app
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:
client = TestClient(app)
resp = client.post(
"/api/v1/auth/register", json={"username": username, "password": PASSWORD}
)
assert resp.status_code == 201, resp.text
return client
def place(
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/orders", json=payload)
def public_orders(**params):
return TestClient(app).get("/api/v1/orders", params=params).json()
def test_order_board_is_public_and_empty_by_default():
body = public_orders()
assert body["total"] == 0
assert body["items"] == []
assert body["page"] == 1
def test_placing_an_order_requires_authentication():
assert place(TestClient(app), CARROCK).status_code == 401
def test_placing_an_order_returns_enriched_public_entry():
alice = authed_client("alice")
resp = place(alice, CARROCK, quantity=2, notes="Need a hauler")
assert resp.status_code == 201, resp.text
order = resp.json()
assert order["class_name"] == CARROCK
assert order["name"] == "Anvil Carrack"
assert order["category"] == "ships"
assert order["manufacturer"]["code"] == "ANVL"
assert order["quantity"] == 2
assert order["notes"] == "Need a hauler"
assert order["username"] == "alice"
assert order["status"] == "open"
assert order["available"] is True
public = public_orders()
assert public["total"] == 1
assert public["items"][0]["id"] == order["id"]
assert public["items"][0]["username"] == "alice"
def test_orders_from_every_user_are_visible():
place(authed_client("alice"), CARROCK)
place(authed_client("bob"), RIFLE)
items = public_orders(sort="name")["items"]
assert [(i["username"], i["class_name"]) for i in items] == [
("alice", CARROCK),
("bob", RIFLE),
]
def test_order_quality_is_stored_and_public_for_ore():
alice = authed_client("alice")
resp = place(alice, IRON_ORE, quantity=10, quality=875)
assert resp.status_code == 201, resp.text
assert resp.json()["quality"] == 875
assert resp.json()["quality_applicable"] is True
item = public_orders()["items"][0]
assert item["quality"] == 875
assert item["quality_applicable"] is True
def test_quality_is_rejected_for_non_mineable_items():
alice = authed_client("alice")
assert place(alice, CONSTRUCTION, quality=500).status_code == 422
assert place(alice, CARROCK, quality=100).status_code == 422
assert place(alice, IRON_ORE, quality=1001).status_code == 422
def test_unknown_catalog_item_is_rejected():
assert place(authed_client("alice"), "NOPE_Does_Not_Exist").status_code == 404
def test_listing_filters_by_category_search_and_status():
alice = authed_client("alice")
bob = authed_client("bob")
first = place(alice, CARROCK).json()
place(bob, RIFLE)
place(alice, IRON_ORE, quality=400)
assert [i["class_name"] for i in public_orders(category="ships")["items"]] == [
CARROCK
]
assert [i["username"] for i in public_orders(search="bob")["items"]] == ["bob"]
assert [
i["class_name"] for i in public_orders(search="iron")["items"]
] == [IRON_ORE]
alice.patch(f"/api/v1/orders/{first['id']}", json={"status": "fulfilled"})
assert [i["class_name"] for i in public_orders(status="open")["items"]] == [
IRON_ORE,
RIFLE,
]
assert [i["class_name"] for i in public_orders(status="fulfilled")["items"]] == [
CARROCK
]
def test_listing_paginates_and_rejects_unknown_sort():
alice = authed_client("alice")
place(alice, CARROCK)
place(alice, RIFLE)
body = public_orders(page=2, page_size=1)
assert body["total"] == 2
assert body["total_pages"] == 2
assert len(body["items"]) == 1
assert public_orders(page_size=500)["page_size"] == 100
assert TestClient(app).get("/api/v1/orders?sort=bogus").status_code == 422
assert TestClient(app).get("/api/v1/orders?status=bogus").status_code == 422
def test_only_the_owner_can_edit_or_remove_an_order():
alice = authed_client("alice")
bob = authed_client("bob")
order = place(alice, IRON_ORE, quality=500).json()
assert (
bob.patch(
f"/api/v1/orders/{order['id']}", json={"quantity": 99}
).status_code
== 404
)
assert bob.delete(f"/api/v1/orders/{order['id']}").status_code == 404
updated = alice.patch(
f"/api/v1/orders/{order['id']}",
json={"quantity": 3, "notes": "Updated", "status": "fulfilled"},
)
assert updated.status_code == 200, updated.text
body = updated.json()
assert body["quantity"] == 3
assert body["notes"] == "Updated"
assert body["status"] == "fulfilled"
assert body["quality"] == 500
cleared = alice.patch(
f"/api/v1/orders/{order['id']}", json={"quality": None}
)
assert cleared.status_code == 200, cleared.text
assert cleared.json()["quality"] is None
assert alice.delete(f"/api/v1/orders/{order['id']}").status_code == 204
assert public_orders()["total"] == 0

View File

@@ -19,10 +19,12 @@ const state = {
user: null, user: null,
inventory: { search: "", category: "", sort: "name" }, inventory: { search: "", category: "", sort: "name" },
available: { search: "", category: "", sort: "updated" }, available: { search: "", category: "", sort: "updated" },
orders: { search: "", category: "", status: "", sort: "updated" },
}; };
let authMode = "login"; let authMode = "login";
let pendingView = null; let pendingView = null;
let currentDetailClass = null;
const els = {}; const els = {};
@@ -38,6 +40,8 @@ function cacheElements() {
"available-view", "avail-search", "avail-category", "avail-sort", "available-view", "avail-search", "avail-category", "avail-sort",
"avail-summary", "avail-empty", "avail-table-wrap", "avail-tbody", "avail-summary", "avail-empty", "avail-table-wrap", "avail-tbody",
"tab-available", "tab-available",
"orders-view", "order-search", "order-category", "order-status", "order-sort",
"order-summary", "order-empty", "order-table-wrap", "order-tbody", "tab-orders",
"auth-backdrop", "auth-close", "auth-title", "auth-form", "auth-backdrop", "auth-close", "auth-title", "auth-form",
"auth-username", "auth-password", "auth-error", "auth-submit", "auth-username", "auth-password", "auth-error", "auth-submit",
"auth-toggle", "toast", "auth-toggle", "toast",
@@ -198,6 +202,11 @@ async function submitAuth(event) {
pendingView = null; pendingView = null;
switchToMine(); switchToMine();
} }
// The detail view was opened while logged out: re-render it so the
// order form becomes available in place.
if (currentDetailClass && !els["modal-backdrop"].classList.contains("hidden")) {
openModal(currentDetailClass);
}
} catch (err) { } catch (err) {
els["auth-error"].textContent = err.message; els["auth-error"].textContent = err.message;
} finally { } finally {
@@ -222,6 +231,8 @@ async function logout() {
state.category = ""; state.category = "";
loadItems(); loadItems();
} }
// Orders stay public, but the owner-only action buttons must disappear.
if (state.view === "orders") loadOrders();
} }
/* ---------- catalog list ---------- */ /* ---------- catalog list ---------- */
@@ -338,6 +349,7 @@ function renderPorts(ports) {
} }
async function openModal(class_name) { async function openModal(class_name) {
currentDetailClass = class_name;
els["modal-body"].innerHTML = '<p class="hint">Loading…</p>'; els["modal-body"].innerHTML = '<p class="hint">Loading…</p>';
els["modal-backdrop"].classList.remove("hidden"); els["modal-backdrop"].classList.remove("hidden");
try { try {
@@ -369,6 +381,27 @@ async function openModal(class_name) {
${state.user ? "Add to my inventory" : "Log in to add"} ${state.user ? "Add to my inventory" : "Log in to add"}
</button> </button>
</div> </div>
<h3>Request this item</h3>
${state.user ? `
<p class="hint">Set up an order so every player can see what you are looking for.</p>
<div class="add-row">
<label for="order-qty">Qty</label>
<input type="number" id="order-qty" min="1" max="1000000" value="1">
${item.quality_applicable ? `
<label for="order-quality">Quality</label>
<input type="number" id="order-quality" min="1" max="1000" placeholder="—">
` : ""}
<input type="text" id="order-notes" class="order-notes-input"
maxlength="1000" placeholder="Notes for other pilots (optional)">
<button type="button" id="create-order" class="btn-primary">Set up order</button>
</div>
<p id="order-error" class="auth-error"></p>
` : `
<div class="add-row">
<span>Log in to set up an order other players can see.</span>
<button type="button" id="order-login" class="btn-primary">Log in to request</button>
</div>
`}
`; `;
document.getElementById("add-to-inventory").addEventListener("click", () => { document.getElementById("add-to-inventory").addEventListener("click", () => {
const qty = Math.max(1, Number(document.getElementById("add-qty").value) || 1); const qty = Math.max(1, Number(document.getElementById("add-qty").value) || 1);
@@ -383,12 +416,27 @@ async function openModal(class_name) {
} }
addToInventory(item.class_name, qty, quality); addToInventory(item.class_name, qty, quality);
}); });
const createOrderButton = document.getElementById("create-order");
if (createOrderButton) {
createOrderButton.addEventListener("click", () =>
submitDetailOrder(item, createOrderButton)
);
} else {
const loginButton = document.getElementById("order-login");
if (loginButton) {
loginButton.addEventListener("click", () =>
openAuth("login", "Log in to set up an order.")
);
}
}
} catch (err) { } catch (err) {
els["modal-body"].innerHTML = `<p class="modal-desc">Failed to load item: ${escapeHtml(err.message)}</p>`; els["modal-body"].innerHTML = `<p class="modal-desc">Failed to load item: ${escapeHtml(err.message)}</p>`;
} }
} }
function closeModal() { function closeModal() {
currentDetailClass = null;
els["modal-backdrop"].classList.add("hidden"); els["modal-backdrop"].classList.add("hidden");
} }
@@ -424,6 +472,7 @@ async function switchToMine() {
state.view = "mine"; state.view = "mine";
els["catalog-view"].classList.add("hidden"); els["catalog-view"].classList.add("hidden");
els["available-view"].classList.add("hidden"); els["available-view"].classList.add("hidden");
els["orders-view"].classList.add("hidden");
els["inventory-view"].classList.remove("hidden"); els["inventory-view"].classList.remove("hidden");
await loadInventory(); await loadInventory();
} }
@@ -432,6 +481,7 @@ function showCatalogView() {
state.view = "catalog"; state.view = "catalog";
els["inventory-view"].classList.add("hidden"); els["inventory-view"].classList.add("hidden");
els["available-view"].classList.add("hidden"); els["available-view"].classList.add("hidden");
els["orders-view"].classList.add("hidden");
els["catalog-view"].classList.remove("hidden"); els["catalog-view"].classList.remove("hidden");
} }
@@ -727,6 +777,7 @@ async function switchToAvailable() {
state.view = "available"; state.view = "available";
els["catalog-view"].classList.add("hidden"); els["catalog-view"].classList.add("hidden");
els["inventory-view"].classList.add("hidden"); els["inventory-view"].classList.add("hidden");
els["orders-view"].classList.add("hidden");
els["available-view"].classList.remove("hidden"); els["available-view"].classList.remove("hidden");
await loadAvailable(); await loadAvailable();
} }
@@ -795,6 +846,190 @@ function renderAvailable(listing) {
} }
} }
/* ---------- orders (public board) ---------- */
async function switchToOrders() {
setActiveTab(els["tab-orders"]);
state.view = "orders";
els["catalog-view"].classList.add("hidden");
els["inventory-view"].classList.add("hidden");
els["available-view"].classList.add("hidden");
els["orders-view"].classList.remove("hidden");
await loadOrders();
}
async function submitDetailOrder(item, button) {
const qtyInput = document.getElementById("order-qty");
const qualityInput = document.getElementById("order-quality");
const notesInput = document.getElementById("order-notes");
const error = document.getElementById("order-error");
const quantity = Number(qtyInput.value);
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 1000000) {
error.textContent = "Quantity must be between 1 and 1,000,000";
return;
}
let quality = null;
if (item.quality_applicable && qualityInput) {
const raw = qualityInput.value.trim();
if (raw !== "") {
quality = Number(raw);
if (!Number.isInteger(quality) || quality < 1 || quality > 1000) {
error.textContent = "Quality must be between 1 and 1000";
return;
}
}
}
const body = {
class_name: item.class_name,
quantity,
notes: notesInput.value.trim(),
};
if (quality !== null) body.quality = quality;
button.disabled = true;
error.textContent = "";
try {
await apiFetch("/api/v1/orders", { method: "POST", body });
showToast("Order set up — visible to all pilots");
if (state.view === "orders") loadOrders();
} catch (err) {
if (!handleUnauthorized(err)) error.textContent = err.message;
} finally {
button.disabled = false;
}
}
async function loadOrders() {
const params = new URLSearchParams();
if (state.orders.search) params.set("search", state.orders.search);
if (state.orders.category) params.set("category", state.orders.category);
if (state.orders.status) params.set("status", state.orders.status);
params.set("sort", state.orders.sort);
params.set("page_size", String(INVENTORY_PAGE_SIZE));
els["order-summary"].textContent = "Loading…";
try {
const listing = await apiFetch(`/api/v1/orders?${params}`);
renderOrders(listing);
} catch (err) {
els["order-summary"].textContent = "";
showToast(err.message, true);
}
}
function renderOrders(listing) {
const tbody = els["order-tbody"];
tbody.innerHTML = "";
els["order-summary"].textContent =
`${listing.total} order${listing.total === 1 ? "" : "s"}`;
els["order-empty"].classList.toggle("hidden", listing.total !== 0);
els["order-table-wrap"].classList.toggle("hidden", listing.total === 0);
for (const entry of listing.items) {
const row = document.createElement("tr");
const nameCell = itemNameCell(entry);
const categoryCell = document.createElement("td");
categoryCell.innerHTML = entry.category
? categoryBadge(entry.category)
: '<span class="badge size">—</span>';
const qtyCell = document.createElement("td");
qtyCell.textContent = String(entry.quantity);
const notesCell = document.createElement("td");
notesCell.className = "order-notes";
notesCell.textContent = entry.notes || "—";
const userCell = document.createElement("td");
userCell.className = "avail-user";
userCell.textContent = entry.username;
const statusCell = document.createElement("td");
const status = document.createElement("span");
status.className = `order-status ${entry.status}`;
status.textContent = entry.status === "fulfilled" ? "Fulfilled" : "Open";
statusCell.appendChild(status);
const updatedCell = document.createElement("td");
updatedCell.className = "avail-updated";
updatedCell.textContent = formatDate(entry.updated_at);
const actionCell = document.createElement("td");
actionCell.className = "order-actions";
if (state.user && entry.username === state.user.username) {
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "visibility-toggle";
const next = entry.status === "fulfilled" ? "open" : "fulfilled";
toggle.textContent = next === "fulfilled" ? "Fulfil" : "Reopen";
toggle.dataset.status = next;
toggle.addEventListener("click", () =>
patchOrder(entry, { status: next }, toggle)
);
const remove = document.createElement("button");
remove.type = "button";
remove.className = "inv-remove";
remove.textContent = "Remove";
remove.addEventListener("click", () => deleteOrder(entry));
actionCell.append(toggle, remove);
}
row.append(
nameCell,
categoryCell,
qtyCell,
qualityCell(entry, false),
notesCell,
userCell,
statusCell,
updatedCell,
actionCell
);
tbody.appendChild(row);
}
}
async function patchOrder(entry, payload, button = null) {
if (button) button.disabled = true;
try {
const updated = await apiFetch(`/api/v1/orders/${entry.id}`, {
method: "PATCH",
body: payload,
});
showToast(
updated.status === "fulfilled" ? "Order marked fulfilled" : "Order reopened"
);
await loadOrders();
} catch (err) {
if (!handleUnauthorized(err)) showToast(err.message, true);
await loadOrders();
} finally {
if (button) button.disabled = false;
}
}
async function deleteOrder(entry) {
const confirmed = await confirmAction(
`Remove your order for "${entry.name}"? It will disappear for everyone.`,
"Remove"
);
if (!confirmed) return;
try {
await apiFetch(`/api/v1/orders/${entry.id}`, { method: "DELETE" });
showToast("Order removed");
await loadOrders();
} catch (err) {
if (!handleUnauthorized(err)) showToast(err.message, true);
}
}
/* ---------- events ---------- */ /* ---------- events ---------- */
function debounce(fn, wait) { function debounce(fn, wait) {
@@ -827,6 +1062,10 @@ function bindEvents() {
switchToAvailable(); switchToAvailable();
return; return;
} }
if (tab.dataset.view === "orders") {
switchToOrders();
return;
}
setActiveTab(tab); setActiveTab(tab);
showCatalogView(); showCatalogView();
state.category = tab.dataset.category; state.category = tab.dataset.category;
@@ -949,6 +1188,28 @@ function bindAvailableEvents() {
}); });
} }
function bindOrderEvents() {
els["order-search"].addEventListener(
"input",
debounce(() => {
state.orders.search = els["order-search"].value.trim();
loadOrders();
}, 300)
);
els["order-category"].addEventListener("change", () => {
state.orders.category = els["order-category"].value;
loadOrders();
});
els["order-status"].addEventListener("change", () => {
state.orders.status = els["order-status"].value;
loadOrders();
});
els["order-sort"].addEventListener("change", () => {
state.orders.sort = els["order-sort"].value;
loadOrders();
});
}
/* ---------- boot ---------- */ /* ---------- boot ---------- */
document.addEventListener("DOMContentLoaded", async () => { document.addEventListener("DOMContentLoaded", async () => {
@@ -957,6 +1218,7 @@ document.addEventListener("DOMContentLoaded", async () => {
bindAuthEvents(); bindAuthEvents();
bindInventoryEvents(); bindInventoryEvents();
bindAvailableEvents(); bindAvailableEvents();
bindOrderEvents();
initHeader(); initHeader();
await initAuth(); await initAuth();
loadItems(); loadItems();

View File

@@ -31,6 +31,7 @@
<button class="tab" data-view="catalog" data-category="commodities">Commodities</button> <button class="tab" data-view="catalog" data-category="commodities">Commodities</button>
<button class="tab tab-mine" id="tab-mine" data-view="mine">My Inventory</button> <button class="tab tab-mine" id="tab-mine" data-view="mine">My Inventory</button>
<button class="tab" id="tab-available" data-view="available">Available</button> <button class="tab" id="tab-available" data-view="available">Available</button>
<button class="tab" id="tab-orders" data-view="orders">Orders</button>
</nav> </nav>
<div id="catalog-view"> <div id="catalog-view">
@@ -140,6 +141,53 @@
</div> </div>
</section> </section>
<section id="orders-view" class="hidden">
<div class="inventory-controls">
<input type="search" id="order-search" placeholder="Search item or pilot…" autocomplete="off">
<select id="order-category">
<option value="">All categories</option>
<option value="ships">Ships</option>
<option value="ship-items">Ship Equipment</option>
<option value="fps-items">FPS Items</option>
<option value="commodities">Commodities</option>
</select>
<select id="order-status" aria-label="Filter by order status">
<option value="">All statuses</option>
<option value="open">Open</option>
<option value="fulfilled">Fulfilled</option>
</select>
<select id="order-sort" aria-label="Sort orders">
<option value="updated">Sort: Recently updated</option>
<option value="name">Sort: Name</option>
<option value="category">Sort: Category</option>
<option value="quantity">Sort: Quantity</option>
</select>
</div>
<div id="order-summary" class="inv-summary"></div>
<div id="order-empty" class="empty-state hidden">
<p>No orders yet.</p>
<p class="hint">Log in, open any catalog item and use “Request this item” to set it up here.</p>
</div>
<div id="order-table-wrap" class="inv-table-wrap hidden">
<table class="inv-table">
<thead>
<tr>
<th>Item</th>
<th>Category</th>
<th>Qty</th>
<th>Quality</th>
<th>Notes</th>
<th>Requested by</th>
<th>Status</th>
<th>Updated</th>
<th></th>
</tr>
</thead>
<tbody id="order-tbody"></tbody>
</table>
</div>
</section>
<footer class="site-footer"> <footer class="site-footer">
<p>Data source: <p>Data source:
<a href="https://github.com/richardthombs/scunpacked" target="_blank" rel="noopener noreferrer">scunpacked</a> <a href="https://github.com/richardthombs/scunpacked" target="_blank" rel="noopener noreferrer">scunpacked</a>

View File

@@ -480,6 +480,40 @@ main {
.avail-updated { color: var(--text-dim); font-size: 0.8rem; white-space: nowrap; } .avail-updated { color: var(--text-dim); font-size: 0.8rem; white-space: nowrap; }
.unavailable-note { color: var(--accent-warm); font-size: 0.75rem; } .unavailable-note { color: var(--accent-warm); font-size: 0.75rem; }
/* Orders board */
.order-notes {
color: var(--text-dim);
font-size: 0.82rem;
min-width: 10rem;
max-width: 18rem;
white-space: normal;
}
.order-status {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
padding: 0.2rem 0.6rem;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--text-dim);
white-space: nowrap;
}
.order-status.open {
color: var(--accent);
border-color: var(--accent);
}
.order-status.fulfilled {
color: var(--success);
border-color: var(--success);
background: rgba(34, 197, 94, 0.12);
}
.order-actions {
display: flex;
gap: 0.35rem;
white-space: nowrap;
}
/* Auth modal */ /* Auth modal */
.modal-narrow { max-width: 400px; } .modal-narrow { max-width: 400px; }
.auth-form { .auth-form {
@@ -532,6 +566,11 @@ main {
padding: 0.5rem 0.6rem; padding: 0.5rem 0.6rem;
} }
.add-row input:focus { outline: none; border-color: var(--accent); } .add-row input:focus { outline: none; border-color: var(--accent); }
.add-row input.order-notes-input {
width: auto;
flex: 1;
min-width: 10rem;
}
/* Toast */ /* Toast */
.toast { .toast {