Add admin stale/outlier indicators and audited manual valuation overrides, with robust submission behavior and authenticated E2E coverage.
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { RouterLink } from 'vue-router';
|
import { RouterLink } from 'vue-router';
|
||||||
|
|
||||||
import { useAuth } from '@/composables/useAuth.js';
|
import { useAuth } from '@/composables/useAuth.js';
|
||||||
@@ -9,7 +9,7 @@ import { createAdminListing, listAdminListings, markListingPending, publishAdmin
|
|||||||
import { createAdminSale, listAdminSales, voidAdminSale } from '@/services/admin-sale-service.js';
|
import { createAdminSale, listAdminSales, voidAdminSale } from '@/services/admin-sale-service.js';
|
||||||
import { createAdminAcquisition, listAdminAcquisitions } from '@/services/admin-acquisition-service.js';
|
import { createAdminAcquisition, listAdminAcquisitions } from '@/services/admin-acquisition-service.js';
|
||||||
import { getAdminDashboard } from '@/services/admin-dashboard-service.js';
|
import { getAdminDashboard } from '@/services/admin-dashboard-service.js';
|
||||||
import { addAdminPricingObservation, calculateAdminValuation } from '@/services/admin-pricing-service.js';
|
import { addAdminPricingObservation, calculateAdminValuation, overrideAdminValuation } from '@/services/admin-pricing-service.js';
|
||||||
|
|
||||||
const { principal } = useAuth();
|
const { principal } = useAuth();
|
||||||
const products = ref([]);
|
const products = ref([]);
|
||||||
@@ -19,6 +19,7 @@ const sales = ref([]);
|
|||||||
const acquisitions = ref([]);
|
const acquisitions = ref([]);
|
||||||
const dashboard = ref(null);
|
const dashboard = ref(null);
|
||||||
const valuations = ref({});
|
const valuations = ref({});
|
||||||
|
const hasValuations = computed(() => Object.keys(valuations.value).length > 0);
|
||||||
const categories = ref([]);
|
const categories = ref([]);
|
||||||
const conditions = ref([]);
|
const conditions = ref([]);
|
||||||
const isLoading = ref(true);
|
const isLoading = ref(true);
|
||||||
@@ -44,6 +45,8 @@ const pricingInventoryId = ref('');
|
|||||||
const pricingProvider = ref('manual');
|
const pricingProvider = ref('manual');
|
||||||
const pricingSource = ref('');
|
const pricingSource = ref('');
|
||||||
const pricingPrice = ref('');
|
const pricingPrice = ref('');
|
||||||
|
const overridePrice = ref('');
|
||||||
|
const overrideReason = ref('');
|
||||||
const voidReasons = ref({});
|
const voidReasons = ref({});
|
||||||
|
|
||||||
function csrfToken() {
|
function csrfToken() {
|
||||||
@@ -151,6 +154,20 @@ async function calculatePricing() {
|
|||||||
finally { isSaving.value = false; }
|
finally { isSaving.value = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function overridePricing() {
|
||||||
|
isSaving.value = true;
|
||||||
|
try {
|
||||||
|
const valuation = await overrideAdminValuation(pricingInventoryId.value, {
|
||||||
|
valueMinor: Number(overridePrice.value),
|
||||||
|
reason: overrideReason.value,
|
||||||
|
}, csrfToken());
|
||||||
|
valuations.value = { ...valuations.value, [pricingInventoryId.value]: { ...valuations.value[pricingInventoryId.value], valueMinor: valuation.valueMinor, confidence: 'OVERRIDE', stale: false } };
|
||||||
|
overridePrice.value = '';
|
||||||
|
overrideReason.value = '';
|
||||||
|
} catch (requestError) { error.value = requestError; }
|
||||||
|
finally { isSaving.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
async function voidSale(saleId) {
|
async function voidSale(saleId) {
|
||||||
isSaving.value = true;
|
isSaving.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -274,6 +291,11 @@ onMounted(load);
|
|||||||
<label class="fixture-label" for="pricing-price">Observed price (cents)</label>
|
<label class="fixture-label" for="pricing-price">Observed price (cents)</label>
|
||||||
<input id="pricing-price" v-model="pricingPrice" class="fixture-input" type="number" min="1" required />
|
<input id="pricing-price" v-model="pricingPrice" class="fixture-input" type="number" min="1" required />
|
||||||
<button class="button button--primary" type="submit" :disabled="isSaving">Add observation and calculate</button>
|
<button class="button button--primary" type="submit" :disabled="isSaving">Add observation and calculate</button>
|
||||||
|
<label class="fixture-label" for="override-price">Manual value (cents)</label>
|
||||||
|
<input id="override-price" v-model="overridePrice" class="fixture-input" type="number" min="0" />
|
||||||
|
<label class="fixture-label" for="override-reason">Override reason</label>
|
||||||
|
<input id="override-reason" v-model="overrideReason" class="fixture-input" minlength="3" />
|
||||||
|
<button class="button button--secondary" type="button" :disabled="isSaving" @click="overridePricing">Save manual value</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -286,8 +308,11 @@ onMounted(load);
|
|||||||
<h2 id="inventory-title">Physical inventory</h2>
|
<h2 id="inventory-title">Physical inventory</h2>
|
||||||
<p v-if="!inventory.length && !isLoading" class="state state--empty">No physical copies yet.</p>
|
<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" :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>
|
<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>
|
||||||
<ul v-if="Object.keys(valuations).length" aria-label="Market valuations">
|
<ul v-if="hasValuations" aria-label="Market valuations">
|
||||||
<li v-for="(valuation, inventoryItemId) in valuations" :key="inventoryItemId">{{ inventoryItemId }} · Market value ${{ (valuation.valueMinor / 100).toFixed(2) }} · {{ valuation.confidence }} confidence · {{ valuation.sourceCount }} source{{ valuation.sourceCount === 1 ? '' : 's' }}</li>
|
<li v-for="(valuation, inventoryItemId) in valuations" :key="inventoryItemId">
|
||||||
|
{{ inventoryItemId }} · Market value ${{ (valuation.valueMinor / 100).toFixed(2) }} · {{ valuation.confidence }} confidence · {{ valuation.sourceCount }} source{{ valuation.sourceCount === 1 ? '' : 's' }}<span v-if="valuation.stale"> · STALE</span>
|
||||||
|
<span v-if="valuation.observations?.some(({ isOutlier }) => isOutlier)"> · OUTLIER FLAGGED</span>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
<section class="admin-list" aria-labelledby="listings-title">
|
<section class="admin-list" aria-labelledby="listings-title">
|
||||||
|
|||||||
@@ -25,3 +25,11 @@ export function calculateAdminValuation(inventoryItemId, csrfToken) {
|
|||||||
headers: { 'x-csrf-token': csrfToken },
|
headers: { 'x-csrf-token': csrfToken },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function overrideAdminValuation(inventoryItemId, input, csrfToken) {
|
||||||
|
return request(`/admin/inventory/${encodeURIComponent(inventoryItemId)}/valuation/override`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'x-csrf-token': csrfToken },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,8 +46,20 @@ test('signs in and publishes a listing after adding an inventory image', async (
|
|||||||
await page.locator('#pricing-inventory').selectOption(createdInventoryId);
|
await page.locator('#pricing-inventory').selectOption(createdInventoryId);
|
||||||
await page.getByLabel('Source identifier').fill('browser-price-1');
|
await page.getByLabel('Source identifier').fill('browser-price-1');
|
||||||
await page.getByLabel('Observed price (cents)').fill('2500');
|
await page.getByLabel('Observed price (cents)').fill('2500');
|
||||||
await page.getByRole('button', { name: 'Add observation and calculate' }).click();
|
const [observationResponse, valuationResponse] = await Promise.all([
|
||||||
await expect(page.getByRole('list', { name: 'Market valuations' })).toContainText('Market value $25.00');
|
page.waitForResponse((response) => response.url().includes('/admin/pricing/observations')),
|
||||||
|
page.waitForResponse((response) => response.url().includes('/valuation') && response.request().method() === 'POST'),
|
||||||
|
page.getByRole('button', { name: 'Add observation and calculate' }).click(),
|
||||||
|
]);
|
||||||
|
expect(observationResponse.status()).toBe(201);
|
||||||
|
expect(valuationResponse.status()).toBe(200);
|
||||||
|
const valuationRow = page.getByRole('list', { name: 'Market valuations' }).getByText(createdInventoryId);
|
||||||
|
await expect(valuationRow).toContainText('Market value $25.00');
|
||||||
|
await page.getByLabel('Manual value (cents)').fill('2700');
|
||||||
|
await page.getByLabel('Override reason').fill('Verified recent sale');
|
||||||
|
await page.getByRole('button', { name: 'Save manual value' }).click();
|
||||||
|
await expect(page.getByRole('list', { name: 'Market valuations' })).toContainText('Market value $27.00');
|
||||||
|
await expect(page.getByRole('list', { name: 'Market valuations' })).toContainText('OVERRIDE confidence');
|
||||||
await inventoryLink.click();
|
await inventoryLink.click();
|
||||||
await expect(page).toHaveURL(/\/admin\/inventory\/[^/]+\/images$/);
|
await expect(page).toHaveURL(/\/admin\/inventory\/[^/]+\/images$/);
|
||||||
await expect(page.getByRole('heading', { name: 'Manage images' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Manage images' })).toBeVisible();
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export function toAdminPricingObservationDto(observation) {
|
|||||||
conditionId: observation.conditionId,
|
conditionId: observation.conditionId,
|
||||||
sourceUrl: observation.sourceUrl ?? null,
|
sourceUrl: observation.sourceUrl ?? null,
|
||||||
qualityScore: observation.qualityScore ?? null,
|
qualityScore: observation.qualityScore ?? null,
|
||||||
|
isOutlier: observation.isOutlier,
|
||||||
|
outlierReason: observation.outlierReason ?? null,
|
||||||
observedAt: toIsoString(observation.observedAt),
|
observedAt: toIsoString(observation.observedAt),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user