diff --git a/app/routes/doc_generator.py b/app/routes/doc_generator.py
index 9c72992..0556aff 100644
--- a/app/routes/doc_generator.py
+++ b/app/routes/doc_generator.py
@@ -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"),
)
diff --git a/static/app.js b/static/app.js
index 8c7ab34..ceca8cc 100644
--- a/static/app.js
+++ b/static/app.js
@@ -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}
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 = `
+