feat: add admin catalog workflow
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-08 22:30:30 -04:00
parent 86c60f9381
commit a6d8b9bbf4
6 changed files with 139 additions and 1 deletions
@@ -25,6 +25,16 @@ export function createInventoryItemRepository(db) {
.orderBy(asc(inventoryItems.createdAt)); .orderBy(asc(inventoryItems.createdAt));
}, },
async listBySellerId(sellerId) {
return db
.select({ item: inventoryItems })
.from(inventoryItems)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(eq(products.sellerId, sellerId))
.orderBy(asc(inventoryItems.createdAt))
.then((rows) => rows.map(({ item }) => item));
},
async searchPublic({ page = 1, limit = 20 } = {}) { async searchPublic({ page = 1, limit = 20 } = {}) {
const conditions = [ const conditions = [
eq(inventoryItems.status, 'ACTIVE'), eq(inventoryItems.status, 'ACTIVE'),
@@ -54,4 +54,8 @@ export async function registerAdminInventoryRoutes(app, options) {
return reply.code(201).send({ data: toAdminInventoryItemDto(inventoryItem) }); return reply.code(201).send({ data: toAdminInventoryItemDto(inventoryItem) });
}); });
app.get('/inventory', async (request) => ({
data: (await inventoryItemService.listBySellerId(request.admin.sellerId)).map(toAdminInventoryItemDto),
}));
} }
@@ -7,6 +7,10 @@ export function createInventoryItemService({
productRepository, productRepository,
}) { }) {
return { return {
async listBySellerId(sellerId) {
return inventoryItemRepository.listBySellerId(sellerId);
},
async create(input) { async create(input) {
const inventoryItem = createInventoryItem(input); const inventoryItem = createInventoryItem(input);
const [product, condition] = await Promise.all([ const [product, condition] = await Promise.all([
+9
View File
@@ -51,6 +51,14 @@ h1 { max-width: 760px; margin: var(--space-3) 0 var(--space-4); font-size: var(-
.admin-sidebar__footer { display: grid; gap: var(--space-3); align-self: end; color: var(--color-text-muted); font-size: var(--text-sm); } .admin-sidebar__footer { display: grid; gap: var(--space-3); align-self: end; color: var(--color-text-muted); font-size: var(--text-sm); }
.admin-shell__content { padding: var(--space-12); } .admin-shell__content { padding: var(--space-12); }
.admin-page { max-width: 880px; } .admin-page { max-width: 880px; }
.admin-workspace-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-4); margin: var(--space-8) 0; }
.admin-workspace-grid form { display: grid; gap: var(--space-3); }
.admin-list { margin-top: var(--space-8); }
.admin-list h2 { margin-bottom: var(--space-3); font-size: var(--text-xl); }
.admin-list ul { display: grid; gap: var(--space-2); margin: 0; padding: 0; list-style: none; }
.admin-list li { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; border-bottom: var(--border-thin) solid var(--color-border); padding: var(--space-3) 0; }
.admin-list li span { color: var(--color-text-muted); font-size: var(--text-sm); }
.admin-list a { color: var(--color-interactive-text); font-weight: var(--weight-bold); }
.showcase-header { display: flex; align-items: end; justify-content: space-between; gap: var(--space-8); margin-bottom: var(--space-12); } .showcase-header { display: flex; align-items: end; justify-content: space-between; gap: var(--space-8); margin-bottom: var(--space-12); }
.showcase-header h1 { max-width: 720px; } .showcase-header h1 { max-width: 720px; }
.showcase-header p { max-width: 600px; color: var(--color-text-muted); } .showcase-header p { max-width: 600px; color: var(--color-text-muted); }
@@ -109,4 +117,5 @@ h1 { max-width: 760px; margin: var(--space-3) 0 var(--space-4); font-size: var(-
.admin-shell { grid-template-columns: 1fr; } .admin-shell { grid-template-columns: 1fr; }
.admin-sidebar { border-right: 0; border-bottom: var(--border-thin) solid var(--color-border); } .admin-sidebar { border-right: 0; border-bottom: var(--border-thin) solid var(--color-border); }
.admin-shell__content { padding: var(--space-6); } .admin-shell__content { padding: var(--space-6); }
.admin-workspace-grid { grid-template-columns: 1fr; }
} }
+89 -1
View File
@@ -1,3 +1,91 @@
<script setup>
import { onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router';
import { useAuth } from '@/composables/useAuth.js';
import { createAdminInventory, createAdminProduct, listAdminInventory, listAdminProducts } from '@/services/admin-catalog-service.js';
const { principal } = useAuth();
const products = ref([]);
const inventory = ref([]);
const isLoading = ref(true);
const isSaving = ref(false);
const error = ref(null);
const productName = ref('');
const categoryId = ref('');
const inventoryProductId = ref('');
const conditionId = ref('');
function csrfToken() {
return document.cookie.split('; ').find((value) => value.startsWith('pokemon_inventory_csrf='))?.split('=')[1] ?? '';
}
async function load() {
isLoading.value = true;
try {
[products.value, inventory.value] = await Promise.all([listAdminProducts(), listAdminInventory()]);
} catch (requestError) { error.value = requestError; }
finally { isLoading.value = false; }
}
async function createProduct() {
isSaving.value = true;
try {
const product = await createAdminProduct({ name: productName.value, categoryId: categoryId.value }, csrfToken());
products.value.push(product);
productName.value = '';
} catch (requestError) { error.value = requestError; }
finally { isSaving.value = false; }
}
async function createInventory() {
isSaving.value = true;
try {
const item = await createAdminInventory({ productId: inventoryProductId.value, conditionId: conditionId.value }, csrfToken());
inventory.value.push(item);
} catch (requestError) { error.value = requestError; }
finally { isSaving.value = false; }
}
onMounted(load);
</script>
<template> <template>
<section class="admin-page"><p class="catalog-kicker">Overview</p><h1>Collector workspace</h1><p class="detail-notes">Select an inventory item to manage its images and listing details.</p></section> <section class="admin-page">
<p class="catalog-kicker">Overview</p>
<h1>Collector workspace</h1>
<p class="detail-notes">{{ principal?.email }} · Build the catalog one physical copy at a time.</p>
<p v-if="isLoading" class="state state--loading" role="status">Loading inventory...</p>
<p v-if="error" class="state state--error" role="alert">{{ error.message }}</p>
<div class="admin-workspace-grid">
<form class="showcase-panel" @submit.prevent="createProduct">
<h2>Add product</h2>
<label class="fixture-label" for="product-name">Name</label>
<input id="product-name" v-model="productName" class="fixture-input" required />
<label class="fixture-label" for="product-category">Category ID</label>
<input id="product-category" v-model="categoryId" class="fixture-input" required />
<button class="button button--primary" type="submit" :disabled="isSaving">Create product</button>
</form>
<form class="showcase-panel" @submit.prevent="createInventory">
<h2>Add inventory copy</h2>
<label class="fixture-label" for="inventory-product">Product ID</label>
<input id="inventory-product" v-model="inventoryProductId" class="fixture-input" required />
<label class="fixture-label" for="inventory-condition">Condition ID</label>
<input id="inventory-condition" v-model="conditionId" class="fixture-input" required />
<button class="button button--primary" type="submit" :disabled="isSaving">Create copy</button>
</form>
</div>
<section class="admin-list" aria-labelledby="products-title">
<h2 id="products-title">Products</h2>
<p v-if="!products.length && !isLoading" class="state state--empty">No products yet.</p>
<ul v-else><li v-for="product in products" :key="product.id">{{ product.name }} <span>{{ product.id }}</span></li></ul>
</section>
<section class="admin-list" aria-labelledby="inventory-title">
<h2 id="inventory-title">Physical inventory</h2>
<p v-if="!inventory.length && !isLoading" class="state state--empty">No physical copies yet.</p>
<ul v-else><li v-for="item in inventory" :key="item.id"><span>{{ item.id }}</span> · {{ item.status }} <RouterLink :to="{ name: 'admin-image-manager', params: { id: item.id } }">Manage images</RouterLink></li></ul>
</section>
</section>
</template> </template>
@@ -0,0 +1,23 @@
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1';
async function request(path, options = {}) {
const response = await fetch(`${apiBaseUrl}${path}`, {
credentials: 'include',
...options,
headers: { 'content-type': 'application/json', ...options.headers },
});
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error?.message ?? 'Admin catalog request failed.');
return payload?.data;
}
export function listAdminProducts() { return request('/admin/products'); }
export function listAdminInventory() { return request('/admin/inventory'); }
export function createAdminProduct(input, csrfToken) {
return request('/admin/products', { method: 'POST', headers: { 'x-csrf-token': csrfToken }, body: JSON.stringify(input) });
}
export function createAdminInventory(input, csrfToken) {
return request('/admin/inventory', { method: 'POST', headers: { 'x-csrf-token': csrfToken }, body: JSON.stringify(input) });
}