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:
@@ -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)),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user