feat: complete inventory image and sales workflows
Quality / test (push) Canceled after 0s

Commit the working vertical slices for inventory image processing and the Sale workflow, including Sale history, audited voiding, multi-item persistence, admin UI, migrations, documentation, and tests.
This commit is contained in:
2026-09-09 00:35:57 -04:00
parent ea145191df
commit 8e5ce80196
32 changed files with 1092 additions and 226 deletions
+74 -1
View File
@@ -6,11 +6,13 @@ 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';
const { principal } = useAuth();
const products = ref([]);
const inventory = ref([]);
const listings = ref([]);
const sales = ref([]);
const categories = ref([]);
const conditions = ref([]);
const isLoading = ref(true);
@@ -22,6 +24,11 @@ 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 voidReasons = ref({});
function csrfToken() {
return document.cookie.split('; ').find((value) => value.startsWith('pokemon_inventory_csrf='))?.split('=')[1] ?? '';
@@ -30,7 +37,7 @@ function csrfToken() {
async function load() {
isLoading.value = true;
try {
[products.value, inventory.value, categories.value, conditions.value, listings.value] = await Promise.all([listAdminProducts(), listAdminInventory(), listAdminCategories(), listAdminConditions(), listAdminListings()]);
[products.value, inventory.value, categories.value, conditions.value, listings.value, sales.value] = await Promise.all([listAdminProducts(), listAdminInventory(), listAdminCategories(), listAdminConditions(), listAdminListings(), listAdminSales()]);
} catch (requestError) { error.value = requestError; }
finally { isLoading.value = false; }
}
@@ -75,6 +82,39 @@ async function transitionListing(action, id) {
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 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>
@@ -123,6 +163,21 @@ onMounted(load);
<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>
</div>
<section class="admin-list" aria-labelledby="products-title">
@@ -140,5 +195,23 @@ onMounted(load);
<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>
</template>