initial commit
This commit is contained in:
294
src/components/table/ProfitRow.vue
Normal file
294
src/components/table/ProfitRow.vue
Normal file
@@ -0,0 +1,294 @@
|
||||
<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"
|
||||
>
|
||||
<!-- Item name -->
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-gray-500 text-xs transition-transform duration-150" :class="expanded ? 'rotate-90' : ''">▶</span>
|
||||
<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-sm font-mono text-gray-300"
|
||||
:title="result.effectiveMaterialCost > 0 ? result.effectiveMaterialCost.toLocaleString() : undefined">
|
||||
{{ formatSilver(result.effectiveMaterialCost) }}
|
||||
</td>
|
||||
|
||||
<!-- Price age -->
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="inline-block w-2.5 h-2.5 rounded-full"
|
||||
:class="ageDotClass(result)"
|
||||
:title="ageDotTitle(result)"
|
||||
/>
|
||||
</td>
|
||||
|
||||
<!-- Add to bill -->
|
||||
<td class="px-3 py-3 text-right whitespace-nowrap" @click.stop>
|
||||
<!-- Inline qty form -->
|
||||
<div v-if="addingToBill" 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>
|
||||
<!-- In-order badge + remove -->
|
||||
<div v-else-if="isInOrder" 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"
|
||||
>×{{ currentQty }}</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)"
|
||||
>✕</button>
|
||||
</div>
|
||||
<!-- Add button -->
|
||||
<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"
|
||||
@click="startBill"
|
||||
>+</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 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">
|
||||
<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">
|
||||
<span class="text-gray-400">RRR ({{ filters.rrr }}%)</span>
|
||||
<span class="text-green-400">−{{ formatSilver(result.materialCost - result.effectiveMaterialCost) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4 border-t border-gray-700/50 pt-1.5">
|
||||
<span class="text-gray-300 font-semibold">Craft Cost</span>
|
||||
<span class="font-semibold text-gray-100">{{ formatSilver(result.effectiveMaterialCost) }}</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 { isManualPrice, getManualEntry, setManualPrice, clearManualPrice } = useAlbionPrices()
|
||||
const { filters } = useFilters()
|
||||
const { upsert, remove, getQty, inOrder } = useProductionOrder()
|
||||
|
||||
const expanded = ref(false)
|
||||
const editingItemId = ref<string | null>(null)
|
||||
const inputValue = ref('')
|
||||
|
||||
// ─── Bill of Production ───────────────────────────────────────────────────────
|
||||
|
||||
const addingToBill = ref(false)
|
||||
const billQty = ref(1)
|
||||
|
||||
const isInOrder = computed(() => inOrder(props.result.recipe.outputItemId))
|
||||
const currentQty = computed(() => getQty(props.result.recipe.outputItemId))
|
||||
|
||||
function startBill() {
|
||||
billQty.value = isInOrder.value ? currentQty.value : 1
|
||||
addingToBill.value = true
|
||||
}
|
||||
|
||||
function confirmBill() {
|
||||
if (billQty.value > 0) upsert(props.result.recipe, billQty.value)
|
||||
addingToBill.value = false
|
||||
}
|
||||
|
||||
function cancelBill() {
|
||||
addingToBill.value = false
|
||||
}
|
||||
|
||||
// ─── 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 {
|
||||
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'
|
||||
}
|
||||
|
||||
// ─── 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>
|
||||
Reference in New Issue
Block a user