Files
albion-crafting-calc/src/components/table/ProfitRow.vue

384 lines
17 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>
<tr
class="border-b border-gray-700/50 hover:bg-gray-700/30 transition-colors cursor-pointer select-none"
:class="result.missingPrices ? 'opacity-60' : ''"
@click="expanded = !expanded"
>
<!-- Expand arrow -->
<td class="pl-4 pr-1 py-3 w-4">
<span class="text-gray-500 text-xs transition-transform duration-150 inline-block" :class="expanded ? 'rotate-90' : ''"></span>
</td>
<!-- 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">
<img
:src="itemImageUrl(result.recipe.outputItemId)"
:alt="result.recipe.displayName"
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-xs text-gray-500">{{ result.recipe.category }}</span>
</div>
</td>
<!-- Tier badge -->
<td class="px-4 py-3">
<span
class="inline-flex items-center justify-center w-8 h-6 rounded text-xs font-bold"
:style="tierEnchantStyle(result.recipe.tier, result.recipe.enchantment)"
>
T{{ result.recipe.tier }}
</span>
</td>
<!-- Effective material cost (after RRR) -->
<td class="px-4 py-3 text-right text-sm font-mono text-gray-300"
:title="result.effectiveMaterialCost > 0 ? result.effectiveMaterialCost.toLocaleString() : undefined">
{{ formatSilver(result.effectiveMaterialCost) }}
</td>
<!-- +15% markup (no focus) -->
<td class="px-4 py-3 text-right text-sm font-mono text-blue-300"
:title="result.effectiveMaterialCost > 0 ? Math.round(result.effectiveMaterialCost * 1.15).toLocaleString() : undefined">
{{ result.effectiveMaterialCost > 0 ? formatSilver(Math.round(result.effectiveMaterialCost * 1.15)) : '—' }}
</td>
<!-- +30% markup (no focus) -->
<td class="px-4 py-3 text-right text-sm font-mono text-emerald-300"
:title="result.effectiveMaterialCost > 0 ? Math.round(result.effectiveMaterialCost * 1.30).toLocaleString() : undefined">
{{ result.effectiveMaterialCost > 0 ? formatSilver(Math.round(result.effectiveMaterialCost * 1.30)) : '—' }}
</td>
<!-- Add to bill (no focus) -->
<td class="px-3 py-3 text-right whitespace-nowrap" @click.stop>
<div v-if="addingMode === 'nofocus'" 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-amber-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-amber-500 hover:text-amber-300 text-xs leading-none" @click="confirmBill"></button>
<button class="text-gray-500 hover:text-gray-300 text-xs leading-none" @click="cancelBill"></button>
</div>
<div v-else-if="isInOrderNoFocus" class="inline-flex items-center gap-1">
<button
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-600/20 border border-amber-600/40 text-amber-400 text-xs font-mono hover:border-amber-500 transition-colors"
@click="startBill('nofocus')"
>×{{ currentQtyNoFocus }}</button>
<button
class="text-gray-600 hover:text-red-400 transition-colors text-xs leading-none"
title="Remove from order"
@click="remove(result.recipe.outputItemId, false)"
></button>
</div>
<button
v-else
class="w-6 h-6 rounded-full bg-gray-700 hover:bg-amber-600/30 hover:text-amber-400 text-gray-400 text-sm leading-none transition-colors inline-flex items-center justify-center"
title="Add to Bill of Production (no focus)"
@click="startBill('nofocus')"
>+</button>
</td>
<!-- Cost (with focus) -->
<td class="px-4 py-3 text-right text-sm font-mono text-violet-300 border-l border-gray-700/40"
:title="result.effectiveMaterialCostFocus > 0 ? result.effectiveMaterialCostFocus.toLocaleString() : undefined">
{{ formatSilver(result.effectiveMaterialCostFocus) }}
</td>
<!-- +15% markup (with focus) -->
<td class="px-4 py-3 text-right text-sm font-mono text-blue-300/70"
:title="result.effectiveMaterialCostFocus > 0 ? Math.round(result.effectiveMaterialCostFocus * 1.15).toLocaleString() : undefined">
{{ result.effectiveMaterialCostFocus > 0 ? formatSilver(Math.round(result.effectiveMaterialCostFocus * 1.15)) : '—' }}
</td>
<!-- +30% markup (with focus) -->
<td class="px-4 py-3 text-right text-sm font-mono text-emerald-300/70"
:title="result.effectiveMaterialCostFocus > 0 ? Math.round(result.effectiveMaterialCostFocus * 1.30).toLocaleString() : undefined">
{{ result.effectiveMaterialCostFocus > 0 ? formatSilver(Math.round(result.effectiveMaterialCostFocus * 1.30)) : '—' }}
</td>
<!-- Add to bill (with focus) -->
<td class="px-3 py-3 text-right whitespace-nowrap" @click.stop>
<div v-if="addingMode === 'focus'" class="inline-flex items-center gap-1">
<input
v-focus
type="number"
min="1"
v-model.number="billQty"
class="w-12 bg-gray-900 border border-violet-500 rounded px-1.5 py-0.5 text-center text-xs font-mono text-gray-200 focus:outline-none"
@keydown.enter="confirmBill"
@keydown.escape="cancelBill"
/>
<button class="text-violet-400 hover:text-violet-200 text-xs leading-none" @click="confirmBill"></button>
<button class="text-gray-500 hover:text-gray-300 text-xs leading-none" @click="cancelBill"></button>
</div>
<div v-else-if="isInOrderFocus" class="inline-flex items-center gap-1">
<button
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-violet-600/20 border border-violet-600/40 text-violet-400 text-xs font-mono hover:border-violet-500 transition-colors"
@click="startBill('focus')"
>×{{ currentQtyFocus }}</button>
<button
class="text-gray-600 hover:text-red-400 transition-colors text-xs leading-none"
title="Remove from order"
@click="remove(result.recipe.outputItemId, true)"
></button>
</div>
<button
v-else
class="w-6 h-6 rounded-full bg-gray-700 hover:bg-violet-600/30 hover:text-violet-400 text-gray-400 text-sm leading-none transition-colors inline-flex items-center justify-center"
title="Add to Bill of Production (with focus)"
@click="startBill('focus')"
>+</button>
</td>
<!-- Status -->
<td class="px-4 py-3 text-center">
<span
class="inline-block w-2.5 h-2.5 rounded-full"
:class="ageDotClass(result)"
:title="ageDotTitle(result)"
/>
</td>
</tr>
<!-- Expanded detail row -->
<tr v-if="expanded" class="border-b border-gray-700/50 bg-gray-900/60">
<td colspan="13" class="px-6 py-4">
<div class="flex gap-8">
<!-- Ingredients breakdown -->
<div class="flex-1">
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Ingredients</p>
<table class="w-full text-xs">
<thead>
<tr class="text-gray-500">
<th class="text-left font-normal pb-1">Material</th>
<th class="text-right font-normal pb-1">Qty</th>
<th class="text-right font-normal pb-1">Unit Price</th>
<th class="text-right font-normal pb-1">Total</th>
</tr>
</thead>
<tbody>
<tr
v-for="ing in result.ingredientBreakdown"
:key="ing.itemId"
class="border-t border-gray-700/30"
>
<td class="py-1 text-gray-300">
<div class="flex items-center gap-1.5">
<img :src="itemImageUrl(ing.itemId)" :alt="ing.displayName" class="w-6 h-6 rounded flex-shrink-0" />
{{ ing.displayName }}
</div>
</td>
<td class="py-1 text-right font-mono text-gray-400">× {{ ing.quantity }}</td>
<td class="py-1 text-right font-mono">
<div
v-if="editingItemId === ing.itemId"
class="flex items-center justify-end gap-1"
@click.stop
>
<input
v-focus
type="number"
min="1"
:placeholder="ing.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(ing.itemId)"
@keydown.escape="cancelEdit"
/>
<button class="text-amber-500 hover:text-amber-300 text-xs leading-none px-0.5" title="Save" @click="saveEdit(ing.itemId)"></button>
<button class="text-gray-500 hover:text-gray-300 text-xs leading-none px-0.5" title="Cancel" @click="cancelEdit"></button>
</div>
<button
v-else
class="group flex items-center gap-0.5 ml-auto"
:class="priceButtonClass(ing.itemId, ing.unitPrice)"
:title="priceTitle(ing.itemId, ing.unitPrice)"
@click.stop="startEdit(ing.itemId, ing.unitPrice)"
>
{{ ing.unitPrice > 0 ? formatSilver(ing.unitPrice) : '—' }}
<span class="opacity-0 group-hover:opacity-40 text-[10px] ml-0.5"></span>
</button>
</td>
<td class="py-1 text-right font-mono text-gray-200"
:title="ing.totalCost > 0 ? ing.totalCost.toLocaleString() : undefined">
{{ ing.totalCost > 0 ? formatSilver(ing.totalCost) : '—' }}
</td>
</tr>
</tbody>
</table>
</div>
<!-- Summary breakdown -->
<div class="min-w-[180px] border-l border-gray-700/50 pl-6">
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Summary</p>
<div class="space-y-1.5 text-xs font-mono">
<div class="flex justify-between gap-4">
<span class="text-gray-400">Raw Materials</span>
<span class="text-gray-200">{{ formatSilver(result.materialCost) }}</span>
</div>
<div class="flex justify-between gap-4 border-t border-gray-700/50 pt-1.5">
<span class="text-gray-400">RRR ({{ result.rrr.toFixed(1) }}%)</span>
<span class="text-gray-200">{{ formatSilver(result.effectiveMaterialCost) }}</span>
</div>
<div class="flex justify-between gap-4">
<span class="text-violet-400/80">RRR + Focus ({{ result.rrrFocus.toFixed(1) }}%)</span>
<span class="text-violet-300">{{ formatSilver(result.effectiveMaterialCostFocus) }}</span>
</div>
</div>
</div>
</div>
</td>
</tr>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { ProfitResult, Tier } from '../../types/crafting'
import { formatSilver, formatLastUpdated, tierEnchantStyle, itemImageUrl } from '../../utils/formatting'
import { useAlbionPrices } from '../../composables/useAlbionPrices'
import { useFilters } from '../../composables/useFilters'
import { useProductionOrder } from '../../composables/useProductionOrder'
const vFocus = { mounted: (el: HTMLElement) => el.focus() }
const props = defineProps<{
result: ProfitResult
}>()
const { getManualEntry, setManualPrice, clearManualPrice } = useAlbionPrices()
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('')
// ─── Bill of Production ───────────────────────────────────────────────────────
const addingMode = ref<null | 'nofocus' | 'focus'>(null)
const billQty = ref(1)
const isInOrderNoFocus = computed(() => inOrder(props.result.recipe.outputItemId, false))
const currentQtyNoFocus = computed(() => getQty(props.result.recipe.outputItemId, false))
const isInOrderFocus = computed(() => inOrder(props.result.recipe.outputItemId, true))
const currentQtyFocus = computed(() => getQty(props.result.recipe.outputItemId, true))
function startBill(mode: 'nofocus' | 'focus') {
const already = mode === 'nofocus' ? isInOrderNoFocus.value : isInOrderFocus.value
const qty = mode === 'nofocus' ? currentQtyNoFocus.value : currentQtyFocus.value
billQty.value = already ? qty : 1
addingMode.value = mode
}
function confirmBill() {
if (billQty.value > 0 && addingMode.value) {
upsert(props.result.recipe, billQty.value, addingMode.value === 'focus')
}
addingMode.value = null
}
function cancelBill() {
addingMode.value = null
}
// ─── Price age display ────────────────────────────────────────────────────────
function formatAge(ms: number): string {
const minutes = Math.floor(ms / 60_000)
if (minutes < 1) return 'just now'
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
return `${Math.floor(hours / 24)}d ago`
}
function ageDotClass(result: ProfitResult): string {
if (result.missingPrices) return 'bg-red-500'
if (result.priceAgeMs === null) return 'bg-gray-600'
const hours = result.priceAgeMs / 3_600_000
if (hours < 1) return 'bg-green-400'
if (hours < 4) return 'bg-yellow-400'
if (hours < 8) return 'bg-orange-400'
return 'bg-red-500'
}
function ageDotTitle(result: ProfitResult): string {
if (result.missingPrices) return 'Missing prices'
if (result.priceAgeMs === null) return 'No price data'
return `Prices last set ${formatAge(result.priceAgeMs)}`
}
// ─── Price cell helpers ───────────────────────────────────────────────────────
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'
}
// ─── Edit state ───────────────────────────────────────────────────────────────
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 && inputValue.value !== 0) {
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 = ''
}
</script>