order button
This commit is contained in:
39
README.md
39
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user