WIP legal profile Excel templates and UI updates

This commit is contained in:
Sean McElwain 2026-06-10 17:29:28 -05:00
parent d30d620ae4
commit 1a877714e9
9 changed files with 3265 additions and 1053 deletions

View File

@ -9,6 +9,7 @@ from pydantic import BaseModel
from tools.doc_generator.logic.document_types import get_document_type, list_document_types
from tools.doc_generator.logic.renderer import generate_docx
from tools.doc_generator.logic.excel_mapper import export_profile_excel, load_excel_maps, load_excel_map, resolve_excel_template
router = APIRouter()
@ -18,11 +19,19 @@ INPUTS_DIR = PROJECT_ROOT / "inputs"
UPLOADS_DIR = INPUTS_DIR / "uploads"
JSON_UPLOADS_DIR = UPLOADS_DIR / "json"
DATA_UPLOADS_DIR = UPLOADS_DIR / "data"
TEMPLATE_UPLOADS_DIR = UPLOADS_DIR / "templates"
class ExportExcelRequest(BaseModel):
document_type_id: str
data: dict
map_id: str = "legacy_datafile"
class GenerateDocRequest(BaseModel):
document_type_id: str
data: dict
template_id: str | None = None
def safe_filename(filename: str) -> str:
@ -159,10 +168,106 @@ async def upload_data(file: UploadFile = File(...)):
}
@router.post("/upload-template/{document_type_id}")
async def upload_template(document_type_id: str, file: UploadFile = File(...)):
if not file.filename.lower().endswith(".docx"):
raise HTTPException(status_code=400, detail="Upload must be a DOCX template.")
# Validate profile exists.
try:
get_document_type(document_type_id)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc))
profile_dir = TEMPLATE_UPLOADS_DIR / safe_filename(document_type_id)
path = save_upload(file, profile_dir)
return {
"ok": True,
"filename": path.name,
"template_id": f"uploaded:{path.name}",
"saved_path": str(path.relative_to(PROJECT_ROOT)),
}
@router.get("/uploaded-templates/{document_type_id}")
def uploaded_templates(document_type_id: str):
profile_dir = TEMPLATE_UPLOADS_DIR / safe_filename(document_type_id)
profile_dir.mkdir(parents=True, exist_ok=True)
templates = []
for path in sorted(profile_dir.glob("*.docx")):
templates.append({
"id": f"uploaded:{path.name}",
"label": path.name,
"filename": path.name,
"saved_path": str(path.relative_to(PROJECT_ROOT)),
"modified": path.stat().st_mtime,
})
return {"templates": templates}
@router.delete("/uploaded-template/{document_type_id}/{template_id}")
def delete_uploaded_template(document_type_id: str, template_id: str):
doc_dir = TEMPLATE_UPLOADS_DIR / safe_filename(document_type_id)
file_path = doc_dir / safe_filename(template_id)
if not file_path.exists():
raise HTTPException(status_code=404, detail="Template not found.")
file_path.unlink()
return {"deleted": True, "template_id": file_path.name}
@router.get("/excel-maps/{document_type_id}")
def get_excel_maps_route(document_type_id: str):
try:
maps = load_excel_maps(document_type_id)
except FileNotFoundError:
return {"maps": []}
return {
"maps": [
{
"id": item.get("id"),
"label": item.get("label", item.get("id")),
"field_count": len(item.get("field_to_cell", {})),
"fields": sorted(item.get("field_to_cell", {}).keys()),
"template": item.get("template"),
"mode": item.get("mode", "map"),
}
for item in maps
]
}
@router.post("/export-excel")
def export_excel(request: ExportExcelRequest):
try:
output_path = export_profile_excel(
request.document_type_id,
request.data,
request.map_id,
)
return {
"filename": output_path.name,
"download_url": f"/api/doc-generator/download/{output_path.name}",
}
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.post("/generate")
def generate_document(request: GenerateDocRequest):
try:
output_path = generate_docx(request.document_type_id, request.data)
output_path = generate_docx(request.document_type_id, request.data, request.template_id)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except Exception as exc:
@ -175,15 +280,87 @@ def generate_document(request: GenerateDocRequest):
}
@router.get("/download/{filename}")
def download(filename: str):
file_path = EXPORTS_DIR / safe_filename(filename)
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found")
@router.get("/excel-template/{template_name}")
def download_excel_template(template_name: str):
allowed = {
"datafile": "template.xlsx",
"amortization": "amortization.xlsx",
}
filename = allowed.get(template_name)
if not filename:
raise HTTPException(status_code=404, detail="Unknown Excel template.")
path = OLD_WORD_DOC_GENERATOR_DIR / filename
if not path.exists():
raise HTTPException(status_code=404, detail=f"Excel template not found: {path}")
return FileResponse(
path=file_path,
filename=file_path.name,
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
path,
filename=filename,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
@router.get("/excel-template-by-map/{document_type_id}/{map_id}")
def download_excel_template_by_map(document_type_id: str, map_id: str):
try:
excel_map = load_excel_map(document_type_id, map_id)
template_path = resolve_excel_template(excel_map.get("template"))
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
return FileResponse(
path=template_path,
filename=template_path.name,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
@router.delete("/uploaded-template/{document_type_id}/{filename}")
def delete_uploaded_template(document_type_id: str, filename: str):
folder = TEMPLATE_UPLOADS_DIR / safe_filename(document_type_id)
path = folder / safe_filename(filename)
if not path.exists():
raise HTTPException(status_code=404, detail="Uploaded template not found.")
if path.suffix.lower() != ".docx":
raise HTTPException(status_code=400, detail="Only DOCX templates can be deleted here.")
path.unlink()
return {"deleted": True, "filename": path.name}
@router.get("/download/{filename}")
def download_file(filename: str):
safe_name = safe_filename(filename)
path = EXPORTS_DIR / safe_name
if not path.exists():
raise HTTPException(status_code=404, detail="File not found.")
suffix = path.suffix.lower()
media_types = {
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".csv": "text/csv",
".json": "application/json",
".pdf": "application/pdf",
}
return FileResponse(
path,
filename=path.name,
media_type=media_types.get(suffix, "application/octet-stream"),
)

View File

@ -10,6 +10,8 @@ const downloadDataButton = document.getElementById("downloadDataButton");
const downloadJsonButton = document.getElementById("downloadJsonButton");
const defaultDocumentOptions = document.getElementById("defaultDocumentOptions");
const uploadedJsonOptions = document.getElementById("uploadedJsonOptions");
const legacyTemplateSelect = document.getElementById("legacyTemplateSelect");
const legacyTemplateRow = document.getElementById("legacyTemplateRow");
const documentPickerToggle = document.getElementById("documentPickerToggle");
const documentPickerMenu = document.getElementById("documentPickerMenu");
const selectedDocumentLabel = document.getElementById("selectedDocumentLabel");
@ -19,6 +21,7 @@ let currentFields = [];
let defaultDocumentTypes = [];
let activePickerKind = "default";
let activePickerId = "";
let selectedTemplateId = "";
function setStatus(message) {
statusBox.innerHTML = message || "";
@ -67,7 +70,13 @@ function createInput(field) {
}
} else {
input = document.createElement("input");
input.type = field.type || "text";
input.type = "text";
if (field.type === "autocomplete" && field.list) {
input.setAttribute("list", `datalist-${field.list}`);
} else {
input.type = field.type || "text";
}
}
input.id = field.name;
@ -164,9 +173,69 @@ function renderFlatFields(fields) {
}
}
function renderDatalists(documentType) {
document.querySelectorAll("datalist[data-generated-list='true']").forEach(el => el.remove());
const lists = documentType.lists || {};
for (const [listName, values] of Object.entries(lists)) {
const datalist = document.createElement("datalist");
datalist.id = `datalist-${listName}`;
datalist.dataset.generatedList = "true";
for (const value of values || []) {
const option = document.createElement("option");
option.value = value;
datalist.appendChild(option);
}
document.body.appendChild(datalist);
}
}
function renderTemplateSelector(documentType) {
if (!legacyTemplateSelect || !legacyTemplateRow) return;
const templates = documentType.templates || [];
legacyTemplateSelect.innerHTML = "";
if (!templates.length) {
legacyTemplateRow.style.display = "none";
selectedTemplateId = "";
return;
}
legacyTemplateRow.style.display = "block";
for (const template of templates) {
const option = document.createElement("option");
option.value = template.id;
option.textContent = template.label || template.id;
legacyTemplateSelect.appendChild(option);
}
const savedTemplate = localStorage.getItem(`utilityAppSelectedTemplate:${documentType.id}`);
selectedTemplateId = savedTemplate && templates.some(t => t.id === savedTemplate)
? savedTemplate
: (documentType.defaultTemplateId || templates[0].id);
legacyTemplateSelect.value = selectedTemplateId;
}
function getSelectedTemplateId() {
if (!legacyTemplateSelect || legacyTemplateRow?.style.display === "none") {
return "";
}
return legacyTemplateSelect.value || selectedTemplateId || "";
}
function renderDocumentType(documentType) {
fieldsContainer.innerHTML = "";
currentFields = [];
renderDatalists(documentType);
renderTemplateSelector(documentType);
if (Array.isArray(documentType.sections)) {
for (const section of documentType.sections) {
@ -600,6 +669,7 @@ docForm.addEventListener("submit", async event => {
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
document_type_id: currentDocumentType.id,
template_id: getSelectedTemplateId(),
data: getFormData(true)
})
});
@ -750,3 +820,491 @@ if (savedActiveView) {
requestAnimationFrame(() => {
document.body.classList.remove("app-loading");
});
if (legacyTemplateSelect) {
legacyTemplateSelect.addEventListener("change", () => {
selectedTemplateId = legacyTemplateSelect.value;
if (currentDocumentType?.id) {
localStorage.setItem(`utilityAppSelectedTemplate:${currentDocumentType.id}`, selectedTemplateId);
}
saveCurrentFormData();
});
}
function removeDynamicFields(groupId) {
document.querySelectorAll(`[data-dynamic-group="${groupId}"]`).forEach(el => el.remove());
currentFields = currentFields.filter(field => field.dynamicGroup !== groupId);
}
function renderDynamicFieldGroup(group) {
if (!currentDocumentType) return;
const countEl = document.getElementById(group.countField);
if (!countEl) return;
const count = Math.min(
parseInt(countEl.value || "0", 10) || 0,
group.maxCount || 100
);
removeDynamicFields(group.id);
if (count <= 0) return;
const wrapper = document.createElement("div");
wrapper.dataset.dynamicGroup = group.id;
wrapper.className = "json-section dynamic-field-group";
const details = document.createElement("details");
details.open = group.defaultOpen !== false;
const summary = document.createElement("summary");
summary.textContent = `${group.section || "Dynamic Fields"} (${count})`;
details.appendChild(summary);
const generatedFields = [];
for (let n = 1; n <= count; n++) {
for (const fieldDef of group.fields || []) {
generatedFields.push({
name: fieldDef.namePattern.replaceAll("{n}", String(n)),
label: fieldDef.labelPattern.replaceAll("{n}", String(n)),
type: fieldDef.type || "text",
list: fieldDef.list,
required: Boolean(fieldDef.required),
dynamicGroup: group.id
});
}
}
currentFields.push(...generatedFields);
appendFieldRows(details, generatedFields);
wrapper.appendChild(details);
const countField = document.getElementById(group.countField);
const sectionWrapper = countField?.closest(".json-section");
if (sectionWrapper) {
sectionWrapper.appendChild(wrapper);
} else {
fieldsContainer.appendChild(wrapper);
}
restoreSavedFormData();
}
function renderAllDynamicFieldGroups() {
if (!currentDocumentType?.dynamicFieldGroups) return;
for (const group of currentDocumentType.dynamicFieldGroups) {
renderDynamicFieldGroup(group);
const countEl = document.getElementById(group.countField);
if (countEl && !countEl.dataset.dynamicListenerAttached) {
countEl.dataset.dynamicListenerAttached = "true";
countEl.addEventListener("input", () => {
saveCurrentFormData();
renderDynamicFieldGroup(group);
});
countEl.addEventListener("change", () => {
saveCurrentFormData();
renderDynamicFieldGroup(group);
});
}
}
}
const originalRenderDocumentTypeForDynamicGroups = renderDocumentType;
renderDocumentType = function(documentType) {
originalRenderDocumentTypeForDynamicGroups(documentType);
renderAllDynamicFieldGroups();
loadExcelMaps();
};
const templateFileInput = document.getElementById("templateFileInput");
async function fetchUploadedTemplatesForCurrentProfile() {
if (!currentDocumentType?.id) return [];
try {
const response = await fetch(`/api/doc-generator/uploaded-templates/${encodeURIComponent(currentDocumentType.id)}`);
const json = await response.json();
if (!response.ok) {
return [];
}
return json.templates || [];
} catch {
return [];
}
}
const originalRenderTemplateSelectorWithUploads = renderTemplateSelector;
renderTemplateSelector = function(documentType) {
if (!legacyTemplateSelect || !legacyTemplateRow) return;
const libraryTemplates = documentType.templates || [];
legacyTemplateSelect.innerHTML = "";
if (!libraryTemplates.length && !documentType.id) {
legacyTemplateRow.style.display = "none";
selectedTemplateId = "";
return;
}
legacyTemplateRow.style.display = "block";
if (libraryTemplates.length) {
const libraryGroup = document.createElement("optgroup");
libraryGroup.label = "Library Templates";
for (const template of libraryTemplates) {
const option = document.createElement("option");
option.value = template.id;
option.textContent = template.label || template.id;
libraryGroup.appendChild(option);
}
legacyTemplateSelect.appendChild(libraryGroup);
}
const uploadedGroup = document.createElement("optgroup");
uploadedGroup.label = "Uploaded Templates";
uploadedGroup.id = "uploadedTemplateOptGroup";
legacyTemplateSelect.appendChild(uploadedGroup);
const savedTemplate = localStorage.getItem(`utilityAppSelectedTemplate:${documentType.id}`);
selectedTemplateId = savedTemplate || documentType.defaultTemplateId || libraryTemplates[0]?.id || "";
if (selectedTemplateId) {
legacyTemplateSelect.value = selectedTemplateId;
}
refreshUploadedTemplateOptions();
};
async function refreshUploadedTemplateOptions() {
if (!legacyTemplateSelect || !currentDocumentType?.id) return;
let uploadedGroup = document.getElementById("uploadedTemplateOptGroup");
if (!uploadedGroup) {
uploadedGroup = document.createElement("optgroup");
uploadedGroup.label = "Uploaded Templates";
uploadedGroup.id = "uploadedTemplateOptGroup";
legacyTemplateSelect.appendChild(uploadedGroup);
}
uploadedGroup.innerHTML = "";
const uploadedTemplates = await fetchUploadedTemplatesForCurrentProfile();
for (const template of uploadedTemplates) {
const option = document.createElement("option");
option.value = template.id;
option.textContent = template.label || template.filename;
uploadedGroup.appendChild(option);
}
const savedTemplate = localStorage.getItem(`utilityAppSelectedTemplate:${currentDocumentType.id}`);
if (savedTemplate && [...legacyTemplateSelect.options].some(option => option.value === savedTemplate)) {
legacyTemplateSelect.value = savedTemplate;
selectedTemplateId = savedTemplate;
}
}
if (templateFileInput) {
templateFileInput.addEventListener("change", async event => {
const file = event.target.files[0];
if (!file) return;
if (!currentDocumentType?.id) {
setStatus("Select a profile before uploading a template.");
templateFileInput.value = "";
return;
}
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetch(`/api/doc-generator/upload-template/${encodeURIComponent(currentDocumentType.id)}`, {
method: "POST",
body: formData
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.detail || "Template upload failed.");
}
await refreshUploadedTemplateOptions();
legacyTemplateSelect.value = result.template_id;
selectedTemplateId = result.template_id;
localStorage.setItem(`utilityAppSelectedTemplate:${currentDocumentType.id}`, selectedTemplateId);
setStatus(`Uploaded template: ${result.filename}<br>Saved to: ${result.saved_path}`);
} catch (error) {
setStatus(`Could not upload template: ${error.message}`);
} finally {
templateFileInput.value = "";
}
});
}
const excelMapSelect = document.getElementById("excelMapSelect");
const exportExcelButton = document.getElementById("exportExcelButton");
const excelImportInput = document.getElementById("excelImportInput");
async function loadExcelMaps() {
if (!excelMapSelect) return;
try {
if (!currentDocumentType?.id) return;
const response = await fetch(`/api/doc-generator/excel-maps/${encodeURIComponent(currentDocumentType.id)}`);
const json = await response.json();
if (!response.ok) {
throw new Error(json.detail || "Could not load Excel maps.");
}
excelMapSelect.innerHTML = "";
for (const item of json.maps || []) {
const option = document.createElement("option");
option.value = item.id;
option.textContent = `${item.label || item.id} (${item.field_count} fields)`;
excelMapSelect.appendChild(option);
}
const saved = localStorage.getItem("utilityAppExcelMapId");
if (saved && [...excelMapSelect.options].some(option => option.value === saved)) {
excelMapSelect.value = saved;
}
} catch (error) {
setStatus(`Could not load Excel maps: ${error.message}`);
}
}
if (excelMapSelect) {
excelMapSelect.addEventListener("change", () => {
localStorage.setItem("utilityAppExcelMapId", excelMapSelect.value);
});
}
if (exportExcelButton) {
exportExcelButton.addEventListener("click", async () => {
if (!currentDocumentType?.id) {
setStatus("Select a profile before exporting Excel.");
return;
}
try {
saveCurrentFormData();
const response = await fetch("/api/doc-generator/export-excel", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
document_type_id: currentDocumentType.id,
map_id: excelMapSelect?.value || "field_to_cell_map",
data: getFormData(true)
})
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.detail || "Excel export failed.");
}
const link = document.createElement("a");
link.href = result.download_url;
link.download = result.filename;
document.body.appendChild(link);
link.click();
link.remove();
setStatus(`Exported Excel: ${result.filename}`);
} catch (error) {
setStatus(`Could not export Excel: ${error.message}`);
}
});
}
if (excelImportInput) {
excelImportInput.addEventListener("change", () => {
setStatus("Excel import is not wired yet. Export is ready.");
excelImportInput.value = "";
});
}
loadExcelMaps();
/* Excel template download button */
const downloadExcelTemplateButton = document.getElementById("downloadExcelTemplateButton");
if (downloadExcelTemplateButton) {
downloadExcelTemplateButton.addEventListener("click", () => {
if (!currentDocumentType?.id || !excelMapSelect?.value) {
setStatus("Select a profile and Excel template/map first.");
return;
}
window.location.href = `/api/doc-generator/excel-template-by-map/${encodeURIComponent(currentDocumentType.id)}/${encodeURIComponent(excelMapSelect.value)}`;
});
}
/* final legal profile UI normalization */
function normalizeLegalUi() {
const profileSelect = document.getElementById("documentTypeSelect");
const excelSelect = document.getElementById("excelMapSelect");
const templateSelect = document.getElementById("legacyTemplateSelect");
for (const select of [profileSelect, excelSelect, templateSelect]) {
if (select) {
select.classList.add("same-dropdown-style");
}
}
if (profileSelect) {
for (const option of [...profileSelect.options]) {
if (option.value === "legal_profile" || option.textContent.trim() === "Legal Profile") {
option.textContent = "Legal";
}
}
}
const longText = "Consumer debt defense legal profile based on the legacy app form fields. Additional template fields are calculated at generation time.";
for (const el of [...document.querySelectorAll("p, div, span")]) {
const value = el.textContent.trim();
if (value === longText || value === "Consumer Debt Defense Legal Profile") {
el.textContent = "Consumer Debt Defense Legal Profile";
el.classList.add("legal-profile-caption");
}
}
}
async function fetchUploadedTemplatesForDeleteUi() {
if (!currentDocumentType?.id) return [];
const response = await fetch(`/api/doc-generator/uploaded-templates/${encodeURIComponent(currentDocumentType.id)}`);
if (!response.ok) return [];
const json = await response.json();
return json.templates || [];
}
function ensureUploadedTemplateManager() {
const row = document.getElementById("legacyTemplateRow");
if (!row) return null;
let manager = document.getElementById("uploadedTemplatesManager");
if (manager) return manager;
manager = document.createElement("div");
manager.id = "uploadedTemplatesManager";
manager.className = "uploaded-template-manager";
manager.innerHTML = `
<div class="uploaded-template-manager-title">Uploaded Templates</div>
<div id="uploadedTemplatesList"></div>
`;
row.appendChild(manager);
return manager;
}
async function renderUploadedTemplateDeleteUi() {
const manager = ensureUploadedTemplateManager();
if (!manager) return;
const list = document.getElementById("uploadedTemplatesList");
if (!list) return;
const templates = await fetchUploadedTemplatesForDeleteUi();
const uploaded = templates.filter(item => {
const id = item.id || item.filename || "";
return id.startsWith("uploaded:") || item.source === "uploaded" || item.uploaded === true;
});
if (!uploaded.length) {
manager.style.display = "none";
list.innerHTML = "";
return;
}
manager.style.display = "";
list.innerHTML = "";
for (const item of uploaded) {
const rawId = item.id || item.filename || "";
const filename = rawId.replace(/^uploaded:/, "");
const row = document.createElement("div");
row.className = "uploaded-template-row";
const name = document.createElement("span");
name.className = "uploaded-template-name";
name.textContent = item.label || filename;
const button = document.createElement("button");
button.type = "button";
button.className = "small-delete-button";
button.textContent = "Delete";
button.addEventListener("click", async () => {
if (!confirm(`Delete uploaded template "${filename}"?`)) return;
const response = await fetch(
`/api/doc-generator/uploaded-template/${encodeURIComponent(currentDocumentType.id)}/${encodeURIComponent(filename)}`,
{method: "DELETE"}
);
if (!response.ok) {
const json = await response.json().catch(() => ({}));
setStatus(`Could not delete template: ${json.detail || response.statusText}`);
return;
}
setStatus("Uploaded template deleted.");
if (typeof renderTemplateSelector === "function") {
await renderTemplateSelector(currentDocumentType);
}
await renderUploadedTemplateDeleteUi();
});
row.appendChild(name);
row.appendChild(button);
list.appendChild(row);
}
}
function runLegalUiCleanup() {
normalizeLegalUi();
renderUploadedTemplateDeleteUi();
}
document.addEventListener("DOMContentLoaded", () => {
runLegalUiCleanup();
setTimeout(runLegalUiCleanup, 300);
setTimeout(runLegalUiCleanup, 1000);
});
const finalLegalUiObserver = new MutationObserver(() => {
clearTimeout(window.__legalUiTimer);
window.__legalUiTimer = setTimeout(runLegalUiCleanup, 100);
});
finalLegalUiObserver.observe(document.body, {
childList: true,
subtree: true
});

