improve the bill of production
This commit is contained in:
@@ -41,36 +41,26 @@
|
||||
</td>
|
||||
|
||||
<!-- Effective material cost (after RRR) -->
|
||||
<td class="px-4 py-3 text-sm font-mono text-gray-300"
|
||||
<td class="px-4 py-3 text-right text-sm font-mono text-gray-300"
|
||||
:title="result.effectiveMaterialCost > 0 ? result.effectiveMaterialCost.toLocaleString() : undefined">
|
||||
{{ formatSilver(result.effectiveMaterialCost) }}
|
||||
</td>
|
||||
|
||||
<!-- +15% markup -->
|
||||
<td class="px-4 py-3 text-sm font-mono text-blue-300"
|
||||
<!-- +15% markup (no focus) -->
|
||||
<td class="px-4 py-3 text-right text-sm font-mono text-blue-300"
|
||||
:title="result.effectiveMaterialCost > 0 ? Math.round(result.effectiveMaterialCost * 1.15).toLocaleString() : undefined">
|
||||
{{ result.effectiveMaterialCost > 0 ? formatSilver(Math.round(result.effectiveMaterialCost * 1.15)) : '—' }}
|
||||
</td>
|
||||
|
||||
<!-- +30% markup -->
|
||||
<td class="px-4 py-3 text-sm font-mono text-emerald-300"
|
||||
<!-- +30% markup (no focus) -->
|
||||
<td class="px-4 py-3 text-right text-sm font-mono text-emerald-300"
|
||||
:title="result.effectiveMaterialCost > 0 ? Math.round(result.effectiveMaterialCost * 1.30).toLocaleString() : undefined">
|
||||
{{ result.effectiveMaterialCost > 0 ? formatSilver(Math.round(result.effectiveMaterialCost * 1.30)) : '—' }}
|
||||
</td>
|
||||
|
||||
<!-- Price age -->
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="inline-block w-2.5 h-2.5 rounded-full"
|
||||
:class="ageDotClass(result)"
|
||||
:title="ageDotTitle(result)"
|
||||
/>
|
||||
</td>
|
||||
|
||||
<!-- Add to bill -->
|
||||
<!-- Add to bill (no focus) -->
|
||||
<td class="px-3 py-3 text-right whitespace-nowrap" @click.stop>
|
||||
<!-- Inline qty form -->
|
||||
<div v-if="addingToBill" class="inline-flex items-center gap-1">
|
||||
<div v-if="addingMode === 'nofocus'" class="inline-flex items-center gap-1">
|
||||
<input
|
||||
v-focus
|
||||
type="number"
|
||||
@@ -83,31 +73,90 @@
|
||||
<button class="text-amber-500 hover:text-amber-300 text-xs leading-none" @click="confirmBill">✓</button>
|
||||
<button class="text-gray-500 hover:text-gray-300 text-xs leading-none" @click="cancelBill">✕</button>
|
||||
</div>
|
||||
<!-- In-order badge + remove -->
|
||||
<div v-else-if="isInOrder" class="inline-flex items-center gap-1">
|
||||
<div v-else-if="isInOrderNoFocus" class="inline-flex items-center gap-1">
|
||||
<button
|
||||
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-600/20 border border-amber-600/40 text-amber-400 text-xs font-mono hover:border-amber-500 transition-colors"
|
||||
@click="startBill"
|
||||
>×{{ currentQty }}</button>
|
||||
@click="startBill('nofocus')"
|
||||
>×{{ currentQtyNoFocus }}</button>
|
||||
<button
|
||||
class="text-gray-600 hover:text-red-400 transition-colors text-xs leading-none"
|
||||
title="Remove from order"
|
||||
@click="remove(result.recipe.outputItemId)"
|
||||
@click="remove(result.recipe.outputItemId, false)"
|
||||
>✕</button>
|
||||
</div>
|
||||
<!-- Add button -->
|
||||
<button
|
||||
v-else
|
||||
class="w-6 h-6 rounded-full bg-gray-700 hover:bg-amber-600/30 hover:text-amber-400 text-gray-400 text-sm leading-none transition-colors inline-flex items-center justify-center"
|
||||
title="Add to Bill of Production"
|
||||
@click="startBill"
|
||||
title="Add to Bill of Production (no focus)"
|
||||
@click="startBill('nofocus')"
|
||||
>+</button>
|
||||
</td>
|
||||
|
||||
<!-- Cost (with focus) -->
|
||||
<td class="px-4 py-3 text-right text-sm font-mono text-violet-300 border-l border-gray-700/40"
|
||||
:title="result.effectiveMaterialCostFocus > 0 ? result.effectiveMaterialCostFocus.toLocaleString() : undefined">
|
||||
{{ formatSilver(result.effectiveMaterialCostFocus) }}
|
||||
</td>
|
||||
|
||||
<!-- +15% markup (with focus) -->
|
||||
<td class="px-4 py-3 text-right text-sm font-mono text-blue-300/70"
|
||||
:title="result.effectiveMaterialCostFocus > 0 ? Math.round(result.effectiveMaterialCostFocus * 1.15).toLocaleString() : undefined">
|
||||
{{ result.effectiveMaterialCostFocus > 0 ? formatSilver(Math.round(result.effectiveMaterialCostFocus * 1.15)) : '—' }}
|
||||
</td>
|
||||
|
||||
<!-- +30% markup (with focus) -->
|
||||
<td class="px-4 py-3 text-right text-sm font-mono text-emerald-300/70"
|
||||
:title="result.effectiveMaterialCostFocus > 0 ? Math.round(result.effectiveMaterialCostFocus * 1.30).toLocaleString() : undefined">
|
||||
{{ result.effectiveMaterialCostFocus > 0 ? formatSilver(Math.round(result.effectiveMaterialCostFocus * 1.30)) : '—' }}
|
||||
</td>
|
||||
|
||||
<!-- Add to bill (with focus) -->
|
||||
<td class="px-3 py-3 text-right whitespace-nowrap" @click.stop>
|
||||
<div v-if="addingMode === 'focus'" class="inline-flex items-center gap-1">
|
||||
<input
|
||||
v-focus
|
||||
type="number"
|
||||
min="1"
|
||||
v-model.number="billQty"
|
||||
class="w-12 bg-gray-900 border border-violet-500 rounded px-1.5 py-0.5 text-center text-xs font-mono text-gray-200 focus:outline-none"
|
||||
@keydown.enter="confirmBill"
|
||||
@keydown.escape="cancelBill"
|
||||
/>
|
||||
<button class="text-violet-400 hover:text-violet-200 text-xs leading-none" @click="confirmBill">✓</button>
|
||||
<button class="text-gray-500 hover:text-gray-300 text-xs leading-none" @click="cancelBill">✕</button>
|
||||
</div>
|
||||
<div v-else-if="isInOrderFocus" class="inline-flex items-center gap-1">
|
||||
<button
|
||||
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-violet-600/20 border border-violet-600/40 text-violet-400 text-xs font-mono hover:border-violet-500 transition-colors"
|
||||
@click="startBill('focus')"
|
||||
>×{{ currentQtyFocus }}</button>
|
||||
<button
|
||||
class="text-gray-600 hover:text-red-400 transition-colors text-xs leading-none"
|
||||
title="Remove from order"
|
||||
@click="remove(result.recipe.outputItemId, true)"
|
||||
>✕</button>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
class="w-6 h-6 rounded-full bg-gray-700 hover:bg-violet-600/30 hover:text-violet-400 text-gray-400 text-sm leading-none transition-colors inline-flex items-center justify-center"
|
||||
title="Add to Bill of Production (with focus)"
|
||||
@click="startBill('focus')"
|
||||
>+</button>
|
||||
</td>
|
||||
|
||||
<!-- Status -->
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span
|
||||
class="inline-block w-2.5 h-2.5 rounded-full"
|
||||
:class="ageDotClass(result)"
|
||||
:title="ageDotTitle(result)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Expanded detail row -->
|
||||
<tr v-if="expanded" class="border-b border-gray-700/50 bg-gray-900/60">
|
||||
<td colspan="9" class="px-6 py-4">
|
||||
<td colspan="13" class="px-6 py-4">
|
||||
<div class="flex gap-8">
|
||||
|
||||
<!-- Ingredients breakdown -->
|
||||
@@ -184,13 +233,13 @@
|
||||
<span class="text-gray-400">Raw Materials</span>
|
||||
<span class="text-gray-200">{{ formatSilver(result.materialCost) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<span class="text-gray-400">RRR ({{ result.rrr.toFixed(1) }}%)</span>
|
||||
<span class="text-green-400">−{{ formatSilver(result.materialCost - result.effectiveMaterialCost) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4 border-t border-gray-700/50 pt-1.5">
|
||||
<span class="text-gray-300 font-semibold">Craft Cost</span>
|
||||
<span class="font-semibold text-gray-100">{{ formatSilver(result.effectiveMaterialCost) }}</span>
|
||||
<span class="text-gray-400">RRR ({{ result.rrr.toFixed(1) }}%)</span>
|
||||
<span class="text-gray-200">{{ formatSilver(result.effectiveMaterialCost) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<span class="text-violet-400/80">RRR + Focus ({{ result.rrrFocus.toFixed(1) }}%)</span>
|
||||
<span class="text-violet-300">{{ formatSilver(result.effectiveMaterialCostFocus) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -241,24 +290,30 @@ const inputValue = ref('')
|
||||
|
||||
// ─── Bill of Production ───────────────────────────────────────────────────────
|
||||
|
||||
const addingToBill = ref(false)
|
||||
const billQty = ref(1)
|
||||
const addingMode = ref<null | 'nofocus' | 'focus'>(null)
|
||||
const billQty = ref(1)
|
||||
|
||||
const isInOrder = computed(() => inOrder(props.result.recipe.outputItemId))
|
||||
const currentQty = computed(() => getQty(props.result.recipe.outputItemId))
|
||||
const isInOrderNoFocus = computed(() => inOrder(props.result.recipe.outputItemId, false))
|
||||
const currentQtyNoFocus = computed(() => getQty(props.result.recipe.outputItemId, false))
|
||||
const isInOrderFocus = computed(() => inOrder(props.result.recipe.outputItemId, true))
|
||||
const currentQtyFocus = computed(() => getQty(props.result.recipe.outputItemId, true))
|
||||
|
||||
function startBill() {
|
||||
billQty.value = isInOrder.value ? currentQty.value : 1
|
||||
addingToBill.value = true
|
||||
function startBill(mode: 'nofocus' | 'focus') {
|
||||
const already = mode === 'nofocus' ? isInOrderNoFocus.value : isInOrderFocus.value
|
||||
const qty = mode === 'nofocus' ? currentQtyNoFocus.value : currentQtyFocus.value
|
||||
billQty.value = already ? qty : 1
|
||||
addingMode.value = mode
|
||||
}
|
||||
|
||||
function confirmBill() {
|
||||
if (billQty.value > 0) upsert(props.result.recipe, billQty.value)
|
||||
addingToBill.value = false
|
||||
if (billQty.value > 0 && addingMode.value) {
|
||||
upsert(props.result.recipe, billQty.value, addingMode.value === 'focus')
|
||||
}
|
||||
addingMode.value = null
|
||||
}
|
||||
|
||||
function cancelBill() {
|
||||
addingToBill.value = false
|
||||
addingMode.value = null
|
||||
}
|
||||
|
||||
// ─── Price age display ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,21 +1,63 @@
|
||||
<template>
|
||||
<thead class="sticky top-0 z-10">
|
||||
<!-- Group row -->
|
||||
<tr class="bg-gray-800 border-b border-gray-700/50">
|
||||
<th colspan="4" class="bg-gray-800" />
|
||||
<th colspan="4" class="px-4 py-1.5 text-center text-[10px] font-semibold text-gray-500 uppercase tracking-wider border-l border-gray-700/60">
|
||||
No Focus
|
||||
</th>
|
||||
<th colspan="4" class="px-4 py-1.5 text-center text-[10px] font-semibold text-violet-400/70 uppercase tracking-wider border-l border-gray-700/60">
|
||||
With Focus
|
||||
</th>
|
||||
<th colspan="1" class="bg-gray-800" />
|
||||
</tr>
|
||||
|
||||
<!-- Column row -->
|
||||
<tr class="bg-gray-800 border-b border-gray-700">
|
||||
<!-- Fixed left cols -->
|
||||
<th class="px-3 py-2 w-4" />
|
||||
<th
|
||||
v-for="col in columns"
|
||||
:key="col.field"
|
||||
class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider whitespace-nowrap select-none"
|
||||
v-for="col in leftCols" :key="col.field"
|
||||
class="px-4 py-2 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider whitespace-nowrap select-none"
|
||||
:class="col.sortable ? 'cursor-pointer hover:text-gray-200 transition-colors' : ''"
|
||||
@click="col.sortable && col.field !== 'status' ? $emit('sort', col.field as SortField) : undefined"
|
||||
@click="col.sortable ? $emit('sort', col.field as SortField) : undefined"
|
||||
>
|
||||
<span class="flex items-center gap-1">
|
||||
{{ col.label }}
|
||||
<span v-if="col.sortable && sortState.field === col.field" class="text-amber-400">
|
||||
{{ sortState.direction === 'asc' ? '↑' : '↓' }}
|
||||
</span>
|
||||
<span v-if="sortState.field === col.field" class="text-amber-400">{{ sortState.direction === 'asc' ? '↑' : '↓' }}</span>
|
||||
<span v-else class="text-gray-600">↕</span>
|
||||
</span>
|
||||
</th>
|
||||
|
||||
<!-- No-focus group -->
|
||||
<th
|
||||
v-for="col in priceCols" :key="'nf-' + col.field"
|
||||
class="px-4 py-2 text-right text-xs font-semibold text-gray-400 uppercase tracking-wider whitespace-nowrap select-none border-l border-gray-700/60"
|
||||
:class="[col.sortable ? 'cursor-pointer hover:text-gray-200 transition-colors' : '', col.first ? 'border-l border-gray-700/60' : '']"
|
||||
@click="col.sortable ? $emit('sort', col.field as SortField) : undefined"
|
||||
>
|
||||
<span class="flex items-center gap-1">
|
||||
{{ col.label }}
|
||||
<span v-if="col.sortable && sortState.field === col.field" class="text-amber-400">{{ sortState.direction === 'asc' ? '↑' : '↓' }}</span>
|
||||
<span v-else-if="col.sortable" class="text-gray-600">↕</span>
|
||||
</span>
|
||||
</th>
|
||||
<th class="px-3 py-2 w-8" />
|
||||
|
||||
<!-- With-focus group -->
|
||||
<th
|
||||
v-for="col in priceCols" :key="'f-' + col.field"
|
||||
class="px-4 py-2 text-right text-xs font-semibold text-violet-400/70 uppercase tracking-wider whitespace-nowrap select-none border-l border-gray-700/60"
|
||||
:class="col.first ? 'border-l border-gray-700/60' : ''"
|
||||
>
|
||||
{{ col.label }}
|
||||
</th>
|
||||
<th class="px-3 py-2 w-8" />
|
||||
|
||||
<!-- Status -->
|
||||
<th class="px-4 py-2 text-center text-xs font-semibold text-gray-400 uppercase tracking-wider whitespace-nowrap select-none">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</template>
|
||||
@@ -23,23 +65,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { SortField, SortState } from '../../types/crafting'
|
||||
|
||||
const columns: { field: SortField | 'status' | 'bill' | 'markup15' | 'markup30' | 'expand'; label: string; sortable: boolean }[] = [
|
||||
{ field: 'expand', label: '', sortable: false },
|
||||
{ field: 'variantType', label: 'Variant', sortable: true },
|
||||
{ field: 'displayName', label: 'Item', sortable: true },
|
||||
{ field: 'tier', label: 'Tier', sortable: true },
|
||||
{ field: 'materialCost', label: 'Cost', sortable: true },
|
||||
{ field: 'markup15', label: '+15%', sortable: false },
|
||||
{ field: 'markup30', label: '+30%', sortable: false },
|
||||
{ field: 'status', label: 'Price Age', sortable: false },
|
||||
{ field: 'bill', label: '', sortable: false },
|
||||
const leftCols = [
|
||||
{ field: 'variantType' as SortField, label: 'Variant', sortable: true },
|
||||
{ field: 'displayName' as SortField, label: 'Item', sortable: true },
|
||||
{ field: 'tier' as SortField, label: 'Tier', sortable: true },
|
||||
]
|
||||
|
||||
defineProps<{
|
||||
sortState: SortState
|
||||
}>()
|
||||
const priceCols = [
|
||||
{ field: 'materialCost' as SortField, label: 'Cost', sortable: true, first: true },
|
||||
{ field: 'markup15' as const, label: '+15%', sortable: false, first: false },
|
||||
{ field: 'markup30' as const, label: '+30%', sortable: false, first: false },
|
||||
]
|
||||
|
||||
defineEmits<{
|
||||
sort: [field: SortField]
|
||||
}>()
|
||||
|
||||
defineProps<{ sortState: SortState }>()
|
||||
defineEmits<{ sort: [field: SortField] }>()
|
||||
</script>
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { CraftingRecipe, ProfitResult, IngredientBreakdown, SortState } fro
|
||||
import type { FilterState, VariantType } from '../types/filters'
|
||||
import { useAlbionPrices } from './useAlbionPrices'
|
||||
import { formatItemId } from '../utils/formatting'
|
||||
import { cityRrr, isRrrExempt } from '../data/cityBonuses'
|
||||
import { localProductionBonus, rrrFromBonus, isRrrExempt, FOCUS_LPB } from '../data/cityBonuses'
|
||||
|
||||
function variantOf(outputItemId: string): VariantType {
|
||||
const id = outputItemId.replace(/@\d$/, '') // strip enchantment suffix
|
||||
@@ -84,15 +84,20 @@ export function useCraftingProfit(
|
||||
}
|
||||
|
||||
const materialCost = basicCost + artefactCost
|
||||
const rrr = cityRrr(city, recipe.outputItemId)
|
||||
const lpb = localProductionBonus(city, recipe.outputItemId)
|
||||
const rrr = rrrFromBonus(lpb)
|
||||
const rrrFocus = rrrFromBonus(lpb + FOCUS_LPB)
|
||||
const effectiveMaterialCost = basicCost * (1 - rrr / 100) + artefactCost
|
||||
const effectiveMaterialCostFocus = basicCost * (1 - rrrFocus / 100) + artefactCost
|
||||
const priceAgeMs = missingPrices ? null : (oldestDate ? Date.now() - new Date(oldestDate).getTime() : null)
|
||||
|
||||
results.push({
|
||||
recipe,
|
||||
materialCost,
|
||||
effectiveMaterialCost,
|
||||
effectiveMaterialCostFocus,
|
||||
rrr,
|
||||
rrrFocus,
|
||||
priceAgeMs,
|
||||
missingPrices,
|
||||
ingredientBreakdown,
|
||||
|
||||
@@ -5,12 +5,17 @@ import { ALL_RECIPES } from '../data/recipes'
|
||||
export interface OrderItem {
|
||||
recipe: CraftingRecipe
|
||||
qty: number
|
||||
focus: boolean
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'albion-production-order'
|
||||
|
||||
const recipeIndex = new Map(ALL_RECIPES.map(r => [r.outputItemId, r]))
|
||||
|
||||
function orderKey(outputItemId: string, focus: boolean): string {
|
||||
return `${outputItemId}|${focus ? 'f' : 'b'}`
|
||||
}
|
||||
|
||||
function load(): Map<string, OrderItem> {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
@@ -19,7 +24,10 @@ function load(): Map<string, OrderItem> {
|
||||
const result: [string, OrderItem][] = []
|
||||
for (const i of items) {
|
||||
const recipe = recipeIndex.get(i.recipe.outputItemId)
|
||||
if (recipe) result.push([recipe.outputItemId, { recipe, qty: i.qty }])
|
||||
if (recipe) {
|
||||
const focus = i.focus ?? false
|
||||
result.push([orderKey(recipe.outputItemId, focus), { recipe, qty: i.qty, focus }])
|
||||
}
|
||||
}
|
||||
return new Map(result)
|
||||
}
|
||||
@@ -39,17 +47,18 @@ const order = ref<Map<string, OrderItem>>(load())
|
||||
|
||||
// ─── Mutations ────────────────────────────────────────────────────────────────
|
||||
|
||||
function upsert(recipe: CraftingRecipe, qty: number): void {
|
||||
if (qty <= 0) { remove(recipe.outputItemId); return }
|
||||
function upsert(recipe: CraftingRecipe, qty: number, focus: boolean): void {
|
||||
const key = orderKey(recipe.outputItemId, focus)
|
||||
if (qty <= 0) { remove(recipe.outputItemId, focus); return }
|
||||
const next = new Map(order.value)
|
||||
next.set(recipe.outputItemId, { recipe, qty })
|
||||
next.set(key, { recipe, qty, focus })
|
||||
order.value = next
|
||||
save(next)
|
||||
}
|
||||
|
||||
function remove(outputItemId: string): void {
|
||||
function remove(outputItemId: string, focus: boolean): void {
|
||||
const next = new Map(order.value)
|
||||
next.delete(outputItemId)
|
||||
next.delete(orderKey(outputItemId, focus))
|
||||
order.value = next
|
||||
save(next)
|
||||
}
|
||||
@@ -64,12 +73,12 @@ function clear(): void {
|
||||
const orderItems = computed(() => [...order.value.values()])
|
||||
const orderCount = computed(() => order.value.size)
|
||||
|
||||
function getQty(outputItemId: string): number {
|
||||
return order.value.get(outputItemId)?.qty ?? 0
|
||||
function getQty(outputItemId: string, focus: boolean): number {
|
||||
return order.value.get(orderKey(outputItemId, focus))?.qty ?? 0
|
||||
}
|
||||
|
||||
function inOrder(outputItemId: string): boolean {
|
||||
return order.value.has(outputItemId)
|
||||
function inOrder(outputItemId: string, focus: boolean): boolean {
|
||||
return order.value.has(orderKey(outputItemId, focus))
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { AlbionCity } from '../types/api'
|
||||
|
||||
const BASE_ROYAL_LPB = 18
|
||||
const SPECIALIZATION_BONUS = 15
|
||||
export const FOCUS_LPB = 59 // flat bonus added when using focus
|
||||
|
||||
const ROYAL_CITIES = new Set<AlbionCity>([
|
||||
'Fort Sterling', 'Lymhurst', 'Bridgewatch', 'Martlock', 'Thetford', 'Caerleon',
|
||||
|
||||
@@ -5,17 +5,7 @@
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h2 class="text-lg font-semibold text-gray-200">Price Editor</h2>
|
||||
<div class="h-5 w-px bg-gray-600" />
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-xs text-gray-400">City</span>
|
||||
<select
|
||||
class="bg-gray-900 border border-gray-700 rounded-lg px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-amber-500 cursor-pointer"
|
||||
:value="filters.city"
|
||||
@change="setCity(($event.target as HTMLSelectElement).value as AlbionCity)"
|
||||
>
|
||||
<option v-for="c in CITIES" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 ml-2">
|
||||
<span class="text-xs text-gray-500">
|
||||
Amber = manual override · Click any price to edit · Empty field to clear override
|
||||
</span>
|
||||
</div>
|
||||
@@ -80,11 +70,10 @@ import { ref, computed } from 'vue'
|
||||
import { tierStyle, itemImageUrl } from '../utils/formatting'
|
||||
import PriceCell from '../components/PriceCell.vue'
|
||||
import { useFilters } from '../composables/useFilters'
|
||||
import { CITIES, ENCHANTMENTS } from '../data/constants'
|
||||
import type { AlbionCity, } from '../types/api'
|
||||
import { ENCHANTMENTS } from '../data/constants'
|
||||
import type { Enchantment } from '../types/crafting'
|
||||
|
||||
const { filters, setCity } = useFilters()
|
||||
const { filters } = useFilters()
|
||||
|
||||
function enchantedId(baseId: string, enchant: Enchantment): string {
|
||||
return enchant === 0 ? baseId : `${baseId}_LEVEL${enchant}@${enchant}`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -43,8 +43,10 @@ export interface IngredientBreakdown {
|
||||
export interface ProfitResult {
|
||||
recipe: CraftingRecipe
|
||||
materialCost: number
|
||||
effectiveMaterialCost: number
|
||||
rrr: number // effective RRR % for this item in the selected city
|
||||
effectiveMaterialCost: number // without focus
|
||||
effectiveMaterialCostFocus: number // with focus
|
||||
rrr: number // RRR % without focus
|
||||
rrrFocus: number // RRR % with focus
|
||||
priceAgeMs: number | null // null = missing prices; ms since oldest manual entry
|
||||
missingPrices: boolean
|
||||
ingredientBreakdown: IngredientBreakdown[]
|
||||
|
||||
Reference in New Issue
Block a user