Files
albion-crafting-calc/src/pages/ProductionPage.vue
2026-03-04 17:01:08 -05:00

287 lines
12 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">Item</th>
<th class="text-center px-3 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wider w-28">Qty</th>
<th class="text-right px-4 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wider">Cost / unit</th>
<th class="w-8"></th>
</tr>
</thead>
<tbody>
<tr
v-for="item in computedItems"
:key="item.recipe.outputItemId"
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>
<span class="text-xs text-gray-500">{{ item.recipe.category }}</span>
</div>
</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))"
/>
</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)"
></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>
</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>
</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>
</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>
</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 ({{ filters.rrr }}%)</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 } 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 type { Tier, JournalType } from '../types/crafting'
const { orderItems, upsert, remove, clear } = useProductionOrder()
const { getPrice } = useAlbionPrices()
const { filters } = useFilters()
// Laborer journal fills ≈ 13 crafts per book (ratio is constant across tiers)
const CRAFTS_PER_JOURNAL = 13
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
const rrrFactor = 1 - filters.value.rrr / 100
return orderItems.value.map(({ recipe, qty }) => {
let rawCost = 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 {
rawCost += entry.sell_price_min * ing.quantity
}
}
const craftCostPerUnit = rawCost * rrrFactor
return { recipe, qty, craftCostPerUnit, totalCraftCost: craftCostPerUnit * qty, missingPrices }
})
})
// ─── 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(() => {
const rrrFactor = 1 - filters.value.rrr / 100
let rawCost = 0
let totalCrafts = 0
let totalItems = 0
for (const item of computedItems.value) {
if (!item.missingPrices) {
rawCost += item.craftCostPerUnit / rrrFactor * item.qty // back to raw
}
totalCrafts += item.qty
totalItems += item.qty
}
const effectiveCost = rawCost * rrrFactor
const rrrSavings = rawCost - effectiveCost
const totalJournals = Math.ceil(totalCrafts / CRAFTS_PER_JOURNAL)
return { rawCost, effectiveCost, rrrSavings, totalCrafts, totalItems, totalJournals }
})
const journalRows = computed(() => {
const map = new Map<string, { journalType: JournalType; tier: Tier; crafts: 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, crafts: 0 }
map.set(key, { ...cur, crafts: cur.crafts + 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.crafts / CRAFTS_PER_JOURNAL),
journalImgUrl: itemImageUrl(`T${row.tier}_${JOURNAL_ITEM_ID[row.journalType]}`),
}))
})
</script>