feat(ventas): structured item selector with weight calculation and inventory usage
CI / Build Native (push) Successful in 6m50s
CI / Build Native (push) Successful in 6m50s
This commit is contained in:
@@ -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>
|
||||
</footer>
|
||||
|
||||
<script src="/static/js/defaults.js?v=5"></script>
|
||||
<script src="/static/js/api.js?v=5"></script>
|
||||
<script src="/static/js/app.js?v=5"></script>
|
||||
<script defer src="/static/js/alpine.min.js?v=5"></script>
|
||||
<script src="/static/js/defaults.js?v=6"></script>
|
||||
<script src="/static/js/api.js?v=6"></script>
|
||||
<script src="/static/js/shots.js?v=6"></script>
|
||||
<script src="/static/js/app.js?v=6"></script>
|
||||
<script defer src="/static/js/alpine.min.js?v=6"></script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
@@ -229,13 +230,9 @@
|
||||
submitting: false,
|
||||
error: null,
|
||||
|
||||
SHOT_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'],
|
||||
SHOT_WEIGHTS: window.shots.WEIGHTS,
|
||||
TIPOS: window.shots.TIPOS,
|
||||
GRADOS: window.shots.GRADOS,
|
||||
|
||||
form: {
|
||||
name: '',
|
||||
@@ -293,27 +290,19 @@
|
||||
},
|
||||
|
||||
tipoShort(tipo) {
|
||||
return { 'Soulshot': 'SS', 'Spiritshot': 'SPS', 'Blessed Spiritshot': 'BSS' }[tipo] || tipo;
|
||||
return window.shots.tipoShort(tipo);
|
||||
},
|
||||
|
||||
maxWeightOf(c) {
|
||||
return (c.baseWeight || 115230) * (1 + (c.skillLevel || 0));
|
||||
return window.shots.maxWeight(c);
|
||||
},
|
||||
|
||||
capacityOf(c, tipo, grado) {
|
||||
const maxW = this.maxWeightOf(c);
|
||||
const w = this.SHOT_WEIGHTS[tipo]?.[grado] || 0;
|
||||
return w > 0 ? Math.floor(maxW / w) : 0;
|
||||
return window.shots.capacityOf(c, tipo, grado);
|
||||
},
|
||||
|
||||
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;
|
||||
return window.shots.totalCapacityOf(c);
|
||||
},
|
||||
|
||||
async updateLevel(c) {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<p class="text-sm text-muted">Duración por defecto: 12 horas</p>
|
||||
</header>
|
||||
<form @submit.prevent="submitSale()">
|
||||
<div class="grid" style="grid-template-columns: 1fr 2fr;">
|
||||
<div class="grid" style="grid-template-columns: 1fr 1fr;">
|
||||
<label>
|
||||
<span class="text-sm text-muted">Nombre del personaje</span>
|
||||
<input type="text"
|
||||
@@ -36,15 +36,100 @@
|
||||
<option :value="c.name"></option>
|
||||
</template>
|
||||
</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 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;">
|
||||
<label style="flex: 0 0 auto;">
|
||||
<span class="text-sm text-muted">Hora de inicio</span>
|
||||
@@ -53,7 +138,7 @@
|
||||
</label>
|
||||
<button type="submit"
|
||||
class="primary"
|
||||
:disabled="submitting">
|
||||
:disabled="submitting || !canSubmit()">
|
||||
<span x-show="!submitting">Agregar</span>
|
||||
<span x-show="submitting">Guardando…</span>
|
||||
</button>
|
||||
@@ -96,6 +181,21 @@
|
||||
</div>
|
||||
</header>
|
||||
<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;">
|
||||
<p class="text-mono text-xl text-bold"
|
||||
:class="getCountdownClass(sale)"
|
||||
@@ -125,10 +225,11 @@
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
<script src="/static/js/defaults.js?v=5"></script>
|
||||
<script src="/static/js/api.js?v=5"></script>
|
||||
<script src="/static/js/app.js?v=5"></script>
|
||||
<script defer src="/static/js/alpine.min.js?v=5"></script>
|
||||
<script src="/static/js/defaults.js?v=6"></script>
|
||||
<script src="/static/js/api.js?v=6"></script>
|
||||
<script src="/static/js/shots.js?v=6"></script>
|
||||
<script src="/static/js/app.js?v=6"></script>
|
||||
<script defer src="/static/js/alpine.min.js?v=6"></script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
@@ -145,10 +246,12 @@
|
||||
|
||||
form: {
|
||||
characterName: '',
|
||||
itemsDescription: '',
|
||||
items: [],
|
||||
startedAt: '',
|
||||
},
|
||||
|
||||
shots: window.shots,
|
||||
|
||||
get sortedSales() {
|
||||
return [...this.sales].sort((a, b) => {
|
||||
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() {
|
||||
if (!this.form.characterName.trim() || !this.form.itemsDescription.trim()) {
|
||||
this.error = 'Completá todos los campos';
|
||||
if (!this.form.characterName.trim() || !this.canSubmit()) {
|
||||
this.error = 'Completá nombre del personaje y al menos un item válido';
|
||||
return;
|
||||
}
|
||||
this.error = null;
|
||||
@@ -211,13 +347,15 @@
|
||||
if (this.form.startedAt) {
|
||||
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({
|
||||
characterName: this.form.characterName.trim(),
|
||||
itemsDescription: this.form.itemsDescription.trim(),
|
||||
itemsDescription: itemsDescription,
|
||||
startedAt: startedAt,
|
||||
});
|
||||
this.form.characterName = '';
|
||||
this.form.itemsDescription = '';
|
||||
this.form.items = [];
|
||||
this.form.startedAt = '';
|
||||
await this.loadSales();
|
||||
} catch (e) {
|
||||
@@ -298,6 +436,29 @@
|
||||
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');
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user