Add the admin Acquisition form and history, calculated cost-basis display, API client, and deterministic authenticated browser coverage.
This commit is contained in:
@@ -7,12 +7,14 @@ 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';
|
||||
|
||||
const { principal } = useAuth();
|
||||
const products = ref([]);
|
||||
const inventory = ref([]);
|
||||
const listings = ref([]);
|
||||
const sales = ref([]);
|
||||
const acquisitions = ref([]);
|
||||
const categories = ref([]);
|
||||
const conditions = ref([]);
|
||||
const isLoading = ref(true);
|
||||
@@ -28,6 +30,12 @@ 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 voidReasons = ref({});
|
||||
|
||||
function csrfToken() {
|
||||
@@ -37,7 +45,7 @@ function csrfToken() {
|
||||
async function load() {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
[products.value, inventory.value, categories.value, conditions.value, listings.value, sales.value] = await Promise.all([listAdminProducts(), listAdminInventory(), listAdminCategories(), listAdminConditions(), listAdminListings(), listAdminSales()]);
|
||||
[products.value, inventory.value, categories.value, conditions.value, listings.value, sales.value, acquisitions.value] = await Promise.all([listAdminProducts(), listAdminInventory(), listAdminCategories(), listAdminConditions(), listAdminListings(), listAdminSales(), listAdminAcquisitions()]);
|
||||
} catch (requestError) { error.value = requestError; }
|
||||
finally { isLoading.value = false; }
|
||||
}
|
||||
@@ -66,8 +74,8 @@ async function createListing() {
|
||||
const product = products.value.find((candidate) => candidate.id === item?.productId);
|
||||
isSaving.value = true;
|
||||
try {
|
||||
const listing = await createAdminListing({ inventoryItemId: item.id, productName: product.name, categoryId: product.categoryId, conditionId: item.conditionId, askingPriceMinor: Number(askingPrice.value) }, csrfToken());
|
||||
listings.value.push(listing);
|
||||
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; }
|
||||
@@ -102,6 +110,26 @@ async function recordSale() {
|
||||
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 voidSale(saleId) {
|
||||
isSaving.value = true;
|
||||
try {
|
||||
@@ -178,6 +206,25 @@ onMounted(load);
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<section class="admin-list" aria-labelledby="products-title">
|
||||
@@ -188,7 +235,7 @@ onMounted(load);
|
||||
<section 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"><span>{{ item.id }}</span> · {{ item.status }} <RouterLink :to="{ name: 'admin-image-manager', params: { id: item.id } }">Manage images</RouterLink></li></ul>
|
||||
<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>
|
||||
</section>
|
||||
<section class="admin-list" aria-labelledby="listings-title">
|
||||
<h2 id="listings-title">Listings</h2>
|
||||
@@ -213,5 +260,18 @@ onMounted(load);
|
||||
</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>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
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: { ...(options.body ? { 'content-type': 'application/json' } : {}), ...options.headers },
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(payload?.error?.message ?? 'Acquisition request failed.');
|
||||
return payload?.data;
|
||||
}
|
||||
|
||||
export function listAdminAcquisitions() {
|
||||
return request('/admin/acquisitions');
|
||||
}
|
||||
|
||||
export function createAdminAcquisition(input, csrfToken) {
|
||||
return request('/admin/acquisitions', {
|
||||
method: 'POST',
|
||||
headers: { 'x-csrf-token': csrfToken },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
@@ -14,21 +14,33 @@ test('signs in and publishes a listing after adding an inventory image', async (
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
await expect(page).toHaveURL(/\/admin$/);
|
||||
await expect(page.getByRole('heading', { name: 'Collector workspace' })).toBeVisible();
|
||||
await expect(page.getByRole('status', { name: 'Loading inventory...' })).toHaveCount(0);
|
||||
|
||||
const category = page.locator('#product-category option').nth(1);
|
||||
await expect(category).toBeAttached();
|
||||
await page.getByLabel('Name').fill(`Browser test collectible ${Date.now()}`);
|
||||
const productName = `Browser test collectible ${Date.now()}`;
|
||||
await page.getByLabel('Name').fill(productName);
|
||||
await page.getByLabel('Category').selectOption({ index: 1 });
|
||||
await page.getByRole('button', { name: 'Create product' }).click();
|
||||
|
||||
await expect(page.locator('#inventory-product option').nth(1)).toBeAttached();
|
||||
await page.getByLabel('Product', { exact: true }).selectOption({ index: 1 });
|
||||
const createdProductId = await page.getByLabel('Product', { exact: true }).locator('option', { hasText: productName }).getAttribute('value');
|
||||
await page.getByLabel('Product', { exact: true }).selectOption(createdProductId);
|
||||
await page.getByLabel('Condition', { exact: true }).selectOption({ index: 1 });
|
||||
await page.getByRole('button', { name: 'Create copy' }).click();
|
||||
|
||||
const inventoryLink = page.locator('section[aria-labelledby="inventory-title"]').getByRole('link', { name: 'Manage images' }).last();
|
||||
const createdInventoryRow = page.locator(`section[aria-labelledby="inventory-title"] li[data-product-id="${createdProductId}"]`).last();
|
||||
const createdInventoryId = await createdInventoryRow.locator('a').getAttribute('href').then((href) => href.split('/').at(-2));
|
||||
const inventoryLink = page.locator(`a[href="/admin/inventory/${createdInventoryId}/images"]`);
|
||||
await expect(inventoryLink).toBeVisible();
|
||||
const createdInventoryId = await page.getByLabel('Inventory copy').locator('option').last().getAttribute('value');
|
||||
await page.locator('#acquisition-inventory').selectOption(createdInventoryId);
|
||||
await page.getByLabel('Method').fill('PURCHASE');
|
||||
await page.getByLabel('Acquisition date').fill('2026-09-09');
|
||||
await page.getByLabel('Cost (cents)').fill('1000');
|
||||
await page.getByLabel('Shipping (cents)').fill('100');
|
||||
await page.getByLabel('Tax (cents)').fill('50');
|
||||
await page.getByRole('button', { name: 'Record acquisition' }).click();
|
||||
await expect(page.locator('section[aria-labelledby="acquisitions-title"] li').first()).toContainText('Basis $11.50');
|
||||
await inventoryLink.click();
|
||||
await expect(page).toHaveURL(/\/admin\/inventory\/[^/]+\/images$/);
|
||||
await expect(page.getByRole('heading', { name: 'Manage images' })).toBeVisible();
|
||||
@@ -44,11 +56,12 @@ test('signs in and publishes a listing after adding an inventory image', async (
|
||||
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();
|
||||
const createdListing = page.locator('section[aria-labelledby="listings-title"] article').filter({ hasText: productName }).last();
|
||||
await expect(createdListing.getByText('Add at least one image before publishing.')).toHaveCount(0);
|
||||
await createdListing.getByRole('button', { name: 'Publish' }).click();
|
||||
await expect(createdListing).toContainText('AVAILABLE');
|
||||
|
||||
await page.getByLabel('Inventory copy', { exact: true }).last().selectOption(createdInventoryId);
|
||||
await page.locator('#sale-inventory').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');
|
||||
|
||||
Reference in New Issue
Block a user