feat(ventas): structured item selector with weight calculation and inventory usage
CI / Build Native (push) Successful in 6m50s

This commit is contained in:
2026-08-23 18:26:42 -04:00
parent 30037bc5cb
commit a654020655
3 changed files with 271 additions and 42 deletions
@@ -0,0 +1,79 @@
window.shots = {
WEIGHTS: {
Soulshot: { D: 3, C: 3, B: 2, A: 2, S: 2 },
Spiritshot: { D: 5, C: 3, B: 3, A: 2, S: 2 },
'Blessed Spiritshot': { D: 5, C: 3, B: 3, A: 2, S: 2 },
},
TIPOS: ['Soulshot', 'Spiritshot', 'Blessed Spiritshot'],
GRADOS: ['D', 'C', 'B', 'A', 'S'],
itemWeight(tipo, grado) {
return this.WEIGHTS[tipo]?.[grado] ?? 0;
},
maxWeight(c) {
return (c.baseWeight || 115230) * (1 + (c.skillLevel || 0));
},
capacityOf(c, tipo, grado) {
const maxW = this.maxWeight(c);
const w = this.itemWeight(tipo, grado);
return w > 0 ? Math.floor(maxW / w) : 0;
},
totalCapacityOf(c) {
let total = 0;
for (const t of this.TIPOS) {
for (const g of this.GRADOS) {
total += this.capacityOf(c, t, g);
}
}
return total;
},
tipoShort(tipo) {
return { Soulshot: 'SS', Spiritshot: 'SPS', 'Blessed Spiritshot': 'BSS' }[tipo] || tipo;
},
calcRowWeight(tipo, grado, cantidad) {
return this.itemWeight(tipo, grado) * cantidad;
},
calcTotalWeight(items) {
return items.reduce((sum, item) => sum + this.calcRowWeight(item.tipo, item.grado, item.cantidad || 0), 0);
},
calcInventoryUsage(character, items) {
const maxW = this.maxWeight(character);
const totalW = this.calcTotalWeight(items);
return {
total: totalW,
max: maxW,
pct: maxW > 0 ? Math.round((totalW / maxW) * 100) : 0,
};
},
formatItemsDescription(items) {
return items
.filter(i => i.cantidad > 0 && i.tipo && i.grado)
.map(i => `${this.tipoShort(i.tipo)} ${i.grado} × ${Number(i.cantidad).toLocaleString('es-CL')}`)
.join('\n');
},
parseItemsDescription(text) {
if (!text) return [];
const lines = text.trim().split('\n');
const parsed = [];
for (const line of lines) {
const m = line.trim().match(/^(SS|SPS|BSS|\w+)\s+([DBCSA])\s*[×x]\s*([\d.,]+)/i);
if (!m) continue;
const tipoMap = { SS: 'Soulshot', SPS: 'Spiritshot', BSS: 'Blessed Spiritshot' };
const tipo = tipoMap[m[1]] || m[1];
const grado = m[2].toUpperCase();
const cantidad = parseInt(m[3].replace(/[.,]/g, ''), 10);
if (!this.WEIGHTS[tipo]?.[grado]) continue;
parsed.push({ uid: crypto.randomUUID(), tipo, grado, cantidad });
}
return parsed;
},
};
@@ -216,10 +216,11 @@
</p> </p>
</footer> </footer>
<script src="/static/js/defaults.js?v=5"></script> <script src="/static/js/defaults.js?v=6"></script>
<script src="/static/js/api.js?v=5"></script> <script src="/static/js/api.js?v=6"></script>
<script src="/static/js/app.js?v=5"></script> <script src="/static/js/shots.js?v=6"></script>
<script defer src="/static/js/alpine.min.js?v=5"></script> <script src="/static/js/app.js?v=6"></script>
<script defer src="/static/js/alpine.min.js?v=6"></script>
<script> <script>
document.addEventListener('alpine:init', () => { document.addEventListener('alpine:init', () => {
@@ -229,13 +230,9 @@
submitting: false, submitting: false,
error: null, error: null,
SHOT_WEIGHTS: { SHOT_WEIGHTS: window.shots.WEIGHTS,
Soulshot: { D: 3, C: 3, B: 2, A: 2, S: 2 }, TIPOS: window.shots.TIPOS,
Spiritshot: { D: 5, C: 3, B: 3, A: 2, S: 2 }, GRADOS: window.shots.GRADOS,
'Blessed Spiritshot': { D: 5, C: 3, B: 3, A: 2, S: 2 },
},
TIPOS: ['Soulshot', 'Spiritshot', 'Blessed Spiritshot'],
GRADOS: ['D', 'C', 'B', 'A', 'S'],
form: { form: {
name: '', name: '',
@@ -293,27 +290,19 @@
}, },
tipoShort(tipo) { tipoShort(tipo) {
return { 'Soulshot': 'SS', 'Spiritshot': 'SPS', 'Blessed Spiritshot': 'BSS' }[tipo] || tipo; return window.shots.tipoShort(tipo);
}, },
maxWeightOf(c) { maxWeightOf(c) {
return (c.baseWeight || 115230) * (1 + (c.skillLevel || 0)); return window.shots.maxWeight(c);
}, },
capacityOf(c, tipo, grado) { capacityOf(c, tipo, grado) {
const maxW = this.maxWeightOf(c); return window.shots.capacityOf(c, tipo, grado);
const w = this.SHOT_WEIGHTS[tipo]?.[grado] || 0;
return w > 0 ? Math.floor(maxW / w) : 0;
}, },
totalCapacityOf(c) { totalCapacityOf(c) {
let total = 0; return window.shots.totalCapacityOf(c);
for (const t of this.TIPOS) {
for (const g of this.GRADOS) {
total += this.capacityOf(c, t, g);
}
}
return total;
}, },
async updateLevel(c) { async updateLevel(c) {
@@ -21,7 +21,7 @@
<p class="text-sm text-muted">Duración por defecto: 12 horas</p> <p class="text-sm text-muted">Duración por defecto: 12 horas</p>
</header> </header>
<form @submit.prevent="submitSale()"> <form @submit.prevent="submitSale()">
<div class="grid" style="grid-template-columns: 1fr 2fr;"> <div class="grid" style="grid-template-columns: 1fr 1fr;">
<label> <label>
<span class="text-sm text-muted">Nombre del personaje</span> <span class="text-sm text-muted">Nombre del personaje</span>
<input type="text" <input type="text"
@@ -36,15 +36,100 @@
<option :value="c.name"></option> <option :value="c.name"></option>
</template> </template>
</datalist> </datalist>
<label>
<span class="text-sm text-muted">Qué vende</span>
<textarea x-model="form.itemsDescription"
rows="3"
maxlength="2000"
required
placeholder="p.ej. SS B-grade x1000, SPS A-grade x500..."></textarea>
</label>
</div> </div>
<div style="margin: 0.75rem 0;">
<p class="text-sm text-muted" style="margin: 0 0 0.5rem 0;">Items en venta</p>
<div class="table-wrapper" style="max-width: 560px;">
<table style="font-size: 0.8rem; margin: 0;">
<thead>
<tr>
<th class="cell-label text-left">Tipo</th>
<th class="cell-label text-center">Grado</th>
<th class="cell-label text-right">Cantidad</th>
<th class="cell-label text-right">Peso unit.</th>
<th class="cell-label text-right">Subtotal</th>
<th></th>
</tr>
</thead>
<tbody>
<template x-for="(item, idx) in form.items" :key="item.uid">
<tr>
<td>
<select x-model="item.tipo"
style="width: 100%; padding: 0.25rem 0.4rem; font-size: 0.8rem;">
<option value="">— tipo —</option>
<template x-for="t in shots.TIPOS" :key="t">
<option :value="t" x-text="t"></option>
</template>
</select>
</td>
<td>
<select x-model="item.grado"
style="width: 100%; padding: 0.25rem 0.4rem; font-size: 0.8rem; text-align: center;">
<option value="">—</option>
<template x-for="g in shots.GRADOS" :key="g">
<option :value="g" x-text="g"></option>
</template>
</select>
</td>
<td>
<input type="number"
min="1"
step="1"
x-model.number="item.cantidad"
placeholder="0"
style="width: 80px; padding: 0.25rem 0.4rem; font-size: 0.8rem; text-align: right;"/>
</td>
<td class="text-mono text-right text-muted" x-text="shots.itemWeight(item.tipo, item.grado) || '—'"></td>
<td class="text-mono text-right" x-text="fmtAdena(shots.calcRowWeight(item.tipo, item.grado, item.cantidad || 0))"></td>
<td>
<button type="button"
@click="removeItem(idx)"
style="padding: 0.1rem 0.4rem; font-size: 0.75rem; background: none; border: 1px solid var(--pico-muted-border-color); cursor: pointer; color: var(--pico-muted-color);">
×
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<button type="button"
@click="addItem()"
style="margin-top: 0.4rem; font-size: 0.75rem; padding: 0.25rem 0.6rem;">
+ Agregar item
</button>
<template x-if="form.items.length > 0">
<div class="flex-row gap-3 mt-3" style="flex-wrap: wrap; align-items: flex-start;">
<div>
<p class="text-xs text-muted mb-1">Peso total</p>
<p class="text-mono text-bold" x-text="fmtAdena(weightInfo().total || 0) + ' adena'"></p>
</div>
<template x-if="selectedCharacter()">
<div>
<p class="text-xs text-muted mb-1">Inventario del personaje</p>
<p class="text-mono">
<span x-text="fmtAdena(weightInfo().total || 0)"></span>
<span class="text-muted"> / </span>
<span x-text="fmtAdena(weightInfo().max || 0)"></span>
<span class="text-muted"> (</span>
<span x-text="weightInfo().pct + '%'"></span>
<span class="text-muted">)</span>
</p>
<div style="width: 120px; height: 6px; background: var(--pico-muted-border-color); border-radius: 3px; margin-top: 0.25rem;">
<div :style="'height: 100%; border-radius: 3px; width: ' + Math.min(100, weightInfo().pct) + '%; background: ' + inventoryBarColor() + ';'"></div>
</div>
</div>
</template>
<template x-if="!selectedCharacter() && form.items.length > 0">
<p class="text-xs text-muted" style="align-self: center;">Seleccioná un personaje para ver uso de inventario</p>
</template>
</div>
</template>
</div>
<div class="flex-row gap-2" style="align-items: flex-end;"> <div class="flex-row gap-2" style="align-items: flex-end;">
<label style="flex: 0 0 auto;"> <label style="flex: 0 0 auto;">
<span class="text-sm text-muted">Hora de inicio</span> <span class="text-sm text-muted">Hora de inicio</span>
@@ -53,7 +138,7 @@
</label> </label>
<button type="submit" <button type="submit"
class="primary" class="primary"
:disabled="submitting"> :disabled="submitting || !canSubmit()">
<span x-show="!submitting">Agregar</span> <span x-show="!submitting">Agregar</span>
<span x-show="submitting">Guardando…</span> <span x-show="submitting">Guardando…</span>
</button> </button>
@@ -96,6 +181,21 @@
</div> </div>
</header> </header>
<p class="text-sm" style="white-space: pre-wrap; margin: 0.5rem 0;" x-text="sale.itemsDescription"></p> <p class="text-sm" style="white-space: pre-wrap; margin: 0.5rem 0;" x-text="sale.itemsDescription"></p>
<template x-if="getCardWeightInfo(sale)">
<div class="flex-row gap-2" style="flex-wrap: wrap; margin: 0.5rem 0; font-size: 0.75rem;">
<span class="text-muted">Peso: <span class="text-mono" x-text="fmtAdena(getCardWeightInfo(sale).total)"></span></span>
<template x-if="getCardWeightInfo(sale).character">
<span class="text-muted">
Inventario: <span class="text-mono" x-text="fmtAdena(getCardWeightInfo(sale).total)"></span>
<span class="text-muted"> / </span>
<span class="text-mono" x-text="fmtAdena(getCardWeightInfo(sale).max)"></span>
<span class="text-muted"> (</span>
<span class="text-mono" :style="'color:' + getCardWeightColor(sale)" x-text="getCardWeightInfo(sale).pct + '%'"></span>
<span class="text-muted">)</span>
</span>
</template>
</div>
</template>
<div style="margin: 0.75rem 0; text-align: center;"> <div style="margin: 0.75rem 0; text-align: center;">
<p class="text-mono text-xl text-bold" <p class="text-mono text-xl text-bold"
:class="getCountdownClass(sale)" :class="getCountdownClass(sale)"
@@ -125,10 +225,11 @@
</p> </p>
</footer> </footer>
<script src="/static/js/defaults.js?v=5"></script> <script src="/static/js/defaults.js?v=6"></script>
<script src="/static/js/api.js?v=5"></script> <script src="/static/js/api.js?v=6"></script>
<script src="/static/js/app.js?v=5"></script> <script src="/static/js/shots.js?v=6"></script>
<script defer src="/static/js/alpine.min.js?v=5"></script> <script src="/static/js/app.js?v=6"></script>
<script defer src="/static/js/alpine.min.js?v=6"></script>
<script> <script>
document.addEventListener('alpine:init', () => { document.addEventListener('alpine:init', () => {
@@ -145,10 +246,12 @@
form: { form: {
characterName: '', characterName: '',
itemsDescription: '', items: [],
startedAt: '', startedAt: '',
}, },
shots: window.shots,
get sortedSales() { get sortedSales() {
return [...this.sales].sort((a, b) => { return [...this.sales].sort((a, b) => {
const aExpired = this.isExpired(a); const aExpired = this.isExpired(a);
@@ -199,9 +302,42 @@
} }
}, },
addItem() {
this.form.items.push({ uid: crypto.randomUUID(), tipo: '', grado: '', cantidad: null });
},
removeItem(idx) {
this.form.items.splice(idx, 1);
},
canSubmit() {
return this.form.items.some(i => i.tipo && i.grado && i.cantidad > 0);
},
selectedCharacter() {
return this.characters.find(c => c.name === this.form.characterName) || null;
},
weightInfo() {
const items = this.form.items.filter(i => i.tipo && i.grado && i.cantidad > 0);
if (items.length === 0) return { total: 0, max: 0, pct: 0 };
const total = window.shots.calcTotalWeight(items);
const char = this.selectedCharacter();
if (!char) return { total, max: 0, pct: 0 };
return window.shots.calcInventoryUsage(char, items);
},
inventoryBarColor() {
const pct = this.weightInfo().pct;
if (pct === 0) return 'var(--pico-muted-border-color)';
if (pct > 100) return '#dc2626';
if (pct > 80) return '#d97706';
return '#059669';
},
async submitSale() { async submitSale() {
if (!this.form.characterName.trim() || !this.form.itemsDescription.trim()) { if (!this.form.characterName.trim() || !this.canSubmit()) {
this.error = 'Completá todos los campos'; this.error = 'Completá nombre del personaje y al menos un item válido';
return; return;
} }
this.error = null; this.error = null;
@@ -211,13 +347,15 @@
if (this.form.startedAt) { if (this.form.startedAt) {
startedAt = new Date(this.form.startedAt).toISOString(); startedAt = new Date(this.form.startedAt).toISOString();
} }
const items = this.form.items.filter(i => i.tipo && i.grado && i.cantidad > 0);
const itemsDescription = window.shots.formatItemsDescription(items);
await window.api.createSale({ await window.api.createSale({
characterName: this.form.characterName.trim(), characterName: this.form.characterName.trim(),
itemsDescription: this.form.itemsDescription.trim(), itemsDescription: itemsDescription,
startedAt: startedAt, startedAt: startedAt,
}); });
this.form.characterName = ''; this.form.characterName = '';
this.form.itemsDescription = ''; this.form.items = [];
this.form.startedAt = ''; this.form.startedAt = '';
await this.loadSales(); await this.loadSales();
} catch (e) { } catch (e) {
@@ -298,6 +436,29 @@
hour: '2-digit', minute: '2-digit', hour: '2-digit', minute: '2-digit',
}); });
}, },
getCardWeightInfo(sale) {
const parsed = window.shots.parseItemsDescription(sale.itemsDescription);
if (parsed.length === 0) return null;
const total = window.shots.calcTotalWeight(parsed);
const char = this.characters.find(c => c.name === sale.characterName);
if (!char) return { total, max: 0, pct: 0, character: false };
const usage = window.shots.calcInventoryUsage(char, parsed);
return { ...usage, character: true };
},
getCardWeightColor(sale) {
const info = this.getCardWeightInfo(sale);
if (!info) return 'inherit';
if (info.pct > 100) return '#dc2626';
if (info.pct > 80) return '#d97706';
return '#059669';
},
fmtAdena(n) {
if (n == null) return '—';
return Math.round(n).toLocaleString('es-CL');
},
})); }));
}); });