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