Compare commits
10 Commits
e1860ef5f9
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 98f0a68110 | |||
| 04b9793773 | |||
| b85f813bed | |||
| 010a92999a | |||
| f910d07b48 | |||
| c069060d8b | |||
| d8386dccf4 | |||
| 4214eeb659 | |||
| f1deb33360 | |||
| 4974d99a7a |
@@ -18,9 +18,10 @@
|
|||||||
<!-- Calculator page -->
|
<!-- Calculator page -->
|
||||||
<div v-if="currentPage === 'calculator'" class="p-6 space-y-4">
|
<div v-if="currentPage === 'calculator'" class="p-6 space-y-4">
|
||||||
<FilterBar :filters="filters" :result-count="profitResults.length" @set-name-filter="setNameFilter"
|
<FilterBar :filters="filters" :result-count="profitResults.length" @set-name-filter="setNameFilter"
|
||||||
@set-city="setCity" @toggle-tier="toggleTier" @set-selected-item-types="setSelectedItemTypes"
|
@toggle-tier="toggleTier" @set-selected-item-types="setSelectedItemTypes"
|
||||||
@toggle-enchantment="toggleEnchantment" @reset-enchantments="resetEnchantments"
|
@toggle-enchantment="toggleEnchantment" @reset-enchantments="resetEnchantments"
|
||||||
@toggle-variant="toggleVariant" @reset-variants="resetVariants" @set-rrr="setRrr" />
|
@toggle-variant="toggleVariant" @reset-variants="resetVariants"
|
||||||
|
@toggle-station="toggleStation" @reset-stations="resetStations" />
|
||||||
|
|
||||||
<ProfitTable :results="profitResults" :sort-state="sortState" @sort="handleSort" />
|
<ProfitTable :results="profitResults" :sort-state="sortState" @sort="handleSort" />
|
||||||
</div>
|
</div>
|
||||||
@@ -66,15 +67,15 @@ const sortState = ref<SortState>({ field: 'materialCost', direction: 'asc' })
|
|||||||
// Filters
|
// Filters
|
||||||
const {
|
const {
|
||||||
filters,
|
filters,
|
||||||
setCity,
|
|
||||||
toggleTier,
|
toggleTier,
|
||||||
setSelectedItemTypes,
|
setSelectedItemTypes,
|
||||||
setRrr,
|
|
||||||
setNameFilter,
|
setNameFilter,
|
||||||
toggleEnchantment,
|
toggleEnchantment,
|
||||||
resetEnchantments,
|
resetEnchantments,
|
||||||
toggleVariant,
|
toggleVariant,
|
||||||
resetVariants,
|
resetVariants,
|
||||||
|
toggleStation,
|
||||||
|
resetStations,
|
||||||
} = useFilters()
|
} = useFilters()
|
||||||
|
|
||||||
// Profit calculation
|
// Profit calculation
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const vFocus = { mounted: (el: HTMLElement) => el.focus() }
|
|||||||
|
|
||||||
const props = defineProps<{ itemId: string }>()
|
const props = defineProps<{ itemId: string }>()
|
||||||
|
|
||||||
const { getPrice, isManualPrice, getManualEntry, setManualPrice, clearManualPrice } = useAlbionPrices()
|
const { getPrice, getManualEntry, setManualPrice, clearManualPrice } = useAlbionPrices()
|
||||||
const { filters } = useFilters()
|
const { filters } = useFilters()
|
||||||
|
|
||||||
const editing = ref(false)
|
const editing = ref(false)
|
||||||
@@ -46,25 +46,22 @@ const city = computed(() => filters.value.city)
|
|||||||
|
|
||||||
const entry = computed(() => getPrice(props.itemId, city.value))
|
const entry = computed(() => getPrice(props.itemId, city.value))
|
||||||
const currentPrice = computed(() => entry.value?.sell_price_min ?? 0)
|
const currentPrice = computed(() => entry.value?.sell_price_min ?? 0)
|
||||||
const isManual = computed(() => isManualPrice(props.itemId, city.value))
|
|
||||||
|
|
||||||
const displayText = computed(() =>
|
const displayText = computed(() =>
|
||||||
currentPrice.value > 0 ? formatSilver(currentPrice.value) : '—'
|
currentPrice.value > 0 ? formatSilver(currentPrice.value) : '—'
|
||||||
)
|
)
|
||||||
|
|
||||||
const cellClass = computed(() => {
|
const cellClass = computed(() =>
|
||||||
if (isManual.value) return 'text-amber-400 hover:text-amber-200'
|
currentPrice.value > 0 ? 'text-amber-400 hover:text-amber-200' : 'text-gray-600 hover:text-amber-400'
|
||||||
if (currentPrice.value === 0) return 'text-gray-600 hover:text-amber-400'
|
)
|
||||||
return 'text-gray-300 hover:text-gray-100'
|
|
||||||
})
|
|
||||||
|
|
||||||
const tooltip = computed(() => {
|
const tooltip = computed(() => {
|
||||||
const exact = currentPrice.value > 0 ? currentPrice.value.toLocaleString() : null
|
|
||||||
const e = getManualEntry(props.itemId, city.value)
|
const e = getManualEntry(props.itemId, city.value)
|
||||||
if (e && isManual.value) {
|
if (e) {
|
||||||
return exact ? `${exact} — set ${formatLastUpdated(new Date(e.editedAt))} — click to edit` : `Set ${formatLastUpdated(new Date(e.editedAt))} — click to edit`
|
const exact = currentPrice.value.toLocaleString()
|
||||||
|
return `${exact} — set ${formatLastUpdated(new Date(e.editedAt))} — click to edit`
|
||||||
}
|
}
|
||||||
return exact ? `${exact} — click to edit` : 'Click to set price'
|
return 'Click to set price'
|
||||||
})
|
})
|
||||||
|
|
||||||
function startEdit(initial?: number) {
|
function startEdit(initial?: number) {
|
||||||
@@ -73,7 +70,7 @@ function startEdit(initial?: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function save() {
|
function save() {
|
||||||
const v = Math.round(Number(inputValue.value))
|
const v = Math.ceil(Number(inputValue.value))
|
||||||
if (!inputValue.value && inputValue.value !== 0) {
|
if (!inputValue.value && inputValue.value !== 0) {
|
||||||
clearManualPrice(props.itemId, city.value)
|
clearManualPrice(props.itemId, city.value)
|
||||||
} else if (v > 0) {
|
} else if (v > 0) {
|
||||||
|
|||||||
@@ -131,29 +131,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- City select -->
|
<!-- Station toggles -->
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<span class="text-xs text-gray-400">City</span>
|
<span class="text-xs text-gray-400">Station</span>
|
||||||
<select
|
<div class="flex gap-0.5">
|
||||||
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"
|
<button class="px-2 py-1 rounded text-xs font-semibold transition-colors" :class="filters.stations === null
|
||||||
:value="filters.city" @change="$emit('set-city', ($event.target as HTMLSelectElement).value as AlbionCity)">
|
? 'bg-amber-600 text-amber-100'
|
||||||
<option v-for="c in CITIES" :key="c" :value="c">{{ c }}</option>
|
: 'bg-gray-800 text-gray-500 hover:bg-gray-700'" @click="$emit('reset-stations')">All</button>
|
||||||
</select>
|
<button v-for="s in ALL_STATIONS" :key="s"
|
||||||
|
class="px-2 py-1 rounded text-xs font-semibold transition-colors"
|
||||||
|
:class="isStationActive(s) ? STATION_ACTIVE_CLASS[s] : 'bg-gray-800 text-gray-600 hover:bg-gray-700'"
|
||||||
|
@click="$emit('toggle-station', s)">{{ STATION_LABEL[s] }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="h-5 w-px bg-gray-600" />
|
|
||||||
|
|
||||||
<!-- RRR -->
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<span class="text-xs text-gray-400 whitespace-nowrap">RRR</span>
|
|
||||||
<input type="number"
|
|
||||||
class="bg-gray-900 border border-gray-700 rounded-lg px-2 py-1.5 text-sm text-gray-200 w-16 focus:outline-none focus:border-amber-500 font-mono"
|
|
||||||
:value="filters.rrr" @input="$emit('set-rrr', Number(($event.target as HTMLInputElement).value))" min="0"
|
|
||||||
max="100" step="0.5" />
|
|
||||||
<span class="text-xs text-gray-500">%</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- Result count -->
|
<!-- Result count -->
|
||||||
<span class="ml-auto text-xs text-gray-500 whitespace-nowrap">{{ resultCount }} results</span>
|
<span class="ml-auto text-xs text-gray-500 whitespace-nowrap">{{ resultCount }} results</span>
|
||||||
|
|
||||||
@@ -165,10 +156,9 @@
|
|||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { tierStyle, enchantStyle } from '../../utils/formatting'
|
import { tierStyle, enchantStyle } from '../../utils/formatting'
|
||||||
import type { FilterState, VariantType } from '../../types/filters'
|
import type { FilterState, VariantType } from '../../types/filters'
|
||||||
import { ALL_VARIANTS } from '../../types/filters'
|
import { ALL_VARIANTS, ALL_STATIONS } from '../../types/filters'
|
||||||
import type { AlbionCity } from '../../types/api'
|
import type { Tier, Enchantment, JournalType } from '../../types/crafting'
|
||||||
import type { Tier, Enchantment } from '../../types/crafting'
|
import { TIERS, ENCHANTMENTS } from '../../data/constants'
|
||||||
import { TIERS, CITIES, ENCHANTMENTS } from '../../data/constants'
|
|
||||||
import { ITEM_TREE, ALL_ITEM_NAMES, getLeaves } from '../../data/itemTree'
|
import { ITEM_TREE, ALL_ITEM_NAMES, getLeaves } from '../../data/itemTree'
|
||||||
import type { TreeNode } from '../../data/itemTree'
|
import type { TreeNode } from '../../data/itemTree'
|
||||||
|
|
||||||
@@ -179,14 +169,14 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'set-name-filter': [value: string]
|
'set-name-filter': [value: string]
|
||||||
'set-city': [city: AlbionCity]
|
|
||||||
'toggle-tier': [tier: Tier]
|
'toggle-tier': [tier: Tier]
|
||||||
'set-selected-item-types': [v: Set<string> | null]
|
'set-selected-item-types': [v: Set<string> | null]
|
||||||
'toggle-enchantment': [enc: Enchantment]
|
'toggle-enchantment': [enc: Enchantment]
|
||||||
'reset-enchantments': []
|
'reset-enchantments': []
|
||||||
'toggle-variant': [v: VariantType]
|
'toggle-variant': [v: VariantType]
|
||||||
'reset-variants': []
|
'reset-variants': []
|
||||||
'set-rrr': [rate: number]
|
'toggle-station': [s: JournalType]
|
||||||
|
'reset-stations': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// ─── Category tree ────────────────────────────────────────────────────────────
|
// ─── Category tree ────────────────────────────────────────────────────────────
|
||||||
@@ -313,6 +303,23 @@ function isVariantActive(v: VariantType): boolean {
|
|||||||
return s === null || s.has(v)
|
return s === null || s.has(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Station helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const STATION_LABEL: Record<JournalType, string> = {
|
||||||
|
warrior: 'Warrior', hunter: 'Hunter', mage: 'Mage', toolmaker: 'Toolmaker',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATION_ACTIVE_CLASS: Record<JournalType, string> = {
|
||||||
|
warrior: 'bg-red-900/60 text-red-300',
|
||||||
|
hunter: 'bg-green-900/60 text-green-300',
|
||||||
|
mage: 'bg-blue-900/60 text-blue-300',
|
||||||
|
toolmaker: 'bg-orange-900/60 text-orange-300',
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStationActive(s: JournalType): boolean {
|
||||||
|
return props.filters.stations === null || props.filters.stations.has(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ─── Dropdown open states ─────────────────────────────────────────────────────
|
// ─── Dropdown open states ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
<template>
|
|
||||||
<aside class="w-64 flex-shrink-0">
|
|
||||||
<div class="sticky top-20 bg-gray-800 rounded-xl border border-gray-700 p-4 space-y-5">
|
|
||||||
<h2 class="text-sm font-semibold text-gray-300 uppercase tracking-wider">Filters</h2>
|
|
||||||
|
|
||||||
<CityFilter :city="filters.city" @change="$emit('set-city', $event)" />
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-gray-400 mb-1">Quality</label>
|
|
||||||
<select
|
|
||||||
class="w-full bg-gray-900 border border-gray-600 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-amber-500"
|
|
||||||
:value="filters.quality"
|
|
||||||
@change="$emit('set-quality', Number(($event.target as HTMLSelectElement).value) as AlbionQuality)"
|
|
||||||
>
|
|
||||||
<option v-for="q in QUALITIES" :key="q.value" :value="q.value">{{ q.label }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CategoryFilter :categories="filters.categories" @toggle="$emit('toggle-category', $event)" />
|
|
||||||
|
|
||||||
<TierFilter :tiers="filters.tiers" @toggle="$emit('toggle-tier', $event)" />
|
|
||||||
|
|
||||||
<TaxInput :tax-rate="filters.taxRate" @change="$emit('set-tax-rate', $event)" />
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-gray-400 mb-1">Min Profit (silver)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
class="w-full bg-gray-900 border border-gray-600 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-amber-500"
|
|
||||||
:value="filters.minProfit"
|
|
||||||
@input="$emit('set-min-profit', Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="0"
|
|
||||||
step="1000"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer select-none">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="w-4 h-4 rounded accent-amber-500"
|
|
||||||
:checked="filters.hideStale"
|
|
||||||
@change="$emit('set-hide-stale', ($event.target as HTMLInputElement).checked)"
|
|
||||||
/>
|
|
||||||
<span class="text-xs text-gray-300">Hide stale prices</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer select-none">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="w-4 h-4 rounded accent-amber-500"
|
|
||||||
:checked="filters.hideMissing"
|
|
||||||
@change="$emit('set-hide-missing', ($event.target as HTMLInputElement).checked)"
|
|
||||||
/>
|
|
||||||
<span class="text-xs text-gray-300">Hide missing prices</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pt-1 border-t border-gray-700 text-xs text-gray-500">
|
|
||||||
{{ resultCount }} results shown
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import CityFilter from './CityFilter.vue'
|
|
||||||
import CategoryFilter from './CategoryFilter.vue'
|
|
||||||
import TierFilter from './TierFilter.vue'
|
|
||||||
import TaxInput from './TaxInput.vue'
|
|
||||||
import type { FilterState } from '../../types/filters'
|
|
||||||
import type { AlbionCity, AlbionQuality } from '../../types/api'
|
|
||||||
import type { Tier, ItemCategory } from '../../types/crafting'
|
|
||||||
import { QUALITIES } from '../../data/constants'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
filters: FilterState
|
|
||||||
resultCount: number
|
|
||||||
}>()
|
|
||||||
|
|
||||||
defineEmits<{
|
|
||||||
'set-city': [city: AlbionCity]
|
|
||||||
'set-quality': [quality: AlbionQuality]
|
|
||||||
'toggle-tier': [tier: Tier]
|
|
||||||
'toggle-category': [category: ItemCategory]
|
|
||||||
'set-tax-rate': [rate: number]
|
|
||||||
'set-min-profit': [value: number]
|
|
||||||
'set-hide-stale': [value: boolean]
|
|
||||||
'set-hide-missing': [value: boolean]
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
@@ -29,6 +29,8 @@ defineEmits<{
|
|||||||
|
|
||||||
function tierActiveClass(tier: Tier): string {
|
function tierActiveClass(tier: Tier): string {
|
||||||
const classes: Record<Tier, string> = {
|
const classes: Record<Tier, string> = {
|
||||||
|
2: 'bg-stone-600 border-stone-500 text-white',
|
||||||
|
3: 'bg-stone-500 border-stone-400 text-white',
|
||||||
4: 'bg-blue-600 border-blue-500 text-white',
|
4: 'bg-blue-600 border-blue-500 text-white',
|
||||||
5: 'bg-green-600 border-green-500 text-white',
|
5: 'bg-green-600 border-green-500 text-white',
|
||||||
6: 'bg-yellow-600 border-yellow-500 text-white',
|
6: 'bg-yellow-600 border-yellow-500 text-white',
|
||||||
|
|||||||
@@ -9,12 +9,28 @@
|
|||||||
{{ totalRecipes }} recipes
|
{{ totalRecipes }} recipes
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- City selector -->
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="text-xs text-gray-400">City</span>
|
||||||
|
<select
|
||||||
|
class="bg-gray-800 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>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
import { useFilters } from '../../composables/useFilters'
|
||||||
totalRecipes: number
|
import { CITIES } from '../../data/constants'
|
||||||
}>()
|
import type { AlbionCity } from '../../types/api'
|
||||||
|
|
||||||
|
defineProps<{ totalRecipes: number }>()
|
||||||
|
|
||||||
|
const { filters, setCity } = useFilters()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -25,11 +25,18 @@
|
|||||||
:alt="result.recipe.displayName"
|
:alt="result.recipe.displayName"
|
||||||
class="w-12 h-12 -my-2 rounded flex-shrink-0"
|
class="w-12 h-12 -my-2 rounded flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm font-medium text-gray-200">{{ result.recipe.displayName }}</span>
|
<span class="text-sm font-medium text-gray-200">{{ result.recipe.displayName.replace(/^T\d+\.\d+\s/, '') }}</span>
|
||||||
<span class="text-xs text-gray-500">{{ result.recipe.category }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
<!-- Station badge -->
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span class="inline-flex items-center justify-center px-2 h-6 rounded text-xs font-semibold whitespace-nowrap"
|
||||||
|
:class="STATION[result.recipe.journalType].cls">
|
||||||
|
{{ STATION[result.recipe.journalType].label }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
<!-- Tier badge -->
|
<!-- Tier badge -->
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<span
|
<span
|
||||||
@@ -41,36 +48,26 @@
|
|||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Effective material cost (after RRR) -->
|
<!-- 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">
|
:title="result.effectiveMaterialCost > 0 ? result.effectiveMaterialCost.toLocaleString() : undefined">
|
||||||
{{ formatSilver(result.effectiveMaterialCost) }}
|
{{ formatSilver(result.effectiveMaterialCost) }}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- +15% markup -->
|
<!-- +15% markup (no focus) -->
|
||||||
<td class="px-4 py-3 text-sm font-mono text-blue-300"
|
<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">
|
:title="result.effectiveMaterialCost > 0 ? Math.round(result.effectiveMaterialCost * 1.15).toLocaleString() : undefined">
|
||||||
{{ result.effectiveMaterialCost > 0 ? formatSilver(Math.round(result.effectiveMaterialCost * 1.15)) : '—' }}
|
{{ result.effectiveMaterialCost > 0 ? formatSilver(Math.round(result.effectiveMaterialCost * 1.15)) : '—' }}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- +30% markup -->
|
<!-- +30% markup (no focus) -->
|
||||||
<td class="px-4 py-3 text-sm font-mono text-emerald-300"
|
<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">
|
:title="result.effectiveMaterialCost > 0 ? Math.round(result.effectiveMaterialCost * 1.30).toLocaleString() : undefined">
|
||||||
{{ result.effectiveMaterialCost > 0 ? formatSilver(Math.round(result.effectiveMaterialCost * 1.30)) : '—' }}
|
{{ result.effectiveMaterialCost > 0 ? formatSilver(Math.round(result.effectiveMaterialCost * 1.30)) : '—' }}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Price age -->
|
<!-- Add to bill (no focus) -->
|
||||||
<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 -->
|
|
||||||
<td class="px-3 py-3 text-right whitespace-nowrap" @click.stop>
|
<td class="px-3 py-3 text-right whitespace-nowrap" @click.stop>
|
||||||
<!-- Inline qty form -->
|
<div v-if="addingMode === 'nofocus'" class="inline-flex items-center gap-1">
|
||||||
<div v-if="addingToBill" class="inline-flex items-center gap-1">
|
|
||||||
<input
|
<input
|
||||||
v-focus
|
v-focus
|
||||||
type="number"
|
type="number"
|
||||||
@@ -83,31 +80,90 @@
|
|||||||
<button class="text-amber-500 hover:text-amber-300 text-xs leading-none" @click="confirmBill">✓</button>
|
<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>
|
<button class="text-gray-500 hover:text-gray-300 text-xs leading-none" @click="cancelBill">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- In-order badge + remove -->
|
<div v-else-if="isInOrderNoFocus" class="inline-flex items-center gap-1">
|
||||||
<div v-else-if="isInOrder" class="inline-flex items-center gap-1">
|
|
||||||
<button
|
<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"
|
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"
|
@click="startBill('nofocus')"
|
||||||
>×{{ currentQty }}</button>
|
>×{{ currentQtyNoFocus }}</button>
|
||||||
<button
|
<button
|
||||||
class="text-gray-600 hover:text-red-400 transition-colors text-xs leading-none"
|
class="text-gray-600 hover:text-red-400 transition-colors text-xs leading-none"
|
||||||
title="Remove from order"
|
title="Remove from order"
|
||||||
@click="remove(result.recipe.outputItemId)"
|
@click="remove(result.recipe.outputItemId, false)"
|
||||||
>✕</button>
|
>✕</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Add button -->
|
|
||||||
<button
|
<button
|
||||||
v-else
|
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"
|
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"
|
title="Add to Bill of Production (no focus)"
|
||||||
@click="startBill"
|
@click="startBill('nofocus')"
|
||||||
>+</button>
|
>+</button>
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
|
|
||||||
<!-- Expanded detail row -->
|
<!-- Expanded detail row -->
|
||||||
<tr v-if="expanded" class="border-b border-gray-700/50 bg-gray-900/60">
|
<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="14" class="px-6 py-4">
|
||||||
<div class="flex gap-8">
|
<div class="flex gap-8">
|
||||||
|
|
||||||
<!-- Ingredients breakdown -->
|
<!-- Ingredients breakdown -->
|
||||||
@@ -184,13 +240,13 @@
|
|||||||
<span class="text-gray-400">Raw Materials</span>
|
<span class="text-gray-400">Raw Materials</span>
|
||||||
<span class="text-gray-200">{{ formatSilver(result.materialCost) }}</span>
|
<span class="text-gray-200">{{ formatSilver(result.materialCost) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between gap-4">
|
|
||||||
<span class="text-gray-400">RRR ({{ filters.rrr }}%)</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">
|
<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="text-gray-400">RRR ({{ result.rrr.toFixed(1) }}%)</span>
|
||||||
<span class="font-semibold text-gray-100">{{ formatSilver(result.effectiveMaterialCost) }}</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,7 +258,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type { ProfitResult, Tier } from '../../types/crafting'
|
import type { ProfitResult, JournalType } from '../../types/crafting'
|
||||||
import { formatSilver, formatLastUpdated, tierEnchantStyle, itemImageUrl } from '../../utils/formatting'
|
import { formatSilver, formatLastUpdated, tierEnchantStyle, itemImageUrl } from '../../utils/formatting'
|
||||||
import { useAlbionPrices } from '../../composables/useAlbionPrices'
|
import { useAlbionPrices } from '../../composables/useAlbionPrices'
|
||||||
import { useFilters } from '../../composables/useFilters'
|
import { useFilters } from '../../composables/useFilters'
|
||||||
@@ -214,11 +270,18 @@ const props = defineProps<{
|
|||||||
result: ProfitResult
|
result: ProfitResult
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { isManualPrice, getManualEntry, setManualPrice, clearManualPrice } = useAlbionPrices()
|
const { getManualEntry, setManualPrice, clearManualPrice } = useAlbionPrices()
|
||||||
const { filters } = useFilters()
|
const { filters } = useFilters()
|
||||||
const { upsert, remove, getQty, inOrder } = useProductionOrder()
|
const { upsert, remove, getQty, inOrder } = useProductionOrder()
|
||||||
|
|
||||||
const VARIANT_INFO: Record<string, { label: string; cls: string }> = {
|
const STATION: Record<JournalType, { label: string; cls: string }> = {
|
||||||
|
warrior: { label: 'Warrior', cls: 'bg-red-900/30 border border-red-700/40 text-red-400' },
|
||||||
|
hunter: { label: 'Hunter', cls: 'bg-green-900/30 border border-green-700/40 text-green-400' },
|
||||||
|
mage: { label: 'Mage', cls: 'bg-blue-900/30 border border-blue-700/40 text-blue-400' },
|
||||||
|
toolmaker: { label: 'Toolmaker', cls: 'bg-orange-900/30 border border-orange-700/40 text-orange-400' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const VARIANT_INFO = {
|
||||||
avalon: { label: 'Avalon', cls: 'bg-violet-600/30 text-violet-300 border border-violet-600/40' },
|
avalon: { label: 'Avalon', cls: 'bg-violet-600/30 text-violet-300 border border-violet-600/40' },
|
||||||
crystal: { label: 'Crystal', cls: 'bg-cyan-600/30 text-cyan-300 border border-cyan-600/40' },
|
crystal: { label: 'Crystal', cls: 'bg-cyan-600/30 text-cyan-300 border border-cyan-600/40' },
|
||||||
artifact: { label: 'Artifact', cls: 'bg-amber-600/30 text-amber-300 border border-amber-600/40' },
|
artifact: { label: 'Artifact', cls: 'bg-amber-600/30 text-amber-300 border border-amber-600/40' },
|
||||||
@@ -241,24 +304,30 @@ const inputValue = ref('')
|
|||||||
|
|
||||||
// ─── Bill of Production ───────────────────────────────────────────────────────
|
// ─── Bill of Production ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const addingToBill = ref(false)
|
const addingMode = ref<null | 'nofocus' | 'focus'>(null)
|
||||||
const billQty = ref(1)
|
const billQty = ref(1)
|
||||||
|
|
||||||
const isInOrder = computed(() => inOrder(props.result.recipe.outputItemId))
|
const isInOrderNoFocus = computed(() => inOrder(props.result.recipe.outputItemId, false))
|
||||||
const currentQty = computed(() => getQty(props.result.recipe.outputItemId))
|
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() {
|
function startBill(mode: 'nofocus' | 'focus') {
|
||||||
billQty.value = isInOrder.value ? currentQty.value : 1
|
const already = mode === 'nofocus' ? isInOrderNoFocus.value : isInOrderFocus.value
|
||||||
addingToBill.value = true
|
const qty = mode === 'nofocus' ? currentQtyNoFocus.value : currentQtyFocus.value
|
||||||
|
billQty.value = already ? qty : 1
|
||||||
|
addingMode.value = mode
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmBill() {
|
function confirmBill() {
|
||||||
if (billQty.value > 0) upsert(props.result.recipe, billQty.value)
|
if (billQty.value > 0 && addingMode.value) {
|
||||||
addingToBill.value = false
|
upsert(props.result.recipe, billQty.value, addingMode.value === 'focus')
|
||||||
|
}
|
||||||
|
addingMode.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelBill() {
|
function cancelBill() {
|
||||||
addingToBill.value = false
|
addingMode.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Price age display ────────────────────────────────────────────────────────
|
// ─── Price age display ────────────────────────────────────────────────────────
|
||||||
@@ -290,23 +359,16 @@ function ageDotTitle(result: ProfitResult): string {
|
|||||||
|
|
||||||
// ─── Price cell helpers ───────────────────────────────────────────────────────
|
// ─── Price cell helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
function priceButtonClass(itemId: string, currentPrice: number): string {
|
function priceButtonClass(_itemId: string, currentPrice: number): string {
|
||||||
if (isManualPrice(itemId, filters.value.city)) {
|
return currentPrice > 0 ? 'text-amber-400 hover:text-amber-200' : 'text-gray-500 hover:text-amber-400'
|
||||||
return 'text-amber-400 hover:text-amber-200'
|
|
||||||
}
|
|
||||||
if (currentPrice === 0) {
|
|
||||||
return 'text-gray-500 hover:text-amber-400'
|
|
||||||
}
|
|
||||||
return 'text-gray-300 hover:text-gray-100'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function priceTitle(itemId: string, currentPrice: number): string {
|
function priceTitle(itemId: string, currentPrice: number): string {
|
||||||
const exact = currentPrice > 0 ? currentPrice.toLocaleString() : null
|
|
||||||
const entry = getManualEntry(itemId, filters.value.city)
|
const entry = getManualEntry(itemId, filters.value.city)
|
||||||
if (entry && isManualPrice(itemId, filters.value.city)) {
|
if (entry) {
|
||||||
return exact ? `${exact} — set ${formatLastUpdated(new Date(entry.editedAt))} — click to edit` : `Set ${formatLastUpdated(new Date(entry.editedAt))} — click to edit`
|
return `${currentPrice.toLocaleString()} — set ${formatLastUpdated(new Date(entry.editedAt))} — click to edit`
|
||||||
}
|
}
|
||||||
return exact ? `${exact} — click to set price` : 'Click to set price'
|
return 'Click to set price'
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Edit state ───────────────────────────────────────────────────────────────
|
// ─── Edit state ───────────────────────────────────────────────────────────────
|
||||||
@@ -317,8 +379,8 @@ function startEdit(itemId: string, current: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function saveEdit(itemId: string) {
|
function saveEdit(itemId: string) {
|
||||||
const v = Math.round(Number(inputValue.value))
|
const v = Math.ceil(Number(inputValue.value))
|
||||||
if (!inputValue.value && inputValue.value !== 0) {
|
if (!inputValue.value) {
|
||||||
clearManualPrice(itemId, filters.value.city)
|
clearManualPrice(itemId, filters.value.city)
|
||||||
} else if (v > 0) {
|
} else if (v > 0) {
|
||||||
setManualPrice(itemId, filters.value.city, v)
|
setManualPrice(itemId, filters.value.city, v)
|
||||||
|
|||||||
@@ -1,21 +1,63 @@
|
|||||||
<template>
|
<template>
|
||||||
<thead class="sticky top-0 z-10">
|
<thead class="sticky top-0 z-10">
|
||||||
|
<!-- Group row -->
|
||||||
|
<tr class="bg-gray-800 border-b border-gray-700/50">
|
||||||
|
<th colspan="5" 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">
|
<tr class="bg-gray-800 border-b border-gray-700">
|
||||||
|
<!-- Fixed left cols -->
|
||||||
|
<th class="px-3 py-2 w-4" />
|
||||||
<th
|
<th
|
||||||
v-for="col in columns"
|
v-for="col in leftCols" :key="col.field"
|
||||||
: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="px-4 py-3 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' : ''"
|
: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">
|
<span class="flex items-center gap-1">
|
||||||
{{ col.label }}
|
{{ col.label }}
|
||||||
<span v-if="col.sortable && sortState.field === col.field" class="text-amber-400">
|
<span v-if="sortState.field === col.field" class="text-amber-400">{{ sortState.direction === 'asc' ? '↑' : '↓' }}</span>
|
||||||
{{ sortState.direction === 'asc' ? '↑' : '↓' }}
|
<span v-else class="text-gray-600">↕</span>
|
||||||
</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 v-else-if="col.sortable" class="text-gray-600">↕</span>
|
||||||
</span>
|
</span>
|
||||||
</th>
|
</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
</template>
|
</template>
|
||||||
@@ -23,23 +65,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { SortField, SortState } from '../../types/crafting'
|
import type { SortField, SortState } from '../../types/crafting'
|
||||||
|
|
||||||
const columns: { field: SortField | 'status' | 'bill' | 'markup15' | 'markup30' | 'expand'; label: string; sortable: boolean }[] = [
|
const leftCols = [
|
||||||
{ field: 'expand', label: '', sortable: false },
|
{ field: 'variantType' as SortField, label: 'Variant', sortable: true },
|
||||||
{ field: 'variantType', label: 'Variant', sortable: true },
|
{ field: 'displayName' as SortField, label: 'Item', sortable: true },
|
||||||
{ field: 'displayName', label: 'Item', sortable: true },
|
{ field: 'station' as SortField, label: 'Station', sortable: true },
|
||||||
{ field: 'tier', label: 'Tier', sortable: true },
|
{ field: 'tier' as SortField, 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 },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
defineProps<{
|
const priceCols = [
|
||||||
sortState: SortState
|
{ 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>
|
</script>
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import { ref, watch, onUnmounted } from 'vue'
|
|
||||||
import type { Ref } from 'vue'
|
|
||||||
import { AUTO_REFRESH_INTERVAL_S } from '../data/constants'
|
|
||||||
|
|
||||||
export function useAutoRefresh(fetchFn: () => Promise<void>, enabled: Ref<boolean>) {
|
|
||||||
const countdown = ref(AUTO_REFRESH_INTERVAL_S)
|
|
||||||
|
|
||||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
|
||||||
let tickId: ReturnType<typeof setInterval> | null = null
|
|
||||||
|
|
||||||
function startRefresh() {
|
|
||||||
stopRefresh()
|
|
||||||
countdown.value = AUTO_REFRESH_INTERVAL_S
|
|
||||||
|
|
||||||
// Countdown tick every second
|
|
||||||
tickId = setInterval(() => {
|
|
||||||
countdown.value = Math.max(0, countdown.value - 1)
|
|
||||||
}, 1000)
|
|
||||||
|
|
||||||
// Fetch every interval
|
|
||||||
intervalId = setInterval(async () => {
|
|
||||||
await fetchFn()
|
|
||||||
countdown.value = AUTO_REFRESH_INTERVAL_S
|
|
||||||
}, AUTO_REFRESH_INTERVAL_S * 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopRefresh() {
|
|
||||||
if (intervalId !== null) {
|
|
||||||
clearInterval(intervalId)
|
|
||||||
intervalId = null
|
|
||||||
}
|
|
||||||
if (tickId !== null) {
|
|
||||||
clearInterval(tickId)
|
|
||||||
tickId = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
enabled,
|
|
||||||
(val) => {
|
|
||||||
if (val) {
|
|
||||||
startRefresh()
|
|
||||||
} else {
|
|
||||||
stopRefresh()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
stopRefresh()
|
|
||||||
})
|
|
||||||
|
|
||||||
return { countdown }
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ import type { CraftingRecipe, ProfitResult, IngredientBreakdown, SortState } fro
|
|||||||
import type { FilterState, VariantType } from '../types/filters'
|
import type { FilterState, VariantType } from '../types/filters'
|
||||||
import { useAlbionPrices } from './useAlbionPrices'
|
import { useAlbionPrices } from './useAlbionPrices'
|
||||||
import { formatItemId } from '../utils/formatting'
|
import { formatItemId } from '../utils/formatting'
|
||||||
|
import { localProductionBonus, rrrFromBonus, isRrrExempt, FOCUS_LPB } from '../data/cityBonuses'
|
||||||
|
|
||||||
function variantOf(outputItemId: string): VariantType {
|
function variantOf(outputItemId: string): VariantType {
|
||||||
const id = outputItemId.replace(/@\d$/, '') // strip enchantment suffix
|
const id = outputItemId.replace(/@\d$/, '') // strip enchantment suffix
|
||||||
@@ -30,7 +31,6 @@ export function useCraftingProfit(
|
|||||||
const profitResults = computed<ProfitResult[]>(() => {
|
const profitResults = computed<ProfitResult[]>(() => {
|
||||||
const f = filters.value
|
const f = filters.value
|
||||||
const city = f.city
|
const city = f.city
|
||||||
const rrrFactor = 1 - f.rrr / 100
|
|
||||||
|
|
||||||
const results: ProfitResult[] = []
|
const results: ProfitResult[] = []
|
||||||
const nameLower = f.nameFilter.trim().toLowerCase()
|
const nameLower = f.nameFilter.trim().toLowerCase()
|
||||||
@@ -39,12 +39,14 @@ export function useCraftingProfit(
|
|||||||
if (!f.tiers.has(recipe.tier)) continue
|
if (!f.tiers.has(recipe.tier)) continue
|
||||||
if (f.enchantments !== null && !f.enchantments.has(recipe.enchantment)) continue
|
if (f.enchantments !== null && !f.enchantments.has(recipe.enchantment)) continue
|
||||||
if (f.variants !== null && !f.variants.has(variantOf(recipe.outputItemId))) continue
|
if (f.variants !== null && !f.variants.has(variantOf(recipe.outputItemId))) continue
|
||||||
|
if (f.stations !== null && !f.stations.has(recipe.journalType)) continue
|
||||||
const baseName = recipe.displayName.replace(/^T\d+\.\d /, '')
|
const baseName = recipe.displayName.replace(/^T\d+\.\d /, '')
|
||||||
if (f.selectedItemTypes !== null && !f.selectedItemTypes.has(baseName)) continue
|
if (f.selectedItemTypes !== null && !f.selectedItemTypes.has(baseName)) continue
|
||||||
if (nameLower && !recipe.displayName.toLowerCase().includes(nameLower)) continue
|
if (nameLower && !recipe.displayName.toLowerCase().includes(nameLower)) continue
|
||||||
|
|
||||||
// Calculate material cost from ingredients only
|
// Calculate material cost from ingredients only
|
||||||
let materialCost = 0
|
let basicCost = 0 // resources subject to RRR
|
||||||
|
let artefactCost = 0 // artefacts / ALCHEMY_RARE — not subject to RRR
|
||||||
let missingPrices = false
|
let missingPrices = false
|
||||||
|
|
||||||
// Track oldest price date (for status column)
|
// Track oldest price date (for status column)
|
||||||
@@ -70,7 +72,8 @@ export function useCraftingProfit(
|
|||||||
} else {
|
} else {
|
||||||
trackDate(ingEntry.sell_price_min_date)
|
trackDate(ingEntry.sell_price_min_date)
|
||||||
const totalCost = unitPrice * ing.quantity
|
const totalCost = unitPrice * ing.quantity
|
||||||
materialCost += totalCost
|
if (isRrrExempt(ing.itemId)) artefactCost += totalCost
|
||||||
|
else basicCost += totalCost
|
||||||
ingredientBreakdown.push({
|
ingredientBreakdown.push({
|
||||||
itemId: ing.itemId,
|
itemId: ing.itemId,
|
||||||
displayName: formatItemId(ing.itemId),
|
displayName: formatItemId(ing.itemId),
|
||||||
@@ -81,13 +84,21 @@ export function useCraftingProfit(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectiveMaterialCost = materialCost * rrrFactor
|
const materialCost = basicCost + artefactCost
|
||||||
|
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)
|
const priceAgeMs = missingPrices ? null : (oldestDate ? Date.now() - new Date(oldestDate).getTime() : null)
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
recipe,
|
recipe,
|
||||||
materialCost,
|
materialCost,
|
||||||
effectiveMaterialCost,
|
effectiveMaterialCost,
|
||||||
|
effectiveMaterialCostFocus,
|
||||||
|
rrr,
|
||||||
|
rrrFocus,
|
||||||
priceAgeMs,
|
priceAgeMs,
|
||||||
missingPrices,
|
missingPrices,
|
||||||
ingredientBreakdown,
|
ingredientBreakdown,
|
||||||
@@ -117,6 +128,10 @@ export function useCraftingProfit(
|
|||||||
aVal = variantRank(a.recipe.outputItemId)
|
aVal = variantRank(a.recipe.outputItemId)
|
||||||
bVal = variantRank(b.recipe.outputItemId)
|
bVal = variantRank(b.recipe.outputItemId)
|
||||||
break
|
break
|
||||||
|
case 'station':
|
||||||
|
aVal = a.recipe.journalType
|
||||||
|
bVal = b.recipe.journalType
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
aVal = a.effectiveMaterialCost
|
aVal = a.effectiveMaterialCost
|
||||||
bVal = b.effectiveMaterialCost
|
bVal = b.effectiveMaterialCost
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import type { FilterState, VariantType } from '../types/filters'
|
import type { FilterState, VariantType } from '../types/filters'
|
||||||
import { ALL_VARIANTS } from '../types/filters'
|
import { ALL_VARIANTS, ALL_STATIONS } from '../types/filters'
|
||||||
import type { Tier, Enchantment } from '../types/crafting'
|
import type { Tier, Enchantment, JournalType } from '../types/crafting'
|
||||||
import type { AlbionCity } from '../types/api'
|
import type { AlbionCity } from '../types/api'
|
||||||
import { ENCHANTMENTS, TIERS } from '../data/constants'
|
import { ENCHANTMENTS, TIERS } from '../data/constants'
|
||||||
|
|
||||||
@@ -11,10 +11,10 @@ const STORAGE_KEY = 'albion-filters'
|
|||||||
|
|
||||||
interface StoredFilters {
|
interface StoredFilters {
|
||||||
city?: AlbionCity
|
city?: AlbionCity
|
||||||
rrr?: number
|
|
||||||
tiers?: Tier[]
|
tiers?: Tier[]
|
||||||
enchantments?: Enchantment[] | null
|
enchantments?: Enchantment[] | null
|
||||||
variants?: VariantType[] | null
|
variants?: VariantType[] | null
|
||||||
|
stations?: JournalType[] | null
|
||||||
selectedItemTypes?: string[] | null
|
selectedItemTypes?: string[] | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,10 +31,10 @@ function buildInitialState(): FilterState {
|
|||||||
const s = loadStored()
|
const s = loadStored()
|
||||||
return {
|
return {
|
||||||
city: s.city ?? 'Caerleon',
|
city: s.city ?? 'Caerleon',
|
||||||
rrr: (s.rrr != null && s.rrr >= 0 && s.rrr <= 100) ? s.rrr : 15,
|
|
||||||
tiers: s.tiers ? new Set(s.tiers) : new Set(TIERS),
|
tiers: s.tiers ? new Set(s.tiers) : new Set(TIERS),
|
||||||
enchantments: s.enchantments === undefined ? null : (s.enchantments === null ? null : new Set(s.enchantments)),
|
enchantments: s.enchantments === undefined ? null : (s.enchantments === null ? null : new Set(s.enchantments)),
|
||||||
variants: s.variants === undefined ? null : (s.variants === null ? null : new Set(s.variants)),
|
variants: s.variants === undefined ? null : (s.variants === null ? null : new Set(s.variants)),
|
||||||
|
stations: s.stations === undefined ? null : (s.stations === null ? null : new Set(s.stations)),
|
||||||
selectedItemTypes: s.selectedItemTypes === undefined ? null : (s.selectedItemTypes === null ? null : new Set(s.selectedItemTypes)),
|
selectedItemTypes: s.selectedItemTypes === undefined ? null : (s.selectedItemTypes === null ? null : new Set(s.selectedItemTypes)),
|
||||||
nameFilter: '',
|
nameFilter: '',
|
||||||
}
|
}
|
||||||
@@ -48,10 +48,10 @@ watch(filters, () => {
|
|||||||
const f = filters.value
|
const f = filters.value
|
||||||
const toStore: StoredFilters = {
|
const toStore: StoredFilters = {
|
||||||
city: f.city,
|
city: f.city,
|
||||||
rrr: f.rrr,
|
|
||||||
tiers: [...f.tiers],
|
tiers: [...f.tiers],
|
||||||
enchantments: f.enchantments === null ? null : [...f.enchantments],
|
enchantments: f.enchantments === null ? null : [...f.enchantments],
|
||||||
variants: f.variants === null ? null : [...f.variants],
|
variants: f.variants === null ? null : [...f.variants],
|
||||||
|
stations: f.stations === null ? null : [...f.stations],
|
||||||
selectedItemTypes: f.selectedItemTypes === null ? null : [...f.selectedItemTypes],
|
selectedItemTypes: f.selectedItemTypes === null ? null : [...f.selectedItemTypes],
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -79,10 +79,6 @@ function setSelectedItemTypes(value: Set<string> | null) {
|
|||||||
filters.value.selectedItemTypes = value
|
filters.value.selectedItemTypes = value
|
||||||
}
|
}
|
||||||
|
|
||||||
function setRrr(rate: number) {
|
|
||||||
filters.value.rrr = Math.max(0, Math.min(100, rate))
|
|
||||||
}
|
|
||||||
|
|
||||||
function setNameFilter(value: string) {
|
function setNameFilter(value: string) {
|
||||||
filters.value.nameFilter = value
|
filters.value.nameFilter = value
|
||||||
}
|
}
|
||||||
@@ -117,17 +113,33 @@ function resetVariants() {
|
|||||||
filters.value.variants = null
|
filters.value.variants = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleStation(station: JournalType) {
|
||||||
|
const current = filters.value.stations
|
||||||
|
const next = current === null ? new Set(ALL_STATIONS) : new Set(current)
|
||||||
|
if (next.has(station)) {
|
||||||
|
next.delete(station)
|
||||||
|
} else {
|
||||||
|
next.add(station)
|
||||||
|
}
|
||||||
|
filters.value.stations = next.size === ALL_STATIONS.length ? null : next
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetStations() {
|
||||||
|
filters.value.stations = null
|
||||||
|
}
|
||||||
|
|
||||||
export function useFilters() {
|
export function useFilters() {
|
||||||
return {
|
return {
|
||||||
filters,
|
filters,
|
||||||
setCity,
|
setCity,
|
||||||
toggleTier,
|
toggleTier,
|
||||||
setSelectedItemTypes,
|
setSelectedItemTypes,
|
||||||
setRrr,
|
|
||||||
setNameFilter,
|
setNameFilter,
|
||||||
toggleEnchantment,
|
toggleEnchantment,
|
||||||
resetEnchantments,
|
resetEnchantments,
|
||||||
toggleVariant,
|
toggleVariant,
|
||||||
resetVariants,
|
resetVariants,
|
||||||
|
toggleStation,
|
||||||
|
resetStations,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,17 @@ import { ALL_RECIPES } from '../data/recipes'
|
|||||||
export interface OrderItem {
|
export interface OrderItem {
|
||||||
recipe: CraftingRecipe
|
recipe: CraftingRecipe
|
||||||
qty: number
|
qty: number
|
||||||
|
focus: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'albion-production-order'
|
const STORAGE_KEY = 'albion-production-order'
|
||||||
|
|
||||||
const recipeIndex = new Map(ALL_RECIPES.map(r => [r.outputItemId, r]))
|
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> {
|
function load(): Map<string, OrderItem> {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
@@ -19,7 +24,10 @@ function load(): Map<string, OrderItem> {
|
|||||||
const result: [string, OrderItem][] = []
|
const result: [string, OrderItem][] = []
|
||||||
for (const i of items) {
|
for (const i of items) {
|
||||||
const recipe = recipeIndex.get(i.recipe.outputItemId)
|
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)
|
return new Map(result)
|
||||||
}
|
}
|
||||||
@@ -39,17 +47,18 @@ const order = ref<Map<string, OrderItem>>(load())
|
|||||||
|
|
||||||
// ─── Mutations ────────────────────────────────────────────────────────────────
|
// ─── Mutations ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function upsert(recipe: CraftingRecipe, qty: number): void {
|
function upsert(recipe: CraftingRecipe, qty: number, focus: boolean): void {
|
||||||
if (qty <= 0) { remove(recipe.outputItemId); return }
|
const key = orderKey(recipe.outputItemId, focus)
|
||||||
|
if (qty <= 0) { remove(recipe.outputItemId, focus); return }
|
||||||
const next = new Map(order.value)
|
const next = new Map(order.value)
|
||||||
next.set(recipe.outputItemId, { recipe, qty })
|
next.set(key, { recipe, qty, focus })
|
||||||
order.value = next
|
order.value = next
|
||||||
save(next)
|
save(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(outputItemId: string): void {
|
function remove(outputItemId: string, focus: boolean): void {
|
||||||
const next = new Map(order.value)
|
const next = new Map(order.value)
|
||||||
next.delete(outputItemId)
|
next.delete(orderKey(outputItemId, focus))
|
||||||
order.value = next
|
order.value = next
|
||||||
save(next)
|
save(next)
|
||||||
}
|
}
|
||||||
@@ -64,12 +73,12 @@ function clear(): void {
|
|||||||
const orderItems = computed(() => [...order.value.values()])
|
const orderItems = computed(() => [...order.value.values()])
|
||||||
const orderCount = computed(() => order.value.size)
|
const orderCount = computed(() => order.value.size)
|
||||||
|
|
||||||
function getQty(outputItemId: string): number {
|
function getQty(outputItemId: string, focus: boolean): number {
|
||||||
return order.value.get(outputItemId)?.qty ?? 0
|
return order.value.get(orderKey(outputItemId, focus))?.qty ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function inOrder(outputItemId: string): boolean {
|
function inOrder(outputItemId: string, focus: boolean): boolean {
|
||||||
return order.value.has(outputItemId)
|
return order.value.has(orderKey(outputItemId, focus))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||||
|
|||||||
56
src/data/cityBonuses.ts
Normal file
56
src/data/cityBonuses.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { AlbionCity } from '../types/api'
|
||||||
|
|
||||||
|
// Source: Albion Online Wiki — Production Bonuses
|
||||||
|
// Formula: RRR% = (1 - 1 / (1 + LPB/100)) × 100
|
||||||
|
//
|
||||||
|
// Royal cities base LPB: 18% → RRR ≈ 15.3%
|
||||||
|
// Specialization bonus: +15% → total 33% LPB → RRR ≈ 24.8%
|
||||||
|
// Black Market (not a crafting location): 0% LPB → RRR = 0%
|
||||||
|
|
||||||
|
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',
|
||||||
|
])
|
||||||
|
|
||||||
|
// Each regex matches all output item IDs that receive the +15% specialization bonus
|
||||||
|
// in that city, including artifact / avalon / crystal variants.
|
||||||
|
const SPECIALIZATION_PATTERNS: Partial<Record<AlbionCity, RegExp>> = {
|
||||||
|
'Fort Sterling': /_MAIN_HAMMER|_2H_HAMMER|_2H_POLEHAMMER|_2H_DUALHAMMER|_2H_RAM_|_MAIN_SPEAR|_2H_SPEAR|_2H_GLAIVE|_2H_HARPOON|_2H_TRIDENT|_HOLYSTAFF|_DIVINESTAFF|_HEAD_PLATE_|_ARMOR_CLOTH_/,
|
||||||
|
'Lymhurst': /_MAIN_SWORD|_2H_CLAYMORE|_2H_DUALSWORD|_SCIMITAR|_2H_CLEAVER|_2H_BOW|_LONGBOW|_WARBOW|_ARCANESTAFF|_ENIGMATICSTAFF|_ENIGMATICORB|_ARCANE_RINGPAIR|_HEAD_LEATHER_|_SHOES_LEATHER_/,
|
||||||
|
'Bridgewatch': /_CROSSBOW|_1HCROSSBOW|_DAGGER|_CLAWPAIR|_RAPIER|_DUALSICKLE|_CURSEDSTAFF|_DEMONICSTAFF|_SKULLORB|_ARMOR_PLATE_|_SHOES_CLOTH_/,
|
||||||
|
'Martlock': /_MAIN_AXE|_2H_AXE|_2H_HALBERD|_2H_SCYTHE|_2H_DUALAXE|_QUARTERSTAFF|_IRONCLADEDSTAFF|_DOUBLEBLADEDSTAFF|_COMBATSTAFF|_TWINSCYTHE|_ROCKSTAFF|_FROSTSTAFF|_GLACIALSTAFF|_ICEGAUNTLETS|_ICECRYSTAL|_SHOES_PLATE_|_OFF_/,
|
||||||
|
'Thetford': /_MAIN_MACE|_2H_MACE|_2H_FLAIL|_ROCKMACE|_2H_DUALMACE|_NATURESTAFF|_WILDSTAFF|_FIRESTAFF|_INFERNOSTAFF|_FIRE_RINGPAIR|_ARMOR_LEATHER_|_HEAD_CLOTH_/,
|
||||||
|
'Caerleon': /_2H_KNUCKLES|_SHAPESHIFTER|_2H_TOOL_/,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the Local Production Bonus (%) for crafting this item in the given city.
|
||||||
|
export function localProductionBonus(city: AlbionCity, outputItemId: string): number {
|
||||||
|
if (!ROYAL_CITIES.has(city)) return 0 // Black Market: no bonus
|
||||||
|
|
||||||
|
const pattern = SPECIALIZATION_PATTERNS[city]
|
||||||
|
const baseId = outputItemId.replace(/@\d$/, '') // strip enchantment suffix
|
||||||
|
const isSpecialized = pattern ? pattern.test(baseId) : false
|
||||||
|
|
||||||
|
return BASE_ROYAL_LPB + (isSpecialized ? SPECIALIZATION_BONUS : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converts a Local Production Bonus (%) to the resulting Resource Return Rate (%).
|
||||||
|
// Formula: RRR = 1 - 1/(1 + LPB/100)
|
||||||
|
export function rrrFromBonus(lpb: number): number {
|
||||||
|
if (lpb === 0) return 0
|
||||||
|
return (1 - 1 / (1 + lpb / 100)) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the effective RRR (%) for crafting this item in the given city.
|
||||||
|
export function cityRrr(city: AlbionCity, outputItemId: string): number {
|
||||||
|
return rrrFromBonus(localProductionBonus(city, outputItemId))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true for ingredients that are NOT subject to RRR (artefacts, ALCHEMY_RARE).
|
||||||
|
// Only basic resources (bars, planks, leather, cloth) are returned by the city bonus.
|
||||||
|
export function isRrrExempt(itemId: string): boolean {
|
||||||
|
return itemId.includes('_ARTEFACT_') || itemId.includes('_ALCHEMY_RARE_')
|
||||||
|
}
|
||||||
@@ -5,18 +5,8 @@
|
|||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<h2 class="text-lg font-semibold text-gray-200">Price Editor</h2>
|
<h2 class="text-lg font-semibold text-gray-200">Price Editor</h2>
|
||||||
<div class="h-5 w-px bg-gray-600" />
|
<div class="h-5 w-px bg-gray-600" />
|
||||||
<div class="flex items-center gap-1.5">
|
<span class="text-xs text-gray-500">
|
||||||
<span class="text-xs text-gray-400">City</span>
|
Amber = price set · Click any price to edit · Leave empty to clear
|
||||||
<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">
|
|
||||||
Amber = manual override · Click any price to edit · Empty field to clear override
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -26,12 +16,15 @@
|
|||||||
<button
|
<button
|
||||||
v-for="mat in MATERIALS"
|
v-for="mat in MATERIALS"
|
||||||
:key="mat.name"
|
:key="mat.name"
|
||||||
class="px-3 py-1 rounded-lg text-xs font-medium transition-colors border"
|
class="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors border"
|
||||||
:class="activeMaterial === mat.name
|
:class="activeMaterial === mat.name
|
||||||
? 'bg-amber-500/15 border-amber-500/60 text-amber-300'
|
? 'bg-amber-500/15 border-amber-500/60 text-amber-300'
|
||||||
: 'bg-transparent border-gray-600 text-gray-400 hover:border-gray-400 hover:text-gray-200'"
|
: 'bg-transparent border-gray-600 text-gray-400 hover:border-gray-400 hover:text-gray-200'"
|
||||||
@click="setActiveMaterial(mat.name)"
|
@click="setActiveMaterial(mat.name)"
|
||||||
>{{ mat.name }}</button>
|
>
|
||||||
|
<img :src="itemImageUrl(mat.baseId(4))" :alt="mat.name" class="w-6 h-6 rounded" />
|
||||||
|
{{ mat.name }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -56,11 +49,13 @@
|
|||||||
<div
|
<div
|
||||||
v-for="enc in (tier >= 4 ? ENCHANTMENTS : [0])"
|
v-for="enc in (tier >= 4 ? ENCHANTMENTS : [0])"
|
||||||
:key="enc"
|
:key="enc"
|
||||||
class="px-4 py-3"
|
class="px-4 py-3 flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<div class="text-[11px] font-semibold mb-2" :style="enchantTextStyle(enc)">
|
<img
|
||||||
.{{ enc }}
|
:src="itemImageUrl(enchantedId(activeMaterialDef.baseId(tier), enc))"
|
||||||
</div>
|
:alt="`${activeMaterialDef.name} .${enc}`"
|
||||||
|
class="w-8 h-8 rounded flex-shrink-0"
|
||||||
|
/>
|
||||||
<PriceCell :item-id="enchantedId(activeMaterialDef.baseId(tier), enc)" />
|
<PriceCell :item-id="enchantedId(activeMaterialDef.baseId(tier), enc)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,17 +67,12 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { tierStyle, enchantTextStyle } from '../utils/formatting'
|
import { tierStyle, itemImageUrl } from '../utils/formatting'
|
||||||
import PriceCell from '../components/PriceCell.vue'
|
import PriceCell from '../components/PriceCell.vue'
|
||||||
import { useFilters } from '../composables/useFilters'
|
import { ENCHANTMENTS } from '../data/constants'
|
||||||
import { CITIES, ENCHANTMENTS } from '../data/constants'
|
|
||||||
import type { AlbionCity, } from '../types/api'
|
|
||||||
import type { Enchantment } from '../types/crafting'
|
|
||||||
|
|
||||||
const { filters, setCity } = useFilters()
|
function enchantedId(baseId: string, enchant: number): string {
|
||||||
|
return enchant === 0 ? baseId : `${baseId}_LEVEL${enchant}`
|
||||||
function enchantedId(baseId: string, enchant: Enchantment): string {
|
|
||||||
return enchant === 0 ? baseId : `${baseId}@${enchant}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MaterialDef {
|
interface MaterialDef {
|
||||||
|
|||||||
@@ -23,16 +23,33 @@
|
|||||||
<table class="w-full text-sm">
|
<table class="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-gray-700 bg-gray-700/30">
|
<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
|
||||||
<th class="text-center px-3 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wider w-28">Qty</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"
|
||||||
<th class="text-right px-4 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wider">Cost / unit</th>
|
@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>
|
<th class="w-8"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr
|
<tr
|
||||||
v-for="item in computedItems"
|
v-for="item in sortedItems"
|
||||||
:key="item.recipe.outputItemId"
|
:key="`${item.recipe.outputItemId}|${item.focus ? 'f' : 'b'}`"
|
||||||
class="border-t border-gray-700/40 hover:bg-gray-700/20"
|
class="border-t border-gray-700/40 hover:bg-gray-700/20"
|
||||||
>
|
>
|
||||||
<td class="px-4 py-2">
|
<td class="px-4 py-2">
|
||||||
@@ -43,16 +60,22 @@
|
|||||||
class="w-12 h-12 -my-2 rounded flex-shrink-0"
|
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-gray-200 font-medium">{{ item.recipe.displayName }}</span>
|
||||||
<span class="text-xs text-gray-500">{{ item.recipe.category }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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">
|
<td class="px-3 py-2 text-center">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
:value="item.qty"
|
: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"
|
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>
|
||||||
<td class="px-4 py-2 text-right font-mono text-gray-300">
|
<td class="px-4 py-2 text-right font-mono text-gray-300">
|
||||||
@@ -63,7 +86,7 @@
|
|||||||
<button
|
<button
|
||||||
class="text-gray-600 hover:text-red-400 transition-colors text-sm leading-none"
|
class="text-gray-600 hover:text-red-400 transition-colors text-sm leading-none"
|
||||||
title="Remove"
|
title="Remove"
|
||||||
@click="remove(item.recipe.outputItemId)"
|
@click="remove(item.recipe.outputItemId, item.focus)"
|
||||||
>✕</button>
|
>✕</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -179,7 +202,7 @@
|
|||||||
<span class="text-gray-200">{{ formatSilver(totals.rawCost) }}</span>
|
<span class="text-gray-200">{{ formatSilver(totals.rawCost) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between gap-4">
|
<div class="flex justify-between gap-4">
|
||||||
<span class="text-gray-400">RRR savings ({{ filters.rrr }}%)</span>
|
<span class="text-gray-400">RRR savings</span>
|
||||||
<span class="text-green-400">−{{ formatSilver(totals.rrrSavings) }}</span>
|
<span class="text-green-400">−{{ formatSilver(totals.rrrSavings) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between gap-4 border-t border-gray-700/50 pt-2">
|
<div class="flex justify-between gap-4 border-t border-gray-700/50 pt-2">
|
||||||
@@ -207,14 +230,15 @@ import { computed, ref } from 'vue'
|
|||||||
import { useProductionOrder } from '../composables/useProductionOrder'
|
import { useProductionOrder } from '../composables/useProductionOrder'
|
||||||
import { useAlbionPrices } from '../composables/useAlbionPrices'
|
import { useAlbionPrices } from '../composables/useAlbionPrices'
|
||||||
import { useFilters } from '../composables/useFilters'
|
import { useFilters } from '../composables/useFilters'
|
||||||
import { formatSilver, formatItemId, formatLastUpdated, tierStyle, itemImageUrl } from '../utils/formatting'
|
import { formatSilver, formatItemId, formatLastUpdated, itemImageUrl } from '../utils/formatting'
|
||||||
import { fameTypeOf, craftsPerJournal } from '../data/constants'
|
import { fameTypeOf, craftsPerJournal } from '../data/constants'
|
||||||
|
import { localProductionBonus, rrrFromBonus, FOCUS_LPB, isRrrExempt } from '../data/cityBonuses'
|
||||||
import type { Tier, JournalType } from '../types/crafting'
|
import type { Tier, JournalType } from '../types/crafting'
|
||||||
|
|
||||||
const vFocus = { mounted: (el: HTMLElement) => el.focus() }
|
const vFocus = { mounted: (el: HTMLElement) => el.focus() }
|
||||||
|
|
||||||
const { orderItems, upsert, remove, clear } = useProductionOrder()
|
const { orderItems, upsert, remove, clear } = useProductionOrder()
|
||||||
const { getPrice, isManualPrice, getManualEntry, setManualPrice, clearManualPrice } = useAlbionPrices()
|
const { getPrice, getManualEntry, setManualPrice, clearManualPrice } = useAlbionPrices()
|
||||||
const { filters } = useFilters()
|
const { filters } = useFilters()
|
||||||
|
|
||||||
// ─── Inline price editing ─────────────────────────────────────────────────────
|
// ─── Inline price editing ─────────────────────────────────────────────────────
|
||||||
@@ -243,23 +267,55 @@ function cancelEdit() {
|
|||||||
inputValue.value = ''
|
inputValue.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function priceButtonClass(itemId: string, currentPrice: number): string {
|
function priceButtonClass(_itemId: string, currentPrice: number): string {
|
||||||
if (isManualPrice(itemId, filters.value.city)) return 'text-amber-400 hover:text-amber-200'
|
return currentPrice > 0 ? 'text-amber-400 hover:text-amber-200' : 'text-gray-500 hover:text-amber-400'
|
||||||
if (currentPrice === 0) return 'text-gray-500 hover:text-amber-400'
|
|
||||||
return 'text-gray-300 hover:text-gray-100'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function priceTitle(itemId: string, currentPrice: number): string {
|
function priceTitle(itemId: string, currentPrice: number): string {
|
||||||
const exact = currentPrice > 0 ? currentPrice.toLocaleString() : null
|
|
||||||
const entry = getManualEntry(itemId, filters.value.city)
|
const entry = getManualEntry(itemId, filters.value.city)
|
||||||
if (entry && isManualPrice(itemId, filters.value.city)) {
|
if (entry) {
|
||||||
return exact
|
return `${currentPrice.toLocaleString()} — set ${formatLastUpdated(new Date(entry.editedAt))} — click to edit`
|
||||||
? `${exact} — set ${formatLastUpdated(new Date(entry.editedAt))} — click to edit`
|
|
||||||
: `Set ${formatLastUpdated(new Date(entry.editedAt))} — click to edit`
|
|
||||||
}
|
}
|
||||||
return exact ? `${exact} — click to set price` : 'Click to set price'
|
return '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> = {
|
const JOURNAL_DISPLAY_NAME: Record<JournalType, string> = {
|
||||||
warrior: "Blacksmith's",
|
warrior: "Blacksmith's",
|
||||||
hunter: "Fletcher's",
|
hunter: "Fletcher's",
|
||||||
@@ -278,10 +334,10 @@ const JOURNAL_ITEM_ID: Record<JournalType, string> = {
|
|||||||
|
|
||||||
const computedItems = computed(() => {
|
const computedItems = computed(() => {
|
||||||
const city = filters.value.city
|
const city = filters.value.city
|
||||||
const rrrFactor = 1 - filters.value.rrr / 100
|
|
||||||
|
|
||||||
return orderItems.value.map(({ recipe, qty }) => {
|
return orderItems.value.map(({ recipe, qty, focus }) => {
|
||||||
let rawCost = 0
|
let basicCost = 0
|
||||||
|
let artefactCost = 0
|
||||||
let missingPrices = false
|
let missingPrices = false
|
||||||
|
|
||||||
for (const ing of recipe.ingredients) {
|
for (const ing of recipe.ingredients) {
|
||||||
@@ -289,12 +345,30 @@ const computedItems = computed(() => {
|
|||||||
if (!entry || entry.sell_price_min === 0) {
|
if (!entry || entry.sell_price_min === 0) {
|
||||||
missingPrices = true
|
missingPrices = true
|
||||||
} else {
|
} else {
|
||||||
rawCost += entry.sell_price_min * ing.quantity
|
if (isRrrExempt(ing.itemId)) artefactCost += entry.sell_price_min * ing.quantity
|
||||||
|
else basicCost += entry.sell_price_min * ing.quantity
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const craftCostPerUnit = rawCost * rrrFactor
|
const rawCost = basicCost + artefactCost
|
||||||
return { recipe, qty, craftCostPerUnit, totalCraftCost: craftCostPerUnit * qty, missingPrices }
|
const lpb = localProductionBonus(city, recipe.outputItemId)
|
||||||
|
const rrr = rrrFromBonus(focus ? lpb + FOCUS_LPB : lpb)
|
||||||
|
const craftCostPerUnit = basicCost * (1 - rrr / 100) + artefactCost
|
||||||
|
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
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -323,24 +397,23 @@ const aggregatedMaterials = computed(() => {
|
|||||||
// ─── Totals + journals ────────────────────────────────────────────────────────
|
// ─── Totals + journals ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const totals = computed(() => {
|
const totals = computed(() => {
|
||||||
const rrrFactor = 1 - filters.value.rrr / 100
|
|
||||||
let rawCost = 0
|
let rawCost = 0
|
||||||
|
let effectiveCost = 0
|
||||||
let totalCrafts = 0
|
let totalCrafts = 0
|
||||||
let totalItems = 0
|
let totalItems = 0
|
||||||
|
|
||||||
for (const item of computedItems.value) {
|
for (const item of computedItems.value) {
|
||||||
if (!item.missingPrices) {
|
if (!item.missingPrices) {
|
||||||
rawCost += item.craftCostPerUnit / rrrFactor * item.qty // back to raw
|
rawCost += item.rawCostPerUnit * item.qty
|
||||||
|
effectiveCost += item.craftCostPerUnit * item.qty
|
||||||
}
|
}
|
||||||
totalCrafts += item.qty
|
totalCrafts += item.qty
|
||||||
totalItems += item.qty
|
totalItems += item.qty
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectiveCost = rawCost * rrrFactor
|
|
||||||
const rrrSavings = rawCost - effectiveCost
|
const rrrSavings = rawCost - effectiveCost
|
||||||
const totalJournals = Math.ceil(totalCrafts / CRAFTS_PER_JOURNAL)
|
|
||||||
|
|
||||||
return { rawCost, effectiveCost, rrrSavings, totalCrafts, totalItems, totalJournals }
|
return { rawCost, effectiveCost, rrrSavings, totalCrafts, totalItems }
|
||||||
})
|
})
|
||||||
|
|
||||||
const journalRows = computed(() => {
|
const journalRows = computed(() => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export type JournalType = 'warrior' | 'mage' | 'hunter' | 'toolmaker'
|
|||||||
|
|
||||||
export type FameType = 'twoHanded' | 'oneHanded' | 'armorChest' | 'small'
|
export type FameType = 'twoHanded' | 'oneHanded' | 'armorChest' | 'small'
|
||||||
|
|
||||||
export type SortField = 'materialCost' | 'displayName' | 'tier' | 'variantType'
|
export type SortField = 'materialCost' | 'displayName' | 'tier' | 'variantType' | 'station'
|
||||||
|
|
||||||
export type SortDirection = 'asc' | 'desc'
|
export type SortDirection = 'asc' | 'desc'
|
||||||
|
|
||||||
@@ -43,7 +43,10 @@ export interface IngredientBreakdown {
|
|||||||
export interface ProfitResult {
|
export interface ProfitResult {
|
||||||
recipe: CraftingRecipe
|
recipe: CraftingRecipe
|
||||||
materialCost: number
|
materialCost: number
|
||||||
effectiveMaterialCost: number
|
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
|
priceAgeMs: number | null // null = missing prices; ms since oldest manual entry
|
||||||
missingPrices: boolean
|
missingPrices: boolean
|
||||||
ingredientBreakdown: IngredientBreakdown[]
|
ingredientBreakdown: IngredientBreakdown[]
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import type { AlbionCity } from './api'
|
import type { AlbionCity } from './api'
|
||||||
import type { Tier, Enchantment } from './crafting'
|
import type { Tier, Enchantment, JournalType } from './crafting'
|
||||||
|
|
||||||
export type VariantType = 'basic' | 'artifact' | 'avalon' | 'crystal'
|
export type VariantType = 'basic' | 'artifact' | 'avalon' | 'crystal'
|
||||||
|
|
||||||
export const ALL_VARIANTS: VariantType[] = ['basic', 'artifact', 'avalon', 'crystal']
|
export const ALL_VARIANTS: VariantType[] = ['basic', 'artifact', 'avalon', 'crystal']
|
||||||
|
|
||||||
|
export const ALL_STATIONS: JournalType[] = ['warrior', 'hunter', 'mage', 'toolmaker']
|
||||||
|
|
||||||
export interface FilterState {
|
export interface FilterState {
|
||||||
city: AlbionCity
|
city: AlbionCity
|
||||||
tiers: Set<Tier>
|
tiers: Set<Tier>
|
||||||
selectedItemTypes: Set<string> | null
|
selectedItemTypes: Set<string> | null
|
||||||
rrr: number
|
|
||||||
nameFilter: string
|
nameFilter: string
|
||||||
enchantments: Set<Enchantment> | null
|
enchantments: Set<Enchantment> | null
|
||||||
variants: Set<VariantType> | null
|
variants: Set<VariantType> | null
|
||||||
|
stations: Set<JournalType> | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ export function enchantTextStyle(enc: number): { color: string } {
|
|||||||
return { color: map[enc] ?? '#9ca3af' }
|
return { color: map[enc] ?? '#9ca3af' }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tierEnchantStyle(tier: number, enchantment: number): object {
|
export function tierEnchantStyle(tier: number, enchantment: number): Record<string, string> {
|
||||||
const base = tierStyle(tier)
|
const base = tierStyle(tier)
|
||||||
if (enchantment === 0) return base
|
if (enchantment === 0) return base
|
||||||
return {
|
return {
|
||||||
@@ -244,14 +244,15 @@ export function tierStyle(tier: number): { backgroundColor: string; color: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatSilver(value: number): string {
|
export function formatSilver(value: number): string {
|
||||||
if (value === 0) return '0'
|
const v = Math.ceil(value)
|
||||||
if (Math.abs(value) >= 1_000_000) {
|
if (v === 0) return '0'
|
||||||
return (value / 1_000_000).toFixed(2) + 'M'
|
if (Math.abs(v) >= 1_000_000) {
|
||||||
|
return (v / 1_000_000).toFixed(2) + 'M'
|
||||||
}
|
}
|
||||||
if (Math.abs(value) >= 1_000) {
|
if (Math.abs(v) >= 1_000) {
|
||||||
return (value / 1_000).toFixed(1) + 'k'
|
return (v / 1_000).toFixed(1) + 'k'
|
||||||
}
|
}
|
||||||
return value.toLocaleString()
|
return v.toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatRoi(roi: number): string {
|
export function formatRoi(roi: number): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user