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
@@ -10,6 +10,7 @@ const images = [
export default { title: 'Organisms/AdminImageManager', component: AdminImageManager };
export const Completed = { args: { images, processingStatus: 'COMPLETED' } };
export const EmptyUpload = { args: { images: [], processingStatus: 'COMPLETED' } };
export const Processing = { args: { images, processingStatus: 'PROCESSING' } };
export const Failed = { args: { images, processingStatus: 'FAILED', errorMessage: 'Derivative generation failed.' } };
export const Saving = { args: { images, processingStatus: 'COMPLETED', isSaving: true } };
@@ -11,7 +11,7 @@ const props = defineProps({
errorMessage: { type: String, default: '' },
isSaving: { type: Boolean, default: false },
});
const emit = defineEmits(['reorder', 'primary', 'alt-text', 'retry', 'delete']);
const emit = defineEmits(['upload', 'reorder', 'primary', 'alt-text', 'retry', 'delete']);
const altText = ref(props.images.find((image) => image.isPrimary)?.altText ?? '');
const isEditingAltText = ref(false);
const orderedImages = computed(() => [...props.images].sort((left, right) => left.sortOrder - right.sortOrder));
@@ -20,6 +20,12 @@ function saveAltText() {
emit('alt-text', altText.value);
isEditingAltText.value = false;
}
function handleFileChange(event) {
const [file] = event.target.files;
if (file) emit('upload', file);
event.target.value = '';
}
</script>
<template>
@@ -38,6 +44,11 @@ function saveAltText() {
<button class="button button--secondary" type="button" @click="emit('retry')">Retry processing</button>
</div>
<div class="admin-image-manager__upload">
<label class="fixture-label" for="admin-image-upload">Add image</label>
<input id="admin-image-upload" class="fixture-input" type="file" accept="image/jpeg,image/png,image/webp" :disabled="isSaving" @change="handleFileChange" />
</div>
<ImageGallery :images="orderedImages" />
<p v-if="isSaving" class="state state--loading" role="status">Saving image changes...</p>
@@ -66,5 +77,6 @@ function saveAltText() {
.admin-image-manager__header { justify-content: space-between; }
.admin-image-manager__header h2 { font-size: var(--text-xl); }
.admin-image-manager__controls { display: grid; gap: var(--space-4); }
.admin-image-manager__upload { display: grid; gap: var(--space-2); }
.admin-image-manager__row--order { align-items: stretch; }
</style>
@@ -7,6 +7,7 @@ import {
retryImageProcessing,
setPrimaryImage,
updateImageAltText,
uploadAdminImage,
} from '@/services/admin-image-service.js';
function csrfToken() {
@@ -35,12 +36,30 @@ export function useAdminImages(inventoryItemId) {
finally { isSaving.value = false; }
}
async function upload(file) {
const objectUrl = URL.createObjectURL(file);
const image = new Image();
image.src = objectUrl;
try {
await image.decode();
await apply((token) => uploadAdminImage(inventoryItemId, file, {
width: image.naturalWidth,
height: image.naturalHeight,
altText: file.name,
isPrimary: images.value.length === 0,
}, token));
} finally {
URL.revokeObjectURL(objectUrl);
}
}
return {
images,
isLoading,
isSaving,
error,
load,
upload,
reorder: (imageId, sortOrder) => apply((token) => reorderImage(imageId, sortOrder, token)),
setPrimary: (imageId) => apply((token) => setPrimaryImage(imageId, token)),
updateAltText: (imageId, altText) => apply((token) => updateImageAltText(imageId, altText, token)),
+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>
+2 -1
View File
@@ -7,7 +7,7 @@ import { useAdminImages } from '@/composables/useAdminImages.js';
const route = useRoute();
const inventoryItemId = route.params.id;
const { images, isLoading, isSaving, error, load, reorder, setPrimary, updateAltText, retry, remove } = useAdminImages(inventoryItemId);
const { images, isLoading, isSaving, error, load, upload, reorder, setPrimary, updateAltText, retry, remove } = useAdminImages(inventoryItemId);
onMounted(load);
</script>
@@ -23,6 +23,7 @@ onMounted(load);
v-else
:images="images"
:processing-status="isSaving ? 'PROCESSING' : (images[0]?.processingStatus ?? 'COMPLETED')"
@upload="upload"
@reorder="reorder($event.id, $event.sortOrder)"
@primary="setPrimary"
@alt-text="updateAltText(images[0]?.id, $event)"
@@ -55,6 +55,25 @@ export function deleteImage(imageId, csrfToken) {
});
}
export async function uploadAdminImage(inventoryItemId, file, fields, csrfToken) {
const formData = new FormData();
formData.append('file', file);
formData.append('width', String(fields.width));
formData.append('height', String(fields.height));
formData.append('altText', fields.altText ?? '');
formData.append('isPrimary', String(fields.isPrimary ?? false));
const response = await fetch(`${apiBaseUrl}/admin/inventory/${encodeURIComponent(inventoryItemId)}/images`, {
method: 'POST',
credentials: 'include',
headers: { 'x-csrf-token': csrfToken },
body: formData,
});
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error?.message ?? 'Image upload is unavailable.');
return payload?.data;
}
export async function listAdminImages(inventoryItemId) {
return request(`/admin/inventory/${encodeURIComponent(inventoryItemId)}/images`, {
method: 'GET',
@@ -1,7 +1,8 @@
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1';
async function request(path, options = {}) {
const response = await fetch(`${apiBaseUrl}${path}`, { credentials: 'include', ...options, headers: { 'content-type': 'application/json', ...options.headers } });
const headers = { ...(options.body ? { 'content-type': 'application/json' } : {}), ...options.headers };
const response = await fetch(`${apiBaseUrl}${path}`, { credentials: 'include', ...options, headers });
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error?.message ?? 'Listing request failed.');
return payload?.data;
@@ -0,0 +1,38 @@
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1';
export async function createAdminSale(input, csrfToken) {
const response = await fetch(`${apiBaseUrl}/admin/sales`, {
method: 'POST',
credentials: 'include',
headers: {
'content-type': 'application/json',
'x-csrf-token': csrfToken,
},
body: JSON.stringify(input),
});
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error?.message ?? 'Sale request failed.');
return payload?.data;
}
export async function listAdminSales() {
const response = await fetch(`${apiBaseUrl}/admin/sales`, { credentials: 'include' });
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error?.message ?? 'Sale history is unavailable.');
return payload?.data ?? [];
}
export async function voidAdminSale(id, reason, csrfToken) {
const response = await fetch(`${apiBaseUrl}/admin/sales/${encodeURIComponent(id)}/void`, {
method: 'POST',
credentials: 'include',
headers: {
'content-type': 'application/json',
'x-csrf-token': csrfToken,
},
body: JSON.stringify({ reason }),
});
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error?.message ?? 'Sale correction failed.');
return payload?.data;
}
@@ -1,6 +1,8 @@
import { expect, test } from '@playwright/test';
test('signs in and creates a product and physical copy from the admin workspace', async ({ page }) => {
const tinyPng = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64');
test('signs in and publishes a listing after adding an inventory image', async ({ page }) => {
const email = process.env.INITIAL_ADMIN_EMAIL;
const password = process.env.INITIAL_ADMIN_PASSWORD;
@@ -24,8 +26,39 @@ test('signs in and creates a product and physical copy from the admin workspace'
await page.getByLabel('Condition', { exact: true }).selectOption({ index: 1 });
await page.getByRole('button', { name: 'Create copy' }).click();
await expect(page.getByText('Manage images')).toBeVisible();
await page.getByText('Manage images').last().click();
const inventoryLink = page.locator('section[aria-labelledby="inventory-title"]').getByRole('link', { name: 'Manage images' }).last();
await expect(inventoryLink).toBeVisible();
const createdInventoryId = await page.getByLabel('Inventory copy').locator('option').last().getAttribute('value');
await inventoryLink.click();
await expect(page).toHaveURL(/\/admin\/inventory\/[^/]+\/images$/);
await expect(page.getByRole('heading', { name: 'Manage images' })).toBeVisible();
await page.getByLabel('Add image').setInputFiles({
name: 'front.png',
mimeType: 'image/png',
buffer: tinyPng,
});
await expect(page.getByLabel('Alt text')).toHaveValue('front.png');
await page.goto('/admin');
await expect(page.getByRole('heading', { name: 'Create listing draft' })).toBeVisible();
await page.getByLabel('Inventory copy', { exact: true }).first().selectOption(createdInventoryId);
await page.getByLabel('Asking price (cents)').fill('2499');
await page.getByRole('button', { name: 'Create listing' }).click();
await expect(page.getByText('Add at least one image before publishing.')).toHaveCount(0);
await page.getByRole('button', { name: 'Publish' }).last().click();
await expect(page.getByText('AVAILABLE').last()).toBeVisible();
await page.getByLabel('Inventory copy', { exact: true }).last().selectOption(createdInventoryId);
await page.getByLabel('Sale date').fill('2026-09-09');
await page.getByLabel('Sold price (cents)').fill('2499');
await page.getByLabel('Cost basis (cents)').fill('1200');
await page.getByRole('button', { name: 'Record sale' }).click();
await expect(page.locator('section[aria-labelledby="inventory-title"]').getByText(`${createdInventoryId} · SOLD`)).toBeVisible();
await expect(page.getByRole('heading', { name: 'Sale history' })).toBeVisible();
const latestSale = page.locator('section[aria-labelledby="sales-title"] li').first();
await expect(latestSale).toContainText('Profit $12.99');
await latestSale.getByLabel('Void reason').fill('Browser test correction');
await latestSale.getByRole('button', { name: 'Void sale' }).click();
await expect(latestSale).toContainText('VOIDED');
await expect(page.locator('section[aria-labelledby="inventory-title"]').getByText(`${createdInventoryId} · ACTIVE`)).toBeVisible();
});