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:
GeorgeandClaude Sonnet 5 committed 2026-08-17 10:34:12 -05:00
commit ab3c613165
154 files changed
+16872

No files matched your search

+91
View File
@@ -0,0 +1,91 @@
const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
export const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";
interface SessionData {
token?: string;
username?: string;
company?: string;
lastActivity?: number;
}
function getSession(): SessionData {
try {
return JSON.parse(localStorage.getItem("sap_session") || "{}");
} catch {
return {};
}
}
function saveSession(data: SessionData): void {
localStorage.setItem("sap_session", JSON.stringify(data));
}
function clearSession(): void {
localStorage.removeItem("sap_session");
}
export function getToken(): string | null {
return getSession().token || null;
}
export function getUsername(): string | null {
return getSession().username || null;
}
export function getCompany(): string | null {
return getSession().company || null;
}
export function setSession(token: string, username: string, company?: string): void {
saveSession({ token, username, company, lastActivity: Date.now() });
}
function hasTimedOut(): boolean {
const session = getSession();
if (!session.lastActivity) return false;
return Date.now() - session.lastActivity > SESSION_TIMEOUT_MS;
}
function updateLastActivity(): void {
const session = getSession();
if (session.token) {
session.lastActivity = Date.now();
saveSession(session);
}
}
function expireSession(): void {
clearSession();
window.location.href = "/login";
}
export async function apiFetch(url: string, options: RequestInit = {}): Promise<Response> {
if (hasTimedOut()) {
expireSession();
throw new Error("Sesión expirada");
}
const token = getToken();
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(token ? { "X-Session-Token": token } : {}),
};
const customHeaders = options.headers as Record<string, string> | undefined;
if (customHeaders) {
for (const key of Object.keys(customHeaders)) {
headers[key] = customHeaders[key];
}
}
const response = await fetch(url, { ...options, headers });
if (response.status === 401) {
expireSession();
throw new Error("Sesión expirada");
}
updateLastActivity();
return response;
}
+5
View File
@@ -0,0 +1,5 @@
export const PAGE_SIZE = 50;
export const DATE_FORMAT = "dd/MM/yyyy";
export const DATE_FORMAT_REGEX = /^(\d{2})\/(\d{2})\/(\d{4})$/;
export const DEFAULT_CURRENCY = "SOL";
export const DEFAULT_SYMBOL = "S/.";
+20
View File
@@ -0,0 +1,20 @@
import { DATE_FORMAT } from "./constants";
/**
* Formatea una fecha (ISO "yyyy-MM-dd", con o sin hora) para presentación en
* tablas y modales, usando el patrón único definido en DATE_FORMAT. Lee los
* componentes con getUTC* para no interpretar la fecha en hora local: una
* fecha "solo día" como "2026-01-15" se parsea como medianoche UTC, y leerla
* con getters locales en husos detrás de UTC (ej. America/Lima) la corre un
* día atrás.
*/
export function formatDisplayDate(value: any): string {
if (value === null || value === undefined || value === "") return "";
const dateOnly = String(value).split("T")[0];
const d = new Date(dateOnly);
if (isNaN(d.getTime())) return String(value);
const day = String(d.getUTCDate()).padStart(2, "0");
const month = String(d.getUTCMonth() + 1).padStart(2, "0");
const year = d.getUTCFullYear();
return DATE_FORMAT.replace("dd", day).replace("MM", month).replace("yyyy", String(year));
}
+81
View File
@@ -0,0 +1,81 @@
export const DOC_TYPE = {
dDocument_Items: "Artículos",
dDocument_Service: "Servicios",
} as const;
export const DOCUMENT_STATUS = {
bost_Open: "Abierto",
bost_Close: "Cerrado",
} as const;
export const CARD_TYPE = {
cCustomer: "Cliente",
cSupplier: "Proveedor",
} as const;
export const ADDRESS_TYPE = {
bo_ShipTo: "Dirección de Envío",
bo_BillTo: "Dirección de Facturación",
} as const;
export const DOCUMENT_SUB_TYPE = {
bod_None: "Ninguno",
} as const;
export const YES_NO = {
tYES: "Sí",
tNO: "No",
} as const;
export const LINE_STATUS = {
bost_Open: "Abierto",
bost_Close: "Cerrado",
} as const;
export const ITEM_TYPE = {
itItems: "Artículos",
itLabor: "Mano de Obra",
itTravel: "Viajes",
itFixedAssets: "Activos Fijos",
} as const;
export const BATCH_STATUS = {
bdsStatus_Released: "Liberado",
bdsStatus_NotAccessible: "No Accesible",
bdsStatus_Locked: "Bloqueado",
} as const;
export const BATCH_STATUS_APROBACION = {
"101": "Cuarentena",
"102": "Aprobado",
"103": "Rechazado",
"104": "Inmovilizado"
}
export const BATCH_STATUS_LIBERACION = {
"101": "Liberado",
"102": "No liberado"
}
export const GL_METHOD = {
glm_WH: "Por Almacén",
glm_ItemClass: "Por Clase de Artículo",
glm_ItemLevel: "Por Artículo",
} as const;
export function enumLabel<T extends Record<string, string>>(
enumObj: T,
key: string | undefined | null,
fallback?: string,
): string {
if (key && key in enumObj) return enumObj[key as keyof T];
return fallback ?? key ?? "";
}
export function enumOptions<T extends Record<string, string>>(
enumObj: T,
): { value: string; label: string }[] {
return Object.entries(enumObj).map(([value, label]) => ({ value, label }));
}
+23
View File
@@ -0,0 +1,23 @@
import type { Column } from "../types/table";
import { formatDisplayDate } from "./dateFormat";
export function formatValue(value: any, column: Column) {
if (value === null || value === undefined) {
return "---";
}
switch (column.type) {
case "currency":
return Number(value).toLocaleString("en-US", {
minimumFractionDigits: column.decimals ?? 2,
maximumFractionDigits: column.decimals ?? 2,
});
case "date":
return formatDisplayDate(value);
default:
return value;
}
}
+10
View File
@@ -0,0 +1,10 @@
export const OBJ_TYPE: Record<string, number> = {
Orders: 17,
Invoices: 13,
PurchaseInvoices: 18,
DeliveryNotes: 15,
Quotations: 23,
PurchaseOrders: 22,
PurchaseDeliveryNotes: 20,
PurchaseRequests: 1470000113,
};
+25
View File
@@ -0,0 +1,25 @@
const STATUS_COLORS_MAP: Record<string, Record<string, { bg: string; text: string; icon?: string }>> = {
BATCH_STATUS_APROBACION: {
"101": { bg: "#fff8e1", text: "#f57f17", icon: "⏳" },
"102": { bg: "#e8f5e9", text: "#2e7d32", icon: "✅" },
"103": { bg: "#fce4ec", text: "#c62828", icon: "❌" },
"104": { bg: "#f5f5f5", text: "#616161", icon: "🔒" },
},
BATCH_STATUS_LIBERACION: {
"101": { bg: "#e8f5e9", text: "#2e7d32", icon: "✅" },
"102": { bg: "#fff8e1", text: "#f57f17" },
},
DOCUMENT_STATUS: {
"bost_Open": { bg: "#e8f5e9", text: "#2e7d32", icon: "✅" },
"bost_Close": { bg: "#fce4ec", text: "#c62828", icon: "🔒" },
},
};
export function getStatusColors(
enumName: string,
value: string | undefined | null,
): { bg: string; text: string; icon?: string } | null {
if (!value) return null;
const group = STATUS_COLORS_MAP[enumName];
return group?.[value] ?? null;
}
+26
View File
@@ -0,0 +1,26 @@
export const DETRACCION_CODIGOS: Record<
string,
{ descripcion: string; porcentaje: number }
> = {
"020": {
descripcion: "Mantenimiento y reparación de bienes muebles",
porcentaje: 12,
},
"021": { descripcion: "Movimiento de carga", porcentaje: 10 },
"022": {
descripcion: "Otros servicios empresariales",
porcentaje: 12,
},
"025": {
descripcion: "Servicio de transporte de personas",
porcentaje: 10,
},
"027": {
descripcion: "Transporte de bienes por vía terrestre",
porcentaje: 4,
},
"037": {
descripcion: "Demás servicios gravados con el IGV (uso general)",
porcentaje: 12,
},
};