Files
pokemon-inventory/apps/web/src/pages/AdminHomePage.vue
T
fragginwagon b8f089a5d9 feat: reconcile application vertical slices
Backfill the completed acquisitions, sales, dashboard, pricing, search, public detail, worker, migration, contracts, validation, tests, and implementation-status changes into one verified application slice.
2026-09-09 22:12:53 -04:00

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>