feat: extract dedicated admin sales and acquisitions screens
Quality / test (push) Canceled after 0s
Quality / test (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
import AdminAcquisitionsPage from './AdminAcquisitionsPage.vue';
|
||||||
|
|
||||||
|
export default { title: 'Pages/AdminAcquisitionsPage', component: AdminAcquisitionsPage };
|
||||||
|
|
||||||
|
export const Acquisitions = {};
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
import { listAdminInventory } from '@/services/admin-catalog-service.js';
|
||||||
|
import { createAdminAcquisition, listAdminAcquisitions } from '@/services/admin-acquisition-service.js';
|
||||||
|
|
||||||
|
const inventory = ref([]);
|
||||||
|
const acquisitions = ref([]);
|
||||||
|
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 isLoading = ref(true);
|
||||||
|
const isSaving = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
|
||||||
|
function csrfToken() {
|
||||||
|
return document.cookie.split('; ').find((value) => value.startsWith('pokemon_inventory_csrf='))?.split('=')[1] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
[inventory.value, acquisitions.value] = await Promise.all([listAdminInventory(), listAdminAcquisitions()]);
|
||||||
|
} catch (requestError) {
|
||||||
|
error.value = requestError.message;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordAcquisition() {
|
||||||
|
isSaving.value = true;
|
||||||
|
error.value = '';
|
||||||
|
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.message;
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="admin-page">
|
||||||
|
<p class="catalog-kicker">Inventory management</p>
|
||||||
|
<h1>Acquisitions</h1>
|
||||||
|
<p v-if="isLoading" class="state state--loading" role="status">Loading acquisitions...</p>
|
||||||
|
<p v-if="error" class="state state--error" role="alert">{{ error }}</p>
|
||||||
|
<form v-if="!isLoading" 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>
|
||||||
|
<section v-if="!isLoading" class="admin-list" aria-labelledby="acquisitions-title">
|
||||||
|
<h2 id="acquisitions-title">Acquisition history</h2>
|
||||||
|
<p v-if="!acquisitions.length" 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>
|
||||||
@@ -26,15 +26,6 @@ import {
|
|||||||
createAdminListing,
|
createAdminListing,
|
||||||
listAdminListings,
|
listAdminListings,
|
||||||
} from "@/services/admin-listing-service.js";
|
} 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";
|
|
||||||
import { getAdminDashboard } from "@/services/admin-dashboard-service.js";
|
import { getAdminDashboard } from "@/services/admin-dashboard-service.js";
|
||||||
import {
|
import {
|
||||||
addAdminPricingObservation,
|
addAdminPricingObservation,
|
||||||
@@ -71,8 +62,6 @@ const inventoryTags = ref("");
|
|||||||
const exportStatus = ref("");
|
const exportStatus = ref("");
|
||||||
const exportJobId = ref("");
|
const exportJobId = ref("");
|
||||||
const listings = ref([]);
|
const listings = ref([]);
|
||||||
const sales = 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 hasValuations = computed(() => Object.keys(valuations.value).length > 0);
|
||||||
@@ -116,16 +105,6 @@ const inventoryProductId = ref("");
|
|||||||
const conditionId = ref("");
|
const conditionId = ref("");
|
||||||
const listingInventoryId = ref("");
|
const listingInventoryId = ref("");
|
||||||
const askingPrice = ref("");
|
const askingPrice = ref("");
|
||||||
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 pricingInventoryId = ref("");
|
const pricingInventoryId = ref("");
|
||||||
const pricingProvider = ref("fixture-provider");
|
const pricingProvider = ref("fixture-provider");
|
||||||
const pricingSource = ref("");
|
const pricingSource = ref("");
|
||||||
@@ -133,7 +112,6 @@ const pricingPrice = ref("");
|
|||||||
const overridePrice = ref("");
|
const overridePrice = ref("");
|
||||||
const overrideReason = ref("");
|
const overrideReason = ref("");
|
||||||
const refreshStatus = ref("");
|
const refreshStatus = ref("");
|
||||||
const voidReasons = ref({});
|
|
||||||
const mappingProductId = ref("");
|
const mappingProductId = ref("");
|
||||||
const mappingProviderId = ref("");
|
const mappingProviderId = ref("");
|
||||||
const mappingType = ref("PRODUCT");
|
const mappingType = ref("PRODUCT");
|
||||||
@@ -160,8 +138,6 @@ async function load() {
|
|||||||
categories.value,
|
categories.value,
|
||||||
conditions.value,
|
conditions.value,
|
||||||
listings.value,
|
listings.value,
|
||||||
sales.value,
|
|
||||||
acquisitions.value,
|
|
||||||
dashboard.value,
|
dashboard.value,
|
||||||
pricingJobs.value,
|
pricingJobs.value,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
@@ -170,8 +146,6 @@ async function load() {
|
|||||||
listAdminCategories(),
|
listAdminCategories(),
|
||||||
listAdminConditions(),
|
listAdminConditions(),
|
||||||
listAdminListings(),
|
listAdminListings(),
|
||||||
listAdminSales(),
|
|
||||||
listAdminAcquisitions(),
|
|
||||||
getAdminDashboard(),
|
getAdminDashboard(),
|
||||||
listAdminPricingJobs(),
|
listAdminPricingJobs(),
|
||||||
]);
|
]);
|
||||||
@@ -345,60 +319,6 @@ async function createListing() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 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 calculatePricing() {
|
async function calculatePricing() {
|
||||||
isSaving.value = true;
|
isSaving.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -508,30 +428,6 @@ async function overridePricing() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToStep(stepId) {
|
function goToStep(stepId) {
|
||||||
if (!WIZARD_STEP_IDS.includes(stepId)) return;
|
if (!WIZARD_STEP_IDS.includes(stepId)) return;
|
||||||
activeStepId.value = stepId;
|
activeStepId.value = stepId;
|
||||||
@@ -772,131 +668,6 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="admin-workspace-grid">
|
|
||||||
<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>
|
|
||||||
<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">
|
<section class="admin-list" aria-labelledby="products-title">
|
||||||
<h2 id="products-title">Products</h2>
|
<h2 id="products-title">Products</h2>
|
||||||
<p v-if="!products.length && !isLoading" class="state state--empty">
|
<p v-if="!products.length && !isLoading" class="state state--empty">
|
||||||
@@ -1030,58 +801,12 @@ onMounted(() => {
|
|||||||
<a class="button button--secondary" href="/admin/listings">Manage listings</a>
|
<a class="button button--secondary" href="/admin/listings">Manage listings</a>
|
||||||
</section>
|
</section>
|
||||||
<section class="admin-list" aria-labelledby="sales-title">
|
<section class="admin-list" aria-labelledby="sales-title">
|
||||||
<h2 id="sales-title">Sale history</h2>
|
<h2 id="sales-title">Sales</h2>
|
||||||
<p v-if="!sales.length && !isLoading" class="state state--empty">
|
<a class="button button--secondary" href="/admin/sales">Manage sales</a>
|
||||||
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>
|
||||||
<section class="admin-list" aria-labelledby="acquisitions-title">
|
<section class="admin-list" aria-labelledby="acquisitions-title">
|
||||||
<h2 id="acquisitions-title">Acquisition history</h2>
|
<h2 id="acquisitions-title">Acquisitions</h2>
|
||||||
<p v-if="!acquisitions.length && !isLoading" class="state state--empty">
|
<a class="button button--secondary" href="/admin/acquisitions">Manage acquisitions</a>
|
||||||
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>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import AdminSalesPage from './AdminSalesPage.vue';
|
||||||
|
|
||||||
|
export default { title: 'Pages/AdminSalesPage', component: AdminSalesPage };
|
||||||
|
|
||||||
|
export const Sales = {};
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
import { listAdminInventory } from '@/services/admin-catalog-service.js';
|
||||||
|
import { createAdminSale, listAdminSales, voidAdminSale } from '@/services/admin-sale-service.js';
|
||||||
|
|
||||||
|
const inventory = ref([]);
|
||||||
|
const sales = ref([]);
|
||||||
|
const saleInventoryId = ref('');
|
||||||
|
const soldPrice = ref('');
|
||||||
|
const costBasis = ref('');
|
||||||
|
const saleDate = ref(new Date().toISOString().slice(0, 10));
|
||||||
|
const voidReasons = ref({});
|
||||||
|
const isLoading = ref(true);
|
||||||
|
const isSaving = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
|
||||||
|
function csrfToken() {
|
||||||
|
return document.cookie.split('; ').find((value) => value.startsWith('pokemon_inventory_csrf='))?.split('=')[1] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
[inventory.value, sales.value] = await Promise.all([listAdminInventory(), listAdminSales()]);
|
||||||
|
} catch (requestError) {
|
||||||
|
error.value = requestError.message;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordSale() {
|
||||||
|
isSaving.value = true;
|
||||||
|
error.value = '';
|
||||||
|
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.message;
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function voidSale(saleId) {
|
||||||
|
isSaving.value = true;
|
||||||
|
error.value = '';
|
||||||
|
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.message;
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="admin-page">
|
||||||
|
<p class="catalog-kicker">Inventory management</p>
|
||||||
|
<h1>Sales</h1>
|
||||||
|
<p v-if="isLoading" class="state state--loading" role="status">Loading sales...</p>
|
||||||
|
<p v-if="error" class="state state--error" role="alert">{{ error }}</p>
|
||||||
|
<form v-if="!isLoading" 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>
|
||||||
|
<section v-if="!isLoading" class="admin-list" aria-labelledby="sales-title">
|
||||||
|
<h2 id="sales-title">Sale history</h2>
|
||||||
|
<p v-if="!sales.length" 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>
|
||||||
@@ -20,6 +20,8 @@ async function logout() {
|
|||||||
<nav aria-label="Admin navigation">
|
<nav aria-label="Admin navigation">
|
||||||
<a href="/admin">Overview</a>
|
<a href="/admin">Overview</a>
|
||||||
<RouterLink :to="{ name: 'admin-listings' }">Listings</RouterLink>
|
<RouterLink :to="{ name: 'admin-listings' }">Listings</RouterLink>
|
||||||
|
<RouterLink :to="{ name: 'admin-sales' }">Sales</RouterLink>
|
||||||
|
<RouterLink :to="{ name: 'admin-acquisitions' }">Acquisitions</RouterLink>
|
||||||
<RouterLink :to="{ name: 'admin-settings' }">Settings</RouterLink>
|
<RouterLink :to="{ name: 'admin-settings' }">Settings</RouterLink>
|
||||||
<RouterLink :to="{ name: 'admin-saved-views' }">Saved views</RouterLink>
|
<RouterLink :to="{ name: 'admin-saved-views' }">Saved views</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import AdminImagePage from '@/pages/AdminImagePage.vue';
|
|||||||
import AdminSettingsPage from '@/pages/AdminSettingsPage.vue';
|
import AdminSettingsPage from '@/pages/AdminSettingsPage.vue';
|
||||||
import AdminSavedViewsPage from '@/pages/AdminSavedViewsPage.vue';
|
import AdminSavedViewsPage from '@/pages/AdminSavedViewsPage.vue';
|
||||||
import AdminListingsPage from '@/pages/AdminListingsPage.vue';
|
import AdminListingsPage from '@/pages/AdminListingsPage.vue';
|
||||||
|
import AdminSalesPage from '@/pages/AdminSalesPage.vue';
|
||||||
|
import AdminAcquisitionsPage from '@/pages/AdminAcquisitionsPage.vue';
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@@ -26,6 +28,8 @@ const router = createRouter({
|
|||||||
{ path: '', name: 'admin-home', component: AdminHomePage },
|
{ path: '', name: 'admin-home', component: AdminHomePage },
|
||||||
{ path: 'inventory/:id/images', name: 'admin-image-manager', component: AdminImagePage },
|
{ path: 'inventory/:id/images', name: 'admin-image-manager', component: AdminImagePage },
|
||||||
{ path: 'listings', name: 'admin-listings', component: AdminListingsPage },
|
{ path: 'listings', name: 'admin-listings', component: AdminListingsPage },
|
||||||
|
{ path: 'sales', name: 'admin-sales', component: AdminSalesPage },
|
||||||
|
{ path: 'acquisitions', name: 'admin-acquisitions', component: AdminAcquisitionsPage },
|
||||||
{ path: 'settings', name: 'admin-settings', component: AdminSettingsPage },
|
{ path: 'settings', name: 'admin-settings', component: AdminSettingsPage },
|
||||||
{ path: 'saved-views', name: 'admin-saved-views', component: AdminSavedViewsPage },
|
{ path: 'saved-views', name: 'admin-saved-views', component: AdminSavedViewsPage },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ Remaining roadmap areas are approved external provider credentials/adapters, bro
|
|||||||
- The admin image manager now supports multi-file upload with a drag-and-drop dropzone, per-image alt-text editing, primary selection, up/down reordering, and per-image retry/delete actions targeted at the correct image instead of the first uploaded image.
|
- The admin image manager now supports multi-file upload with a drag-and-drop dropzone, per-image alt-text editing, primary selection, up/down reordering, and per-image retry/delete actions targeted at the correct image instead of the first uploaded image.
|
||||||
- The guided inventory workflow is now a true single-step-at-a-time wizard: the stepper is clickable, only the active stage's panel is shown, Back/Next controls advance between stages, and the active stage persists across reloads through local storage.
|
- The guided inventory workflow is now a true single-step-at-a-time wizard: the stepper is clickable, only the active stage's panel is shown, Back/Next controls advance between stages, and the active stage persists across reloads through local storage.
|
||||||
- Listing management (publish/mark pending) is now a dedicated `/admin/listings` screen with its own navigation entry; the guided workflow overview links to it and only tracks listing creation and a lightweight count for wizard completion.
|
- Listing management (publish/mark pending) is now a dedicated `/admin/listings` screen with its own navigation entry; the guided workflow overview links to it and only tracks listing creation and a lightweight count for wizard completion.
|
||||||
|
- Sale recording/voiding and acquisition recording are now dedicated `/admin/sales` and `/admin/acquisitions` screens with their own navigation entries; the guided workflow overview links to both instead of embedding their forms and history inline.
|
||||||
|
|
||||||
## Local Commands
|
## Local Commands
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user