add variant fileter
This commit is contained in:
@@ -19,7 +19,8 @@
|
||||
<div v-if="currentPage === 'calculator'" class="p-6 space-y-4">
|
||||
<FilterBar :filters="filters" :result-count="profitResults.length" @set-name-filter="setNameFilter"
|
||||
@set-city="setCity" @toggle-tier="toggleTier" @set-selected-item-types="setSelectedItemTypes"
|
||||
@toggle-enchantment="toggleEnchantment" @reset-enchantments="resetEnchantments" @set-rrr="setRrr" />
|
||||
@toggle-enchantment="toggleEnchantment" @reset-enchantments="resetEnchantments"
|
||||
@toggle-variant="toggleVariant" @reset-variants="resetVariants" @set-rrr="setRrr" />
|
||||
|
||||
<ProfitTable :results="profitResults" :sort-state="sortState" @sort="handleSort" />
|
||||
</div>
|
||||
@@ -72,6 +73,8 @@ const {
|
||||
setNameFilter,
|
||||
toggleEnchantment,
|
||||
resetEnchantments,
|
||||
toggleVariant,
|
||||
resetVariants,
|
||||
} = useFilters()
|
||||
|
||||
// Profit calculation
|
||||
|
||||
@@ -117,6 +117,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Variant toggles -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-xs text-gray-400">Variant</span>
|
||||
<div class="flex gap-0.5">
|
||||
<button class="px-2 py-1 rounded text-xs font-semibold transition-colors" :class="filters.variants === null
|
||||
? 'bg-amber-600 text-amber-100'
|
||||
: 'bg-gray-800 text-gray-500 hover:bg-gray-700'" @click="$emit('reset-variants')">All</button>
|
||||
<button v-for="v in ALL_VARIANTS" :key="v"
|
||||
class="px-2 py-1 rounded text-xs font-semibold transition-colors"
|
||||
:class="isVariantActive(v) ? VARIANT_ACTIVE_CLASS[v] : 'bg-gray-800 text-gray-600 hover:bg-gray-700'"
|
||||
@click="$emit('toggle-variant', v)">{{ VARIANT_LABEL[v] }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- City select -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-xs text-gray-400">City</span>
|
||||
@@ -150,7 +164,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { tierStyle, enchantStyle } from '../../utils/formatting'
|
||||
import type { FilterState } from '../../types/filters'
|
||||
import type { FilterState, VariantType } from '../../types/filters'
|
||||
import { ALL_VARIANTS } from '../../types/filters'
|
||||
import type { AlbionCity } from '../../types/api'
|
||||
import type { Tier, Enchantment } from '../../types/crafting'
|
||||
import { TIERS, CITIES, ENCHANTMENTS } from '../../data/constants'
|
||||
@@ -169,6 +184,8 @@ const emit = defineEmits<{
|
||||
'set-selected-item-types': [v: Set<string> | null]
|
||||
'toggle-enchantment': [enc: Enchantment]
|
||||
'reset-enchantments': []
|
||||
'toggle-variant': [v: VariantType]
|
||||
'reset-variants': []
|
||||
'set-rrr': [rate: number]
|
||||
}>()
|
||||
|
||||
@@ -278,6 +295,24 @@ function isEnchantActive(enc: Enchantment): boolean {
|
||||
return s === null || s.has(enc)
|
||||
}
|
||||
|
||||
// ─── Variant helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
const VARIANT_LABEL: Record<VariantType, string> = {
|
||||
basic: 'Basic', artifact: 'Artifact', avalon: 'Avalon', crystal: 'Crystal',
|
||||
}
|
||||
|
||||
const VARIANT_ACTIVE_CLASS: Record<VariantType, string> = {
|
||||
basic: 'bg-gray-600 text-gray-200',
|
||||
artifact: 'bg-amber-600/70 text-amber-100',
|
||||
avalon: 'bg-violet-600/70 text-violet-100',
|
||||
crystal: 'bg-cyan-600/70 text-cyan-100',
|
||||
}
|
||||
|
||||
function isVariantActive(v: VariantType): boolean {
|
||||
const s = props.filters.variants
|
||||
return s === null || s.has(v)
|
||||
}
|
||||
|
||||
|
||||
// ─── Dropdown open states ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
:class="result.missingPrices ? 'opacity-60' : ''"
|
||||
@click="expanded = !expanded"
|
||||
>
|
||||
<!-- Variant 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="variantClass"
|
||||
>{{ variantLabel }}</span>
|
||||
</td>
|
||||
|
||||
<!-- Item name -->
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -83,7 +91,7 @@
|
||||
|
||||
<!-- Expanded detail row -->
|
||||
<tr v-if="expanded" class="border-b border-gray-700/50 bg-gray-900/60">
|
||||
<td colspan="5" class="px-6 py-4">
|
||||
<td colspan="6" class="px-6 py-4">
|
||||
<div class="flex gap-8">
|
||||
|
||||
<!-- Ingredients breakdown -->
|
||||
@@ -194,6 +202,23 @@ const { isManualPrice, getManualEntry, setManualPrice, clearManualPrice } = useA
|
||||
const { filters } = useFilters()
|
||||
const { upsert, remove, getQty, inOrder } = useProductionOrder()
|
||||
|
||||
const VARIANT_INFO: Record<string, { label: string; cls: string }> = {
|
||||
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' },
|
||||
artifact: { label: 'Artifact', cls: 'bg-amber-600/30 text-amber-300 border border-amber-600/40' },
|
||||
basic: { label: 'Basic', cls: 'bg-gray-700/50 text-gray-400 border border-gray-600/40' },
|
||||
}
|
||||
|
||||
const variantInfo = computed(() => {
|
||||
const id = props.result.recipe.outputItemId.replace(/@\d$/, '')
|
||||
if (id.endsWith('_AVALON')) return VARIANT_INFO.avalon
|
||||
if (id.endsWith('_CRYSTAL')) return VARIANT_INFO.crystal
|
||||
if (/_(?:UNDEAD|HELL|MORGANA|KEEPER|ROYAL|FEY)$/.test(id)) return VARIANT_INFO.artifact
|
||||
return VARIANT_INFO.basic
|
||||
})
|
||||
const variantLabel = computed(() => variantInfo.value.label)
|
||||
const variantClass = computed(() => variantInfo.value.cls)
|
||||
|
||||
const expanded = ref(false)
|
||||
const editingItemId = ref<string | null>(null)
|
||||
const inputValue = ref('')
|
||||
|
||||
@@ -24,9 +24,9 @@
|
||||
import type { SortField, SortState } from '../../types/crafting'
|
||||
|
||||
const columns: { field: SortField | 'status' | 'bill'; label: string; sortable: boolean }[] = [
|
||||
{ field: 'variantType', label: 'Variant', sortable: true },
|
||||
{ field: 'displayName', label: 'Item', sortable: true },
|
||||
{ field: 'tier', label: 'Tier', sortable: true },
|
||||
{ field: 'variantType', label: 'Variant', sortable: true },
|
||||
{ field: 'materialCost', label: 'Craft Cost', sortable: true },
|
||||
{ field: 'status', label: 'Price Age', sortable: false },
|
||||
{ field: 'bill', label: '', sortable: false },
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { computed } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import type { CraftingRecipe, ProfitResult, IngredientBreakdown, SortState } from '../types/crafting'
|
||||
import type { FilterState } from '../types/filters'
|
||||
import type { FilterState, VariantType } from '../types/filters'
|
||||
import { useAlbionPrices } from './useAlbionPrices'
|
||||
import { formatItemId } from '../utils/formatting'
|
||||
|
||||
// Returns 0=basic, 1=artifact, 2=avalon, 3=crystal
|
||||
function variantRank(outputItemId: string): number {
|
||||
function variantOf(outputItemId: string): VariantType {
|
||||
const id = outputItemId.replace(/@\d$/, '') // strip enchantment suffix
|
||||
if (id.endsWith('_AVALON')) return 2
|
||||
if (id.endsWith('_CRYSTAL')) return 3
|
||||
if (/_SET[123]$/.test(id)) return 0
|
||||
// Artifact suffixes: UNDEAD, HELL, MORGANA, KEEPER, and unique named artifacts
|
||||
if (/_(?:UNDEAD|HELL|MORGANA|KEEPER|ROYAL|FEY)$/.test(id)) return 1
|
||||
return 0
|
||||
if (id.endsWith('_AVALON')) return 'avalon'
|
||||
if (id.endsWith('_CRYSTAL')) return 'crystal'
|
||||
if (/_SET[123]$/.test(id)) return 'basic'
|
||||
if (/_(?:UNDEAD|HELL|MORGANA|KEEPER|ROYAL|FEY)$/.test(id)) return 'artifact'
|
||||
return 'basic'
|
||||
}
|
||||
|
||||
const VARIANT_ORDER: Record<VariantType, number> = { basic: 0, artifact: 1, avalon: 2, crystal: 3 }
|
||||
|
||||
function variantRank(outputItemId: string): number {
|
||||
return VARIANT_ORDER[variantOf(outputItemId)]
|
||||
}
|
||||
|
||||
export function useCraftingProfit(
|
||||
@@ -34,6 +38,7 @@ export function useCraftingProfit(
|
||||
for (const recipe of recipes) {
|
||||
if (!f.tiers.has(recipe.tier)) continue
|
||||
if (f.enchantments !== null && !f.enchantments.has(recipe.enchantment)) continue
|
||||
if (f.variants !== null && !f.variants.has(variantOf(recipe.outputItemId))) continue
|
||||
const baseName = recipe.displayName.replace(/^T\d+\.\d /, '')
|
||||
if (f.selectedItemTypes !== null && !f.selectedItemTypes.has(baseName)) continue
|
||||
if (nameLower && !recipe.displayName.toLowerCase().includes(nameLower)) continue
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ref } from 'vue'
|
||||
import type { FilterState } from '../types/filters'
|
||||
import type { FilterState, VariantType } from '../types/filters'
|
||||
import { ALL_VARIANTS } from '../types/filters'
|
||||
import type { Tier, Enchantment } from '../types/crafting'
|
||||
import type { AlbionCity } from '../types/api'
|
||||
import { ENCHANTMENTS } from '../data/constants'
|
||||
@@ -20,6 +21,7 @@ const filters = ref<FilterState>({
|
||||
rrr: loadRrr(),
|
||||
nameFilter: '',
|
||||
enchantments: null,
|
||||
variants: null,
|
||||
})
|
||||
|
||||
function setCity(city: AlbionCity) {
|
||||
@@ -65,6 +67,21 @@ function resetEnchantments() {
|
||||
filters.value.enchantments = null
|
||||
}
|
||||
|
||||
function toggleVariant(variant: VariantType) {
|
||||
const current = filters.value.variants
|
||||
const next = current === null ? new Set(ALL_VARIANTS) : new Set(current)
|
||||
if (next.has(variant)) {
|
||||
next.delete(variant)
|
||||
} else {
|
||||
next.add(variant)
|
||||
}
|
||||
filters.value.variants = next.size === ALL_VARIANTS.length ? null : next
|
||||
}
|
||||
|
||||
function resetVariants() {
|
||||
filters.value.variants = null
|
||||
}
|
||||
|
||||
export function useFilters() {
|
||||
return {
|
||||
filters,
|
||||
@@ -75,5 +92,7 @@ export function useFilters() {
|
||||
setNameFilter,
|
||||
toggleEnchantment,
|
||||
resetEnchantments,
|
||||
toggleVariant,
|
||||
resetVariants,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,8 @@
|
||||
<tr class="border-b border-gray-700 bg-gray-700/30">
|
||||
<th class="text-left px-4 py-2 text-gray-400 font-semibold uppercase tracking-wider">Material</th>
|
||||
<th class="text-right px-4 py-2 text-gray-400 font-semibold uppercase tracking-wider">Qty</th>
|
||||
<th class="text-right px-4 py-2 text-gray-400 font-semibold uppercase tracking-wider">Unit Price</th>
|
||||
<th class="text-right px-4 py-2 text-gray-400 font-semibold uppercase tracking-wider">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -100,6 +102,36 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-1.5 text-right font-mono text-gray-200 font-medium">{{ mat.qty.toLocaleString() }}</td>
|
||||
<td class="px-4 py-1.5 text-right font-mono">
|
||||
<div v-if="editingItemId === mat.itemId" class="flex items-center justify-end gap-1">
|
||||
<input
|
||||
v-focus
|
||||
type="number"
|
||||
min="1"
|
||||
:placeholder="mat.unitPrice > 0 ? 'empty to clear' : 'silver'"
|
||||
class="w-28 bg-gray-900 border border-amber-500 rounded px-1.5 py-0.5 text-right text-xs font-mono text-gray-200 focus:outline-none placeholder-gray-600"
|
||||
v-model="inputValue"
|
||||
@keydown.enter="saveEdit(mat.itemId)"
|
||||
@keydown.escape="cancelEdit"
|
||||
/>
|
||||
<button class="text-amber-500 hover:text-amber-300 text-xs leading-none px-0.5" @click="saveEdit(mat.itemId)">✓</button>
|
||||
<button class="text-gray-500 hover:text-gray-300 text-xs leading-none px-0.5" @click="cancelEdit">✕</button>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
class="group flex items-center gap-0.5 ml-auto"
|
||||
:class="priceButtonClass(mat.itemId, mat.unitPrice)"
|
||||
:title="priceTitle(mat.itemId, mat.unitPrice)"
|
||||
@click="startEdit(mat.itemId, mat.unitPrice)"
|
||||
>
|
||||
{{ mat.unitPrice > 0 ? formatSilver(mat.unitPrice) : '—' }}
|
||||
<span class="opacity-0 group-hover:opacity-40 text-[10px] ml-0.5">✎</span>
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-4 py-1.5 text-right font-mono text-gray-200"
|
||||
:title="mat.totalCost > 0 ? mat.totalCost.toLocaleString() : undefined">
|
||||
{{ mat.totalCost > 0 ? formatSilver(mat.totalCost) : '—' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -169,17 +201,62 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useProductionOrder } from '../composables/useProductionOrder'
|
||||
import { useAlbionPrices } from '../composables/useAlbionPrices'
|
||||
import { useFilters } from '../composables/useFilters'
|
||||
import { formatSilver, formatItemId, tierStyle, itemImageUrl } from '../utils/formatting'
|
||||
import { formatSilver, formatItemId, formatLastUpdated, tierStyle, itemImageUrl } from '../utils/formatting'
|
||||
import type { Tier, JournalType } from '../types/crafting'
|
||||
|
||||
const vFocus = { mounted: (el: HTMLElement) => el.focus() }
|
||||
|
||||
const { orderItems, upsert, remove, clear } = useProductionOrder()
|
||||
const { getPrice } = useAlbionPrices()
|
||||
const { getPrice, isManualPrice, getManualEntry, setManualPrice, clearManualPrice } = useAlbionPrices()
|
||||
const { filters } = useFilters()
|
||||
|
||||
// ─── Inline price editing ─────────────────────────────────────────────────────
|
||||
|
||||
const editingItemId = ref<string | null>(null)
|
||||
const inputValue = ref('')
|
||||
|
||||
function startEdit(itemId: string, current: number) {
|
||||
editingItemId.value = itemId
|
||||
inputValue.value = current > 0 ? String(current) : ''
|
||||
}
|
||||
|
||||
function saveEdit(itemId: string) {
|
||||
const v = Math.round(Number(inputValue.value))
|
||||
if (!inputValue.value) {
|
||||
clearManualPrice(itemId, filters.value.city)
|
||||
} else if (v > 0) {
|
||||
setManualPrice(itemId, filters.value.city, v)
|
||||
}
|
||||
editingItemId.value = null
|
||||
inputValue.value = ''
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingItemId.value = null
|
||||
inputValue.value = ''
|
||||
}
|
||||
|
||||
function priceButtonClass(itemId: string, currentPrice: number): string {
|
||||
if (isManualPrice(itemId, filters.value.city)) 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 {
|
||||
const exact = currentPrice > 0 ? currentPrice.toLocaleString() : null
|
||||
const entry = getManualEntry(itemId, filters.value.city)
|
||||
if (entry && isManualPrice(itemId, filters.value.city)) {
|
||||
return exact
|
||||
? `${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'
|
||||
}
|
||||
|
||||
// Laborer journal fills ≈ 13 crafts per book (ratio is constant across tiers)
|
||||
const CRAFTS_PER_JOURNAL = 13
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { AlbionCity } from './api'
|
||||
import type { Tier, Enchantment } from './crafting'
|
||||
|
||||
export type VariantType = 'basic' | 'artifact' | 'avalon' | 'crystal'
|
||||
|
||||
export const ALL_VARIANTS: VariantType[] = ['basic', 'artifact', 'avalon', 'crystal']
|
||||
|
||||
export interface FilterState {
|
||||
city: AlbionCity
|
||||
tiers: Set<Tier>
|
||||
@@ -8,4 +12,5 @@ export interface FilterState {
|
||||
rrr: number
|
||||
nameFilter: string
|
||||
enchantments: Set<Enchantment> | null
|
||||
variants: Set<VariantType> | null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user