This commit is contained in:
@@ -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([
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user