Files
albion-crafting-calc/src/pages/ProductionPage.vue

438 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="p-6 space-y-6">
<!-- Empty state -->
<div v-if="orderItems.length === 0" class="flex flex-col items-center justify-center py-24 text-gray-500">
<p class="text-lg font-medium text-gray-400">No items in production</p>
<p class="text-sm mt-1">Click the <span class="text-amber-400 font-mono">+</span> button on any item in the Calculator</p>
</div>
<template v-else>
<!-- Production Order -->
<section>
<div class="flex items-center justify-between mb-3">
<h2 class="text-sm font-semibold text-gray-400 uppercase tracking-wider">Production Order</h2>
<button
class="text-xs text-red-400 hover:text-red-300 transition-colors"
@click="clear()"
>Clear all</button>
</div>
<div class="bg-gray-800/50 border border-gray-700 rounded-xl overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-700 bg-gray-700/30">
<th
class="text-left px-4 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-200 transition-colors select-none"
@click="toggleSort('name')"
><span class="flex items-center gap-1">Item <span class="text-amber-400">{{ sortIndicator('name') }}</span></span></th>
<th
class="text-left px-4 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-200 transition-colors select-none"
@click="toggleSort('station')"
><span class="flex items-center gap-1">Station <span class="text-amber-400">{{ sortIndicator('station') }}</span></span></th>
<th
class="text-left px-4 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-200 transition-colors select-none"
@click="toggleSort('focus')"
><span class="flex items-center gap-1">Focus <span class="text-amber-400">{{ sortIndicator('focus') }}</span></span></th>
<th
class="text-center px-3 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wider w-28 cursor-pointer hover:text-gray-200 transition-colors select-none"
@click="toggleSort('qty')"
><span class="flex items-center justify-center gap-1">Qty <span class="text-amber-400">{{ sortIndicator('qty') }}</span></span></th>
<th
class="text-right px-4 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-200 transition-colors select-none"
@click="toggleSort('cost')"
><span class="flex items-center justify-end gap-1">Cost / unit <span class="text-amber-400">{{ sortIndicator('cost') }}</span></span></th>
<th class="w-8"></th>
</tr>
</thead>
<tbody>
<tr
v-for="item in sortedItems"
:key="`${item.recipe.outputItemId}|${item.focus ? 'f' : 'b'}`"
class="border-t border-gray-700/40 hover:bg-gray-700/20"
>
<td class="px-4 py-2">
<div class="flex items-center gap-2">
<img
:src="itemImageUrl(item.recipe.outputItemId)"
:alt="item.recipe.displayName"
class="w-12 h-12 -my-2 rounded flex-shrink-0"
/>
<span class="text-gray-200 font-medium">{{ item.recipe.displayName }}</span>
</div>
</td>
<td class="px-4 py-2">
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold" :class="stationClass(item.recipe.journalType)">{{ stationLabel(item.recipe.journalType) }}</span>
</td>
<td class="px-4 py-2">
<span v-if="item.focus" class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-violet-600/20 border border-violet-600/40 text-violet-400">Focus</span>
<span v-else class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-gray-700/50 border border-gray-600/40 text-gray-400">No Focus</span>
</td>
<td class="px-3 py-2 text-center">
<input
type="number"
min="1"
:value="item.qty"
class="w-20 bg-gray-900 border border-gray-700 rounded px-2 py-0.5 text-center text-sm font-mono text-gray-200 focus:outline-none focus:border-amber-500"
@change="upsert(item.recipe, Number(($event.target as HTMLInputElement).value), item.focus)"
/>
</td>
<td class="px-4 py-2 text-right font-mono text-gray-300">
<span v-if="item.missingPrices" class="text-red-400 text-xs">Missing prices</span>
<span v-else>{{ formatSilver(item.craftCostPerUnit) }}</span>
</td>
<td class="px-2 py-2">
<button
class="text-gray-600 hover:text-red-400 transition-colors text-sm leading-none"
title="Remove"
@click="remove(item.recipe.outputItemId, item.focus)"
></button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- Bottom section: Materials + Summary -->
<div class="flex gap-6 items-start">
<!-- Materials Needed + Journals -->
<section class="flex-[2] min-w-0 space-y-4">
<div>
<h2 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Materials Needed</h2>
<div class="bg-gray-800/50 border border-gray-700 rounded-xl overflow-hidden">
<table class="w-full text-xs">
<thead>
<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>
<tr
v-for="mat in aggregatedMaterials"
:key="mat.itemId"
class="border-t border-gray-700/40 hover:bg-gray-700/20"
>
<td class="px-4 py-1.5 text-gray-300">
<div class="flex items-center gap-2">
<img :src="itemImageUrl(mat.itemId)" :alt="mat.displayName" class="w-10 h-10 -my-1 rounded flex-shrink-0" />
{{ mat.displayName }}
</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>
</div>
</div>
<!-- Journals -->
<div>
<div class="bg-gray-800/50 border border-gray-700 rounded-xl overflow-hidden">
<table class="w-full text-xs">
<thead>
<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">Journal</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">Total Fame</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in journalRows"
:key="`${row.journalType}::${row.tier}`"
class="border-t border-gray-700/40 hover:bg-gray-700/20"
>
<td class="px-4 py-1.5 text-gray-300">
<div class="flex items-center gap-2">
<img :src="row.journalImgUrl" :alt="row.journalName" class="w-10 h-10 -my-1 rounded flex-shrink-0" />
{{ row.journalName }}
</div>
</td>
<td class="px-4 py-1.5 text-right font-mono text-gray-200 font-medium">{{ row.journals.toLocaleString() }}</td>
<td class="px-4 py-1.5 text-right font-mono text-gray-300">{{ row.totalFame.toLocaleString() }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- Summary -->
<section class="flex-1 min-w-0">
<h2 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Summary</h2>
<div class="bg-gray-800/50 border border-gray-700 rounded-xl p-4 space-y-2 text-xs font-mono">
<div class="flex justify-between gap-4">
<span class="text-gray-400">Raw materials cost</span>
<span class="text-gray-200">{{ formatSilver(totals.rawCost) }}</span>
</div>
<div class="flex justify-between gap-4">
<span class="text-gray-400">RRR savings</span>
<span class="text-green-400">{{ formatSilver(totals.rrrSavings) }}</span>
</div>
<div class="flex justify-between gap-4 border-t border-gray-700/50 pt-2">
<span class="text-gray-200 font-semibold">Total craft cost</span>
<span class="text-gray-100 font-semibold">{{ formatSilver(totals.effectiveCost) }}</span>
</div>
<div class="flex justify-between gap-4 border-t border-gray-700/50 pt-2">
<span class="text-gray-400">Total items</span>
<span class="text-gray-200">{{ totals.totalItems }}</span>
</div>
<div class="flex justify-between gap-4">
<span class="text-gray-400">Crafting actions</span>
<span class="text-gray-200">{{ totals.totalCrafts }}</span>
</div>
</div>
</section>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useProductionOrder } from '../composables/useProductionOrder'
import { useAlbionPrices } from '../composables/useAlbionPrices'
import { useFilters } from '../composables/useFilters'
import { formatSilver, formatItemId, formatLastUpdated, itemImageUrl } from '../utils/formatting'
import { fameTypeOf, craftsPerJournal } from '../data/constants'
import { localProductionBonus, rrrFromBonus, FOCUS_LPB, isRrrExempt } from '../data/cityBonuses'
import type { Tier, JournalType } from '../types/crafting'
const vFocus = { mounted: (el: HTMLElement) => el.focus() }
const { orderItems, upsert, remove, clear } = useProductionOrder()
const { getPrice, 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 {
return currentPrice > 0 ? 'text-amber-400 hover:text-amber-200' : 'text-gray-500 hover:text-amber-400'
}
function priceTitle(itemId: string, currentPrice: number): string {
const entry = getManualEntry(itemId, filters.value.city)
if (entry) {
return `${currentPrice.toLocaleString()} — set ${formatLastUpdated(new Date(entry.editedAt))} — click to edit`
}
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> = {
warrior: "Blacksmith's",
hunter: "Fletcher's",
mage: "Imbuer's",
toolmaker: "Toolmaker's",
}
const JOURNAL_ITEM_ID: Record<JournalType, string> = {
warrior: 'JOURNAL_WARRIOR',
hunter: 'JOURNAL_HUNTER',
mage: 'JOURNAL_MAGE',
toolmaker: 'JOURNAL_TOOLMAKER',
}
// ─── Per-item computed values ─────────────────────────────────────────────────
const computedItems = computed(() => {
const city = filters.value.city
return orderItems.value.map(({ recipe, qty, focus }) => {
let basicCost = 0
let artefactCost = 0
let missingPrices = false
for (const ing of recipe.ingredients) {
const entry = getPrice(ing.itemId, city)
if (!entry || entry.sell_price_min === 0) {
missingPrices = true
} else {
if (isRrrExempt(ing.itemId)) artefactCost += entry.sell_price_min * ing.quantity
else basicCost += entry.sell_price_min * ing.quantity
}
}
const rawCost = basicCost + artefactCost
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
}
})
})
// ─── Aggregated materials ─────────────────────────────────────────────────────
const aggregatedMaterials = computed(() => {
const city = filters.value.city
const map = new Map<string, { displayName: string; qty: number; unitPrice: number }>()
for (const { recipe, qty } of orderItems.value) {
for (const ing of recipe.ingredients) {
const entry = getPrice(ing.itemId, city)
const unitPrice = entry?.sell_price_min ?? 0
if (!map.has(ing.itemId)) {
map.set(ing.itemId, { displayName: formatItemId(ing.itemId), qty: 0, unitPrice })
}
map.get(ing.itemId)!.qty += ing.quantity * qty
}
}
return [...map.entries()]
.sort((a, b) => a[1].displayName.localeCompare(b[1].displayName))
.map(([itemId, m]) => ({ ...m, itemId, totalCost: m.qty * m.unitPrice }))
})
// ─── Totals + journals ────────────────────────────────────────────────────────
const totals = computed(() => {
let rawCost = 0
let effectiveCost = 0
let totalCrafts = 0
let totalItems = 0
for (const item of computedItems.value) {
if (!item.missingPrices) {
rawCost += item.rawCostPerUnit * item.qty
effectiveCost += item.craftCostPerUnit * item.qty
}
totalCrafts += item.qty
totalItems += item.qty
}
const rrrSavings = rawCost - effectiveCost
return { rawCost, effectiveCost, rrrSavings, totalCrafts, totalItems }
})
const journalRows = computed(() => {
const map = new Map<string, { journalType: JournalType; tier: Tier; journalFills: number; totalFame: number }>()
for (const { recipe, qty } of orderItems.value) {
const key = `${recipe.journalType}::${recipe.tier}`
const cur = map.get(key) ?? { journalType: recipe.journalType, tier: recipe.tier as Tier, journalFills: 0, totalFame: 0 }
const fills = qty / craftsPerJournal(fameTypeOf(recipe.outputItemId), recipe.enchantment)
map.set(key, { ...cur, journalFills: cur.journalFills + fills, totalFame: cur.totalFame + recipe.famePerCraft * qty })
}
return [...map.values()]
.sort((a, b) => a.journalType.localeCompare(b.journalType) || a.tier - b.tier)
.map(row => ({
...row,
journalName: JOURNAL_DISPLAY_NAME[row.journalType],
journals: Math.ceil(row.journalFills),
journalImgUrl: itemImageUrl(`T${row.tier}_${JOURNAL_ITEM_ID[row.journalType]}`),
}))
})
</script>