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