Backfill the completed acquisitions, sales, dashboard, pricing, search, public detail, worker, migration, contracts, validation, tests, and implementation-status changes into one verified application slice.
1083 lines
31 KiB
Vue
1083 lines
31 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,
|
|
enqueueAdminPricingRefresh,
|
|
getAdminPricingJob,
|
|
overrideAdminValuation,
|
|
} from "@/services/admin-pricing-service.js";
|
|
import {
|
|
listAdminPricingJobs,
|
|
retryAdminPricingJob,
|
|
} from "@/services/admin-jobs-service.js";
|
|
import {
|
|
createAdminProviderMapping,
|
|
listAdminProviderMappings,
|
|
} from "@/services/admin-provider-mapping-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("fixture-provider");
|
|
const pricingSource = ref("");
|
|
const pricingPrice = ref("");
|
|
const overridePrice = ref("");
|
|
const overrideReason = ref("");
|
|
const refreshStatus = ref("");
|
|
const voidReasons = ref({});
|
|
const mappingProductId = ref("");
|
|
const mappingProviderId = ref("");
|
|
const mappingType = ref("PRODUCT");
|
|
const mappingExternalIdentifier = ref("");
|
|
const mappingSearchQuery = ref("");
|
|
const providerMappings = ref([]);
|
|
const pricingJobs = 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,
|
|
pricingJobs.value,
|
|
] = await Promise.all([
|
|
listAdminProducts(),
|
|
listAdminInventory(),
|
|
listAdminCategories(),
|
|
listAdminConditions(),
|
|
listAdminListings(),
|
|
listAdminSales(),
|
|
listAdminAcquisitions(),
|
|
getAdminDashboard(),
|
|
listAdminPricingJobs(),
|
|
]);
|
|
} 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 loadProviderMappings(productId = mappingProductId.value) {
|
|
if (!productId) {
|
|
providerMappings.value = [];
|
|
return;
|
|
}
|
|
providerMappings.value = await listAdminProviderMappings(productId);
|
|
}
|
|
|
|
async function createProviderMapping() {
|
|
isSaving.value = true;
|
|
try {
|
|
const mapping = await createAdminProviderMapping(
|
|
mappingProductId.value,
|
|
{
|
|
providerId: mappingProviderId.value,
|
|
mappingType: mappingType.value,
|
|
externalIdentifier: mappingExternalIdentifier.value || undefined,
|
|
searchQuery: mappingSearchQuery.value || undefined,
|
|
},
|
|
csrfToken(),
|
|
);
|
|
providerMappings.value.push(mapping);
|
|
mappingProviderId.value = "";
|
|
mappingExternalIdentifier.value = "";
|
|
mappingSearchQuery.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 refreshPricing() {
|
|
isSaving.value = true;
|
|
refreshStatus.value = "Queued";
|
|
try {
|
|
const item = inventory.value.find(
|
|
({ id }) => id === pricingInventoryId.value,
|
|
);
|
|
const job = await enqueueAdminPricingRefresh(
|
|
item.id,
|
|
{ productId: item.productId, conditionId: item.conditionId },
|
|
csrfToken(),
|
|
);
|
|
for (let attempt = 0; attempt < 15; attempt += 1) {
|
|
const status = await getAdminPricingJob(job.id);
|
|
refreshStatus.value = status.status;
|
|
if (status.status === "COMPLETED") {
|
|
valuations.value = {
|
|
...valuations.value,
|
|
[item.id]: {
|
|
valueMinor: status.valueMinor,
|
|
confidence: "PROVIDER",
|
|
sourceCount: null,
|
|
stale: false,
|
|
},
|
|
};
|
|
return;
|
|
}
|
|
if (status.status === "FAILED")
|
|
throw new Error(status.error ?? "Pricing refresh failed.");
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
}
|
|
throw new Error("Pricing refresh is taking longer than expected.");
|
|
} catch (requestError) {
|
|
error.value = requestError;
|
|
refreshStatus.value = "FAILED";
|
|
} finally {
|
|
isSaving.value = false;
|
|
}
|
|
}
|
|
|
|
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 {
|
|
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>
|
|
<a href="#inventory-title">{{ dashboard.counts.missingImages }}</a>
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Missing pricing</dt>
|
|
<dd>
|
|
<a href="#pricing-title">{{ dashboard.counts.missingPricing }}</a>
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Stale pricing</dt>
|
|
<dd>
|
|
<a href="#pricing-title">{{ dashboard.counts.stalePricing }}</a>
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Provider failures</dt>
|
|
<dd>
|
|
<a href="#pricing-title">{{
|
|
dashboard.counts.failedPricingJobs
|
|
}}</a>
|
|
</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>Market value</dt>
|
|
<dd>
|
|
${{ (dashboard.financials.totalMarketValueMinor / 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="createProviderMapping">
|
|
<h2>Link provider mapping</h2>
|
|
<label class="fixture-label" for="mapping-product">Product</label>
|
|
<select
|
|
id="mapping-product"
|
|
v-model="mappingProductId"
|
|
class="fixture-input"
|
|
required
|
|
@change="loadProviderMappings()"
|
|
>
|
|
<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="mapping-provider">Provider ID</label>
|
|
<input
|
|
id="mapping-provider"
|
|
v-model="mappingProviderId"
|
|
class="fixture-input"
|
|
required
|
|
placeholder="ebay"
|
|
/>
|
|
<label class="fixture-label" for="mapping-type">Mapping type</label>
|
|
<select id="mapping-type" v-model="mappingType" class="fixture-input">
|
|
<option value="PRODUCT">Product</option>
|
|
<option value="SEARCH">Search</option>
|
|
</select>
|
|
<label class="fixture-label" for="mapping-external"
|
|
>External identifier</label
|
|
>
|
|
<input
|
|
id="mapping-external"
|
|
v-model="mappingExternalIdentifier"
|
|
class="fixture-input"
|
|
/>
|
|
<label class="fixture-label" for="mapping-query">Search query</label>
|
|
<input
|
|
id="mapping-query"
|
|
v-model="mappingSearchQuery"
|
|
class="fixture-input"
|
|
/>
|
|
<button
|
|
class="button button--primary"
|
|
type="submit"
|
|
:disabled="isSaving"
|
|
>
|
|
Save provider mapping
|
|
</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 id="pricing-title">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>
|
|
<button
|
|
class="button button--secondary"
|
|
type="button"
|
|
:disabled="isSaving"
|
|
@click="refreshPricing"
|
|
>
|
|
Refresh from provider<span v-if="refreshStatus">
|
|
({{ refreshStatus }})</span
|
|
>
|
|
</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="provider-mappings-title">
|
|
<h2 id="provider-mappings-title">Active provider mappings</h2>
|
|
<p v-if="!providerMappings.length" class="state state--empty">
|
|
Select a product to view mappings.
|
|
</p>
|
|
<ul v-else>
|
|
<li v-for="mapping in providerMappings" :key="mapping.id">
|
|
{{ mapping.providerId }} · {{ mapping.mappingType }} ·
|
|
{{ mapping.externalIdentifier ?? mapping.searchQuery }}
|
|
</li>
|
|
</ul>
|
|
</section>
|
|
<section
|
|
id="inventory-title"
|
|
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>
|
|
<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 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>
|