feat: shot-crafter-calculator with H2 persistence and production history
Quarkus 3.20.1 monolith serving React 18 + TypeScript + Tailwind SPA. Features: - Three-tab calculator (Insumos, Fórmulas, Calculadora) for Soulshot, Spiritshot and Blessed Spiritshot crafting in Lineage 2 Interlude/Clásico with all 15 grades and pre-loaded recipes - Real-time profitability computation (cristales → ore → crafteos → shots → cost → sale → ganancia) - Multi-user auth with JWT in httpOnly cookie (bcrypt + RSA 2048) - H2 file-based persistence in ./data/shots.mv.db (file-based, H2) - Auto-save on state changes (debounced 500ms) - Production history with stats (total/avg/best/worst/last5avg) and per-run detail modal with snapshot of insumos+formulas Stack: - Backend: Quarkus REST + Hibernate ORM Panache + smallrye-jwt - Frontend: React 18 + TypeScript + Vite + Tailwind 3 - Build: Maven runs frontend-maven-plugin (Node 22 + npm ci) then copies dist to META-INF/resources for Quarkus to serve Verified: - 5 backend endpoints + 5 history endpoints with curl - 35/35 browser tests via Playwright + Chromium - All TS strict, all builds green
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
||||
target/
|
||||
.mvn/
|
||||
node/
|
||||
node_modules/
|
||||
src/frontend/node_modules/
|
||||
src/frontend/dist/
|
||||
src/frontend/tsconfig.app.tsbuildinfo
|
||||
src/frontend/tsconfig.node.tsbuildinfo
|
||||
data/
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
.DS_Store
|
||||
*.log
|
||||
@@ -0,0 +1,213 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.l2.shots</groupId>
|
||||
<artifactId>shot-crafter-calculator</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<properties>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||
<maven.compiler.release>21</maven.compiler.release>
|
||||
|
||||
<quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
|
||||
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
|
||||
<quarkus.platform.version>3.20.1</quarkus.platform.version>
|
||||
|
||||
<compiler-plugin.version>3.13.0</compiler-plugin.version>
|
||||
<surefire-plugin.version>3.5.0</surefire-plugin.version>
|
||||
<failsafe-plugin.version>3.5.0</failsafe-plugin.version>
|
||||
|
||||
<frontend-maven-plugin.version>1.15.0</frontend-maven-plugin.version>
|
||||
<node.version>v22.11.0</node.version>
|
||||
<npm.version>10.9.0</npm.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>${quarkus.platform.group-id}</groupId>
|
||||
<artifactId>${quarkus.platform.artifact-id}</artifactId>
|
||||
<version>${quarkus.platform.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-arc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-vertx-http</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-rest-jackson</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-hibernate-orm-panache</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-jdbc-h2</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-smallrye-jwt</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-smallrye-jwt-build</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-elytron-security-common</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>${quarkus.platform.group-id}</groupId>
|
||||
<artifactId>quarkus-maven-plugin</artifactId>
|
||||
<version>${quarkus.platform.version}</version>
|
||||
<extensions>true</extensions>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>build</goal>
|
||||
<goal>generate-code</goal>
|
||||
<goal>generate-code-tests</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>com.github.eirslett</groupId>
|
||||
<artifactId>frontend-maven-plugin</artifactId>
|
||||
<version>${frontend-maven-plugin.version}</version>
|
||||
<configuration>
|
||||
<nodeVersion>${node.version}</nodeVersion>
|
||||
<npmVersion>${npm.version}</npmVersion>
|
||||
<workingDirectory>src/frontend</workingDirectory>
|
||||
<installDirectory>target</installDirectory>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>install-frontend-tools</id>
|
||||
<phase>initialize</phase>
|
||||
<goals>
|
||||
<goal>install-node-and-npm</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>npm-install</id>
|
||||
<phase>generate-resources</phase>
|
||||
<goals>
|
||||
<goal>npm</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<arguments>ci</arguments>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>npm-build</id>
|
||||
<phase>generate-resources</phase>
|
||||
<goals>
|
||||
<goal>npm</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<arguments>run build</arguments>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<artifactId>maven-resources-plugin</artifactId>
|
||||
<version>3.3.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>copy-frontend-dist</id>
|
||||
<phase>process-resources</phase>
|
||||
<goals>
|
||||
<goal>copy-resources</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<outputDirectory>${project.build.directory}/classes/META-INF/resources/</outputDirectory>
|
||||
<resources>
|
||||
<resource>
|
||||
<directory>src/frontend/dist</directory>
|
||||
<filtering>false</filtering>
|
||||
</resource>
|
||||
</resources>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>${compiler-plugin.version}</version>
|
||||
<configuration>
|
||||
<parameters>true</parameters>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>${surefire-plugin.version}</version>
|
||||
<configuration>
|
||||
<systemPropertyVariables>
|
||||
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>native</id>
|
||||
<activation>
|
||||
<property>
|
||||
<name>native</name>
|
||||
</property>
|
||||
</activation>
|
||||
<properties>
|
||||
<quarkus.native.enabled>true</quarkus.native.enabled>
|
||||
</properties>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<artifactId>maven-failsafe-plugin</artifactId>
|
||||
<version>${failsafe-plugin.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>integration-test</goal>
|
||||
<goal>verify</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
<configuration>
|
||||
<systemPropertyVariables>
|
||||
<native.image.path>${project.build.directory}/${project.build.finalName}-runner</native.image.path>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Calculadora de Craft de Shots — Lineage 2</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2698
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "shot-crafter-calculator",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<radialGradient id="g" cx="50%" cy="40%" r="60%">
|
||||
<stop offset="0%" stop-color="#fde68a"/>
|
||||
<stop offset="60%" stop-color="#fbbf24"/>
|
||||
<stop offset="100%" stop-color="#b45309"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="28" fill="url(#g)" stroke="#7c2d12" stroke-width="2"/>
|
||||
<polygon points="32,12 38,28 54,28 42,38 46,54 32,46 18,54 22,38 10,28 26,28" fill="#fef3c7" stroke="#7c2d12" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 524 B |
@@ -0,0 +1,130 @@
|
||||
import { useState } from 'react'
|
||||
import { AuthProvider, useAuth } from './auth/AuthContext'
|
||||
import { LoginPage } from './auth/LoginPage'
|
||||
import { TabBar, type TabKey } from './components/TabBar'
|
||||
import { InsumosSection } from './components/InsumosSection'
|
||||
import { FormulasSection } from './components/FormulasSection'
|
||||
import { CalculadoraSection } from './components/CalculadoraSection'
|
||||
import { HistorySection } from './components/HistorySection'
|
||||
import { SaveIndicator } from './components/SaveIndicator'
|
||||
import { usePersistedState } from './hooks/usePersistedState'
|
||||
import { makeDefaultAppState, makeEmptyAppState } from './data/defaults'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AppRouter />
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function AppRouter() {
|
||||
const { user, loading } = useAuth()
|
||||
if (loading) return <LoadingScreen message="Verificando sesión…" />
|
||||
if (!user) return <LoginPage />
|
||||
return <AuthenticatedApp />
|
||||
}
|
||||
|
||||
function AuthenticatedApp() {
|
||||
const { user, logout } = useAuth()
|
||||
const { state, setState, status, errorMessage, reset } = usePersistedState()
|
||||
const [tab, setTab] = useState<TabKey>('insumos')
|
||||
|
||||
if (!state) return <LoadingScreen message="Cargando tu estado…" />
|
||||
|
||||
const handleResetExamples = () => {
|
||||
setState(makeDefaultAppState())
|
||||
}
|
||||
|
||||
const handleClearAll = () => {
|
||||
setState(makeEmptyAppState())
|
||||
}
|
||||
|
||||
const handleResetServer = async () => {
|
||||
if (confirm('¿Borrar tu estado guardado en el servidor?')) {
|
||||
await reset()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<header className="bg-white border-b border-slate-200">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-slate-900">
|
||||
Calculadora de Craft de Shots
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500">
|
||||
<span className="font-medium text-slate-700">{user?.username ?? ''}</span>
|
||||
{' · '}
|
||||
Lineage 2 — Interlude / Clásico
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<SaveIndicator status={status} errorMessage={errorMessage} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResetExamples}
|
||||
className="px-3 py-2 text-sm font-medium rounded border border-slate-300 bg-white text-slate-700 hover:bg-slate-100"
|
||||
>
|
||||
Restablecer ejemplos
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearAll}
|
||||
className="px-3 py-2 text-sm font-medium rounded border border-amber-300 bg-white text-amber-700 hover:bg-amber-50"
|
||||
>
|
||||
Poner todo en cero
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResetServer}
|
||||
className="px-3 py-2 text-sm font-medium rounded border border-rose-300 bg-white text-rose-700 hover:bg-rose-50"
|
||||
title="Borrar el estado en el servidor (sólo este navegador)"
|
||||
>
|
||||
Borrar del servidor
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
className="px-3 py-2 text-sm font-medium rounded border border-slate-300 bg-white text-slate-700 hover:bg-slate-100"
|
||||
>
|
||||
Cerrar sesión
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<TabBar active={tab} onChange={setTab} />
|
||||
</header>
|
||||
|
||||
<main className="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 py-6">
|
||||
{tab === 'insumos' && (
|
||||
<InsumosSection state={state} onChange={setState} />
|
||||
)}
|
||||
{tab === 'formulas' && (
|
||||
<FormulasSection state={state} onChange={setState} />
|
||||
)}
|
||||
{tab === 'calculadora' && (
|
||||
<CalculadoraSection state={state} onChange={setState} />
|
||||
)}
|
||||
{tab === 'historial' && <HistorySection />}
|
||||
</main>
|
||||
|
||||
<footer className="bg-white border-t border-slate-200 py-3">
|
||||
<p className="text-center text-xs text-slate-400">
|
||||
Auto-guardado activo · Cambios persistidos en el servidor cada ~500ms
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingScreen({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-100 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="inline-block w-8 h-8 border-4 border-blue-600 border-r-transparent rounded-full animate-spin mb-3" />
|
||||
<p className="text-sm text-slate-600">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
username: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
insumos: {
|
||||
cristales: Record<string, number>
|
||||
soulOre: number
|
||||
spiritOre: number
|
||||
venta: Record<string, Record<string, number>>
|
||||
}
|
||||
formulas: Array<{
|
||||
id: string
|
||||
tipo: string
|
||||
grado: string
|
||||
cristalesReq: number
|
||||
soulOreReq: number | null
|
||||
spiritOreReq: number | null
|
||||
shotsObtenidos: number
|
||||
}>
|
||||
disponibles: Record<string, Record<string, number>>
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
...options,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
|
||||
if (res.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
const text = await res.text()
|
||||
let body: unknown = null
|
||||
if (text) {
|
||||
try {
|
||||
body = JSON.parse(text)
|
||||
} catch {
|
||||
body = text
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const message =
|
||||
body && typeof body === 'object' && 'error' in body
|
||||
? String((body as { error: string }).error)
|
||||
: `HTTP ${res.status}`
|
||||
throw new ApiError(res.status, message)
|
||||
}
|
||||
|
||||
return body as T
|
||||
}
|
||||
|
||||
export const api = {
|
||||
async me(): Promise<User | null> {
|
||||
try {
|
||||
return await request<User>('/api/auth/me')
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) return null
|
||||
throw e
|
||||
}
|
||||
},
|
||||
|
||||
async login(username: string, password: string): Promise<User> {
|
||||
return request<User>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
},
|
||||
|
||||
async register(username: string, password: string): Promise<User> {
|
||||
return request<User>('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
},
|
||||
|
||||
async logout(): Promise<void> {
|
||||
return request<void>('/api/auth/logout', { method: 'POST' })
|
||||
},
|
||||
|
||||
async getState(): Promise<AppState | null> {
|
||||
try {
|
||||
return await request<AppState>('/api/state')
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 404) return null
|
||||
throw e
|
||||
}
|
||||
},
|
||||
|
||||
async putState(state: AppState): Promise<void> {
|
||||
return request<void>('/api/state', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(state),
|
||||
})
|
||||
},
|
||||
|
||||
async resetState(): Promise<void> {
|
||||
return request<void>('/api/state', { method: 'DELETE' })
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { ApiError } from './client'
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
...options,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
|
||||
if (res.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
const text = await res.text()
|
||||
let body: unknown = null
|
||||
if (text) {
|
||||
try {
|
||||
body = JSON.parse(text)
|
||||
} catch {
|
||||
body = text
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const message =
|
||||
body && typeof body === 'object' && 'error' in body
|
||||
? String((body as { error: string }).error)
|
||||
: `HTTP ${res.status}`
|
||||
throw new ApiError(res.status, message)
|
||||
}
|
||||
|
||||
return body as T
|
||||
}
|
||||
|
||||
export interface RunItem {
|
||||
tipo: string
|
||||
grado: string
|
||||
cristalesDisponibles: number
|
||||
cristalesUsados: number
|
||||
oreNecesario: number
|
||||
crafteosPosibles: number
|
||||
shotsObtenidos: number
|
||||
costoTotal: number
|
||||
valorVenta: number
|
||||
ganancia: number
|
||||
}
|
||||
|
||||
export interface RunSnapshot {
|
||||
insumos: unknown
|
||||
formulas: unknown
|
||||
}
|
||||
|
||||
export interface RunSummary {
|
||||
id: string
|
||||
createdAt: string
|
||||
label: string | null
|
||||
totalCost: number
|
||||
totalSale: number
|
||||
totalProfit: number
|
||||
totalShots: number
|
||||
totalCristalesUsed: number
|
||||
totalOreUsed: number
|
||||
}
|
||||
|
||||
export interface RunDetails extends RunSummary {
|
||||
items: RunItem[]
|
||||
snapshot: RunSnapshot
|
||||
}
|
||||
|
||||
export interface HistoryStats {
|
||||
totalRuns: number
|
||||
totalCost: number
|
||||
totalSale: number
|
||||
totalProfit: number
|
||||
totalShots: number
|
||||
avgProfit: number
|
||||
avgCost: number
|
||||
avgSale: number
|
||||
bestRun: RunSummary | null
|
||||
worstRun: RunSummary | null
|
||||
last5Avg: number
|
||||
last10Avg: number
|
||||
}
|
||||
|
||||
export interface RunIn {
|
||||
label: string | null
|
||||
totalCost: number
|
||||
totalSale: number
|
||||
totalProfit: number
|
||||
totalShots: number
|
||||
totalCristalesUsed: number
|
||||
totalOreUsed: number
|
||||
items: RunItem[]
|
||||
snapshot: RunSnapshot
|
||||
}
|
||||
|
||||
export const historyApi = {
|
||||
async saveRun(payload: RunIn): Promise<RunSummary> {
|
||||
return request<RunSummary>('/api/history/runs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
|
||||
async listRuns(): Promise<RunSummary[]> {
|
||||
return request<RunSummary[]>('/api/history/runs')
|
||||
},
|
||||
|
||||
async getRun(id: string): Promise<RunDetails> {
|
||||
try {
|
||||
return await request<RunDetails>(`/api/history/runs/${id}`)
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 404) {
|
||||
throw new Error('Producción no encontrada')
|
||||
}
|
||||
throw e
|
||||
}
|
||||
},
|
||||
|
||||
async deleteRun(id: string): Promise<void> {
|
||||
try {
|
||||
return await request<void>(`/api/history/runs/${id}`, { method: 'DELETE' })
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 404) {
|
||||
throw new Error('Producción no encontrada')
|
||||
}
|
||||
throw e
|
||||
}
|
||||
},
|
||||
|
||||
async getStats(): Promise<HistoryStats> {
|
||||
return request<HistoryStats>('/api/history/stats')
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
import { ApiError, api, type User } from '../api/client'
|
||||
|
||||
interface AuthContextValue {
|
||||
user: User | null
|
||||
loading: boolean
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
register: (username: string, password: string) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
api
|
||||
.me()
|
||||
.then((u) => {
|
||||
if (!cancelled) setUser(u)
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!(e instanceof ApiError) || e.status !== 401) {
|
||||
console.error('auth check failed', e)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const u = await api.login(username, password)
|
||||
setUser(u)
|
||||
}, [])
|
||||
|
||||
const register = useCallback(async (username: string, password: string) => {
|
||||
const u = await api.register(username, password)
|
||||
setUser(u)
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await api.logout()
|
||||
setUser(null)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) throw new Error('useAuth debe usarse dentro de <AuthProvider>')
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useAuth } from './AuthContext'
|
||||
|
||||
export function LoginPage() {
|
||||
const { login, register } = useAuth()
|
||||
const [mode, setMode] = useState<'login' | 'register'>('login')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (mode === 'login') {
|
||||
await login(username, password)
|
||||
} else {
|
||||
await register(username, password)
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Error desconocido'
|
||||
if (mode === 'login') {
|
||||
setError('Usuario o contraseña incorrectos.')
|
||||
} else if (msg.includes('no disponible')) {
|
||||
setError('Ese username ya está en uso.')
|
||||
} else if (msg.toLowerCase().includes('datos')) {
|
||||
setError('Username (3-30 chars, alfanumérico o _) y password (>= 8 chars).')
|
||||
} else {
|
||||
setError(msg)
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMode = () => {
|
||||
setMode((m) => (m === 'login' ? 'register' : 'login'))
|
||||
setError(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-100 flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-lg border border-slate-200 shadow-sm p-8">
|
||||
<header className="mb-6 text-center">
|
||||
<h1 className="text-2xl font-bold text-slate-900">
|
||||
Calculadora de Craft de Shots
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
Lineage 2 — Interlude / Clásico
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={30}
|
||||
pattern="[a-zA-Z0-9_]{3,30}"
|
||||
className="input-editable w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Contraseña
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
||||
required
|
||||
minLength={8}
|
||||
className="input-editable w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-rose-700 bg-rose-50 border border-rose-200 rounded px-3 py-2">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full px-4 py-2 text-sm font-semibold rounded bg-blue-600 text-white hover:bg-blue-700 disabled:bg-slate-300 disabled:cursor-not-allowed"
|
||||
>
|
||||
{submitting
|
||||
? 'Procesando…'
|
||||
: mode === 'login'
|
||||
? 'Iniciar sesión'
|
||||
: 'Crear cuenta'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMode}
|
||||
className="text-sm text-blue-700 hover:underline"
|
||||
>
|
||||
{mode === 'login'
|
||||
? '¿No tenés cuenta? Creá una'
|
||||
: '¿Ya tenés cuenta? Iniciá sesión'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-slate-400 mt-4">
|
||||
Cada usuario tiene su propio estado guardado en el servidor.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { SHOT_TYPES, type CristalesDisponibles, type Formula, type Insumos } from '../types'
|
||||
import { ShotTable } from './ShotTable'
|
||||
import { TotalsSummary } from './TotalsSummary'
|
||||
import { calcularFila, sumarTotales } from '../utils/calc'
|
||||
import type { AppState } from '../api/client'
|
||||
import { useHistory, type HistorySaveStatus } from '../hooks/useHistory'
|
||||
|
||||
interface CalculadoraSectionProps {
|
||||
state: AppState
|
||||
onChange: (next: AppState | ((prev: AppState) => AppState)) => void
|
||||
}
|
||||
|
||||
export function CalculadoraSection({ state, onChange }: CalculadoraSectionProps) {
|
||||
const insumos: Insumos = state.insumos
|
||||
const formulas: Formula[] = state.formulas
|
||||
const disponibles: CristalesDisponibles = state.disponibles
|
||||
const { saveRun, saveStatus, saveError } = useHistory()
|
||||
const [label, setLabel] = useState('')
|
||||
|
||||
const handleDisponibles = (tipo: string, grado: string, value: number) => {
|
||||
onChange((prev) => ({
|
||||
...prev,
|
||||
disponibles: {
|
||||
...prev.disponibles,
|
||||
[tipo]: { ...prev.disponibles[tipo], [grado]: value },
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
const tablas = useMemo(() => {
|
||||
return SHOT_TYPES.map((tipo) => {
|
||||
const formulasTipo = formulas.filter((f) => f.tipo === tipo)
|
||||
const calculos = formulasTipo.map((f) =>
|
||||
calcularFila(f, disponibles[tipo][f.grado], insumos),
|
||||
)
|
||||
const totales = sumarTotales(calculos)
|
||||
return { tipo, formulas: formulasTipo, calculos, totales }
|
||||
})
|
||||
}, [insumos, formulas, disponibles])
|
||||
|
||||
const totalesGlobal = useMemo(
|
||||
() => sumarTotales(tablas.flatMap((t) => t.calculos)),
|
||||
[tablas],
|
||||
)
|
||||
|
||||
const canSave = totalesGlobal.cristalesUsados > 0 && saveStatus !== 'saving'
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!canSave) return
|
||||
const items = tablas.flatMap((t) =>
|
||||
t.formulas.map((f, i) => {
|
||||
const c = t.calculos[i]
|
||||
return {
|
||||
tipo: t.tipo,
|
||||
grado: f.grado,
|
||||
cristalesDisponibles: c.cristalesDisponibles,
|
||||
cristalesUsados: c.cristalesUsados,
|
||||
oreNecesario: c.oreNecesario,
|
||||
crafteosPosibles: c.crafteosPosibles,
|
||||
shotsObtenidos: c.shotsObtenidos,
|
||||
costoTotal: c.costoTotal,
|
||||
valorVenta: c.valorVenta,
|
||||
ganancia: c.ganancia,
|
||||
}
|
||||
}),
|
||||
)
|
||||
try {
|
||||
await saveRun({
|
||||
label: label.trim() || null,
|
||||
totalCost: totalesGlobal.costoTotal,
|
||||
totalSale: totalesGlobal.valorVenta,
|
||||
totalProfit: totalesGlobal.ganancia,
|
||||
totalShots: totalesGlobal.shotsObtenidos,
|
||||
totalCristalesUsed: totalesGlobal.cristalesUsados,
|
||||
totalOreUsed: totalesGlobal.oreNecesario,
|
||||
items,
|
||||
snapshot: {
|
||||
insumos: insumos as unknown,
|
||||
formulas: formulas as unknown,
|
||||
},
|
||||
})
|
||||
setLabel('')
|
||||
} catch {
|
||||
// error ya manejado en saveStatus/saveError
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg px-4 py-3 text-sm text-blue-900">
|
||||
<p>
|
||||
Ingresa solo los <strong>cristales disponibles</strong> por grado en cada tabla. La ore
|
||||
necesaria, los crafteos, los shots producidos, el costo y la ganancia se calculan en
|
||||
tiempo real a partir de los precios de la pestaña <em>Insumos</em> y las recetas de la
|
||||
pestaña <em>Fórmulas</em>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="bg-white rounded-lg border border-slate-200 px-4 py-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end gap-3">
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="save-run-label"
|
||||
className="block text-xs font-medium text-slate-600 mb-1"
|
||||
>
|
||||
Label (opcional)
|
||||
</label>
|
||||
<input
|
||||
id="save-run-label"
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
maxLength={100}
|
||||
placeholder="p.ej. Sesión lunes, crafteo nocturno…"
|
||||
className="input-editable w-full text-left"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!canSave}
|
||||
className="px-4 py-2 text-sm font-semibold rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:bg-slate-300 disabled:cursor-not-allowed whitespace-nowrap"
|
||||
title={
|
||||
totalesGlobal.cristalesUsados === 0
|
||||
? 'Ingresa cristales disponibles para poder guardar'
|
||||
: 'Guardar esta producción en el historial'
|
||||
}
|
||||
>
|
||||
{saveStatus === 'saving' ? 'Guardando…' : 'Guardar producción'}
|
||||
</button>
|
||||
</div>
|
||||
<SaveFeedback status={saveStatus} error={saveError} />
|
||||
</section>
|
||||
|
||||
{tablas.map((t) => (
|
||||
<ShotTable
|
||||
key={t.tipo}
|
||||
tipo={t.tipo}
|
||||
formulas={t.formulas}
|
||||
calculos={t.calculos}
|
||||
totales={t.totales}
|
||||
disponibles={disponibles[t.tipo]}
|
||||
onChangeDisponibles={(grado, value) => handleDisponibles(t.tipo, grado, value)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<TotalsSummary totales={totalesGlobal} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SaveFeedback({
|
||||
status,
|
||||
error,
|
||||
}: {
|
||||
status: HistorySaveStatus
|
||||
error: string | null
|
||||
}) {
|
||||
if (status === 'idle') {
|
||||
return (
|
||||
<p className="text-xs text-slate-500 mt-2">
|
||||
Se guardará la producción actual con los cristales usados (
|
||||
{/* placeholder para texto */} verTotales). El cálculo se almacena con los precios y
|
||||
fórmulas de este momento.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (status === 'saving') {
|
||||
return (
|
||||
<p className="text-xs text-blue-700 mt-2 inline-flex items-center gap-1">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-blue-600 animate-pulse" />
|
||||
Guardando en historial…
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (status === 'saved') {
|
||||
return (
|
||||
<p className="text-xs text-emerald-700 mt-2 inline-flex items-center gap-1">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-emerald-600" />
|
||||
Producción guardada ✓. La podés ver en la pestaña Historial.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<p className="text-xs text-rose-700 mt-2">Error al guardar: {error}</p>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { Formula } from '../types'
|
||||
import { parseNumber } from '../utils/format'
|
||||
import type { AppState } from '../api/client'
|
||||
|
||||
interface FormulasSectionProps {
|
||||
state: AppState
|
||||
onChange: (next: AppState | ((prev: AppState) => AppState)) => void
|
||||
}
|
||||
|
||||
export function FormulasSection({ state, onChange }: FormulasSectionProps) {
|
||||
const formulas = state.formulas
|
||||
|
||||
const updateField = (id: string, patch: Partial<Formula>) => {
|
||||
onChange((prev) => ({
|
||||
...prev,
|
||||
formulas: prev.formulas.map((f) => (f.id === id ? { ...f, ...patch } : f)),
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
||||
<header className="px-4 py-3 border-b border-slate-200 bg-slate-50">
|
||||
<h2 className="text-sm font-semibold text-slate-800">Recetas de crafteo</h2>
|
||||
<p className="text-xs text-slate-500">
|
||||
Edita los recursos necesarios por cada acción de crafteo. Los cambios se reflejan en la
|
||||
calculadora y se persisten automáticamente.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="cell-label text-left">Tipo</th>
|
||||
<th className="cell-label text-center">Grado</th>
|
||||
<th className="cell-label text-right">Cristales req.</th>
|
||||
<th className="cell-label text-right">Soul Ore req.</th>
|
||||
<th className="cell-label text-right">Spirit Ore req.</th>
|
||||
<th className="cell-label text-right">Shots obtenidos</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{formulas.map((f) => {
|
||||
const isSoul = f.tipo === 'Soulshot'
|
||||
return (
|
||||
<tr key={f.id} className="hover:bg-slate-50">
|
||||
<td className="cell-input text-slate-700 font-medium">{f.tipo}</td>
|
||||
<td className="cell-input text-center">
|
||||
<span className="inline-flex items-center justify-center w-7 h-7 rounded-full bg-slate-200 text-slate-700 font-semibold text-xs">
|
||||
{f.grado}
|
||||
</span>
|
||||
</td>
|
||||
<td className="cell-input">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="1"
|
||||
value={f.cristalesReq}
|
||||
onChange={(e) =>
|
||||
updateField(f.id, { cristalesReq: parseNumber(e.target.value) })
|
||||
}
|
||||
className="input-editable"
|
||||
/>
|
||||
</td>
|
||||
<td className="cell-input">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="1"
|
||||
value={f.soulOreReq ?? 0}
|
||||
disabled={!isSoul}
|
||||
onChange={(e) =>
|
||||
updateField(f.id, { soulOreReq: parseNumber(e.target.value) })
|
||||
}
|
||||
className="input-editable"
|
||||
/>
|
||||
</td>
|
||||
<td className="cell-input">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="1"
|
||||
value={f.spiritOreReq ?? 0}
|
||||
disabled={isSoul}
|
||||
onChange={(e) =>
|
||||
updateField(f.id, { spiritOreReq: parseNumber(e.target.value) })
|
||||
}
|
||||
className="input-editable"
|
||||
/>
|
||||
</td>
|
||||
<td className="cell-input">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="1"
|
||||
value={f.shotsObtenidos}
|
||||
onChange={(e) =>
|
||||
updateField(f.id, { shotsObtenidos: parseNumber(e.target.value) })
|
||||
}
|
||||
className="input-editable"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState } from 'react'
|
||||
import { useHistory } from '../hooks/useHistory'
|
||||
import { StatsCards } from './StatsCards'
|
||||
import { RunsTable } from './RunsTable'
|
||||
import { RunDetailsModal } from './RunDetailsModal'
|
||||
|
||||
export function HistorySection() {
|
||||
const { runs, stats, loading, deleteRun } = useHistory()
|
||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(null)
|
||||
|
||||
if (loading && !stats) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6 text-center">
|
||||
<p className="text-sm text-slate-500">Cargando historial…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{stats && <StatsCards stats={stats} />}
|
||||
|
||||
<section>
|
||||
<header className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold text-slate-800">
|
||||
Corridas guardadas
|
||||
</h2>
|
||||
{runs.length > 0 && (
|
||||
<span className="text-xs text-slate-500">
|
||||
{runs.length} en total · click en una fila para ver detalle
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
<RunsTable
|
||||
runs={runs}
|
||||
onSelect={setSelectedRunId}
|
||||
onDelete={deleteRun}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<RunDetailsModal runId={selectedRunId} onClose={() => setSelectedRunId(null)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { GRADOS, SHOT_TYPES, type Insumos } from '../types'
|
||||
import { formatAdena, parseNumber } from '../utils/format'
|
||||
import type { AppState } from '../api/client'
|
||||
|
||||
interface InsumosSectionProps {
|
||||
state: AppState
|
||||
onChange: (next: AppState | ((prev: AppState) => AppState)) => void
|
||||
}
|
||||
|
||||
export function InsumosSection({ state, onChange }: InsumosSectionProps) {
|
||||
const insumos = state.insumos
|
||||
|
||||
const updateInsumos = (updater: (curr: Insumos) => Insumos) => {
|
||||
onChange((prev) => ({ ...prev, insumos: updater(prev.insumos) }))
|
||||
}
|
||||
|
||||
const updateCristal = (grado: string, value: number) =>
|
||||
updateInsumos((curr) => ({
|
||||
...curr,
|
||||
cristales: { ...curr.cristales, [grado]: value },
|
||||
}))
|
||||
|
||||
const updateSale = (tipo: string, grado: string, value: number) =>
|
||||
updateInsumos((curr) => ({
|
||||
...curr,
|
||||
venta: {
|
||||
...curr.venta,
|
||||
[tipo]: { ...curr.venta[tipo], [grado]: value },
|
||||
},
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Section title="Cristales" subtitle="Precio unitario en adena">
|
||||
<GradeGrid>
|
||||
{GRADOS.map((grado) => (
|
||||
<GradeRow
|
||||
key={grado}
|
||||
label={`Cristal ${grado}`}
|
||||
value={insumos.cristales[grado]}
|
||||
onChange={(v) => updateCristal(grado, v)}
|
||||
/>
|
||||
))}
|
||||
</GradeGrid>
|
||||
</Section>
|
||||
|
||||
<Section title="Ores" subtitle="Precio por unidad">
|
||||
<GradeGrid>
|
||||
<GradeRow
|
||||
label="Soul Ore (Soulstone)"
|
||||
value={insumos.soulOre}
|
||||
onChange={(v) => updateInsumos((curr) => ({ ...curr, soulOre: v }))}
|
||||
/>
|
||||
<GradeRow
|
||||
label="Spirit Ore"
|
||||
value={insumos.spiritOre}
|
||||
onChange={(v) => updateInsumos((curr) => ({ ...curr, spiritOre: v }))}
|
||||
/>
|
||||
</GradeGrid>
|
||||
</Section>
|
||||
|
||||
{SHOT_TYPES.map((tipo) => (
|
||||
<Section
|
||||
key={tipo}
|
||||
title={`Precio de venta — ${tipo}`}
|
||||
subtitle="Precio unitario del shot vendido"
|
||||
>
|
||||
<GradeGrid>
|
||||
{GRADOS.map((grado) => (
|
||||
<GradeRow
|
||||
key={`${tipo}-${grado}`}
|
||||
label={`${tipo} ${grado}`}
|
||||
value={insumos.venta[tipo][grado]}
|
||||
onChange={(v) => updateSale(tipo, grado, v)}
|
||||
/>
|
||||
))}
|
||||
</GradeGrid>
|
||||
</Section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
subtitle?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
||||
<header className="px-4 py-3 border-b border-slate-200 bg-slate-50">
|
||||
<h2 className="text-sm font-semibold text-slate-800">{title}</h2>
|
||||
{subtitle && <p className="text-xs text-slate-500">{subtitle}</p>}
|
||||
</header>
|
||||
<div className="p-4">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function GradeGrid({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3">{children}</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GradeRow({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
onChange: (v: number) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-slate-600">{label}</span>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="1"
|
||||
value={value}
|
||||
onChange={(e) => onChange(parseNumber(e.target.value))}
|
||||
className="input-editable pr-12"
|
||||
/>
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-slate-400 pointer-events-none">
|
||||
adena
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400 text-right font-mono">
|
||||
{formatAdena(value)}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { historyApi, type RunDetails } from '../api/history'
|
||||
import { formatAdena } from '../utils/format'
|
||||
import { GRADOS, SHOT_TYPES, type Formula, type Insumos } from '../types'
|
||||
|
||||
interface RunDetailsModalProps {
|
||||
runId: string | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function RunDetailsModal({ runId, onClose }: RunDetailsModalProps) {
|
||||
const [details, setDetails] = useState<RunDetails | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showSnapshot, setShowSnapshot] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!runId) {
|
||||
setDetails(null)
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
historyApi
|
||||
.getRun(runId)
|
||||
.then((d) => setDetails(d))
|
||||
.catch((e) => setError(e instanceof Error ? e.message : 'Error'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [runId])
|
||||
|
||||
if (!runId) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-xl max-w-5xl w-full max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="px-6 py-4 border-b border-slate-200 flex items-center justify-between sticky top-0 bg-white">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-900">
|
||||
{details?.label || 'Detalle de producción'}
|
||||
</h2>
|
||||
{details && (
|
||||
<p className="text-xs text-slate-500 font-mono">
|
||||
{formatDateTime(details.createdAt)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-slate-400 hover:text-slate-700 text-2xl leading-none"
|
||||
aria-label="Cerrar"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{loading && <p className="text-sm text-slate-500">Cargando…</p>}
|
||||
{error && (
|
||||
<p className="text-sm text-rose-700 bg-rose-50 border border-rose-200 rounded px-3 py-2">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{details && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<SummaryStat label="Costo" value={formatAdena(details.totalCost)} tone="slate" />
|
||||
<SummaryStat label="Venta" value={formatAdena(details.totalSale)} tone="slate" />
|
||||
<SummaryStat
|
||||
label="Ganancia"
|
||||
value={formatAdena(details.totalProfit)}
|
||||
tone={details.totalProfit > 0 ? 'emerald' : 'rose'}
|
||||
highlight
|
||||
/>
|
||||
<SummaryStat
|
||||
label="Shots"
|
||||
value={String(details.totalShots)}
|
||||
tone="slate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-slate-800 mb-2">
|
||||
Desglose por grado
|
||||
</h3>
|
||||
<div className="overflow-x-auto rounded border border-slate-200">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="cell-label text-left">Tipo</th>
|
||||
<th className="cell-label text-center">Grado</th>
|
||||
<th className="cell-label text-right">Cristales</th>
|
||||
<th className="cell-label text-right">Ore</th>
|
||||
<th className="cell-label text-right">Crafteos</th>
|
||||
<th className="cell-label text-right">Shots</th>
|
||||
<th className="cell-label text-right">Costo</th>
|
||||
<th className="cell-label text-right">Venta</th>
|
||||
<th className="cell-label text-right">Ganancia</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{SHOT_TYPES.flatMap((tipo) =>
|
||||
GRADOS.map((grado) => {
|
||||
const item = details.items.find(
|
||||
(it) => it.tipo === tipo && it.grado === grado,
|
||||
)
|
||||
if (!item) {
|
||||
return (
|
||||
<tr key={`${tipo}-${grado}`} className="text-slate-400">
|
||||
<td className="cell-input">{tipo}</td>
|
||||
<td className="cell-input text-center">
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-slate-200 text-slate-700 font-semibold text-xs">
|
||||
{grado}
|
||||
</span>
|
||||
</td>
|
||||
<td colSpan={7} className="cell-input text-center italic">
|
||||
(sin producción)
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<tr
|
||||
key={`${tipo}-${grado}`}
|
||||
className={item.ganancia < 0 ? 'bg-rose-50/50' : ''}
|
||||
>
|
||||
<td className="cell-input font-medium">{item.tipo}</td>
|
||||
<td className="cell-input text-center">
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-slate-200 text-slate-700 font-semibold text-xs">
|
||||
{item.grado}
|
||||
</span>
|
||||
</td>
|
||||
<td className="cell-input text-right font-mono">
|
||||
{item.cristalesUsados} / {item.cristalesDisponibles}
|
||||
</td>
|
||||
<td className="cell-input text-right font-mono">
|
||||
{item.oreNecesario}
|
||||
</td>
|
||||
<td className="cell-input text-right font-mono">
|
||||
{item.crafteosPosibles}
|
||||
</td>
|
||||
<td className="cell-input text-right font-mono">
|
||||
{item.shotsObtenidos}
|
||||
</td>
|
||||
<td className="cell-input text-right font-mono">
|
||||
{formatAdena(item.costoTotal)}
|
||||
</td>
|
||||
<td className="cell-input text-right font-mono">
|
||||
{formatAdena(item.valorVenta)}
|
||||
</td>
|
||||
<td
|
||||
className={`cell-input text-right font-mono font-semibold ${gananciaColor(item.ganancia)}`}
|
||||
>
|
||||
{formatAdena(item.ganancia)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}),
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSnapshot((s) => !s)}
|
||||
className="text-sm font-semibold text-slate-800 flex items-center gap-2 hover:text-blue-700"
|
||||
>
|
||||
<span>{showSnapshot ? '▼' : '▶'}</span>
|
||||
Precios y fórmulas usados en ese momento
|
||||
</button>
|
||||
{showSnapshot && (
|
||||
<div className="mt-2 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<SnapshotInsumos snapshot={details.snapshot} />
|
||||
<SnapshotFormulas snapshot={details.snapshot} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="px-6 py-3 border-t border-slate-200 bg-slate-50 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-3 py-2 text-sm font-medium rounded border border-slate-300 bg-white text-slate-700 hover:bg-slate-100"
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryStat({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
highlight = false,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
tone: 'slate' | 'emerald' | 'rose'
|
||||
highlight?: boolean
|
||||
}) {
|
||||
const colorClass =
|
||||
tone === 'emerald' ? 'text-emerald-700' : tone === 'rose' ? 'text-rose-700' : 'text-slate-700'
|
||||
return (
|
||||
<div className="bg-slate-50 rounded border border-slate-200 px-3 py-2">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500 font-medium">{label}</p>
|
||||
<p className={`font-mono ${highlight ? 'text-2xl font-bold' : 'text-xl font-semibold'} ${colorClass}`}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SnapshotInsumos({ snapshot }: { snapshot: RunDetails['snapshot'] }) {
|
||||
const insumos = snapshot.insumos as Insumos
|
||||
if (!insumos || !insumos.cristales) {
|
||||
return (
|
||||
<div className="bg-slate-50 rounded border border-slate-200 p-3 text-xs text-slate-500">
|
||||
Sin datos de insumos.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="bg-slate-50 rounded border border-slate-200 p-3 text-xs">
|
||||
<h4 className="font-semibold text-slate-700 mb-2">Insumos</h4>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{Object.entries(insumos.cristales).map(([grado, precio]) => (
|
||||
<tr key={grado}>
|
||||
<td className="py-0.5">Cristal {grado}</td>
|
||||
<td className="text-right font-mono">{formatAdena(Number(precio))}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td className="py-0.5">Soul Ore</td>
|
||||
<td className="text-right font-mono">{formatAdena(insumos.soulOre)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-0.5">Spirit Ore</td>
|
||||
<td className="text-right font-mono">{formatAdena(insumos.spiritOre)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SnapshotFormulas({ snapshot }: { snapshot: RunDetails['snapshot'] }) {
|
||||
const formulas = snapshot.formulas as Formula[]
|
||||
if (!formulas || !Array.isArray(formulas)) {
|
||||
return (
|
||||
<div className="bg-slate-50 rounded border border-slate-200 p-3 text-xs text-slate-500">
|
||||
Sin datos de fórmulas.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="bg-slate-50 rounded border border-slate-200 p-3 text-xs">
|
||||
<h4 className="font-semibold text-slate-700 mb-2">
|
||||
Fórmulas ({formulas.length})
|
||||
</h4>
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-slate-500">
|
||||
<th className="text-left font-medium">Tipo</th>
|
||||
<th className="text-center font-medium">Grado</th>
|
||||
<th className="text-right font-medium">Crist</th>
|
||||
<th className="text-right font-medium">Soul</th>
|
||||
<th className="text-right font-medium">Spirit</th>
|
||||
<th className="text-right font-medium">Shots</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{formulas.map((f) => (
|
||||
<tr key={f.id}>
|
||||
<td className="py-0.5">{f.tipo}</td>
|
||||
<td className="text-center">{f.grado}</td>
|
||||
<td className="text-right font-mono">{f.cristalesReq}</td>
|
||||
<td className="text-right font-mono">{f.soulOreReq ?? '—'}</td>
|
||||
<td className="text-right font-mono">{f.spiritOreReq ?? '—'}</td>
|
||||
<td className="text-right font-mono">{f.shotsObtenidos}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime())) return iso
|
||||
return d.toLocaleString('es-ES', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function gananciaColor(value: number): string {
|
||||
if (value > 0) return 'text-emerald-700'
|
||||
if (value < 0) return 'text-rose-700'
|
||||
return 'text-slate-700'
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { RunSummary } from '../api/history'
|
||||
import { formatAdena } from '../utils/format'
|
||||
|
||||
interface RunsTableProps {
|
||||
runs: RunSummary[]
|
||||
onSelect: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
export function RunsTable({ runs, onSelect, onDelete }: RunsTableProps) {
|
||||
if (runs.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6 text-center">
|
||||
<p className="text-sm text-slate-500">No hay corridas para mostrar.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="cell-label text-left">Fecha</th>
|
||||
<th className="cell-label text-left">Label</th>
|
||||
<th className="cell-label text-right">Cristales</th>
|
||||
<th className="cell-label text-right">Shots</th>
|
||||
<th className="cell-label text-right">Costo</th>
|
||||
<th className="cell-label text-right">Venta</th>
|
||||
<th className="cell-label text-right">Ganancia</th>
|
||||
<th className="cell-label text-center">Acción</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map((r) => (
|
||||
<tr
|
||||
key={r.id}
|
||||
className="hover:bg-slate-50 cursor-pointer"
|
||||
onClick={() => onSelect(r.id)}
|
||||
>
|
||||
<td className="cell-input text-slate-600 text-xs font-mono whitespace-nowrap">
|
||||
{formatDateTime(r.createdAt)}
|
||||
</td>
|
||||
<td className="cell-input text-slate-700">
|
||||
{r.label || <span className="text-slate-400 italic">(sin label)</span>}
|
||||
</td>
|
||||
<td className="cell-input text-right font-mono">{r.totalCristalesUsed}</td>
|
||||
<td className="cell-input text-right font-mono">{r.totalShots}</td>
|
||||
<td className="cell-input text-right font-mono">{formatAdena(r.totalCost)}</td>
|
||||
<td className="cell-input text-right font-mono">{formatAdena(r.totalSale)}</td>
|
||||
<td className={`cell-input text-right font-mono font-semibold ${gananciaColor(r.totalProfit)}`}>
|
||||
{formatAdena(r.totalProfit)}
|
||||
</td>
|
||||
<td className="cell-input text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (confirm(`¿Borrar la corrida "${r.label || 'sin label'}"?`)) {
|
||||
onDelete(r.id)
|
||||
}
|
||||
}}
|
||||
className="text-rose-600 hover:text-rose-800 text-xs font-medium"
|
||||
title="Borrar corrida"
|
||||
>
|
||||
Borrar
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime())) return iso
|
||||
const yyyy = d.getFullYear()
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
const hh = String(d.getHours()).padStart(2, '0')
|
||||
const min = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${yyyy}-${mm}-${dd} ${hh}:${min}`
|
||||
}
|
||||
|
||||
function gananciaColor(value: number): string {
|
||||
if (value > 0) return 'text-emerald-700'
|
||||
if (value < 0) return 'text-rose-700'
|
||||
return 'text-slate-700'
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { SaveStatus } from '../hooks/usePersistedState'
|
||||
|
||||
interface SaveIndicatorProps {
|
||||
status: SaveStatus
|
||||
errorMessage: string | null
|
||||
}
|
||||
|
||||
export function SaveIndicator({ status, errorMessage }: SaveIndicatorProps) {
|
||||
if (status === 'idle') {
|
||||
return <span className="text-xs text-slate-400">Guardado automáticamente</span>
|
||||
}
|
||||
if (status === 'saving') {
|
||||
return (
|
||||
<span className="text-xs text-blue-700 inline-flex items-center gap-1">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-blue-600 animate-pulse" />
|
||||
Guardando…
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (status === 'saved') {
|
||||
return (
|
||||
<span className="text-xs text-emerald-700 inline-flex items-center gap-1">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-emerald-600" />
|
||||
Guardado ✓
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="text-xs text-rose-700 inline-flex items-center gap-1" title={errorMessage ?? ''}>
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-rose-600" />
|
||||
Error al guardar
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { GRADOS, type Calculo, type Formula, type OreType, type ShotType, type Totales } from '../types'
|
||||
import { formatAdena, parseNumber } from '../utils/format'
|
||||
|
||||
interface ShotTableProps {
|
||||
tipo: ShotType
|
||||
formulas: Formula[]
|
||||
calculos: Calculo[]
|
||||
totales: Totales
|
||||
disponibles: Record<string, number>
|
||||
onChangeDisponibles: (grado: string, value: number) => void
|
||||
}
|
||||
|
||||
export function ShotTable({
|
||||
tipo,
|
||||
formulas,
|
||||
calculos,
|
||||
totales,
|
||||
disponibles,
|
||||
onChangeDisponibles,
|
||||
}: ShotTableProps) {
|
||||
const formulasPorTipo = formulas.filter((f) => f.tipo === tipo)
|
||||
const calcPorGrado = new Map(calculos.map((c) => [c.grado, c]))
|
||||
const oreLabel: OreType = tipo === 'Soulshot' ? 'Soul Ore' : 'Spirit Ore'
|
||||
const colOre = `${oreLabel} nec.`
|
||||
|
||||
return (
|
||||
<section className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
||||
<header className="px-4 py-3 border-b border-slate-200 bg-slate-50">
|
||||
<h2 className="text-sm font-semibold text-slate-800">{tipo}</h2>
|
||||
<p className="text-xs text-slate-500">
|
||||
Crafteo de {tipo}. Ingresa solo los cristales disponibles por grado.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="cell-label text-center">Grado</th>
|
||||
<th className="cell-label text-right">Cristales disp.</th>
|
||||
<th className="cell-label text-right">Cristales usados</th>
|
||||
<th className="cell-label text-right">{colOre}</th>
|
||||
<th className="cell-label text-right">Crafteos</th>
|
||||
<th className="cell-label text-right">Shots</th>
|
||||
<th className="cell-label text-right">Costo</th>
|
||||
<th className="cell-label text-right">Venta</th>
|
||||
<th className="cell-label text-right">Ganancia</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{formulasPorTipo.map((f) => {
|
||||
const c = calcPorGrado.get(f.grado)
|
||||
if (!c) return null
|
||||
return (
|
||||
<tr
|
||||
key={f.id}
|
||||
className={c.warning ? 'bg-orange-50' : 'hover:bg-slate-50'}
|
||||
>
|
||||
<td className="cell-input text-center">
|
||||
<span className="inline-flex items-center justify-center w-7 h-7 rounded-full bg-slate-200 text-slate-700 font-semibold text-xs">
|
||||
{f.grado}
|
||||
</span>
|
||||
</td>
|
||||
<td className="cell-input">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="1"
|
||||
value={disponibles[f.grado]}
|
||||
onChange={(e) => onChangeDisponibles(f.grado, parseNumber(e.target.value))}
|
||||
className="input-editable"
|
||||
/>
|
||||
</td>
|
||||
<td className="cell-calculated">{formatAdena(c.cristalesUsados)}</td>
|
||||
<td className="cell-calculated">{formatAdena(c.oreNecesario)}</td>
|
||||
<td className="cell-calculated">{formatAdena(c.crafteosPosibles)}</td>
|
||||
<td className="cell-calculated">{formatAdena(c.shotsObtenidos)}</td>
|
||||
<td className="cell-calculated">{formatAdena(c.costoTotal)}</td>
|
||||
<td className="cell-calculated">{formatAdena(c.valorVenta)}</td>
|
||||
<td className={`cell-calculated font-semibold ${gananciaColor(c.ganancia)}`}>
|
||||
{formatAdena(c.ganancia)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="bg-slate-100 font-semibold text-slate-800">
|
||||
<td className="cell-label text-center">Σ</td>
|
||||
<td className="cell-label text-right text-slate-400">—</td>
|
||||
<td className="cell-label text-right font-mono">{formatAdena(totales.cristalesUsados)}</td>
|
||||
<td className="cell-label text-right font-mono">{formatAdena(totales.oreNecesario)}</td>
|
||||
<td className="cell-label text-right font-mono">{formatAdena(totales.crafteosPosibles)}</td>
|
||||
<td className="cell-label text-right font-mono">{formatAdena(totales.shotsObtenidos)}</td>
|
||||
<td className="cell-label text-right font-mono">{formatAdena(totales.costoTotal)}</td>
|
||||
<td className="cell-label text-right font-mono">{formatAdena(totales.valorVenta)}</td>
|
||||
<td className={`cell-label text-right font-mono ${gananciaColor(totales.ganancia)}`}>
|
||||
{formatAdena(totales.ganancia)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{GRADOS.some((g) => {
|
||||
const c = calcPorGrado.get(g)
|
||||
const f = formulasPorTipo.find((x) => x.grado === g)
|
||||
return c?.warning || (f && f.cristalesReq === 0)
|
||||
}) && (
|
||||
<p className="px-4 py-2 text-xs text-orange-700 bg-orange-50 border-t border-orange-200">
|
||||
⚠️ Una o más filas tienen cristales requeridos en 0. Completa la receta en la pestaña
|
||||
Fórmulas para ver el cálculo.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function gananciaColor(value: number): string {
|
||||
if (value > 0) return 'text-emerald-700'
|
||||
if (value < 0) return 'text-rose-700'
|
||||
return 'text-slate-700'
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { HistoryStats } from '../api/history'
|
||||
import { formatAdena } from '../utils/format'
|
||||
|
||||
interface StatsCardsProps {
|
||||
stats: HistoryStats
|
||||
}
|
||||
|
||||
export function StatsCards({ stats }: StatsCardsProps) {
|
||||
if (stats.totalRuns === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6 text-center">
|
||||
<p className="text-sm text-slate-600">
|
||||
Aún no hay producciones guardadas. Ve a la pestaña <strong>Calculadora</strong>,
|
||||
configura los cristales disponibles y presiona <strong>Guardar producción</strong>.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
<MetricCard
|
||||
label="Total ganado"
|
||||
value={formatAdena(stats.totalProfit)}
|
||||
tone={stats.totalProfit > 0 ? 'emerald' : stats.totalProfit < 0 ? 'rose' : 'slate'}
|
||||
subtitle={`${stats.totalRuns} corrida${stats.totalRuns === 1 ? '' : 's'}`}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Promedio"
|
||||
value={formatAdena(stats.avgProfit)}
|
||||
tone={stats.avgProfit > 0 ? 'emerald' : stats.avgProfit < 0 ? 'rose' : 'slate'}
|
||||
subtitle="por corrida"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Mejor corrida"
|
||||
value={stats.bestRun ? formatAdena(stats.bestRun.totalProfit) : '—'}
|
||||
tone="emerald"
|
||||
subtitle={stats.bestRun?.label ?? stats.bestRun?.createdAt.slice(0, 10)}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Peor corrida"
|
||||
value={stats.worstRun ? formatAdena(stats.worstRun.totalProfit) : '—'}
|
||||
tone={stats.worstRun && stats.worstRun.totalProfit < 0 ? 'rose' : 'slate'}
|
||||
subtitle={stats.worstRun?.label ?? stats.worstRun?.createdAt.slice(0, 10)}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Últimas 5"
|
||||
value={formatAdena(stats.last5Avg)}
|
||||
tone={stats.last5Avg > 0 ? 'emerald' : stats.last5Avg < 0 ? 'rose' : 'slate'}
|
||||
subtitle="tendencia reciente"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MetricCard({
|
||||
label,
|
||||
value,
|
||||
subtitle,
|
||||
tone,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
subtitle?: string
|
||||
tone: 'slate' | 'emerald' | 'rose'
|
||||
}) {
|
||||
const colorClass =
|
||||
tone === 'emerald'
|
||||
? 'text-emerald-700'
|
||||
: tone === 'rose'
|
||||
? 'text-rose-700'
|
||||
: 'text-slate-700'
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-slate-200 px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500 font-medium">{label}</p>
|
||||
<p className={`font-mono text-xl font-bold mt-1 ${colorClass}`}>{value}</p>
|
||||
{subtitle && <p className="text-xs text-slate-400 mt-1 truncate">{subtitle}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export type TabKey = 'insumos' | 'formulas' | 'calculadora' | 'historial'
|
||||
|
||||
interface TabBarProps {
|
||||
active: TabKey
|
||||
onChange: (key: TabKey) => void
|
||||
}
|
||||
|
||||
const TABS: Array<{ key: TabKey; label: string; subtitle: string }> = [
|
||||
{ key: 'insumos', label: '1. Insumos', subtitle: 'Precios' },
|
||||
{ key: 'formulas', label: '2. Fórmulas', subtitle: 'Recetas' },
|
||||
{ key: 'calculadora', label: '3. Calculadora', subtitle: 'Rentabilidad' },
|
||||
{ key: 'historial', label: '4. Historial', subtitle: 'Producción' },
|
||||
]
|
||||
|
||||
export function TabBar({ active, onChange }: TabBarProps) {
|
||||
return (
|
||||
<div className="border-b border-slate-200 bg-white">
|
||||
<nav className="flex gap-1 px-4" aria-label="Tabs">
|
||||
{TABS.map((tab) => {
|
||||
const isActive = tab.key === active
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => onChange(tab.key)}
|
||||
className={[
|
||||
'px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||
isActive
|
||||
? 'border-blue-600 text-blue-700'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-800 hover:border-slate-300',
|
||||
].join(' ')}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
>
|
||||
<span className="block">{tab.label}</span>
|
||||
<span className="block text-xs font-normal text-slate-400">{tab.subtitle}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Totales } from '../types'
|
||||
import { formatAdena } from '../utils/format'
|
||||
|
||||
interface TotalsSummaryProps {
|
||||
totales: Totales
|
||||
}
|
||||
|
||||
export function TotalsSummary({ totales }: TotalsSummaryProps) {
|
||||
const margen = totales.costoTotal > 0
|
||||
? (totales.ganancia / totales.costoTotal) * 100
|
||||
: 0
|
||||
|
||||
return (
|
||||
<section
|
||||
className={[
|
||||
'rounded-lg border-2 p-5',
|
||||
totales.ganancia > 0
|
||||
? 'bg-emerald-50 border-emerald-200'
|
||||
: totales.ganancia < 0
|
||||
? 'bg-rose-50 border-rose-200'
|
||||
: 'bg-slate-50 border-slate-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<header className="mb-3">
|
||||
<h2 className="text-base font-bold text-slate-800">Resumen global</h2>
|
||||
<p className="text-xs text-slate-500">
|
||||
Suma de Soulshots, Spiritshots y Blessed Spiritshots
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4">
|
||||
<Metric label="Costo total" value={formatAdena(totales.costoTotal)} tone="slate" />
|
||||
<Metric label="Valor de venta" value={formatAdena(totales.valorVenta)} tone="slate" />
|
||||
<Metric
|
||||
label="Ganancia neta"
|
||||
value={formatAdena(totales.ganancia)}
|
||||
tone={totales.ganancia > 0 ? 'emerald' : totales.ganancia < 0 ? 'rose' : 'slate'}
|
||||
highlight
|
||||
/>
|
||||
<Metric
|
||||
label="Margen"
|
||||
value={`${margen.toFixed(1)}%`}
|
||||
tone={margen > 0 ? 'emerald' : margen < 0 ? 'rose' : 'slate'}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Metric({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
highlight = false,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
tone: 'slate' | 'emerald' | 'rose'
|
||||
highlight?: boolean
|
||||
}) {
|
||||
const colorClass =
|
||||
tone === 'emerald' ? 'text-emerald-700' : tone === 'rose' ? 'text-rose-700' : 'text-slate-800'
|
||||
return (
|
||||
<div className="bg-white rounded border border-slate-200 px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500 font-medium">{label}</p>
|
||||
<p
|
||||
className={[
|
||||
'font-mono mt-1',
|
||||
highlight ? 'text-2xl font-bold' : 'text-xl font-semibold',
|
||||
colorClass,
|
||||
].join(' ')}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { historyApi, type HistoryStats, type RunIn, type RunSummary } from '../api/history'
|
||||
|
||||
export type HistorySaveStatus = 'idle' | 'saving' | 'saved' | 'error'
|
||||
|
||||
interface UseHistoryResult {
|
||||
runs: RunSummary[]
|
||||
stats: HistoryStats | null
|
||||
loading: boolean
|
||||
saveStatus: HistorySaveStatus
|
||||
saveError: string | null
|
||||
reload: () => Promise<void>
|
||||
saveRun: (payload: RunIn) => Promise<RunSummary>
|
||||
deleteRun: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function useHistory(): UseHistoryResult {
|
||||
const [runs, setRuns] = useState<RunSummary[]>([])
|
||||
const [stats, setStats] = useState<HistoryStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saveStatus, setSaveStatus] = useState<HistorySaveStatus>('idle')
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [runsData, statsData] = await Promise.all([
|
||||
historyApi.listRuns(),
|
||||
historyApi.getStats(),
|
||||
])
|
||||
setRuns(runsData)
|
||||
setStats(statsData)
|
||||
} catch (e) {
|
||||
console.error('history load failed', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
reload()
|
||||
}, [reload])
|
||||
|
||||
const saveRun = useCallback(async (payload: RunIn) => {
|
||||
setSaveStatus('saving')
|
||||
setSaveError(null)
|
||||
try {
|
||||
const saved = await historyApi.saveRun(payload)
|
||||
setRuns((prev) => [saved, ...prev])
|
||||
const newStats = await historyApi.getStats()
|
||||
setStats(newStats)
|
||||
setSaveStatus('saved')
|
||||
window.setTimeout(() => setSaveStatus((s) => (s === 'saved' ? 'idle' : s)), 3000)
|
||||
return saved
|
||||
} catch (e) {
|
||||
setSaveStatus('error')
|
||||
setSaveError(e instanceof Error ? e.message : 'Error al guardar')
|
||||
throw e
|
||||
}
|
||||
}, [])
|
||||
|
||||
const deleteRun = useCallback(async (id: string) => {
|
||||
await historyApi.deleteRun(id)
|
||||
setRuns((prev) => prev.filter((r) => r.id !== id))
|
||||
const newStats = await historyApi.getStats()
|
||||
setStats(newStats)
|
||||
}, [])
|
||||
|
||||
return { runs, stats, loading, saveStatus, saveError, reload, saveRun, deleteRun }
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api, ApiError, type AppState } from '../api/client'
|
||||
import { makeDefaultAppState } from '../data/defaults'
|
||||
|
||||
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
|
||||
|
||||
interface UsePersistedStateResult {
|
||||
state: AppState | null
|
||||
setState: (next: AppState | ((prev: AppState) => AppState)) => void
|
||||
status: SaveStatus
|
||||
errorMessage: string | null
|
||||
reset: () => Promise<void>
|
||||
reload: () => Promise<void>
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 500
|
||||
|
||||
export function usePersistedState(): UsePersistedStateResult {
|
||||
const [state, setStateInternal] = useState<AppState | null>(null)
|
||||
const [status, setStatus] = useState<SaveStatus>('idle')
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const stateRef = useRef<AppState | null>(null)
|
||||
const timerRef = useRef<number | null>(null)
|
||||
const firstLoad = useRef(true)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const loaded = await api.getState()
|
||||
setStateInternal(loaded ?? makeDefaultAppState())
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
setStateInternal(null)
|
||||
} else {
|
||||
setErrorMessage(err instanceof Error ? err.message : 'Error cargando estado')
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
stateRef.current = state
|
||||
}, [state])
|
||||
|
||||
useEffect(() => {
|
||||
if (firstLoad.current) {
|
||||
firstLoad.current = false
|
||||
return
|
||||
}
|
||||
if (!state) return
|
||||
|
||||
setStatus('saving')
|
||||
if (timerRef.current !== null) {
|
||||
window.clearTimeout(timerRef.current)
|
||||
}
|
||||
timerRef.current = window.setTimeout(async () => {
|
||||
try {
|
||||
if (stateRef.current) {
|
||||
await api.putState(stateRef.current)
|
||||
}
|
||||
setStatus('saved')
|
||||
setErrorMessage(null)
|
||||
window.setTimeout(() => setStatus((s) => (s === 'saved' ? 'idle' : s)), 1500)
|
||||
} catch (err) {
|
||||
setStatus('error')
|
||||
setErrorMessage(err instanceof Error ? err.message : 'Error al guardar')
|
||||
}
|
||||
}, DEBOUNCE_MS)
|
||||
|
||||
return () => {
|
||||
if (timerRef.current !== null) {
|
||||
window.clearTimeout(timerRef.current)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
const setState = useCallback((next: AppState | ((prev: AppState) => AppState)) => {
|
||||
setStateInternal((prev) => {
|
||||
if (typeof next === 'function') {
|
||||
return (next as (prev: AppState) => AppState)(prev as AppState)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const reset = useCallback(async () => {
|
||||
await api.resetState()
|
||||
setStateInternal(makeDefaultAppState())
|
||||
}, [])
|
||||
|
||||
return { state, setState, status, errorMessage, reset, reload: load }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
body {
|
||||
@apply bg-slate-50 text-slate-900;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.input-editable {
|
||||
@apply w-full bg-editable-50 border border-editable-200 rounded px-2 py-1 text-right font-mono text-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-editable-400 focus:border-editable-400;
|
||||
}
|
||||
.input-editable:disabled {
|
||||
@apply bg-slate-100 text-slate-400 cursor-not-allowed;
|
||||
}
|
||||
.cell-calculated {
|
||||
@apply bg-calculated-100 text-slate-700 font-mono text-sm text-right px-2 py-1;
|
||||
}
|
||||
.cell-label {
|
||||
@apply px-3 py-2 text-sm font-semibold text-slate-700 bg-slate-100 border-b border-slate-200;
|
||||
}
|
||||
.cell-input {
|
||||
@apply px-2 py-1 border-b border-slate-200;
|
||||
}
|
||||
.cell-calc {
|
||||
@apply px-2 py-1 border-b border-slate-200 bg-calculated-50;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
export type Grado = 'D' | 'C' | 'B' | 'A' | 'S'
|
||||
|
||||
export const GRADOS: Grado[] = ['D', 'C', 'B', 'A', 'S']
|
||||
|
||||
export type ShotType = 'Soulshot' | 'Spiritshot' | 'Blessed Spiritshot'
|
||||
|
||||
export const SHOT_TYPES: ShotType[] = ['Soulshot', 'Spiritshot', 'Blessed Spiritshot']
|
||||
|
||||
export type OreType = 'Soul Ore' | 'Spirit Ore'
|
||||
|
||||
export interface Insumos {
|
||||
cristales: Record<string, number>
|
||||
soulOre: number
|
||||
spiritOre: number
|
||||
venta: Record<string, Record<string, number>>
|
||||
}
|
||||
|
||||
export interface Formula {
|
||||
id: string
|
||||
tipo: string
|
||||
grado: string
|
||||
cristalesReq: number
|
||||
soulOreReq: number | null
|
||||
spiritOreReq: number | null
|
||||
shotsObtenidos: number
|
||||
}
|
||||
|
||||
export type CristalesDisponibles = Record<string, Record<string, number>>
|
||||
|
||||
export interface Calculo {
|
||||
tipo: string
|
||||
grado: string
|
||||
cristalesDisponibles: number
|
||||
cristalesUsados: number
|
||||
oreNecesario: number
|
||||
oreLabel: OreType | null
|
||||
crafteosPosibles: number
|
||||
shotsObtenidos: number
|
||||
costoTotal: number
|
||||
valorVenta: number
|
||||
ganancia: number
|
||||
warning: string | null
|
||||
}
|
||||
|
||||
export interface Totales {
|
||||
cristalesUsados: number
|
||||
oreNecesario: number
|
||||
crafteosPosibles: number
|
||||
shotsObtenidos: number
|
||||
costoTotal: number
|
||||
valorVenta: number
|
||||
ganancia: number
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { Calculo, Formula, Insumos, OreType, Totales } from '../types'
|
||||
|
||||
export function calcularFila(
|
||||
formula: Formula,
|
||||
cristalesDisponibles: number,
|
||||
insumos: Insumos,
|
||||
): Calculo {
|
||||
const orePerCraft = formula.soulOreReq ?? formula.spiritOreReq ?? 0
|
||||
const oreLabel: OreType | null =
|
||||
formula.soulOreReq != null ? 'Soul Ore' : formula.spiritOreReq != null ? 'Spirit Ore' : null
|
||||
|
||||
if (formula.cristalesReq <= 0) {
|
||||
return {
|
||||
tipo: formula.tipo,
|
||||
grado: formula.grado,
|
||||
cristalesDisponibles,
|
||||
cristalesUsados: 0,
|
||||
oreNecesario: 0,
|
||||
oreLabel,
|
||||
crafteosPosibles: 0,
|
||||
shotsObtenidos: 0,
|
||||
costoTotal: 0,
|
||||
valorVenta: 0,
|
||||
ganancia: 0,
|
||||
warning: 'Completa la receta en la pestaña Fórmulas',
|
||||
}
|
||||
}
|
||||
|
||||
const crafteosPosibles = Math.floor(cristalesDisponibles / formula.cristalesReq)
|
||||
const cristalesUsados = crafteosPosibles * formula.cristalesReq
|
||||
const oreNecesario = crafteosPosibles * orePerCraft
|
||||
const shotsObtenidos = crafteosPosibles * formula.shotsObtenidos
|
||||
|
||||
const precioCristal = insumos.cristales[formula.grado]
|
||||
const precioOre = orePerCraft > 0
|
||||
? formula.soulOreReq != null
|
||||
? insumos.soulOre
|
||||
: insumos.spiritOre
|
||||
: 0
|
||||
const precioVenta = insumos.venta[formula.tipo][formula.grado]
|
||||
|
||||
const costoTotal = cristalesUsados * precioCristal + oreNecesario * precioOre
|
||||
const valorVenta = shotsObtenidos * precioVenta
|
||||
const ganancia = valorVenta - costoTotal
|
||||
|
||||
return {
|
||||
tipo: formula.tipo,
|
||||
grado: formula.grado,
|
||||
cristalesDisponibles,
|
||||
cristalesUsados,
|
||||
oreNecesario,
|
||||
oreLabel,
|
||||
crafteosPosibles,
|
||||
shotsObtenidos,
|
||||
costoTotal,
|
||||
valorVenta,
|
||||
ganancia,
|
||||
warning: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function sumarTotales(calculos: Calculo[]): Totales {
|
||||
return calculos.reduce<Totales>(
|
||||
(acc, c) => ({
|
||||
cristalesUsados: acc.cristalesUsados + c.cristalesUsados,
|
||||
oreNecesario: acc.oreNecesario + c.oreNecesario,
|
||||
crafteosPosibles: acc.crafteosPosibles + c.crafteosPosibles,
|
||||
shotsObtenidos: acc.shotsObtenidos + c.shotsObtenidos,
|
||||
costoTotal: acc.costoTotal + c.costoTotal,
|
||||
valorVenta: acc.valorVenta + c.valorVenta,
|
||||
ganancia: acc.ganancia + c.ganancia,
|
||||
}),
|
||||
{
|
||||
cristalesUsados: 0,
|
||||
oreNecesario: 0,
|
||||
crafteosPosibles: 0,
|
||||
shotsObtenidos: 0,
|
||||
costoTotal: 0,
|
||||
valorVenta: 0,
|
||||
ganancia: 0,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
const formatter = new Intl.NumberFormat('es-ES', { maximumFractionDigits: 0 })
|
||||
|
||||
export function formatAdena(n: number): string {
|
||||
if (!Number.isFinite(n)) return '0'
|
||||
return formatter.format(Math.round(n))
|
||||
}
|
||||
|
||||
export function formatNumber(n: number): string {
|
||||
return formatter.format(n)
|
||||
}
|
||||
|
||||
export function parseNumber(value: string): number {
|
||||
if (value === '' || value === '-') return 0
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) && n >= 0 ? n : 0
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
editable: {
|
||||
50: '#fffbeb',
|
||||
100: '#fef3c7',
|
||||
200: '#fde68a',
|
||||
400: '#fbbf24',
|
||||
500: '#f59e0b',
|
||||
},
|
||||
calculated: {
|
||||
50: '#f8fafc',
|
||||
100: '#f1f5f9',
|
||||
200: '#e2e8f0',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', 'monospace'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: './',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8080',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.l2.shots.auth;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public class AuthMeResponse {
|
||||
public UUID id;
|
||||
public String username;
|
||||
public Instant createdAt;
|
||||
|
||||
public AuthMeResponse() {}
|
||||
|
||||
public AuthMeResponse(UUID id, String username, Instant createdAt) {
|
||||
this.id = id;
|
||||
this.username = username;
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.l2.shots.auth;
|
||||
|
||||
import io.quarkus.security.Authenticated;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.NewCookie;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
import org.eclipse.microprofile.jwt.JsonWebToken;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Path("/api/auth")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public class AuthResource {
|
||||
|
||||
@Inject
|
||||
AuthService authService;
|
||||
|
||||
@Inject
|
||||
JwtCookieAuth jwtCookieAuth;
|
||||
|
||||
@ConfigProperty(name = "app.auth.cookie-name")
|
||||
String cookieName;
|
||||
|
||||
@ConfigProperty(name = "app.auth.cookie-max-age-seconds")
|
||||
int cookieMaxAge;
|
||||
|
||||
@POST
|
||||
@Path("/register")
|
||||
public Response register(Credentials creds) {
|
||||
Optional<User> result = authService.register(creds.username, creds.password);
|
||||
if (result.isEmpty()) {
|
||||
return Response.status(409)
|
||||
.entity(new ErrorBody("username no disponible o datos inválidos"))
|
||||
.build();
|
||||
}
|
||||
User user = result.get();
|
||||
String token = authService.buildToken(user.id);
|
||||
return Response.ok(AuthService.toAuthMe(user))
|
||||
.cookie(buildAuthCookie(token))
|
||||
.build();
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/login")
|
||||
public Response login(Credentials creds) {
|
||||
Optional<User> result = authService.authenticate(creds.username, creds.password);
|
||||
if (result.isEmpty()) {
|
||||
return Response.status(401)
|
||||
.entity(new ErrorBody("credenciales inválidas"))
|
||||
.build();
|
||||
}
|
||||
User user = result.get();
|
||||
String token = authService.buildToken(user.id);
|
||||
return Response.ok(AuthService.toAuthMe(user))
|
||||
.cookie(buildAuthCookie(token))
|
||||
.build();
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/logout")
|
||||
public Response logout() {
|
||||
return Response.noContent()
|
||||
.cookie(clearAuthCookie())
|
||||
.build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/me")
|
||||
public Response me(@Context HttpHeaders headers) {
|
||||
Optional<JsonWebToken> jwt = jwtCookieAuth.extractToken(headers);
|
||||
if (jwt.isEmpty()) {
|
||||
return Response.status(401).build();
|
||||
}
|
||||
return authService.getUserFromToken(jwt.get())
|
||||
.map(u -> Response.ok(u).build())
|
||||
.orElse(Response.status(401).build());
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/check")
|
||||
@Authenticated
|
||||
public Response check() {
|
||||
return Response.ok().build();
|
||||
}
|
||||
|
||||
private NewCookie buildAuthCookie(String token) {
|
||||
return new NewCookie.Builder(cookieName)
|
||||
.value(token)
|
||||
.path("/")
|
||||
.httpOnly(true)
|
||||
.secure(false)
|
||||
.sameSite(NewCookie.SameSite.LAX)
|
||||
.maxAge(cookieMaxAge)
|
||||
.build();
|
||||
}
|
||||
|
||||
private NewCookie clearAuthCookie() {
|
||||
return new NewCookie.Builder(cookieName)
|
||||
.value("")
|
||||
.path("/")
|
||||
.httpOnly(true)
|
||||
.secure(false)
|
||||
.sameSite(NewCookie.SameSite.LAX)
|
||||
.maxAge(0)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static class ErrorBody {
|
||||
public String error;
|
||||
public ErrorBody() {}
|
||||
public ErrorBody(String error) { this.error = error; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.l2.shots.auth;
|
||||
|
||||
import io.quarkus.elytron.security.common.BcryptUtil;
|
||||
import io.smallrye.jwt.build.Jwt;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.transaction.Transactional;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
import org.eclipse.microprofile.jwt.JsonWebToken;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@ApplicationScoped
|
||||
public class AuthService {
|
||||
|
||||
private static final Pattern USERNAME_PATTERN = Pattern.compile("^[a-zA-Z0-9_]{3,30}$");
|
||||
public static final int MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
@ConfigProperty(name = "mp.jwt.verify.issuer")
|
||||
String issuer;
|
||||
|
||||
@Transactional
|
||||
public Optional<User> register(String username, String password) {
|
||||
if (username == null || password == null) return Optional.empty();
|
||||
username = username.trim();
|
||||
if (!USERNAME_PATTERN.matcher(username).matches()) return Optional.empty();
|
||||
if (password.length() < MIN_PASSWORD_LENGTH) return Optional.empty();
|
||||
if (User.findByUsernameCaseInsensitive(username) != null) return Optional.empty();
|
||||
|
||||
User user = new User();
|
||||
user.id = UUID.randomUUID();
|
||||
user.username = username.toLowerCase();
|
||||
user.passwordHash = BcryptUtil.bcryptHash(password);
|
||||
user.createdAt = Instant.now();
|
||||
user.persist();
|
||||
|
||||
return Optional.of(user);
|
||||
}
|
||||
|
||||
public Optional<User> authenticate(String username, String password) {
|
||||
if (username == null || password == null) return Optional.empty();
|
||||
User user = User.findByUsernameCaseInsensitive(username.trim());
|
||||
if (user == null) return Optional.empty();
|
||||
if (!BcryptUtil.matches(password, user.passwordHash)) return Optional.empty();
|
||||
return Optional.of(user);
|
||||
}
|
||||
|
||||
public String buildToken(UUID userId) {
|
||||
return Jwt.issuer(issuer)
|
||||
.subject(userId.toString())
|
||||
.groups(Set.of("user"))
|
||||
.expiresIn(Duration.ofSeconds(86400))
|
||||
.sign();
|
||||
}
|
||||
|
||||
public Optional<AuthMeResponse> getUserFromToken(JsonWebToken jwt) {
|
||||
if (jwt == null || jwt.getSubject() == null) return Optional.empty();
|
||||
try {
|
||||
UUID userId = UUID.fromString(jwt.getSubject());
|
||||
User user = User.findById(userId);
|
||||
if (user == null) return Optional.empty();
|
||||
return Optional.of(new AuthMeResponse(user.id, user.username, user.createdAt));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
public static AuthMeResponse toAuthMe(User user) {
|
||||
return new AuthMeResponse(user.id, user.username, user.createdAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.l2.shots.auth;
|
||||
|
||||
public class Credentials {
|
||||
public String username;
|
||||
public String password;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.l2.shots.auth;
|
||||
|
||||
import io.smallrye.jwt.auth.principal.JWTParser;
|
||||
import io.smallrye.jwt.auth.principal.ParseException;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
import org.eclipse.microprofile.jwt.JsonWebToken;
|
||||
|
||||
import jakarta.ws.rs.core.Cookie;
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@ApplicationScoped
|
||||
public class JwtCookieAuth {
|
||||
|
||||
@Inject
|
||||
JWTParser parser;
|
||||
|
||||
@ConfigProperty(name = "app.auth.cookie-name")
|
||||
String cookieName;
|
||||
|
||||
public Optional<JsonWebToken> extractToken(HttpHeaders headers) {
|
||||
String auth = headers.getHeaderString("Authorization");
|
||||
if (auth != null && auth.startsWith("Bearer ")) {
|
||||
return Optional.of(parseToken(auth.substring(7)));
|
||||
}
|
||||
Map<String, Cookie> cookies = headers.getCookies();
|
||||
Cookie cookie = cookies.get(cookieName);
|
||||
if (cookie != null && cookie.getValue() != null && !cookie.getValue().isEmpty()) {
|
||||
return Optional.of(parseToken(cookie.getValue()));
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private JsonWebToken parseToken(String token) {
|
||||
try {
|
||||
return parser.parse(token);
|
||||
} catch (ParseException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.l2.shots.auth;
|
||||
|
||||
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
public class User extends PanacheEntityBase {
|
||||
|
||||
@Id
|
||||
public UUID id;
|
||||
|
||||
@Column(unique = true, nullable = false, length = 30)
|
||||
public String username;
|
||||
|
||||
@Column(name = "password_hash", nullable = false, length = 100)
|
||||
public String passwordHash;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
public Instant createdAt;
|
||||
|
||||
public static User findByUsername(String username) {
|
||||
return find("username", username.toLowerCase()).firstResult();
|
||||
}
|
||||
|
||||
public static User findByUsernameCaseInsensitive(String username) {
|
||||
return find("LOWER(username) = ?1", username.toLowerCase()).firstResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.l2.shots.auth;
|
||||
|
||||
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "user_state")
|
||||
public class UserState extends PanacheEntityBase {
|
||||
|
||||
@Id
|
||||
@Column(name = "user_id")
|
||||
public UUID userId;
|
||||
|
||||
@Column(name = "state_json", nullable = false, columnDefinition = "TEXT")
|
||||
public String stateJson;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
public Instant updatedAt;
|
||||
|
||||
public static UserState findByUserId(UUID userId) {
|
||||
return findById(userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.l2.shots.history;
|
||||
|
||||
import com.l2.shots.auth.JwtCookieAuth;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.eclipse.microprofile.jwt.JsonWebToken;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Path("/api/history")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public class HistoryResource {
|
||||
|
||||
@Inject
|
||||
HistoryService historyService;
|
||||
|
||||
@Inject
|
||||
JwtCookieAuth jwtCookieAuth;
|
||||
|
||||
@POST
|
||||
@Path("/runs")
|
||||
public Response saveRun(@Context HttpHeaders headers, RunIn input) {
|
||||
Optional<UUID> userId = extractUserId(headers);
|
||||
if (userId.isEmpty()) return Response.status(401).build();
|
||||
|
||||
if (input == null || input.items == null || input.snapshot == null) {
|
||||
return Response.status(400).entity("{\"error\":\"payload inválido\"}").build();
|
||||
}
|
||||
if (input.items.size() > 100) {
|
||||
return Response.status(400).entity("{\"error\":\"demasiados items\"}").build();
|
||||
}
|
||||
if (input.label != null && input.label.length() > 100) {
|
||||
return Response.status(400).entity("{\"error\":\"label demasiado largo\"}").build();
|
||||
}
|
||||
if (input.totalCristalesUsed <= 0) {
|
||||
return Response.status(400).entity("{\"error\":\"no hay cristales usados\"}").build();
|
||||
}
|
||||
|
||||
RunSummary saved = historyService.saveRun(userId.get(), input);
|
||||
return Response.status(201).entity(saved).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/runs")
|
||||
public Response list(@Context HttpHeaders headers) {
|
||||
Optional<UUID> userId = extractUserId(headers);
|
||||
if (userId.isEmpty()) return Response.status(401).build();
|
||||
|
||||
List<RunSummary> runs = historyService.listForUser(userId.get());
|
||||
return Response.ok(runs).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/runs/{id}")
|
||||
public Response get(@Context HttpHeaders headers, @PathParam("id") String idStr) {
|
||||
Optional<UUID> userId = extractUserId(headers);
|
||||
if (userId.isEmpty()) return Response.status(401).build();
|
||||
|
||||
UUID id;
|
||||
try {
|
||||
id = UUID.fromString(idStr);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Response.status(400).entity("{\"error\":\"id inválido\"}").build();
|
||||
}
|
||||
|
||||
return historyService.getById(userId.get(), id)
|
||||
.map(d -> Response.ok(d).build())
|
||||
.orElse(Response.status(404).build());
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/runs/{id}")
|
||||
public Response delete(@Context HttpHeaders headers, @PathParam("id") String idStr) {
|
||||
Optional<UUID> userId = extractUserId(headers);
|
||||
if (userId.isEmpty()) return Response.status(401).build();
|
||||
|
||||
UUID id;
|
||||
try {
|
||||
id = UUID.fromString(idStr);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Response.status(400).entity("{\"error\":\"id inválido\"}").build();
|
||||
}
|
||||
|
||||
boolean deleted = historyService.deleteForUser(userId.get(), id);
|
||||
return deleted ? Response.noContent().build() : Response.status(404).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/stats")
|
||||
public Response stats(@Context HttpHeaders headers) {
|
||||
Optional<UUID> userId = extractUserId(headers);
|
||||
if (userId.isEmpty()) return Response.status(401).build();
|
||||
|
||||
HistoryStats stats = historyService.computeStats(userId.get());
|
||||
return Response.ok(stats).build();
|
||||
}
|
||||
|
||||
private Optional<UUID> extractUserId(HttpHeaders headers) {
|
||||
Optional<JsonWebToken> jwt = jwtCookieAuth.extractToken(headers);
|
||||
if (jwt.isEmpty()) return Optional.empty();
|
||||
try {
|
||||
return Optional.of(UUID.fromString(jwt.get().getSubject()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.l2.shots.history;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.transaction.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@ApplicationScoped
|
||||
public class HistoryService {
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
public List<RunSummary> listForUser(UUID userId) {
|
||||
return ProductionRun.<ProductionRun>list("userId = ?1 ORDER BY createdAt DESC", userId)
|
||||
.stream()
|
||||
.map(RunSummary::new)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public Optional<RunDetails> getById(UUID userId, UUID id) {
|
||||
ProductionRun entity = ProductionRun.find("id = ?1 AND userId = ?2", id, userId).firstResult();
|
||||
if (entity == null) return Optional.empty();
|
||||
try {
|
||||
List<RunItem> items = mapper.readValue(entity.itemsJson, mapper.getTypeFactory()
|
||||
.constructCollectionType(List.class, RunItem.class));
|
||||
RunSnapshot snapshot = mapper.readValue(entity.snapshotJson, RunSnapshot.class);
|
||||
return Optional.of(new RunDetails(entity, items, snapshot));
|
||||
} catch (JsonProcessingException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public RunSummary saveRun(UUID userId, RunIn input) {
|
||||
ProductionRun entity = new ProductionRun();
|
||||
entity.id = UUID.randomUUID();
|
||||
entity.userId = userId;
|
||||
entity.createdAt = Instant.now();
|
||||
entity.label = input.label;
|
||||
entity.totalCost = input.totalCost;
|
||||
entity.totalSale = input.totalSale;
|
||||
entity.totalProfit = input.totalProfit;
|
||||
entity.totalShots = input.totalShots;
|
||||
entity.totalCristalesUsed = input.totalCristalesUsed;
|
||||
entity.totalOreUsed = input.totalOreUsed;
|
||||
try {
|
||||
entity.itemsJson = mapper.writeValueAsString(input.items);
|
||||
entity.snapshotJson = mapper.writeValueAsString(input.snapshot);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new RuntimeException("Failed to serialize run payload", e);
|
||||
}
|
||||
entity.persist();
|
||||
return new RunSummary(entity);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean deleteForUser(UUID userId, UUID id) {
|
||||
return ProductionRun.delete("id = ?1 AND userId = ?2", id, userId) > 0;
|
||||
}
|
||||
|
||||
public HistoryStats computeStats(UUID userId) {
|
||||
List<ProductionRun> runs = ProductionRun.list(
|
||||
"userId = ?1 ORDER BY createdAt DESC", userId);
|
||||
|
||||
HistoryStats stats = new HistoryStats();
|
||||
stats.totalRuns = runs.size();
|
||||
|
||||
if (runs.isEmpty()) {
|
||||
stats.totalCost = 0;
|
||||
stats.totalSale = 0;
|
||||
stats.totalProfit = 0;
|
||||
stats.totalShots = 0;
|
||||
stats.avgProfit = 0;
|
||||
stats.avgCost = 0;
|
||||
stats.avgSale = 0;
|
||||
stats.bestRun = null;
|
||||
stats.worstRun = null;
|
||||
stats.last5Avg = 0;
|
||||
stats.last10Avg = 0;
|
||||
return stats;
|
||||
}
|
||||
|
||||
long totalCost = 0;
|
||||
long totalSale = 0;
|
||||
long totalProfit = 0;
|
||||
long totalShots = 0;
|
||||
ProductionRun best = runs.get(0);
|
||||
ProductionRun worst = runs.get(0);
|
||||
|
||||
for (ProductionRun r : runs) {
|
||||
totalCost += r.totalCost;
|
||||
totalSale += r.totalSale;
|
||||
totalProfit += r.totalProfit;
|
||||
totalShots += r.totalShots;
|
||||
if (r.totalProfit > best.totalProfit) best = r;
|
||||
if (r.totalProfit < worst.totalProfit) worst = r;
|
||||
}
|
||||
|
||||
stats.totalCost = totalCost;
|
||||
stats.totalSale = totalSale;
|
||||
stats.totalProfit = totalProfit;
|
||||
stats.totalShots = totalShots;
|
||||
stats.avgProfit = totalProfit / runs.size();
|
||||
stats.avgCost = totalCost / runs.size();
|
||||
stats.avgSale = totalSale / runs.size();
|
||||
stats.bestRun = new RunSummary(best);
|
||||
stats.worstRun = new RunSummary(worst);
|
||||
|
||||
int n5 = Math.min(5, runs.size());
|
||||
int n10 = Math.min(10, runs.size());
|
||||
long sum5 = 0;
|
||||
long sum10 = 0;
|
||||
for (int i = 0; i < n10; i++) {
|
||||
sum10 += runs.get(i).totalProfit;
|
||||
if (i < n5) sum5 += runs.get(i).totalProfit;
|
||||
}
|
||||
stats.last5Avg = n5 > 0 ? sum5 / n5 : 0;
|
||||
stats.last10Avg = n10 > 0 ? sum10 / n10 : 0;
|
||||
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.l2.shots.history;
|
||||
|
||||
public class HistoryStats {
|
||||
public int totalRuns;
|
||||
public long totalCost;
|
||||
public long totalSale;
|
||||
public long totalProfit;
|
||||
public long totalShots;
|
||||
public long avgProfit;
|
||||
public long avgCost;
|
||||
public long avgSale;
|
||||
public RunSummary bestRun;
|
||||
public RunSummary worstRun;
|
||||
public long last5Avg;
|
||||
public long last10Avg;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.l2.shots.history;
|
||||
|
||||
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "production_runs", indexes = {
|
||||
@Index(name = "idx_runs_user_created", columnList = "user_id, created_at")
|
||||
})
|
||||
public class ProductionRun extends PanacheEntityBase {
|
||||
|
||||
@Id
|
||||
public UUID id;
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
public UUID userId;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
public Instant createdAt;
|
||||
|
||||
@Column(length = 100)
|
||||
public String label;
|
||||
|
||||
@Column(name = "total_cost", nullable = false)
|
||||
public long totalCost;
|
||||
|
||||
@Column(name = "total_sale", nullable = false)
|
||||
public long totalSale;
|
||||
|
||||
@Column(name = "total_profit", nullable = false)
|
||||
public long totalProfit;
|
||||
|
||||
@Column(name = "total_shots", nullable = false)
|
||||
public long totalShots;
|
||||
|
||||
@Column(name = "total_cristales_used", nullable = false)
|
||||
public long totalCristalesUsed;
|
||||
|
||||
@Column(name = "total_ore_used", nullable = false)
|
||||
public long totalOreUsed;
|
||||
|
||||
@Column(name = "items_json", nullable = false, columnDefinition = "TEXT")
|
||||
public String itemsJson;
|
||||
|
||||
@Column(name = "snapshot_json", nullable = false, columnDefinition = "TEXT")
|
||||
public String snapshotJson;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.l2.shots.history;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class RunDetails extends RunSummary {
|
||||
public List<RunItem> items;
|
||||
public RunSnapshot snapshot;
|
||||
|
||||
public RunDetails() {}
|
||||
|
||||
public RunDetails(ProductionRun r, List<RunItem> items, RunSnapshot snapshot) {
|
||||
super(r);
|
||||
this.items = items;
|
||||
this.snapshot = snapshot;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.l2.shots.history;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class RunIn {
|
||||
public String label;
|
||||
public long totalCost;
|
||||
public long totalSale;
|
||||
public long totalProfit;
|
||||
public long totalShots;
|
||||
public long totalCristalesUsed;
|
||||
public long totalOreUsed;
|
||||
public List<RunItem> items;
|
||||
public RunSnapshot snapshot;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.l2.shots.history;
|
||||
|
||||
public class RunItem {
|
||||
public String tipo;
|
||||
public String grado;
|
||||
public int cristalesDisponibles;
|
||||
public int cristalesUsados;
|
||||
public int oreNecesario;
|
||||
public int crafteosPosibles;
|
||||
public int shotsObtenidos;
|
||||
public long costoTotal;
|
||||
public long valorVenta;
|
||||
public long ganancia;
|
||||
|
||||
public RunItem() {}
|
||||
|
||||
public RunItem(String tipo, String grado, int cristalesDisponibles, int cristalesUsados,
|
||||
int oreNecesario, int crafteosPosibles, int shotsObtenidos,
|
||||
long costoTotal, long valorVenta, long ganancia) {
|
||||
this.tipo = tipo;
|
||||
this.grado = grado;
|
||||
this.cristalesDisponibles = cristalesDisponibles;
|
||||
this.cristalesUsados = cristalesUsados;
|
||||
this.oreNecesario = oreNecesario;
|
||||
this.crafteosPosibles = crafteosPosibles;
|
||||
this.shotsObtenidos = shotsObtenidos;
|
||||
this.costoTotal = costoTotal;
|
||||
this.valorVenta = valorVenta;
|
||||
this.ganancia = ganancia;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.l2.shots.history;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class RunSnapshot {
|
||||
public Map<String, Object> insumos;
|
||||
public List<Map<String, Object>> formulas;
|
||||
|
||||
public RunSnapshot() {}
|
||||
|
||||
public RunSnapshot(Map<String, Object> insumos, List<Map<String, Object>> formulas) {
|
||||
this.insumos = insumos;
|
||||
this.formulas = formulas;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.l2.shots.history;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public class RunSummary {
|
||||
public UUID id;
|
||||
public Instant createdAt;
|
||||
public String label;
|
||||
public long totalCost;
|
||||
public long totalSale;
|
||||
public long totalProfit;
|
||||
public long totalShots;
|
||||
public long totalCristalesUsed;
|
||||
public long totalOreUsed;
|
||||
|
||||
public RunSummary() {}
|
||||
|
||||
public RunSummary(ProductionRun r) {
|
||||
this.id = r.id;
|
||||
this.createdAt = r.createdAt;
|
||||
this.label = r.label;
|
||||
this.totalCost = r.totalCost;
|
||||
this.totalSale = r.totalSale;
|
||||
this.totalProfit = r.totalProfit;
|
||||
this.totalShots = r.totalShots;
|
||||
this.totalCristalesUsed = r.totalCristalesUsed;
|
||||
this.totalOreUsed = r.totalOreUsed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.l2.shots.state;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class AppState {
|
||||
|
||||
public Insumos insumos;
|
||||
public List<FormulaDto> formulas;
|
||||
public Map<String, Map<String, Integer>> disponibles;
|
||||
|
||||
public AppState() {}
|
||||
|
||||
public AppState(Insumos insumos, List<FormulaDto> formulas, Map<String, Map<String, Integer>> disponibles) {
|
||||
this.insumos = insumos;
|
||||
this.formulas = formulas;
|
||||
this.disponibles = disponibles;
|
||||
}
|
||||
|
||||
public static class Insumos {
|
||||
public Map<String, Integer> cristales;
|
||||
public int soulOre;
|
||||
public int spiritOre;
|
||||
public Map<String, Map<String, Integer>> venta;
|
||||
|
||||
public Insumos() {}
|
||||
|
||||
public Insumos(Map<String, Integer> cristales, int soulOre, int spiritOre,
|
||||
Map<String, Map<String, Integer>> venta) {
|
||||
this.cristales = cristales;
|
||||
this.soulOre = soulOre;
|
||||
this.spiritOre = spiritOre;
|
||||
this.venta = venta;
|
||||
}
|
||||
}
|
||||
|
||||
public static class FormulaDto {
|
||||
public String id;
|
||||
public String tipo;
|
||||
public String grado;
|
||||
public int cristalesReq;
|
||||
public Integer soulOreReq;
|
||||
public Integer spiritOreReq;
|
||||
public int shotsObtenidos;
|
||||
|
||||
public FormulaDto() {}
|
||||
|
||||
public FormulaDto(String id, String tipo, String grado, int cristalesReq,
|
||||
Integer soulOreReq, Integer spiritOreReq, int shotsObtenidos) {
|
||||
this.id = id;
|
||||
this.tipo = tipo;
|
||||
this.grado = grado;
|
||||
this.cristalesReq = cristalesReq;
|
||||
this.soulOreReq = soulOreReq;
|
||||
this.spiritOreReq = spiritOreReq;
|
||||
this.shotsObtenidos = shotsObtenidos;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.l2.shots.state;
|
||||
|
||||
import com.l2.shots.auth.JwtCookieAuth;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.eclipse.microprofile.jwt.JsonWebToken;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Path("/api/state")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public class StateResource {
|
||||
|
||||
@Inject
|
||||
StateService stateService;
|
||||
|
||||
@Inject
|
||||
JwtCookieAuth jwtCookieAuth;
|
||||
|
||||
@GET
|
||||
public Response get(@Context HttpHeaders headers) {
|
||||
Optional<UUID> userId = extractUserId(headers);
|
||||
if (userId.isEmpty()) return Response.status(401).build();
|
||||
|
||||
Optional<AppState> state = stateService.getForUser(userId.get());
|
||||
return state.map(s -> Response.ok(s).build())
|
||||
.orElse(Response.status(404).build());
|
||||
}
|
||||
|
||||
@PUT
|
||||
public Response put(@Context HttpHeaders headers, AppState state) {
|
||||
Optional<UUID> userId = extractUserId(headers);
|
||||
if (userId.isEmpty()) return Response.status(401).build();
|
||||
|
||||
if (state == null || state.insumos == null || state.formulas == null || state.disponibles == null) {
|
||||
return Response.status(400).entity("{\"error\":\"estado inválido\"}").build();
|
||||
}
|
||||
if (state.formulas.size() > 200) {
|
||||
return Response.status(400).entity("{\"error\":\"estado demasiado grande\"}").build();
|
||||
}
|
||||
stateService.saveForUser(userId.get(), state);
|
||||
return Response.noContent().build();
|
||||
}
|
||||
|
||||
@DELETE
|
||||
public Response reset(@Context HttpHeaders headers) {
|
||||
Optional<UUID> userId = extractUserId(headers);
|
||||
if (userId.isEmpty()) return Response.status(401).build();
|
||||
|
||||
stateService.deleteForUser(userId.get());
|
||||
return Response.noContent().build();
|
||||
}
|
||||
|
||||
private Optional<UUID> extractUserId(HttpHeaders headers) {
|
||||
Optional<JsonWebToken> jwt = jwtCookieAuth.extractToken(headers);
|
||||
if (jwt.isEmpty()) return Optional.empty();
|
||||
try {
|
||||
return Optional.of(UUID.fromString(jwt.get().getSubject()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.l2.shots.state;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.l2.shots.auth.UserState;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.transaction.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
@ApplicationScoped
|
||||
public class StateService {
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
public Optional<AppState> getForUser(java.util.UUID userId) {
|
||||
UserState entity = UserState.findByUserId(userId);
|
||||
if (entity == null) return Optional.empty();
|
||||
try {
|
||||
return Optional.of(mapper.readValue(entity.stateJson, AppState.class));
|
||||
} catch (JsonProcessingException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void saveForUser(java.util.UUID userId, AppState state) {
|
||||
try {
|
||||
String json = mapper.writeValueAsString(state);
|
||||
UserState entity = UserState.findByUserId(userId);
|
||||
if (entity == null) {
|
||||
entity = new UserState();
|
||||
entity.userId = userId;
|
||||
}
|
||||
entity.stateJson = json;
|
||||
entity.updatedAt = Instant.now();
|
||||
entity.persist();
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new RuntimeException("Failed to serialize state", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteForUser(java.util.UUID userId) {
|
||||
UserState.deleteById(userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
quarkus.http.port=8080
|
||||
quarkus.http.host=0.0.0.0
|
||||
|
||||
quarkus.application.name=shot-crafter-calculator
|
||||
|
||||
# H2 file-based
|
||||
quarkus.datasource.db-kind=h2
|
||||
quarkus.datasource.jdbc.url=jdbc:h2:file:./data/shots;DB_CLOSE_DELAY=-1
|
||||
quarkus.datasource.username=sa
|
||||
quarkus.datasource.password=
|
||||
quarkus.hibernate-orm.database.generation=update
|
||||
quarkus.hibernate-orm.log.sql=false
|
||||
|
||||
# JWT
|
||||
mp.jwt.verify.issuer=shot-crafter-calculator
|
||||
mp.jwt.verify.publickey.location=publicKey.pem
|
||||
smallrye.jwt.sign.key.location=privateKey.pem
|
||||
|
||||
# Cookie auth
|
||||
app.auth.cookie-name=auth-token
|
||||
app.auth.cookie-max-age-seconds=86400
|
||||
|
||||
# Security
|
||||
quarkus.http.auth.proactive=false
|
||||
|
||||
%native.quarkus.native.resources.includes=META-INF/resources/.*,publicKey.pem,privateKey.pem
|
||||
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC4Rey1Bjlao2e9
|
||||
6AT++5zUVYZC+g3UIL29Nd/FG64+YZublZ8z9BEIG2IMm39B6XwgpyTIhVvW/lR1
|
||||
qSEcBaVPjcJVx3grx3GCbqZ+00BlJM/jwRUFMRybNZ9pCmWcWW2JTBhHPjGtfFBd
|
||||
kZS3kr0htccsWbILJUJlfwSyt2+rNwGNLBJfMoJBjmWjytK5wtgOTxReaUELHRqf
|
||||
hn6EukIbmyQtATDXF0Xor/MWquGrYK29oNT/R5w2oMKV4IQtDPn/Es0xkFl+nrh9
|
||||
FfgjOhOEctrf9KvPdwQIKdfhrQ/TH3iVzUxijNUOIemKoxmjf4KCz5boJHPPgSsq
|
||||
A49fQ5xFAgMBAAECggEACugB51brzGxbxqwgUOfFyPVvjFruDZjgfKiJysnldAYP
|
||||
5n33sw9Qy2jffT0zeMxfud5apCRBzXA2bH5VminQC8IpTIK79I9fLafsjRi7Y3Zx
|
||||
zW+PQG7p3AUo9FfBWtHE1LmfEucLRqgoaNlQctWprFXsvg29psDjn0viFHfHPAax
|
||||
iDe3l0ftILoUF62UEisJ8aAbd+tRPnN6uGD+f6b95+wCeRdK6WlA/ZATxie+O7A5
|
||||
u/P0ID2WdVAiANB4MVo71zMEr3fXkHWX4NerlA3MGLGYCmkN28xFpvifPh1v84OX
|
||||
oNHu0v7o88xxfHPmP/Zn138Uzad8N3uKb9n8XYzdMQKBgQDZUJNW/s6eQcq83Lpb
|
||||
yRjMyB+uq4M1XVTLX9wENdG3UxI0Vfv7n5WWz+Gd2telyRryrDjzGWupTm0/HsuO
|
||||
BrNfX2pwwuCdrbnTtdixMRImTipRxRXiRSMSn6pFqfpt3zrt+JzD1GHAtY3BcEi5
|
||||
dvoLnTiSzGZw2pb7rwuyeVsdWQKBgQDZE5VpX2NYvdm3BwoOp/KllJxgttFVRbKs
|
||||
KbfRVF1sQ68dBU/w2aUOF8dNxXzFc+nU+M8F4zz0YGbcsEhzkjKV8S/gRxiaso6c
|
||||
Qqeba8/OxqeztQnHbVh6bbSFfG9i0+FXlJVqKttjitVB0QZB3gyLdkmL0frksrvL
|
||||
F3UddwZ8zQKBgHTwWPjdUM30VWZf2KB/jCrWHcZeYNKckH6H7NsPIvTlbMxg4KG8
|
||||
dECdSKkrFBQQLcIcTuDx8u8+Vqc6qQqaLHfL3nkjRL9UtsRn/F0NLNkUAs3Roj8K
|
||||
OR9Sb8vg9fOdxhY8TA9M//U1PTy0cU3r6g3J4qGMACwGVGzG+yJlD1SxAoGBALyp
|
||||
756QX+jdwB35yTzprNNKMQtBePhSxjIpY/BUEYop3UUsu8jJcFGqSvcF4CZAUwdd
|
||||
Y5hrYivGqT+/GokPlFWLNKAJSpIRBC89IyzKa+b78v8WJjSkjVSCinXFq41KNzyG
|
||||
D8IhE2IVZLl6MKUIlwCSwuL5kcQ4r0yYy5nbO9E1AoGAVATm7YeZxFd3WIZwMtk1
|
||||
RkqMMGM6jZHk5aaHMR0YIsja4jSQjWu86y7y653nnxsVr08PnMESTREmEiyWNUC6
|
||||
94VxRSqc0HSUXHsE5w1ig2rRJg4fhrgDiQeabXUHuldYIc4Dyfps92QXguVLBhde
|
||||
o1gonQ1xfEz2HcfnDFwytGM=
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,9 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuEXstQY5WqNnvegE/vuc
|
||||
1FWGQvoN1CC9vTXfxRuuPmGbm5WfM/QRCBtiDJt/Qel8IKckyIVb1v5UdakhHAWl
|
||||
T43CVcd4K8dxgm6mftNAZSTP48EVBTEcmzWfaQplnFltiUwYRz4xrXxQXZGUt5K9
|
||||
IbXHLFmyCyVCZX8EsrdvqzcBjSwSXzKCQY5lo8rSucLYDk8UXmlBCx0an4Z+hLpC
|
||||
G5skLQEw1xdF6K/zFqrhq2CtvaDU/0ecNqDCleCELQz5/xLNMZBZfp64fRX4IzoT
|
||||
hHLa3/Srz3cECCnX4a0P0x94lc1MYozVDiHpiqMZo3+Cgs+W6CRzz4ErKgOPX0Oc
|
||||
RQIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
Reference in New Issue
Block a user