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:
@@ -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)"
|
||||
|
||||
Reference in New Issue
Block a user