View File

@ -2,7 +2,7 @@
<html>
<head>
<title>Utility App</title>
<link rel="stylesheet" href="/static/styles.css?v=shell1">
<link rel="stylesheet" href="/static/styles.css?v=legalui3">
</head>
<body class="app-loading">
<header class="app-header">
@ -55,6 +55,11 @@
</label>
<button id="downloadDataButton" class="tool-action-button" type="button">Download Data CSV</button>
<label class="file-button tool-action-button">
Upload Template
<input type="file" id="templateFileInput" accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document">
</label>
</div>
<h2>Document Type</h2>
@ -78,7 +83,44 @@
<div id="documentDescription"></div>
</div>
<form id="docForm">
<div id="excelTransferRow" class="excel-transfer-row">
<div class="select-block excel-map-block">
<label for="excelMapSelect">Excel Template / Map</label>
<select id="excelMapSelect" class="styled-select"></select>
</div>
<button id="downloadExcelTemplateButton" class="tool-action-button" type="button">Download Excel Template</button>
<button id="exportExcelButton" class="tool-action-button" type="button">Export Excel</button>
<label class="file-button tool-action-button">
Import Excel
<input type="file" id="excelImportInput" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet">
</label>
</div>
<div id="legacyTemplateRow" class="template-picker-row" style="display:none;">
<label for="legacyTemplateSelect">Document Template</label>
<select id="legacyTemplateSelect" class="styled-select"></select>
<div id="uploadedTemplatesManager" class="uploaded-template-manager" style="display:none;">
<div class="uploaded-template-manager-title">Uploaded Templates</div>
<div id="uploadedTemplatesList"></div>
</div>
</div>
<div id="fieldsContainer"></div>
<h2>Generate Documents</h2>
@ -110,6 +152,6 @@
<div id="status"></div>
</main>
<script src="/static/app.js?v=shell1"></script>
<script src="/static/app.js?v=legalui3"></script>
</body>
</html>

