Initial commit: mini-erp (backend FastAPI + frontend React/Vite)
Base para el flujo de ramas por cliente. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
ab3c613165
154 files changed
+16872
No files matched your search
@@ -0,0 +1,270 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { API_URL, apiFetch } from "../../utils/api";
|
||||
import { htmlReportsConfig } from "../../config/htmlReports";
|
||||
import ModalSearchTemplate from "../ui/ModalSearchTemplate";
|
||||
import type { F4Config } from "../../types/reports";
|
||||
|
||||
export default function HtmlReportRunner() {
|
||||
const { module: moduleKey, docCode } = useParams<{ module: string; docCode: string }>();
|
||||
|
||||
const definition = htmlReportsConfig[moduleKey || ""]?.find((r) => r.id === docCode);
|
||||
const parameters = definition?.parameters || [];
|
||||
|
||||
const [paramValues, setParamValues] = useState<Record<string, any>>(() => {
|
||||
const initial: Record<string, any> = {};
|
||||
for (const p of parameters) {
|
||||
initial[p.name] = p.defaultValue ?? "";
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
|
||||
const [f4State, setF4State] = useState<{
|
||||
param: (typeof parameters)[number];
|
||||
config: F4Config;
|
||||
} | null>(null);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
|
||||
const handleChange = useCallback((name: string, value: any) => {
|
||||
setParamValues((prev) => ({ ...prev, [name]: value }));
|
||||
}, []);
|
||||
|
||||
const handleF4Confirm = useCallback(
|
||||
(rows: any[]) => {
|
||||
if (!f4State) return;
|
||||
const { param, config } = f4State;
|
||||
handleChange(param.name, rows[0]?.[config.valueField] ?? "");
|
||||
setF4State(null);
|
||||
},
|
||||
[f4State, handleChange],
|
||||
);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!definition) return;
|
||||
|
||||
const body: Record<string, any> = {};
|
||||
for (const p of parameters) {
|
||||
const raw = paramValues[p.name];
|
||||
body[p.name] = p.inputType === "number" ? Number(raw) : raw;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setPreviewUrl("");
|
||||
try {
|
||||
const res = await apiFetch(`${API_URL}/api/reports/${definition.endpoint}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => null);
|
||||
throw new Error(errData?.detail || "Error al generar el reporte");
|
||||
}
|
||||
const blob = await res.blob();
|
||||
setPreviewUrl(URL.createObjectURL(blob));
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Error de conexión");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderField = (param: (typeof parameters)[number]) => {
|
||||
const val = paramValues[param.name] ?? "";
|
||||
const required = param.required;
|
||||
|
||||
switch (param.inputType) {
|
||||
case "text":
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||
{param.label}
|
||||
{required && <span className="text-red-500 ml-0.5">*</span>}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={val}
|
||||
placeholder={param.placeholder}
|
||||
onChange={(e) => handleChange(param.name, e.target.value)}
|
||||
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] outline-none focus:border-[var(--color-input-focus)] text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||
{param.label}
|
||||
{required && <span className="text-red-500 ml-0.5">*</span>}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={val}
|
||||
placeholder={param.placeholder}
|
||||
onChange={(e) => handleChange(param.name, e.target.value)}
|
||||
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] outline-none focus:border-[var(--color-input-focus)] text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "date":
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||
{param.label}
|
||||
{required && <span className="text-red-500 ml-0.5">*</span>}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={val}
|
||||
onChange={(e) => handleChange(param.name, e.target.value)}
|
||||
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] outline-none focus:border-[var(--color-input-focus)] text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "select":
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||
{param.label}
|
||||
{required && <span className="text-red-500 ml-0.5">*</span>}
|
||||
</label>
|
||||
<select
|
||||
value={val}
|
||||
onChange={(e) => handleChange(param.name, e.target.value)}
|
||||
className="w-full px-2 py-1 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] outline-none focus:border-[var(--color-input-focus)] text-xs"
|
||||
>
|
||||
{(param.options || []).map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "f4-modal": {
|
||||
const config = param.f4Config;
|
||||
if (!config) return null;
|
||||
const displayVal = val ? String(val) : "";
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||
{param.label}
|
||||
{required && <span className="text-red-500 ml-0.5">*</span>}
|
||||
</label>
|
||||
<div className="flex items-center border border-[var(--color-input-border)] bg-[var(--color-surface)] focus-within:border-[var(--color-input-focus)]">
|
||||
<input
|
||||
type="text"
|
||||
value={displayVal}
|
||||
readOnly
|
||||
placeholder={param.placeholder || "Seleccionar..."}
|
||||
className="flex-1 min-w-0 h-[28px] px-2 border-none outline-none bg-transparent text-xs text-[var(--color-text)] cursor-pointer"
|
||||
onClick={() => setF4State({ param, config })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setF4State({ param, config })}
|
||||
className="h-[28px] px-1.5 bg-transparent border-none cursor-pointer text-xs text-[var(--color-primary)] shrink-0"
|
||||
title="Buscar"
|
||||
>
|
||||
📋
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)] mb-1">
|
||||
<Link
|
||||
to={`/reportes-html/${moduleKey}`}
|
||||
className="decoration-transparent text-[var(--color-primary)] hover:underline"
|
||||
>
|
||||
Reportes HTML
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-[var(--color-sidebar-title)]">
|
||||
{definition?.title || docCode}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-[var(--color-sidebar-title)]">
|
||||
{definition?.icon} {definition?.title || docCode}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-[var(--color-error)] bg-[var(--color-error-bg)] border border-[var(--color-error-border)] p-2 mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parameters.length > 0 && (
|
||||
<div className="border border-[var(--color-border)] bg-[var(--color-bg)] p-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{parameters.map((p) => (
|
||||
<div key={p.name} className="min-w-0">
|
||||
{renderField(p)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-3 border-t border-[var(--color-border)] mt-3">
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={loading}
|
||||
className="px-5 py-1.5 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white font-medium rounded-none border-none cursor-pointer text-xs disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? "Generando..." : "Generar Reporte"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parameters.length === 0 && (
|
||||
<div className="text-xs text-[var(--color-text-muted)] p-6 text-center border border-dashed border-[var(--color-border)]">
|
||||
Este reporte no tiene parámetros definidos
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewUrl && (
|
||||
<div className="mt-4 border border-[var(--color-border)]">
|
||||
<iframe
|
||||
src={previewUrl}
|
||||
className="w-full h-[600px] border-none"
|
||||
title="Vista previa del reporte"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{f4State && f4State.config && (
|
||||
<ModalSearchTemplate
|
||||
isOpen={!!f4State}
|
||||
onClose={() => setF4State(null)}
|
||||
title={f4State.param.label}
|
||||
endpoint={f4State.config.endpoint}
|
||||
fields={f4State.config.fields}
|
||||
columns={f4State.config.columns}
|
||||
baseFilter={f4State.config.baseFilter || ""}
|
||||
seleccion={f4State.config.selectionMode || "unica"}
|
||||
onConfirmarSeleccion={handleF4Confirm}
|
||||
orderby={f4State.config.orderby}
|
||||
rowKey={f4State.config.valueField}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user