diff --git a/apps/web/src/pages/AdminHomePage.vue b/apps/web/src/pages/AdminHomePage.vue index 466aa3f..9a2b862 100644 --- a/apps/web/src/pages/AdminHomePage.vue +++ b/apps/web/src/pages/AdminHomePage.vue @@ -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); +
+

Record acquisition

+ + + + + + + + + + + + + +
@@ -188,7 +235,7 @@ onMounted(load);

Physical inventory

No physical copies yet.

- +

Listings

@@ -213,5 +260,18 @@ onMounted(load);
+
+

Acquisition history

+

No acquisitions recorded yet.

+ +
diff --git a/apps/web/src/services/admin-acquisition-service.js b/apps/web/src/services/admin-acquisition-service.js new file mode 100644 index 0000000..02167e3 --- /dev/null +++ b/apps/web/src/services/admin-acquisition-service.js @@ -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), + }); +} diff --git a/apps/web/tests/e2e/admin-catalog-workflow.spec.js b/apps/web/tests/e2e/admin-catalog-workflow.spec.js index 32b9694..90193a1 100644 --- a/apps/web/tests/e2e/admin-catalog-workflow.spec.js +++ b/apps/web/tests/e2e/admin-catalog-workflow.spec.js @@ -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');