384 lines
17 KiB
Vue
384 lines
17 KiB
Vue
<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>
|