initial commit

This commit is contained in:
Jo Blo
2026-03-04 17:01:08 -05:00
commit 39d61d797d
51 changed files with 7864 additions and 0 deletions

View 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>

View File

@@ -0,0 +1,47 @@
<template>
<div class="flex-1 min-w-0">
<!-- Empty state -->
<div
v-if="results.length === 0"
class="flex flex-col items-center justify-center py-20 text-gray-500"
>
<div class="text-4xl mb-4">📊</div>
<p class="text-lg font-medium text-gray-400">No results</p>
<p class="text-sm mt-1">Try adjusting your filters or refreshing prices</p>
</div>
<!-- Table -->
<div v-else class="overflow-x-auto rounded-xl border border-gray-700 bg-gray-800/50">
<table class="w-full text-left border-collapse">
<TableHeader :sort-state="sortState" @sort="$emit('sort', $event)" />
<tbody>
<ProfitRow
v-for="(result, i) in results"
:key="`${result.recipe.outputItemId}-${i}`"
:result="result"
/>
</tbody>
</table>
</div>
<!-- Result count -->
<div v-if="results.length > 0" class="mt-2 text-xs text-gray-500 text-right">
Showing {{ results.length }} items
</div>
</div>
</template>
<script setup lang="ts">
import TableHeader from './TableHeader.vue'
import ProfitRow from './ProfitRow.vue'
import type { ProfitResult, SortField, SortState } from '../../types/crafting'
defineProps<{
results: ProfitResult[]
sortState: SortState
}>()
defineEmits<{
sort: [field: SortField]
}>()
</script>

View File

@@ -0,0 +1,42 @@
<template>
<thead class="sticky top-0 z-10">
<tr class="bg-gray-800 border-b border-gray-700">
<th
v-for="col in columns"
:key="col.field"
class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider whitespace-nowrap select-none"
:class="col.sortable ? 'cursor-pointer hover:text-gray-200 transition-colors' : ''"
@click="col.sortable && col.field !== 'status' ? $emit('sort', col.field as SortField) : undefined"
>
<span class="flex items-center gap-1">
{{ col.label }}
<span v-if="col.sortable && sortState.field === col.field" class="text-amber-400">
{{ sortState.direction === 'asc' ? '' : '' }}
</span>
<span v-else-if="col.sortable" class="text-gray-600"></span>
</span>
</th>
</tr>
</thead>
</template>
<script setup lang="ts">
import type { SortField, SortState } from '../../types/crafting'
const columns: { field: SortField | 'status' | 'bill'; label: string; sortable: boolean }[] = [
{ field: 'displayName', label: 'Item', sortable: true },
{ field: 'tier', label: 'Tier', sortable: true },
{ field: 'variantType', label: 'Variant', sortable: true },
{ field: 'materialCost', label: 'Craft Cost', sortable: true },
{ field: 'status', label: 'Price Age', sortable: false },
{ field: 'bill', label: '', sortable: false },
]
defineProps<{
sortState: SortState
}>()
defineEmits<{
sort: [field: SortField]
}>()
</script>