View File

@ -429,3 +429,481 @@ body.app-loading .container {
.container {
transition: opacity 120ms ease-out;
}
.tool-button-row {
overflow-x: auto;
padding-bottom: 2px;
}
.tool-action-button,
button.tool-action-button,
label.tool-action-button {
flex: 0 0 120px;
}
.excel-transfer-row {
display: flex;
align-items: end;
gap: 10px;
margin: 12px 0 22px 0;
flex-wrap: wrap;
}
.excel-transfer-row label {
font-weight: 700;
}
.excel-transfer-row select {
min-width: 260px;
flex: 1 1 260px;
}
.excel-transfer-row .tool-action-button {
height: 34px;
}
#exportExcelButton:not(:disabled) {
cursor: pointer;
opacity: 1;
}
#exportExcelButton:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.native-hidden-select {
display: none !important;
}
.excel-transfer-row {
display: flex;
align-items: flex-end;
gap: 10px;
margin: 12px 0 14px 0;
flex-wrap: wrap;
}
.template-picker-row {
margin: 0 0 22px 0;
}
.custom-select-block {
flex: 1 1 320px;
min-width: 260px;
}
.custom-select-block label {
display: block;
font-weight: 700;
margin-bottom: 5px;
}
.custom-picker {
position: relative;
width: 100%;
}
.custom-picker-button {
width: 100%;
min-height: 36px;
text-align: left;
padding: 7px 34px 7px 10px;
border: 1px solid #bbb;
border-radius: 4px;
background: #fff;
color: #111;
font: inherit;
cursor: pointer;
position: relative;
}
.custom-picker-button::after {
content: "▾";
position: absolute;
right: 10px;
top: 7px;
color: #444;
}
.custom-picker-menu {
display: none;
position: absolute;
z-index: 50;
left: 0;
right: 0;
top: calc(100% + 3px);
max-height: 340px;
overflow-y: auto;
background: #fff;
border: 1px solid #aaa;
border-radius: 4px;
box-shadow: 0 6px 18px rgba(0,0,0,0.18);
}
.custom-picker.open .custom-picker-menu {
display: block;
}
.custom-picker-group-label {
padding: 7px 10px;
font-size: 12px;
font-weight: 700;
color: #555;
background: #f1f1f1;
border-bottom: 1px solid #ddd;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.custom-picker-option {
display: block;
width: 100%;
padding: 8px 10px;
border: 0;
border-bottom: 1px solid #eee;
background: #fff;
text-align: left;
font: inherit;
color: #111;
cursor: pointer;
}
.custom-picker-option:hover,
.custom-picker-option.selected {
background: #eaf2ff;
}
#downloadExcelTemplateButton,
#exportExcelButton {
flex: 0 0 145px;
}
.excel-transfer-row {
display: flex;
align-items: flex-end;
gap: 10px;
margin: 14px 0 18px 0;
flex-wrap: wrap;
}
.excel-map-block {
flex: 1 1 360px;
min-width: 260px;
}
.template-picker-row {
margin: 0 0 22px 0;
}
.template-picker-row label,
.excel-map-block label {
display: block;
font-weight: 700;
margin-bottom: 6px;
}
.styled-select {
width: 100%;
min-height: 38px;
padding: 7px 10px;
border: 1px solid #999;
border-radius: 4px;
background: #fff;
color: #111;
font: inherit;
}
#downloadExcelTemplateButton,
#exportExcelButton,
.excel-transfer-row .file-button {
flex: 0 0 auto;
min-height: 38px;
}
.document-type-caption {
font-size: 0.82rem;
color: #8a8a8a;
margin-top: 6px;
margin-bottom: 14px;
line-height: 1.3;
}
.excel-transfer-row {
display: flex;
align-items: flex-end;
gap: 10px;
flex-wrap: wrap;
margin: 12px 0 18px 0;
}
.select-block {
flex: 1 1 340px;
min-width: 260px;
}
.template-picker-row {
margin: 0 0 22px 0;
}
.template-picker-row label,
.select-block label {
display: block;
font-weight: 700;
margin-bottom: 6px;
}
.uploaded-template-manager {
margin-top: 10px;
border-top: 1px solid #ddd;
padding-top: 10px;
}
.uploaded-template-manager-title {
font-size: 0.9rem;
font-weight: 700;
margin-bottom: 6px;
}
.uploaded-template-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 6px 0;
}
.uploaded-template-name {
font-size: 0.92rem;
color: #333;
word-break: break-word;
}
.small-delete-button {
border: 1px solid #bbb;
background: #f7f7f7;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
}
#downloadExcelTemplateButton,
#exportExcelButton,
.excel-transfer-row .file-button {
min-height: 38px;
flex: 0 0 auto;
}
/* unified dropdown styling */
#documentTypeSelect,
#excelMapSelect,
#legacyTemplateSelect {
width: 100%;
min-height: 38px;
padding: 7px 34px 7px 10px;
border: 1px solid #9f9f9f;
border-radius: 4px;
background-color: #eeeef4;
color: #222;
font: inherit;
line-height: 1.25;
box-sizing: border-box;
}
#documentTypeSelect {
max-width: 390px;
}
#excelMapSelect {
min-width: 0;
}
#legacyTemplateSelect {
width: 100%;
}
/* small light-gray profile caption */
#documentTypeDescription,
.document-type-description,
.profile-description,
[data-role="document-type-description"] {
font-size: 0.82rem !important;
color: #8a8a8a !important;
line-height: 1.3 !important;
margin: 6px 0 14px 0 !important;
font-weight: 400 !important;
}
/* tighter Excel row */
#excelTransferRow,
.excel-transfer-row {
display: grid !important;
grid-template-columns: minmax(260px, 1fr) auto auto auto;
gap: 10px;
align-items: end;
margin: 12px 0 18px 0;
}
#excelTransferRow label,
.excel-transfer-row label {
font-weight: 700;
margin-bottom: 6px;
}
#excelTransferRow .file-button,
.excel-transfer-row .file-button,
#downloadExcelTemplateButton,
#exportExcelButton {
min-height: 38px;
white-space: nowrap;
}
/* template selector below Excel row */
#legacyTemplateRow,
.template-picker-row {
margin: 0 0 22px 0;
}
#legacyTemplateRow label,
.template-picker-row label {
display: block;
font-weight: 700;
margin-bottom: 6px;
}
@media (max-width: 760px) {
#excelTransferRow,
.excel-transfer-row {
grid-template-columns: 1fr;
}
#downloadExcelTemplateButton,
#exportExcelButton,
#excelTransferRow .file-button,
.excel-transfer-row .file-button {
width: 100%;
}
}
.uploaded-template-manager {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #ddd;
}
.uploaded-template-manager-title {
font-size: 0.85rem;
font-weight: 700;
color: #555;
margin-bottom: 6px;
}
.uploaded-template-row {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: center;
padding: 5px 0;
}
.uploaded-template-name {
font-size: 0.86rem;
color: #444;
word-break: break-word;
}
.small-delete-button {
border: 1px solid #bbb;
background: #f6f6f6;
border-radius: 4px;
padding: 4px 8px;
font-size: 0.8rem;
cursor: pointer;
}
/* final legal UI overrides */
#documentTypeSelect,
#excelMapSelect,
#legacyTemplateSelect,
.same-dropdown-style {
width: 100% !important;
min-height: 38px !important;
padding: 7px 34px 7px 10px !important;
border: 1px solid #9f9f9f !important;
border-radius: 4px !important;
background-color: #eeeef4 !important;
color: #222 !important;
font: inherit !important;
line-height: 1.25 !important;
box-sizing: border-box !important;
}
#documentTypeSelect {
max-width: 390px !important;
}
.legal-profile-caption {
font-size: 0.82rem !important;
color: #8a8a8a !important;
line-height: 1.3 !important;
margin: 6px 0 14px 0 !important;
font-weight: 400 !important;
}
#excelTransferRow,
.excel-transfer-row {
display: grid !important;
grid-template-columns: minmax(260px, 1fr) auto auto auto !important;
gap: 10px !important;
align-items: end !important;
margin: 12px 0 18px 0 !important;
}
#legacyTemplateRow,
.template-picker-row {
margin: 0 0 22px 0 !important;
}
.uploaded-template-manager {
margin-top: 10px !important;
padding-top: 10px !important;
border-top: 1px solid #ddd !important;
}
.uploaded-template-manager-title {
font-size: 0.85rem !important;
font-weight: 700 !important;
color: #555 !important;
margin-bottom: 6px !important;
}
.uploaded-template-row {
display: flex !important;
justify-content: space-between !important;
gap: 10px !important;
align-items: center !important;
padding: 5px 0 !important;
}
.uploaded-template-name {
font-size: 0.86rem !important;
color: #444 !important;
word-break: break-word !important;
}
.small-delete-button {
border: 1px solid #bbb !important;
background: #f6f6f6 !important;
border-radius: 4px !important;
padding: 4px 8px !important;
font-size: 0.8rem !important;
cursor: pointer !important;
}
@media (max-width: 760px) {
#excelTransferRow,
.excel-transfer-row {
grid-template-columns: 1fr !important;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,551 @@
{
"id": "legal_profile",
"label": "Legal Profile Excel Maps",
"maps": [
{
"id": "legacy_datafile",
"sourceName": "fieldToCellMap",
"label": "Legacy Datafile Template",
"description": "Generated from public/constants.js object fieldToCellMap.",
"template": "excel/legal_profile/template.xlsx",
"legacyTemplateCandidates": [
{
"label": "amortization.xlsx",
"legacyPath": "/mnt/storage/sftp/mcelwain/repository/word-doc-generator/amortization.xlsx",
"filename": "amortization.xlsx"
},
{
"label": "template.xlsx",
"legacyPath": "/mnt/storage/sftp/mcelwain/repository/word-doc-generator/template.xlsx",
"filename": "template.xlsx"
}
],
"fields": {
"debtCollector6AddressLine1": "A100",
"debtCollector7Name": "A104",
"debtCollector7AddressLine1": "A106",
"SSNLastFour": "A11",
"debtCollector8Name": "A110",
"debtCollector8AddressLine1": "A112",
"debtCollector9Name": "A116",
"debtCollector9AddressLine1": "A118",
"debtCollector10Name": "A122",
"debtCollector10AddressLine1": "A124",
"debtCollector11Name": "A128",
"debtCollector11AddressLine1": "A130",
"debtCollector12Name": "A134",
"debtCollector12AddressLine1": "A136",
"client2FirstName": "A14",
"debtCollector13Name": "A140",
"debtCollector13AddressLine1": "A142",
"debtCollector14Name": "A146",
"debtCollector14AddressLine1": "A148",
"debtCollector15Name": "A152",
"debtCollector15AddressLine1": "A154",
"debtCollector16Name": "A158",
"client2homeAddress": "A16",
"debtCollector16AddressLine1": "A160",
"debtCollector17Name": "A164",
"debtCollector17AddressLine1": "A166",
"debtCollector18Name": "A170",
"debtCollector18AddressLine1": "A172",
"debtCollector19Name": "A176",
"debtCollector19AddressLine1": "A178",
"client2homeCounty": "A18",
"debtCollector20Name": "A182",
"debtCollector20AddressLine1": "A184",
"debtCollector21Name": "A188",
"debtCollector21AddressLine1": "A190",
"debtCollector22Name": "A194",
"debtCollector22AddressLine1": "A196",
"client2dob": "A20",
"debtCollector23Name": "A200",
"debtCollector23AddressLine1": "A202",
"debtCollector24Name": "A206",
"debtCollector24AddressLine1": "A208",
"debtCollector25Name": "A212",
"debtCollector25AddressLine1": "A214",
"debtCollector26Name": "A218",
"SSN2LastFour": "A22",
"debtCollector26AddressLine1": "A220",
"debtCollector27Name": "A224",
"debtCollector27AddressLine1": "A226",
"debtCollector28Name": "A230",
"debtCollector28AddressLine1": "A232",
"debtCollector29Name": "A236",
"debtCollector29AddressLine1": "A238",
"debtCollector30Name": "A242",
"debtCollector30AddressLine1": "A244",
"caseDesignation": "A27",
"caseNumber": "A29",
"clientFirstName": "A3",
"caseSuitAmount": "A31",
"caseAnswerDate": "A33",
"caseFilingDate": "A35",
"caseAnswerFiledDate": "A37",
"settlementAmount": "A46",
"homeAddress": "A5",
"fee": "A54",
"nameOnCard": "A56",
"billingAddress": "A58",
"notes": "A63",
"debtCollector1Name": "A68",
"homeCounty": "A7",
"debtCollector1AddressLine1": "A70",
"debtCollector2Name": "A74",
"debtCollector2AddressLine1": "A76",
"debtCollector3Name": "A80",
"debtCollector3AddressLine1": "A82",
"debtCollector4Name": "A86",
"debtCollector4AddressLine1": "A88",
"dob": "A9",
"debtCollector5Name": "A92",
"debtCollector5AddressLine1": "A94",
"debtCollector6Name": "A98",
"debtCollector7Creditor": "B104",
"dmcName": "B11",
"debtCollector8Creditor": "B110",
"debtCollector9Creditor": "B116",
"debtCollector10Creditor": "B122",
"debtCollector11Creditor": "B128",
"debtCollector12Creditor": "B134",
"client2MiddleName": "B14",
"debtCollector13Creditor": "B140",
"debtCollector14Creditor": "B146",
"debtCollector15Creditor": "B152",
"debtCollector16Creditor": "B158",
"client2homeCity": "B16",
"debtCollector17Creditor": "B164",
"debtCollector18Creditor": "B170",
"debtCollector19Creditor": "B176",
"client2homePhone": "B18",
"debtCollector20Creditor": "B182",
"debtCollector21Creditor": "B188",
"debtCollector22Creditor": "B194",
"client2alias": "B20",
"debtCollector23Creditor": "B200",
"debtCollector24Creditor": "B206",
"debtCollector25Creditor": "B212",
"debtCollector26Creditor": "B218",
"debtCollector27Creditor": "B224",
"debtCollector28Creditor": "B230",
"debtCollector29Creditor": "B236",
"debtCollector30Creditor": "B242",
"caseCounty": "B27",
"casePlaintiff": "B29",
"clientMiddleName": "B3",
"caseSuitTheory": "B31",
"caseDivisionNumber": "B33",
"caseFilingAttorney": "B35",
"caseDisposition": "B37",
"settlementInstallmentAmount": "B46",
"homeCity": "B5",
"installmentAmount": "B54",
"cardNumber": "B56",
"billingZip": "B58",
"debtCollector1Creditor": "B68",
"homePhone": "B7",
"debtCollector2Creditor": "B74",
"debtCollector3Creditor": "B80",
"debtCollector4Creditor": "B86",
"alias": "B9",
"debtCollector5Creditor": "B92",
"debtCollector6Creditor": "B98",
"debtCollector6AddressLine2": "C100",
"debtCollector7Account": "C104",
"debtCollector7AddressLine2": "C106",
"debtCollector8Account": "C110",
"debtCollector8AddressLine2": "C112",
"debtCollector9Account": "C116",
"debtCollector9AddressLine2": "C118",
"debtCollector10Account": "C122",
"debtCollector10AddressLine2": "C124",
"debtCollector11Account": "C128",
"debtCollector11AddressLine2": "C130",
"debtCollector12Account": "C134",
"debtCollector12AddressLine2": "C136",
"client2LastName": "C14",
"debtCollector13Account": "C140",
"debtCollector13AddressLine2": "C142",
"debtCollector14Account": "C146",
"debtCollector14AddressLine2": "C148",
"debtCollector15Account": "C152",
"debtCollector15AddressLine2": "C154",
"debtCollector16Account": "C158",
"client2homeState": "C16",
"debtCollector16AddressLine2": "C160",
"debtCollector17Account": "C164",
"debtCollector17AddressLine2": "C166",
"debtCollector18Account": "C170",
"debtCollector18AddressLine2": "C172",
"debtCollector19Account": "C176",
"debtCollector19AddressLine2": "C178",
"client2cellPhone": "C18",
"debtCollector20Account": "C182",
"debtCollector20AddressLine2": "C184",
"debtCollector21Account": "C188",
"debtCollector21AddressLine2": "C190",
"debtCollector22Account": "C194",
"debtCollector22AddressLine2": "C196",
"client2NamePrefix": "C20",
"debtCollector23Account": "C200",
"debtCollector23AddressLine2": "C202",
"debtCollector24Account": "C206",
"debtCollector24AddressLine2": "C208",
"debtCollector25Account": "C212",
"debtCollector25AddressLine2": "C214",
"debtCollector26Account": "C218",
"debtCollector26AddressLine2": "C220",
"debtCollector27Account": "C224",
"debtCollector27AddressLine2": "C226",
"debtCollector28Account": "C230",
"debtCollector28AddressLine2": "C232",
"debtCollector29Account": "C236",
"debtCollector29AddressLine2": "C238",
"debtCollector30Account": "C242",
"debtCollector30AddressLine2": "C244",
"caseState": "C27",
"caseDefendant": "C29",
"clientLastName": "C3",
"caseOriginalCreditor": "C31",
"caseDivisionJudge": "C33",
"caseAccLastFour": "C35",
"caseDispositionDate": "C37",
"settlementFirstPaymentDate": "C46",
"homeState": "C5",
"installmentDate": "C54",
"securityCode": "C56",
"debtCollector1Account": "C68",
"cellPhone": "C7",
"debtCollector1AddressLine2": "C70",
"debtCollector2Account": "C74",
"debtCollector2AddressLine2": "C76",
"debtCollector3Account": "C80",
"debtCollector3AddressLine2": "C82",
"debtCollector4Account": "C86",
"debtCollector4AddressLine2": "C88",
"clientNamePrefix": "C9",
"debtCollector5Account": "C92",
"debtCollector5AddressLine2": "C94",
"debtCollector6Account": "C98",
"debtCollector7Amount": "D104",
"debtCollector8Amount": "D110",
"debtCollector9Amount": "D116",
"debtCollector10Amount": "D122",
"debtCollector11Amount": "D128",
"debtCollector12Amount": "D134",
"SSN2": "D14",
"debtCollector13Amount": "D140",
"debtCollector14Amount": "D146",
"debtCollector15Amount": "D152",
"debtCollector16Amount": "D158",
"client2homeZip": "D16",
"debtCollector17Amount": "D164",
"debtCollector18Amount": "D170",
"debtCollector19Amount": "D176",
"client2email": "D18",
"debtCollector20Amount": "D182",
"debtCollector21Amount": "D188",
"debtCollector22Amount": "D194",
"client2NameSuffix": "D20",
"debtCollector23Amount": "D200",
"debtCollector24Amount": "D206",
"debtCollector25Amount": "D212",
"debtCollector26Amount": "D218",
"debtCollector27Amount": "D224",
"debtCollector28Amount": "D230",
"debtCollector29Amount": "D236",
"debtCollector30Amount": "D242",
"caseDivisionDesignation": "D27",
"caseOpposingCounsel": "D29",
"SSN": "D3",
"caseAccountNumber": "D31",
"discoCosDate": "D33",
"caseOCFileNumber": "D35",
"settlementInstallmentNo": "D46",
"homeZip": "D5",
"expiration": "D56",
"debtCollector1Amount": "D68",
"email": "D7",
"debtCollector2Amount": "D74",
"debtCollector3Amount": "D80",
"debtCollector4Amount": "D86",
"clientNameSuffix": "D9",
"debtCollector5Amount": "D92",
"debtCollector6Amount": "D98"
},
"mode": "datafile"
},
{
"id": "legacy_datafile_old",
"sourceName": "fieldToCellMapOld",
"label": "Legacy Datafile Template - Old Map",
"description": "Generated from public/constants.js object fieldToCellMapOld.",
"template": "excel/legal_profile/template.xlsx",
"legacyTemplateCandidates": [
{
"label": "amortization.xlsx",
"legacyPath": "/mnt/storage/sftp/mcelwain/repository/word-doc-generator/amortization.xlsx",
"filename": "amortization.xlsx"
},
{
"label": "template.xlsx",
"legacyPath": "/mnt/storage/sftp/mcelwain/repository/word-doc-generator/template.xlsx",
"filename": "template.xlsx"
}
],
"fields": {
"debtCollector12Name": "A101",
"debtCollector12AddressLine1": "A103",
"debtCollector13Name": "A105",
"debtCollector13AddressLine1": "A107",
"debtCollector14Name": "A109",
"dob": "A11",
"debtCollector14AddressLine1": "A111",
"debtCollector15Name": "A113",
"debtCollector15AddressLine1": "A115",
"debtCollector16Name": "A117",
"debtCollector16AddressLine1": "A119",
"debtCollector17Name": "A121",
"debtCollector17AddressLine1": "A123",
"debtCollector18Name": "A125",
"debtCollector18AddressLine1": "A127",
"debtCollector19Name": "A129",
"debtCollector19AddressLine1": "A131",
"debtCollector20Name": "A133",
"debtCollector20AddressLine1": "A135",
"debtCollector21Name": "A137",
"debtCollector21AddressLine1": "A139",
"debtCollector22Name": "A141",
"debtCollector22AddressLine1": "A143",
"debtCollector23Name": "A145",
"debtCollector23AddressLine1": "A147",
"debtCollector24Name": "A149",
"caseDesignation": "A15",
"debtCollector24AddressLine1": "A151",
"debtCollector25Name": "A153",
"debtCollector25AddressLine1": "A155",
"debtCollector26Name": "A157",
"debtCollector26AddressLine1": "A159",
"debtCollector27Name": "A161",
"debtCollector27AddressLine1": "A163",
"debtCollector28Name": "A165",
"debtCollector28AddressLine1": "A167",
"debtCollector29Name": "A169",
"caseNumber": "A17",
"debtCollector29AddressLine1": "A171",
"caseSuitAmount": "A19",
"caseAnswerDate": "A21",
"fee": "A25",
"nameOnCard": "A27",
"billingAddress": "A29",
"clientFirstName": "A3",
"settlementAmount": "A32",
"debtCollector1Name": "A39",
"debtCollector1AddressLine1": "A41",
"debtCollector2Name": "A45",
"debtCollector2AddressLine1": "A47",
"client2FirstName": "A5",
"debtCollector3Name": "A51",
"debtCollector3AddressLine1": "A53",
"debtCollector4Name": "A57",
"debtCollector4AddressLine1": "A59",
"debtCollector5Name": "A63",
"debtCollector5AddressLine1": "A65",
"debtCollector6Name": "A69",
"homeAddress": "A7",
"debtCollector6AddressLine1": "A71",
"debtCollector7Name": "A75",
"debtCollector7AddressLine1": "A77",
"debtCollector8Name": "A81",
"debtCollector8AddressLine1": "A83",
"debtCollector9Name": "A87",
"debtCollector9AddressLine1": "A89",
"homeCounty": "A9",
"debtCollector10Name": "A91",
"debtCollector10AddressLine1": "A93",
"debtCollector11Name": "A95",
"debtCollector11AddressLine1": "A97",
"debtCollector12Creditor": "B101",
"debtCollector13Creditor": "B105",
"debtCollector14Creditor": "B109",
"alias": "B11",
"debtCollector15Creditor": "B113",
"debtCollector16Creditor": "B117",
"debtCollector17Creditor": "B121",
"debtCollector18Creditor": "B125",
"debtCollector19Creditor": "B129",
"debtCollector20Creditor": "B133",
"debtCollector21Creditor": "B137",
"debtCollector22Creditor": "B141",
"debtCollector23Creditor": "B145",
"debtCollector24Creditor": "B149",
"caseCounty": "B15",
"debtCollector25Creditor": "B153",
"debtCollector26Creditor": "B157",
"debtCollector27Creditor": "B161",
"debtCollector28Creditor": "B165",
"debtCollector29Creditor": "B169",
"casePlaintiff": "B17",
"caseSuitTheory": "B19",
"caseDivisionNumber": "B21",
"installmentAmount": "B25",
"cardNumber": "B27",
"billingZip": "B29",
"clientMiddleName": "B3",
"notes": "B31",
"settlementInstallmentAmount": "B32",
"debtCollector1Creditor": "B39",
"debtCollector2Creditor": "B45",
"client2MiddleName": "B5",
"debtCollector3Creditor": "B51",
"debtCollector4Creditor": "B57",
"debtCollector5Creditor": "B63",
"debtCollector6Creditor": "B69",
"homeCity": "B7",
"debtCollector7Creditor": "B75",
"debtCollector8Creditor": "B81",
"debtCollector9Creditor": "B87",
"homePhone": "B9",
"debtCollector10Creditor": "B91",
"debtCollector11Creditor": "B95",
"debtCollector12Account": "C101",
"debtCollector12AddressLine2": "C103",
"debtCollector13Account": "C105",
"debtCollector13AddressLine2": "C107",
"debtCollector14Account": "C109",
"clientNameSuffix": "C11",
"debtCollector14AddressLine2": "C111",
"debtCollector15Account": "C113",
"debtCollector15AddressLine2": "C115",
"debtCollector16Account": "C117",
"debtCollector16AddressLine2": "C119",
"debtCollector17Account": "C121",
"debtCollector17AddressLine2": "C123",
"debtCollector18Account": "C125",
"debtCollector18AddressLine2": "C127",
"debtCollector19Account": "C129",
"debtCollector19AddressLine2": "C131",
"debtCollector20Account": "C133",
"debtCollector20AddressLine2": "C135",
"debtCollector21Account": "C137",
"debtCollector21AddressLine2": "C139",
"debtCollector22Account": "C141",
"debtCollector22AddressLine2": "C143",
"debtCollector23Account": "C145",
"debtCollector23AddressLine2": "C147",
"debtCollector24Account": "C149",
"caseState": "C15",
"debtCollector24AddressLine2": "C151",
"debtCollector25Account": "C153",
"debtCollector25AddressLine2": "C155",
"debtCollector26Account": "C157",
"debtCollector26AddressLine2": "C159",
"debtCollector27Account": "C161",
"debtCollector27AddressLine2": "C163",
"debtCollector28Account": "C165",
"debtCollector28AddressLine2": "C167",
"debtCollector29Account": "C169",
"caseDefendant": "C17",
"debtCollector29AddressLine2": "C171",
"caseOriginalCreditor": "C19",
"caseDivisionJudge": "C21",
"installmentDate": "C25",
"securityCode": "C27",
"clientLastName": "C3",
"settlementFirstPaymentDate": "C32",
"debtCollector1Account": "C39",
"debtCollector1AddressLine2": "C41",
"debtCollector2Account": "C45",
"debtCollector2AddressLine2": "C47",
"client2LastName": "C5",
"debtCollector3Account": "C51",
"debtCollector3AddressLine2": "C53",
"debtCollector4Account": "C57",
"debtCollector4AddressLine2": "C59",
"debtCollector5Account": "C63",
"debtCollector5AddressLine2": "C65",
"debtCollector6Account": "C69",
"homeState": "C7",
"debtCollector6AddressLine2": "C71",
"debtCollector7Account": "C75",
"debtCollector7AddressLine2": "C77",
"debtCollector8Account": "C81",
"debtCollector8AddressLine2": "C83",
"debtCollector9Account": "C87",
"debtCollector9AddressLine2": "C89",
"cellPhone": "C9",
"debtCollector10Account": "C91",
"debtCollector10AddressLine2": "C93",
"debtCollector11Account": "C95",
"debtCollector11AddressLine2": "C97",
"debtCollector12Amount": "D101",
"debtCollector13Amount": "D105",
"debtCollector14Amount": "D109",
"debtCollector15Amount": "D113",
"debtCollector16Amount": "D117",
"debtCollector17Amount": "D121",
"debtCollector18Amount": "D125",
"debtCollector19Amount": "D129",
"debtCollector20Amount": "D133",
"debtCollector21Amount": "D137",
"debtCollector22Amount": "D141",
"debtCollector23Amount": "D145",
"debtCollector24Amount": "D149",
"caseDivisionDesignation": "D15",
"debtCollector25Amount": "D153",
"debtCollector26Amount": "D157",
"debtCollector27Amount": "D161",
"debtCollector28Amount": "D165",
"debtCollector29Amount": "D169",
"caseOpposingCounsel": "D17",
"caseAccountNumber": "D19",
"discoCosDate": "D21",
"expiration": "D27",
"SSN": "D3",
"settlementInstallmentNo": "D32",
"debtCollector1Amount": "D39",
"debtCollector2Amount": "D45",
"SSN2": "D5",
"debtCollector3Amount": "D51",
"debtCollector4Amount": "D57",
"debtCollector5Amount": "D63",
"debtCollector6Amount": "D69",
"homeZip": "D7",
"debtCollector7Amount": "D75",
"debtCollector8Amount": "D81",
"debtCollector9Amount": "D87",
"email": "D9",
"debtCollector10Amount": "D91",
"debtCollector11Amount": "D95"
},
"mode": "datafile"
},
{
"id": "settlement_amortization",
"label": "Settlement Amortization Calculator",
"description": "Writes settlement values into amortization.xlsx for a quick amortization check.",
"template": "excel/legal_profile/amortization.xlsx",
"mode": "calculator",
"fields": [
"casePlaintiff",
"settlementAmount",
"settlementFirstPaymentDate",
"settlementInstallmentAmount",
"settlementInstallmentNo",
"settlementInterestRate",
"settlementPaymentsPerYear"
],
"field_to_cell": {
"settlementAmount": "E3",
"settlementInterestRate": "E4",
"settlementInstallmentNo": "E5",
"settlementPaymentsPerYear": "E6",
"settlementFirstPaymentDate": "E7",
"settlementInstallmentAmount": "I3",
"casePlaintiff": "H9"
},
"field_count": 7
}
]
}

View File

@ -0,0 +1,211 @@
import json
import re
import shutil
import zipfile
import xml.etree.ElementTree as ET
from pathlib import Path
from openpyxl import load_workbook
from tools.doc_generator.logic.calculations import apply_calculations
from tools.doc_generator.logic.core_fields import merge_core_fields
from tools.doc_generator.logic.document_types import get_document_type
BASE_DIR = Path(__file__).resolve().parent.parent
PROJECT_ROOT = BASE_DIR.parent.parent
CONTENT_DIR = BASE_DIR / "content"
EXCEL_MAPS_DIR = CONTENT_DIR / "excel_maps"
TEMPLATES_DIR = CONTENT_DIR / "templates"
EXCEL_TEMPLATE_DIR = TEMPLATES_DIR / "legacy_excel"
OLD_WORD_DOC_GENERATOR_DIR = Path("/mnt/storage/sftp/mcelwain/repository/word-doc-generator")
EXPORTS_DIR = PROJECT_ROOT / "exports"
MAIN_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
ET.register_namespace("", MAIN_NS)
def safe_filename(value: str) -> str:
value = str(value or "legacy_export").strip()
value = re.sub(r"[^A-Za-z0-9._ -]+", "", value)
value = re.sub(r"\s+", "_", value)
return value or "legacy_export"
def get_excel_map_set_id(document_type_id: str) -> str:
document_type = get_document_type(document_type_id)
return document_type.get("excelMapSet") or document_type_id
def load_excel_map_set(document_type_id: str) -> dict:
map_set_id = get_excel_map_set_id(document_type_id)
path = EXCEL_MAPS_DIR / f"{map_set_id}.json"
if not path.exists():
raise FileNotFoundError(f"Excel map set not found: {path}")
return json.loads(path.read_text(encoding="utf-8"))
def load_excel_maps(document_type_id: str) -> list[dict]:
return load_excel_map_set(document_type_id).get("maps", [])
def load_excel_map(document_type_id: str, map_id: str) -> dict:
for item in load_excel_maps(document_type_id):
if item.get("id") == map_id:
return item
raise FileNotFoundError(f"Excel map not found for {document_type_id}: {map_id}")
def resolve_excel_template(template_name: str) -> Path:
template_name = str(template_name or "").strip()
template_basename = Path(template_name).name
# Prefer explicit profile-library template paths from the map file.
candidate_paths = [
TEMPLATES_DIR / template_name,
CONTENT_DIR / "templates" / template_name,
# Backward-compatible fallbacks.
EXCEL_TEMPLATE_DIR / template_basename,
OLD_WORD_DOC_GENERATOR_DIR / template_basename,
]
# Last-resort recursive lookups.
candidate_paths.extend(sorted(TEMPLATES_DIR.rglob(template_basename)))
candidate_paths.extend(sorted(OLD_WORD_DOC_GENERATOR_DIR.rglob(template_basename)))
template_path = next((candidate for candidate in candidate_paths if candidate.exists()), None)
if template_path is None:
searched = "\n".join(str(candidate) for candidate in candidate_paths[:30])
raise FileNotFoundError(f"Excel template not found: {template_name}. Searched:\n{searched}")
return template_path
def cell_parts(cell_ref: str):
col = "".join(ch for ch in cell_ref if ch.isalpha()).upper()
row = int("".join(ch for ch in cell_ref if ch.isdigit()))
return col, row
def col_number(col: str) -> int:
total = 0
for ch in col:
total = total * 26 + (ord(ch) - ord("A") + 1)
return total
def cell_sort_key(cell_ref: str):
col, row = cell_parts(cell_ref)
return row, col_number(col)
def find_or_create_row(sheet_data, row_number: int):
ns = f"{{{MAIN_NS}}}"
rows = sheet_data.findall(ns + "row")
for row in rows:
if int(row.attrib.get("r", "0")) == row_number:
return row
new_row = ET.Element(ns + "row", {"r": str(row_number)})
inserted = False
for index, row in enumerate(rows):
if int(row.attrib.get("r", "0")) > row_number:
sheet_data.insert(index, new_row)
inserted = True
break
if not inserted:
sheet_data.append(new_row)
return new_row
def find_or_create_cell(row, cell_ref: str):
ns = f"{{{MAIN_NS}}}"
cells = row.findall(ns + "c")
for cell in cells:
if cell.attrib.get("r") == cell_ref:
return cell
new_cell = ET.Element(ns + "c", {"r": cell_ref})
inserted = False
for index, cell in enumerate(cells):
if cell_sort_key(cell.attrib.get("r", "A1")) > cell_sort_key(cell_ref):
row.insert(index, new_cell)
inserted = True
break
if not inserted:
row.append(new_cell)
return new_cell
def set_cell_text(cell, value):
ns = f"{{{MAIN_NS}}}"
for child in list(cell):
cell.remove(child)
cell.attrib.pop("s", None)
cell.attrib["t"] = "inlineStr"
inline = ET.SubElement(cell, ns + "is")
text = ET.SubElement(inline, ns + "t")
text.text = "" if value is None else str(value)
def write_sheet1_values(xlsx_path: Path, output_path: Path, field_to_cell: dict, data: dict):
workbook = load_workbook(xlsx_path)
worksheet = workbook.worksheets[0]
for field, cell_ref in field_to_cell.items():
worksheet[cell_ref] = "" if data.get(field) is None else data.get(field, "")
# Ask Excel-compatible apps to recalculate formulas when the workbook opens.
try:
workbook.calculation.fullCalcOnLoad = True
workbook.calculation.forceFullCalc = True
except Exception:
pass
workbook.save(output_path)
def export_profile_excel(document_type_id: str, data: dict, map_id: str) -> Path:
document_type = get_document_type(document_type_id)
final_data = merge_core_fields(data)
final_data = apply_calculations(document_type, final_data)
excel_map = load_excel_map(document_type_id, map_id)
template_name = excel_map.get("template") or "template.xlsx"
template_path = resolve_excel_template(template_name)
EXPORTS_DIR.mkdir(parents=True, exist_ok=True)
case_number = safe_filename(final_data.get("caseNumber") or "no_case_number")
timestamp = safe_filename(final_data.get("timestamp_YYYY-MM-DD_HH-mm-ss") or "export")
output_path = EXPORTS_DIR / f"{document_type_id}_{map_id}_{case_number}_{timestamp}.xlsx"
write_sheet1_values(
xlsx_path=template_path,
output_path=output_path,
field_to_cell=excel_map.get("fields", {}),
data=final_data,
)
return output_path
# Backward-compatible function name while routes are transitioning.
def export_legacy_excel(document_type_id: str, data: dict, map_id: str = "legacy_datafile") -> Path:
return export_profile_excel(document_type_id, data, map_id)