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,218 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { API_URL, apiFetch } from "../../utils/api";
|
||||
import { PAGE_SIZE } from "../../utils/constants";
|
||||
import SearchInput from "./SearchInput";
|
||||
import PrimaryButton from "./PrimaryButton";
|
||||
import DenseTable from "./DenseTable";
|
||||
import PaginationBar from "./PaginationBar";
|
||||
import type { CopyFromSource, DocumentLine } from "../../types/sapMarketing";
|
||||
|
||||
interface CopyFromModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
source: CopyFromSource;
|
||||
cardCode: string;
|
||||
docType: "dDocument_Items" | "dDocument_Service";
|
||||
copiedDocEntries: number[];
|
||||
onConfirm: (lines: DocumentLine[], sourceLabel: string) => void;
|
||||
}
|
||||
|
||||
export default function CopyFromModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
source,
|
||||
cardCode,
|
||||
docType,
|
||||
copiedDocEntries,
|
||||
onConfirm,
|
||||
}: CopyFromModalProps) {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [accepting, setAccepting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [filterValue, setFilterValue] = useState("");
|
||||
const [activeFilterValue, setActiveFilterValue] = useState("");
|
||||
const [selectedColumn, setSelectedColumn] = useState(source.columns[0]?.key || "");
|
||||
const [skip, setSkip] = useState(0);
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
|
||||
const top = PAGE_SIZE;
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSelectedKeys([]);
|
||||
setActiveFilterValue("");
|
||||
setFilterValue("");
|
||||
setSkip(0);
|
||||
setError("");
|
||||
setAccepting(false);
|
||||
setSelectedColumn(source.columns[0]?.key || "");
|
||||
}
|
||||
}, [isOpen, source]);
|
||||
|
||||
const buildFilter = () => {
|
||||
let filter = `CardCode eq '${cardCode}' and DocumentStatus eq 'bost_Open' and DocType eq '${docType}'`;
|
||||
for (const entry of copiedDocEntries) {
|
||||
filter += ` and DocEntry ne ${entry}`;
|
||||
}
|
||||
if (activeFilterValue.trim()) {
|
||||
const clause = `contains(${selectedColumn}, '${activeFilterValue.trim().toUpperCase()}')`;
|
||||
filter += ` and ${clause}`;
|
||||
}
|
||||
return filter;
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!isOpen) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const fieldsQuery = source.fields.join(",");
|
||||
let url = `${API_URL}/api/${source.endpoint}?skip=${skip}&top=${top}&fields=${fieldsQuery}&filter=${encodeURIComponent(buildFilter())}`;
|
||||
const response = await apiFetch(url);
|
||||
if (!response.ok) throw new Error("Error al obtener el listado");
|
||||
const json = await response.json();
|
||||
setData(json.value || []);
|
||||
setTotalCount(json["odata.count"] ?? json["@odata.count"] ?? 0);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Error de conexión");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchData();
|
||||
}
|
||||
}, [isOpen, skip, activeFilterValue, source.endpoint]);
|
||||
|
||||
const handleBuscar = () => {
|
||||
setActiveFilterValue(filterValue);
|
||||
setSkip(0);
|
||||
};
|
||||
|
||||
const handleAnterior = () => setSkip(Math.max(0, skip - top));
|
||||
const handleSiguiente = () => setSkip(skip + top);
|
||||
|
||||
const handleSelectionChange = (keys: string[]) => {
|
||||
setSelectedKeys(keys);
|
||||
};
|
||||
|
||||
const handleConfirmar = async () => {
|
||||
if (selectedKeys.length === 0) return;
|
||||
setAccepting(true);
|
||||
setError("");
|
||||
try {
|
||||
const allLines: DocumentLine[] = [];
|
||||
for (const docEntry of selectedKeys) {
|
||||
const response = await apiFetch(
|
||||
`${API_URL}/api/${source.endpoint}/${docEntry}`,
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new Error(`Error al obtener el documento #${docEntry}`);
|
||||
const doc = await response.json();
|
||||
const lines = (doc.DocumentLines || [])
|
||||
.filter((l: any) => l.LineStatus === "bost_Open")
|
||||
.map((l: any) => ({
|
||||
...l,
|
||||
BaseType: source.baseType,
|
||||
BaseEntry: Number(docEntry),
|
||||
BaseLine: l.LineNum,
|
||||
BaseRef: doc.DocNum,
|
||||
LineStatus: "bost_Open" as const,
|
||||
LineNum: undefined,
|
||||
}));
|
||||
allLines.push(...lines);
|
||||
}
|
||||
onConfirm(allLines, source.label);
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Error al copiar las líneas");
|
||||
} finally {
|
||||
setAccepting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 antialiased font-sans text-xs text-[var(--color-text)]">
|
||||
<div className="bg-[var(--color-surface)] w-full max-w-3xl border border-[var(--color-border)] flex flex-col max-h-[80vh] min-h-[400px]">
|
||||
<div className="h-10 bg-[var(--color-header-bg)] text-white flex items-center justify-between px-3 select-none shrink-0">
|
||||
<span className="font-semibold">
|
||||
Copiar de — {source.label}
|
||||
</span>
|
||||
<span className="text-[11px] text-[var(--color-header-muted)]">
|
||||
Socio: {cardCode}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-2 border-b border-[var(--color-sidebar-submenu-border)] bg-[var(--color-bg)] flex items-center gap-2 shrink-0">
|
||||
<SearchInput
|
||||
value={filterValue}
|
||||
placeholder={`Buscar en ${source.columns.find((c) => c.key === selectedColumn)?.title || selectedColumn}`}
|
||||
onChange={setFilterValue}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleBuscar(); }}
|
||||
/>
|
||||
<PrimaryButton onClick={handleBuscar}>Buscar</PrimaryButton>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-[var(--color-error)] bg-[var(--color-error-bg)] border border-[var(--color-error-border)] p-2 mx-2 mt-2 shrink-0">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-auto p-2 min-h-0">
|
||||
{loading ? (
|
||||
<div className="text-[var(--color-primary)] p-2">
|
||||
Cargando registros desde SAP Service Layer...
|
||||
</div>
|
||||
) : (
|
||||
<DenseTable
|
||||
columns={source.columns}
|
||||
data={data}
|
||||
selectedColumn={selectedColumn}
|
||||
onColumnSelect={setSelectedColumn}
|
||||
rowKey="DocEntry"
|
||||
selectionMode="multiple"
|
||||
selectedKeys={selectedKeys}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-2 pt-1 shrink-0">
|
||||
<PaginationBar
|
||||
skip={skip}
|
||||
top={top}
|
||||
currentCount={data.length}
|
||||
totalCount={totalCount}
|
||||
loading={loading}
|
||||
onPrevious={handleAnterior}
|
||||
onNext={handleSiguiente}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-2 border-t border-[var(--color-border-light)] bg-[var(--color-bg)] flex justify-end gap-2 shrink-0">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-1.5 bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] rounded-none cursor-pointer text-xs"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmar}
|
||||
disabled={selectedKeys.length === 0 || accepting}
|
||||
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"
|
||||
>
|
||||
{accepting
|
||||
? "Copiando líneas..."
|
||||
: `Aceptar (${selectedKeys.length})`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user