feat: extract dedicated admin inventory management screen
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-10 06:46:11 -04:00
parent 934aad2afd
commit c85693c919
12 changed files with 249 additions and 181 deletions
@@ -1,5 +1,6 @@
import {
toAdminMarketValuationDto,
toAdminMarketValuationSummaryDto,
toAdminPricingObservationDto,
} from "@pokemon-inventory/contracts/pricing";
import {
@@ -91,6 +92,14 @@ export async function registerAdminPricingRoutes(app, options) {
return reply.code(201).send({ data: override });
});
app.get("/valuations", async (request) => ({
data: (
await pricingService.listValuations({
sellerId: request.admin.sellerId,
})
).map(toAdminMarketValuationSummaryDto),
}));
app.post("/inventory/:id/pricing/refresh", async (request, reply) => {
await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
@@ -203,6 +203,10 @@ export function createPricingService({
return pricingRefreshJobRepository?.listForSeller(sellerId) ?? [];
},
async listValuations({ sellerId }) {
return pricingRepository.listLatestValuationsForSeller(sellerId);
},
async retryJob({ sellerId, jobId }) {
const job = await pricingRefreshJobRepository?.retryForSeller(
jobId,
@@ -182,3 +182,38 @@ test("lists jobs and retries a failed job with CSRF protection", async (testCont
assert.equal(retryResponse.statusCode, 202);
assert.equal(csrfChecks, 1);
});
test("lists seller-scoped latest market valuations", async (testContext) => {
const app = buildApp({
authenticateAdmin: async () => ({ sellerId: "seller-1" }),
pricingService: {
async listValuations({ sellerId }) {
assert.equal(sellerId, "seller-1");
return [
{
id: "valuation-1",
inventoryItemId: "item-1",
valueMinor: 2500,
currency: "USD",
strategy: "MEDIAN",
confidence: "MEDIUM",
sourceCount: 2,
calculatedAt: new Date().toISOString(),
},
];
},
},
});
testContext.after(() => app.close());
const response = await app.inject({
method: "GET",
url: "/api/v1/admin/valuations",
});
assert.equal(response.statusCode, 200);
const [valuation] = response.json().data;
assert.equal(valuation.inventoryItemId, "item-1");
assert.equal(valuation.valueMinor, 2500);
assert.equal(valuation.stale, false);
});
+6 -180
View File
@@ -12,13 +12,10 @@ import ReviewListStep from "@/components/molecules/ReviewListStep.vue";
import {
createAdminInventory,
createAdminProduct,
archiveAdminInventory,
listAdminCategories,
listAdminConditions,
listAdminInventory,
listAdminProducts,
refreshAdminInventoryPricing,
updateAdminInventoryTags,
enqueueAdminInventoryExport,
getAdminInventoryExportJob,
} from "@/services/admin-catalog-service.js";
@@ -34,10 +31,6 @@ import {
getAdminPricingJob,
overrideAdminValuation,
} from "@/services/admin-pricing-service.js";
import {
listAdminPricingJobs,
retryAdminPricingJob,
} from "@/services/admin-jobs-service.js";
import {
createAdminProviderMapping,
listAdminProviderMappings,
@@ -57,8 +50,6 @@ const activeStepId = ref(WIZARD_STEP_IDS[0]);
const activeStepIndex = computed(() => WIZARD_STEP_IDS.indexOf(activeStepId.value));
const products = ref([]);
const inventory = ref([]);
const selectedInventoryIds = ref([]);
const inventoryTags = ref("");
const exportStatus = ref("");
const exportJobId = ref("");
const listings = ref([]);
@@ -118,7 +109,6 @@ const mappingType = ref("PRODUCT");
const mappingExternalIdentifier = ref("");
const mappingSearchQuery = ref("");
const providerMappings = ref([]);
const pricingJobs = ref([]);
function csrfToken() {
return (
@@ -139,7 +129,6 @@ async function load() {
conditions.value,
listings.value,
dashboard.value,
pricingJobs.value,
] = await Promise.all([
listAdminProducts(),
listAdminInventory(),
@@ -147,7 +136,6 @@ async function load() {
listAdminConditions(),
listAdminListings(),
getAdminDashboard(),
listAdminPricingJobs(),
]);
} catch (requestError) {
error.value = requestError;
@@ -219,61 +207,6 @@ async function createInventory() {
}
}
async function archiveSelectedInventory() {
if (!selectedInventoryIds.value.length) return;
isSaving.value = true;
try {
const archivedItems = await archiveAdminInventory(
selectedInventoryIds.value,
csrfToken(),
);
const archivedById = new Map(archivedItems.map((item) => [item.id, item]));
inventory.value = inventory.value.map((item) => archivedById.get(item.id) ?? item);
selectedInventoryIds.value = [];
} catch (requestError) {
error.value = requestError;
} finally {
isSaving.value = false;
}
}
async function refreshSelectedPricing() {
if (!selectedInventoryIds.value.length) return;
isSaving.value = true;
try {
const jobs = await refreshAdminInventoryPricing(
selectedInventoryIds.value,
pricingProvider.value,
csrfToken(),
);
pricingJobs.value = [...jobs, ...pricingJobs.value];
selectedInventoryIds.value = [];
} catch (requestError) {
error.value = requestError;
} finally {
isSaving.value = false;
}
}
async function updateSelectedTags(action) {
const tags = inventoryTags.value.split(",").map((tag) => tag.trim()).filter(Boolean);
if (!selectedInventoryIds.value.length || !tags.length) return;
isSaving.value = true;
try {
await updateAdminInventoryTags(
selectedInventoryIds.value,
action === "add" ? tags : [],
action === "remove" ? tags : [],
csrfToken(),
);
inventoryTags.value = "";
selectedInventoryIds.value = [];
} catch (requestError) {
error.value = requestError;
} finally {
isSaving.value = false;
}
}
async function enqueueInventoryExport() {
isSaving.value = true;
@@ -387,18 +320,6 @@ async function refreshPricing() {
}
}
async function retryPricingJob(jobId) {
isSaving.value = true;
try {
await retryAdminPricingJob(jobId, csrfToken());
pricingJobs.value = await listAdminPricingJobs();
} catch (requestError) {
error.value = requestError;
} finally {
isSaving.value = false;
}
}
async function overridePricing() {
isSaving.value = true;
try {
@@ -538,25 +459,25 @@ onMounted(() => {
<div>
<dt>Missing images</dt>
<dd>
<a href="#inventory-title">{{ dashboard.counts.missingImages }}</a>
<a href="/admin/inventory">{{ dashboard.counts.missingImages }}</a>
</dd>
</div>
<div>
<dt>Missing pricing</dt>
<dd>
<a href="#pricing-title">{{ dashboard.counts.missingPricing }}</a>
<a href="/admin/inventory">{{ dashboard.counts.missingPricing }}</a>
</dd>
</div>
<div>
<dt>Stale pricing</dt>
<dd>
<a href="#pricing-title">{{ dashboard.counts.stalePricing }}</a>
<a href="/admin/inventory">{{ dashboard.counts.stalePricing }}</a>
</dd>
</div>
<div>
<dt>Provider failures</dt>
<dd>
<a href="#pricing-title">{{
<a href="/admin/inventory">{{
dashboard.counts.failedPricingJobs
}}</a>
</dd>
@@ -717,103 +638,8 @@ onMounted(() => {
aria-labelledby="inventory-title"
>
<h2 id="inventory-title">Physical inventory</h2>
<div class="admin-list__actions">
<button
class="button button--secondary"
type="button"
:disabled="isSaving || !selectedInventoryIds.length"
@click="archiveSelectedInventory"
>
Archive selected
</button>
<button
class="button button--secondary"
type="button"
:disabled="isSaving || !selectedInventoryIds.length"
@click="refreshSelectedPricing"
>
Refresh pricing
</button>
<input
v-model="inventoryTags"
class="fixture-input"
type="text"
placeholder="Tags, comma separated"
aria-label="Tags to add or remove"
/>
<button
class="button button--secondary"
type="button"
:disabled="isSaving || !selectedInventoryIds.length || !inventoryTags.trim()"
@click="updateSelectedTags('add')"
>
Add tags
</button>
<button
class="button button--secondary"
type="button"
:disabled="isSaving || !selectedInventoryIds.length || !inventoryTags.trim()"
@click="updateSelectedTags('remove')"
>
Remove tags
</button>
</div>
<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"
:data-product-id="item.productId"
>
<label v-if="item.status === 'ACTIVE'">
<input
v-model="selectedInventoryIds"
type="checkbox"
:value="item.id"
:aria-label="`Select inventory item ${item.id}`"
/>
<span>{{ item.id }}</span>
</label>
<span v-else>{{ item.id }}</span>
· {{ item.status }}
</li>
</ul>
<ul v-if="hasValuations" aria-label="Market valuations">
<li
v-for="(valuation, inventoryItemId) in valuations"
:key="inventoryItemId"
>
{{ inventoryItemId }} · Market value ${{
(valuation.valueMinor / 100).toFixed(2)
}}
· {{ valuation.confidence }} confidence ·
{{ valuation.sourceCount }} source{{
valuation.sourceCount === 1 ? "" : "s"
}}<span v-if="valuation.stale"> · STALE</span>
<span
v-if="valuation.observations?.some(({ isOutlier }) => isOutlier)"
>
· OUTLIER FLAGGED</span
>
</li>
</ul>
<ul v-if="pricingJobs.length" aria-label="Pricing jobs">
<li v-for="job in pricingJobs" :key="job.id">
{{ job.id }} · {{ job.providerId }} · {{ job.status
}}<span v-if="job.error"> · {{ job.error }}</span>
<button
v-if="job.status === 'FAILED'"
class="button button--secondary"
type="button"
:disabled="isSaving"
@click="retryPricingJob(job.id)"
>
Retry job
</button>
</li>
</ul>
<p>{{ inventory.length }} physical cop{{ inventory.length === 1 ? "y" : "ies" }} recorded.</p>
<a class="button button--secondary" href="/admin/inventory">Manage inventory</a>
</section>
<section class="admin-list" aria-labelledby="listings-title">
<h2 id="listings-title">Listings</h2>
@@ -0,0 +1,5 @@
import AdminInventoryPage from './AdminInventoryPage.vue';
export default { title: 'Pages/AdminInventoryPage', component: AdminInventoryPage };
export const Inventory = {};
+149
View File
@@ -0,0 +1,149 @@
<script setup>
import { onMounted, ref } from 'vue';
import {
archiveAdminInventory,
listAdminInventory,
refreshAdminInventoryPricing,
updateAdminInventoryTags,
} from '@/services/admin-catalog-service.js';
import { listAdminPricingJobs, retryAdminPricingJob } from '@/services/admin-jobs-service.js';
import { listAdminValuations } from '@/services/admin-pricing-service.js';
const inventory = ref([]);
const valuations = ref([]);
const pricingJobs = ref([]);
const selectedInventoryIds = ref([]);
const inventoryTags = ref('');
const isLoading = ref(true);
const isSaving = ref(false);
const error = ref('');
function csrfToken() {
return document.cookie.split('; ').find((value) => value.startsWith('pokemon_inventory_csrf='))?.split('=')[1] ?? '';
}
async function load() {
try {
[inventory.value, valuations.value, pricingJobs.value] = await Promise.all([
listAdminInventory(),
listAdminValuations(),
listAdminPricingJobs(),
]);
} catch (requestError) {
error.value = requestError.message;
} finally {
isLoading.value = false;
}
}
async function archiveSelectedInventory() {
if (!selectedInventoryIds.value.length) return;
isSaving.value = true;
error.value = '';
try {
const archivedItems = await archiveAdminInventory(selectedInventoryIds.value, csrfToken());
const archivedById = new Map(archivedItems.map((item) => [item.id, item]));
inventory.value = inventory.value.map((item) => archivedById.get(item.id) ?? item);
selectedInventoryIds.value = [];
} catch (requestError) {
error.value = requestError.message;
} finally {
isSaving.value = false;
}
}
async function refreshSelectedPricing() {
if (!selectedInventoryIds.value.length) return;
isSaving.value = true;
error.value = '';
try {
const jobs = await refreshAdminInventoryPricing(selectedInventoryIds.value, 'fixture-provider', csrfToken());
pricingJobs.value = [...jobs, ...pricingJobs.value];
selectedInventoryIds.value = [];
} catch (requestError) {
error.value = requestError.message;
} finally {
isSaving.value = false;
}
}
async function updateSelectedTags(action) {
const tags = inventoryTags.value.split(',').map((tag) => tag.trim()).filter(Boolean);
if (!selectedInventoryIds.value.length || !tags.length) return;
isSaving.value = true;
error.value = '';
try {
await updateAdminInventoryTags(
selectedInventoryIds.value,
action === 'add' ? tags : [],
action === 'remove' ? tags : [],
csrfToken(),
);
inventoryTags.value = '';
selectedInventoryIds.value = [];
} catch (requestError) {
error.value = requestError.message;
} finally {
isSaving.value = false;
}
}
async function retryPricingJob(jobId) {
isSaving.value = true;
error.value = '';
try {
await retryAdminPricingJob(jobId, csrfToken());
pricingJobs.value = await listAdminPricingJobs();
} catch (requestError) {
error.value = requestError.message;
} finally {
isSaving.value = false;
}
}
onMounted(load);
</script>
<template>
<section class="admin-page">
<p class="catalog-kicker">Inventory management</p>
<h1>Physical inventory</h1>
<p v-if="isLoading" class="state state--loading" role="status">Loading inventory...</p>
<p v-if="error" class="state state--error" role="alert">{{ error }}</p>
<section v-if="!isLoading" class="admin-list" aria-labelledby="inventory-title">
<h2 id="inventory-title">All physical copies</h2>
<div class="admin-list__actions">
<button class="button button--secondary" type="button" :disabled="isSaving || !selectedInventoryIds.length" @click="archiveSelectedInventory">Archive selected</button>
<button class="button button--secondary" type="button" :disabled="isSaving || !selectedInventoryIds.length" @click="refreshSelectedPricing">Refresh pricing</button>
<input v-model="inventoryTags" class="fixture-input" type="text" placeholder="Tags, comma separated" aria-label="Tags to add or remove" />
<button class="button button--secondary" type="button" :disabled="isSaving || !selectedInventoryIds.length || !inventoryTags.trim()" @click="updateSelectedTags('add')">Add tags</button>
<button class="button button--secondary" type="button" :disabled="isSaving || !selectedInventoryIds.length || !inventoryTags.trim()" @click="updateSelectedTags('remove')">Remove tags</button>
</div>
<p v-if="!inventory.length" class="state state--empty">No physical copies yet.</p>
<ul v-else>
<li v-for="item in inventory" :key="item.id" :data-product-id="item.productId">
<label v-if="item.status === 'ACTIVE'">
<input v-model="selectedInventoryIds" type="checkbox" :value="item.id" :aria-label="`Select inventory item ${item.id}`" />
<span>{{ item.id }}</span>
</label>
<span v-else>{{ item.id }}</span>
· {{ item.status }}
</li>
</ul>
<ul v-if="valuations.length" aria-label="Market valuations">
<li v-for="valuation in valuations" :key="valuation.id">
{{ valuation.inventoryItemId }} · Market value ${{ (valuation.valueMinor / 100).toFixed(2) }}
· {{ valuation.confidence }} confidence ·
{{ valuation.sourceCount }} source{{ valuation.sourceCount === 1 ? "" : "s" }}<span v-if="valuation.stale"> · STALE</span>
</li>
</ul>
<ul v-if="pricingJobs.length" aria-label="Pricing jobs">
<li v-for="job in pricingJobs" :key="job.id">
{{ job.id }} · {{ job.providerId }} · {{ job.status }}<span v-if="job.error"> · {{ job.error }}</span>
<button v-if="job.status === 'FAILED'" class="button button--secondary" type="button" :disabled="isSaving" @click="retryPricingJob(job.id)">Retry job</button>
</li>
</ul>
</section>
</section>
</template>
+1
View File
@@ -19,6 +19,7 @@ async function logout() {
<h1>Inventory</h1>
<nav aria-label="Admin navigation">
<a href="/admin">Overview</a>
<RouterLink :to="{ name: 'admin-inventory' }">Inventory</RouterLink>
<RouterLink :to="{ name: 'admin-listings' }">Listings</RouterLink>
<RouterLink :to="{ name: 'admin-sales' }">Sales</RouterLink>
<RouterLink :to="{ name: 'admin-acquisitions' }">Acquisitions</RouterLink>
+2
View File
@@ -13,6 +13,7 @@ import AdminSavedViewsPage from '@/pages/AdminSavedViewsPage.vue';
import AdminListingsPage from '@/pages/AdminListingsPage.vue';
import AdminSalesPage from '@/pages/AdminSalesPage.vue';
import AdminAcquisitionsPage from '@/pages/AdminAcquisitionsPage.vue';
import AdminInventoryPage from '@/pages/AdminInventoryPage.vue';
const router = createRouter({
history: createWebHistory(),
@@ -27,6 +28,7 @@ const router = createRouter({
children: [
{ path: '', name: 'admin-home', component: AdminHomePage },
{ path: 'inventory/:id/images', name: 'admin-image-manager', component: AdminImagePage },
{ path: 'inventory', name: 'admin-inventory', component: AdminInventoryPage },
{ path: 'listings', name: 'admin-listings', component: AdminListingsPage },
{ path: 'sales', name: 'admin-sales', component: AdminSalesPage },
{ path: 'acquisitions', name: 'admin-acquisitions', component: AdminAcquisitionsPage },
@@ -58,3 +58,7 @@ export function enqueueAdminPricingRefresh(inventoryItemId, input, csrfToken) {
export function getAdminPricingJob(jobId) {
return request(`/admin/jobs/${encodeURIComponent(jobId)}`);
}
export function listAdminValuations() {
return request("/admin/valuations");
}
+15
View File
@@ -171,4 +171,19 @@ test('creates an admin saved view through the CSRF-protected API', async () => {
} finally {
globalThis.fetch = originalFetch;
}
});
test('lists seller-scoped latest market valuations', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async (url) => {
assert.equal(url, '/api/v1/admin/valuations');
return { ok: true, async json() { return { data: [{ id: 'valuation-1', inventoryItemId: 'item-1', valueMinor: 2500 }] }; } };
};
try {
const { listAdminValuations } = await import('../src/services/admin-pricing-service.js');
assert.deepEqual(await listAdminValuations(), [{ id: 'valuation-1', inventoryItemId: 'item-1', valueMinor: 2500 }]);
} finally {
globalThis.fetch = originalFetch;
}
});