improve the bill of production

This commit is contained in:
2026-03-05 02:48:33 -05:00
parent 4214eeb659
commit d8386dccf4
8 changed files with 279 additions and 105 deletions

View File

@@ -23,16 +23,33 @@
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-700 bg-gray-700/30">
<th class="text-left px-4 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wider">Item</th>
<th class="text-center px-3 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wider w-28">Qty</th>
<th class="text-right px-4 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wider">Cost / unit</th>
<th
class="text-left px-4 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-200 transition-colors select-none"
@click="toggleSort('name')"
><span class="flex items-center gap-1">Item <span class="text-amber-400">{{ sortIndicator('name') }}</span></span></th>
<th
class="text-left px-4 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-200 transition-colors select-none"
@click="toggleSort('station')"
><span class="flex items-center gap-1">Station <span class="text-amber-400">{{ sortIndicator('station') }}</span></span></th>
<th
class="text-left px-4 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-200 transition-colors select-none"
@click="toggleSort('focus')"
><span class="flex items-center gap-1">Focus <span class="text-amber-400">{{ sortIndicator('focus') }}</span></span></th>
<th
class="text-center px-3 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wider w-28 cursor-pointer hover:text-gray-200 transition-colors select-none"
@click="toggleSort('qty')"
><span class="flex items-center justify-center gap-1">Qty <span class="text-amber-400">{{ sortIndicator('qty') }}</span></span></th>
<th
class="text-right px-4 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-200 transition-colors select-none"
@click="toggleSort('cost')"
><span class="flex items-center justify-end gap-1">Cost / unit <span class="text-amber-400">{{ sortIndicator('cost') }}</span></span></th>
<th class="w-8"></th>
</tr>
</thead>
<tbody>
<tr
v-for="item in computedItems"
:key="item.recipe.outputItemId"
v-for="item in sortedItems"
:key="`${item.recipe.outputItemId}|${item.focus ? 'f' : 'b'}`"
class="border-t border-gray-700/40 hover:bg-gray-700/20"
>
<td class="px-4 py-2">
@@ -43,16 +60,22 @@
class="w-12 h-12 -my-2 rounded flex-shrink-0"
/>
<span class="text-gray-200 font-medium">{{ item.recipe.displayName }}</span>
<span class="text-xs text-gray-500">{{ item.recipe.category }}</span>
</div>
</td>
<td class="px-4 py-2">
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold" :class="stationClass(item.recipe.journalType)">{{ stationLabel(item.recipe.journalType) }}</span>
</td>
<td class="px-4 py-2">
<span v-if="item.focus" class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-violet-600/20 border border-violet-600/40 text-violet-400">Focus</span>
<span v-else class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-gray-700/50 border border-gray-600/40 text-gray-400">No Focus</span>
</td>
<td class="px-3 py-2 text-center">
<input
type="number"
min="1"
:value="item.qty"
class="w-20 bg-gray-900 border border-gray-700 rounded px-2 py-0.5 text-center text-sm font-mono text-gray-200 focus:outline-none focus:border-amber-500"
@change="upsert(item.recipe, Number(($event.target as HTMLInputElement).value))"
@change="upsert(item.recipe, Number(($event.target as HTMLInputElement).value), item.focus)"
/>
</td>
<td class="px-4 py-2 text-right font-mono text-gray-300">
@@ -63,7 +86,7 @@
<button
class="text-gray-600 hover:text-red-400 transition-colors text-sm leading-none"
title="Remove"
@click="remove(item.recipe.outputItemId)"
@click="remove(item.recipe.outputItemId, item.focus)"
></button>
</td>
</tr>
@@ -209,7 +232,7 @@ import { useAlbionPrices } from '../composables/useAlbionPrices'
import { useFilters } from '../composables/useFilters'
import { formatSilver, formatItemId, formatLastUpdated, tierStyle, itemImageUrl } from '../utils/formatting'
import { fameTypeOf, craftsPerJournal } from '../data/constants'
import { cityRrr, isRrrExempt } from '../data/cityBonuses'
import { localProductionBonus, rrrFromBonus, FOCUS_LPB, isRrrExempt } from '../data/cityBonuses'
import type { Tier, JournalType } from '../types/crafting'
const vFocus = { mounted: (el: HTMLElement) => el.focus() }
@@ -261,6 +284,43 @@ function priceTitle(itemId: string, currentPrice: number): string {
return exact ? `${exact} — click to set price` : 'Click to set price'
}
// ─── Order sort ───────────────────────────────────────────────────────────────
type OrderSortField = 'name' | 'station' | 'focus' | 'qty' | 'cost'
const orderSortField = ref<OrderSortField>('name')
const orderSortDir = ref<'asc' | 'desc'>('asc')
function toggleSort(field: OrderSortField) {
if (orderSortField.value === field) {
orderSortDir.value = orderSortDir.value === 'asc' ? 'desc' : 'asc'
} else {
orderSortField.value = field
orderSortDir.value = 'asc'
}
}
function sortIndicator(field: OrderSortField): string {
if (orderSortField.value !== field) return ''
return orderSortDir.value === 'asc' ? '↑' : '↓'
}
const STATION_LABEL: Record<JournalType, string> = {
warrior: "Warrior's Forge",
hunter: "Hunter's Lodge",
mage: "Mage's Tower",
toolmaker: "Toolmaker's Workshop",
}
const STATION_CLASS: Record<JournalType, string> = {
warrior: 'bg-red-900/30 border border-red-700/40 text-red-400',
hunter: 'bg-green-900/30 border border-green-700/40 text-green-400',
mage: 'bg-blue-900/30 border border-blue-700/40 text-blue-400',
toolmaker: 'bg-orange-900/30 border border-orange-700/40 text-orange-400',
}
function stationLabel(type: JournalType): string { return STATION_LABEL[type] }
function stationClass(type: JournalType): string { return STATION_CLASS[type] }
const JOURNAL_DISPLAY_NAME: Record<JournalType, string> = {
warrior: "Blacksmith's",
hunter: "Fletcher's",
@@ -280,7 +340,7 @@ const JOURNAL_ITEM_ID: Record<JournalType, string> = {
const computedItems = computed(() => {
const city = filters.value.city
return orderItems.value.map(({ recipe, qty }) => {
return orderItems.value.map(({ recipe, qty, focus }) => {
let basicCost = 0
let artefactCost = 0
let missingPrices = false
@@ -296,9 +356,24 @@ const computedItems = computed(() => {
}
const rawCost = basicCost + artefactCost
const rrr = cityRrr(city, recipe.outputItemId)
const lpb = localProductionBonus(city, recipe.outputItemId)
const rrr = rrrFromBonus(focus ? lpb + FOCUS_LPB : lpb)
const craftCostPerUnit = basicCost * (1 - rrr / 100) + artefactCost
return { recipe, qty, rawCostPerUnit: rawCost, craftCostPerUnit, totalCraftCost: craftCostPerUnit * qty, rrr, missingPrices }
return { recipe, qty, focus, rawCostPerUnit: rawCost, craftCostPerUnit, totalCraftCost: craftCostPerUnit * qty, rrr, missingPrices }
})
})
const sortedItems = computed(() => {
const dir = orderSortDir.value === 'asc' ? 1 : -1
return [...computedItems.value].sort((a, b) => {
switch (orderSortField.value) {
case 'name': return dir * a.recipe.displayName.localeCompare(b.recipe.displayName)
case 'station': return dir * a.recipe.journalType.localeCompare(b.recipe.journalType)
case 'focus': return dir * (Number(a.focus) - Number(b.focus))
case 'qty': return dir * (a.qty - b.qty)
case 'cost': return dir * (a.craftCostPerUnit - b.craftCostPerUnit)
default: return 0
}
})
})