Files
pokemon-inventory/apps/web/src/pages/AdminHomePage.vue
T
fragginwagon 7aa8e9eaf2
Quality / test (push) Canceled after 0s
feat: expose pricing quality controls
Add admin stale/outlier indicators and audited manual valuation overrides, with robust submission behavior and authenticated E2E coverage.
2026-09-09 01:28:17 -04:00

365 lines
20 KiB
Vue

<script setup>
import { computed, onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router';
import { useAuth } from '@/composables/useAuth.js';
import AdminListingStatus from '@/components/molecules/AdminListingStatus.vue';
import { createAdminInventory, createAdminProduct, listAdminCategories, listAdminConditions, listAdminInventory, listAdminProducts } from '@/services/admin-catalog-service.js';
import { createAdminListing, listAdminListings, markListingPending, publishAdminListing } from '@/services/admin-listing-service.js';
import { createAdminSale, listAdminSales, voidAdminSale } from '@/services/admin-sale-service.js';
import { createAdminAcquisition, listAdminAcquisitions } from '@/services/admin-acquisition-service.js';
import { getAdminDashboard } from '@/services/admin-dashboard-service.js';
import { addAdminPricingObservation, calculateAdminValuation, overrideAdminValuation } from '@/services/admin-pricing-service.js';
const { principal } = useAuth();
const products = ref([]);
const inventory = ref([]);
const listings = ref([]);
const sales = ref([]);
const acquisitions = ref([]);
const dashboard = ref(null);
const valuations = ref({});
const hasValuations = computed(() => Object.keys(valuations.value).length > 0);
const categories = ref([]);
const conditions = 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('');
const listingInventoryId = ref('');
const askingPrice = ref('');
const saleInventoryId = ref('');
const soldPrice = ref('');
const costBasis = ref('');
const saleDate = ref(new Date().toISOString().slice(0, 10));
const acquisitionInventoryId = ref('');
const acquisitionMethod = ref('PURCHASE');
const acquisitionCost = ref('');
const acquisitionShipping = ref('');
const acquisitionTax = ref('');
const acquisitionDate = ref(new Date().toISOString().slice(0, 10));
const pricingInventoryId = ref('');
const pricingProvider = ref('manual');
const pricingSource = ref('');
const pricingPrice = ref('');
const overridePrice = ref('');
const overrideReason = ref('');
const voidReasons = 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, categories.value, conditions.value, listings.value, sales.value, acquisitions.value, dashboard.value] = await Promise.all([listAdminProducts(), listAdminInventory(), listAdminCategories(), listAdminConditions(), listAdminListings(), listAdminSales(), listAdminAcquisitions(), getAdminDashboard()]);
} 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; }
}
async function createListing() {
const item = inventory.value.find((candidate) => candidate.id === listingInventoryId.value);
const product = products.value.find((candidate) => candidate.id === item?.productId);
isSaving.value = true;
try {
await createAdminListing({ inventoryItemId: item.id, productName: product.name, categoryId: product.categoryId, conditionId: item.conditionId, askingPriceMinor: Number(askingPrice.value) }, csrfToken());
listings.value = await listAdminListings();
askingPrice.value = '';
} catch (requestError) { error.value = requestError; }
finally { isSaving.value = false; }
}
async function transitionListing(action, id) {
isSaving.value = true;
try {
const listing = await action(id, csrfToken());
listings.value = listings.value.map((candidate) => candidate.id === listing.id ? listing : candidate);
} catch (requestError) { error.value = requestError; }
finally { isSaving.value = false; }
}
async function recordSale() {
isSaving.value = true;
try {
const sale = await createAdminSale({
inventoryItemId: saleInventoryId.value,
costBasisMinor: Number(costBasis.value),
saleDate: saleDate.value,
soldPriceMinor: Number(soldPrice.value),
}, csrfToken());
inventory.value = inventory.value.map((item) => item.id === sale.inventoryItem.id
? { ...item, status: sale.inventoryItem.status }
: item);
sales.value.unshift(sale);
saleInventoryId.value = '';
soldPrice.value = '';
costBasis.value = '';
} catch (requestError) { error.value = requestError; }
finally { isSaving.value = false; }
}
async function recordAcquisition() {
isSaving.value = true;
try {
const acquisition = await createAdminAcquisition({
inventoryItemId: acquisitionInventoryId.value,
method: acquisitionMethod.value,
costMinor: Number(acquisitionCost.value),
shippingMinor: Number(acquisitionShipping.value || 0),
taxMinor: Number(acquisitionTax.value || 0),
date: acquisitionDate.value,
}, csrfToken());
acquisitions.value.unshift(acquisition);
acquisitionInventoryId.value = '';
acquisitionCost.value = '';
acquisitionShipping.value = '';
acquisitionTax.value = '';
} catch (requestError) { error.value = requestError; }
finally { isSaving.value = false; }
}
async function calculatePricing() {
isSaving.value = true;
try {
const item = inventory.value.find(({ id }) => id === pricingInventoryId.value);
await addAdminPricingObservation({ productId: item.productId, inventoryItemId: item.id, providerId: pricingProvider.value, priceMinor: Number(pricingPrice.value), sourceIdentifier: pricingSource.value, conditionId: item.conditionId }, csrfToken());
const valuation = await calculateAdminValuation(item.id, csrfToken());
valuations.value = { ...valuations.value, [item.id]: valuation };
pricingSource.value = '';
pricingPrice.value = '';
} catch (requestError) { error.value = requestError; }
finally { isSaving.value = false; }
}
async function overridePricing() {
isSaving.value = true;
try {
const valuation = await overrideAdminValuation(pricingInventoryId.value, {
valueMinor: Number(overridePrice.value),
reason: overrideReason.value,
}, csrfToken());
valuations.value = { ...valuations.value, [pricingInventoryId.value]: { ...valuations.value[pricingInventoryId.value], valueMinor: valuation.valueMinor, confidence: 'OVERRIDE', stale: false } };
overridePrice.value = '';
overrideReason.value = '';
} catch (requestError) { error.value = requestError; }
finally { isSaving.value = false; }
}
async function voidSale(saleId) {
isSaving.value = true;
try {
const sale = await voidAdminSale(saleId, voidReasons.value[saleId] ?? '', csrfToken());
sales.value = sales.value.map((candidate) => candidate.id === sale.id ? sale : candidate);
inventory.value = inventory.value.map((item) => item.id === sale.inventoryItem.id
? { ...item, status: sale.inventoryItem.status }
: item);
voidReasons.value = { ...voidReasons.value, [saleId]: '' };
} catch (requestError) { error.value = requestError; }
finally { isSaving.value = false; }
}
onMounted(load);
</script>
<template>
<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>
<section v-if="dashboard" class="dashboard-summary" aria-labelledby="dashboard-summary-title">
<h2 id="dashboard-summary-title">At a glance</h2>
<dl class="dashboard-summary__grid">
<div><dt>Active inventory</dt><dd>{{ dashboard.counts.activeInventory }}</dd></div>
<div><dt>Available listings</dt><dd>{{ dashboard.counts.availableListings }}</dd></div>
<div><dt>Sold inventory</dt><dd>{{ dashboard.counts.soldInventory }}</dd></div>
<div><dt>Missing images</dt><dd>{{ dashboard.counts.missingImages }}</dd></div>
<div><dt>Acquisition cost</dt><dd>${{ (dashboard.financials.totalAcquisitionCostMinor / 100).toFixed(2) }}</dd></div>
<div><dt>Asking value</dt><dd>${{ (dashboard.financials.totalAskingValueMinor / 100).toFixed(2) }}</dd></div>
<div><dt>Net proceeds</dt><dd>${{ (dashboard.financials.totalNetSaleProceedsMinor / 100).toFixed(2) }}</dd></div>
<div><dt>Profit</dt><dd>${{ (dashboard.financials.totalProfitMinor / 100).toFixed(2) }}</dd></div>
</dl>
</section>
<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</label>
<select id="product-category" v-model="categoryId" class="fixture-input" required>
<option value="" disabled>Select a category</option>
<option v-for="category in categories" :key="category.id" :value="category.id">{{ category.name }}</option>
</select>
<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</label>
<select id="inventory-product" v-model="inventoryProductId" class="fixture-input" required>
<option value="" disabled>Select a product</option>
<option v-for="product in products" :key="product.id" :value="product.id">{{ product.name }}</option>
</select>
<label class="fixture-label" for="inventory-condition">Condition</label>
<select id="inventory-condition" v-model="conditionId" class="fixture-input" required>
<option value="" disabled>Select a condition</option>
<option v-for="condition in conditions" :key="condition.id" :value="condition.id">{{ condition.name }} ({{ condition.code }})</option>
</select>
<button class="button button--primary" type="submit" :disabled="isSaving">Create copy</button>
</form>
<form class="showcase-panel" @submit.prevent="createListing">
<h2>Create listing draft</h2>
<label class="fixture-label" for="listing-inventory">Inventory copy</label>
<select id="listing-inventory" v-model="listingInventoryId" class="fixture-input" required>
<option value="" disabled>Select a physical copy</option>
<option v-for="item in inventory" :key="item.id" :value="item.id">{{ item.id }} · {{ item.status }}</option>
</select>
<label class="fixture-label" for="listing-price">Asking price (cents)</label>
<input id="listing-price" v-model="askingPrice" class="fixture-input" type="number" min="1" required />
<button class="button button--primary" type="submit" :disabled="isSaving">Create listing</button>
</form>
<form class="showcase-panel" @submit.prevent="recordSale">
<h2>Record sale</h2>
<label class="fixture-label" for="sale-inventory">Inventory copy</label>
<select id="sale-inventory" v-model="saleInventoryId" class="fixture-input" required>
<option value="" disabled>Select an active copy</option>
<option v-for="item in inventory.filter(({ status }) => status === 'ACTIVE')" :key="item.id" :value="item.id">{{ item.id }}</option>
</select>
<label class="fixture-label" for="sale-date">Sale date</label>
<input id="sale-date" v-model="saleDate" class="fixture-input" type="date" required />
<label class="fixture-label" for="sold-price">Sold price (cents)</label>
<input id="sold-price" v-model="soldPrice" class="fixture-input" type="number" min="1" required />
<label class="fixture-label" for="cost-basis">Cost basis (cents)</label>
<input id="cost-basis" v-model="costBasis" class="fixture-input" type="number" min="0" required />
<button class="button button--primary" type="submit" :disabled="isSaving">Record sale</button>
</form>
<form class="showcase-panel" @submit.prevent="recordAcquisition">
<h2>Record acquisition</h2>
<label class="fixture-label" for="acquisition-inventory">Inventory copy</label>
<select id="acquisition-inventory" v-model="acquisitionInventoryId" class="fixture-input" required>
<option value="" disabled>Select a copy</option>
<option v-for="item in inventory" :key="item.id" :value="item.id">{{ item.id }}</option>
</select>
<label class="fixture-label" for="acquisition-method">Method</label>
<input id="acquisition-method" v-model="acquisitionMethod" class="fixture-input" required />
<label class="fixture-label" for="acquisition-date">Acquisition date</label>
<input id="acquisition-date" v-model="acquisitionDate" class="fixture-input" type="date" required />
<label class="fixture-label" for="acquisition-cost">Cost (cents)</label>
<input id="acquisition-cost" v-model="acquisitionCost" class="fixture-input" type="number" min="0" required />
<label class="fixture-label" for="acquisition-shipping">Shipping (cents)</label>
<input id="acquisition-shipping" v-model="acquisitionShipping" class="fixture-input" type="number" min="0" />
<label class="fixture-label" for="acquisition-tax">Tax (cents)</label>
<input id="acquisition-tax" v-model="acquisitionTax" class="fixture-input" type="number" min="0" />
<button class="button button--primary" type="submit" :disabled="isSaving">Record acquisition</button>
</form>
<form class="showcase-panel" @submit.prevent="calculatePricing">
<h2>Calculate market value</h2>
<label class="fixture-label" for="pricing-inventory">Inventory copy</label>
<select id="pricing-inventory" v-model="pricingInventoryId" class="fixture-input" required>
<option value="" disabled>Select a copy</option>
<option v-for="item in inventory" :key="item.id" :value="item.id">{{ item.id }}</option>
</select>
<label class="fixture-label" for="pricing-provider">Provider</label>
<input id="pricing-provider" v-model="pricingProvider" class="fixture-input" required />
<label class="fixture-label" for="pricing-source">Source identifier</label>
<input id="pricing-source" v-model="pricingSource" class="fixture-input" required />
<label class="fixture-label" for="pricing-price">Observed price (cents)</label>
<input id="pricing-price" v-model="pricingPrice" class="fixture-input" type="number" min="1" required />
<button class="button button--primary" type="submit" :disabled="isSaving">Add observation and calculate</button>
<label class="fixture-label" for="override-price">Manual value (cents)</label>
<input id="override-price" v-model="overridePrice" class="fixture-input" type="number" min="0" />
<label class="fixture-label" for="override-reason">Override reason</label>
<input id="override-reason" v-model="overrideReason" class="fixture-input" minlength="3" />
<button class="button button--secondary" type="button" :disabled="isSaving" @click="overridePricing">Save manual value</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" :data-product-id="item.productId"><span>{{ item.id }}</span> · {{ item.status }} <RouterLink :to="{ name: 'admin-image-manager', params: { id: item.id } }">Manage images</RouterLink></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>
</section>
<section class="admin-list" aria-labelledby="listings-title">
<h2 id="listings-title">Listings</h2>
<p v-if="!listings.length && !isLoading" class="state state--empty">No listings yet.</p>
<div v-else class="admin-listing-stack"><AdminListingStatus v-for="listing in listings" :key="listing.id" :listing="listing" :is-saving="isSaving" @publish="transitionListing(publishAdminListing, listing.id)" @pending="transitionListing(markListingPending, listing.id)" /></div>
</section>
<section class="admin-list" aria-labelledby="sales-title">
<h2 id="sales-title">Sale history</h2>
<p v-if="!sales.length && !isLoading" class="state state--empty">No sales recorded yet.</p>
<ul v-else>
<li v-for="sale in sales" :key="sale.id">
<strong>{{ sale.productName ?? sale.saleItem.inventoryItemId }}</strong>
· {{ sale.saleDate }}
· {{ sale.status }}
· Sold ${{ (sale.soldPriceMinor / 100).toFixed(2) }}
· Profit ${{ (sale.profitMinor / 100).toFixed(2) }}
<form v-if="sale.status !== 'VOIDED'" @submit.prevent="voidSale(sale.id)">
<label class="fixture-label" :for="`void-reason-${sale.id}`">Void reason</label>
<input :id="`void-reason-${sale.id}`" v-model="voidReasons[sale.id]" class="fixture-input" minlength="3" required />
<button class="button button--secondary" type="submit" :disabled="isSaving">Void sale</button>
</form>
</li>
</ul>
</section>
<section class="admin-list" aria-labelledby="acquisitions-title">
<h2 id="acquisitions-title">Acquisition history</h2>
<p v-if="!acquisitions.length && !isLoading" class="state state--empty">No acquisitions recorded yet.</p>
<ul v-else>
<li v-for="acquisition in acquisitions" :key="acquisition.id">
<strong>{{ acquisition.inventoryItemId }}</strong>
· {{ acquisition.date ?? 'Undated' }}
· {{ acquisition.method }}
· Cost ${{ (acquisition.costMinor / 100).toFixed(2) }}
· Basis ${{ ((acquisition.costBasisMinor ?? 0) / 100).toFixed(2) }}
</li>
</ul>
</section>
</section>
</template>
<style scoped lang="scss">
.dashboard-summary { margin: var(--space-6) 0; }
.dashboard-summary h2 { margin-bottom: var(--space-3); }
.dashboard-summary__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: var(--space-3); margin: 0; }
.dashboard-summary__grid div { border: var(--border-thin) solid var(--color-border); border-radius: var(--radius-lg); padding: var(--space-3); background: var(--color-surface); }
.dashboard-summary__grid dt { color: var(--color-text-muted); font-size: var(--text-sm); }
.dashboard-summary__grid dd { margin: var(--space-2) 0 0; font-size: var(--text-xl); font-weight: 700; }
</style>