coming together

This commit is contained in:
HotSwapp
2025-08-13 18:53:35 -05:00
parent acc5155bf7
commit 5111079149
51 changed files with 14457 additions and 588 deletions

View File

@@ -1,10 +1,13 @@
""" """
Customer (Rolodex) API endpoints Customer (Rolodex) API endpoints
""" """
from typing import List, Optional from typing import List, Optional, Union
from fastapi import APIRouter, Depends, HTTPException, status, Query from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
from sqlalchemy import or_, func from sqlalchemy import or_, and_, func, asc, desc
from fastapi.responses import StreamingResponse
import csv
import io
from app.database.base import get_db from app.database.base import get_db
from app.models.rolodex import Rolodex, Phone from app.models.rolodex import Rolodex, Phone
@@ -169,36 +172,263 @@ async def get_customer_stats(
} }
@router.get("/", response_model=List[CustomerResponse]) class PaginatedCustomersResponse(BaseModel):
items: List[CustomerResponse]
total: int
@router.get("/", response_model=Union[List[CustomerResponse], PaginatedCustomersResponse])
async def list_customers( async def list_customers(
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200), limit: int = Query(50, ge=1, le=200),
search: Optional[str] = Query(None), search: Optional[str] = Query(None),
group: Optional[str] = Query(None, description="Filter by customer group (exact match)"),
state: Optional[str] = Query(None, description="Filter by state abbreviation (exact match)"),
groups: Optional[List[str]] = Query(None, description="Filter by multiple groups (repeat param)"),
states: Optional[List[str]] = Query(None, description="Filter by multiple states (repeat param)"),
sort_by: Optional[str] = Query("id", description="Sort field: id, name, city, email"),
sort_dir: Optional[str] = Query("asc", description="Sort direction: asc or desc"),
include_total: bool = Query(False, description="When true, returns {items, total} instead of a plain list"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""List customers with pagination and search""" """List customers with pagination and search"""
try: try:
query = db.query(Rolodex).options(joinedload(Rolodex.phone_numbers)) base_query = db.query(Rolodex)
if search: if search:
query = query.filter( s = (search or "").strip()
or_( s_lower = s.lower()
Rolodex.id.contains(search), tokens = [t for t in s_lower.split() if t]
Rolodex.last.contains(search), # Basic contains search on several fields (case-insensitive)
Rolodex.first.contains(search), contains_any = or_(
Rolodex.city.contains(search), func.lower(Rolodex.id).contains(s_lower),
Rolodex.email.contains(search) func.lower(Rolodex.last).contains(s_lower),
) func.lower(Rolodex.first).contains(s_lower),
func.lower(Rolodex.middle).contains(s_lower),
func.lower(Rolodex.city).contains(s_lower),
func.lower(Rolodex.email).contains(s_lower),
) )
# Multi-token name support: every token must match either first, middle, or last
customers = query.offset(skip).limit(limit).all() name_tokens = [
or_(
func.lower(Rolodex.first).contains(tok),
func.lower(Rolodex.middle).contains(tok),
func.lower(Rolodex.last).contains(tok),
)
for tok in tokens
]
combined = contains_any if not name_tokens else or_(contains_any, and_(*name_tokens))
# Comma pattern: "Last, First"
last_first_filter = None
if "," in s_lower:
last_part, first_part = [p.strip() for p in s_lower.split(",", 1)]
if last_part and first_part:
last_first_filter = and_(
func.lower(Rolodex.last).contains(last_part),
func.lower(Rolodex.first).contains(first_part),
)
elif last_part:
last_first_filter = func.lower(Rolodex.last).contains(last_part)
final_filter = or_(combined, last_first_filter) if last_first_filter is not None else combined
base_query = base_query.filter(final_filter)
# Apply group/state filters (support single and multi-select)
effective_groups = [g for g in (groups or []) if g] or ([group] if group else [])
if effective_groups:
base_query = base_query.filter(Rolodex.group.in_(effective_groups))
effective_states = [s for s in (states or []) if s] or ([state] if state else [])
if effective_states:
base_query = base_query.filter(Rolodex.abrev.in_(effective_states))
# Apply sorting (whitelisted fields only)
normalized_sort_by = (sort_by or "id").lower()
normalized_sort_dir = (sort_dir or "asc").lower()
is_desc = normalized_sort_dir == "desc"
order_columns = []
if normalized_sort_by == "id":
order_columns = [Rolodex.id]
elif normalized_sort_by == "name":
# Sort by last, then first
order_columns = [Rolodex.last, Rolodex.first]
elif normalized_sort_by == "city":
# Sort by city, then state abbreviation
order_columns = [Rolodex.city, Rolodex.abrev]
elif normalized_sort_by == "email":
order_columns = [Rolodex.email]
else:
# Fallback to id to avoid arbitrary column injection
order_columns = [Rolodex.id]
# Case-insensitive ordering where applicable, preserving None ordering default
ordered = []
for col in order_columns:
# Use lower() for string-like cols; SQLAlchemy will handle non-string safely enough for SQLite/Postgres
expr = func.lower(col) if col.type.python_type in (str,) else col # type: ignore[attr-defined]
ordered.append(desc(expr) if is_desc else asc(expr))
if ordered:
base_query = base_query.order_by(*ordered)
customers = base_query.options(joinedload(Rolodex.phone_numbers)).offset(skip).limit(limit).all()
if include_total:
total = base_query.count()
return {"items": customers, "total": total}
return customers return customers
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"Error loading customers: {str(e)}") raise HTTPException(status_code=500, detail=f"Error loading customers: {str(e)}")
@router.get("/export")
async def export_customers(
# Optional pagination for exporting only current page; omit to export all
skip: Optional[int] = Query(None, ge=0),
limit: Optional[int] = Query(None, ge=1, le=1000000),
search: Optional[str] = Query(None),
group: Optional[str] = Query(None, description="Filter by customer group (exact match)"),
state: Optional[str] = Query(None, description="Filter by state abbreviation (exact match)"),
groups: Optional[List[str]] = Query(None, description="Filter by multiple groups (repeat param)"),
states: Optional[List[str]] = Query(None, description="Filter by multiple states (repeat param)"),
sort_by: Optional[str] = Query("id", description="Sort field: id, name, city, email"),
sort_dir: Optional[str] = Query("asc", description="Sort direction: asc or desc"),
fields: Optional[List[str]] = Query(None, description="CSV fields to include: id,name,group,city,state,phone,email"),
export_all: bool = Query(False, description="When true, ignore skip/limit and export all matches"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Export customers as CSV respecting search, filters, and sorting.
If skip/limit provided, exports that slice; otherwise exports all matches.
"""
try:
base_query = db.query(Rolodex)
if search:
s = (search or "").strip()
s_lower = s.lower()
tokens = [t for t in s_lower.split() if t]
contains_any = or_(
func.lower(Rolodex.id).contains(s_lower),
func.lower(Rolodex.last).contains(s_lower),
func.lower(Rolodex.first).contains(s_lower),
func.lower(Rolodex.middle).contains(s_lower),
func.lower(Rolodex.city).contains(s_lower),
func.lower(Rolodex.email).contains(s_lower),
)
name_tokens = [
or_(
func.lower(Rolodex.first).contains(tok),
func.lower(Rolodex.middle).contains(tok),
func.lower(Rolodex.last).contains(tok),
)
for tok in tokens
]
combined = contains_any if not name_tokens else or_(contains_any, and_(*name_tokens))
last_first_filter = None
if "," in s_lower:
last_part, first_part = [p.strip() for p in s_lower.split(",", 1)]
if last_part and first_part:
last_first_filter = and_(
func.lower(Rolodex.last).contains(last_part),
func.lower(Rolodex.first).contains(first_part),
)
elif last_part:
last_first_filter = func.lower(Rolodex.last).contains(last_part)
final_filter = or_(combined, last_first_filter) if last_first_filter is not None else combined
base_query = base_query.filter(final_filter)
effective_groups = [g for g in (groups or []) if g] or ([group] if group else [])
if effective_groups:
base_query = base_query.filter(Rolodex.group.in_(effective_groups))
effective_states = [s for s in (states or []) if s] or ([state] if state else [])
if effective_states:
base_query = base_query.filter(Rolodex.abrev.in_(effective_states))
normalized_sort_by = (sort_by or "id").lower()
normalized_sort_dir = (sort_dir or "asc").lower()
is_desc = normalized_sort_dir == "desc"
order_columns = []
if normalized_sort_by == "id":
order_columns = [Rolodex.id]
elif normalized_sort_by == "name":
order_columns = [Rolodex.last, Rolodex.first]
elif normalized_sort_by == "city":
order_columns = [Rolodex.city, Rolodex.abrev]
elif normalized_sort_by == "email":
order_columns = [Rolodex.email]
else:
order_columns = [Rolodex.id]
ordered = []
for col in order_columns:
try:
expr = func.lower(col) if col.type.python_type in (str,) else col # type: ignore[attr-defined]
except Exception:
expr = col
ordered.append(desc(expr) if is_desc else asc(expr))
if ordered:
base_query = base_query.order_by(*ordered)
if not export_all:
if skip is not None:
base_query = base_query.offset(skip)
if limit is not None:
base_query = base_query.limit(limit)
customers = base_query.options(joinedload(Rolodex.phone_numbers)).all()
# Prepare CSV
output = io.StringIO()
writer = csv.writer(output)
allowed_fields_in_order = ["id", "name", "group", "city", "state", "phone", "email"]
header_names = {
"id": "Customer ID",
"name": "Name",
"group": "Group",
"city": "City",
"state": "State",
"phone": "Primary Phone",
"email": "Email",
}
requested = [f.lower() for f in (fields or []) if isinstance(f, str)]
selected_fields = [f for f in allowed_fields_in_order if f in requested] if requested else allowed_fields_in_order
if not selected_fields:
selected_fields = allowed_fields_in_order
writer.writerow([header_names[f] for f in selected_fields])
for c in customers:
full_name = f"{(c.first or '').strip()} {(c.last or '').strip()}".strip()
primary_phone = ""
try:
if c.phone_numbers:
primary_phone = c.phone_numbers[0].phone or ""
except Exception:
primary_phone = ""
row_map = {
"id": c.id,
"name": full_name,
"group": c.group or "",
"city": c.city or "",
"state": c.abrev or "",
"phone": primary_phone,
"email": c.email or "",
}
writer.writerow([row_map[f] for f in selected_fields])
output.seek(0)
filename = "customers_export.csv"
return StreamingResponse(
output,
media_type="text/csv",
headers={
"Content-Disposition": f"attachment; filename={filename}"
},
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error exporting customers: {str(e)}")
@router.get("/{customer_id}", response_model=CustomerResponse) @router.get("/{customer_id}", response_model=CustomerResponse)
async def get_customer( async def get_customer(
customer_id: str, customer_id: str,

View File

@@ -118,10 +118,18 @@ async def create_qdro(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Create new QDRO""" """Create new QDRO"""
qdro = QDRO(**qdro_data.model_dump()) # Only accept fields that exist on the model and exclude None values
allowed_fields = {c.name for c in QDRO.__table__.columns}
payload = {
k: v
for k, v in qdro_data.model_dump(exclude_unset=True).items()
if v is not None and k in allowed_fields
}
qdro = QDRO(**payload)
if not qdro.created_date: # Backfill created_date if model supports it; otherwise rely on created_at
qdro.created_date = date.today() if hasattr(qdro, "created_date") and not getattr(qdro, "created_date"):
setattr(qdro, "created_date", date.today())
db.add(qdro) db.add(qdro)
db.commit() db.commit()
@@ -172,9 +180,11 @@ async def update_qdro(
detail="QDRO not found" detail="QDRO not found"
) )
# Update fields # Update fields present on the model only
allowed_fields = {c.name for c in QDRO.__table__.columns}
for field, value in qdro_data.model_dump(exclude_unset=True).items(): for field, value in qdro_data.model_dump(exclude_unset=True).items():
setattr(qdro, field, value) if field in allowed_fields:
setattr(qdro, field, value)
db.commit() db.commit()
db.refresh(qdro) db.refresh(qdro)
@@ -525,23 +535,33 @@ async def generate_document(
document_id = str(uuid.uuid4()) document_id = str(uuid.uuid4())
file_name = f"{template.form_name}_{file_obj.file_no}_{date.today().isoformat()}" file_name = f"{template.form_name}_{file_obj.file_no}_{date.today().isoformat()}"
exports_dir = "/app/exports"
try:
os.makedirs(exports_dir, exist_ok=True)
except Exception:
try:
os.makedirs("exports", exist_ok=True)
exports_dir = "exports"
except Exception:
exports_dir = "."
if request.output_format.upper() == "PDF": if request.output_format.upper() == "PDF":
file_path = f"/app/exports/{document_id}.pdf" file_path = f"{exports_dir}/{document_id}.pdf"
file_name += ".pdf" file_name += ".pdf"
# Here you would implement PDF generation # Here you would implement PDF generation
# For now, create a simple text file # For now, create a simple text file
with open(f"/app/exports/{document_id}.txt", "w") as f: with open(f"{exports_dir}/{document_id}.txt", "w") as f:
f.write(merged_content) f.write(merged_content)
file_path = f"/app/exports/{document_id}.txt" file_path = f"{exports_dir}/{document_id}.txt"
elif request.output_format.upper() == "DOCX": elif request.output_format.upper() == "DOCX":
file_path = f"/app/exports/{document_id}.docx" file_path = f"{exports_dir}/{document_id}.docx"
file_name += ".docx" file_name += ".docx"
# Implement DOCX generation # Implement DOCX generation
with open(f"/app/exports/{document_id}.txt", "w") as f: with open(f"{exports_dir}/{document_id}.txt", "w") as f:
f.write(merged_content) f.write(merged_content)
file_path = f"/app/exports/{document_id}.txt" file_path = f"{exports_dir}/{document_id}.txt"
else: # HTML else: # HTML
file_path = f"/app/exports/{document_id}.html" file_path = f"{exports_dir}/{document_id}.html"
file_name += ".html" file_name += ".html"
html_content = f"<html><body><pre>{merged_content}</pre></body></html>" html_content = f"<html><body><pre>{merged_content}</pre></body></html>"
with open(file_path, "w") as f: with open(file_path, "w") as f:
@@ -768,6 +788,9 @@ async def upload_document(
max_size = 10 * 1024 * 1024 # 10MB max_size = 10 * 1024 * 1024 # 10MB
content = await file.read() content = await file.read()
# Treat zero-byte payloads as no file uploaded to provide a clearer client error
if len(content) == 0:
raise HTTPException(status_code=400, detail="No file uploaded")
if len(content) > max_size: if len(content) > max_size:
raise HTTPException(status_code=400, detail="File too large") raise HTTPException(status_code=400, detail="File too large")

View File

@@ -294,33 +294,82 @@ async def _update_file_balances(file_obj: File, db: Session):
async def get_recent_time_entries( async def get_recent_time_entries(
days: int = Query(7, ge=1, le=30), days: int = Query(7, ge=1, le=30),
employee: Optional[str] = Query(None), employee: Optional[str] = Query(None),
skip: int = Query(0, ge=0), status: Optional[str] = Query(None, description="billed|unbilled"),
q: Optional[str] = Query(None, description="text search across description, file, employee, matter, client name"),
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=200), limit: int = Query(50, ge=1, le=200),
sort_by: str = Query("date"),
sort_dir: str = Query("desc"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Get recent time entries across all files""" """Get recent time entries across all files with server-side sorting and pagination"""
cutoff_date = date.today() - timedelta(days=days) cutoff_date = date.today() - timedelta(days=days)
query = db.query(Ledger)\ # Base query with joins for sorting/searching by client/matter
.options(joinedload(Ledger.file).joinedload(File.owner))\ base_query = db.query(Ledger) \
.join(File, Ledger.file_no == File.file_no) \
.outerjoin(Rolodex, File.id == Rolodex.id) \
.options(joinedload(Ledger.file).joinedload(File.owner)) \
.filter(and_( .filter(and_(
Ledger.date >= cutoff_date, Ledger.date >= cutoff_date,
Ledger.t_type == "2" # Time entries Ledger.t_type == "2"
))\ ))
.order_by(desc(Ledger.date))
if employee: if employee:
query = query.filter(Ledger.empl_num == employee) base_query = base_query.filter(Ledger.empl_num == employee)
entries = query.offset(skip).limit(limit).all() # Status/billed filtering
if status:
status_l = str(status).strip().lower()
if status_l in ("billed", "unbilled"):
billed_value = "Y" if status_l == "billed" else "N"
base_query = base_query.filter(Ledger.billed == billed_value)
# Text search across multiple fields
if q:
query_text = f"%{q.strip()}%"
base_query = base_query.filter(
or_(
Ledger.note.ilike(query_text),
Ledger.file_no.ilike(query_text),
Ledger.empl_num.ilike(query_text),
File.regarding.ilike(query_text),
Rolodex.first.ilike(query_text),
Rolodex.last.ilike(query_text)
)
)
# Sorting mapping (supported columns)
sort_map = {
"date": Ledger.date,
"file_no": Ledger.file_no,
"client_name": Rolodex.last, # best-effort: sort by client last name
"empl_num": Ledger.empl_num,
"quantity": Ledger.quantity,
"hours": Ledger.quantity, # alias
"rate": Ledger.rate,
"amount": Ledger.amount,
"billed": Ledger.billed,
"description": Ledger.note,
}
sort_column = sort_map.get(sort_by.lower(), Ledger.date)
direction = desc if str(sort_dir).lower() == "desc" else asc
# Total count for pagination (distinct on Ledger.id to avoid join-induced dupes)
total_count = base_query.with_entities(func.count(func.distinct(Ledger.id))).scalar()
# Apply sorting and pagination
offset = (page - 1) * limit
page_query = base_query.order_by(direction(sort_column)).offset(offset).limit(limit)
entries = page_query.all()
# Format results with file and client information # Format results with file and client information
results = [] results = []
for entry in entries: for entry in entries:
file_obj = entry.file file_obj = entry.file
client = file_obj.owner if file_obj else None client = file_obj.owner if file_obj else None
results.append({ results.append({
"id": entry.id, "id": entry.id,
"date": entry.date.isoformat(), "date": entry.date.isoformat(),
@@ -334,8 +383,15 @@ async def get_recent_time_entries(
"description": entry.note, "description": entry.note,
"billed": entry.billed == "Y" "billed": entry.billed == "Y"
}) })
return {"entries": results, "total_entries": len(results)} return {
"entries": results,
"total_count": total_count,
"page": page,
"limit": limit,
"sort_by": sort_by,
"sort_dir": sort_dir,
}
@router.post("/time-entry/quick") @router.post("/time-entry/quick")

281
app/api/flexible.py Normal file
View File

@@ -0,0 +1,281 @@
"""
Flexible Imports admin API: list, filter, and export unmapped rows captured during CSV imports.
"""
from typing import Optional, Dict, Any, List
from datetime import datetime
import csv
import io
from fastapi import APIRouter, Depends, Query, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from sqlalchemy import func, or_, cast, String
from app.database.base import get_db
from app.auth.security import get_admin_user
from app.models.flexible import FlexibleImport
router = APIRouter(prefix="/flexible", tags=["flexible"])
@router.get("/imports")
async def list_flexible_imports(
file_type: Optional[str] = Query(None, description="Filter by CSV file type (e.g., FILES.csv)"),
target_table: Optional[str] = Query(None, description="Filter by target model table name"),
q: Optional[str] = Query(None, description="Quick text search across file type, target table, and unmapped data"),
has_keys: Optional[List[str]] = Query(
None,
description="Filter rows where extra_data (or its 'unmapped' payload) contains these keys. Repeat param for multiple keys.",
),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=500),
db: Session = Depends(get_db),
current_user=Depends(get_admin_user),
):
"""List flexible import rows with optional filtering, quick search, and pagination."""
query = db.query(FlexibleImport)
if file_type:
query = query.filter(FlexibleImport.file_type == file_type)
if target_table:
query = query.filter(FlexibleImport.target_table == target_table)
if q:
pattern = f"%{q.strip()}%"
# Search across file_type, target_table, and serialized JSON extra_data
query = query.filter(
or_(
FlexibleImport.file_type.ilike(pattern),
FlexibleImport.target_table.ilike(pattern),
cast(FlexibleImport.extra_data, String).ilike(pattern),
)
)
# Filter by key presence inside JSON payload by string matching of the serialized JSON
# This is DB-agnostic and works across SQLite/Postgres, though not as precise as JSON operators.
if has_keys:
for k in [k for k in has_keys if k is not None and str(k).strip() != ""]:
key = str(k).strip()
# Look for the JSON key token followed by a colon, e.g. "key":
query = query.filter(cast(FlexibleImport.extra_data, String).ilike(f'%"{key}":%'))
total = query.count()
items = (
query.order_by(FlexibleImport.id.desc())
.offset(skip)
.limit(limit)
.all()
)
def serialize(item: FlexibleImport) -> Dict[str, Any]:
return {
"id": item.id,
"file_type": item.file_type,
"target_table": item.target_table,
"primary_key_field": item.primary_key_field,
"primary_key_value": item.primary_key_value,
"extra_data": item.extra_data,
}
return {
"total": total,
"skip": skip,
"limit": limit,
"items": [serialize(i) for i in items],
}
@router.get("/options")
async def flexible_options(
db: Session = Depends(get_db),
current_user=Depends(get_admin_user),
):
"""Return distinct file types and target tables for filter dropdowns."""
file_types: List[str] = [
ft for (ft,) in db.query(func.distinct(FlexibleImport.file_type)).order_by(FlexibleImport.file_type.asc()).all()
if ft is not None
]
target_tables: List[str] = [
tt for (tt,) in db.query(func.distinct(FlexibleImport.target_table)).order_by(FlexibleImport.target_table.asc()).all()
if tt is not None and tt != ""
]
return {"file_types": file_types, "target_tables": target_tables}
@router.get("/export")
async def export_unmapped_csv(
file_type: Optional[str] = Query(None, description="Filter by CSV file type (e.g., FILES.csv)"),
target_table: Optional[str] = Query(None, description="Filter by target model table name"),
has_keys: Optional[List[str]] = Query(
None,
description="Filter rows where extra_data (or its 'unmapped' payload) contains these keys. Repeat param for multiple keys.",
),
db: Session = Depends(get_db),
current_user=Depends(get_admin_user),
):
"""Export unmapped rows as CSV for review. Includes basic metadata columns and unmapped fields.
If FlexibleImport.extra_data contains a nested 'unmapped' dict, those keys are exported.
Otherwise, all keys of extra_data are exported.
"""
query = db.query(FlexibleImport)
if file_type:
query = query.filter(FlexibleImport.file_type == file_type)
if target_table:
query = query.filter(FlexibleImport.target_table == target_table)
if has_keys:
for k in [k for k in has_keys if k is not None and str(k).strip() != ""]:
key = str(k).strip()
query = query.filter(cast(FlexibleImport.extra_data, String).ilike(f'%"{key}":%'))
rows: List[FlexibleImport] = query.order_by(FlexibleImport.id.asc()).all()
if not rows:
raise HTTPException(status_code=404, detail="No matching flexible imports to export")
# Determine union of unmapped keys across all rows
unmapped_keys: List[str] = []
key_set = set()
for r in rows:
data = r.extra_data or {}
payload = data.get("unmapped") if isinstance(data, dict) and isinstance(data.get("unmapped"), dict) else data
if isinstance(payload, dict):
for k in payload.keys():
if k not in key_set:
key_set.add(k)
unmapped_keys.append(k)
# Prepare CSV
meta_headers = [
"id",
"file_type",
"target_table",
"primary_key_field",
"primary_key_value",
]
fieldnames = meta_headers + unmapped_keys
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=fieldnames)
writer.writeheader()
for r in rows:
row_out: Dict[str, Any] = {
"id": r.id,
"file_type": r.file_type,
"target_table": r.target_table or "",
"primary_key_field": r.primary_key_field or "",
"primary_key_value": r.primary_key_value or "",
}
data = r.extra_data or {}
payload = data.get("unmapped") if isinstance(data, dict) and isinstance(data.get("unmapped"), dict) else data
if isinstance(payload, dict):
for k in unmapped_keys:
v = payload.get(k)
# Normalize lists/dicts to JSON strings for CSV safety
if isinstance(v, (dict, list)):
try:
import json as _json
row_out[k] = _json.dumps(v, ensure_ascii=False)
except Exception:
row_out[k] = str(v)
else:
row_out[k] = v if v is not None else ""
writer.writerow(row_out)
output.seek(0)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename_parts = ["flexible_unmapped"]
if file_type:
filename_parts.append(file_type.replace("/", "-").replace(" ", "_"))
if target_table:
filename_parts.append(target_table.replace("/", "-").replace(" ", "_"))
filename = "_".join(filename_parts) + f"_{timestamp}.csv"
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={
"Content-Disposition": f"attachment; filename=\"{filename}\"",
},
)
@router.get("/export/{row_id}")
async def export_single_row_csv(
row_id: int,
db: Session = Depends(get_db),
current_user=Depends(get_admin_user),
):
"""Export a single flexible import row as CSV.
Includes metadata columns plus keys from the row's unmapped payload.
If FlexibleImport.extra_data contains a nested 'unmapped' dict, those keys are exported;
otherwise, all keys of extra_data are exported.
"""
row: Optional[FlexibleImport] = (
db.query(FlexibleImport).filter(FlexibleImport.id == row_id).first()
)
if not row:
raise HTTPException(status_code=404, detail="Flexible import row not found")
data = row.extra_data or {}
payload = (
data.get("unmapped")
if isinstance(data, dict) and isinstance(data.get("unmapped"), dict)
else data
)
unmapped_keys: List[str] = []
if isinstance(payload, dict):
for k in payload.keys():
unmapped_keys.append(k)
meta_headers = [
"id",
"file_type",
"target_table",
"primary_key_field",
"primary_key_value",
]
fieldnames = meta_headers + unmapped_keys
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=fieldnames)
writer.writeheader()
row_out: Dict[str, Any] = {
"id": row.id,
"file_type": row.file_type,
"target_table": row.target_table or "",
"primary_key_field": row.primary_key_field or "",
"primary_key_value": row.primary_key_value or "",
}
if isinstance(payload, dict):
for k in unmapped_keys:
v = payload.get(k)
if isinstance(v, (dict, list)):
try:
import json as _json
row_out[k] = _json.dumps(v, ensure_ascii=False)
except Exception:
row_out[k] = str(v)
else:
row_out[k] = v if v is not None else ""
writer.writerow(row_out)
output.seek(0)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = (
f"flexible_row_{row.id}_{row.file_type.replace('/', '-').replace(' ', '_')}_{timestamp}.csv"
if row.file_type
else f"flexible_row_{row.id}_{timestamp}.csv"
)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={
"Content-Disposition": f"attachment; filename=\"{filename}\"",
},
)

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
""" """
Advanced Search API endpoints - Comprehensive search across all data types Advanced Search API endpoints - Comprehensive search across all data types
""" """
from typing import List, Optional, Union, Dict, Any from typing import List, Optional, Union, Dict, Any, Tuple
from fastapi import APIRouter, Depends, HTTPException, status, Query, Body from fastapi import APIRouter, Depends, HTTPException, status, Query, Body
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
from sqlalchemy import or_, and_, func, desc, asc, text, case, cast, String, DateTime, Date, Numeric from sqlalchemy import or_, and_, func, desc, asc, text, case, cast, String, DateTime, Date, Numeric
@@ -11,6 +11,14 @@ import json
import re import re
from app.database.base import get_db from app.database.base import get_db
from app.api.search_highlight import (
build_query_tokens,
highlight_text,
create_customer_highlight,
create_file_highlight,
create_ledger_highlight,
create_qdro_highlight,
)
from app.models.rolodex import Rolodex, Phone from app.models.rolodex import Rolodex, Phone
from app.models.files import File from app.models.files import File
from app.models.ledger import Ledger from app.models.ledger import Ledger
@@ -1059,60 +1067,16 @@ def _calculate_document_relevance(doc: FormIndex, query: str) -> float:
# Highlight functions # Highlight functions
def _create_customer_highlight(customer: Rolodex, query: str) -> str: def _create_customer_highlight(customer: Rolodex, query: str) -> str:
"""Create highlight snippet for customer""" return create_customer_highlight(customer, query)
if not query:
return ""
full_name = f"{customer.first or ''} {customer.last}".strip()
if query.lower() in full_name.lower():
return f"Name: {full_name}"
if customer.email and query.lower() in customer.email.lower():
return f"Email: {customer.email}"
if customer.city and query.lower() in customer.city.lower():
return f"City: {customer.city}"
return ""
def _create_file_highlight(file_obj: File, query: str) -> str: def _create_file_highlight(file_obj: File, query: str) -> str:
"""Create highlight snippet for file""" return create_file_highlight(file_obj, query)
if not query:
return ""
if file_obj.regarding and query.lower() in file_obj.regarding.lower():
return f"Matter: {file_obj.regarding}"
if file_obj.file_type and query.lower() in file_obj.file_type.lower():
return f"Type: {file_obj.file_type}"
return ""
def _create_ledger_highlight(ledger: Ledger, query: str) -> str: def _create_ledger_highlight(ledger: Ledger, query: str) -> str:
"""Create highlight snippet for ledger""" return create_ledger_highlight(ledger, query)
if not query:
return ""
if ledger.note and query.lower() in ledger.note.lower():
return f"Note: {ledger.note[:100]}..."
return ""
def _create_qdro_highlight(qdro: QDRO, query: str) -> str: def _create_qdro_highlight(qdro: QDRO, query: str) -> str:
"""Create highlight snippet for QDRO""" return create_qdro_highlight(qdro, query)
if not query:
return ""
if qdro.form_name and query.lower() in qdro.form_name.lower():
return f"Form: {qdro.form_name}"
if qdro.pet and query.lower() in qdro.pet.lower():
return f"Petitioner: {qdro.pet}"
if qdro.case_number and query.lower() in qdro.case_number.lower():
return f"Case: {qdro.case_number}"
return ""

141
app/api/search_highlight.py Normal file
View File

@@ -0,0 +1,141 @@
"""
Server-side highlight utilities for search results.
These functions generate HTML snippets with <strong> around matched tokens,
preserving the original casing of the source text. The output is intended to be
sanitized on the client before insertion into the DOM.
"""
from typing import List, Tuple, Any
import re
def build_query_tokens(query: str) -> List[str]:
"""Split query into alphanumeric tokens, trimming punctuation and deduping while preserving order."""
if not query:
return []
raw_parts = re.sub(r"[,_;:]+", " ", str(query or "").strip()).split()
cleaned: List[str] = []
seen = set()
for part in raw_parts:
token = re.sub(r"^[^A-Za-z0-9]+|[^A-Za-z0-9]+$", "", part)
lowered = token.lower()
if token and lowered not in seen:
cleaned.append(token)
seen.add(lowered)
return cleaned
def _merge_ranges(ranges: List[Tuple[int, int]]) -> List[Tuple[int, int]]:
if not ranges:
return []
ranges.sort(key=lambda x: (x[0], x[1]))
merged: List[Tuple[int, int]] = []
cur_s, cur_e = ranges[0]
for s, e in ranges[1:]:
if s <= cur_e:
cur_e = max(cur_e, e)
else:
merged.append((cur_s, cur_e))
cur_s, cur_e = s, e
merged.append((cur_s, cur_e))
return merged
def highlight_text(value: str, tokens: List[str]) -> str:
"""Return `value` with case-insensitive matches of `tokens` wrapped in <strong>, preserving original casing."""
if value is None:
return ""
source = str(value)
if not source or not tokens:
return source
haystack = source.lower()
ranges: List[Tuple[int, int]] = []
for t in tokens:
needle = str(t or "").lower()
if not needle:
continue
start = 0
last_possible = max(0, len(haystack) - len(needle))
while start <= last_possible and len(needle) > 0:
idx = haystack.find(needle, start)
if idx == -1:
break
ranges.append((idx, idx + len(needle)))
start = idx + 1
if not ranges:
return source
parts: List[str] = []
merged = _merge_ranges(ranges)
pos = 0
for s, e in merged:
if pos < s:
parts.append(source[pos:s])
parts.append("<strong>" + source[s:e] + "</strong>")
pos = e
if pos < len(source):
parts.append(source[pos:])
return "".join(parts)
def create_customer_highlight(customer: Any, query: str) -> str:
if not query:
return ""
tokens = build_query_tokens(query)
full_name = f"{getattr(customer, 'first', '') or ''} {getattr(customer, 'last', '')}".strip()
email = getattr(customer, 'email', None)
city = getattr(customer, 'city', None)
ql = query.lower()
if full_name and ql in full_name.lower():
return f"Name: {highlight_text(full_name, tokens)}"
if email and ql in str(email).lower():
return f"Email: {highlight_text(str(email), tokens)}"
if city and ql in str(city).lower():
return f"City: {highlight_text(str(city), tokens)}"
return ""
def create_file_highlight(file_obj: Any, query: str) -> str:
if not query:
return ""
tokens = build_query_tokens(query)
regarding = getattr(file_obj, 'regarding', None)
file_type = getattr(file_obj, 'file_type', None)
ql = query.lower()
if regarding and ql in str(regarding).lower():
return f"Matter: {highlight_text(str(regarding), tokens)}"
if file_type and ql in str(file_type).lower():
return f"Type: {highlight_text(str(file_type), tokens)}"
return ""
def create_ledger_highlight(ledger: Any, query: str) -> str:
if not query:
return ""
tokens = build_query_tokens(query)
note = getattr(ledger, 'note', None)
if note and query.lower() in str(note).lower():
text = str(note) or ""
preview = text[:160]
suffix = "..." if len(text) > 160 else ""
return f"Note: {highlight_text(preview, tokens)}{suffix}"
return ""
def create_qdro_highlight(qdro: Any, query: str) -> str:
if not query:
return ""
tokens = build_query_tokens(query)
form_name = getattr(qdro, 'form_name', None)
pet = getattr(qdro, 'pet', None)
case_number = getattr(qdro, 'case_number', None)
ql = query.lower()
if form_name and ql in str(form_name).lower():
return f"Form: {highlight_text(str(form_name), tokens)}"
if pet and ql in str(pet).lower():
return f"Petitioner: {highlight_text(str(pet), tokens)}"
if case_number and ql in str(case_number).lower():
return f"Case: {highlight_text(str(case_number), tokens)}"
return ""

View File

@@ -9,8 +9,9 @@ from datetime import datetime
import secrets import secrets
from app.database.base import get_db from app.database.base import get_db
from app.models import User, SupportTicket, TicketResponse, TicketStatus, TicketPriority, TicketCategory from app.models import User, SupportTicket, TicketResponse as TicketResponseModel, TicketStatus, TicketPriority, TicketCategory
from app.auth.security import get_current_user, get_admin_user from app.auth.security import get_current_user, get_admin_user
from app.services.audit import audit_service
router = APIRouter() router = APIRouter()
@@ -46,7 +47,7 @@ class ResponseCreate(BaseModel):
is_internal: bool = False is_internal: bool = False
class TicketResponse(BaseModel): class TicketResponseOut(BaseModel):
"""Ticket response model""" """Ticket response model"""
id: int id: int
ticket_id: int ticket_id: int
@@ -80,7 +81,7 @@ class TicketDetail(BaseModel):
assigned_to: Optional[int] assigned_to: Optional[int]
assigned_admin_name: Optional[str] assigned_admin_name: Optional[str]
submitter_name: Optional[str] submitter_name: Optional[str]
responses: List[TicketResponse] = [] responses: List[TicketResponseOut] = []
class Config: class Config:
from_attributes = True from_attributes = True
@@ -135,6 +136,20 @@ async def create_support_ticket(
db.commit() db.commit()
db.refresh(new_ticket) db.refresh(new_ticket)
# Audit logging (non-blocking)
try:
audit_service.log_action(
db=db,
action="CREATE",
resource_type="SUPPORT_TICKET",
user=current_user,
resource_id=str(new_ticket.id),
details={"ticket_number": new_ticket.ticket_number},
request=request,
)
except Exception:
pass
return { return {
"message": "Support ticket created successfully", "message": "Support ticket created successfully",
"ticket_number": new_ticket.ticket_number, "ticket_number": new_ticket.ticket_number,
@@ -225,7 +240,7 @@ async def get_ticket(
ticket = db.query(SupportTicket).options( ticket = db.query(SupportTicket).options(
joinedload(SupportTicket.submitter), joinedload(SupportTicket.submitter),
joinedload(SupportTicket.assigned_admin), joinedload(SupportTicket.assigned_admin),
joinedload(SupportTicket.responses).joinedload(TicketResponse.author) joinedload(SupportTicket.responses).joinedload(TicketResponseModel.author)
).filter(SupportTicket.id == ticket_id).first() ).filter(SupportTicket.id == ticket_id).first()
if not ticket: if not ticket:
@@ -303,8 +318,19 @@ async def update_ticket(
ticket.updated_at = datetime.utcnow() ticket.updated_at = datetime.utcnow()
db.commit() db.commit()
# Log the update (audit logging can be added later) # Audit logging (non-blocking)
# TODO: Add audit logging for ticket updates try:
audit_service.log_action(
db=db,
action="UPDATE",
resource_type="SUPPORT_TICKET",
user=current_user,
resource_id=str(ticket_id),
details={"changes": changes} if changes else None,
request=request,
)
except Exception:
pass
return {"message": "Ticket updated successfully"} return {"message": "Ticket updated successfully"}
@@ -327,7 +353,7 @@ async def add_response(
) )
# Create response # Create response
response = TicketResponse( response = TicketResponseModel(
ticket_id=ticket_id, ticket_id=ticket_id,
message=response_data.message, message=response_data.message,
is_internal=response_data.is_internal, is_internal=response_data.is_internal,
@@ -343,8 +369,19 @@ async def add_response(
db.commit() db.commit()
db.refresh(response) db.refresh(response)
# Log the response (audit logging can be added later) # Audit logging (non-blocking)
# TODO: Add audit logging for ticket responses try:
audit_service.log_action(
db=db,
action="ADD_RESPONSE",
resource_type="SUPPORT_TICKET",
user=current_user,
resource_id=str(ticket_id),
details={"response_id": response.id, "is_internal": response_data.is_internal},
request=request,
)
except Exception:
pass
return {"message": "Response added successfully", "response_id": response.id} return {"message": "Response added successfully", "response_id": response.id}

View File

@@ -68,6 +68,7 @@ from app.api.documents import router as documents_router
from app.api.search import router as search_router from app.api.search import router as search_router
from app.api.admin import router as admin_router from app.api.admin import router as admin_router
from app.api.import_data import router as import_router from app.api.import_data import router as import_router
from app.api.flexible import router as flexible_router
from app.api.support import router as support_router from app.api.support import router as support_router
from app.api.settings import router as settings_router from app.api.settings import router as settings_router
@@ -82,14 +83,15 @@ app.include_router(admin_router, prefix="/api/admin", tags=["admin"])
app.include_router(import_router, prefix="/api/import", tags=["import"]) app.include_router(import_router, prefix="/api/import", tags=["import"])
app.include_router(support_router, prefix="/api/support", tags=["support"]) app.include_router(support_router, prefix="/api/support", tags=["support"])
app.include_router(settings_router, prefix="/api/settings", tags=["settings"]) app.include_router(settings_router, prefix="/api/settings", tags=["settings"])
app.include_router(flexible_router, prefix="/api")
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
async def root(request: Request): async def root(request: Request):
"""Main application - redirect to login""" """Dashboard as the main landing page. Client-side JS handles auth redirect."""
return templates.TemplateResponse( return templates.TemplateResponse(
"login.html", "dashboard.html",
{"request": request, "title": "Login - " + settings.app_name} {"request": request, "title": "Dashboard - " + settings.app_name}
) )
@@ -167,6 +169,15 @@ async def import_page(request: Request):
) )
@app.get("/flexible", response_class=HTMLResponse)
async def flexible_page(request: Request):
"""Flexible imports admin page (admin only)."""
return templates.TemplateResponse(
"flexible.html",
{"request": request, "title": "Flexible Imports - " + settings.app_name}
)
@app.get("/health") @app.get("/health")
async def health_check(): async def health_check():
"""Health check endpoint""" """Health check endpoint"""

View File

@@ -7,9 +7,10 @@ from .rolodex import Rolodex, Phone
from .files import File from .files import File
from .ledger import Ledger from .ledger import Ledger
from .qdro import QDRO from .qdro import QDRO
from .audit import AuditLog, LoginAttempt from .audit import AuditLog, LoginAttempt, ImportAudit, ImportAuditFile
from .auth import RefreshToken from .auth import RefreshToken
from .additional import Deposit, Payment, FileNote, FormVariable, ReportVariable, Document from .additional import Deposit, Payment, FileNote, FormVariable, ReportVariable, Document
from .flexible import FlexibleImport
from .support import SupportTicket, TicketResponse, TicketStatus, TicketPriority, TicketCategory from .support import SupportTicket, TicketResponse, TicketStatus, TicketPriority, TicketCategory
from .pensions import ( from .pensions import (
Pension, PensionSchedule, MarriageHistory, DeathBenefit, Pension, PensionSchedule, MarriageHistory, DeathBenefit,
@@ -23,8 +24,8 @@ from .lookups import (
__all__ = [ __all__ = [
"BaseModel", "User", "Rolodex", "Phone", "File", "Ledger", "QDRO", "BaseModel", "User", "Rolodex", "Phone", "File", "Ledger", "QDRO",
"AuditLog", "LoginAttempt", "RefreshToken", "AuditLog", "LoginAttempt", "ImportAudit", "ImportAuditFile", "RefreshToken",
"Deposit", "Payment", "FileNote", "FormVariable", "ReportVariable", "Document", "Deposit", "Payment", "FileNote", "FormVariable", "ReportVariable", "Document", "FlexibleImport",
"SupportTicket", "TicketResponse", "TicketStatus", "TicketPriority", "TicketCategory", "SupportTicket", "TicketResponse", "TicketStatus", "TicketPriority", "TicketCategory",
"Pension", "PensionSchedule", "MarriageHistory", "DeathBenefit", "Pension", "PensionSchedule", "MarriageHistory", "DeathBenefit",
"SeparationAgreement", "LifeTable", "NumberTable", "SeparationAgreement", "LifeTable", "NumberTable",

View File

@@ -46,4 +46,57 @@ class LoginAttempt(BaseModel):
failure_reason = Column(String(200), nullable=True) # Reason for failure failure_reason = Column(String(200), nullable=True) # Reason for failure
def __repr__(self): def __repr__(self):
return f"<LoginAttempt(username='{self.username}', success={bool(self.success)}, timestamp='{self.timestamp}')>" return f"<LoginAttempt(username='{self.username}', success={bool(self.success)}, timestamp='{self.timestamp}')>"
class ImportAudit(BaseModel):
"""
Records each batch CSV upload run with metrics and outcome.
"""
__tablename__ = "import_audit"
id = Column(Integer, primary_key=True, autoincrement=True, index=True)
started_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
finished_at = Column(DateTime, nullable=True, index=True)
status = Column(String(30), nullable=False, default="running", index=True) # running|success|completed_with_errors|failed
total_files = Column(Integer, nullable=False, default=0)
successful_files = Column(Integer, nullable=False, default=0)
failed_files = Column(Integer, nullable=False, default=0)
total_imported = Column(Integer, nullable=False, default=0)
total_errors = Column(Integer, nullable=False, default=0)
initiated_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
initiated_by_username = Column(String(100), nullable=True)
message = Column(String(255), nullable=True)
details = Column(JSON, nullable=True) # optional, compact summary payload
user = relationship("User")
files = relationship("ImportAuditFile", back_populates="audit", cascade="all, delete-orphan")
def __repr__(self):
return (
f"<ImportAudit(id={self.id}, status='{self.status}', files={self.successful_files}/{self.total_files}, "
f"imported={self.total_imported}, errors={self.total_errors})>"
)
class ImportAuditFile(BaseModel):
"""Per-file result for a given batch import run."""
__tablename__ = "import_audit_files"
id = Column(Integer, primary_key=True, autoincrement=True, index=True)
audit_id = Column(Integer, ForeignKey("import_audit.id", ondelete="CASCADE"), nullable=False, index=True)
file_type = Column(String(64), nullable=False, index=True)
status = Column(String(30), nullable=False, index=True)
imported_count = Column(Integer, nullable=False, default=0)
errors = Column(Integer, nullable=False, default=0)
message = Column(String(255), nullable=True)
details = Column(JSON, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
audit = relationship("ImportAudit", back_populates="files")
def __repr__(self):
return f"<ImportAuditFile(audit_id={self.audit_id}, file='{self.file_type}', status='{self.status}', imported={self.imported_count}, errors={self.errors})>"

37
app/models/flexible.py Normal file
View File

@@ -0,0 +1,37 @@
"""
Flexible storage for unmapped CSV columns during import
"""
from sqlalchemy import Column, Integer, String
from sqlalchemy.types import JSON
from app.models.base import BaseModel
class FlexibleImport(BaseModel):
"""Stores per-row extra/unmapped data for any import, without persisting mapping patterns."""
__tablename__ = "flexible_imports"
id = Column(Integer, primary_key=True, autoincrement=True)
# The CSV filename used by the importer (e.g., "FILES.csv" or arbitrary names in flexible mode)
file_type = Column(String(120), nullable=False, index=True)
# The SQLAlchemy model table this extra data is associated with (if any)
target_table = Column(String(120), nullable=True, index=True)
# Optional link to the primary record created in the target table
primary_key_field = Column(String(120), nullable=True)
primary_key_value = Column(String(255), nullable=True, index=True)
# Extra unmapped columns from the CSV row
extra_data = Column(JSON, nullable=False)
def __repr__(self) -> str: # pragma: no cover - repr utility
return (
f"<FlexibleImport(id={self.id}, file_type='{self.file_type}', "
f"target_table='{self.target_table}', pk_field='{self.primary_key_field}', "
f"pk_value='{self.primary_key_value}')>"
)

6539
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -21,9 +21,11 @@
"testEnvironment": "jsdom" "testEnvironment": "jsdom"
}, },
"devDependencies": { "devDependencies": {
"@jest/environment": "^30.0.5",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"tailwindcss": "^3.4.10",
"jest": "^29.7.0", "jest": "^29.7.0",
"jsdom": "^22.1.0" "jest-environment-jsdom": "^30.0.5",
"jsdom": "^22.1.0",
"tailwindcss": "^3.4.10"
} }
} }

View File

@@ -0,0 +1,133 @@
/** @jest-environment jsdom */
const path = require('path');
// Load sanitizer and alerts modules (IIFE attaches to window)
require(path.join(__dirname, '..', 'sanitizer.js'));
require(path.join(__dirname, '..', 'alerts.js'));
// Polyfill requestAnimationFrame for jsdom
beforeAll(() => {
if (!global.requestAnimationFrame) {
global.requestAnimationFrame = (cb) => setTimeout(cb, 0);
}
});
beforeEach(() => {
document.body.innerHTML = '';
});
describe('alerts.show UI behavior', () => {
it('creates a container and renders a notification', () => {
const wrapper = window.alerts.show('Hello world', 'info', { duration: 0 });
const container = document.getElementById('notification-container');
expect(container).toBeTruthy();
expect(container.contains(wrapper)).toBe(true);
expect(wrapper.className).toMatch(/alert-notification/);
});
it('applies styling based on type and aliases', () => {
const s = window.alerts.show('ok', 'success', { duration: 0 });
const e = window.alerts.show('bad', 'error', { duration: 0 }); // alias to danger
const w = window.alerts.show('warn', 'warning', { duration: 0 });
const i = window.alerts.show('info', 'info', { duration: 0 });
expect(s.className).toContain('bg-green-50');
expect(e.className).toContain('bg-red-50');
expect(w.className).toContain('bg-yellow-50');
expect(i.className).toContain('bg-blue-50');
});
it('renders title when provided', () => {
const wrapper = window.alerts.show('Body', 'info', { title: 'My Title', duration: 0 });
const titleEl = wrapper.querySelector('p.text-sm.font-bold');
expect(titleEl).toBeTruthy();
expect(titleEl.textContent).toBe('My Title');
});
it('sanitizes HTML when html option is true', () => {
const wrapper = window.alerts.show('<img src=x onerror=alert(1)><script>evil()</script><p>hi</p>', 'info', { html: true, duration: 0 });
const textEl = wrapper.querySelector('.text-sm.mt-1.font-semibold');
expect(textEl).toBeTruthy();
const html = textEl.innerHTML;
expect(html).toContain('<img');
expect(html).toContain('<p>hi</p>');
expect(html).not.toMatch(/<script/i);
expect(html).not.toMatch(/onerror/i);
});
it('supports Node message content without sanitization', () => {
const span = document.createElement('span');
span.textContent = 'node message';
const wrapper = window.alerts.show(span, 'info', { duration: 0 });
const textEl = wrapper.querySelector('.text-sm.mt-1.font-semibold');
expect(textEl.textContent).toContain('node message');
});
it('is dismissible by default and calls onClose', () => {
const onClose = jest.fn();
const wrapper = window.alerts.show('dismiss me', 'info', { onClose, duration: 0 });
const btn = wrapper.querySelector('button[aria-label="Close"]');
expect(btn).toBeTruthy();
btn.click();
expect(document.body.contains(wrapper)).toBe(false);
expect(onClose).toHaveBeenCalledTimes(1);
});
it('can be non-dismissible', () => {
const wrapper = window.alerts.show('stay', 'info', { dismissible: false, duration: 0 });
const btn = wrapper.querySelector('button[aria-label="Close"]');
expect(btn).toBeNull();
});
it('auto-closes after duration', () => {
jest.useFakeTimers();
const wrapper = window.alerts.show('timeout', 'info', { duration: 50 });
expect(document.body.contains(wrapper)).toBe(true);
jest.advanceTimersByTime(400);
expect(document.body.contains(wrapper)).toBe(false);
jest.useRealTimers();
});
it('renders actions and handles clicks (autoClose true by default)', () => {
const onClick = jest.fn();
const wrapper = window.alerts.show('with action', 'info', {
duration: 0,
actions: [
{ label: 'Retry', onClick }
]
});
const buttons = Array.from(wrapper.querySelectorAll('button'));
const retryBtn = buttons.find((b) => b.textContent === 'Retry');
expect(retryBtn).toBeTruthy();
retryBtn.click();
expect(onClick).toHaveBeenCalledTimes(1);
expect(document.body.contains(wrapper)).toBe(false);
});
it('respects action.autoClose = false', () => {
const onClick = jest.fn();
const wrapper = window.alerts.show('stay open', 'info', {
duration: 0,
actions: [
{ label: 'Stay', onClick, autoClose: false }
]
});
const buttons = Array.from(wrapper.querySelectorAll('button'));
const stayBtn = buttons.find((b) => b.textContent === 'Stay');
expect(stayBtn).toBeTruthy();
stayBtn.click();
expect(onClick).toHaveBeenCalledTimes(1);
expect(document.body.contains(wrapper)).toBe(true);
});
it('supports custom containerId and element id', () => {
const wrapper = window.alerts.show('custom', 'info', { duration: 0, containerId: 'alt-container', id: 'toast-1' });
const container = document.getElementById('alt-container');
expect(container).toBeTruthy();
expect(wrapper.id).toBe('toast-1');
expect(container.contains(wrapper)).toBe(true);
});
});

View File

@@ -0,0 +1,79 @@
/** @jest-environment jsdom */
// Load dependencies used by highlight utils
require('../sanitizer.js');
require('../highlight.js');
describe('highlightUtils', () => {
const { buildTokens, highlight, escape: esc, formatSnippet } = window.highlightUtils;
test('buildTokens normalizes punctuation, trims non-alphanumerics, and dedupes', () => {
const tokens = buildTokens(' John, Smith; "Smith" (J.) ');
// Expect order preserved except deduping
expect(tokens).toEqual(['John', 'Smith', 'J']);
const empty = buildTokens(' , ; : ');
expect(empty).toEqual([]);
});
test('escape encodes special characters safely', () => {
const out = esc('<div> & "quotes" and \'apostrophes\'');
expect(out).toContain('&lt;div&gt;');
expect(out).toContain('&amp;');
expect(out).toContain('&quot;');
expect(out).toContain('&#39;');
expect(esc('Tom & Jerry')).toBe('Tom &amp; Jerry');
});
test('highlight wraps tokens in <strong> and does not break HTML by escaping first', () => {
const tokens = buildTokens('john smith');
const result = highlight('Hello <b>John</b> Smith & Sons', tokens);
// Should escape original tags and then apply strong
expect(result).toContain('&lt;b&gt;');
expect(result).toMatch(/<strong>John<\/strong>/i);
expect(result).toMatch(/<strong>Smith<\/strong>/i);
// Ampersand must be escaped
expect(result).toContain('&amp; Sons');
});
test('highlight handles overlapping tokens by sequential replacement', () => {
const tokens = buildTokens('ann anna');
const out = highlight('Anna and Ann went', tokens);
// Both tokens should appear highlighted; order of replacement should not remove prior highlights
const strongCount = (out.match(/<strong>/g) || []).length;
expect(strongCount).toBeGreaterThanOrEqual(2);
expect(out).toMatch(/<strong>Anna<\/strong> and <strong>Ann<\/strong> went/i);
});
test('formatSnippet uses server-provided strong tags if present', () => {
const tokens = buildTokens('alpha');
const serverSnippet = 'Value: <strong>Alpha</strong> beta';
const html = formatSnippet(serverSnippet, tokens);
// Should preserve strong from server
expect(html).toContain('<strong>Alpha</strong>');
// Should be sanitized and not double-escaped
expect(html).toContain('Value: ');
});
test('formatSnippet applies client-side bold when server snippet is plain text', () => {
const tokens = buildTokens('delta');
const plain = 'Gamma delta epsilon';
const html = formatSnippet(plain, tokens);
expect(html).toMatch(/Gamma <strong>delta<\/strong> epsilon/i);
});
test('highlight is case-insensitive and preserves original text casing', () => {
const tokens = buildTokens('joHN smiTH');
const out = highlight('John Smith', tokens);
// Must wrap both tokens and preserve the original casing from the source text
expect(out).toBe('<strong>John</strong> <strong>Smith</strong>');
});
test('formatSnippet highlights with mixed-case query tokens but keeps snippet casing', () => {
const tokens = buildTokens('doE');
const html = formatSnippet('Hello Doe', tokens);
// Exact casing from snippet should be preserved inside <strong>
expect(html).toContain('Hello <strong>Doe</strong>');
});
});

View File

@@ -0,0 +1,123 @@
/** @jest-environment jsdom */
const path = require('path');
// Install a fetch mock BEFORE loading the fetch-wrapper so it captures our mock
const fetchMock = jest.fn();
global.fetch = fetchMock;
// Load sanitizer and alerts (IIFE attaches to window)
require(path.join(__dirname, '..', 'sanitizer.js'));
require(path.join(__dirname, '..', 'alerts.js'));
// Load fetch wrapper (captures current global fetch as originalFetch)
require(path.join(__dirname, '..', 'fetch-wrapper.js'));
// Polyfill requestAnimationFrame for jsdom animations in alerts
beforeAll(() => {
if (!global.requestAnimationFrame) {
global.requestAnimationFrame = (cb) => setTimeout(cb, 0);
}
});
beforeEach(() => {
document.body.innerHTML = '';
fetchMock.mockReset();
});
// Minimal UI helper that simulates the upload UI error handling
async function uploadFileUI() {
// Attempt an upload; on failure, surface the envelope via alerts.error with a sanitized HTML message
const resp = await window.http.wrappedFetch('/api/documents/upload/FILE-123', {
method: 'POST',
body: new FormData(),
});
if (!resp.ok) {
const err = await window.http.toError(resp, 'Upload failed');
const msg = window.http.formatAlert(err, 'Upload failed');
// html: true to allow basic markup from server but sanitized; duration 0 so it stays for assertions
return window.alerts.error(msg, { html: true, duration: 0, id: 'upload-error' });
}
return null;
}
function makeErrorResponse({ status = 400, envelope, headerCid = null }) {
return {
ok: false,
status,
headers: {
get: (name) => {
if (name && name.toLowerCase() === 'x-correlation-id') return headerCid || null;
return null;
},
},
clone() {
return this;
},
async json() {
return envelope;
},
async text() {
try { return JSON.stringify(envelope); } catch (_) { return ''; }
},
};
}
describe('Upload UI error handling', () => {
it('displays server error via alerts.error, sanitizes HTML, and includes correlation ID', async () => {
const cid = 'cid-abc123';
const envelope = {
success: false,
error: { status: 400, code: 'http_error', message: 'Invalid <b>file</b> <script>evil()</script><img src=x onerror="alert(1)">' },
correlation_id: cid,
};
fetchMock.mockResolvedValueOnce(makeErrorResponse({ status: 400, envelope, headerCid: cid }));
const wrapper = await uploadFileUI();
expect(wrapper).toBeTruthy();
expect(wrapper.id).toBe('upload-error');
const content = wrapper.querySelector('.text-sm.mt-1.font-semibold');
expect(content).toBeTruthy();
const html = content.innerHTML;
// Preserves safe markup
expect(html).toContain('<b>file</b>');
// Scripts and event handlers removed
expect(html).not.toMatch(/<script/i);
expect(html).not.toMatch(/onerror=/i);
// Correlation reference present
expect(html).toMatch(/Ref: cid-abc123/);
});
it('uses correlation ID from header when both header and envelope provide values', async () => {
const headerCid = 'cid-header';
const bodyCid = 'cid-body';
const envelope = {
success: false,
error: { status: 400, code: 'http_error', message: 'Invalid type' },
correlation_id: bodyCid,
};
fetchMock.mockResolvedValueOnce(makeErrorResponse({ status: 400, envelope, headerCid }));
const wrapper = await uploadFileUI();
const html = wrapper.querySelector('.text-sm.mt-1.font-semibold').innerHTML;
expect(html).toMatch(/Ref: cid-header/);
expect(html).not.toMatch(/cid-body/);
});
it('falls back to basic text if alerts module is missing but our alerts is present; ensures container exists', async () => {
const cid = 'cid-xyz';
const envelope = {
success: false,
error: { status: 400, code: 'http_error', message: 'Bad <em>upload</em>' },
correlation_id: cid,
};
fetchMock.mockResolvedValueOnce(makeErrorResponse({ status: 400, envelope, headerCid: cid }));
const wrapper = await uploadFileUI();
const container = document.getElementById('notification-container');
expect(container).toBeTruthy();
expect(container.contains(wrapper)).toBe(true);
});
});

View File

@@ -240,8 +240,9 @@ function showCustomerDetailsModal(customer) {
<button onclick="closeCustomerDetailsModal()" class="px-4 py-2 bg-neutral-300 dark:bg-neutral-600 text-neutral-800 dark:text-white hover:bg-neutral-400 dark:hover:bg-neutral-500 rounded-lg transition-colors text-sm font-medium border border-neutral-400 dark:border-neutral-500"> <button onclick="closeCustomerDetailsModal()" class="px-4 py-2 bg-neutral-300 dark:bg-neutral-600 text-neutral-800 dark:text-white hover:bg-neutral-400 dark:hover:bg-neutral-500 rounded-lg transition-colors text-sm font-medium border border-neutral-400 dark:border-neutral-500">
Close Close
</button> </button>
<button onclick="closeCustomerDetailsModal(); editCustomer('${escapeHtml(customer.id)}');" style="background-color: #dc2626; color: white; padding: 8px 16px; border-radius: 6px; border: none; font-weight: 500; font-size: 14px;"> <button onclick="closeCustomerDetailsModal(); editCustomer('${escapeHtml(customer.id)}');" class="inline-flex items-center px-4 py-2 bg-danger-600 text-white hover:bg-danger-700 rounded-lg transition-colors text-sm font-medium shadow-sm">
<i class="fa-solid fa-pencil" style="margin-right: 4px;"></i>Edit <i class="fa-solid fa-pencil mr-2"></i>
Edit
</button> </button>
</div> </div>
</div> </div>

View File

@@ -3,6 +3,18 @@ let currentPage = 0;
let currentSearch = ''; let currentSearch = '';
let isEditing = false; let isEditing = false;
let editingCustomerId = null; let editingCustomerId = null;
let selectedCustomerIds = new Set();
let customerCompactMode = false;
// Local debounce fallback to avoid dependency on main.js
function _localDebounce(func, wait) {
let timeout;
return function() {
const context = this, args = arguments;
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(context, args), wait);
};
}
// Enhanced table display function // Enhanced table display function
function displayCustomers(customers) { function displayCustomers(customers) {
@@ -18,51 +30,74 @@ function displayCustomers(customers) {
emptyState.classList.add('hidden'); emptyState.classList.add('hidden');
// Selection removed
// Build highlight function based on currentSearch tokens
const tokens = (window.highlightUtils && typeof window.highlightUtils.buildTokens === 'function')
? window.highlightUtils.buildTokens((currentSearch || '').trim())
: [];
function highlightText(text) {
if (!text) return '';
if (!window.highlightUtils || typeof window.highlightUtils.highlight !== 'function' || tokens.length === 0) {
return escapeHtml(String(text));
}
// Use safe highlighter that computes ranges, then transform <strong> to styled <mark>
const strongHtml = window.highlightUtils.highlight(String(text), tokens);
return strongHtml
.replace(/<strong>/g, '<mark class="bg-yellow-200 text-neutral-900 rounded px-0.5">')
.replace(/<\/strong>/g, '</mark>');
}
customers.forEach(customer => { customers.forEach(customer => {
const phones = Array.isArray(customer.phone_numbers) ? customer.phone_numbers : [];
const primaryPhone = phones.length > 0 ? (phones[0].phone || '') : '';
const phoneCount = phones.length;
const phoneHtml = `${highlightText(primaryPhone)}${phoneCount > 1 ? ` <span class="text-xs text-neutral-500">(+${phoneCount - 1} more)</span>` : ''}`;
const row = document.createElement('tr'); const row = document.createElement('tr');
row.className = 'group border-b border-neutral-100 dark:border-neutral-700/50 hover:bg-gradient-to-r hover:from-blue-50/30 hover:to-indigo-50/30 dark:hover:from-blue-900/10 dark:hover:to-indigo-900/10 transition-all duration-200'; row.className = 'group odd:bg-neutral-50 dark:odd:bg-neutral-800/50 border-b border-neutral-100 dark:border-neutral-700/50 hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors';
// Store customer ID as data attribute to avoid escaping issues in onclick // Store customer ID as data attribute to avoid escaping issues in onclick
row.dataset.customerId = customer.id; row.dataset.customerId = customer.id;
// Build clean, simple row structure with clickable rows (no inline onclick to avoid backslash issues) // Build clean, simple row structure with clickable rows (no inline onclick to avoid backslash issues)
const pad = customerCompactMode ? 'px-3 py-2' : 'px-6 py-4';
row.innerHTML = ` row.innerHTML = `
<td class="px-4 py-4 cursor-pointer customer-cell"> <td class="${pad} cursor-pointer customer-cell">
<div class="text-sm font-mono font-semibold text-neutral-900 dark:text-neutral-100 bg-gradient-to-br from-neutral-50 to-neutral-100 dark:from-neutral-800 dark:to-neutral-700 px-3 py-2 rounded-lg shadow-sm border border-neutral-200/50 dark:border-neutral-600/50 group-hover:shadow-md transition-shadow"> <div class="text-sm font-mono font-semibold text-neutral-900 dark:text-neutral-100 bg-gradient-to-br from-neutral-50 to-neutral-100 dark:from-neutral-800 dark:to-neutral-700 px-3 py-2 rounded-lg shadow-sm border border-neutral-200/50 dark:border-neutral-600/50 group-hover:shadow-md transition-shadow">
${escapeHtml(customer.id || '')} ${highlightText(customer.id || '')}
</div> </div>
</td> </td>
<td class="px-4 py-4 cursor-pointer customer-cell"> <td class="${pad} cursor-pointer customer-cell">
<div class="text-sm font-semibold text-neutral-900 dark:text-neutral-100 group-hover:text-blue-700 dark:group-hover:text-blue-300 transition-colors"> <div class="text-sm font-semibold text-neutral-900 dark:text-neutral-100 group-hover:text-blue-700 dark:group-hover:text-blue-300 transition-colors">
${escapeHtml(formatFullName(customer))} ${highlightText(formatFullName(customer))}
</div> </div>
${customer.title ? `<div class="text-xs text-neutral-500 dark:text-neutral-400 mt-1 font-medium">${escapeHtml(customer.title)}</div>` : ''} ${customerCompactMode ? '' : (customer.title ? `<div class="text-xs text-neutral-500 dark:text-neutral-400 mt-1 font-medium">${highlightText(customer.title)}</div>` : '')}
</td> </td>
<td class="px-4 py-4 cursor-pointer customer-cell"> <td class="${pad} cursor-pointer customer-cell">
${customer.group ? `<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-bold bg-gradient-to-r from-blue-50 to-blue-100 dark:from-blue-900/40 dark:to-blue-800/40 text-blue-800 dark:text-blue-200 border border-blue-200/70 dark:border-blue-700/70 shadow-sm">${escapeHtml(customer.group)}</span>` : '<span class="text-neutral-400 text-sm font-medium">-</span>'} ${customer.group ? `<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-bold bg-gradient-to-r from-blue-50 to-blue-100 dark:from-blue-900/40 dark:to-blue-800/40 text-blue-800 dark:text-blue-200 border border-blue-200/70 dark:border-blue-700/70 shadow-sm">${highlightText(customer.group)}</span>` : '<span class="text-neutral-400 text-sm font-medium">-</span>'}
</td> </td>
<td class="px-4 py-4 cursor-pointer customer-cell"> <td class="${pad} cursor-pointer customer-cell">
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100"> <div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
${escapeHtml(formatCityState(customer))} ${highlightText(formatCityState(customer))}
</div> </div>
${customer.a1 ? `<div class="text-xs text-neutral-500 dark:text-neutral-400 mt-1 truncate max-w-xs font-medium">${escapeHtml(customer.a1)}</div>` : ''} ${customerCompactMode ? '' : (customer.a1 ? `<div class="text-xs text-neutral-500 dark:text-neutral-400 mt-1 truncate max-w-xs font-medium">${highlightText(customer.a1)}</div>` : '')}
</td> </td>
<td class="px-4 py-4 cursor-pointer customer-cell"> <td class="${pad} cursor-pointer customer-cell">
<div class="text-sm font-mono font-medium text-neutral-900 dark:text-neutral-100"> <div class="text-sm font-mono font-medium text-neutral-900 dark:text-neutral-100">
${formatPrimaryPhone(customer.phone_numbers || [])} ${phoneHtml}
</div> </div>
</td> </td>
<td class="px-4 py-4 cursor-pointer customer-cell"> <td class="${pad} cursor-pointer customer-cell">
${customer.email ? `<a href="mailto:${encodeURIComponent(customer.email)}" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 text-sm font-medium transition-colors underline decoration-blue-300/50 hover:decoration-blue-500" onclick="event.stopPropagation()">${escapeHtml(customer.email)}</a>` : '<span class="text-neutral-400 text-sm font-medium">-</span>'} ${customer.email ? `<a href="mailto:${encodeURIComponent(customer.email)}" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 text-sm font-medium transition-colors underline decoration-blue-300/50 hover:decoration-blue-500" onclick="event.stopPropagation()">${highlightText(customer.email)}</a>` : '<span class="text-neutral-400 text-sm font-medium">-</span>'}
</td> </td>
<td class="px-4 py-4 text-right"> <td class="${pad} text-right">
<div class="flex items-center justify-end space-x-2 opacity-70 group-hover:opacity-100 transition-opacity"> <div class="flex items-center justify-end space-x-2 opacity-70 group-hover:opacity-100 transition-opacity">
<button class="view-customer-btn inline-flex items-center px-3 py-2 bg-gradient-to-r from-slate-100 to-slate-200 dark:from-slate-700 dark:to-slate-600 text-slate-700 dark:text-slate-200 hover:from-slate-200 hover:to-slate-300 dark:hover:from-slate-600 dark:hover:to-slate-500 rounded-lg text-sm font-semibold transition-all duration-200 shadow-sm hover:shadow-md border border-slate-300/50 dark:border-slate-500/50"> <button class="view-customer-btn inline-flex items-center px-3 py-2 bg-gradient-to-r from-slate-100 to-slate-200 dark:from-slate-700 dark:to-slate-600 text-slate-700 dark:text-slate-200 hover:from-slate-200 hover:to-slate-300 dark:hover:from-slate-600 dark:hover:to-slate-500 rounded-lg text-sm font-semibold transition-all duration-200 shadow-sm hover:shadow-md border border-slate-300/50 dark:border-slate-500/50">
<i class="fa-solid fa-eye mr-2"></i> <i class="fa-solid fa-eye mr-2"></i>
View View
</button> </button>
<button class="edit-customer-btn" style="display: inline-flex; align-items: center; background-color: #dc2626; color: white; padding: 8px 12px; border-radius: 6px; border: none; font-weight: 600; font-size: 14px;"> <button class="edit-customer-btn inline-flex items-center px-3 py-2 bg-primary-600 text-white hover:bg-primary-700 rounded-lg text-sm font-semibold transition-all duration-200 shadow-sm hover:shadow-md">
<i class="fa-solid fa-pencil" style="margin-right: 8px;"></i> <i class="fa-solid fa-pencil mr-2"></i>
Edit Edit
</button> </button>
</div> </div>
@@ -90,6 +125,8 @@ function displayCustomers(customers) {
tbody.appendChild(row); tbody.appendChild(row);
}); });
// No select-all
} }
// Helper functions // Helper functions
@@ -713,4 +750,132 @@ window.populateEditForm = populateEditForm;
window.populatePhoneNumbers = populatePhoneNumbers; window.populatePhoneNumbers = populatePhoneNumbers;
window.clearCustomerForm = clearCustomerForm; window.clearCustomerForm = clearCustomerForm;
window.addPhoneNumber = addPhoneNumber; window.addPhoneNumber = addPhoneNumber;
window.removePhoneNumber = removePhoneNumber; window.removePhoneNumber = removePhoneNumber;
window.updateRowSelectionClass = updateRowSelectionClass;
window.syncSelectAllCheckbox = syncSelectAllCheckbox;
window.enhanceCustomerTableRows = enhanceCustomerTableRows;
window.initializeCustomerListEnhancer = initializeCustomerListEnhancer;
// Enhance existing rows (useful for phone search results or server-rendered content)
function enhanceCustomerTableRows() {
const tbody = document.getElementById('customersTableBody');
if (!tbody) return;
// Load persisted selection
let savedSet = new Set();
try {
const saved = JSON.parse(localStorage.getItem('customers.selectedIds') || '[]');
savedSet = new Set(Array.isArray(saved) ? saved : []);
} catch (_) {}
Array.from(tbody.querySelectorAll('tr')).forEach(row => {
const id = row.dataset && row.dataset.customerId ? row.dataset.customerId : null;
if (!id) return;
let firstCell = row.children[0];
const hasCheckbox = firstCell && firstCell.querySelector && firstCell.querySelector('.customer-row-select');
if (!hasCheckbox) {
const cell = document.createElement('td');
cell.className = `px-4 ${customerCompactMode ? 'py-2' : 'py-4'} text-center align-middle`;
cell.innerHTML = `<input type="checkbox" class="customer-row-select h-4 w-4" data-id="${escapeHtml(id)}">`;
row.insertBefore(cell, row.firstChild);
firstCell = cell;
}
const checkbox = row.querySelector('.customer-row-select');
if (checkbox) {
checkbox.checked = savedSet.has(id);
updateRowSelectionClass(row, checkbox.checked);
if (!checkbox._enhanced) {
checkbox.addEventListener('change', () => {
if (checkbox.checked) {
selectedCustomerIds.add(id);
} else {
selectedCustomerIds.delete(id);
}
saveSelectedIds();
updateRowSelectionClass(row, checkbox.checked);
syncSelectAllCheckbox();
});
checkbox._enhanced = true;
}
}
});
syncSelectAllCheckbox();
}
function initializeCustomerListEnhancer() {
const tbody = document.getElementById('customersTableBody');
if (!tbody || window._customerListObserver) return;
const debouncedEnhance = (typeof window.debounce === 'function' ? window.debounce : _localDebounce)(() => enhanceCustomerTableRows(), 10);
const observer = new MutationObserver(() => debouncedEnhance());
observer.observe(tbody, { childList: true, subtree: false });
window._customerListObserver = observer;
// Initial pass
enhanceCustomerTableRows();
}
// Selection helpers
function saveSelectedIds() {
try { localStorage.setItem('customers.selectedIds', JSON.stringify(Array.from(selectedCustomerIds))); } catch (_) {}
}
function updateRowSelectionClass(row, selected) {
row.classList.toggle('bg-blue-50', selected);
row.classList.toggle('dark:bg-blue-900/30', selected);
}
function syncSelectAllCheckbox() {
const headerCb = document.getElementById('selectAllCustomers');
if (!headerCb) return;
const checkboxes = Array.from(document.querySelectorAll('#customersTableBody .customer-row-select'));
if (checkboxes.length === 0) {
headerCb.checked = false;
headerCb.indeterminate = false;
return;
}
const checkedCount = checkboxes.filter(cb => cb.checked).length;
headerCb.checked = checkedCount === checkboxes.length;
headerCb.indeterminate = checkedCount > 0 && checkedCount < checkboxes.length;
}
// Compact mode helpers
function initializeCustomerListState() {
try {
customerCompactMode = localStorage.getItem('customers.compactMode') === '1';
} catch (_) { customerCompactMode = false; }
updateCompactModeButton();
}
function toggleCompactMode() {
customerCompactMode = !customerCompactMode;
try { localStorage.setItem('customers.compactMode', customerCompactMode ? '1' : '0'); } catch (_) {}
updateCompactModeButton();
// Re-render current page with current search
loadCustomers(currentPage, currentSearch);
}
function updateCompactModeButton() {
const btn = document.getElementById('toggleCompactMode');
if (btn) {
btn.textContent = `Compact: ${customerCompactMode ? 'On' : 'Off'}`;
}
}
function onSelectAllChange(checked) {
const checkboxes = Array.from(document.querySelectorAll('#customersTableBody .customer-row-select'));
checkboxes.forEach(cb => {
cb.checked = checked;
const row = cb.closest('tr');
const id = cb.dataset.id;
if (checked) {
selectedCustomerIds.add(id);
} else {
selectedCustomerIds.delete(id);
}
updateRowSelectionClass(row, checked);
});
saveSelectedIds();
syncSelectAllCheckbox();
}
// Expose helpers
window.initializeCustomerListState = initializeCustomerListState;
window.toggleCompactMode = toggleCompactMode;
window.onSelectAllChange = onSelectAllChange;

314
static/js/flexible.js Normal file
View File

@@ -0,0 +1,314 @@
(function() {
const apiBase = '/api/flexible';
let state = {
fileType: '',
targetTable: '',
q: '',
skip: 0,
limit: 50,
total: 0,
hasKeys: [],
};
function q(id) { return document.getElementById(id); }
function formatPreviewHtml(obj, term) {
// Returns sanitized HTML with clickable keys
try {
const payload = obj && obj.unmapped && typeof obj.unmapped === 'object' ? obj.unmapped : obj;
const keys = Object.keys(payload || {}).slice(0, 5);
const segments = keys.map((k) => {
const safeKey = window.htmlSanitizer.escape(String(k));
const valueStr = String(payload[k]).slice(0, 60);
const valueHtml = term && term.trim().length > 0 ? highlight(valueStr, term) : window.htmlSanitizer.escape(valueStr);
return `<span class="kv-pair"><button type="button" class="key-link text-primary-700 dark:text-primary-400 hover:underline" data-key="${safeKey}">${safeKey}</button>: ${valueHtml}</span>`;
});
return segments.join(', ');
} catch (_) { return ''; }
}
function escapeRegExp(str) {
return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function highlight(text, term) {
if (!term) return window.htmlSanitizer.escape(text);
const pattern = new RegExp(escapeRegExp(term), 'ig');
const escaped = window.htmlSanitizer.escape(text);
// Replace on the escaped string to avoid breaking HTML
return escaped.replace(pattern, (m) => `<mark>${window.htmlSanitizer.escape(m)}</mark>`);
}
async function loadOptions() {
try {
const res = await window.http.wrappedFetch(`${apiBase}/options`);
if (!res.ok) throw await window.http.toError(res, 'Failed to load options');
const data = await res.json();
const fileSel = q('filterFileType');
const tableSel = q('filterTargetTable');
// Clear existing except first
fileSel.length = 1; tableSel.length = 1;
(data.file_types || []).forEach(v => {
const opt = document.createElement('option');
opt.value = v; opt.textContent = v; fileSel.appendChild(opt);
});
(data.target_tables || []).forEach(v => {
const opt = document.createElement('option');
opt.value = v; opt.textContent = v; tableSel.appendChild(opt);
});
} catch (e) {
alert(window.http.formatAlert(e, 'Error loading options'));
}
}
async function loadRows() {
try {
const params = new URLSearchParams();
if (state.fileType) params.set('file_type', state.fileType);
if (state.targetTable) params.set('target_table', state.targetTable);
if (state.q) params.set('q', state.q);
if (Array.isArray(state.hasKeys)) {
state.hasKeys.forEach((k) => {
if (k && String(k).trim().length > 0) params.append('has_keys', String(k).trim());
});
}
params.set('skip', String(state.skip));
params.set('limit', String(state.limit));
const res = await window.http.wrappedFetch(`${apiBase}/imports?${params.toString()}`);
if (!res.ok) throw await window.http.toError(res, 'Failed to load flexible imports');
const data = await res.json();
state.total = data.total || 0;
renderRows(data.items || []);
renderMeta();
renderKeyChips();
} catch (e) {
alert(window.http.formatAlert(e, 'Error loading flexible imports'));
}
}
function renderRows(items) {
const tbody = q('flexibleRows');
tbody.innerHTML = '';
items.forEach(item => {
const tr = document.createElement('tr');
tr.className = 'hover:bg-neutral-50 dark:hover:bg-neutral-700/40 cursor-pointer';
tr.innerHTML = `
<td class="px-3 py-2 whitespace-nowrap">${item.id}</td>
<td class="px-3 py-2 whitespace-nowrap">${window.htmlSanitizer.escape(item.file_type || '')}</td>
<td class="px-3 py-2 whitespace-nowrap">${window.htmlSanitizer.escape(item.target_table || '')}</td>
<td class="px-3 py-2 whitespace-nowrap text-xs text-neutral-500">${window.htmlSanitizer.escape((item.primary_key_field || '') + (item.primary_key_value ? '=' + item.primary_key_value : ''))}</td>
<td class="px-3 py-2 text-xs previewCell"></td>
<td class="px-3 py-2 text-right">
<button class="inline-flex items-center gap-1 text-primary-600 hover:text-primary-700 px-2 py-1 text-xs rounded-md hover:bg-primary-50 dark:hover:bg-primary-900/30" data-action="export" data-id="${item.id}">
<i class="fa-solid fa-download"></i>
<span>CSV</span>
</button>
</td>
`;
// Set sanitized highlighted preview
const previewCell = tr.querySelector('.previewCell');
const previewHtml = formatPreviewHtml(item.extra_data || {}, state.q);
window.setSafeHTML(previewCell, previewHtml);
// Bind click on keys to add filters
previewCell.querySelectorAll('.key-link').forEach((btn) => {
btn.addEventListener('click', (ev) => {
ev.stopPropagation();
const key = btn.getAttribute('data-key') || '';
addKeyFilter(key);
});
});
// Row click opens modal
tr.addEventListener('click', (ev) => {
// Ignore clicks on the export button inside the row
const target = ev.target.closest('button[data-action="export"]');
if (target) return;
openDetailModal(item);
});
// Export button handler
tr.querySelector('button[data-action="export"]').addEventListener('click', (ev) => {
ev.stopPropagation();
exportSingleRow(item.id);
});
tbody.appendChild(tr);
});
}
function renderMeta() {
const start = state.total === 0 ? 0 : state.skip + 1;
const end = Math.min(state.skip + state.limit, state.total);
q('rowsMeta').textContent = `Showing ${start}-${end} of ${state.total}`;
q('prevPageBtn').disabled = state.skip === 0;
q('nextPageBtn').disabled = state.skip + state.limit >= state.total;
}
function applyFilters() {
state.fileType = q('filterFileType').value || '';
state.targetTable = q('filterTargetTable').value || '';
state.q = (q('quickSearch').value || '').trim();
state.skip = 0;
loadRows();
}
function addKeyFilter(key) {
const k = String(key || '').trim();
if (!k) return;
if (!Array.isArray(state.hasKeys)) state.hasKeys = [];
if (!state.hasKeys.includes(k)) {
state.hasKeys.push(k);
state.skip = 0;
loadRows();
}
}
function removeKeyFilter(key) {
const k = String(key || '').trim();
if (!k) return;
state.hasKeys = (state.hasKeys || []).filter((x) => x !== k);
state.skip = 0;
loadRows();
}
function clearKeyFilters() {
if ((state.hasKeys || []).length === 0) return;
state.hasKeys = [];
state.skip = 0;
loadRows();
}
function renderKeyChips() {
const container = q('keyChipsContainer');
const chipsWrap = q('keyChips');
const clearBtn = q('clearKeyChips');
if (!container || !chipsWrap) return;
chipsWrap.innerHTML = '';
const keys = state.hasKeys || [];
if (keys.length === 0) {
container.classList.add('hidden');
} else {
container.classList.remove('hidden');
keys.forEach((k) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs bg-primary-50 text-primary-700 border border-primary-200 hover:bg-primary-100 dark:bg-primary-900/30 dark:text-primary-200 dark:border-primary-800';
btn.setAttribute('data-chip-key', k);
btn.innerHTML = `<span class="font-mono">${window.htmlSanitizer.escape(k)}</span> <i class="fa-solid fa-xmark"></i>`;
btn.addEventListener('click', (ev) => {
ev.stopPropagation();
removeKeyFilter(k);
});
chipsWrap.appendChild(btn);
});
}
if (clearBtn) {
clearBtn.onclick = (ev) => { ev.preventDefault(); clearKeyFilters(); };
}
}
async function exportCsv() {
try {
const params = new URLSearchParams();
if (state.fileType) params.set('file_type', state.fileType);
if (state.targetTable) params.set('target_table', state.targetTable);
if (Array.isArray(state.hasKeys)) {
state.hasKeys.forEach((k) => {
if (k && String(k).trim().length > 0) params.append('has_keys', String(k).trim());
});
}
const url = `${apiBase}/export?${params.toString()}`;
const res = await window.http.wrappedFetch(url);
if (!res.ok) throw await window.http.toError(res, 'Export failed');
const blob = await res.blob();
const a = document.createElement('a');
const objectUrl = URL.createObjectURL(blob);
a.href = objectUrl;
a.download = 'flexible_unmapped.csv';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
} catch (e) {
alert(window.http.formatAlert(e, 'Error exporting CSV'));
}
}
async function exportSingleRow(rowId) {
try {
const res = await window.http.wrappedFetch(`${apiBase}/export/${rowId}`);
if (!res.ok) throw await window.http.toError(res, 'Export failed');
const blob = await res.blob();
const a = document.createElement('a');
const objectUrl = URL.createObjectURL(blob);
a.href = objectUrl;
a.download = `flexible_row_${rowId}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
} catch (e) {
alert(window.http.formatAlert(e, 'Error exporting row CSV'));
}
}
function openDetailModal(item) {
// Populate fields
q('detailRowId').textContent = `#${item.id}`;
q('detailFileType').textContent = item.file_type || '';
q('detailTargetTable').textContent = item.target_table || '';
q('detailPkField').textContent = item.primary_key_field || '';
q('detailPkValue').textContent = item.primary_key_value || '';
try {
const pretty = JSON.stringify(item.extra_data || {}, null, 2);
q('detailJson').textContent = pretty;
} catch (_) {
q('detailJson').textContent = '';
}
const exportBtn = q('detailExportBtn');
exportBtn.onclick = () => exportSingleRow(item.id);
openModal('flexibleDetailModal');
}
function bindEvents() {
q('applyFiltersBtn').addEventListener('click', applyFilters);
q('exportCsvBtn').addEventListener('click', exportCsv);
const clearBtn = q('clearKeyChips');
if (clearBtn) clearBtn.addEventListener('click', (ev) => { ev.preventDefault(); clearKeyFilters(); });
// Quick search with debounce
const searchInput = q('quickSearch');
let searchTimer = null;
searchInput.addEventListener('input', () => {
const value = searchInput.value || '';
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
state.q = value.trim();
state.skip = 0;
loadRows();
}, 300);
});
searchInput.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') {
ev.preventDefault();
clearTimeout(searchTimer);
state.q = (searchInput.value || '').trim();
state.skip = 0;
loadRows();
}
});
q('prevPageBtn').addEventListener('click', () => {
state.skip = Math.max(0, state.skip - state.limit);
loadRows();
});
q('nextPageBtn').addEventListener('click', () => {
if (state.skip + state.limit < state.total) {
state.skip += state.limit;
loadRows();
}
});
}
document.addEventListener('DOMContentLoaded', () => {
bindEvents();
loadOptions().then(loadRows);
});
})();

95
static/js/highlight.js Normal file
View File

@@ -0,0 +1,95 @@
(function(){
function buildTokens(rawQuery) {
const q = (rawQuery || '').trim();
if (!q) return [];
// Normalize punctuation to spaces, trim non-alphanumerics at ends, dedupe
const tokens = q
.replace(/[,_;:]+/g, ' ')
.split(/\s+/)
.map(t => t.replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/g, ''))
.filter(Boolean);
return Array.from(new Set(tokens));
}
function escapeHtml(text) {
try {
return (window.htmlSanitizer && window.htmlSanitizer.escape)
? window.htmlSanitizer.escape(text)
: String(text == null ? '' : text);
} catch (_) {
return String(text == null ? '' : text);
}
}
function highlight(text, tokens) {
const value = text == null ? '' : String(text);
if (!value || !Array.isArray(tokens) || tokens.length === 0) return escapeHtml(value);
try {
const source = String(value);
const haystack = source.toLowerCase();
const uniqueTokens = Array.from(new Set((tokens || []).map(t => String(t).toLowerCase()).filter(Boolean)));
const ranges = [];
uniqueTokens.forEach(t => {
let from = 0;
while (from <= haystack.length - t.length && t.length > 0) {
const idx = haystack.indexOf(t, from);
if (idx === -1) break;
ranges.push([idx, idx + t.length]);
from = idx + 1; // allow overlapping matches shift by 1
}
});
if (ranges.length === 0) return escapeHtml(source);
// Merge overlapping/adjacent ranges
ranges.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const merged = [];
let [curStart, curEnd] = ranges[0];
for (let i = 1; i < ranges.length; i++) {
const [s, e] = ranges[i];
if (s <= curEnd) {
// overlap or adjacency
curEnd = Math.max(curEnd, e);
} else {
merged.push([curStart, curEnd]);
[curStart, curEnd] = [s, e];
}
}
merged.push([curStart, curEnd]);
// Build output with escaping of text segments
let out = '';
let pos = 0;
merged.forEach(([s, e]) => {
if (pos < s) out += escapeHtml(source.slice(pos, s));
out += '<strong>' + escapeHtml(source.slice(s, e)) + '</strong>';
pos = e;
});
if (pos < source.length) out += escapeHtml(source.slice(pos));
return out;
} catch (_) {
return escapeHtml(String(text));
}
}
function formatSnippet(snippet, tokens) {
if (!snippet) return '';
let html = String(snippet);
try {
const hasStrong = /<\s*strong\b/i.test(html);
if (!hasStrong) {
html = highlight(html, Array.isArray(tokens) ? tokens : []);
}
if (window.htmlSanitizer && typeof window.htmlSanitizer.sanitize === 'function') {
html = window.htmlSanitizer.sanitize(html);
}
} catch (_) {}
return html;
}
window.highlightUtils = {
buildTokens,
highlight,
escape: escapeHtml,
formatSnippet
};
})();

View File

@@ -55,6 +55,10 @@ function handleKeyboardShortcuts(event) {
event.preventDefault(); event.preventDefault();
navigateTo('/documents'); navigateTo('/documents');
break; break;
case 'Alt+I':
event.preventDefault();
navigateTo('/import');
break;
case 'Alt+A': case 'Alt+A':
event.preventDefault(); event.preventDefault();
navigateTo('/admin'); navigateTo('/admin');

View File

@@ -277,7 +277,7 @@ function setAuthToken(token) {
// Page helpers // Page helpers
function isLoginPage() { function isLoginPage() {
const path = window.location.pathname; const path = window.location.pathname;
return path === '/login' || path === '/'; return path === '/login';
} }
// Verify the current access token by hitting /api/auth/me // Verify the current access token by hitting /api/auth/me
@@ -329,6 +329,14 @@ async function checkUserPermissions() {
const adminDivider = document.getElementById('admin-menu-divider'); const adminDivider = document.getElementById('admin-menu-divider');
if (adminItem) adminItem.classList.remove('hidden'); if (adminItem) adminItem.classList.remove('hidden');
if (adminDivider) adminDivider.classList.remove('hidden'); if (adminDivider) adminDivider.classList.remove('hidden');
const importDesktop = document.getElementById('nav-import-desktop');
const importMobile = document.getElementById('nav-import-mobile');
if (importDesktop) importDesktop.classList.remove('hidden');
if (importMobile) importMobile.classList.remove('hidden');
const flexibleDesktop = document.getElementById('nav-flexible-desktop');
const flexibleMobile = document.getElementById('nav-flexible-mobile');
if (flexibleDesktop) flexibleDesktop.classList.remove('hidden');
if (flexibleMobile) flexibleMobile.classList.remove('hidden');
} }
const userDropdownName = document.querySelector('#userDropdown button span'); const userDropdownName = document.querySelector('#userDropdown button span');
if (user.full_name && userDropdownName) { if (user.full_name && userDropdownName) {
@@ -555,6 +563,8 @@ function initializeDataTable(tableId, options = {}) {
const headers = table.querySelectorAll('th[data-sort]'); const headers = table.querySelectorAll('th[data-sort]');
headers.forEach(header => { headers.forEach(header => {
header.classList.add('sortable-header'); header.classList.add('sortable-header');
header.classList.add('cursor-pointer');
header.classList.add('select-none');
header.addEventListener('click', () => sortTable(table, header)); header.addEventListener('click', () => sortTable(table, header));
}); });
@@ -577,10 +587,16 @@ function sortTable(table, header) {
// Remove sort classes from all headers // Remove sort classes from all headers
table.querySelectorAll('th').forEach(th => { table.querySelectorAll('th').forEach(th => {
th.classList.remove('sort-asc', 'sort-desc'); th.classList.remove('sort-asc', 'sort-desc');
const indicator = th.querySelector('.sort-indicator');
if (indicator) indicator.remove();
}); });
// Add sort class to current header // Add sort class to current header
header.classList.add(isAscending ? 'sort-asc' : 'sort-desc'); header.classList.add(isAscending ? 'sort-asc' : 'sort-desc');
const indicator = document.createElement('span');
indicator.className = 'sort-indicator ml-1 text-neutral-400';
indicator.textContent = isAscending ? '▲' : '▼';
header.appendChild(indicator);
rows.sort((a, b) => { rows.sort((a, b) => {
const aValue = a.children[columnIndex].textContent.trim(); const aValue = a.children[columnIndex].textContent.trim();
@@ -669,7 +685,7 @@ function initializeSearch(searchInput, resultsContainer, searchFunction) {
try { try {
showLoading(resultsContainer, 'Searching...'); showLoading(resultsContainer, 'Searching...');
const results = await searchFunction(query); const results = await searchFunction(query);
displaySearchResults(resultsContainer, results); displaySearchResults(resultsContainer, results, query);
} catch (error) { } catch (error) {
resultsContainer.innerHTML = '<p class="text-danger">Search failed</p>'; resultsContainer.innerHTML = '<p class="text-danger">Search failed</p>';
} }
@@ -677,18 +693,21 @@ function initializeSearch(searchInput, resultsContainer, searchFunction) {
}); });
} }
function displaySearchResults(container, results) { function displaySearchResults(container, results, query = '') {
if (!results || results.length === 0) { if (!results || results.length === 0) {
container.innerHTML = '<p class="text-neutral-500">No results found</p>'; container.innerHTML = '<p class="text-neutral-500">No results found</p>';
return; return;
} }
const tokens = (window.highlightUtils && typeof window.highlightUtils.buildTokens === 'function')
? window.highlightUtils.buildTokens(query)
: [];
const resultsHtmlRaw = results.map(result => ` const resultsHtmlRaw = results.map(result => `
<div class="search-result p-2 border-bottom"> <div class="search-result p-2 border-bottom">
<div class="flex justify-between"> <div class="flex justify-between">
<div> <div>
<strong>${result.title}</strong> <strong>${window.highlightUtils ? window.highlightUtils.highlight(result.title || '', tokens) : (result.title || '')}</strong>
<small class="text-neutral-500 block">${result.description}</small> <small class="text-neutral-500 block">${window.highlightUtils ? window.highlightUtils.highlight(result.description || '', tokens) : (result.description || '')}</small>
</div> </div>
<span class="inline-block px-2 py-0.5 text-xs rounded bg-neutral-200 text-neutral-700">${result.type}</span> <span class="inline-block px-2 py-0.5 text-xs rounded bg-neutral-200 text-neutral-700">${result.type}</span>
</div> </div>

View File

@@ -64,14 +64,24 @@
return window.DOMPurify.sanitize(dirty); return window.DOMPurify.sanitize(dirty);
} }
// Trigger async load so the next call benefits // Trigger async load so the next call benefits
ensureDOMPurifyLoaded().catch(() => {}); try {
const loader = (window && window.htmlSanitizer && typeof window.htmlSanitizer.ensureDOMPurifyLoaded === 'function')
? window.htmlSanitizer.ensureDOMPurifyLoaded
: ensureDOMPurifyLoaded;
loader().catch(() => {});
} catch (_) {}
return fallbackSanitize(dirty); return fallbackSanitize(dirty);
} }
function escapeHtml(text) { function escapeHtml(text) {
const span = document.createElement('span'); // Encode &, <, >, ", and '
span.textContent = String(text == null ? '' : text); const str = String(text == null ? '' : text);
return span.innerHTML; return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
} }
function setSafeHTML(element, html) { function setSafeHTML(element, html) {

View File

@@ -0,0 +1,46 @@
(function() {
/**
* uploadWithAlerts
* Small helper to perform a fetch for uploads and, on failure, show a UI alert with
* a correlation reference from the server's error envelope. It uses window.http.toError
* and window.http.formatAlert to produce a user-facing message.
*
* Usage:
* const formData = new FormData();
* formData.append('file', input.files[0]);
* const respJson = await uploadWithAlerts(`/api/documents/upload/${fileNo}`, formData);
* // respJson is the parsed JSON on success, otherwise an Error is thrown after alerting
*
* The alert includes "Ref: <correlation-id>" when available.
*/
async function uploadWithAlerts(url, formData, { method = 'POST', extraOptions = {}, alertTitle = 'Upload failed' } = {}) {
const options = {
method,
body: formData,
...extraOptions,
};
const response = await window.http.wrappedFetch(url, options);
if (!response.ok) {
const err = await window.http.toError(response, alertTitle);
const msg = window.http.formatAlert(err, alertTitle);
if (window.alerts && typeof window.alerts.error === 'function') {
window.alerts.error(msg, { html: true, duration: 0 });
} else if (window.showNotification) {
window.showNotification(msg, 'error', 8000);
} else {
alert(String(msg));
}
throw err;
}
try {
return await response.json();
} catch (_) {
return null;
}
}
// Expose globally for pages to use
window.uploadWithAlerts = uploadWithAlerts;
})();

View File

@@ -141,15 +141,15 @@
<i class="fa-solid fa-file-import"></i> <i class="fa-solid fa-file-import"></i>
<span>Import</span> <span>Import</span>
</button> </button>
<button class="flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 border-transparent hover:border-primary-300 text-neutral-600 dark:text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-neutral-50 dark:hover:bg-neutral-700/50 transition-all duration-200" id="backup-tab" data-tab-target="#backup" type="button" role="tab">
<i class="fa-solid fa-shield-halved"></i>
<span>Backup</span>
</button>
<button class="flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 border-transparent hover:border-primary-300 text-neutral-600 dark:text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-neutral-50 dark:hover:bg-neutral-700/50 transition-all duration-200" id="issues-tab" data-tab-target="#issues" type="button" role="tab"> <button class="flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 border-transparent hover:border-primary-300 text-neutral-600 dark:text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-neutral-50 dark:hover:bg-neutral-700/50 transition-all duration-200" id="issues-tab" data-tab-target="#issues" type="button" role="tab">
<i class="fa-solid fa-bug"></i> <i class="fa-solid fa-bug"></i>
<span>Issues</span> <span>Issues</span>
<span class="ml-1 px-2 py-0.5 bg-red-100 dark:bg-red-800 text-red-700 dark:text-red-400 text-xs rounded-full hidden" id="issues-badge">0</span> <span class="ml-1 px-2 py-0.5 bg-red-100 dark:bg-red-800 text-red-700 dark:text-red-400 text-xs rounded-full hidden" id="issues-badge">0</span>
</button> </button>
<button class="flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 border-transparent hover:border-primary-300 text-neutral-600 dark:text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-neutral-50 dark:hover:bg-neutral-700/50 transition-all duration-200" id="backup-tab" data-tab-target="#backup" type="button" role="tab">
<i class="fa-solid fa-shield-halved"></i>
<span>Backup</span>
</button>
</nav> </nav>
<!-- Tab Content --> <!-- Tab Content -->
@@ -2087,36 +2087,54 @@ async function viewIssue(issueId) {
}[issue.status] || 'bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300'); }[issue.status] || 'bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300');
// Update context info // Update context info
document.getElementById('issueCurrentPage').textContent = issue.current_page || 'Unknown'; document.getElementById('issueDetailPage').textContent = issue.current_page || 'Unknown';
document.getElementById('issueBrowserInfo').textContent = issue.browser_info || 'Unknown'; document.getElementById('issueDetailBrowser').textContent = issue.browser_info || 'Unknown';
document.getElementById('issueIpAddress').textContent = issue.ip_address || 'Unknown';
// Update sidebar info // Update sidebar info (only if elements exist)
document.getElementById('issueDetailReporter').textContent = issue.contact_name; const reporterEl = document.getElementById('issueDetailReporter');
document.getElementById('issueDetailEmail').textContent = issue.contact_email; if (reporterEl) reporterEl.textContent = issue.contact_name;
document.getElementById('issueDetailCreated').textContent = new Date(issue.created_at).toLocaleString();
document.getElementById('issueDetailUpdated').textContent = issue.updated_at ? new Date(issue.updated_at).toLocaleString() : 'Never'; const emailEl = document.getElementById('issueDetailEmail');
if (emailEl) emailEl.textContent = issue.contact_email;
const createdEl = document.getElementById('issueDetailCreated');
if (createdEl) createdEl.textContent = new Date(issue.created_at).toLocaleString();
const updatedEl = document.getElementById('issueDetailUpdated');
if (updatedEl) updatedEl.textContent = issue.updated_at ? new Date(issue.updated_at).toLocaleString() : 'Never';
if (issue.resolved_at) { if (issue.resolved_at) {
document.getElementById('issueDetailResolved').textContent = new Date(issue.resolved_at).toLocaleString(); const resolvedEl = document.getElementById('issueDetailResolved');
document.getElementById('issueResolvedInfo').style.display = 'block'; if (resolvedEl) resolvedEl.textContent = new Date(issue.resolved_at).toLocaleString();
const resolvedInfoEl = document.getElementById('issueResolvedInfo');
if (resolvedInfoEl) resolvedInfoEl.style.display = 'block';
} else { } else {
document.getElementById('issueResolvedInfo').style.display = 'none'; const resolvedInfoEl = document.getElementById('issueResolvedInfo');
if (resolvedInfoEl) resolvedInfoEl.style.display = 'none';
} }
// Update form fields for editing // Update form fields for editing (only if elements exist)
document.getElementById('updateStatus').value = issue.status; const statusEl = document.getElementById('updateStatus');
document.getElementById('updatePriority').value = issue.priority; if (statusEl) statusEl.value = issue.status;
document.getElementById('updateAssignee').value = issue.assigned_to || '';
const priorityEl = document.getElementById('updatePriority');
if (priorityEl) priorityEl.value = issue.priority;
const assigneeEl = document.getElementById('updateAssignee');
if (assigneeEl) assigneeEl.value = issue.assigned_to || '';
// Store current issue ID for updates // Store current issue ID for updates
window.currentIssueId = issue.id; window.currentIssueId = issue.id;
// Load users for assignment dropdown // Load users for assignment dropdown (if function exists)
await loadUsersForAssignment(); if (typeof loadUsersForAssignment === 'function') {
await loadUsersForAssignment();
}
// Load and display responses // Load and display responses (if function exists)
displayIssueResponses(issue.responses); if (typeof displayIssueResponses === 'function') {
displayIssueResponses(issue.responses);
}
// Show modal // Show modal
openModal('issueDetailModal'); openModal('issueDetailModal');

View File

@@ -51,6 +51,14 @@
<i class="fa-solid fa-magnifying-glass"></i> <i class="fa-solid fa-magnifying-glass"></i>
<span>Search</span> <span>Search</span>
</a> </a>
<a id="nav-import-desktop" href="/import" data-shortcut="Alt+I" class="hidden flex items-center gap-2 px-3 py-2 rounded-lg text-primary-100 hover:text-white hover:bg-primary-700 transition-all duration-200">
<i class="fa-solid fa-cloud-arrow-up"></i>
<span>Import</span>
</a>
<a id="nav-flexible-desktop" href="/flexible" class="hidden flex items-center gap-2 px-3 py-2 rounded-lg text-primary-100 hover:text-white hover:bg-primary-700 transition-all duration-200">
<i class="fa-solid fa-table-columns"></i>
<span>Flexible</span>
</a>
</div> </div>
<!-- Right side items --> <!-- Right side items -->
@@ -113,6 +121,14 @@
<i class="fa-solid fa-magnifying-glass"></i> <i class="fa-solid fa-magnifying-glass"></i>
<span>Search</span> <span>Search</span>
</a> </a>
<a id="nav-import-mobile" href="/import" class="hidden flex items-center gap-3 px-3 py-2 rounded-lg text-primary-100 hover:text-white hover:bg-primary-700 transition-all duration-200">
<i class="fa-solid fa-cloud-arrow-up"></i>
<span>Import</span>
</a>
<a id="nav-flexible-mobile" href="/flexible" class="hidden flex items-center gap-3 px-3 py-2 rounded-lg text-primary-100 hover:text-white hover:bg-primary-700 transition-all duration-200">
<i class="fa-solid fa-table-columns"></i>
<span>Flexible</span>
</a>
</div> </div>
</div> </div>
</div> </div>
@@ -190,6 +206,10 @@
<span class="text-neutral-600 dark:text-neutral-400">Documents/QDROs</span> <span class="text-neutral-600 dark:text-neutral-400">Documents/QDROs</span>
<kbd class="px-2 py-1 bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded text-xs font-mono">Alt+D</kbd> <kbd class="px-2 py-1 bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded text-xs font-mono">Alt+D</kbd>
</li> </li>
<li class="flex items-center justify-between">
<span class="text-neutral-600 dark:text-neutral-400">Data Import</span>
<kbd class="px-2 py-1 bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded text-xs font-mono">Alt+I</kbd>
</li>
<li class="flex items-center justify-between"> <li class="flex items-center justify-between">
<span class="text-neutral-600 dark:text-neutral-400">Admin Panel</span> <span class="text-neutral-600 dark:text-neutral-400">Admin Panel</span>
<kbd class="px-2 py-1 bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded text-xs font-mono">Alt+A</kbd> <kbd class="px-2 py-1 bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded text-xs font-mono">Alt+A</kbd>
@@ -359,15 +379,29 @@
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.classList.add('hidden'); if (el) el.classList.add('hidden');
} }
// Lightweight shake animation utility class
(function(){
const styleId = 'inline-shake-style';
if (!document.getElementById(styleId)) {
const css = '@keyframes _shake_kf{0%,100%{transform:translateX(0)}20%{transform:translateX(-4px)}40%{transform:translateX(4px)}60%{transform:translateX(-3px)}80%{transform:translateX(3px)}}.animate-shake{animation:_shake_kf .4s ease-in-out;}';
const tag = document.createElement('style');
tag.id = styleId;
tag.type = 'text/css';
tag.appendChild(document.createTextNode(css));
document.head.appendChild(tag);
}
})();
</script> </script>
<!-- Custom JavaScript --> <!-- Custom JavaScript -->
<!-- Fetch wrapper should be loaded early. It exposes window.http.wrappedFetch and also wraps global fetch for compatibility. --> <!-- Fetch wrapper should be loaded early. It exposes window.http.wrappedFetch and also wraps global fetch for compatibility. -->
<script src="/static/js/fetch-wrapper.js"></script> <script src="/static/js/fetch-wrapper.js"></script>
<script src="/static/js/sanitizer.js"></script> <script src="/static/js/sanitizer.js"></script>
<script src="/static/js/highlight.js"></script>
<!-- Load main.js first so global handlers are registered before other scripts --> <!-- Load main.js first so global handlers are registered before other scripts -->
<script src="/static/js/main.js"></script> <script src="/static/js/main.js"></script>
<script src="/static/js/alerts.js"></script> <script src="/static/js/alerts.js"></script>
<script src="/static/js/upload-helper.js"></script>
<script src="/static/js/keyboard-shortcuts.js"></script> <script src="/static/js/keyboard-shortcuts.js"></script>
{% block extra_scripts %}{% endblock %} {% block extra_scripts %}{% endblock %}
@@ -391,7 +425,8 @@
'/documents': 'Document Management', '/documents': 'Document Management',
'/import': 'Data Import', '/import': 'Data Import',
'/search': 'Advanced Search', '/search': 'Advanced Search',
'/admin': 'System Administration' '/admin': 'System Administration',
'/flexible': 'Flexible Imports'
}; };
const currentPage = pageNames[path] || `Page: ${path}`; const currentPage = pageNames[path] || `Page: ${path}`;

View File

@@ -3,7 +3,7 @@
{% block title %}Customers (Rolodex) - Delphi Database{% endblock %} {% block title %}Customers (Rolodex) - Delphi Database{% endblock %}
{% block content %} {% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6"> <div class="space-y-6">
<!-- Page Header --> <!-- Page Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -27,6 +27,12 @@
<!-- Search and Filter Panel --> <!-- Search and Filter Panel -->
<div class="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-soft"> <div class="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-soft">
<div class="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700">
<h5 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
<i class="fa-solid fa-filter"></i>
<span>Search & Filters</span>
</h5>
</div>
<div class="p-6"> <div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="lg:col-span-2"> <div class="lg:col-span-2">
@@ -42,16 +48,20 @@
</div> </div>
</div> </div>
<div> <div>
<label for="groupFilter" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Group Filter</label> <label for="groupFilter" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Groups</label>
<select id="groupFilter" class="w-full px-3 py-3 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all duration-200"> <select id="groupFilter" multiple class="w-full px-3 py-3 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all duration-200"></select>
<option value="">All Groups</option> <p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">Select one or more groups</p>
</select>
</div> </div>
<div> <div>
<label for="stateFilter" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">State Filter</label> <label for="stateFilter" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">States</label>
<select id="stateFilter" class="w-full px-3 py-3 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all duration-200"> <select id="stateFilter" multiple class="w-full px-3 py-3 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all duration-200"></select>
<option value="">All States</option> <p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">Select one or more states</p>
</select> </div>
</div>
<div class="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<div class="flex items-center justify-between">
<div id="activeFilterChips" class="flex flex-wrap gap-2"></div>
<button id="clearAllFiltersBtn" class="hidden px-3 py-1.5 text-sm rounded-lg bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-200 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors">Clear all</button>
</div> </div>
</div> </div>
<div class="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700"> <div class="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
@@ -73,17 +83,72 @@
<!-- Customer List --> <!-- Customer List -->
<div class="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-soft overflow-hidden"> <div class="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-soft overflow-hidden">
<div class="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700 flex items-center justify-between">
<h5 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
<i class="fa-solid fa-users"></i>
<span>Customer List</span>
</h5>
<div class="flex items-center gap-3">
<label for="pageSizeSelect" class="text-xs text-neutral-600 dark:text-neutral-300">Page size</label>
<select id="pageSizeSelect" class="px-2 py-1.5 text-sm rounded-lg bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-200 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors">
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="200">200</option>
</select>
<button id="toggleCompactMode" class="px-3 py-1.5 text-sm rounded-lg bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-200 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors" title="Toggle compact mode">
Compact: Off
</button>
<button id="copyViewLinkBtn" class="px-3 py-1.5 text-sm rounded-lg bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-200 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors" title="Copy link to this view">
<i class="fa-solid fa-link mr-1"></i>
Copy link
</button>
<button id="exportCsvBtn" class="px-3 py-1.5 text-sm rounded-lg bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-200 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors" title="Export current view to CSV">
<i class="fa-solid fa-file-csv mr-1"></i>
Export CSV
</button>
<span id="exportPreview" class="text-xs text-neutral-600 dark:text-neutral-300" title="Export preview: current page vs all matches"></span>
<div class="relative inline-block" id="exportColumnsWrapper">
<button id="selectColumnsBtn" class="px-3 py-1.5 text-sm rounded-lg bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-200 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors" title="Select CSV columns">
<i class="fa-solid fa-table-columns mr-1"></i>
Columns
</button>
<div id="columnsPopover" class="hidden absolute right-0 mt-2 w-64 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-lg p-3 z-20">
<div class="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase mb-2">Export Columns</div>
<div class="space-y-2 text-sm">
<label class="flex items-center gap-2"><input type="checkbox" class="export-col" value="id" checked> <span>Customer ID</span></label>
<label class="flex items-center gap-2"><input type="checkbox" class="export-col" value="name" checked> <span>Name</span></label>
<label class="flex items-center gap-2"><input type="checkbox" class="export-col" value="group" checked> <span>Group</span></label>
<label class="flex items-center gap-2"><input type="checkbox" class="export-col" value="city" checked> <span>City</span></label>
<label class="flex items-center gap-2"><input type="checkbox" class="export-col" value="state" checked> <span>State</span></label>
<label class="flex items-center gap-2"><input type="checkbox" class="export-col" value="phone" checked> <span>Primary Phone</span></label>
<label class="flex items-center gap-2"><input type="checkbox" class="export-col" value="email" checked> <span>Email</span></label>
</div>
<div class="mt-3 border-t border-neutral-200 dark:border-neutral-700 pt-3">
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" id="exportAllToggle">
<span>Export all matches (ignore pagination)</span>
</label>
</div>
<div class="mt-3 flex items-center justify-between">
<button id="columnsSelectAll" class="text-xs text-blue-600 dark:text-blue-400 hover:underline">Select all</button>
<button id="columnsClearAll" class="text-xs text-neutral-600 dark:text-neutral-300 hover:underline">Clear</button>
</div>
</div>
</div>
</div>
</div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-sm text-left text-neutral-900 dark:text-neutral-100" id="customersTable"> <table class="w-full text-sm text-left text-neutral-900 dark:text-neutral-100" id="customersTable">
<thead class="bg-neutral-50 dark:bg-neutral-800/50"> <thead class="bg-neutral-50 dark:bg-neutral-800/60">
<tr class="border-b border-neutral-200 dark:border-neutral-700"> <tr class="border-b border-neutral-200 dark:border-neutral-700">
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Customer</th> <th id="thCustomer" data-sort="text" data-sort-field="id" class="sticky top-0 z-10 px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider bg-neutral-50 dark:bg-neutral-800/60 cursor-pointer select-none">Customer</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Name</th> <th id="thName" data-sort="text" data-sort-field="name" class="sticky top-0 z-10 px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider bg-neutral-50 dark:bg-neutral-800/60 cursor-pointer select-none">Name</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Group</th> <th data-sort="text" class="sticky top-0 z-10 px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider bg-neutral-50 dark:bg-neutral-800/60 select-none">Group</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Location</th> <th id="thCity" data-sort="text" data-sort-field="city" class="sticky top-0 z-10 px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider bg-neutral-50 dark:bg-neutral-800/60 cursor-pointer select-none">Location</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Phone</th> <th data-sort="text" class="sticky top-0 z-10 px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider bg-neutral-50 dark:bg-neutral-800/60 select-none">Phone</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Email</th> <th id="thEmail" data-sort="text" data-sort-field="email" class="sticky top-0 z-10 px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider bg-neutral-50 dark:bg-neutral-800/60 cursor-pointer select-none">Email</th>
<th class="px-4 py-3 text-right text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Actions</th> <th class="sticky top-0 z-10 px-6 py-3 text-right text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider bg-neutral-50 dark:bg-neutral-800/60">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody id="customersTableBody" class="divide-y divide-neutral-200 dark:divide-neutral-700"> <tbody id="customersTableBody" class="divide-y divide-neutral-200 dark:divide-neutral-700">
@@ -290,10 +355,50 @@ document.addEventListener('DOMContentLoaded', function() {
return; return;
} }
if (window.initializeCustomerListState) { window.initializeCustomerListState(); }
// Initialize page size from storage before first load
try {
const savedSize = parseInt(localStorage.getItem('customers.pageSize') || '50', 10);
window.customerPageSize = [25, 50, 100, 200].includes(savedSize) ? savedSize : 50;
} catch (_) {
window.customerPageSize = 50;
}
// Load saved sort state
try {
window.currentSortBy = localStorage.getItem('customers.sortBy') || 'id';
window.currentSortDir = localStorage.getItem('customers.sortDir') || 'asc';
} catch (_) {
window.currentSortBy = 'id';
window.currentSortDir = 'asc';
}
updateSortIndicators();
// Load saved filters
try {
const savedGroups = localStorage.getItem('customers.filterGroups');
const savedStates = localStorage.getItem('customers.filterStates');
const singleGroup = localStorage.getItem('customers.filterGroup') || '';
const singleState = localStorage.getItem('customers.filterState') || '';
window.currentGroupFilters = savedGroups ? JSON.parse(savedGroups) : (singleGroup ? [singleGroup] : []);
window.currentStateFilters = savedStates ? JSON.parse(savedStates) : (singleState ? [singleState] : []);
if (!Array.isArray(window.currentGroupFilters)) window.currentGroupFilters = [];
if (!Array.isArray(window.currentStateFilters)) window.currentStateFilters = [];
} catch (_) {
window.currentGroupFilters = [];
window.currentStateFilters = [];
}
renderActiveFilterChips();
loadCustomers(); loadCustomers();
loadGroups(); loadGroups();
loadStates(); loadStates();
setupEventListeners(); setupEventListeners();
try { if (window.initializeDataTable) { window.initializeDataTable('customersTable'); } } catch (_) {}
const compactBtn = document.getElementById('toggleCompactMode');
if (compactBtn && window.toggleCompactMode) {
compactBtn.addEventListener('click', window.toggleCompactMode);
}
// Initialize page size selector value
const sizeSel = document.getElementById('pageSizeSelect');
if (sizeSel) { sizeSel.value = String(window.customerPageSize); }
}); });
function setupEventListeners() { function setupEventListeners() {
@@ -354,6 +459,196 @@ function setupEventListeners() {
if (customerIdInput) { if (customerIdInput) {
customerIdInput.addEventListener('blur', validateCustomerId); customerIdInput.addEventListener('blur', validateCustomerId);
} }
// Page size selector
const pageSizeSelect = document.getElementById('pageSizeSelect');
if (pageSizeSelect) {
pageSizeSelect.addEventListener('change', function() {
const newSize = parseInt(this.value, 10);
if ([25, 50, 100, 200].includes(newSize)) {
try { localStorage.setItem('customers.pageSize', String(newSize)); } catch (_) {}
window.customerPageSize = newSize;
currentPage = 0;
loadCustomers(currentPage, currentSearch);
}
});
}
// Copy view link button
const copyBtn = document.getElementById('copyViewLinkBtn');
if (copyBtn) {
copyBtn.addEventListener('click', async function() {
const url = typeof buildViewUrl === 'function' ? buildViewUrl() : window.location.href;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(url);
if (window.alerts && window.alerts.show) { window.alerts.show('Link copied', 'success'); }
} else {
throw new Error('Clipboard API not available');
}
} catch (e) {
prompt('Copy this link:', url);
}
});
}
// Export CSV button
const exportBtn = document.getElementById('exportCsvBtn');
if (exportBtn) {
exportBtn.addEventListener('click', function() {
// Build URL for export endpoint using current state
const u = new URL(window.location.origin + '/api/customers/export');
const p = u.searchParams;
p.set('skip', String(currentPage * (window.customerPageSize || 50)));
p.set('limit', String(window.customerPageSize || 50));
const q = (document.getElementById('searchInput')?.value || '').trim();
if (q) p.set('search', q);
const by = window.currentSortBy || 'id';
const dir = window.currentSortDir || 'asc';
p.set('sort_by', by);
p.set('sort_dir', dir);
(Array.isArray(window.currentGroupFilters) ? window.currentGroupFilters : []).forEach(v => p.append('groups', v));
(Array.isArray(window.currentStateFilters) ? window.currentStateFilters : []).forEach(v => p.append('states', v));
// Selected columns
const cols = Array.from(document.querySelectorAll('#columnsPopover .export-col'))
.filter(cb => cb.checked)
.map(cb => cb.value);
cols.forEach(f => p.append('fields', f));
// Export all toggle
const exportAll = document.getElementById('exportAllToggle');
const shouldExportAll = exportAll && exportAll.checked;
if (shouldExportAll) p.set('export_all', '1'); else p.delete('export_all');
// Trigger download
window.location.href = u.toString();
});
}
// Columns popover
const selectColumnsBtn = document.getElementById('selectColumnsBtn');
const columnsPopover = document.getElementById('columnsPopover');
if (selectColumnsBtn && columnsPopover) {
selectColumnsBtn.addEventListener('click', function(e) {
e.stopPropagation();
columnsPopover.classList.toggle('hidden');
});
document.addEventListener('click', function() {
columnsPopover.classList.add('hidden');
});
columnsPopover.addEventListener('click', function(e) { e.stopPropagation(); });
const selAll = document.getElementById('columnsSelectAll');
const clrAll = document.getElementById('columnsClearAll');
if (selAll) selAll.addEventListener('click', function(e) {
e.preventDefault();
Array.from(columnsPopover.querySelectorAll('.export-col')).forEach(cb => cb.checked = true);
});
if (clrAll) clrAll.addEventListener('click', function(e) {
e.preventDefault();
Array.from(columnsPopover.querySelectorAll('.export-col')).forEach(cb => cb.checked = false);
});
// Persist selection
try {
const saved = JSON.parse(localStorage.getItem('customers.exportFields') || '[]');
if (Array.isArray(saved) && saved.length) {
const set = new Set(saved);
Array.from(columnsPopover.querySelectorAll('.export-col')).forEach(cb => {
cb.checked = set.has(cb.value);
});
}
} catch (_) {}
// Persist export all toggle
try {
const savedAll = localStorage.getItem('customers.exportAll') === '1';
const toggle = document.getElementById('exportAllToggle');
if (toggle) {
toggle.checked = savedAll;
try { updateExportPreview(window.lastCustomersTotal || 0, window.lastCustomersPageCount || 0); } catch (_) {}
}
} catch (_) {}
columnsPopover.querySelectorAll('.export-col').forEach(cb => {
cb.addEventListener('change', function() {
const cols = Array.from(columnsPopover.querySelectorAll('.export-col'))
.filter(x => x.checked).map(x => x.value);
try { localStorage.setItem('customers.exportFields', JSON.stringify(cols)); } catch (_) {}
});
});
const exportAllToggle = document.getElementById('exportAllToggle');
if (exportAllToggle) {
exportAllToggle.addEventListener('change', function() {
try { localStorage.setItem('customers.exportAll', this.checked ? '1' : '0'); } catch (_) {}
try { updateExportPreview(window.lastCustomersTotal || 0, window.lastCustomersPageCount || 0); } catch (_) {}
});
}
}
// Sort header clicks
const thCustomer = document.getElementById('thCustomer');
const thName = document.getElementById('thName');
const thCity = document.getElementById('thCity');
const thEmail = document.getElementById('thEmail');
const addSortHandler = (el, field) => {
if (!el) return;
el.addEventListener('click', () => {
const prevField = window.currentSortBy || 'id';
const prevDir = window.currentSortDir || 'asc';
if (prevField === field) {
window.currentSortDir = prevDir === 'asc' ? 'desc' : 'asc';
} else {
window.currentSortBy = field;
window.currentSortDir = 'asc';
}
try {
localStorage.setItem('customers.sortBy', window.currentSortBy);
localStorage.setItem('customers.sortDir', window.currentSortDir);
} catch (_) {}
updateSortIndicators();
currentPage = 0;
loadCustomers(currentPage, currentSearch);
});
};
addSortHandler(thCustomer, 'id');
addSortHandler(thName, 'name');
addSortHandler(thCity, 'city');
addSortHandler(thEmail, 'email');
// Filter changes (multi-select)
const groupSel = document.getElementById('groupFilter');
const stateSel = document.getElementById('stateFilter');
if (groupSel) {
groupSel.addEventListener('change', function() {
const values = Array.from(this.selectedOptions).map(o => o.value).filter(Boolean);
window.currentGroupFilters = values;
try { localStorage.setItem('customers.filterGroups', JSON.stringify(values)); } catch (_) {}
currentPage = 0;
loadCustomers(currentPage, currentSearch);
renderActiveFilterChips();
});
}
if (stateSel) {
stateSel.addEventListener('change', function() {
const values = Array.from(this.selectedOptions).map(o => o.value).filter(Boolean);
window.currentStateFilters = values;
try { localStorage.setItem('customers.filterStates', JSON.stringify(values)); } catch (_) {}
currentPage = 0;
loadCustomers(currentPage, currentSearch);
renderActiveFilterChips();
});
}
const clearBtn = document.getElementById('clearAllFiltersBtn');
if (clearBtn) {
clearBtn.addEventListener('click', function() {
window.currentGroupFilters = [];
window.currentStateFilters = [];
try { localStorage.setItem('customers.filterGroups', JSON.stringify([])); } catch (_) {}
try { localStorage.setItem('customers.filterStates', JSON.stringify([])); } catch (_) {}
const gSel = document.getElementById('groupFilter');
const sSel = document.getElementById('stateFilter');
if (gSel) Array.from(gSel.options).forEach(o => o.selected = false);
if (sSel) Array.from(sSel.options).forEach(o => o.selected = false);
currentPage = 0;
renderActiveFilterChips();
loadCustomers(currentPage, currentSearch);
});
}
} }
// Modal functions // Modal functions
@@ -368,18 +663,31 @@ async function loadCustomers(page = 0, search = '') {
setSearchLoading(true); setSearchLoading(true);
const params = new URLSearchParams({ const params = new URLSearchParams({
skip: page * 50, skip: String(page * (window.customerPageSize || 50)),
limit: 50 limit: String(window.customerPageSize || 50)
}); });
if (search) params.append('search', search); if (search) params.append('search', search);
// Sorting
const sortBy = window.currentSortBy || 'id';
const sortDir = window.currentSortDir || 'asc';
params.append('sort_by', sortBy);
params.append('sort_dir', sortDir);
// Filters (multi)
const grpArr = Array.isArray(window.currentGroupFilters) ? window.currentGroupFilters : [];
const stArr = Array.isArray(window.currentStateFilters) ? window.currentStateFilters : [];
grpArr.forEach(v => params.append('groups', v));
stArr.forEach(v => params.append('states', v));
params.append('include_total', '1');
const response = await window.http.wrappedFetch(`/api/customers/?${params}`); const response = await window.http.wrappedFetch(`/api/customers/?${params}`);
if (!response.ok) throw new Error('Failed to load customers'); if (!response.ok) throw new Error('Failed to load customers');
const customers = await response.json(); const data = await response.json();
displayCustomers(customers); displayCustomers(data.items);
renderPagination(data.total, data.items.length);
try { updateExportPreview(data.total, data.items.length); } catch (_) {}
} catch (error) { } catch (error) {
console.error('Error loading customers:', error); console.error('Error loading customers:', error);
@@ -403,6 +711,29 @@ function performSearch() {
currentSearch = document.getElementById('searchInput').value.trim(); currentSearch = document.getElementById('searchInput').value.trim();
currentPage = 0; currentPage = 0;
loadCustomers(currentPage, currentSearch); loadCustomers(currentPage, currentSearch);
if (typeof syncUrlToState === 'function') { try { syncUrlToState(); } catch (_) {} }
}
function renderPagination(totalCount, returnedCount) {
const container = document.getElementById('pagination');
if (!container) return;
const size = window.customerPageSize || 50;
const isFirst = currentPage === 0;
const totalPages = Math.max(1, Math.ceil(totalCount / size));
const isLast = currentPage + 1 >= totalPages || returnedCount < size;
container.innerHTML = `
<button id="prevPageBtn" class="px-3 py-1.5 text-sm rounded-lg ${isFirst ? 'bg-neutral-100 text-neutral-400 cursor-not-allowed' : 'bg-white dark:bg-neutral-700 text-neutral-700 dark:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-600'} border border-neutral-200 dark:border-neutral-600" ${isFirst ? 'disabled' : ''}>
<i class="fa-solid fa-chevron-left mr-1"></i> Prev
</button>
<span class="px-3 py-1.5 text-sm text-neutral-700 dark:text-neutral-300">Page ${currentPage + 1} of ${totalPages} • ${totalCount.toLocaleString()} results</span>
<button id="nextPageBtn" class="px-3 py-1.5 text-sm rounded-lg ${isLast ? 'bg-neutral-100 text-neutral-400 cursor-not-allowed' : 'bg-white dark:bg-neutral-700 text-neutral-700 dark:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-600'} border border-neutral-200 dark:border-neutral-600" ${isLast ? 'disabled' : ''}>
Next <i class="fa-solid fa-chevron-right ml-1"></i>
</button>
`;
const prevBtn = document.getElementById('prevPageBtn');
const nextBtn = document.getElementById('nextPageBtn');
if (prevBtn && !isFirst) prevBtn.addEventListener('click', () => { currentPage = Math.max(0, currentPage - 1); loadCustomers(currentPage, currentSearch); if (typeof syncUrlToState === 'function') { try { syncUrlToState(); } catch (_) {} } });
if (nextBtn && !isLast) nextBtn.addEventListener('click', () => { currentPage = currentPage + 1; loadCustomers(currentPage, currentSearch); if (typeof syncUrlToState === 'function') { try { syncUrlToState(); } catch (_) {} } });
} }
async function performPhoneSearch() { async function performPhoneSearch() {
@@ -436,6 +767,20 @@ function displayPhoneSearchResults(results) {
emptyState.classList.add('hidden'); emptyState.classList.add('hidden');
const tokens = (window.highlightUtils && typeof window.highlightUtils.buildTokens === 'function')
? window.highlightUtils.buildTokens((currentSearch || '').trim())
: [];
function highlightText(text) {
if (!text) return '';
if (!window.highlightUtils || typeof window.highlightUtils.highlight !== 'function' || tokens.length === 0) {
return escapeHtml(String(text));
}
const strongHtml = window.highlightUtils.highlight(String(text), tokens);
return strongHtml
.replace(/<strong>/g, '<mark class="bg-yellow-200 text-neutral-900 rounded px-0.5">')
.replace(/<\/strong>/g, '</mark>');
}
results.forEach(result => { results.forEach(result => {
const row = document.createElement('tr'); const row = document.createElement('tr');
row.className = 'group border-b border-neutral-100 dark:border-neutral-700/50 hover:bg-gradient-to-r hover:from-blue-50/30 hover:to-indigo-50/30 dark:hover:from-blue-900/10 dark:hover:to-indigo-900/10 transition-all duration-200 cursor-pointer'; row.className = 'group border-b border-neutral-100 dark:border-neutral-700/50 hover:bg-gradient-to-r hover:from-blue-50/30 hover:to-indigo-50/30 dark:hover:from-blue-900/10 dark:hover:to-indigo-900/10 transition-all duration-200 cursor-pointer';
@@ -455,12 +800,15 @@ function displayPhoneSearchResults(results) {
</td> </td>
<td class=\"px-4 py-4 customer-cell\"> <td class=\"px-4 py-4 customer-cell\">
<div class=\"text-sm font-medium text-neutral-900 dark:text-neutral-100\" title=\"${result.customer.city}, ${result.customer.state}\"> <div class=\"text-sm font-medium text-neutral-900 dark:text-neutral-100\" title=\"${result.customer.city}, ${result.customer.state}\">
${result.customer.city}, ${result.customer.state} ${highlightText(`${result.customer.city}, ${result.customer.state}`)}
</div> </div>
</td> </td>
<td class=\"px-4 py-4 customer-cell\"> <td class=\"px-4 py-4 customer-cell\">
<div class=\"text-sm font-mono font-medium text-neutral-900 dark:text-neutral-100\" title=\"${result.location}: ${result.phone}\"> <div class=\"text-sm font-mono font-medium text-neutral-900 dark:text-neutral-100\" title=\"${result.location}: ${result.phone}\">
<div class="font-semibold text-warning-600 dark:text-warning-400">${result.location}: ${result.phone}</div> <div class="font-semibold text-warning-600 dark:text-warning-400">
<span class="mr-1">${result.location}:</span>
<span>${escapeHtml(result.phone)}</span>
</div>
</div> </div>
</td> </td>
<td class=\"px-4 py-4 customer-cell\"> <td class=\"px-4 py-4 customer-cell\">
@@ -517,6 +865,15 @@ async function loadGroups() {
option.textContent = g.group; option.textContent = g.group;
select.appendChild(option); select.appendChild(option);
}); });
// Apply saved selections
try {
const savedStr = localStorage.getItem('customers.filterGroups');
const savedLegacy = localStorage.getItem('customers.filterGroup') || '';
const saved = savedStr ? JSON.parse(savedStr) : (savedLegacy ? [savedLegacy] : []);
if (Array.isArray(saved)) {
Array.from(select.options).forEach(o => { o.selected = saved.includes(o.value); });
}
} catch (_) {}
} }
} catch (error) { } catch (error) {
console.error('Error loading groups:', error); console.error('Error loading groups:', error);
@@ -536,12 +893,69 @@ async function loadStates() {
option.textContent = s.state; option.textContent = s.state;
select.appendChild(option); select.appendChild(option);
}); });
// Apply saved selections
try {
const savedStr = localStorage.getItem('customers.filterStates');
const savedLegacy = localStorage.getItem('customers.filterState') || '';
const saved = savedStr ? JSON.parse(savedStr) : (savedLegacy ? [savedLegacy] : []);
if (Array.isArray(saved)) {
Array.from(select.options).forEach(o => { o.selected = saved.includes(o.value); });
}
} catch (_) {}
} }
} catch (error) { } catch (error) {
console.error('Error loading states:', error); console.error('Error loading states:', error);
} }
} }
function renderActiveFilterChips() {
const container = document.getElementById('activeFilterChips');
const clearBtn = document.getElementById('clearAllFiltersBtn');
if (!container) return;
const groups = Array.isArray(window.currentGroupFilters) ? window.currentGroupFilters : [];
const states = Array.isArray(window.currentStateFilters) ? window.currentStateFilters : [];
const chips = [];
groups.forEach(g => chips.push({ type: 'group', label: g }));
states.forEach(s => chips.push({ type: 'state', label: s }));
if (chips.length === 0) {
container.innerHTML = '';
if (clearBtn) clearBtn.classList.add('hidden');
return;
}
container.innerHTML = chips.map((c, idx) => `
<span class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border border-blue-200 dark:border-blue-700" data-type="${c.type}" data-value="${c.label}">
${c.type === 'group' ? 'Group' : 'State'}: ${c.label}
<button type="button" class="chip-remove ml-1 text-blue-700 dark:text-blue-300 hover:text-blue-900" aria-label="Remove">
<i class="fa-solid fa-xmark"></i>
</button>
</span>
`).join('');
if (clearBtn) clearBtn.classList.remove('hidden');
// Wire remove events
Array.from(container.querySelectorAll('.chip-remove')).forEach(btn => {
btn.addEventListener('click', (e) => {
const chip = e.currentTarget.closest('span');
if (!chip) return;
const type = chip.getAttribute('data-type');
const value = chip.getAttribute('data-value');
if (type === 'group') {
window.currentGroupFilters = (window.currentGroupFilters || []).filter(v => v !== value);
try { localStorage.setItem('customers.filterGroups', JSON.stringify(window.currentGroupFilters)); } catch (_) {}
const sel = document.getElementById('groupFilter');
if (sel) Array.from(sel.options).forEach(o => { if (o.value === value) o.selected = false; });
} else if (type === 'state') {
window.currentStateFilters = (window.currentStateFilters || []).filter(v => v !== value);
try { localStorage.setItem('customers.filterStates', JSON.stringify(window.currentStateFilters)); } catch (_) {}
const sel = document.getElementById('stateFilter');
if (sel) Array.from(sel.options).forEach(o => { if (o.value === value) o.selected = false; });
}
currentPage = 0;
renderActiveFilterChips();
loadCustomers(currentPage, currentSearch);
});
});
}
async function showStats() { async function showStats() {
try { try {
const response = await window.http.wrappedFetch('/api/customers/stats'); const response = await window.http.wrappedFetch('/api/customers/stats');
@@ -597,4 +1011,112 @@ function displayStats(stats) {
// Functions are now implemented in the external customers-tailwind.js file // Functions are now implemented in the external customers-tailwind.js file
</script> </script>
<script>
// Build sharable URL reflecting current state
function buildViewUrl() {
const u = new URL(window.location.href);
const p = u.searchParams;
p.set('skip', String(currentPage * (window.customerPageSize || 50)));
p.set('limit', String(window.customerPageSize || 50));
const q = (document.getElementById('searchInput')?.value || '').trim();
if (q) p.set('search', q); else p.delete('search');
const by = window.currentSortBy || 'id';
const dir = window.currentSortDir || 'asc';
p.set('sort_by', by);
p.set('sort_dir', dir);
// Filters
p.delete('groups'); p.delete('states');
(Array.isArray(window.currentGroupFilters) ? window.currentGroupFilters : []).forEach(v => p.append('groups', v));
(Array.isArray(window.currentStateFilters) ? window.currentStateFilters : []).forEach(v => p.append('states', v));
u.search = p.toString();
return u.toString();
}
function syncUrlToState() {
const url = buildViewUrl();
window.history.replaceState(null, '', url);
}
// On load, hydrate state from URL if present
function hydrateStateFromUrl() {
const p = new URLSearchParams(window.location.search);
const skip = parseInt(p.get('skip') || '0', 10);
const limit = parseInt(p.get('limit') || String(window.customerPageSize || 50), 10);
if ([25,50,100,200].includes(limit)) {
window.customerPageSize = limit;
try { localStorage.setItem('customers.pageSize', String(limit)); } catch (_) {}
const sizeSel = document.getElementById('pageSizeSelect');
if (sizeSel) sizeSel.value = String(limit);
}
currentPage = Math.max(0, Math.floor(skip / (window.customerPageSize || 50)));
const search = p.get('search') || '';
if (search) {
const input = document.getElementById('searchInput');
if (input) input.value = search;
currentSearch = search;
}
const by = p.get('sort_by');
const dir = p.get('sort_dir');
if (by) window.currentSortBy = by;
if (dir === 'asc' || dir === 'desc') window.currentSortDir = dir;
try {
const urlGroups = p.getAll('groups');
const urlStates = p.getAll('states');
if (urlGroups && urlGroups.length) window.currentGroupFilters = urlGroups;
if (urlStates && urlStates.length) window.currentStateFilters = urlStates;
} catch (_) {}
}
// Call hydrator before first loadCustomers
try { hydrateStateFromUrl(); } catch (_) {}
// After each successful load, reflect state in URL
const __origLoadCustomers = loadCustomers;
loadCustomers = async function(page = 0, search = '') {
await __origLoadCustomers(page, search);
try { syncUrlToState(); } catch (_) {}
}
// Sort indicator rendering
function updateSortIndicators() {
const by = window.currentSortBy || 'id';
const dir = (window.currentSortDir || 'asc') === 'desc' ? 'desc' : 'asc';
const arrow = dir === 'asc' ? '▲' : '▼';
// Reset labels
const labelMap = {
thCustomer: 'Customer',
thName: 'Name',
thCity: 'Location',
thEmail: 'Email'
};
Object.keys(labelMap).forEach(id => {
const el = document.getElementById(id);
if (!el) return;
el.textContent = labelMap[id];
});
// Apply arrow
const idByField = { id: 'thCustomer', name: 'thName', city: 'thCity', email: 'thEmail' };
const activeId = idByField[by] || 'thCustomer';
const activeEl = document.getElementById(activeId);
if (activeEl) {
activeEl.textContent = `${activeEl.textContent} ${arrow}`;
}
}
// Export preview updater
function updateExportPreview(totalCount, pageCount) {
window.lastCustomersTotal = Number.isFinite(totalCount) ? totalCount : 0;
window.lastCustomersPageCount = Number.isFinite(pageCount) ? pageCount : 0;
const el = document.getElementById('exportPreview');
if (!el) return;
const toggle = document.getElementById('exportAllToggle');
const isAll = !!(toggle && toggle.checked);
const pageClsActive = isAll ? 'text-neutral-600 dark:text-neutral-300' : 'font-semibold text-primary-700 dark:text-primary-300';
const allClsActive = isAll ? 'font-semibold text-primary-700 dark:text-primary-300' : 'text-neutral-600 dark:text-neutral-300';
const pageStr = (window.lastCustomersPageCount || 0).toLocaleString();
const allStr = (window.lastCustomersTotal || 0).toLocaleString();
el.innerHTML = `Export: <span class="${pageClsActive}">${pageStr}</span> page • <span class="${allClsActive}">${allStr}</span> all`;
}
</script>
{% endblock %} {% endblock %}

View File

@@ -121,6 +121,11 @@
<span class="font-medium">Global Search</span> <span class="font-medium">Global Search</span>
<kbd class="text-xs text-neutral-500 dark:text-neutral-400 mt-1">Ctrl+F</kbd> <kbd class="text-xs text-neutral-500 dark:text-neutral-400 mt-1">Ctrl+F</kbd>
</button> </button>
<button onclick="window.location.href='/import'" class="w-full flex flex-col items-center justify-center p-4 bg-neutral-50 dark:bg-neutral-900/50 hover:bg-neutral-100 dark:hover:bg-neutral-900 rounded-lg border border-neutral-200 dark:border-neutral-700 transition-colors duration-200">
<i class="fa-solid fa-cloud-arrow-up text-2xl text-primary-600 mb-1"></i>
<span class="font-medium">Import Data</span>
<kbd class="text-xs text-neutral-500 dark:text-neutral-400 mt-1">Alt+I</kbd>
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -130,14 +135,22 @@
<div class="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700"> <div class="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700">
<h5 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2"> <h5 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
<i class="fa-solid fa-clock-rotate-left"></i> <i class="fa-solid fa-clock-rotate-left"></i>
<span>Recent Activity</span> <span>Recent Activity & Imports</span>
</h5> </h5>
</div> </div>
<div class="p-6" id="recent-activity"> <div class="p-6 space-y-4">
<div id="recent-imports">
<div class="flex flex-col items-center justify-center py-4 text-neutral-500 dark:text-neutral-400">
<i class="fa-solid fa-file-arrow-up text-2xl mb-2"></i>
<p>Loading recent imports...</p>
</div>
</div>
<div id="recent-activity">
<div class="flex flex-col items-center justify-center py-4 text-neutral-500 dark:text-neutral-400"> <div class="flex flex-col items-center justify-center py-4 text-neutral-500 dark:text-neutral-400">
<i class="fa-solid fa-hourglass-half text-2xl mb-2"></i> <i class="fa-solid fa-hourglass-half text-2xl mb-2"></i>
<p>Loading recent activity...</p> <p>Loading recent activity...</p>
</div> </div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -227,6 +240,81 @@ function globalSearch() {
// Load data on page load // Load data on page load
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
loadDashboardData(); // Uncomment when authentication is implemented loadDashboardData(); // Uncomment when authentication is implemented
loadRecentImports();
loadRecentActivity();
}); });
async function loadRecentActivity() {
// Placeholder: existing system would populate; if an endpoint exists, hook it here.
}
async function loadRecentImports() {
try {
const [statusResp, recentResp] = await Promise.all([
window.http.wrappedFetch('/api/import/status'),
window.http.wrappedFetch('/api/import/recent-batches?limit=5')
]);
if (!statusResp.ok) return;
const status = await statusResp.json();
const recent = recentResp && recentResp.ok ? (await recentResp.json()).recent || [] : [];
const entries = Object.entries(status || {});
const total = entries.reduce((sum, [, v]) => sum + (v && v.record_count ? v.record_count : 0), 0);
const top = entries
.filter(([, v]) => (v && v.record_count) > 0)
.slice(0, 6)
.map(([k, v]) => ({ name: k, count: v.record_count, table: v.table_name }));
const container = document.getElementById('recent-imports');
if (!container) return;
if (entries.length === 0 && recent.length === 0) {
container.innerHTML = '<p class="text-neutral-500">No import status available.</p>';
return;
}
const items = top.map(({ name, count }) => `
<div class="flex items-center justify-between py-1 text-sm">
<span class="font-mono">${name}</span>
<span class="inline-block px-2 py-0.5 rounded bg-neutral-100 dark:bg-neutral-700">${Number(count).toLocaleString()}</span>
</div>
`).join('');
const recentRows = (recent || []).map(r => `
<tr>
<td class="px-2 py-1"><span class="inline-block px-2 py-0.5 rounded text-xs ${r.status === 'success' ? 'bg-green-100 text-green-700' : (r.status === 'completed_with_errors' ? 'bg-yellow-100 text-yellow-700' : 'bg-red-100 text-red-700')}">${r.status}</span></td>
<td class="px-2 py-1 text-xs">${r.started_at ? new Date(r.started_at).toLocaleString() : ''}</td>
<td class="px-2 py-1 text-xs">${r.finished_at ? new Date(r.finished_at).toLocaleString() : ''}</td>
<td class="px-2 py-1 text-xs">${r.successful_files}/${r.total_files}</td>
<td class="px-2 py-1 text-xs">${Number(r.total_imported || 0).toLocaleString()}</td>
</tr>
`).join('');
const html = `
<div>
<div class="flex items-center justify-between mb-2">
<h6 class="text-sm font-semibold flex items-center gap-2"><i class="fa-solid fa-file-arrow-up"></i> Recent Import Status</h6>
<a href="/import" class="text-primary-600 hover:underline text-sm">Open Import</a>
</div>
<div class="border border-neutral-200 dark:border-neutral-700 rounded-lg p-3">${items || '<p class="text-neutral-500 text-sm">No imported data yet.</p>'}</div>
<div class="mt-2 text-xs text-neutral-600 dark:text-neutral-400">Total records across tracked CSVs: <strong>${Number(total).toLocaleString()}</strong></div>
<div class="mt-3">
<h6 class="text-sm font-semibold mb-1">Last 5 Batch Uploads</h6>
<div class="overflow-x-auto">
<table class="w-full text-xs border border-neutral-200 dark:border-neutral-700 rounded">
<thead class="bg-neutral-50 dark:bg-neutral-800">
<tr>
<th class="px-2 py-1 text-left">Status</th>
<th class="px-2 py-1 text-left">Started</th>
<th class="px-2 py-1 text-left">Finished</th>
<th class="px-2 py-1 text-left">Files</th>
<th class="px-2 py-1 text-left">Imported</th>
</tr>
</thead>
<tbody>
${recentRows || '<tr><td class="px-2 py-2 text-neutral-500" colspan="5">No recent batch uploads</td></tr>'}
</tbody>
</table>
</div>
</div>
</div>
`;
container.innerHTML = html;
} catch (_) {}
}
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -133,8 +133,41 @@
<div id="generated" class="tabcontent p-6 hidden"> <div id="generated" class="tabcontent p-6 hidden">
<div class="mt-3 bg-white dark:bg-neutral-800 rounded-xl shadow-soft border border-neutral-200 dark:border-neutral-700"> <div class="mt-3 bg-white dark:bg-neutral-800 rounded-xl shadow-soft border border-neutral-200 dark:border-neutral-700">
<div class="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700"><h5 class="mb-0 font-semibold"><i class="fa-regular fa-file-pdf"></i> Generated Documents</h5></div> <div class="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700"><h5 class="mb-0 font-semibold"><i class="fa-regular fa-file-pdf"></i> Generated & Uploaded Documents</h5></div>
<div class="p-6"><div id="generatedDocuments"><p class="text-neutral-500">Generated documents will appear here...</p></div></div> <div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 items-end mb-4">
<div>
<label for="uploadFileNo" class="block text-sm font-medium mb-1">File Number</label>
<div class="flex gap-2">
<input type="text" class="w-full px-3 py-2 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-900 dark:text-neutral-100" id="uploadFileNo" placeholder="Enter file #">
<button type="button" id="clearUploadFileNoBtn" class="px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-700" title="Clear">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div id="uploadFileNoError" class="text-xs text-red-600 mt-1 hidden">Please enter a file number</div>
</div>
<div>
<label for="uploadInput" class="block text-sm font-medium mb-1">Choose File</label>
<input type="file" id="uploadInput" class="block w-full text-sm text-neutral-900 dark:text-neutral-100 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-neutral-100 dark:file:bg-neutral-700 file:text-neutral-700 dark:file:text-neutral-200 hover:file:bg-neutral-200 dark:hover:file:bg-neutral-600" />
<div id="uploadInputError" class="text-xs text-red-600 mt-1 hidden">Please choose a file to upload</div>
</div>
<div class="flex gap-2">
<button type="button" id="uploadBtn" class="px-4 py-2 bg-primary-600 text-white hover:bg-primary-700 rounded-lg transition-colors"><i class="fa-solid fa-upload mr-2"></i>Upload</button>
<button type="button" id="refreshUploadsBtn" class="px-4 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors"><i class="fa-solid fa-rotate-right mr-2"></i>Refresh</button>
</div>
</div>
<div id="uploadDropZone" class="mt-3 p-6 border-2 border-dashed border-neutral-300 dark:border-neutral-600 rounded-lg text-center text-neutral-500">
<i class="fa-solid fa-cloud-arrow-up text-2xl mb-2"></i>
<div>Drag & drop files here to upload</div>
<div class="text-xs mt-1">or use the chooser above and click Upload</div>
</div>
<div id="uploadingIndicator" class="mt-2 text-sm text-neutral-500 hidden"><i class="fa-solid fa-spinner animate-spin mr-2"></i>Uploading…</div>
<div id="uploadProgressList" class="space-y-2 mt-3"></div>
<div id="uploadedDocuments" class="mb-6">
<p class="text-neutral-500">Uploaded documents will appear here.</p>
</div>
<div id="generatedDocuments"><p class="text-neutral-500">Generated documents will appear here...</p></div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -432,8 +465,36 @@
</div> </div>
</div> </div>
<!-- Edit Upload Description Modal -->
<div class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" id="editUploadModal">
<div class="bg-white dark:bg-neutral-800 rounded-xl shadow-xl max-w-md w-full">
<div class="flex items-center justify-between px-6 py-4 border-b border-neutral-200 dark:border-neutral-700">
<h5 class="text-lg font-semibold">Edit Description</h5>
<button type="button" class="text-neutral-400 hover:text-neutral-600 dark:text-neutral-500 dark:hover:text-neutral-300" onclick="closeModal('editUploadModal')"><i class="fa-solid fa-xmark"></i></button>
</div>
<div class="px-6 py-4">
<input type="hidden" id="editUploadId">
<label for="editUploadDescription" class="block text-sm font-medium mb-1">Description</label>
<textarea id="editUploadDescription" class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg" rows="4" placeholder="Enter description..."></textarea>
</div>
<div class="flex items-center justify-end gap-2 px-6 py-4 border-t border-neutral-200 dark:border-neutral-700">
<button type="button" class="px-4 py-2 bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600 rounded-lg" onclick="closeModal('editUploadModal')">Cancel</button>
<button type="button" class="px-4 py-2 bg-primary-600 text-white hover:bg-primary-700 rounded-lg" id="saveEditUploadBtn"><i class="fa-regular fa-circle-check"></i> Save</button>
</div>
</div>
</div>
<script> <script>
// Document Management JavaScript // Document Management JavaScript
// Example: Upload with correlation-aware alerts
// -------------------------------------------------
// const input = document.querySelector('#uploadInput');
// const fileNo = 'ABC-123';
// const form = new FormData();
// form.append('file', input.files[0]);
// uploadWithAlerts(`/api/documents/upload/${fileNo}`, form)
// .then(() => alerts.success('Upload completed', { duration: 3000 }))
// .catch(() => {/* failure already alerted with Ref: <cid> */});
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Check authentication first // Check authentication first
const token = localStorage.getItem('auth_token'); const token = localStorage.getItem('auth_token');
@@ -590,6 +651,153 @@ function setupEventHandlers() {
// Refresh buttons // Refresh buttons
document.getElementById('refreshTemplatesBtn').addEventListener('click', loadTemplates); document.getElementById('refreshTemplatesBtn').addEventListener('click', loadTemplates);
document.getElementById('refreshQdrosBtn').addEventListener('click', loadQdros); document.getElementById('refreshQdrosBtn').addEventListener('click', loadQdros);
const refreshUploadsBtn = document.getElementById('refreshUploadsBtn');
if (refreshUploadsBtn) refreshUploadsBtn.addEventListener('click', loadUploadedDocuments);
const uploadBtn = document.getElementById('uploadBtn');
if (uploadBtn) uploadBtn.addEventListener('click', handleUploadClick);
const saveEditUploadBtn = document.getElementById('saveEditUploadBtn');
if (saveEditUploadBtn) saveEditUploadBtn.addEventListener('click', saveEditUpload);
const dropZone = document.getElementById('uploadDropZone');
if (dropZone) initUploadDropZone(dropZone);
const clearUploadBtn = document.getElementById('clearUploadFileNoBtn');
if (clearUploadBtn) clearUploadBtn.addEventListener('click', clearUploadFileNo);
// Upload controls enable/disable
const fileNoInput = document.getElementById('uploadFileNo');
const uploadInput = document.getElementById('uploadInput');
if (fileNoInput) {
fileNoInput.addEventListener('input', updateUploadControlsState);
fileNoInput.addEventListener('change', updateUploadControlsState);
fileNoInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const fileNo = (fileNoInput.value || '').trim();
const hasFile = !!(uploadInput && uploadInput.files && uploadInput.files.length > 0);
if (fileNo && hasFile) {
const btn = document.getElementById('uploadBtn');
if (btn && btn.disabled) return;
handleUploadClick();
} else if (!hasFile && uploadInput) {
const inputErr = document.getElementById('uploadInputError');
if (inputErr) inputErr.classList.remove('hidden');
uploadInput.focus();
}
}
});
}
if (uploadInput) {
uploadInput.addEventListener('change', () => {
updateUploadControlsState();
try {
const fileNoInputEl = document.getElementById('uploadFileNo');
const fileNoVal = (fileNoInputEl?.value || '').trim();
const hasFile = !!(uploadInput && uploadInput.files && uploadInput.files.length > 0);
if (hasFile && !fileNoVal && fileNoInputEl) {
fileNoInputEl.focus();
const fileNoErr = document.getElementById('uploadFileNoError');
if (fileNoErr) fileNoErr.classList.remove('hidden');
shakeElement(fileNoInputEl);
}
} catch (_) {}
});
uploadInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const fileNo = (document.getElementById('uploadFileNo')?.value || '').trim();
const hasFile = !!(uploadInput && uploadInput.files && uploadInput.files.length > 0);
if (fileNo && hasFile) {
const btn = document.getElementById('uploadBtn');
if (btn && btn.disabled) return;
handleUploadClick();
} else if (!fileNo) {
const err = document.getElementById('uploadFileNoError');
if (err) err.classList.remove('hidden');
const fileNoInputEl = document.getElementById('uploadFileNo');
if (fileNoInputEl) {
fileNoInputEl.focus();
shakeElement(fileNoInputEl);
}
}
} else if (e.key === 'Escape') {
e.preventDefault();
try {
uploadInput.value = '';
updateUploadControlsState();
uploadInput.focus();
const inputErr = document.getElementById('uploadInputError');
if (inputErr) inputErr.classList.add('hidden');
} catch (_) {}
}
});
}
updateUploadControlsState();
// Persist and restore last used upload file number
const fileNoInput = document.getElementById('uploadFileNo');
if (fileNoInput) {
try {
const saved = localStorage.getItem('docs_last_upload_file_no');
if (saved) {
fileNoInput.value = saved;
// Auto-load uploads for restored file number and show one-time hint
try {
if ((saved || '').trim()) {
loadUploadedDocuments().then(() => {
try {
if (!sessionStorage.getItem('docs_auto_loaded_hint_shown')) {
showAlert(`Loaded uploads for file ${saved}`, 'info');
sessionStorage.setItem('docs_auto_loaded_hint_shown', '1');
}
} catch (_) {}
});
}
} catch (_) {}
}
const persist = () => {
try { localStorage.setItem('docs_last_upload_file_no', (fileNoInput.value || '').trim()); } catch (_) {}
};
fileNoInput.addEventListener('input', persist);
fileNoInput.addEventListener('change', persist);
} catch (_) {}
}
}
function updateUploadControlsState() {
try {
const btn = document.getElementById('uploadBtn');
const fileNo = (document.getElementById('uploadFileNo')?.value || '').trim();
const input = document.getElementById('uploadInput');
const hasFile = !!(input && input.files && input.files.length > 0);
const enabled = !!fileNo && hasFile;
if (btn) {
btn.disabled = !enabled;
btn.classList.toggle('opacity-50', !enabled);
btn.classList.toggle('cursor-not-allowed', !enabled);
btn.setAttribute('aria-disabled', String(!enabled));
}
// Inline error messages
const fileNoErr = document.getElementById('uploadFileNoError');
if (fileNoErr) fileNoErr.classList.toggle('hidden', !!fileNo);
const inputErr = document.getElementById('uploadInputError');
if (inputErr) inputErr.classList.toggle('hidden', hasFile);
} catch (_) {}
}
function shakeElement(el) {
try {
el.classList.add('animate-shake');
setTimeout(() => el.classList.remove('animate-shake'), 400);
} catch (_) {}
}
function clearUploadFileNo() {
try {
const input = document.getElementById('uploadFileNo');
if (input) input.value = '';
try { localStorage.removeItem('docs_last_upload_file_no'); } catch (_) {}
const container = document.getElementById('uploadedDocuments');
if (container) container.innerHTML = '<p class="text-neutral-500">No uploads found for this file.</p>';
} catch (_) {}
} }
// Authorization and JSON headers are injected by window.http.wrappedFetch // Authorization and JSON headers are injected by window.http.wrappedFetch
@@ -633,6 +841,209 @@ async function loadTemplates() {
} }
} }
function initUploadDropZone(zoneEl) {
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
['dragenter','dragover','dragleave','drop'].forEach(eventName => {
zoneEl.addEventListener(eventName, preventDefaults, false);
});
zoneEl.addEventListener('dragover', () => zoneEl.classList.add('bg-neutral-50', 'dark:bg-neutral-900/20'));
zoneEl.addEventListener('dragleave', () => zoneEl.classList.remove('bg-neutral-50', 'dark:bg-neutral-900/20', 'border-red-400'));
zoneEl.addEventListener('drop', async (e) => {
zoneEl.classList.remove('bg-neutral-50', 'dark:bg-neutral-900/20', 'border-red-400');
const dt = e.dataTransfer;
const files = dt && dt.files ? Array.from(dt.files) : [];
if (!files.length) return;
const fileNo = (document.getElementById('uploadFileNo')?.value || '').trim();
if (!fileNo) {
zoneEl.classList.add('border-red-400');
const fileNoErr = document.getElementById('uploadFileNoError');
if (fileNoErr) fileNoErr.classList.remove('hidden');
showAlert('Please enter a file number', 'warning');
return;
}
try { localStorage.setItem('docs_last_upload_file_no', fileNo); } catch (_) {}
setUploadingState(true);
await concurrentUploads(files, fileNo, 3);
setUploadingState(false);
loadUploadedDocuments();
});
}
function createUploadItem(file) {
const list = document.getElementById('uploadProgressList');
const item = document.createElement('div');
item.className = 'flex items-center justify-between p-3 border rounded-lg';
const name = document.createElement('div');
name.className = 'text-sm font-medium truncate max-w-[60%]';
name.textContent = file.name;
const right = document.createElement('div');
right.className = 'flex items-center gap-3';
const status = document.createElement('span');
status.className = 'text-xs text-neutral-500';
status.textContent = 'Queued';
const barWrap = document.createElement('div');
barWrap.className = 'w-40 h-2 bg-neutral-200 dark:bg-neutral-700 rounded overflow-hidden';
const bar = document.createElement('div');
bar.className = 'h-2 bg-primary-500 w-0 transition-all';
bar.style.width = '0%';
barWrap.appendChild(bar);
const cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.title = 'Cancel upload';
cancelBtn.className = 'px-2 py-1 border border-neutral-400 text-neutral-600 rounded hover:bg-neutral-100 text-xs';
cancelBtn.innerHTML = '<i class="fa-solid fa-xmark"></i>';
cancelBtn.disabled = true;
right.appendChild(status);
right.appendChild(barWrap);
right.appendChild(cancelBtn);
item.appendChild(name);
item.appendChild(right);
list.appendChild(item);
let abortFn = null;
cancelBtn.addEventListener('click', () => {
if (typeof abortFn === 'function') {
status.textContent = 'Cancelling…';
cancelBtn.disabled = true;
abortFn();
}
});
return { item, status, bar, cancelBtn, setAbort: (fn) => { abortFn = fn; cancelBtn.disabled = !fn; } };
}
function setUploadingState(isUploading) {
try {
const uploadBtn = document.getElementById('uploadBtn');
const dropZone = document.getElementById('uploadDropZone');
const indicator = document.getElementById('uploadingIndicator');
if (uploadBtn) uploadBtn.disabled = !!isUploading;
if (dropZone) dropZone.classList.toggle('opacity-50', !!isUploading);
if (dropZone) dropZone.classList.toggle('pointer-events-none', !!isUploading);
if (indicator) indicator.classList.toggle('hidden', !isUploading);
} catch (_) {}
}
async function concurrentUploads(files, fileNo, concurrency = 3) {
const queue = Array.from(files);
const active = new Set();
let completed = 0;
function startNext() {
if (queue.length === 0 || active.size >= concurrency) return null;
const file = queue.shift();
const ui = createUploadItem(file);
const controller = new AbortController();
ui.setAbort(() => controller.abort());
const task = (async () => {
try {
ui.status.textContent = 'Uploading…';
ui.bar.style.width = '25%';
const form = new FormData();
form.append('file', file);
await uploadWithAlerts(`/api/documents/upload/${encodeURIComponent(fileNo)}`, form, { alertTitle: 'Upload failed', extraOptions: { signal: controller.signal } });
ui.bar.style.width = '100%';
ui.status.textContent = 'Done';
ui.status.className = 'text-xs text-green-600';
} catch (err) {
if (err && (err.name === 'AbortError' || /aborted/i.test(String(err && err.message)))) {
ui.status.textContent = 'Canceled';
ui.status.className = 'text-xs text-yellow-600';
ui.bar.style.width = '100%';
ui.bar.classList.remove('bg-primary-500');
ui.bar.classList.add('bg-yellow-500');
} else {
ui.status.textContent = 'Failed';
ui.status.className = 'text-xs text-red-600';
ui.bar.style.width = '100%';
ui.bar.classList.remove('bg-primary-500');
ui.bar.classList.add('bg-red-500');
// Keep failed item visible and allow dismiss
try {
ui.item.classList.add('bg-red-50');
ui.item.classList.add('border');
ui.item.classList.add('border-red-300');
ui.cancelBtn.disabled = false;
ui.cancelBtn.title = 'Dismiss';
ui.cancelBtn.addEventListener('click', () => {
try {
ui.item.style.transition = 'opacity 250ms ease-in-out';
ui.item.style.opacity = '0';
setTimeout(() => ui.item.remove(), 260);
} catch (_) { ui.item.remove(); }
});
} catch (_) {}
}
} finally {
completed += 1;
active.delete(task);
ui.setAbort(null);
startNext();
}
})();
active.add(task);
return task;
}
// Kick off initial batch
const starters = [];
for (let i = 0; i < concurrency; i++) {
const t = startNext();
if (t) starters.push(t);
}
await Promise.allSettled(starters);
// Drain the rest
while (active.size > 0 || queue.length > 0) {
if (queue.length > 0) startNext();
await Promise.race(Array.from(active));
}
const anyFailed = Array.from(document.querySelectorAll('#uploadProgressList .text-red-600')).length > 0;
if (!anyFailed) {
showAlert('All uploads completed', 'success');
}
cleanupUploadProgress(true);
}
function cleanupUploadProgress(preserveFailures = true) {
try {
const list = document.getElementById('uploadProgressList');
if (!list) return;
const items = Array.from(list.children);
items.forEach((item) => {
try {
const statusEl = item.querySelector('span.text-xs');
const isFailed = preserveFailures && statusEl && statusEl.classList.contains('text-red-600');
if (isFailed) {
// Ensure a dismiss button exists
const hasDismiss = item.querySelector('button');
if (!hasDismiss) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'px-2 py-1 border border-neutral-400 text-neutral-600 rounded hover:bg-neutral-100 text-xs';
btn.title = 'Dismiss';
btn.innerHTML = '<i class="fa-solid fa-xmark"></i>';
btn.addEventListener('click', () => {
try {
item.style.transition = 'opacity 250ms ease-in-out';
item.style.opacity = '0';
setTimeout(() => item.remove(), 260);
} catch (_) { item.remove(); }
});
const right = item.querySelector('.flex.items-center.gap-3');
if (right) right.appendChild(btn);
}
return;
}
item.style.transition = 'opacity 250ms ease-in-out';
item.style.opacity = '0';
setTimeout(() => item.remove(), 260);
} catch (_) {}
});
} catch (_) {}
}
function displayTemplates(templates) { function displayTemplates(templates) {
const tbody = document.getElementById('templatesTableBody'); const tbody = document.getElementById('templatesTableBody');
tbody.innerHTML = ''; tbody.innerHTML = '';
@@ -954,6 +1365,168 @@ function showAlert(message, type = 'info') {
} }
} }
// Uploads UI
async function loadUploadedDocuments() {
try {
const fileNo = (document.getElementById('uploadFileNo')?.value || '').trim();
if (!fileNo) {
showAlert('Enter a file number to view uploads', 'warning');
return;
}
const resp = await window.http.wrappedFetch(`/api/documents/${encodeURIComponent(fileNo)}/uploaded`);
if (!resp.ok) {
const err = await window.http.toError(resp, 'Failed to load uploads');
const msg = window.http.formatAlert(err, 'Failed to load uploads');
showAlert(msg, 'danger');
return;
}
const docs = await resp.json();
displayUploadedDocuments(docs);
} catch (error) {
console.error('Error loading uploaded documents', error);
showAlert('Failed to load uploads', 'danger');
}
}
function displayUploadedDocuments(docs) {
const container = document.getElementById('uploadedDocuments');
if (!Array.isArray(docs) || docs.length === 0) {
container.innerHTML = '<p class="text-neutral-500">No uploads found for this file.</p>';
return;
}
const rows = docs.map((d) => `
<tr>
<td class="px-4 py-2">${d.id || ''}</td>
<td class="px-4 py-2">${d.filename || ''}</td>
<td class="px-4 py-2">${(d.type || '').split('/').pop()}</td>
<td class="px-4 py-2">${Number(d.size || 0).toLocaleString()} bytes</td>
<td class="px-4 py-2"><a href="/${d.path || ''}" target="_blank" class="text-primary-600 hover:underline">View</a></td>
<td class="px-4 py-2">
<button class="px-2 py-1 border border-cyan-600 text-cyan-600 rounded hover:bg-blue-100 mr-2" title="Edit description" onclick="openEditUploadModal(${JSON.stringify(String(d.id || ''))}, ${JSON.stringify(String(d.description || ''))})"><i class="fa-solid fa-pencil"></i></button>
<button class="px-2 py-1 border border-danger-600 text-danger-600 rounded hover:bg-red-100" title="Delete" onclick="deleteUploadedDocument(${JSON.stringify(String(d.id || ''))})"><i class="fa-solid fa-trash"></i></button>
</td>
</tr>
`).join('');
const html = `
<table class="w-full text-sm text-left border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden">
<thead>
<tr>
<th class="px-4 py-2">ID</th>
<th class="px-4 py-2">Name</th>
<th class="px-4 py-2">Type</th>
<th class="px-4 py-2">Size</th>
<th class="px-4 py-2">Link</th>
<th class="px-4 py-2">Actions</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
`;
if (window.setSafeHTML) { window.setSafeHTML(container, html); } else { container.innerHTML = html; }
}
async function handleUploadClick() {
const fileNo = (document.getElementById('uploadFileNo')?.value || '').trim();
const input = document.getElementById('uploadInput');
if (!fileNo) {
showAlert('Please enter a file number', 'warning');
const fileNoErr = document.getElementById('uploadFileNoError');
if (fileNoErr) fileNoErr.classList.remove('hidden');
return;
}
try { localStorage.setItem('docs_last_upload_file_no', fileNo); } catch (_) {}
if (!input || !input.files || input.files.length === 0) {
showAlert('Please choose a file to upload', 'warning');
const inputErr = document.getElementById('uploadInputError');
if (inputErr) inputErr.classList.remove('hidden');
return;
}
const form = new FormData();
form.append('file', input.files[0]);
setUploadingState(true);
try {
await uploadWithAlerts(`/api/documents/upload/${encodeURIComponent(fileNo)}`, form);
if (window.alerts && window.alerts.success) {
window.alerts.success('Upload completed', { duration: 3000 });
} else {
showAlert('Upload completed', 'success');
}
// refresh list
loadUploadedDocuments();
// clear chooser
input.value = '';
} catch (_) {
// Error already alerted by helper
} finally {
setUploadingState(false);
}
}
async function deleteUploadedDocument(docId) {
try {
if (!docId) {
showAlert('Invalid document id', 'warning');
return;
}
if (!confirm('Are you sure you want to delete this uploaded document?')) return;
const resp = await window.http.wrappedFetch(`/api/documents/uploaded/${encodeURIComponent(String(docId))}`, { method: 'DELETE' });
if (!resp.ok) {
const err = await window.http.toError(resp, 'Failed to delete upload');
const msg = window.http.formatAlert(err, 'Failed to delete upload');
showAlert(msg, 'danger');
return;
}
showAlert('Upload deleted successfully', 'success');
loadUploadedDocuments();
} catch (error) {
const msg = window.http && window.http.formatAlert && error instanceof Error
? window.http.formatAlert(error, 'Failed to delete upload')
: 'Failed to delete upload';
showAlert(msg, 'danger');
}
}
function openEditUploadModal(docId, currentDescription) {
try {
document.getElementById('editUploadId').value = String(docId || '');
document.getElementById('editUploadDescription').value = String(currentDescription || '');
openModal('editUploadModal');
} catch (error) {
showAlert('Unable to open editor', 'danger');
}
}
async function saveEditUpload() {
const docId = document.getElementById('editUploadId').value;
const description = document.getElementById('editUploadDescription').value;
if (!docId) {
showAlert('Invalid document id', 'warning');
return;
}
try {
const form = new FormData();
form.append('description', description || '');
const resp = await window.http.wrappedFetch(`/api/documents/uploaded/${encodeURIComponent(String(docId))}`, {
method: 'PUT',
body: form
});
if (!resp.ok) {
const err = await window.http.toError(resp, 'Failed to update description');
const msg = window.http.formatAlert(err, 'Failed to update description');
showAlert(msg, 'danger');
return;
}
showAlert('Description updated', 'success');
closeModal('editUploadModal');
loadUploadedDocuments();
} catch (error) {
const msg = window.http && window.http.formatAlert && error instanceof Error
? window.http.formatAlert(error, 'Failed to update description')
: 'Failed to update description';
showAlert(msg, 'danger');
}
}
// Lightweight client error logger specific to Documents page // Lightweight client error logger specific to Documents page
async function logClientError({ message, action = null, error = null, extra = null }) { async function logClientError({ message, action = null, error = null, extra = null }) {
try { try {
@@ -1185,6 +1758,12 @@ function openTab(evt, tabName) {
loadTemplates(); loadTemplates();
} else if (tabName === 'qdros') { } else if (tabName === 'qdros') {
loadQdros(); loadQdros();
} else if (tabName === 'generated') {
const fileNoInput = document.getElementById('uploadFileNo');
const uploadInput = document.getElementById('uploadInput');
if (fileNoInput && (fileNoInput.value || '').trim() && uploadInput) {
uploadInput.focus();
}
} }
} }
</script> </script>

View File

@@ -3,8 +3,7 @@
{% block title %}File Cabinet - Delphi Database{% endblock %} {% block title %}File Cabinet - Delphi Database{% endblock %}
{% block content %} {% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div class="space-y-6">
<div class="space-y-6">
<!-- Page Header --> <!-- Page Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -13,7 +12,7 @@
</div> </div>
<h1 class="text-2xl font-bold text-neutral-900 dark:text-neutral-100">File Cabinet</h1> <h1 class="text-2xl font-bold text-neutral-900 dark:text-neutral-100">File Cabinet</h1>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button id="addFileBtn" class="flex items-center gap-2 px-4 py-2 bg-success-600 text-white hover:bg-success-700 rounded-lg transition-colors duration-200"> <button id="addFileBtn" class="flex items-center gap-2 px-4 py-2 bg-success-600 text-white hover:bg-success-700 rounded-lg transition-colors duration-200">
<i class="fa-solid fa-circle-plus"></i> <i class="fa-solid fa-circle-plus"></i>
<span>New File</span> <span>New File</span>
@@ -23,7 +22,7 @@
<i class="fa-solid fa-chart-line"></i> <i class="fa-solid fa-chart-line"></i>
<span>Statistics</span> <span>Statistics</span>
</button> </button>
<button id="advancedSearchBtn" class="flex items-center gap-2 px-4 py-2 bg-secondary-600 text-white hover:bg-secondary-700 rounded-lg transition-colors duration-200"> <button id="advancedSearchBtn" class="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white hover:bg-primary-700 rounded-lg transition-colors duration-200">
<i class="fa-solid fa-magnifying-glass"></i> <i class="fa-solid fa-magnifying-glass"></i>
<span>Advanced Search</span> <span>Advanced Search</span>
</button> </button>
@@ -31,8 +30,15 @@
</div> </div>
<!-- Search and Filter Panel --> <!-- Search and Filter Panel -->
<div class="bg-white dark:bg-neutral-800 rounded-lg shadow-md p-6"> <div class="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-soft">
<div class="grid grid-cols-1 md:grid-cols-5 gap-4"> <div class="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700">
<h5 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
<i class="fa-solid fa-filter"></i>
<span>Search & Filters</span>
</h5>
</div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-5 gap-4">
<div class="md:col-span-3"> <div class="md:col-span-3">
<label for="searchInput" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Search Files</label> <label for="searchInput" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Search Files</label>
<div class="relative"> <div class="relative">
@@ -41,7 +47,8 @@
<button id="searchBtn" class="absolute right-2 top-1/2 transform -translate-y-1/2 text-neutral-400 hover:text-primary-600 dark:text-neutral-500 dark:hover:text-primary-400 transition-colors"> <button id="searchBtn" class="absolute right-2 top-1/2 transform -translate-y-1/2 text-neutral-400 hover:text-primary-600 dark:text-neutral-500 dark:hover:text-primary-400 transition-colors">
<i class="fa-solid fa-arrow-right"></i> <i class="fa-solid fa-arrow-right"></i>
</button> </button>
</div> </div>
</div>
</div> </div>
<div> <div>
<label for="statusFilter" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Status</label> <label for="statusFilter" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Status</label>
@@ -62,10 +69,10 @@
</select> </select>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button id="clearFiltersBtn" class="px-3 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors duration-200"> <button id="clearFiltersBtn" class="px-3 py-2 bg-neutral-100 dark:bg-neutral-700 text-neutral-800 dark:text-neutral-200 rounded-lg hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors duration-200">
<i class="fa-regular fa-circle-xmark"></i> Clear <i class="fa-regular fa-circle-xmark"></i> Clear
</button> </button>
<button id="refreshBtn" class="px-3 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors duration-200"> <button id="refreshBtn" class="px-3 py-2 bg-neutral-100 dark:bg-neutral-700 text-neutral-800 dark:text-neutral-200 rounded-lg hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors duration-200">
<i class="fa-solid fa-rotate-right"></i> Refresh <i class="fa-solid fa-rotate-right"></i> Refresh
</button> </button>
</div> </div>
@@ -73,32 +80,39 @@
</div> </div>
<!-- File List --> <!-- File List -->
<div class="bg-white dark:bg-neutral-800 rounded-lg shadow-md p-6"> <div class="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-soft">
<div class="overflow-x-auto"> <div class="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700">
<table class="w-full text-sm text-left text-neutral-900 dark:text-neutral-100 border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden" id="filesTable"> <h5 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
<thead class="bg-neutral-100 dark:bg-neutral-700 text-neutral-800 dark:text-neutral-100"> <i class="fa-regular fa-folder-open"></i>
<tr> <span>File List</span>
<th data-sort="file_no" class="px-4 py-2 uppercase text-xs tracking-wider">File #</th> </h5>
<th data-sort="client_name" class="px-4 py-2 uppercase text-xs tracking-wider">Client</th> </div>
<th data-sort="regarding" class="px-4 py-2 uppercase text-xs tracking-wider">Matter</th> <div class="p-0">
<th data-sort="file_type" class="px-4 py-2 uppercase text-xs tracking-wider">Type</th> <div class="overflow-x-auto">
<th data-sort="status" class="px-4 py-2 uppercase text-xs tracking-wider">Status</th> <table class="w-full text-sm text-left text-neutral-900 dark:text-neutral-100" id="filesTable">
<th data-sort="employee" class="px-4 py-2 uppercase text-xs tracking-wider">Attorney</th> <thead class="bg-neutral-50 dark:bg-neutral-800/50">
<th data-sort="opened" class="px-4 py-2 uppercase text-xs tracking-wider">Opened</th> <tr class="border-b border-neutral-200 dark:border-neutral-700">
<th data-sort="amount_owing" class="px-4 py-2 text-right uppercase text-xs tracking-wider">Balance</th> <th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">File #</th>
<th class="px-4 py-2 uppercase text-xs tracking-wider">Actions</th> <th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Client</th>
</tr> <th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Matter</th>
</thead> <th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Type</th>
<tbody id="filesTableBody"> <th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Status</th>
<!-- File rows will be populated here --> <th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Attorney</th>
</tbody> <th data-sort="date" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Opened</th>
</table> <th data-sort="number" class="px-4 py-3 text-right text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Balance</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody id="filesTableBody">
<!-- File rows will be populated here -->
</tbody>
</table>
</div>
<!-- Pagination -->
<nav aria-label="File pagination" class="px-6 py-4 border-t border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800/50 flex items-center justify-center" id="pagination"></nav>
</div> </div>
<!-- Pagination -->
<nav aria-label="File pagination" class="mt-6 flex items-center justify-center" id="pagination"></nav>
</div> </div>
</div>
</div> </div>
<!-- File Modal --> <!-- File Modal -->
@@ -203,7 +217,7 @@
<div class="bg-white dark:bg-neutral-800 rounded-lg shadow-md p-6" id="financialSummaryCard" style="display: none;"> <div class="bg-white dark:bg-neutral-800 rounded-lg shadow-md p-6" id="financialSummaryCard" style="display: none;">
<div class="flex items-center justify-between pb-2 mb-3 border-b border-neutral-200 dark:border-neutral-700"> <div class="flex items-center justify-between pb-2 mb-3 border-b border-neutral-200 dark:border-neutral-700">
<h6 class="mb-0 font-semibold">Financial Summary</h6> <h6 class="mb-0 font-semibold">Financial Summary</h6>
<button type="button" class="px-2 py-1 border border-info-600 text-info-600 rounded hover:bg-blue-100" id="viewFullFinancialBtn"><i class="fa-solid fa-calculator"></i> View Details</button> <button type="button" class="px-3 py-2 bg-info-600 text-white hover:bg-info-700 rounded-lg transition-colors text-sm font-medium" id="viewFullFinancialBtn"><i class="fa-solid fa-calculator mr-2"></i> View Details</button>
</div> </div>
<div> <div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4" id="financialSummary"> <div class="grid grid-cols-1 md:grid-cols-4 gap-4" id="financialSummary">
@@ -215,22 +229,22 @@
<div class="bg-white dark:bg-neutral-800 rounded-lg shadow-md p-6 md:col-span-2" id="documentsCard" style="display: none;"> <div class="bg-white dark:bg-neutral-800 rounded-lg shadow-md p-6 md:col-span-2" id="documentsCard" style="display: none;">
<div class="flex items-center justify-between pb-2 mb-3 border-b border-neutral-200 dark:border-neutral-700"> <div class="flex items-center justify-between pb-2 mb-3 border-b border-neutral-200 dark:border-neutral-700">
<h6 class="mb-0 font-semibold">Documents</h6> <h6 class="mb-0 font-semibold">Documents</h6>
<button type="button" class="px-2 py-1 border border-success-600 text-success-600 rounded hover:bg-green-100" id="uploadDocumentBtn"><i class="fa-solid fa-upload"></i> Upload</button> <button type="button" class="px-3 py-2 bg-success-600 text-white hover:bg-success-700 rounded-lg transition-colors text-sm font-medium" id="uploadDocumentBtn"><i class="fa-solid fa-upload mr-2"></i> Upload</button>
</div> </div>
<div> <div>
<div class="mb-3"> <div class="mb-3">
<input type="file" id="documentFile" class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg"> <input type="file" id="documentFile" class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg">
<input type="text" id="documentDescription" class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg mt-2" placeholder="Description (optional)"> <input type="text" id="documentDescription" class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg mt-2" placeholder="Description (optional)">
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-sm text-left text-neutral-900 dark:text-neutral-100 border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden" id="documentsTable"> <table class="w-full text-sm text-left text-neutral-900 dark:text-neutral-100 border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden" id="documentsTable">
<thead> <thead>
<tr> <tr>
<th>Filename</th> <th class="px-3 py-2 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Filename</th>
<th>Description</th> <th class="px-3 py-2 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Description</th>
<th>Uploaded</th> <th class="px-3 py-2 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Uploaded</th>
<th>Size</th> <th class="px-3 py-2 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Size</th>
<th>Actions</th> <th class="px-3 py-2 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody id="documentsTableBody"> <tbody id="documentsTableBody">
@@ -335,13 +349,13 @@
</div> </div>
<div class="overflow-y-auto max-h-96"> <div class="overflow-y-auto max-h-96">
<table class="w-full text-sm text-left text-neutral-900 dark:text-neutral-100 border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden"> <table class="w-full text-sm text-left text-neutral-900 dark:text-neutral-100 border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden">
<thead> <thead class="bg-neutral-50 dark:bg-neutral-800/50">
<tr> <tr class="border-b border-neutral-200 dark:border-neutral-700">
<th>ID</th> <th class="px-3 py-2 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">ID</th>
<th>Name</th> <th class="px-3 py-2 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Name</th>
<th>City, State</th> <th class="px-3 py-2 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">City, State</th>
<th>Group</th> <th class="px-3 py-2 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Group</th>
<th>Action</th> <th class="px-3 py-2 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Action</th>
</tr> </tr>
</thead> </thead>
<tbody id="clientSelectionTableBody"> <tbody id="clientSelectionTableBody">
@@ -435,6 +449,35 @@ function setupEventListeners() {
document.getElementById('fileNo').addEventListener('blur', validateFileNumber); document.getElementById('fileNo').addEventListener('blur', validateFileNumber);
} }
// Highlight helpers
function _escapeHtml(text) {
try { if (window.htmlSanitizer && typeof window.htmlSanitizer.escape === 'function') { return window.htmlSanitizer.escape(text); } } catch (_) {}
const str = String(text == null ? '' : text);
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function _buildTokens(raw) {
return String(raw || '')
.trim()
.replace(/[,_;:]+/g, ' ')
.split(/\s+/)
.map(t => t.replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/g, ''))
.filter(Boolean);
}
function highlightText(text, tokens) {
if (!text) return '';
const unique = Array.from(new Set(tokens || []));
if (unique.length === 0) return _escapeHtml(text);
let safe = _escapeHtml(String(text));
try {
unique.forEach(tok => {
const esc = tok.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`(${esc})`, 'ig');
safe = safe.replace(re, '<mark class="bg-yellow-200 text-neutral-900 rounded px-0.5">$1</mark>');
});
} catch (_) {}
return safe;
}
async function loadLookupData() { async function loadLookupData() {
try { try {
// Load all lookup data in parallel // Load all lookup data in parallel
@@ -514,6 +557,7 @@ async function loadFiles(page = 0, filters = {}) {
function displayFiles(files) { function displayFiles(files) {
const tbody = document.getElementById('filesTableBody'); const tbody = document.getElementById('filesTableBody');
tbody.innerHTML = ''; tbody.innerHTML = '';
const tokens = _buildTokens(document.getElementById('searchInput') ? document.getElementById('searchInput').value : '');
if (files.length === 0) { if (files.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-neutral-500">No files found</td></tr>'; tbody.innerHTML = '<tr><td colspan="9" class="text-center text-neutral-500">No files found</td></tr>';
@@ -523,19 +567,19 @@ function displayFiles(files) {
files.forEach(file => { files.forEach(file => {
const row = document.createElement('tr'); const row = document.createElement('tr');
row.innerHTML = ` row.innerHTML = `
<td class="px-4 py-2"><strong>${file.file_no}</strong></td> <td class="px-4 py-2"><strong>${highlightText(file.file_no, tokens)}</strong></td>
<td class="px-4 py-2"> <td class="px-4 py-2">
<div> <div>
<div>${file.client_name || 'Unknown Client'}</div> <div>${highlightText(file.client_name || 'Unknown Client', tokens)}</div>
<div class="text-xs text-neutral-500">${file.client_id}</div> <div class="text-xs text-neutral-500">${highlightText(file.client_id || '', tokens)}</div>
</div> </div>
</td> </td>
<td class="px-4 py-2"> <td class="px-4 py-2">
${file.regarding ? `<div>${file.regarding.substring(0, 50)}${file.regarding.length > 50 ? '...' : ''}</div>` : '<em class="text-neutral-500">No description</em>'} ${file.regarding ? `<div>${highlightText(file.regarding.substring(0, 50) + (file.regarding.length > 50 ? '...' : ''), tokens)}</div>` : '<em class="text-neutral-500">No description</em>'}
</td> </td>
<td class="px-4 py-2">${file.file_type}</td> <td class="px-4 py-2">${highlightText(file.file_type || '', tokens)}</td>
<td class="px-4 py-2"><span class="${getStatusBadgeClass(file.status)}">${file.status}</span></td> <td class="px-4 py-2"><span class="${getStatusBadgeClass(file.status)}">${highlightText(file.status || '', tokens)}</span></td>
<td class="px-4 py-2">${file.empl_num}</td> <td class="px-4 py-2">${highlightText(file.empl_num || '', tokens)}</td>
<td class="px-4 py-2">${formatDate(file.opened)}</td> <td class="px-4 py-2">${formatDate(file.opened)}</td>
<td class="px-4 py-2 text-right"> <td class="px-4 py-2 text-right">
<strong class="${file.amount_owing > 0 ? 'text-success-600' : 'text-neutral-500'}"> <strong class="${file.amount_owing > 0 ? 'text-success-600' : 'text-neutral-500'}">
@@ -544,8 +588,8 @@ function displayFiles(files) {
</td> </td>
<td class="px-4 py-2"> <td class="px-4 py-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button class="px-2 py-1 bg-primary-600 text-white rounded hover:bg-primary-700" onclick="editFile('${file.file_no}')"><i class="fa-solid fa-pencil"></i></button> <button class="inline-flex items-center px-3 py-2 bg-primary-600 text-white hover:bg-primary-700 rounded-lg text-sm font-semibold transition-colors" onclick="editFile('${String(file.file_no).replace(/"/g, '&quot;')}')"><i class="fa-solid fa-pencil mr-1.5"></i> Edit</button>
<button class="px-2 py-1 bg-info-600 text-white rounded hover:bg-info-700" onclick="viewFile('${file.file_no}')"><i class="fa-regular fa-eye"></i></button> <button class="inline-flex items-center px-3 py-2 bg-info-600 text-white hover:bg-info-700 rounded-lg text-sm font-semibold transition-colors" onclick="viewFile('${String(file.file_no).replace(/"/g, '&quot;')}')"><i class="fa-regular fa-eye mr-1.5"></i> View</button>
</div> </div>
</td> </td>
`; `;
@@ -868,16 +912,17 @@ async function searchClients() {
function displayClientOptions(clients) { function displayClientOptions(clients) {
const tbody = document.getElementById('clientSelectionTableBody'); const tbody = document.getElementById('clientSelectionTableBody');
tbody.innerHTML = ''; tbody.innerHTML = '';
const tokens = _buildTokens(document.getElementById('clientSearchInput') ? document.getElementById('clientSearchInput').value : '');
clients.forEach(client => { clients.forEach(client => {
const row = document.createElement('tr'); const row = document.createElement('tr');
row.innerHTML = ` row.innerHTML = `
<td>${client.id}</td> <td>${highlightText(client.id || '', tokens)}</td>
<td>${client.first || ''} ${client.last}</td> <td>${highlightText(`${client.first || ''} ${client.last || ''}`.trim(), tokens)}</td>
<td>${client.city || ''}, ${client.abrev || ''}</td> <td>${highlightText(`${client.city || ''}, ${client.abrev || ''}`.trim(), tokens)}</td>
<td>${client.group || ''}</td> <td>${highlightText(client.group || '', tokens)}</td>
<td> <td>
<button class="px-2 py-1 bg-primary-600 text-white rounded hover:bg-primary-700 text-sm" onclick="selectClient('${client.id}', '${(client.first || '') + ' ' + client.last}', '${client.city || ''}, ${client.abrev || ''}')">Select</button> <button class="px-2 py-1 bg-primary-600 text-white rounded hover:bg-primary-700 text-sm" onclick="selectClient('${String(client.id).replace(/"/g, '&quot;')}', '${((client.first || '') + ' ' + (client.last || '')).replace(/"/g, '&quot;')}', '${(`${client.city || ''}, ${client.abrev || ''}`).replace(/"/g, '&quot;')}')">Select</button>
</td> </td>
`; `;
tbody.appendChild(row); tbody.appendChild(row);

View File

@@ -3,7 +3,7 @@
{% block title %}Financial/Ledger - Delphi Database{% endblock %} {% block title %}Financial/Ledger - Delphi Database{% endblock %}
{% block content %} {% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6"> <div class="space-y-6">
<!-- Page Header --> <!-- Page Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -61,9 +61,9 @@
</div> </div>
<!-- Recent Time Entries --> <!-- Recent Time Entries -->
<div class="bg-white dark:bg-neutral-800 rounded-lg shadow-md overflow-hidden mb-6"> <div class="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-soft overflow-hidden">
<div class="flex justify-between items-center p-4 border-b border-neutral-200 dark:border-neutral-700"> <div class="flex justify-between items-center px-6 py-4 border-b border-neutral-200 dark:border-neutral-700">
<h2 class="text-lg font-semibold"><i class="fa-solid fa-clock-rotate-left mr-2"></i>Recent Time Entries</h2> <h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2"><i class="fa-solid fa-clock-rotate-left"></i><span>Recent Time Entries</span></h2>
<div class="flex gap-2"> <div class="flex gap-2">
<select class="px-3 py-1 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-sm text-neutral-900 dark:text-neutral-100 transition-all duration-200" id="recentDaysFilter"> <select class="px-3 py-1 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-sm text-neutral-900 dark:text-neutral-100 transition-all duration-200" id="recentDaysFilter">
<option value="7">Last 7 days</option> <option value="7">Last 7 days</option>
@@ -73,25 +73,31 @@
<select class="px-3 py-1 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-sm text-neutral-900 dark:text-neutral-100 transition-all duration-200" id="employeeFilter"> <select class="px-3 py-1 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-sm text-neutral-900 dark:text-neutral-100 transition-all duration-200" id="employeeFilter">
<option value="">All Employees</option> <option value="">All Employees</option>
</select> </select>
<button class="px-3 py-1 bg-gray-200 dark:bg-neutral-700 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-300 dark:hover:bg-neutral-600 transition-colors" id="refreshRecentBtn"> <select class="px-3 py-1 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-sm text-neutral-900 dark:text-neutral-100 transition-all duration-200" id="statusFilter">
<option value="">All Status</option>
<option value="billed">Billed</option>
<option value="unbilled">Unbilled</option>
</select>
<input type="search" id="descSearch" placeholder="Search description..." class="px-3 py-1 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-sm text-neutral-900 dark:text-neutral-100 transition-all duration-200 w-48" />
<button class="px-3 py-1 bg-neutral-100 dark:bg-neutral-700 text-neutral-800 dark:text-neutral-200 rounded-lg hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors" id="refreshRecentBtn">
<i class="fa-solid fa-rotate-right"></i> <i class="fa-solid fa-rotate-right"></i>
</button> </button>
</div> </div>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-sm text-left"> <table class="w-full text-sm text-left text-neutral-900 dark:text-neutral-100" id="recentEntriesTable">
<thead class="bg-neutral-100 dark:bg-neutral-700"> <thead class="bg-neutral-50 dark:bg-neutral-800/50">
<tr> <tr class="border-b border-neutral-200 dark:border-neutral-700">
<th class="px-4 py-2">Date</th> <th data-sort="date" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Date</th>
<th class="px-4 py-2">File</th> <th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">File</th>
<th class="px-4 py-2">Client</th> <th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Client</th>
<th class="px-4 py-2">Employee</th> <th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Employee</th>
<th class="px-4 py-2">Hours</th> <th data-sort="number" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Hours</th>
<th class="px-4 py-2">Rate</th> <th data-sort="number" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Rate</th>
<th class="px-4 py-2">Amount</th> <th data-sort="number" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Amount</th>
<th class="px-4 py-2">Description</th> <th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Description</th>
<th class="px-4 py-2">Status</th> <th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Status</th>
<th class="px-4 py-2">Actions</th> <th class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody id="recentEntriesTableBody"> <tbody id="recentEntriesTableBody">
@@ -99,6 +105,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="flex items-center justify-between px-6 py-3 border-t border-neutral-200 dark:border-neutral-700 text-sm" id="recentPagination" aria-live="polite"></div>
</div> </div>
<!-- Action Cards Row --> <!-- Action Cards Row -->
@@ -125,17 +132,17 @@
</div> </div>
</div> </div>
<div class="lg:col-span-2"> <div class="lg:col-span-2">
<div class="bg-white dark:bg-neutral-800 rounded-lg shadow-md"> <div class="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-soft">
<div class="p-4 border-b border-neutral-200 dark:border-neutral-700"> <div class="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700">
<h2 class="text-lg font-semibold"><i class="fa-solid fa-chart-column mr-2"></i>Top Files by Balance</h2> <h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2"><i class="fa-solid fa-chart-column"></i><span>Top Files by Balance</span></h2>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-sm text-left"> <table class="w-full text-sm text-left text-neutral-900 dark:text-neutral-100" id="topFilesTable">
<thead class="bg-neutral-100 dark:bg-neutral-700"> <thead class="bg-neutral-50 dark:bg-neutral-800/50">
<tr> <tr class="border-b border-neutral-200 dark:border-neutral-700">
<th class="px-4 py-2">File</th> <th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">File</th>
<th class="px-4 py-2">Total Charges</th> <th data-sort="number" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Total Charges</th>
<th class="px-4 py-2">Amount Owing</th> <th data-sort="number" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Amount Owing</th>
</tr> </tr>
</thead> </thead>
<tbody id="topFilesTableBody"> <tbody id="topFilesTableBody">
@@ -482,6 +489,7 @@
let dashboardData = null; let dashboardData = null;
let recentEntries = []; let recentEntries = [];
let unbilledData = null; let unbilledData = null;
let recentEditSnapshots = {};
// Authorization and JSON headers are injected by window.http.wrappedFetch // Authorization and JSON headers are injected by window.http.wrappedFetch
@@ -509,10 +517,54 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
function initializeFinancialPage() { function initializeFinancialPage() {
// Initialize any data tables or components // Initialize sortable tables using shared helper
try { initializeDataTable('recentEntriesTable'); } catch (_) {}
try { initializeDataTable('topFilesTable'); } catch (_) {}
// Persisted filters restore
try {
const saved = JSON.parse(localStorage.getItem('financial_recent_filters') || '{}');
if (saved && typeof saved === 'object') {
if (saved.days && document.getElementById('recentDaysFilter')) document.getElementById('recentDaysFilter').value = String(saved.days);
if (typeof saved.employee === 'string' && document.getElementById('employeeFilter')) document.getElementById('employeeFilter').value = saved.employee;
if (typeof saved.status === 'string' && document.getElementById('statusFilter')) document.getElementById('statusFilter').value = saved.status;
if (typeof saved.query === 'string' && document.getElementById('descSearch')) document.getElementById('descSearch').value = saved.query;
}
} catch (_) {}
// Persist sort state on header clicks
attachSortPersistence('recentEntriesTable', 'financial_recent_sort');
attachSortPersistence('topFilesTable', 'financial_top_sort');
console.log('Financial page initialized'); console.log('Financial page initialized');
} }
// Highlight helpers
function _finEscapeHtml(text) {
try { if (window.htmlSanitizer && typeof window.htmlSanitizer.escape === 'function') { return window.htmlSanitizer.escape(text); } } catch (_) {}
const str = String(text == null ? '' : text);
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;').replace(/'/g, '&#39;');
}
function _finBuildTokens(raw) {
return String(raw || '')
.trim()
.replace(/[,_;:]+/g, ' ')
.split(/\s+/)
.map(t => t.replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/g, ''))
.filter(Boolean);
}
function finHighlightText(text, tokens) {
if (!text) return '';
const unique = Array.from(new Set(tokens || []));
if (unique.length === 0) return _finEscapeHtml(text);
let safe = _finEscapeHtml(String(text));
try {
unique.forEach(tok => {
const esc = tok.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`(${esc})`, 'ig');
safe = safe.replace(re, '<mark class="bg-yellow-200 text-neutral-900 rounded px-0.5">$1</mark>');
});
} catch (_) {}
return safe;
}
function setupEventListeners() { function setupEventListeners() {
// Quick actions // Quick actions
document.getElementById('quickTimeBtn').addEventListener('click', showQuickTimeModal); document.getElementById('quickTimeBtn').addEventListener('click', showQuickTimeModal);
@@ -530,9 +582,28 @@ function setupEventListeners() {
document.getElementById('billSelectedBtn').addEventListener('click', billSelectedEntries); document.getElementById('billSelectedBtn').addEventListener('click', billSelectedEntries);
// Filters // Filters
document.getElementById('recentDaysFilter').addEventListener('change', loadRecentTimeEntries); document.getElementById('recentDaysFilter').addEventListener('change', () => { setRecentServerState({ page: 1 }); loadRecentTimeEntries(); });
document.getElementById('employeeFilter').addEventListener('change', loadRecentTimeEntries); document.getElementById('employeeFilter').addEventListener('change', () => { setRecentServerState({ page: 1 }); loadRecentTimeEntries(); });
document.getElementById('refreshRecentBtn').addEventListener('click', loadRecentTimeEntries); document.getElementById('refreshRecentBtn').addEventListener('click', loadRecentTimeEntries);
const statusFilterEl = document.getElementById('statusFilter');
if (statusFilterEl) statusFilterEl.addEventListener('change', () => { setRecentServerState({ page: 1 }); loadRecentTimeEntries(); });
const descSearchEl = document.getElementById('descSearch');
if (descSearchEl) descSearchEl.addEventListener('input', (typeof debounce === 'function' ? debounce(() => { setRecentServerState({ page: 1 }); loadRecentTimeEntries(); }, 300) : () => { setRecentServerState({ page: 1 }); loadRecentTimeEntries(); }));
// Persist filters on change
const persistFilters = () => {
const payload = {
days: parseInt(document.getElementById('recentDaysFilter')?.value || '7', 10),
employee: document.getElementById('employeeFilter')?.value || '',
status: document.getElementById('statusFilter')?.value || '',
query: document.getElementById('descSearch')?.value || ''
};
try { localStorage.setItem('financial_recent_filters', JSON.stringify(payload)); } catch (_) {}
};
['recentDaysFilter','employeeFilter','statusFilter','descSearch'].forEach(id => {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener(id === 'descSearch' ? 'input' : 'change', persistFilters);
});
// File selection buttons // File selection buttons
document.getElementById('selectFileBtn').addEventListener('click', () => showFileSelector('quickTimeFile')); document.getElementById('selectFileBtn').addEventListener('click', () => showFileSelector('quickTimeFile'));
@@ -580,33 +651,57 @@ function updateDashboardSummary(data) {
`; `;
tbody.appendChild(row); tbody.appendChild(row);
}); });
// Reapply previous sort state if any
reapplyTableSort('topFilesTable');
} }
async function loadRecentTimeEntries() { async function loadRecentTimeEntries() {
const days = document.getElementById('recentDaysFilter').value; const days = document.getElementById('recentDaysFilter').value;
const employee = document.getElementById('employeeFilter').value; const employee = document.getElementById('employeeFilter').value;
const status = (document.getElementById('statusFilter')?.value || '').trim();
const q = (document.getElementById('descSearch')?.value || '').trim();
try { try {
const params = new URLSearchParams({ days }); const { page, limit, sort_by, sort_dir } = getRecentServerState();
const params = new URLSearchParams({ days, page, limit, sort_by, sort_dir });
if (employee) params.append('employee', employee); if (employee) params.append('employee', employee);
if (status) params.append('status', status);
if (q) params.append('q', q);
const response = await window.http.wrappedFetch(`/api/financial/time-entries/recent?${params}`); const response = await window.http.wrappedFetch(`/api/financial/time-entries/recent?${params}`);
if (!response.ok) throw new Error('Failed to load recent entries'); if (!response.ok) throw new Error('Failed to load recent entries');
const data = await response.json(); const data = await response.json();
recentEntries = data.entries; recentEntries = data.entries;
displayRecentTimeEntries(data.entries); renderRecentPagination(data.total_count, data.page, data.limit);
refreshRecentEntriesView();
} catch (error) { } catch (error) {
console.error('Error loading recent entries:', error); console.error('Error loading recent entries:', error);
showAlert('Error loading recent time entries: ' + error.message, 'danger'); showAlert('Error loading recent time entries: ' + error.message, 'danger');
} }
} }
function refreshRecentEntriesView() {
displayRecentTimeEntries(recentEntries || []);
}
function applyRecentEntriesFilters(entries) {
const status = (document.getElementById('statusFilter')?.value || '').toLowerCase();
const query = (document.getElementById('descSearch')?.value || '').trim().toLowerCase();
if (!entries || entries.length === 0) return [];
return entries.filter(e => {
let statusOk = true;
if (status === 'billed') statusOk = !!e.billed;
else if (status === 'unbilled') statusOk = !e.billed;
const text = [e.description, e.client_name, e.file_no, e.employee].filter(Boolean).join(' ').toLowerCase();
const queryOk = query ? text.includes(query) : true;
return statusOk && queryOk;
});
}
function displayRecentTimeEntries(entries) { function displayRecentTimeEntries(entries) {
const tbody = document.getElementById('recentEntriesTableBody'); const tbody = document.getElementById('recentEntriesTableBody');
tbody.innerHTML = ''; tbody.innerHTML = '';
const tokens = _finBuildTokens(document.getElementById('descSearch') ? document.getElementById('descSearch').value : '');
if (entries.length === 0) { if (entries.length === 0) {
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-neutral-500">No recent time entries found</td></tr>'; tbody.innerHTML = '<tr><td colspan="10" class="text-center text-neutral-500">No recent time entries found</td></tr>';
@@ -615,28 +710,128 @@ function displayRecentTimeEntries(entries) {
entries.forEach(entry => { entries.forEach(entry => {
const row = document.createElement('tr'); const row = document.createElement('tr');
row.setAttribute('data-entry-id', String(entry.id));
row.innerHTML = ` row.innerHTML = `
<td>${formatDate(entry.date)}</td> <td>${formatDate(entry.date)}</td>
<td><strong>${entry.file_no}</strong></td> <td><strong>${finHighlightText(entry.file_no, tokens)}</strong></td>
<td>${entry.client_name}</td> <td>${finHighlightText(entry.client_name, tokens)}</td>
<td>${entry.employee}</td> <td>${finHighlightText(entry.employee, tokens)}</td>
<td class="text-center">${entry.hours}</td> <td class="text-center">${entry.hours}</td>
<td class="text-right">${formatCurrency(entry.rate)}</td> <td class="text-right">${formatCurrency(entry.rate)}</td>
<td class="text-right text-green-600"><strong>${formatCurrency(entry.amount)}</strong></td> <td class="text-right text-green-600"><strong>${formatCurrency(entry.amount)}</strong></td>
<td class="small">${entry.description ? entry.description.substring(0, 50) + (entry.description.length > 50 ? '...' : '') : ''}</td> <td class="small">${entry.description ? finHighlightText(entry.description.substring(0, 50) + (entry.description.length > 50 ? '...' : ''), tokens) : ''}</td>
<td> <td>
<span class="inline-block px-2 py-0.5 text-xs rounded ${entry.billed ? 'bg-green-100 text-green-700 border border-green-400' : 'bg-yellow-100 text-yellow-700 border border-yellow-500'}"> <span class="inline-block px-2 py-0.5 text-xs rounded ${entry.billed ? 'bg-green-100 text-green-700 border border-green-400' : 'bg-yellow-100 text-yellow-700 border border-yellow-500'}">
${entry.billed ? 'Billed' : 'Unbilled'} ${entry.billed ? 'Billed' : 'Unbilled'}
</span> </span>
</td> </td>
<td> <td>
<button class="px-2 py-1 border border-blue-600 text-blue-600 rounded hover:bg-blue-100" onclick="editTimeEntry(${entry.id})"> <button class="px-2 py-1 border border-blue-600 text-blue-600 rounded hover:bg-blue-100" onclick="editTimeEntry(${entry.id})" title="Edit">
<i class="fa-solid fa-pencil"></i> <i class="fa-solid fa-pencil"></i>
</button> </button>
${entry.billed ? '' : `
<button class="ml-1 px-2 py-1 border border-danger-600 text-danger-600 rounded hover:bg-danger-50" onclick="deleteTimeEntry(${entry.id})" title="Delete">
<i class="fa-regular fa-trash-can"></i>
</button>
`}
</td> </td>
`; `;
tbody.appendChild(row); tbody.appendChild(row);
}); });
// Reapply previous sort state if any
reapplyTableSort('recentEntriesTable');
}
// Preserve and reapply table sort state across re-renders
function reapplyTableSort(tableId) {
const table = document.getElementById(tableId);
if (!table) return;
const headerAsc = table.querySelector('th.sort-asc');
const headerDesc = table.querySelector('th.sort-desc');
const header = headerAsc || headerDesc;
if (header) {
// The shared sortTable determines direction based on presence of 'sort-asc' BEFORE it removes classes
if (headerAsc) header.classList.remove('sort-asc');
else if (headerDesc) header.classList.add('sort-asc');
try { sortTable(table, header); } catch (_) {}
return;
}
// No current sort in DOM; attempt to restore persisted sort
const storageKey = tableId === 'recentEntriesTable' ? 'financial_recent_sort' : (tableId === 'topFilesTable' ? 'financial_top_sort' : null);
if (!storageKey) return;
let spec = null;
try { spec = JSON.parse(localStorage.getItem(storageKey) || 'null'); } catch (_) { spec = null; }
if (!spec || typeof spec.columnIndex !== 'number' || !spec.direction) return;
const headerRow = table.querySelector('thead tr');
if (!headerRow) return;
const th = headerRow.children[spec.columnIndex];
if (!th) return;
if (String(spec.direction).toLowerCase() === 'asc') th.classList.remove('sort-asc');
else th.classList.add('sort-asc');
try { sortTable(table, th); } catch (_) {}
}
function attachSortPersistence(tableId, storageKey) {
const table = document.getElementById(tableId);
if (!table) return;
const headers = table.querySelectorAll('th[data-sort]');
headers.forEach((header, idx) => {
header.addEventListener('click', () => {
// Determine target server sort field for recent table
if (tableId === 'recentEntriesTable') {
const fieldMap = ['date','file_no','client_name','empl_num','hours','rate','amount','description','billed'];
const sortBy = fieldMap[idx] || 'date';
// Toggle direction based on current persisted direction
const current = getRecentServerState();
const nextDir = (current.sort_by === sortBy) ? (current.sort_dir === 'asc' ? 'desc' : 'asc') : 'asc';
setRecentServerState({ sort_by: sortBy, sort_dir: nextDir, page: 1 });
loadRecentTimeEntries();
}
// Persist client-side header state for visual cues
setTimeout(() => {
const isAsc = header.classList.contains('sort-asc');
const direction = isAsc ? 'asc' : (header.classList.contains('sort-desc') ? 'desc' : 'asc');
const payload = { columnIndex: idx, direction };
try { localStorage.setItem(storageKey, JSON.stringify(payload)); } catch (_) {}
}, 0);
});
});
}
function getRecentServerState() {
let saved = null;
try { saved = JSON.parse(localStorage.getItem('financial_recent_server') || 'null'); } catch (_) { saved = null; }
const defaults = { page: 1, limit: 50, sort_by: 'date', sort_dir: 'desc' };
return Object.assign({}, defaults, saved || {});
}
function setRecentServerState(partial) {
const current = getRecentServerState();
const next = Object.assign({}, current, partial || {});
try { localStorage.setItem('financial_recent_server', JSON.stringify(next)); } catch (_) {}
return next;
}
function renderRecentPagination(totalCount, page, limit) {
const container = document.getElementById('recentPagination');
if (!container) return;
const totalPages = Math.max(1, Math.ceil((totalCount || 0) / (limit || 50)));
const canPrev = page > 1;
const canNext = page < totalPages;
container.innerHTML = `
<div>
Showing page ${page} of ${totalPages} (${totalCount || 0} entries)
</div>
<div class="space-x-2">
<button class="px-3 py-1 border rounded ${canPrev ? 'hover:bg-neutral-100 dark:hover:bg-neutral-700' : 'opacity-50 cursor-not-allowed'}" ${canPrev ? '' : 'disabled'} id="recentPrevPage">Prev</button>
<button class="px-3 py-1 border rounded ${canNext ? 'hover:bg-neutral-100 dark:hover:bg-neutral-700' : 'opacity-50 cursor-not-allowed'}" ${canNext ? '' : 'disabled'} id="recentNextPage">Next</button>
</div>
`;
const prevBtn = document.getElementById('recentPrevPage');
const nextBtn = document.getElementById('recentNextPage');
if (prevBtn) prevBtn.addEventListener('click', () => { setRecentServerState({ page: page - 1 }); loadRecentTimeEntries(); });
if (nextBtn) nextBtn.addEventListener('click', () => { setRecentServerState({ page: page + 1 }); loadRecentTimeEntries(); });
} }
async function loadEmployeeOptions() { async function loadEmployeeOptions() {
@@ -854,6 +1049,7 @@ function displayUnbilledItems(data) {
const container = document.getElementById('unbilledItemsContainer'); const container = document.getElementById('unbilledItemsContainer');
container.innerHTML = ''; container.innerHTML = '';
const tokens = _finBuildTokens(document.getElementById('unbilledFileFilter') ? document.getElementById('unbilledFileFilter').value : '');
if (data.files.length === 0) { if (data.files.length === 0) {
container.innerHTML = '<div class="text-center text-neutral-500 p-4">No unbilled entries found</div>'; container.innerHTML = '<div class="text-center text-neutral-500 p-4">No unbilled entries found</div>';
@@ -866,8 +1062,8 @@ function displayUnbilledItems(data) {
fileCard.innerHTML = ` fileCard.innerHTML = `
<div class="px-4 py-3 flex justify-between items-center border-b border-neutral-200 dark:border-neutral-700"> <div class="px-4 py-3 flex justify-between items-center border-b border-neutral-200 dark:border-neutral-700">
<div> <div>
<strong>${file.file_no}</strong> - ${file.client_name} <strong>${finHighlightText(file.file_no, tokens)}</strong> - ${finHighlightText(file.client_name, tokens)}
<br><small class="text-neutral-500">${file.matter}</small> <br><small class="text-neutral-500">${finHighlightText(file.matter, tokens)}</small>
</div> </div>
<div class="text-right"> <div class="text-right">
<div><strong>Total: ${formatCurrency(file.total_amount)}</strong></div> <div><strong>Total: ${formatCurrency(file.total_amount)}</strong></div>
@@ -900,12 +1096,12 @@ function displayUnbilledItems(data) {
data-entry-id="${entry.id}" data-file="${file.file_no}"> data-entry-id="${entry.id}" data-file="${file.file_no}">
</td> </td>
<td>${formatDate(entry.date)}</td> <td>${formatDate(entry.date)}</td>
<td>${entry.type}</td> <td>${finHighlightText(entry.type, tokens)}</td>
<td>${entry.employee}</td> <td>${finHighlightText(entry.employee, tokens)}</td>
<td class="text-center">${entry.quantity}</td> <td class="text-center">${entry.quantity}</td>
<td class="text-right">${formatCurrency(entry.rate)}</td> <td class="text-right">${formatCurrency(entry.rate)}</td>
<td class="text-right text-green-600">${formatCurrency(entry.amount)}</td> <td class="text-right text-green-600">${formatCurrency(entry.amount)}</td>
<td class="small">${entry.description || ''}</td> <td class="small">${entry.description ? finHighlightText(entry.description, tokens) : ''}</td>
</tr> </tr>
`).join('')} `).join('')}
</tbody> </tbody>
@@ -1057,6 +1253,136 @@ function showFileSelector(targetInputId) {
document.getElementById(targetInputId).focus(); document.getElementById(targetInputId).focus();
} }
// Inline edit for recent time entries
function editTimeEntry(entryId) {
const row = document.querySelector(`tr[data-entry-id="${entryId}"]`);
if (!row) return;
if (row.dataset.editing === 'true') {
saveEditedEntry(entryId, row);
return;
}
const entry = (recentEntries || []).find(e => e.id === entryId);
if (!entry) return;
if (entry.billed) {
showAlert('This entry is billed and cannot be edited.', 'warning');
return;
}
// Snapshot for rollback
recentEditSnapshots[entryId] = {
entry: { ...entry },
rowHTML: row.innerHTML
};
row.dataset.editing = 'true';
const hoursCell = row.children[4];
const rateCell = row.children[5];
const amountCell = row.children[6];
const descCell = row.children[7];
const actionsCell = row.children[9];
const currentHours = Number(entry.hours) || 0;
const rateValue = Number(entry.rate) || 0;
hoursCell.innerHTML = `<input type="number" class="w-20 px-2 py-1 border rounded" step="0.25" min="0" value="${currentHours}">`;
descCell.innerHTML = `<input type="text" class="w-full px-2 py-1 border rounded" value="${entry.description ? String(entry.description).replace(/"/g, '&quot;') : ''}">`;
const hoursInput = hoursCell.querySelector('input');
hoursInput.addEventListener('input', () => {
const h = parseFloat(hoursInput.value) || 0;
const amt = h * rateValue;
amountCell.innerHTML = `<strong>${formatCurrency(amt)}</strong>`;
});
actionsCell.innerHTML = `
<button class="px-2 py-1 border border-green-600 text-green-700 rounded hover:bg-green-100 mr-1" onclick="editTimeEntry(${entryId})" title="Save">
<i class="fa-regular fa-circle-check"></i>
</button>
<button class="px-2 py-1 border border-neutral-500 text-neutral-700 rounded hover:bg-neutral-100" onclick="cancelEditEntry(${entryId})" title="Cancel">
<i class="fa-solid fa-xmark"></i>
</button>
`;
}
async function saveEditedEntry(entryId, rowEl) {
const row = rowEl || document.querySelector(`tr[data-entry-id="${entryId}"]`);
if (!row) return;
const hoursInput = row.children[4].querySelector('input');
const descInput = row.children[7].querySelector('input');
const rateText = row.children[5].textContent || '';
const rate = parseFloat((rateText.replace(/[^0-9.\-]/g, ''))) || 0;
const newHours = parseFloat(hoursInput && hoursInput.value ? hoursInput.value : '0') || 0;
const newDesc = descInput ? descInput.value : '';
const newAmount = newHours * rate;
const payload = { quantity: newHours, amount: newAmount, note: newDesc };
const entryIndex = (recentEntries || []).findIndex(e => e.id === entryId);
if (entryIndex === -1) return;
// Optimistic UI update
const snapshot = recentEditSnapshots[entryId];
recentEntries[entryIndex] = { ...recentEntries[entryIndex], hours: newHours, amount: newAmount, description: newDesc };
// Render non-editing cells immediately
row.children[4].innerHTML = `${newHours}`;
row.children[6].innerHTML = `<strong>${formatCurrency(newAmount)}</strong>`;
row.children[7].innerHTML = `${newDesc ? newDesc.substring(0, 50) + (newDesc.length > 50 ? '...' : '') : ''}`;
row.children[9].innerHTML = `
<button class=\"px-2 py-1 border border-blue-600 text-blue-600 rounded hover:bg-blue-100\" onclick=\"editTimeEntry(${entryId})\" title=\"Edit\">\n <i class=\"fa-solid fa-pencil\"></i>\n </button>
`;
delete row.dataset.editing;
try {
const resp = await window.http.wrappedFetch(`/api/financial/ledger/${entryId}`, {
method: 'PUT',
body: JSON.stringify(payload)
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: 'Update failed' }));
throw new Error(err.detail || 'Update failed');
}
showAlert('Entry updated successfully', 'success');
loadDashboardData();
// Resort if needed
reapplyTableSort('recentEntriesTable');
} catch (error) {
// Rollback
if (snapshot && snapshot.entry) {
recentEntries[entryIndex] = { ...snapshot.entry };
}
refreshRecentEntriesView();
showAlert('Failed to update entry: ' + error.message, 'danger');
} finally {
delete recentEditSnapshots[entryId];
}
}
function cancelEditEntry(entryId) {
// Simply re-render current filtered view
delete recentEditSnapshots[entryId];
refreshRecentEntriesView();
}
async function deleteTimeEntry(entryId) {
const idx = (recentEntries || []).findIndex(e => e.id === entryId);
if (idx === -1) return;
const entry = recentEntries[idx];
if (entry.billed) {
showAlert('This entry is billed and cannot be deleted.', 'warning');
return;
}
const ok = window.confirm('Delete this time entry? This cannot be undone.');
if (!ok) return;
const backup = { index: idx, entry: { ...entry } };
recentEntries.splice(idx, 1);
refreshRecentEntriesView();
try {
const resp = await window.http.wrappedFetch(`/api/financial/ledger/${entryId}`, { method: 'DELETE' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: 'Delete failed' }));
throw new Error(err.detail || 'Delete failed');
}
showAlert('Entry deleted', 'success');
loadDashboardData();
} catch (error) {
// rollback
const restoreIndex = Math.min(backup.index, recentEntries.length);
recentEntries.splice(restoreIndex, 0, backup.entry);
refreshRecentEntriesView();
showAlert('Failed to delete entry: ' + error.message, 'danger');
}
}
async function validateQuickTimeFile() { async function validateQuickTimeFile() {
const fileNo = document.getElementById('quickTimeFile').value; const fileNo = document.getElementById('quickTimeFile').value;
if (!fileNo) return; if (!fileNo) return;

113
templates/flexible.html Normal file
View File

@@ -0,0 +1,113 @@
{% extends "base.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">Flexible Imports</h1>
<div class="flex items-center gap-2">
<button id="exportCsvBtn" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition-colors">
<i class="fa-solid fa-file-csv mr-2"></i> Export CSV
</button>
</div>
</div>
<div class="bg-white dark:bg-neutral-800 rounded-xl border border-neutral-200 dark:border-neutral-700 p-4">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label class="block text-sm font-medium mb-1">File Type</label>
<select id="filterFileType" class="w-full rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 p-2">
<option value="">All</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1">Target Table</label>
<select id="filterTargetTable" class="w-full rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 p-2">
<option value="">All</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1">Quick Search</label>
<input id="quickSearch" type="text" placeholder="Search file type, target table, keys and values" class="w-full rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 p-2" />
</div>
<div class="flex items-end">
<button id="applyFiltersBtn" class="w-full md:w-auto px-4 py-2 bg-neutral-100 dark:bg-neutral-700 hover:bg-neutral-200 dark:hover:bg-neutral-600 rounded-lg border border-neutral-200 dark:border-neutral-600">Apply</button>
</div>
</div>
<!-- Key filter chips -->
<div id="keyChipsContainer" class="mt-3 hidden">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs text-neutral-500">Filters:</span>
<div id="keyChips" class="flex items-center gap-2 flex-wrap"></div>
<button id="clearKeyChips" class="ml-auto text-xs text-neutral-600 hover:text-neutral-800 dark:text-neutral-300 dark:hover:text-white">Clear</button>
</div>
</div>
</div>
<div class="bg-white dark:bg-neutral-800 rounded-xl border border-neutral-200 dark:border-neutral-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="bg-neutral-100 dark:bg-neutral-700 text-left">
<th class="px-3 py-2">ID</th>
<th class="px-3 py-2">File Type</th>
<th class="px-3 py-2">Target Table</th>
<th class="px-3 py-2">PK</th>
<th class="px-3 py-2">Unmapped Preview</th>
<th class="px-3 py-2 text-right">Actions</th>
</tr>
</thead>
<tbody id="flexibleRows" class="divide-y divide-neutral-200 dark:divide-neutral-700">
</tbody>
</table>
</div>
<div class="flex items-center justify-between p-3 border-t border-neutral-200 dark:border-neutral-700">
<div class="text-xs text-neutral-500" id="rowsMeta">Loading...</div>
<div class="flex items-center gap-2">
<button id="prevPageBtn" class="px-3 py-1.5 bg-neutral-100 dark:bg-neutral-700 disabled:opacity-50 rounded-lg">Prev</button>
<button id="nextPageBtn" class="px-3 py-1.5 bg-neutral-100 dark:bg-neutral-700 disabled:opacity-50 rounded-lg">Next</button>
</div>
</div>
</div>
<!-- Row detail modal -->
<div id="flexibleDetailModal" class="hidden fixed inset-0 bg-black/60 z-50 overflow-y-auto" aria-hidden="true">
<div class="flex min-h-full items-center justify-center p-4">
<div class="bg-white dark:bg-neutral-800 rounded-xl shadow-xl max-w-4xl w-full max-h-[85vh] overflow-hidden">
<div class="flex items-center justify-between px-6 py-4 border-b border-neutral-200 dark:border-neutral-700">
<h2 class="text-lg font-semibold">Flexible Row <span id="detailRowId"></span></h2>
<div class="flex items-center gap-2">
<button id="detailExportBtn" class="px-3 py-1.5 bg-primary-600 hover:bg-primary-700 text-white rounded-lg text-sm">
<i class="fa-solid fa-file-csv mr-1"></i> Export CSV
</button>
<button onclick="closeModal('flexibleDetailModal')" class="text-neutral-500 hover:text-neutral-700">
<i class="fa-solid fa-xmark text-xl"></i>
</button>
</div>
</div>
<div class="p-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-3 text-xs text-neutral-600 dark:text-neutral-300">
<div>File Type: <span id="detailFileType" class="font-mono"></span></div>
<div>Target Table: <span id="detailTargetTable" class="font-mono"></span></div>
<div>PK Field: <span id="detailPkField" class="font-mono"></span></div>
<div>PK Value: <span id="detailPkValue" class="font-mono"></span></div>
</div>
<div class="border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden">
<pre id="detailJson" class="p-4 text-xs bg-neutral-50 dark:bg-neutral-900 overflow-auto max-h-[60vh]"></pre>
</div>
</div>
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800/50">
<button onclick="closeModal('flexibleDetailModal')" class="px-4 py-2 bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600 rounded-lg transition-colors duration-200">
Close
</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="/static/js/flexible.js"></script>
{% endblock %}

View File

@@ -49,17 +49,21 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<label class="text-sm font-medium text-neutral-700 dark:text-neutral-300">Mode:</label> <label class="text-sm font-medium text-neutral-700 dark:text-neutral-300">Mode:</label>
<select id="uploadMode" class="px-2 py-1 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded text-sm"> <select id="uploadMode" class="px-2 py-1 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded text-sm">
<option value="batch">Batch Upload (Recommended)</option>
<option value="single">Single File</option> <option value="single">Single File</option>
<option value="batch">Batch Upload</option>
</select> </select>
<button type="button" id="importHelpBtn" class="ml-2 px-2 py-1 text-xs border border-neutral-300 dark:border-neutral-600 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700" title="How to select and order files">
<i class="fa-solid fa-circle-question"></i>
Help
</button>
</div> </div>
</div> </div>
</div> </div>
<div class="p-6"> <div class="p-6">
<!-- Single File Upload Form --> <!-- Single File Upload Form -->
<form id="importForm" enctype="multipart/form-data" class="single-upload"> <form id="importForm" enctype="multipart/form-data" class="single-upload hidden">
<div class="grid grid-cols-1 md:grid-cols-12 gap-4"> <div class="grid grid-cols-1 md:grid-cols-12 gap-4">
<div class="md:col-span-4"> <div class="md:col-span-4" id="fileTypeContainer">
<label for="fileType" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Data Type *</label> <label for="fileType" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Data Type *</label>
<select class="w-full px-3 py-3 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all duration-200" id="fileType" name="fileType" required> <select class="w-full px-3 py-3 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all duration-200" id="fileType" name="fileType" required>
<option value="">Select data type...</option> <option value="">Select data type...</option>
@@ -72,10 +76,16 @@
<div class="text-sm text-neutral-500 dark:text-neutral-400 mt-1">Select the CSV file to import</div> <div class="text-sm text-neutral-500 dark:text-neutral-400 mt-1">Select the CSV file to import</div>
</div> </div>
<div class="md:col-span-2 flex items-end"> <div class="md:col-span-2 flex items-end">
<label class="inline-flex items-center gap-2"> <div class="flex flex-col gap-2">
<input class="h-4 w-4 text-primary-600 border-neutral-300 rounded" type="checkbox" id="replaceExisting" name="replaceExisting"> <label class="inline-flex items-center gap-2">
<span class="text-sm">Replace existing</span> <input class="h-4 w-4 text-primary-600 border-neutral-300 rounded" type="checkbox" id="replaceExisting" name="replaceExisting">
</label> <span class="text-sm">Replace existing</span>
</label>
<label class="inline-flex items-center gap-2">
<input class="h-4 w-4 text-primary-600 border-neutral-300 rounded" type="checkbox" id="flexibleOnly" name="flexibleOnly">
<span class="text-sm">Flexible-only</span>
</label>
</div>
</div> </div>
</div> </div>
@@ -90,6 +100,9 @@
<span>Import Data</span> <span>Import Data</span>
</button> </button>
</div> </div>
<p class="mt-2 text-xs text-neutral-500 dark:text-neutral-400" id="flexibleHint" style="display:none;">
Flexible-only: Upload any CSV. All columns will be stored as flexible JSON and visible in Flexible Imports.
</p>
</div> </div>
</form> </form>
@@ -98,15 +111,24 @@
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label for="batchFiles" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Select Multiple CSV Files *</label> <label for="batchFiles" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Select Multiple CSV Files *</label>
<input type="file" class="w-full px-3 py-3 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-900 dark:text-neutral-100 file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-primary-100 file:text-primary-700 hover:file:bg-primary-200 transition-all duration-200" id="batchFiles" name="batchFiles" accept=".csv" multiple required> <div class="relative">
<div class="text-sm text-neutral-500 dark:text-neutral-400 mt-1">Select multiple CSV files (max 20). Files will be imported in optimal dependency order.</div> <input type="file" class="w-full px-3 py-3 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-900 dark:text-neutral-100 file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-primary-100 file:text-primary-700 hover:file:bg-primary-200 transition-all duration-200" id="batchFiles" name="batchFiles" accept=".csv" multiple required>
</div>
<div class="text-sm text-neutral-500 dark:text-neutral-400 mt-1">
Select all your CSV files at once (max 25). Files will be validated and imported in dependency order automatically.
<br><strong>Tip:</strong> Use Ctrl+A in the file dialog to select all CSV files from your export folder.
</div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div class="space-y-2">
<label class="inline-flex items-center gap-2"> <label class="inline-flex items-center gap-2">
<input class="h-4 w-4 text-primary-600 border-neutral-300 rounded" type="checkbox" id="batchReplaceExisting" name="batchReplaceExisting"> <input class="h-4 w-4 text-primary-600 border-neutral-300 rounded" type="checkbox" id="batchReplaceExisting" name="batchReplaceExisting">
<span class="text-sm">Replace existing data</span> <span class="text-sm font-medium">Replace existing data</span>
</label>
<label class="inline-flex items-center gap-2">
<input class="h-4 w-4 text-success-600 border-neutral-300 rounded" type="checkbox" id="validateAllFirst" name="validateAllFirst" checked>
<span class="text-sm font-medium text-success-700 dark:text-success-400">Validate all files before import</span>
</label> </label>
</div> </div>
<div class="text-right"> <div class="text-right">
@@ -116,13 +138,17 @@
<div id="selectedFilesList" class="hidden"> <div id="selectedFilesList" class="hidden">
<h6 class="text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Selected Files (Import Order):</h6> <h6 class="text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Selected Files (Import Order):</h6>
<div class="bg-neutral-50 dark:bg-neutral-900 rounded-lg p-3 max-h-32 overflow-y-auto" id="filesList"></div> <div class="bg-neutral-50 dark:bg-neutral-900 rounded-lg p-3 max-h-40 overflow-y-auto" id="filesList"></div>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button type="button" class="px-4 py-2 bg-info-600 text-white hover:bg-info-700 rounded-lg transition-colors duration-200 flex items-center gap-2" id="batchValidateBtn">
<i class="fa-solid fa-clipboard-check"></i>
<span>Validate All Files</span>
</button>
<button type="submit" class="px-4 py-2 bg-success-600 text-white hover:bg-success-700 rounded-lg transition-colors duration-200 flex items-center gap-2" id="batchImportBtn"> <button type="submit" class="px-4 py-2 bg-success-600 text-white hover:bg-success-700 rounded-lg transition-colors duration-200 flex items-center gap-2" id="batchImportBtn">
<i class="fa-solid fa-layer-group"></i> <i class="fa-solid fa-layer-group"></i>
<span>Batch Import</span> <span>Import All Files</span>
</button> </button>
<button type="button" class="px-4 py-2 bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600 rounded-lg transition-colors duration-200 flex items-center gap-2" id="clearBatchBtn"> <button type="button" class="px-4 py-2 bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600 rounded-lg transition-colors duration-200 flex items-center gap-2" id="clearBatchBtn">
<i class="fa-solid fa-xmark"></i> <i class="fa-solid fa-xmark"></i>
@@ -189,6 +215,22 @@
</div> </div>
</div> </div>
<!-- Recent Batch Uploads Panel -->
<div class="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-soft" id="recentBatchesPanel">
<div class="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700">
<h5 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
<i class="fa-solid fa-clock-rotate-left"></i>
<span>Recent Batch Uploads</span>
</h5>
</div>
<div class="p-6" id="recentBatches">
<div class="flex flex-col items-center justify-center py-4 text-neutral-500 dark:text-neutral-400">
<i class="fa-solid fa-file-arrow-up text-2xl mb-2"></i>
<p>Loading recent batches...</p>
</div>
</div>
</div>
<!-- Data Management Panel --> <!-- Data Management Panel -->
<div class="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-soft"> <div class="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-soft">
<div class="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700"> <div class="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700">
@@ -234,6 +276,7 @@
// Import functionality // Import functionality
let availableFiles = {}; let availableFiles = {};
let importInProgress = false; let importInProgress = false;
let recentState = { limit: 5, offset: 0, status: 'all', start: '', end: '' };
// Authorization is injected by window.http.wrappedFetch // Authorization is injected by window.http.wrappedFetch
@@ -245,10 +288,35 @@ document.addEventListener('DOMContentLoaded', function() {
window.location.href = '/login'; window.location.href = '/login';
return; return;
} }
// Ensure admin only access for this page
(async () => {
try {
const resp = await window.http.wrappedFetch('/api/auth/me');
if (!resp.ok) {
window.location.href = '/login';
return;
}
const me = await resp.json();
if (!me || !me.is_admin) {
window.location.href = '/';
return;
}
} catch (_) {
window.location.href = '/login';
return;
}
})();
loadAvailableFiles(); loadAvailableFiles();
loadImportStatus(); loadImportStatus();
loadRecentBatches(false);
setupEventListeners(); setupEventListeners();
// Set batch mode as default after a short delay to ensure DOM is ready
setTimeout(() => {
document.getElementById('uploadMode').value = 'batch';
switchUploadMode();
}, 100);
}); });
function setupEventListeners() { function setupEventListeners() {
@@ -258,9 +326,12 @@ function setupEventListeners() {
// Upload mode switching // Upload mode switching
document.getElementById('uploadMode').addEventListener('change', switchUploadMode); document.getElementById('uploadMode').addEventListener('change', switchUploadMode);
const helpBtn = document.getElementById('importHelpBtn');
if (helpBtn) helpBtn.addEventListener('click', showImportHelp);
// Validation button // Validation buttons
document.getElementById('validateBtn').addEventListener('click', validateFile); document.getElementById('validateBtn').addEventListener('click', validateFile);
document.getElementById('batchValidateBtn').addEventListener('click', validateAllFiles);
// File type selection // File type selection
document.getElementById('fileType').addEventListener('change', updateFileTypeDescription); document.getElementById('fileType').addEventListener('change', updateFileTypeDescription);
@@ -278,6 +349,24 @@ function setupEventListeners() {
// Other buttons // Other buttons
document.getElementById('backupBtn').addEventListener('click', downloadBackup); document.getElementById('backupBtn').addEventListener('click', downloadBackup);
document.getElementById('viewLogsBtn').addEventListener('click', viewLogs); document.getElementById('viewLogsBtn').addEventListener('click', viewLogs);
const flexibleOnly = document.getElementById('flexibleOnly');
if (flexibleOnly) {
flexibleOnly.addEventListener('change', () => {
const isFlex = flexibleOnly.checked;
const fileTypeContainer = document.getElementById('fileTypeContainer');
const fileType = document.getElementById('fileType');
const hint = document.getElementById('flexibleHint');
if (isFlex) {
if (fileTypeContainer) fileTypeContainer.classList.add('opacity-50');
if (fileType) fileType.required = false;
if (hint) hint.style.display = '';
} else {
if (fileTypeContainer) fileTypeContainer.classList.remove('opacity-50');
if (fileType) fileType.required = true;
if (hint) hint.style.display = 'none';
}
});
}
} }
async function loadAvailableFiles() { async function loadAvailableFiles() {
@@ -398,10 +487,11 @@ function updateFileTypeDescription() {
} }
async function validateFile() { async function validateFile() {
const flexibleOnly = document.getElementById('flexibleOnly').checked;
const fileType = document.getElementById('fileType').value; const fileType = document.getElementById('fileType').value;
const fileInput = document.getElementById('csvFile'); const fileInput = document.getElementById('csvFile');
if (!fileType || !fileInput.files[0]) { if ((!flexibleOnly && !fileType) || !fileInput.files[0]) {
showAlert('Please select both data type and CSV file', 'warning'); showAlert('Please select both data type and CSV file', 'warning');
return; return;
} }
@@ -412,7 +502,9 @@ async function validateFile() {
try { try {
showProgress(true, 'Validating file...'); showProgress(true, 'Validating file...');
const response = await window.http.wrappedFetch(`/api/import/validate/${fileType}`, { const endpoint = flexibleOnly ? '/api/import/upload-flexible' : `/api/import/validate/${fileType}`;
const method = flexibleOnly ? 'POST' : 'POST';
const response = await window.http.wrappedFetch(endpoint, {
method: 'POST', method: 'POST',
body: formData body: formData
}); });
@@ -423,7 +515,23 @@ async function validateFile() {
} }
const result = await response.json(); const result = await response.json();
displayValidationResults(result); if (flexibleOnly) {
// Synthesize a validation-like display for flexible-only
displayValidationResults({
valid: true,
headers: {
found: result.auto_mapping?.unmapped_headers || [],
mapped: {},
unmapped: result.auto_mapping?.unmapped_headers || [],
},
sample_data: [],
validation_errors: [],
total_errors: 0,
auto_mapping: { suggestions: {} },
});
} else {
displayValidationResults(result);
}
} catch (error) { } catch (error) {
console.error('Validation error:', error); console.error('Validation error:', error);
@@ -446,24 +554,37 @@ function displayValidationResults(result) {
html += ` html += `
<div class="p-4 bg-${statusClass}-100 dark:bg-${statusClass}-900/30 rounded-lg mb-4"> <div class="p-4 bg-${statusClass}-100 dark:bg-${statusClass}-900/30 rounded-lg mb-4">
<i class="fa-solid fa-${statusIcon} mr-2"></i> <i class="fa-solid fa-${statusIcon} mr-2"></i>
<span class="font-medium">File validation ${result.valid ? 'passed' : 'failed'}</span> <span class="font-medium">File validation ${result.valid ? 'passed' : 'completed with issues'}</span>
</div> </div>
`; `;
// Headers validation // Auto-discovery mapping summary
html += '<h6 class="text-sm font-semibold mb-2">Column Headers</h6>'; html += '<h6 class="text-sm font-semibold mb-2">Auto-Discovery Mapping</h6>';
if (result.headers.missing.length > 0) { const mapped = (result.headers && result.headers.mapped) || {};
html += `<div class="p-3 bg-warning-100 dark:bg-warning-900/30 rounded-lg mb-2"> const unmapped = (result.headers && result.headers.unmapped) || [];
<strong class="text-warning-700 dark:text-warning-300">Missing columns:</strong> ${result.headers.missing.join(', ')} const suggestions = (result.auto_mapping && result.auto_mapping.suggestions) || {};
</div>`; const mappedCount = Object.keys(mapped).length;
const unmappedCount = unmapped.length;
html += `<div class="p-3 bg-neutral-100 dark:bg-neutral-900/30 rounded-lg mb-3 text-sm">
<div>Mapped columns: <strong>${mappedCount}</strong> | Unmapped columns: <strong>${unmappedCount}</strong></div>
</div>`;
if (mappedCount > 0) {
html += '<div class="overflow-x-auto mb-3"><table class="w-full text-sm"><thead><tr class="bg-neutral-100 dark:bg-neutral-700"><th class="px-3 py-2 text-left font-medium">CSV Column</th><th class="px-3 py-2 text-left font-medium">Mapped To</th></tr></thead><tbody class="divide-y divide-neutral-200 dark:divide-neutral-700">';
Object.entries(mapped).forEach(([csvCol, dbField]) => {
html += `<tr><td class="px-3 py-2">${csvCol}</td><td class="px-3 py-2 font-mono">${dbField}</td></tr>`;
});
html += '</tbody></table></div>';
} }
if (result.headers.extra.length > 0) { if (unmappedCount > 0) {
html += `<div class="p-3 bg-info-100 dark:bg-info-900/30 rounded-lg mb-2"> html += '<div class="p-3 bg-warning-100 dark:bg-warning-900/30 rounded-lg mb-2">Some columns were not recognized and will be stored as flexible JSON data:</div>';
<strong class="text-info-700 dark:text-info-300">Extra columns:</strong> ${result.headers.extra.join(', ')} html += '<div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-neutral-100 dark:bg-neutral-700"><th class="px-3 py-2 text-left font-medium">Unmapped CSV Column</th><th class="px-3 py-2 text-left font-medium">Top Suggestions</th></tr></thead><tbody class="divide-y divide-neutral-200 dark:divide-neutral-700">';
</div>`; unmapped.forEach(col => {
} const sug = suggestions[col] || [];
if (result.headers.missing.length === 0 && result.headers.extra.length === 0) { const sugText = sug.map(([name, score]) => `${name} (${(score*100).toFixed(0)}%)`).join(', ');
html += '<div class="p-3 bg-success-100 dark:bg-success-900/30 rounded-lg mb-2">All expected columns found</div>'; html += `<tr><td class="px-3 py-2">${col}</td><td class="px-3 py-2 text-neutral-600 dark:text-neutral-400">${sugText || '—'}</td></tr>`;
});
html += '</tbody></table></div>';
} }
// Sample data // Sample data
@@ -511,11 +632,12 @@ async function handleImport(event) {
return; return;
} }
const flexibleOnly = document.getElementById('flexibleOnly').checked;
const fileType = document.getElementById('fileType').value; const fileType = document.getElementById('fileType').value;
const fileInput = document.getElementById('csvFile'); const fileInput = document.getElementById('csvFile');
const replaceExisting = document.getElementById('replaceExisting').checked; const replaceExisting = document.getElementById('replaceExisting').checked;
if (!fileType || !fileInput.files[0]) { if ((!flexibleOnly && !fileType) || !fileInput.files[0]) {
showAlert('Please select both data type and CSV file', 'warning'); showAlert('Please select both data type and CSV file', 'warning');
return; return;
} }
@@ -529,10 +651,12 @@ async function handleImport(event) {
try { try {
showProgress(true, 'Importing data...'); showProgress(true, 'Importing data...');
const response = await window.http.wrappedFetch(`/api/import/upload/${fileType}`, { let response;
method: 'POST', if (flexibleOnly) {
body: formData response = await window.http.wrappedFetch('/api/import/upload-flexible', { method: 'POST', body: formData });
}); } else {
response = await window.http.wrappedFetch(`/api/import/upload/${fileType}`, { method: 'POST', body: formData });
}
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
@@ -574,6 +698,19 @@ function displayImportResults(result) {
</div> </div>
`; `;
if (result.auto_mapping) {
const mappedCount = Object.keys(result.auto_mapping.mapped_headers || {}).length;
const unmappedCount = (result.auto_mapping.unmapped_headers || []).length;
const flexSaved = result.auto_mapping.flexible_saved_rows || 0;
html += `
<div class="p-3 bg-neutral-100 dark:bg-neutral-900/30 rounded-lg mb-4 text-sm">
<strong>Auto-Discovery Summary</strong>
<div class="mt-1">Mapped columns: ${mappedCount} | Unmapped stored as flexible JSON: ${unmappedCount}</div>
<div>Rows with flexible data saved: ${flexSaved}</div>
</div>
`;
}
if (result.errors && result.errors.length > 0) { if (result.errors && result.errors.length > 0) {
html += '<h6 class="text-sm font-semibold mb-2">Import Errors</h6>'; html += '<h6 class="text-sm font-semibold mb-2">Import Errors</h6>';
html += '<div class="p-3 bg-danger-100 dark:bg-danger-900/30 rounded-lg">'; html += '<div class="p-3 bg-danger-100 dark:bg-danger-900/30 rounded-lg">';
@@ -651,15 +788,206 @@ function switchUploadMode() {
const singleForm = document.querySelector('.single-upload'); const singleForm = document.querySelector('.single-upload');
const batchForm = document.querySelector('.batch-upload'); const batchForm = document.querySelector('.batch-upload');
console.log('Switch mode to:', mode);
console.log('Single form found:', !!singleForm);
console.log('Batch form found:', !!batchForm);
if (mode === 'batch') { if (mode === 'batch') {
singleForm.classList.add('hidden'); if (singleForm) singleForm.classList.add('hidden');
batchForm.classList.remove('hidden'); if (batchForm) batchForm.classList.remove('hidden');
console.log('Switched to batch mode');
} else { } else {
singleForm.classList.remove('hidden'); if (singleForm) singleForm.classList.remove('hidden');
batchForm.classList.add('hidden'); if (batchForm) batchForm.classList.add('hidden');
console.log('Switched to single mode');
} }
} }
function showImportHelp() {
const tips = `
<div class="space-y-2 text-sm">
<div class="p-2 bg-neutral-100 dark:bg-neutral-900/40 rounded">
<strong>Recommended flow:</strong>
<ol class="list-decimal list-inside mt-1 space-y-1">
<li>Choose Batch Upload mode.</li>
<li>Select all exported CSVs at once (Cmd+A/ Ctrl+A).</li>
<li>Keep file names exactly as exported (e.g., STATES.csv, GRUPLKUP.csv, …).</li>
<li>Optionally Validate All first to catch header/format issues.</li>
<li>Click Import All Files.</li>
</ol>
</div>
<div>
Files will be imported in dependency order automatically:
<code class="block mt-1">STATES.csv → GRUPLKUP.csv → EMPLOYEE.csv → FILETYPE.csv → FILESTAT.csv → TRNSTYPE.csv → TRNSLKUP.csv → FOOTERS.csv → SETUP.csv → PRINTERS.csv → ROLODEX.csv → PHONE.csv → FILES.csv → LEDGER.csv → TRNSACTN.csv → QDROS.csv → PENSIONS.csv → PLANINFO.csv → PAYMENTS.csv → DEPOSITS.csv → FILENOTS.csv → FORM_INX.csv → FORM_LST.csv → FVARLKUP.csv → RVARLKUP.csv</code>
</div>
<div>
Unrecognized columns are saved as flexible JSON automatically. Unknown CSVs fall back to flexible-only storage.
</div>
<div class="text-xs text-neutral-500 mt-1">
Tip: Use Replace Existing to clear a table before importing its file.
</div>
</div>`;
if (window.alerts && window.alerts.show) {
window.alerts.show(tips, 'info', { html: true, duration: 0, title: 'Import Help' });
} else if (window.showNotification) {
window.showNotification('See import tips in console', 'info');
console.log('[Import Help]', tips);
} else {
alert('Batch mode → select all CSVs → Validate (optional) → Import. Files auto-ordered. Unknown columns saved as flexible.');
}
}
async function validateAllFiles() {
const fileInput = document.getElementById('batchFiles');
if (!fileInput.files || fileInput.files.length === 0) {
showAlert('Please select at least one CSV file', 'warning');
return;
}
if (fileInput.files.length > 25) {
showAlert('Maximum 25 files allowed per batch', 'warning');
return;
}
showProgress(true, 'Validating all selected files...');
try {
const formData = new FormData();
for (let file of fileInput.files) {
formData.append('files', file);
}
const response = await window.http.wrappedFetch('/api/import/batch-validate', {
method: 'POST',
body: formData
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Batch validation failed');
}
const result = await response.json();
displayBatchValidationResults(result.batch_validation_results, result.summary.all_valid);
} catch (error) {
console.error('Batch validation error:', error);
showAlert('Batch validation failed: ' + error.message, 'danger');
} finally {
showProgress(false);
}
}
function displayBatchValidationResults(results, allValid) {
const panel = document.getElementById('validationPanel');
const container = document.getElementById('validationResults');
const statusClass = allValid ? 'success' : 'warning';
const statusIcon = allValid ? 'circle-check text-success-600' : 'triangle-exclamation text-warning-600';
const validCount = results.filter(r => r.valid).length;
const invalidCount = results.filter(r => !r.valid && r.error !== 'Unsupported file type').length;
const errorCount = results.filter(r => r.error && !r.valid).length;
const unsupportedCount = results.filter(r => r.error === 'Unsupported file type').length;
let html = `
<div class="p-4 bg-${statusClass}-100 dark:bg-${statusClass}-900/30 rounded-lg mb-4">
<div class="flex items-center gap-2">
<i class="fa-solid fa-${statusIcon}"></i>
<span class="font-medium">Batch validation ${allValid ? 'passed' : 'completed with issues'}</span>
</div>
<div class="text-sm mt-2">
Validated ${results.length} files:
<span class="text-success-600 dark:text-success-400">${validCount} valid</span>,
<span class="text-warning-600 dark:text-warning-400">${invalidCount} invalid</span>,
<span class="text-danger-600 dark:text-danger-400">${errorCount} errors</span>,
<span class="text-neutral-600 dark:text-neutral-400">${unsupportedCount} unsupported</span>
</div>
</div>
`;
html += '<h6 class="text-sm font-semibold mb-3">File Validation Details</h6>';
html += '<div class="space-y-2">';
results.forEach(result => {
let resultClass, resultIcon, status;
if (result.valid) {
resultClass = 'success';
resultIcon = 'circle-check';
status = 'valid';
} else if (result.error === 'Unsupported file type') {
resultClass = 'neutral';
resultIcon = 'circle-info';
status = 'unsupported';
} else if (result.error && result.error.includes('failed')) {
resultClass = 'danger';
resultIcon = 'circle-xmark';
status = 'error';
} else {
resultClass = 'warning';
resultIcon = 'triangle-exclamation';
status = 'invalid';
}
html += `
<div class="p-3 bg-${resultClass}-100 dark:bg-${resultClass}-900/30 rounded-lg">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<i class="fa-solid fa-${resultIcon} text-${resultClass}-600 dark:text-${resultClass}-400"></i>
<strong class="text-sm">${result.file_type}</strong>
</div>
<div class="text-sm text-${resultClass}-600 dark:text-${resultClass}-400 capitalize">${status}</div>
</div>
`;
if (result.headers && result.headers.mapped) {
const mappedCount = Object.keys(result.headers.mapped).length;
const unmappedCount = (result.headers.unmapped || []).length;
html += `<p class="text-xs text-neutral-600 dark:text-neutral-400 mt-1">${mappedCount} mapped, ${unmappedCount} unmapped</p>`;
}
if (result.total_errors > 0) {
html += `<p class="text-xs text-neutral-600 dark:text-neutral-400 mt-1">${result.total_errors} data validation errors found</p>`;
}
if (result.error) {
html += `<p class="text-xs text-${resultClass}-600 dark:text-${resultClass}-400 mt-1">${result.error}</p>`;
}
html += `</div>`;
});
html += '</div>';
if (allValid) {
html += `
<div class="mt-4 p-3 bg-success-100 dark:bg-success-900/30 rounded-lg">
<div class="flex items-center gap-2">
<i class="fa-solid fa-thumbs-up text-success-600"></i>
<span class="text-sm font-medium text-success-700 dark:text-success-300">All files passed validation! Ready for import.</span>
</div>
</div>
`;
} else {
html += `
<div class="mt-4 p-3 bg-warning-100 dark:bg-warning-900/30 rounded-lg">
<div class="flex items-center gap-2">
<i class="fa-solid fa-exclamation-triangle text-warning-600"></i>
<span class="text-sm font-medium text-warning-700 dark:text-warning-300">Some files have issues. Review the details above before importing.</span>
</div>
</div>
`;
}
container.innerHTML = html;
panel.classList.remove('hidden');
// Scroll to validation panel
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function updateSelectedFiles() { function updateSelectedFiles() {
const fileInput = document.getElementById('batchFiles'); const fileInput = document.getElementById('batchFiles');
const countSpan = document.getElementById('selectedFilesCount'); const countSpan = document.getElementById('selectedFilesCount');
@@ -734,19 +1062,91 @@ async function handleBatchImport(event) {
const fileInput = document.getElementById('batchFiles'); const fileInput = document.getElementById('batchFiles');
const replaceExisting = document.getElementById('batchReplaceExisting').checked; const replaceExisting = document.getElementById('batchReplaceExisting').checked;
const validateFirst = document.getElementById('validateAllFirst').checked;
if (!fileInput.files || fileInput.files.length === 0) { if (!fileInput.files || fileInput.files.length === 0) {
showAlert('Please select at least one CSV file', 'warning'); showAlert('Please select at least one CSV file', 'warning');
return; return;
} }
if (fileInput.files.length > 20) { if (fileInput.files.length > 25) {
showAlert('Maximum 20 files allowed per batch', 'warning'); showAlert('Maximum 25 files allowed per batch', 'warning');
return; return;
} }
importInProgress = true; importInProgress = true;
// Validate all files first if option is selected
if (validateFirst) {
try {
showProgress(true, 'Pre-validating all files before import...');
const validationResults = [];
let hasErrors = false;
for (let i = 0; i < fileInput.files.length; i++) {
const file = fileInput.files[i];
const fileName = file.name;
showProgress(true, `Pre-validating ${fileName} (${i + 1}/${fileInput.files.length})...`);
if (availableFiles.available_files && availableFiles.available_files.includes(fileName)) {
try {
const formData = new FormData();
formData.append('file', file);
const response = await window.http.wrappedFetch(`/api/import/validate/${fileName}`, {
method: 'POST',
body: formData
});
if (response.ok) {
const result = await response.json();
if (!result.valid) {
hasErrors = true;
validationResults.push({ fileName, valid: false, errors: result.validation_errors || [] });
}
} else {
hasErrors = true;
validationResults.push({ fileName, valid: false, errors: ['Validation request failed'] });
}
} catch (error) {
hasErrors = true;
validationResults.push({ fileName, valid: false, errors: [error.message] });
}
} else {
hasErrors = true;
validationResults.push({ fileName, valid: false, errors: ['Unsupported file type'] });
}
}
if (hasErrors) {
importInProgress = false;
showProgress(false);
let errorMessage = 'Pre-validation found issues in the following files:\n\n';
validationResults.forEach(result => {
if (!result.valid) {
errorMessage += `• ${result.fileName}: ${result.errors.join(', ')}\n`;
}
});
errorMessage += '\nPlease fix these issues before importing, or disable "Validate all files before import" to proceed anyway.';
showAlert(errorMessage, 'danger');
return;
} else {
showAlert('All files passed pre-validation. Proceeding with import...', 'success');
}
} catch (error) {
importInProgress = false;
showProgress(false);
showAlert('Pre-validation failed: ' + error.message, 'danger');
return;
}
}
// Continue with import if validation passed or was skipped
const formData = new FormData(); const formData = new FormData();
for (let file of fileInput.files) { for (let file of fileInput.files) {
formData.append('files', file); formData.append('files', file);
@@ -846,6 +1246,12 @@ function displayBatchResults(result) {
</div> </div>
</div> </div>
<p class="text-sm text-neutral-600 dark:text-neutral-400 mt-1">${fileResult.message}</p> <p class="text-sm text-neutral-600 dark:text-neutral-400 mt-1">${fileResult.message}</p>
${fileResult.auto_mapping ? `
<div class=\"mt-2 text-xs text-neutral-600 dark:text-neutral-400\">
<span>${Object.keys(fileResult.auto_mapping.mapped_headers || {}).length} mapped</span>
<span class=\"ml-2\">${(fileResult.auto_mapping.unmapped_headers || []).length} unmapped (stored as flexible)</span>
</div>
` : ''}
</div> </div>
`; `;
}); });
@@ -856,6 +1262,198 @@ function displayBatchResults(result) {
panel.classList.remove('hidden'); panel.classList.remove('hidden');
} }
async function loadRecentBatches(append) {
try {
const params = new URLSearchParams();
params.set('limit', String(recentState.limit));
params.set('offset', String(recentState.offset));
if (recentState.status && recentState.status !== 'all') params.set('status', recentState.status);
if (recentState.start) params.set('start', recentState.start);
if (recentState.end) params.set('end', recentState.end);
const resp = await window.http.wrappedFetch(`/api/import/recent-batches?${params.toString()}`);
if (!resp.ok) return;
const data = await resp.json();
const rows = (data.recent || []).map(r => `
<tr class="hover:bg-neutral-50 dark:hover:bg-neutral-800 cursor-pointer" onclick="viewAuditDetails(${r.id})">
<td class="px-3 py-2 text-sm"><span class="inline-block px-2 py-0.5 rounded ${r.status === 'success' ? 'bg-green-100 text-green-700' : (r.status === 'completed_with_errors' ? 'bg-yellow-100 text-yellow-700' : (r.status === 'running' ? 'bg-blue-100 text-blue-700' : 'bg-red-100 text-red-700'))}">${r.status}</span></td>
<td class="px-3 py-2 text-sm">${r.started_at ? new Date(r.started_at).toLocaleString() : ''}</td>
<td class="px-3 py-2 text-sm">${r.finished_at ? new Date(r.finished_at).toLocaleString() : ''}</td>
<td class="px-3 py-2 text-sm">${r.successful_files}/${r.total_files}</td>
<td class="px-3 py-2 text-sm">${Number(r.total_imported || 0).toLocaleString()}</td>
<td class="px-3 py-2 text-right text-sm"><button class="px-2 py-1 border rounded" onclick="event.stopPropagation();downloadAuditJson(${r.id})">JSON</button></td>
</tr>
`).join('');
if (!append) {
const html = `
<div class="mb-3 flex flex-wrap items-end gap-2">
<div>
<label class="block text-xs text-neutral-600 dark:text-neutral-400 mb-1">Status</label>
<select id="recentStatusFilter" class="px-2 py-1 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded text-sm">
<option value="all" ${recentState.status==='all'?'selected':''}>All</option>
<option value="running" ${recentState.status==='running'?'selected':''}>Running</option>
<option value="success" ${recentState.status==='success'?'selected':''}>Success</option>
<option value="completed_with_errors" ${recentState.status==='completed_with_errors'?'selected':''}>Completed with errors</option>
<option value="failed" ${recentState.status==='failed'?'selected':''}>Failed</option>
</select>
</div>
<div>
<label class="block text-xs text-neutral-600 dark:text-neutral-400 mb-1">Start</label>
<input id="recentStartFilter" type="datetime-local" class="px-2 py-1 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded text-sm" value="${recentState.start || ''}">
</div>
<div>
<label class="block text-xs text-neutral-600 dark:text-neutral-400 mb-1">End</label>
<input id="recentEndFilter" type="datetime-local" class="px-2 py-1 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded text-sm" value="${recentState.end || ''}">
</div>
<div class="ml-auto">
<button id="recentApplyBtn" class="px-3 py-1.5 bg-neutral-100 dark:bg-neutral-700 hover:bg-neutral-200 dark:hover:bg-neutral-600 rounded">Apply</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm border border-neutral-200 dark:border-neutral-700 rounded">
<thead class="bg-neutral-50 dark:bg-neutral-800">
<tr>
<th class="px-3 py-2 text-left">Status</th>
<th class="px-3 py-2 text-left">Started</th>
<th class="px-3 py-2 text-left">Finished</th>
<th class="px-3 py-2 text-left">Files</th>
<th class="px-3 py-2 text-left">Imported</th>
<th class="px-3 py-2 text-right">Actions</th>
</tr>
</thead>
<tbody id="recentBatchesTableBody">
${rows || '<tr><td class="px-3 py-3 text-neutral-500" colspan="6">No batch uploads</td></tr>'}
</tbody>
</table>
</div>
<div class="mt-3 text-center">
<button id="recentLoadMoreBtn" class="px-3 py-1.5 border rounded ${((data.offset||0)+(data.recent?.length||0)) >= (data.total||0) ? 'opacity-50 cursor-not-allowed' : ''}">Load more</button>
<span class="ml-2 text-xs text-neutral-500">Showing ${(data.offset||0)+(data.recent?.length||0)} of ${data.total||0}</span>
</div>
`;
document.getElementById('recentBatches').innerHTML = html;
const statusEl = document.getElementById('recentStatusFilter');
const startEl = document.getElementById('recentStartFilter');
const endEl = document.getElementById('recentEndFilter');
const applyBtn = document.getElementById('recentApplyBtn');
const loadMoreBtn = document.getElementById('recentLoadMoreBtn');
if (applyBtn) applyBtn.addEventListener('click', (e) => {
e.preventDefault();
recentState.status = statusEl.value || 'all';
recentState.start = startEl.value || '';
recentState.end = endEl.value || '';
recentState.offset = 0;
loadRecentBatches(false);
});
if (loadMoreBtn) loadMoreBtn.addEventListener('click', (e) => {
e.preventDefault();
if (((data.offset||0)+(data.recent?.length||0)) >= (data.total||0)) return;
recentState.offset = (data.offset||0) + (data.recent?.length||0);
loadRecentBatches(true);
});
} else {
const tbody = document.getElementById('recentBatchesTableBody');
if (tbody) tbody.insertAdjacentHTML('beforeend', rows);
const showing = recentState.offset + (data.recent?.length || 0);
const loadMoreBtn = document.getElementById('recentLoadMoreBtn');
if (loadMoreBtn && showing >= (data.total||0)) {
loadMoreBtn.classList.add('opacity-50', 'cursor-not-allowed');
}
}
} catch (_) {}
}
async function viewAuditDetails(auditId) {
try {
const resp = await window.http.wrappedFetch(`/api/import/recent-batches/${auditId}`);
if (!resp.ok) return;
const data = await resp.json();
const files = (data.files || []).map(f => `
<tr>
<td class="px-3 py-2 text-sm font-mono">${f.file_type}</td>
<td class="px-3 py-2 text-sm"><span class="inline-block px-2 py-0.5 rounded ${f.status === 'success' ? 'bg-green-100 text-green-700' : (f.status === 'completed_with_errors' ? 'bg-yellow-100 text-yellow-700' : 'bg-red-100 text-red-700')}">${f.status}</span></td>
<td class="px-3 py-2 text-sm">${f.imported_count}</td>
<td class="px-3 py-2 text-sm">${f.errors}</td>
<td class="px-3 py-2 text-sm">${f.message || ''}</td>
</tr>
`).join('');
const hasFailed = Number(data.audit.failed_files || 0) > 0;
const content = `
<div class="space-y-2 text-sm">
<div><strong>Status:</strong> ${data.audit.status}</div>
<div><strong>Started:</strong> ${data.audit.started_at ? new Date(data.audit.started_at).toLocaleString() : ''}</div>
<div><strong>Finished:</strong> ${data.audit.finished_at ? new Date(data.audit.finished_at).toLocaleString() : ''}</div>
<div><strong>Files:</strong> ${data.audit.successful_files}/${data.audit.total_files}
<button class="ml-2 px-2 py-1 border rounded" onclick="downloadAuditJson(${data.audit.id})">Download JSON</button>
${hasFailed ? `<button class="ml-2 px-2 py-1 border rounded" onclick="rerunFailedFiles(${data.audit.id})">Rerun failed files</button>` : ''}
</div>
<div class="overflow-x-auto mt-2">
<table class="w-full text-sm border border-neutral-200 dark:border-neutral-700 rounded">
<thead class="bg-neutral-50 dark:bg-neutral-800">
<tr>
<th class="px-3 py-2 text-left">File</th>
<th class="px-3 py-2 text-left">Status</th>
<th class="px-3 py-2 text-left">Imported</th>
<th class="px-3 py-2 text-left">Errors</th>
<th class="px-3 py-2 text-left">Message</th>
</tr>
</thead>
<tbody>
${files || '<tr><td class="px-3 py-3 text-neutral-500" colspan="5">No file records</td></tr>'}
</tbody>
</table>
</div>
</div>
`;
if (window.alerts && window.alerts.show) {
window.alerts.show(content, 'info', { html: true, duration: 0, title: `Batch #${data.audit.id}` });
} else {
alert(`Batch ${data.audit.id}: ${data.audit.status}`);
}
} catch (_) {}
}
async function downloadAuditJson(auditId) {
try {
const resp = await window.http.wrappedFetch(`/api/import/recent-batches/${auditId}`);
if (!resp.ok) return;
const data = await resp.json();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `import_audit_${auditId}.json`;
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(url);
a.remove();
} catch (_) {}
}
async function rerunFailedFiles(auditId) {
try {
const confirmReplace = confirm('Replace existing records for these file types before rerun? Click OK to replace, Cancel to append.');
const formData = new FormData();
if (confirmReplace) formData.append('replace_existing', 'true');
showProgress(true, 'Re-running failed files...');
const resp = await window.http.wrappedFetch(`/api/import/recent-batches/${auditId}/rerun-failed`, { method: 'POST', body: formData });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || 'Rerun failed');
}
const result = await resp.json();
displayBatchResults(result);
await loadRecentBatches(false);
showAlert('Rerun completed', 'success');
} catch (e) {
console.error('Rerun failed', e);
showAlert('Rerun failed: ' + (e?.message || 'Unknown error'), 'danger');
} finally {
showProgress(false);
}
}
function showAlert(message, type = 'info') { function showAlert(message, type = 'info') {
if (window.alerts && typeof window.alerts.show === 'function') { if (window.alerts && typeof window.alerts.show === 'function') {
window.alerts.show(message, type); window.alerts.show(message, type);

View File

@@ -441,6 +441,8 @@ function initializeAdvancedSearch() {
}); });
} }
// Use shared highlight utilities
async function loadSearchFacets() { async function loadSearchFacets() {
try { try {
const response = await window.http.wrappedFetch('/api/search/facets'); const response = await window.http.wrappedFetch('/api/search/facets');
@@ -744,6 +746,9 @@ function displaySearchResults(data) {
const resultsContainer = document.getElementById('searchResults'); const resultsContainer = document.getElementById('searchResults');
const statusElement = document.getElementById('searchStatus'); const statusElement = document.getElementById('searchStatus');
const facetsCard = document.getElementById('facetsCard'); const facetsCard = document.getElementById('facetsCard');
const tokens = (window.highlightUtils && typeof window.highlightUtils.buildTokens === 'function')
? window.highlightUtils.buildTokens(currentSearchCriteria.query || '')
: [];
// Update status // Update status
const executionTime = data.stats?.search_execution_time || 0; const executionTime = data.stats?.search_execution_time || 0;
@@ -777,6 +782,9 @@ function displaySearchResults(data) {
data.results.forEach(result => { data.results.forEach(result => {
const typeIcon = getTypeIcon(result.type); const typeIcon = getTypeIcon(result.type);
const typeBadge = getTypeBadge(result.type); const typeBadge = getTypeBadge(result.type);
const matchHtml = (window.highlightUtils && typeof window.highlightUtils.formatSnippet === 'function')
? window.highlightUtils.formatSnippet(result.highlight, tokens)
: (result.highlight || '');
resultsHTML += ` resultsHTML += `
<div class="search-result-item border-b py-3"> <div class="search-result-item border-b py-3">
@@ -787,7 +795,7 @@ function displaySearchResults(data) {
<div class="flex-1"> <div class="flex-1">
<div class="flex justify-between items-start mb-1"> <div class="flex justify-between items-start mb-1">
<h6 class="mb-1"> <h6 class="mb-1">
<a href="${result.url}" class="hover:underline">${result.title}</a> <a href="${result.url}" class="hover:underline">${window.highlightUtils ? window.highlightUtils.highlight(result.title || '', tokens) : (result.title || '')}</a>
${typeBadge} ${typeBadge}
</h6> </h6>
<div class="text-right"> <div class="text-right">
@@ -795,16 +803,20 @@ function displaySearchResults(data) {
${result.updated_at ? `<br><small class="text-neutral-500">${formatDate(result.updated_at)}</small>` : ''} ${result.updated_at ? `<br><small class="text-neutral-500">${formatDate(result.updated_at)}</small>` : ''}
</div> </div>
</div> </div>
<p class="mb-1 text-neutral-500">${result.description}</p> <p class="mb-1 text-neutral-500">${window.highlightUtils ? window.highlightUtils.highlight(result.description || '', tokens) : (result.description || '')}</p>
${result.highlight ? `<div class="text-sm text-info-600"><strong>Match:</strong> ${result.highlight}</div>` : ''} ${matchHtml ? `<div class="text-sm text-info-600"><strong>Match:</strong> ${matchHtml}</div>` : ''}
${displayResultMetadata(result.metadata)} ${displayResultMetadata(result.metadata, tokens)}
</div> </div>
</div> </div>
</div> </div>
`; `;
}); });
resultsContainer.innerHTML = resultsHTML; if (window.setSafeHTML) {
window.setSafeHTML(resultsContainer, resultsHTML);
} else {
resultsContainer.innerHTML = resultsHTML;
}
// Display pagination // Display pagination
if (data.page_info.total_pages > 1) { if (data.page_info.total_pages > 1) {
@@ -903,14 +915,23 @@ function getTypeBadge(type) {
return badges[type] || ''; return badges[type] || '';
} }
function displayResultMetadata(metadata) { function displayResultMetadata(metadata, tokens) {
if (!metadata) return ''; if (!metadata) return '';
let metadataHTML = '<div class="text-sm text-neutral-500 mt-1">'; let metadataHTML = '<div class="text-sm text-neutral-500 mt-1">';
Object.entries(metadata).forEach(([key, value]) => { Object.entries(metadata).forEach(([key, value]) => {
if (value && key !== 'phones') { // Skip complex objects if (value && key !== 'phones') { // Skip complex objects
metadataHTML += `<span class="mr-3"><strong>${key.replace('_', ' ')}:</strong> ${value}</span>`; const label = (window.highlightUtils && window.highlightUtils.escape)
? window.highlightUtils.escape(String(key).replace('_', ' '))
: String(key).replace('_', ' ');
const valStr = typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
? String(value)
: '';
const valueHtml = (window.highlightUtils && typeof window.highlightUtils.highlight === 'function')
? window.highlightUtils.highlight(valStr, Array.isArray(tokens) ? tokens : [])
: valStr;
metadataHTML += `<span class="mr-3"><strong>${label}:</strong> ${valueHtml}</span>`;
} }
}); });

View File

@@ -1,185 +1,110 @@
#!/usr/bin/env python3 """Tests for Customers API using FastAPI TestClient (no live server required)."""
""" import os
Test script for the customers module import uuid
"""
import requests
import pytest
import json
from datetime import datetime from datetime import datetime
BASE_URL = "http://localhost:6920" import pytest
from fastapi.testclient import TestClient
# Ensure required env vars for app import/config
os.environ.setdefault("SECRET_KEY", "x" * 32)
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/delphi_test.sqlite")
from app.main import app # noqa: E402
from app.auth.security import get_current_user # noqa: E402
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def token(): def client():
"""Obtain an access token from the running server, or skip if unavailable.""" # Override auth to bypass JWT for these tests
try: app.dependency_overrides[get_current_user] = lambda: {
response = requests.post(f"{BASE_URL}/api/auth/login", json={ "id": "test",
"username": "admin", "username": "tester",
"password": "admin123" "is_admin": True,
}, timeout=3) "is_active": True,
if response.status_code == 200: }
data = response.json()
if data and data.get("access_token"):
return data["access_token"]
except Exception:
pass
pytest.skip("Auth server not available; skipping integration tests")
def test_auth():
"""Test authentication"""
print("🔐 Testing authentication...")
# First, create an admin user if needed
try: try:
response = requests.post(f"{BASE_URL}/api/auth/register", json={ yield TestClient(app)
"username": "admin", finally:
"email": "admin@delphicg.local", app.dependency_overrides.pop(get_current_user, None)
"password": "admin123",
"full_name": "System Administrator",
"is_admin": True
})
print(f"Registration: {response.status_code}")
except Exception as e:
print(f"Registration may already exist: {e}")
# Login
response = requests.post(f"{BASE_URL}/api/auth/login", json={
"username": "admin",
"password": "admin123"
})
if response.status_code == 200:
token_data = response.json()
token = token_data["access_token"]
print(f"✅ Login successful, token: {token[:20]}...")
return token
else:
print(f"❌ Login failed: {response.status_code} - {response.text}")
return None
def test_customers_api(token):
"""Test customers API endpoints""" def test_customers_crud_and_queries(client: TestClient):
headers = {"Authorization": f"Bearer {token}"} unique_id = f"TEST-{uuid.uuid4().hex[:8]}"
print("\n📋 Testing Customers API...") # List (empty or not, but should 200)
resp = client.get("/api/customers/?limit=5")
# Test getting customers list (should be empty initially) assert resp.status_code == 200
response = requests.get(f"{BASE_URL}/api/customers/", headers=headers) assert isinstance(resp.json(), list)
print(f"Get customers: {response.status_code}")
if response.status_code == 200: # Create
customers = response.json() payload = {
print(f"Found {len(customers)} customers") "id": unique_id,
# Test creating a customer
test_customer = {
"id": "TEST001",
"last": "Doe", "last": "Doe",
"first": "John", "first": "John",
"middle": "Q",
"prefix": "Mr.",
"title": "Attorney",
"group": "Client",
"a1": "123 Main Street",
"a2": "Suite 100",
"city": "Dallas", "city": "Dallas",
"abrev": "TX", "abrev": "TX",
"zip": "75201",
"email": "john.doe@example.com", "email": "john.doe@example.com",
"legal_status": "Petitioner", "memo": "Created by pytest",
"memo": "Test customer created by automated test"
} }
resp = client.post("/api/customers/", json=payload)
response = requests.post(f"{BASE_URL}/api/customers/", json=test_customer, headers=headers) assert resp.status_code == 200
print(f"Create customer: {response.status_code}") body = resp.json()
if response.status_code == 200: assert body["id"] == unique_id
created_customer = response.json() assert body["last"] == "Doe"
print(f"✅ Created customer: {created_customer['id']} - {created_customer['last']}")
customer_id = created_customer['id']
else:
print(f"❌ Create failed: {response.text}")
return
# Test adding phone numbers
phone1 = {"location": "Office", "phone": "(214) 555-0100"}
phone2 = {"location": "Mobile", "phone": "(214) 555-0101"}
for phone in [phone1, phone2]:
response = requests.post(f"{BASE_URL}/api/customers/{customer_id}/phones",
json=phone, headers=headers)
print(f"Add phone {phone['location']}: {response.status_code}")
# Test getting customer with phones
response = requests.get(f"{BASE_URL}/api/customers/{customer_id}", headers=headers)
if response.status_code == 200:
customer = response.json()
print(f"✅ Customer has {len(customer['phone_numbers'])} phone numbers")
for phone in customer['phone_numbers']:
print(f" {phone['location']}: {phone['phone']}")
# Test search functionality
response = requests.get(f"{BASE_URL}/api/customers/?search=Doe", headers=headers)
if response.status_code == 200:
results = response.json()
print(f"✅ Search for 'Doe' found {len(results)} results")
# Test phone search
response = requests.get(f"{BASE_URL}/api/customers/search/phone?phone=214", headers=headers)
if response.status_code == 200:
results = response.json()
print(f"✅ Phone search for '214' found {len(results)} results")
# Test stats
response = requests.get(f"{BASE_URL}/api/customers/stats", headers=headers)
if response.status_code == 200:
stats = response.json()
print(f"✅ Stats: {stats['total_customers']} customers, {stats['total_phone_numbers']} phones")
print(f" Groups: {[g['group'] + ':' + str(g['count']) for g in stats['group_breakdown']]}")
# Test updating customer
update_data = {"memo": f"Updated at {datetime.now().isoformat()}"}
response = requests.put(f"{BASE_URL}/api/customers/{customer_id}",
json=update_data, headers=headers)
print(f"Update customer: {response.status_code}")
print(f"\n✅ All customer API tests completed successfully!")
return customer_id
def test_web_page(): # Add phones
"""Test the web page loads""" for phone in ("(214) 555-0100", "(214) 555-0101"):
print("\n🌐 Testing web page...") resp = client.post(
f"/api/customers/{unique_id}/phones",
# Test health endpoint json={"location": "Office", "phone": phone},
response = requests.get(f"{BASE_URL}/health") )
print(f"Health check: {response.status_code}") assert resp.status_code == 200
# Test customers page (will require authentication in browser) # Retrieve and assert phones present
response = requests.get(f"{BASE_URL}/customers") resp = client.get(f"/api/customers/{unique_id}")
print(f"Customers page: {response.status_code}") assert resp.status_code == 200
if response.status_code == 200: customer = resp.json()
print("✅ Customers page loads successfully") assert customer["id"] == unique_id
else: assert len(customer.get("phone_numbers", [])) >= 2
print(f"Note: Customers page requires authentication (status {response.status_code})")
# Search by last name
resp = client.get("/api/customers/?search=Doe")
assert resp.status_code == 200
assert any(c["id"] == unique_id for c in resp.json())
# Phone search
resp = client.get("/api/customers/search/phone", params={"phone": "214"})
assert resp.status_code == 200
assert isinstance(resp.json(), list)
# Stats endpoint returns expected fields
resp = client.get("/api/customers/stats")
assert resp.status_code == 200
stats = resp.json()
assert {"total_customers", "total_phone_numbers", "customers_with_email", "group_breakdown"} <= set(stats.keys())
# Update
resp = client.put(
f"/api/customers/{unique_id}",
json={"memo": f"Updated at {datetime.utcnow().isoformat()}"},
)
assert resp.status_code == 200
# Cleanup
resp = client.delete(f"/api/customers/{unique_id}")
assert resp.status_code == 200
def test_pages_and_health(client: TestClient):
# Health
resp = client.get("/health")
assert resp.status_code == 200
assert resp.json().get("status") == "healthy"
# Customers page should load HTML
resp = client.get("/customers")
assert resp.status_code in (200, 401, 403) or resp.headers.get("content-type", "").startswith("text/html")
def main():
print("🚀 Testing Delphi Database Customers Module")
print("=" * 50)
# Test authentication
token = test_auth()
if not token:
print("❌ Cannot proceed without authentication")
return
# Test API endpoints
customer_id = test_customers_api(token)
# Test web interface
test_web_page()
print("\n🎉 Customer module testing completed!")
print(f"🌐 Visit http://localhost:6920/customers to see the web interface")
print(f"📚 API docs available at http://localhost:6920/docs")
if __name__ == "__main__":
main()

11
tests/conftest.py Normal file
View File

@@ -0,0 +1,11 @@
import os
# Ensure required settings exist for app modules imported during tests
os.environ.setdefault("SECRET_KEY", "x" * 32)
# Use a file-based SQLite DB so metadata.create_all and sessions share state
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/delphi_test.sqlite")
os.environ.setdefault("PYTEST_RUNNING", "1")
os.environ.setdefault("DISABLE_LOG_ENQUEUE", "1")

25
tests/helpers.py Normal file
View File

@@ -0,0 +1,25 @@
def assert_validation_error(resp, field_name: str):
assert resp.status_code == 422
body = resp.json()
assert body.get("success") is False
assert body.get("error", {}).get("code") == "validation_error"
# Ensure correlation id is present and echoed in header
cid = body.get("correlation_id")
assert isinstance(cid, str) and cid
assert resp.headers.get("X-Correlation-ID") == cid
# Ensure the field appears in details
details = body.get("error", {}).get("details", [])
assert any(field_name in ":".join(map(str, err.get("loc", []))) for err in details)
def assert_http_error(resp, status_code: int, message_substr: str):
assert resp.status_code == status_code
body = resp.json()
assert body.get("success") is False
assert body.get("error", {}).get("code") == "http_error"
assert message_substr in body.get("error", {}).get("message", "")
cid = body.get("correlation_id")
assert isinstance(cid, str) and cid
assert resp.headers.get("X-Correlation-ID") == cid

101
tests/test_admin_api.py Normal file
View File

@@ -0,0 +1,101 @@
import os
import uuid
import pytest
from fastapi.testclient import TestClient
os.environ.setdefault("SECRET_KEY", "x" * 32)
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/delphi_test.sqlite")
from app.main import app # noqa: E402
from app.auth.security import get_current_user, get_admin_user # noqa: E402
from tests.helpers import assert_http_error # noqa: E402
class _User:
def __init__(self, is_admin: bool):
self.id = 1 if is_admin else 2
self.username = "admin" if is_admin else "user"
self.is_admin = is_admin
self.is_active = True
self.first_name = "Test"
self.last_name = "User"
@pytest.fixture()
def client_admin():
app.dependency_overrides[get_current_user] = lambda: _User(True)
app.dependency_overrides[get_admin_user] = lambda: _User(True)
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_current_user, None)
app.dependency_overrides.pop(get_admin_user, None)
@pytest.fixture()
def client_user():
app.dependency_overrides[get_current_user] = lambda: _User(False)
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_current_user, None)
def test_admin_only_access(client_user: TestClient):
# Drop auth to simulate unauthenticated
app.dependency_overrides.pop(get_current_user, None)
c = TestClient(app)
resp = c.get("/api/admin/health")
assert_http_error(resp, 403, "Not authenticated")
# Authenticated non-admin should get 403 from admin endpoints
app.dependency_overrides[get_current_user] = lambda: _User(False)
resp = c.get("/api/admin/users")
assert_http_error(resp, 403, "Not enough permissions")
def test_lookup_crud_file_types_and_statuses_and_audit(client_admin: TestClient):
# List lookup tables
resp = client_admin.get("/api/admin/lookups/tables")
assert resp.status_code == 200
assert "tables" in resp.json()
# Create a system setting (as a simple admin CRUD target)
skey = f"test_setting_{uuid.uuid4().hex[:6]}"
resp = client_admin.post(
"/api/admin/settings",
json={
"setting_key": skey,
"setting_value": "on",
"description": "pytest",
"setting_type": "STRING",
},
)
assert resp.status_code == 200
assert resp.json()["setting"]["setting_key"] == skey
# Update the setting
resp = client_admin.put(
f"/api/admin/settings/{skey}",
json={"setting_value": "off", "description": "changed"},
)
assert resp.status_code == 200
assert resp.json()["setting"]["setting_value"] == "off"
# Read the setting
resp = client_admin.get(f"/api/admin/settings/{skey}")
assert resp.status_code == 200
assert resp.json()["setting_key"] == skey
# Delete the setting
resp = client_admin.delete(f"/api/admin/settings/{skey}")
assert resp.status_code == 200
# Verify audit logs endpoint is accessible and returns structure
resp = client_admin.get("/api/admin/audit/logs")
assert resp.status_code == 200
body = resp.json()
assert set(body.keys()) == {"total", "logs"}

View File

@@ -0,0 +1,168 @@
import os
import uuid
import pytest
from fastapi.testclient import TestClient
# Ensure required env vars for app import/config
os.environ.setdefault("SECRET_KEY", "x" * 32)
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/delphi_test.sqlite")
from app.main import app # noqa: E402
from app.auth.security import get_current_user # noqa: E402
@pytest.fixture(scope="module")
def client():
# Override auth to bypass JWT for these tests
class _User:
def __init__(self):
self.id = "test"
self.username = "tester"
self.is_admin = True
self.is_active = True
app.dependency_overrides[get_current_user] = lambda: _User()
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_current_user, None)
def _assert_validation_error(resp, field_name: str):
assert resp.status_code == 422
body = resp.json()
assert body.get("success") is False
assert body.get("error", {}).get("code") == "validation_error"
# Ensure correlation id is present and echoed in header
cid = body.get("correlation_id")
assert isinstance(cid, str) and cid
assert resp.headers.get("X-Correlation-ID") == cid
# Ensure the field appears in details
details = body.get("error", {}).get("details", [])
assert any(field_name in ":".join(map(str, err.get("loc", []))) for err in details)
def _assert_http_error(resp, status_code: int, message_substr: str):
assert resp.status_code == status_code
body = resp.json()
assert body.get("success") is False
assert body.get("error", {}).get("code") == "http_error"
assert message_substr in body.get("error", {}).get("message", "")
cid = body.get("correlation_id")
assert isinstance(cid, str) and cid
assert resp.headers.get("X-Correlation-ID") == cid
def test_create_customer_invalid_email_returns_422(client: TestClient):
customer_id = f"SCHEMA-{uuid.uuid4().hex[:8]}"
payload = {
"id": customer_id,
"last": "InvalidEmail",
"email": "not-an-email",
}
resp = client.post("/api/customers/", json=payload)
_assert_validation_error(resp, "email")
def test_update_customer_invalid_email_returns_422(client: TestClient):
customer_id = f"SCHEMA-UPD-{uuid.uuid4().hex[:8]}"
# Create valid customer first
create_payload = {
"id": customer_id,
"last": "Valid",
"email": "ok@example.com",
}
resp = client.post("/api/customers/", json=create_payload)
assert resp.status_code == 200
# Attempt invalid email on update
resp = client.put(f"/api/customers/{customer_id}", json={"email": "bad"})
_assert_validation_error(resp, "email")
# Cleanup
resp = client.delete(f"/api/customers/{customer_id}")
assert resp.status_code == 200
def test_create_customer_duplicate_id_returns_400(client: TestClient):
customer_id = f"DUP-{uuid.uuid4().hex[:8]}"
payload = {
"id": customer_id,
"last": "Doe",
"email": "john.doe@example.com",
}
# First create OK
resp = client.post("/api/customers/", json=payload)
assert resp.status_code == 200
# Duplicate should be 400 with envelope
resp = client.post("/api/customers/", json=payload)
_assert_http_error(resp, 400, "Customer ID already exists")
# Cleanup
resp = client.delete(f"/api/customers/{customer_id}")
assert resp.status_code == 200
def test_get_update_delete_nonexistent_customer_404(client: TestClient):
missing_id = f"NOPE-{uuid.uuid4().hex[:8]}"
resp = client.get(f"/api/customers/{missing_id}")
_assert_http_error(resp, 404, "Customer not found")
resp = client.put(f"/api/customers/{missing_id}", json={"last": "X"})
_assert_http_error(resp, 404, "Customer not found")
resp = client.delete(f"/api/customers/{missing_id}")
_assert_http_error(resp, 404, "Customer not found")
def test_phones_endpoints_404_for_missing_customer_and_phone(client: TestClient):
missing_id = f"NOPE-{uuid.uuid4().hex[:8]}"
# Missing customer: get and add phone
resp = client.get(f"/api/customers/{missing_id}/phones")
_assert_http_error(resp, 404, "Customer not found")
resp = client.post(
f"/api/customers/{missing_id}/phones",
json={"location": "Office", "phone": "(555) 000-0000"},
)
_assert_http_error(resp, 404, "Customer not found")
# Create a real customer to test non-existent phone id
real_id = f"PHONE-{uuid.uuid4().hex[:8]}"
resp = client.post(
"/api/customers/",
json={"id": real_id, "last": "Phones", "email": "phones@example.com"},
)
assert resp.status_code == 200
# Update non-existent phone for this customer
resp = client.put(
f"/api/customers/{real_id}/phones/999999",
json={"location": "Home", "phone": "(555) 111-2222"},
)
_assert_http_error(resp, 404, "Phone number not found")
# Delete non-existent phone for this customer
resp = client.delete(f"/api/customers/{real_id}/phones/999999")
_assert_http_error(resp, 404, "Phone number not found")
# Cleanup
resp = client.delete(f"/api/customers/{real_id}")
assert resp.status_code == 200
def test_list_customers_query_param_validation_422(client: TestClient):
# limit must be >=1 and <=200
resp = client.get("/api/customers/?limit=0")
_assert_validation_error(resp, "limit")
# skip must be >=0
resp = client.get("/api/customers/?skip=-1")
_assert_validation_error(resp, "skip")

View File

@@ -0,0 +1,130 @@
import os
import io
import uuid
from datetime import date
import pytest
from fastapi.testclient import TestClient
# Ensure required env vars for app import/config
os.environ.setdefault("SECRET_KEY", "x" * 32)
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/delphi_test.sqlite")
from app.main import app # noqa: E402
from app.auth.security import get_current_user # noqa: E402
from tests.helpers import assert_http_error, assert_validation_error # noqa: E402
class _User:
def __init__(self):
self.id = 1
self.username = "uploader"
self.is_admin = True
self.is_active = True
@pytest.fixture()
def client():
app.dependency_overrides[get_current_user] = lambda: _User()
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_current_user, None)
def _create_customer_and_file(client: TestClient):
customer_id = f"UP-{uuid.uuid4().hex[:8]}"
resp = client.post(
"/api/customers/",
json={"id": customer_id, "last": "Upload", "email": "u@example.com"},
)
assert resp.status_code == 200
file_no = f"U-{uuid.uuid4().hex[:6]}"
file_payload = {
"file_no": file_no,
"id": customer_id,
"regarding": "Upload doc test",
"empl_num": "E01",
"file_type": "CIVIL",
"opened": date.today().isoformat(),
"status": "ACTIVE",
"rate_per_hour": 100.0,
}
resp = client.post("/api/files/", json=file_payload)
assert resp.status_code == 200
return file_no
def test_upload_invalid_file_type_returns_400_envelope_and_correlation_header(client: TestClient):
file_no = _create_customer_and_file(client)
files = {
"file": ("bad.txt", b"hello", "text/plain"),
}
resp = client.post(f"/api/documents/upload/{file_no}", files=files)
assert_http_error(resp, 400, "Invalid file type")
def test_upload_oversize_file_returns_400_envelope_and_correlation_header(client: TestClient):
file_no = _create_customer_and_file(client)
# 10MB + 1 byte
big_bytes = b"x" * (10 * 1024 * 1024 + 1)
files = {
"file": ("large.pdf", big_bytes, "application/pdf"),
}
resp = client.post(f"/api/documents/upload/{file_no}", files=files)
assert_http_error(resp, 400, "File too large")
def test_upload_uses_incoming_correlation_id_when_provided(client: TestClient):
file_no = _create_customer_and_file(client)
cid = f"cid-{uuid.uuid4().hex[:8]}"
files = {
"file": ("bad.txt", b"hello", "text/plain"),
}
resp = client.post(
f"/api/documents/upload/{file_no}",
files=files,
headers={"X-Correlation-ID": cid},
)
# Envelope shape and message
assert_http_error(resp, 400, "Invalid file type")
# Echoes our provided correlation id
body = resp.json()
assert resp.headers.get("X-Correlation-ID") == cid
assert body.get("correlation_id") == cid
def test_upload_without_file_returns_400_no_file_uploaded(client: TestClient):
file_no = _create_customer_and_file(client)
# Provide an empty filename to trigger the explicit 400 in route logic
files = {
# Use a valid filename but zero-byte payload to hit the 400 "No file uploaded" branch
"file": ("empty.pdf", io.BytesIO(b""), "application/pdf"),
}
resp = client.post(f"/api/documents/upload/{file_no}", files=files)
assert_http_error(resp, 400, "No file uploaded")
def test_upload_missing_file_field_returns_422_validation_envelope(client: TestClient):
file_no = _create_customer_and_file(client)
# Submit without the required `file` field
resp = client.post(
f"/api/documents/upload/{file_no}",
data={"description": "missing file"},
headers={"X-Correlation-ID": f"cid-{uuid.uuid4().hex[:8]}"},
)
# Ensure 422 envelope and correlation header; details should mention `file`
assert_validation_error(resp, "file")

155
tests/test_documents_api.py Normal file
View File

@@ -0,0 +1,155 @@
import os
import uuid
from datetime import date
import pytest
from fastapi.testclient import TestClient
os.environ.setdefault("SECRET_KEY", "x" * 32)
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/delphi_test.sqlite")
from app.main import app # noqa: E402
from app.auth.security import get_current_user # noqa: E402
from tests.helpers import assert_validation_error, assert_http_error # noqa: E402
class _User:
def __init__(self):
self.id = 1
self.username = "tester"
self.is_admin = True
self.is_active = True
@pytest.fixture()
def client():
app.dependency_overrides[get_current_user] = lambda: _User()
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_current_user, None)
def _create_customer_and_file(client: TestClient):
cust_id = f"DOC-{uuid.uuid4().hex[:8]}"
resp = client.post("/api/customers/", json={"id": cust_id, "last": "Doc", "email": "d@example.com"})
assert resp.status_code == 200
file_no = f"D-{uuid.uuid4().hex[:6]}"
payload = {
"file_no": file_no,
"id": cust_id,
"regarding": "Doc matter",
"empl_num": "E01",
"file_type": "CIVIL",
"opened": date.today().isoformat(),
"status": "ACTIVE",
"rate_per_hour": 100.0,
}
resp = client.post("/api/files/", json=payload)
assert resp.status_code == 200
return cust_id, file_no
def test_qdro_schema_validation_and_404s(client: TestClient):
# Missing required: file_no
resp = client.post("/api/documents/qdros/", json={"version": "01"})
assert_validation_error(resp, "file_no")
# Bad dates type
resp = client.post(
"/api/documents/qdros/",
json={
"file_no": "NOFILE-1",
"created_date": "not-a-date",
"approved_date": "also-bad",
},
)
assert_validation_error(resp, "created_date")
# 404 get/update/delete for missing
resp = client.get("/api/documents/qdros/NOFILE-1/999999")
assert_http_error(resp, 404, "QDRO not found")
resp = client.put("/api/documents/qdros/NOFILE-1/999999", json={"status": "APPROVED"})
assert_http_error(resp, 404, "QDRO not found")
resp = client.delete("/api/documents/qdros/NOFILE-1/999999")
assert_http_error(resp, 404, "QDRO not found")
def test_qdro_end_to_end_crud(client: TestClient):
_, file_no = _create_customer_and_file(client)
# Create
create_payload = {
"file_no": file_no,
"version": "01",
"status": "DRAFT",
"created_date": date.today().isoformat(),
"plan_name": "Plan X",
"plan_administrator": "Admin",
}
resp = client.post("/api/documents/qdros/", json=create_payload)
assert resp.status_code == 200
qdro = resp.json()
qid = qdro["id"]
assert qdro["file_no"] == file_no
# List by file
resp = client.get(f"/api/documents/qdros/{file_no}")
assert resp.status_code == 200
assert any(item["id"] == qid for item in resp.json())
# Get by composite path
resp = client.get(f"/api/documents/qdros/{file_no}/{qid}")
assert resp.status_code == 200
assert resp.json()["id"] == qid
# Update
resp = client.put(f"/api/documents/qdros/{file_no}/{qid}", json={"status": "APPROVED"})
assert resp.status_code == 200
assert resp.json()["status"] == "APPROVED"
# Delete
resp = client.delete(f"/api/documents/qdros/{file_no}/{qid}")
assert resp.status_code == 200
def test_template_crud_and_generate_requires_file_and_shapes(client: TestClient):
# Create a template
tid = f"TMP-{uuid.uuid4().hex[:6]}"
tpl_payload = {
"form_id": tid,
"form_name": "Letter",
"category": "GENERAL",
"content": "Hello {{CLIENT_FULL}} on ^TODAY for file ^FILE_NO",
}
resp = client.post("/api/documents/templates/", json=tpl_payload)
assert resp.status_code == 200
# Get template
resp = client.get(f"/api/documents/templates/{tid}")
assert resp.status_code == 200
assert resp.json()["form_id"] == tid
# Update template
resp = client.put(f"/api/documents/templates/{tid}", json={"content": "Updated"})
assert resp.status_code == 200
assert "content" in resp.json()
# Generate requires an existing file
_, file_no = _create_customer_and_file(client)
resp = client.post(
f"/api/documents/generate/{tid}",
json={"template_id": tid, "file_no": file_no, "output_format": "HTML"},
)
assert resp.status_code == 200
gen = resp.json()
assert {"document_id", "file_name", "file_path", "size", "created_at"} <= set(gen.keys())
# 404 when file missing
resp = client.post(
f"/api/documents/generate/{tid}",
json={"template_id": tid, "file_no": "X-NOFILE", "output_format": "PDF"},
)
assert_http_error(resp, 404, "File not found")

119
tests/test_files_api.py Normal file
View File

@@ -0,0 +1,119 @@
import os
import uuid
from datetime import date
import pytest
from fastapi.testclient import TestClient
# Ensure required env vars for app import/config
os.environ.setdefault("SECRET_KEY", "x" * 32)
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/delphi_test.sqlite")
from app.main import app # noqa: E402
from app.auth.security import get_current_user # noqa: E402
from tests.helpers import assert_validation_error, assert_http_error # noqa: E402
@pytest.fixture(scope="module")
def client():
# Override auth to bypass JWT for these tests
class _User:
def __init__(self):
self.id = "test"
self.username = "tester"
self.is_admin = True
self.is_active = True
app.dependency_overrides[get_current_user] = lambda: _User()
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_current_user, None)
def _create_customer(client: TestClient) -> str:
customer_id = f"FILE-CUST-{uuid.uuid4().hex[:8]}"
payload = {"id": customer_id, "last": "FileOwner", "email": "owner@example.com"}
resp = client.post("/api/customers/", json=payload)
assert resp.status_code == 200
return customer_id
def _valid_file_payload(file_no: str, owner_id: str) -> dict:
return {
"file_no": file_no,
"id": owner_id,
"regarding": "Matter description",
"empl_num": "E01",
"file_type": "CIVIL",
"opened": date.today().isoformat(),
"status": "ACTIVE",
"rate_per_hour": 200.0,
"memo": "Created by pytest",
}
def test_create_file_schema_validation_errors(client: TestClient):
owner_id = _create_customer(client)
file_no = f"F-{uuid.uuid4().hex[:6]}"
# Missing required fields should trigger validation errors
resp = client.post("/api/files/", json={})
assert_validation_error(resp, "file_no")
# Wrong types: rate_per_hour as string, opened wrong format
bad = _valid_file_payload(file_no, owner_id)
bad["rate_per_hour"] = "twenty"
bad["opened"] = "not-a-date"
resp = client.post("/api/files/", json=bad)
assert_validation_error(resp, "rate_per_hour")
assert_validation_error(resp, "opened")
def test_create_file_and_duplicate_file_no_returns_400(client: TestClient):
owner_id = _create_customer(client)
file_no = f"F-{uuid.uuid4().hex[:6]}"
payload = _valid_file_payload(file_no, owner_id)
# First create OK
resp = client.post("/api/files/", json=payload)
assert resp.status_code == 200
# Duplicate should be 400 with envelope
resp = client.post("/api/files/", json=payload)
assert_http_error(resp, 400, "File number already exists")
# Cleanup
resp = client.delete(f"/api/files/{file_no}")
assert resp.status_code == 200
def test_get_update_delete_missing_file_returns_404(client: TestClient):
missing = f"NOFILE-{uuid.uuid4().hex[:6]}"
resp = client.get(f"/api/files/{missing}")
assert_http_error(resp, 404, "File not found")
resp = client.put(f"/api/files/{missing}", json={"status": "ACTIVE"})
assert_http_error(resp, 404, "File not found")
resp = client.delete(f"/api/files/{missing}")
assert_http_error(resp, 404, "File not found")
def test_financial_and_client_info_404_for_missing_file(client: TestClient):
missing = f"NOFILE-{uuid.uuid4().hex[:6]}"
resp = client.get(f"/api/files/{missing}/financial-summary")
assert_http_error(resp, 404, "File not found")
resp = client.get(f"/api/files/{missing}/client-info")
assert_http_error(resp, 404, "File not found")
def test_list_and_advanced_search_param_validation(client: TestClient):
resp = client.get("/api/files/?limit=0")
assert_validation_error(resp, "limit")
resp = client.get("/api/files/?skip=-1")
assert_validation_error(resp, "skip")

263
tests/test_financial_api.py Normal file
View File

@@ -0,0 +1,263 @@
import os
import uuid
from datetime import date
import pytest
from fastapi.testclient import TestClient
# Ensure required env vars for app import/config
os.environ.setdefault("SECRET_KEY", "x" * 32)
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/delphi_test.sqlite")
from app.main import app # noqa: E402
from app.auth.security import get_current_user # noqa: E402
from tests.helpers import assert_validation_error, assert_http_error # noqa: E402
@pytest.fixture(scope="module")
def client():
# Override auth to bypass JWT for these tests
class _User:
def __init__(self):
self.id = "test"
self.username = "tester"
self.is_admin = True
self.is_active = True
app.dependency_overrides[get_current_user] = lambda: _User()
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_current_user, None)
def _create_customer(client: TestClient) -> str:
customer_id = f"LEDGER-CUST-{uuid.uuid4().hex[:8]}"
payload = {"id": customer_id, "last": "LedgerOwner", "email": "owner@example.com"}
resp = client.post("/api/customers/", json=payload)
assert resp.status_code == 200
return customer_id
def _create_file(client: TestClient, owner_id: str) -> str:
file_no = f"L-{uuid.uuid4().hex[:6]}"
payload = {
"file_no": file_no,
"id": owner_id,
"regarding": "Ledger matter",
"empl_num": "E01",
"file_type": "CIVIL",
"opened": date.today().isoformat(),
"status": "ACTIVE",
"rate_per_hour": 100.0,
"memo": "Created by pytest",
}
resp = client.post("/api/files/", json=payload)
assert resp.status_code == 200
return file_no
def test_ledger_create_validation_errors(client: TestClient):
owner_id = _create_customer(client)
file_no = _create_file(client, owner_id)
# Missing required fields
resp = client.post("/api/financial/ledger/", json={})
assert_validation_error(resp, "file_no")
# Wrong types for amount/date
bad = {
"file_no": file_no,
"date": "not-a-date",
"t_code": "TIME",
"t_type": "2",
"empl_num": "E01",
"quantity": 1.5,
"rate": 100.0,
"amount": "one hundred",
"billed": "N",
"note": "Invalid types",
}
resp = client.post("/api/financial/ledger/", json=bad)
assert_validation_error(resp, "date")
assert_validation_error(resp, "amount")
# Query param validation on list endpoint
resp = client.get(f"/api/financial/ledger/{file_no}?limit=0")
assert_validation_error(resp, "limit")
resp = client.get(f"/api/financial/ledger/{file_no}?skip=-1")
assert_validation_error(resp, "skip")
def test_ledger_404s_for_missing_file_and_entries(client: TestClient):
# Create against missing file
payload = {
"file_no": "NOFILE-123",
"date": date.today().isoformat(),
"t_code": "TIME",
"t_type": "2",
"empl_num": "E01",
"quantity": 1.0,
"rate": 100.0,
"amount": 100.0,
"billed": "N",
"note": "Should 404",
}
resp = client.post("/api/financial/ledger/", json=payload)
assert_http_error(resp, 404, "File not found")
# Update/delete missing entry id
resp = client.put("/api/financial/ledger/9999999", json={"amount": 10})
assert_http_error(resp, 404, "Ledger entry not found")
resp = client.delete("/api/financial/ledger/9999999")
assert_http_error(resp, 404, "Ledger entry not found")
# Report and quick endpoints on missing file
resp = client.get("/api/financial/reports/NOFILE-123")
assert_http_error(resp, 404, "File not found")
resp = client.post("/api/financial/time-entry/quick", params={"file_no": "NOFILE-123", "hours": 1.0, "description": "x"})
assert_http_error(resp, 404, "File not found")
resp = client.post("/api/financial/payments/", params={"file_no": "NOFILE-123", "amount": 10.0})
assert_http_error(resp, 404, "File not found")
resp = client.post("/api/financial/expenses/", params={"file_no": "NOFILE-123", "amount": 10.0, "description": "x"})
assert_http_error(resp, 404, "File not found")
# Bill entries with no matching ids (body expects a raw JSON array)
resp = client.post("/api/financial/bill-entries", json=[999999])
assert_http_error(resp, 404, "No entries found")
def test_ledger_totals_update_after_crud(client: TestClient):
owner_id = _create_customer(client)
file_no = _create_file(client, owner_id)
# Baseline
resp = client.get(f"/api/files/{file_no}")
assert resp.status_code == 200
file_data = resp.json()
assert file_data["total_charges"] == 0
assert file_data["amount_owing"] == 0
# 1) Create hourly time entry (t_type "2")
t_payload = {
"file_no": file_no,
"date": date.today().isoformat(),
"t_code": "TIME",
"t_type": "2",
"empl_num": "E01",
"quantity": 2.0,
"rate": 100.0,
"amount": 200.0,
"billed": "N",
"note": "Work",
}
resp = client.post("/api/financial/ledger/", json=t_payload)
assert resp.status_code == 200
time_entry = resp.json()
resp = client.get(f"/api/files/{file_no}")
f = resp.json()
assert f["hours"] == 2.0
assert f["hourly_fees"] == 200.0
assert f["total_charges"] == 200.0
assert f["amount_owing"] == 200.0
# 2) Create disbursement (t_type "4") amount 50
d_payload = {
"file_no": file_no,
"date": date.today().isoformat(),
"t_code": "MISC",
"t_type": "4",
"empl_num": "E01",
"quantity": 0.0,
"rate": 0.0,
"amount": 50.0,
"billed": "N",
"note": "Expense",
}
resp = client.post("/api/financial/ledger/", json=d_payload)
assert resp.status_code == 200
disb_entry = resp.json()
resp = client.get(f"/api/files/{file_no}")
f = resp.json()
assert f["disbursements"] == 50.0
assert f["total_charges"] == 250.0
assert f["amount_owing"] == 250.0
# 3) Create credit/payment (t_type "5") amount 100
c_payload = {
"file_no": file_no,
"date": date.today().isoformat(),
"t_code": "PMT",
"t_type": "5",
"empl_num": "E01",
"quantity": 0.0,
"rate": 0.0,
"amount": 100.0,
"billed": "Y",
"note": "Payment",
}
resp = client.post("/api/financial/ledger/", json=c_payload)
assert resp.status_code == 200
credit_entry = resp.json()
resp = client.get(f"/api/files/{file_no}")
f = resp.json()
assert f["credit_bal"] == 100.0
assert f["amount_owing"] == 150.0
# 4) Trust deposit (t_type "1") amount 80
trust_payload = {
"file_no": file_no,
"date": date.today().isoformat(),
"t_code": "TRUST",
"t_type": "1",
"empl_num": "E01",
"quantity": 0.0,
"rate": 0.0,
"amount": 80.0,
"billed": "Y",
"note": "Trust deposit",
}
resp = client.post("/api/financial/ledger/", json=trust_payload)
assert resp.status_code == 200
trust_entry = resp.json()
resp = client.get(f"/api/files/{file_no}")
f = resp.json()
assert f["trust_bal"] == 80.0
assert f["transferable"] == 80.0
# 5) Update credit entry to 200
resp = client.put(f"/api/financial/ledger/{credit_entry['id']}", json={"amount": 200.0})
assert resp.status_code == 200
resp = client.get(f"/api/files/{file_no}")
f = resp.json()
assert f["credit_bal"] == 200.0
assert f["amount_owing"] == 50.0
assert f["transferable"] == 50.0
# 6) Delete trust deposit, transferable should drop to 0
resp = client.delete(f"/api/financial/ledger/{trust_entry['id']}")
assert resp.status_code == 200
resp = client.get(f"/api/files/{file_no}")
f = resp.json()
assert f["trust_bal"] == 0.0
assert f["transferable"] == 0.0
# 7) Verify report totals consistency
resp = client.get(f"/api/financial/reports/{file_no}")
assert resp.status_code == 200
report = resp.json()
assert report["total_hours"] == 2.0
assert report["total_hourly_fees"] == 200.0
assert report["total_disbursements"] == 50.0
assert report["total_credits"] == 200.0
assert report["total_charges"] == 250.0
assert report["amount_owing"] == 50.0

View File

@@ -0,0 +1,79 @@
import io
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.import_data import router as import_router
from app.database.base import engine, SessionLocal
from app.models.base import BaseModel
from app.models.user import User
from app.models.flexible import FlexibleImport
from app.auth.security import get_current_user
def test_batch_upload_unknown_csv_saved_as_flexible_rows():
# Fresh DB
BaseModel.metadata.drop_all(bind=engine)
BaseModel.metadata.create_all(bind=engine)
# Seed an admin user
db = SessionLocal()
try:
user = User(
username="tester",
email="tester@example.com",
hashed_password="x",
is_active=True,
is_admin=True,
)
db.add(user)
db.commit()
db.refresh(user)
finally:
db.close()
# Minimal app with import router and auth override
app = FastAPI()
app.include_router(import_router, prefix="/api/import")
def _override_current_user():
return user # type: ignore[return-value]
app.dependency_overrides[get_current_user] = _override_current_user
client = TestClient(app)
# Unknown CSV that should fall back to flexible import
csv_bytes = b"alpha,beta\n1,2\n3,4\n"
files = [("files", ("UNKNOWN.csv", io.BytesIO(csv_bytes), "text/csv"))]
resp = client.post("/api/import/batch-upload", files=files)
assert resp.status_code == 200
body = resp.json()
# Assert API result shows success and correct row count
results = body.get("batch_results", [])
assert any(
r.get("file_type") == "UNKNOWN.csv"
and r.get("status") == "success"
and r.get("imported_count") == 2
for r in results
)
# Assert rows persisted in flexible storage
db = SessionLocal()
try:
rows = (
db.query(FlexibleImport)
.filter(FlexibleImport.file_type == "UNKNOWN.csv")
.order_by(FlexibleImport.id.asc())
.all()
)
assert len(rows) == 2
assert rows[0].target_table is None
assert set(rows[0].extra_data.keys()) == {"alpha", "beta"}
assert set(rows[1].extra_data.keys()) == {"alpha", "beta"}
finally:
db.close()

View File

@@ -0,0 +1,103 @@
import uuid
from fastapi.testclient import TestClient
from app.main import app
from app.auth.security import get_current_user
from app.database.base import SessionLocal
from app.models.flexible import FlexibleImport
def test_batch_import_unknown_csv_saves_flexible_rows():
# Override auth to bypass JWT for this test
app.dependency_overrides[get_current_user] = lambda: {
"id": "test",
"username": "tester",
"is_admin": True,
"is_active": True,
}
client = TestClient(app)
unique_suffix = uuid.uuid4().hex[:8]
filename = f"UNKNOWN_TEST_{unique_suffix}.csv"
csv_content = "col1,col2\nA,B\nC,D\n"
try:
resp = client.post(
"/api/import/batch-upload",
files=[("files", (filename, csv_content, "text/csv"))],
)
assert resp.status_code == 200
payload = resp.json()
assert "batch_results" in payload
result = next((r for r in payload["batch_results"] if r["file_type"] == filename), None)
assert result is not None
assert result["status"] == "success"
assert result["imported_count"] == 2
assert result["auto_mapping"]["flexible_saved_rows"] == 2
# Verify rows persisted
db = SessionLocal()
try:
count = (
db.query(FlexibleImport)
.filter(FlexibleImport.file_type == filename)
.count()
)
assert count == 2
finally:
# Clean up created rows to keep DB tidy
db.query(FlexibleImport).filter(FlexibleImport.file_type == filename).delete()
db.commit()
db.close()
finally:
# Restore dependencies
app.dependency_overrides.pop(get_current_user, None)
def test_single_upload_flexible_creates_rows():
# Override auth to bypass JWT for this test
app.dependency_overrides[get_current_user] = lambda: {
"id": "test",
"username": "tester",
"is_admin": True,
"is_active": True,
}
client = TestClient(app)
filename = "SINGLE_UNKNOWN.csv"
csv_content = "a,b\n1,2\n3,4\n"
try:
# Upload via flexible-only endpoint
resp = client.post(
"/api/import/upload-flexible",
files={"file": (filename, csv_content, "text/csv")},
data={"replace_existing": "false"},
)
assert resp.status_code == 200
payload = resp.json()
assert payload["file_type"] == filename
assert payload["imported_count"] == 2
assert payload["auto_mapping"]["flexible_saved_rows"] == 2
# Verify rows persisted
db = SessionLocal()
try:
count = (
db.query(FlexibleImport)
.filter(FlexibleImport.file_type == filename)
.count()
)
assert count == 2
finally:
# Clean up created rows to keep DB tidy
db.query(FlexibleImport).filter(FlexibleImport.file_type == filename).delete()
db.commit()
db.close()
finally:
# Restore dependencies
app.dependency_overrides.pop(get_current_user, None)

132
tests/test_import_api.py Normal file
View File

@@ -0,0 +1,132 @@
import os
import io
import pytest
from fastapi.testclient import TestClient
# Ensure required env vars for app import/config
os.environ.setdefault("SECRET_KEY", "x" * 32)
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/delphi_test.sqlite")
from app.main import app # noqa: E402
from app.auth.security import get_current_user, get_admin_user # noqa: E402
from tests.helpers import assert_http_error # noqa: E402
class _User:
def __init__(self, is_admin: bool):
self.id = 1 if is_admin else 2
self.username = "admin" if is_admin else "user"
self.is_admin = is_admin
self.is_active = True
@pytest.fixture()
def client_admin():
app.dependency_overrides[get_current_user] = lambda: _User(True)
app.dependency_overrides[get_admin_user] = lambda: _User(True)
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_current_user, None)
app.dependency_overrides.pop(get_admin_user, None)
@pytest.fixture()
def client_user():
app.dependency_overrides[get_current_user] = lambda: _User(False)
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_current_user, None)
def _make_csv(content: str, filename: str = "ROLODEX.csv"):
return {"file": (filename, io.BytesIO(content.encode("utf-8")), "text/csv")}
def test_import_requires_auth_and_rejects_malformed_csv(client_user: TestClient):
# Unauthenticated should 403 envelope
app.dependency_overrides.pop(get_current_user, None)
c = TestClient(app)
resp = c.post("/api/import/upload/ROLODEX.csv", files=_make_csv("Id,Last\n"))
assert_http_error(resp, 403, "Not authenticated")
# Auth but malformed content: wrong extension
app.dependency_overrides[get_current_user] = lambda: _User(False)
resp = c.post("/api/import/upload/ROLODEX.csv", files={"file": ("file.txt", io.BytesIO(b"abc"), "text/plain")})
assert_http_error(resp, 400, "File must be a CSV file")
# Unsupported file type
resp = c.post("/api/import/upload/UNKNOWN.csv", files=_make_csv("x,y\n1,2\n", filename="UNKNOWN.csv"))
assert_http_error(resp, 400, "Unsupported file type")
# Severely malformed CSV that can't parse headers
bad = "" # empty
resp = c.post("/api/import/upload/ROLODEX.csv", files=_make_csv(bad))
# The importer treats empty as error in parsing or yields 500; ensure error envelope present
assert resp.status_code in (400, 500)
body = resp.json()
assert body.get("success") is False
assert resp.headers.get("X-Correlation-ID") == body.get("correlation_id")
def test_successful_import_updates_counts(client_admin: TestClient):
# Initial status counts
resp = client_admin.get("/api/import/status")
assert resp.status_code == 200
status_before = resp.json()
rolodex_before = status_before.get("ROLODEX.csv", {}).get("record_count", 0)
# Minimal valid ROLODEX import (id,last)
csv_data = "Id,Last,Email\nIMP-1,Doe,john@example.com\nIMP-2,Smith,smith@example.com\n"
resp = client_admin.post("/api/import/upload/ROLODEX.csv", files=_make_csv(csv_data))
assert resp.status_code == 200
result = resp.json()
assert result["file_type"] == "ROLODEX.csv"
assert result["imported_count"] >= 2
assert isinstance(result["auto_mapping"]["mapped_headers"], dict)
# Status after should increase
resp = client_admin.get("/api/import/status")
assert resp.status_code == 200
status_after = resp.json()
rolodex_after = status_after.get("ROLODEX.csv", {}).get("record_count", 0)
assert rolodex_after >= rolodex_before + 2
def test_batch_validate_and_batch_upload_auth_and_errors(client_admin: TestClient):
# Batch validate with too many files not triggered, but ensure happy path
files = [
("ROLODEX.csv", "Id,Last\nB1,Alpha\n"),
("FILES.csv", "File_No,Id,File_Type,Regarding,Opened,Empl_Num,Status,Rate_Per_Hour\nF-1,B1,CIVIL,Test,2024-01-01,E01,ACTIVE,100\n"),
]
payload = [("files", (name, io.BytesIO(data.encode("utf-8")), "text/csv")) for name, data in files]
resp = client_admin.post("/api/import/batch-validate", files=payload)
assert resp.status_code == 200
body = resp.json()
assert "batch_validation_results" in body
# Batch upload mixed: include unsupported file to trigger a failed result but 200 overall
files2 = [
("UNKNOWN.csv", "a,b\n1,2\n"),
("ROLODEX.csv", "Id,Last\nB2,Beta\n"),
]
payload2 = [("files", (name, io.BytesIO(data.encode("utf-8")), "text/csv")) for name, data in files2]
resp = client_admin.post("/api/import/batch-upload", files=payload2)
assert resp.status_code == 200
summary = resp.json().get("summary", {})
assert "total_files" in summary
def test_clear_requires_admin_and_unknown_type_errors(client_user: TestClient):
# Non-admin authenticated should still be able to call due to current dependency (get_current_user)
# We enforce admin via existing admin endpoint as a proxy
resp = client_user.get("/api/auth/users")
assert_http_error(resp, 403, "Not enough permissions")
# Unknown file type on clear
resp = client_user.delete("/api/import/clear/UNKNOWN.csv")
assert_http_error(resp, 400, "Unknown file type")

286
tests/test_search_api.py Normal file
View File

@@ -0,0 +1,286 @@
import os
import sys
from pathlib import Path
import uuid
from datetime import date
import pytest
from fastapi.testclient import TestClient
# Ensure required env vars for app import/config
os.environ.setdefault("SECRET_KEY", "x" * 32)
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/delphi_test.sqlite")
# Ensure repository root on sys.path for direct test runs
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.main import app # noqa: E402
from app.auth.security import get_current_user # noqa: E402
from tests.helpers import assert_validation_error # noqa: E402
from app.api.financial import LedgerCreate # noqa: E402
from app.database.base import SessionLocal # noqa: E402
from app.models.qdro import QDRO # noqa: E402
@pytest.fixture(scope="module")
def client():
# Override auth to bypass JWT for these tests
class _User:
def __init__(self):
self.id = "test"
self.username = "tester"
self.is_admin = True
self.is_active = True
app.dependency_overrides[get_current_user] = lambda: _User()
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_current_user, None)
def _create_customer(client: TestClient, last_suffix: str) -> str:
customer_id = f"SRCH-CUST-{uuid.uuid4().hex[:8]}"
payload = {
"id": customer_id,
"last": f"Search-{last_suffix}",
"first": "Unit",
"email": f"{customer_id.lower()}@example.com",
"city": "Austin",
"abrev": "TX",
}
resp = client.post("/api/customers/", json=payload)
assert resp.status_code == 200
return customer_id
def _create_file(client: TestClient, owner_id: str, regarding_token: str) -> str:
file_no = f"SRCH-F-{uuid.uuid4().hex[:6]}"
payload = {
"file_no": file_no,
"id": owner_id,
"regarding": f"Search Matter {regarding_token}",
"empl_num": "E01",
"file_type": "CIVIL",
"opened": date.today().isoformat(),
"status": "ACTIVE",
"rate_per_hour": 150.0,
"memo": "Created by search tests",
}
resp = client.post("/api/files/", json=payload)
assert resp.status_code == 200
return file_no
def test_search_customers_min_length_and_limit_validation(client: TestClient):
# q must be at least 2 chars
resp = client.get("/api/search/customers", params={"q": "a"})
assert_validation_error(resp, "q")
# limit must be between 1 and 100
resp = client.get("/api/search/customers", params={"q": "ab", "limit": 0})
assert_validation_error(resp, "limit")
resp = client.get("/api/search/customers", params={"q": "ab", "limit": 101})
assert_validation_error(resp, "limit")
def test_search_files_min_length_and_limit_validation(client: TestClient):
resp = client.get("/api/search/files", params={"q": "a"})
assert_validation_error(resp, "q")
resp = client.get("/api/search/files", params={"q": "ab", "limit": 0})
assert_validation_error(resp, "limit")
resp = client.get("/api/search/files", params={"q": "ab", "limit": 101})
assert_validation_error(resp, "limit")
def test_search_customers_results_and_filtering(client: TestClient):
token = f"TOK-{uuid.uuid4().hex[:6]}"
id1 = _create_customer(client, f"{token}-Alpha")
id2 = _create_customer(client, f"{token}-Beta")
# Search by shared token
resp = client.get("/api/search/customers", params={"q": token, "limit": 50})
assert resp.status_code == 200
results = resp.json()
assert isinstance(results, list)
assert all(r.get("type") == "customer" for r in results)
ids = {r.get("id") for r in results}
assert id1 in ids and id2 in ids
# Limit parameter should restrict result count
resp = client.get("/api/search/customers", params={"q": token, "limit": 1})
assert resp.status_code == 200
assert isinstance(resp.json(), list) and len(resp.json()) == 1
def test_search_files_results_and_filtering(client: TestClient):
token = f"FTOK-{uuid.uuid4().hex[:6]}"
owner_id = _create_customer(client, f"Owner-{token}")
f1 = _create_file(client, owner_id, regarding_token=f"{token}-Alpha")
f2 = _create_file(client, owner_id, regarding_token=f"{token}-Beta")
# Search by token in regarding
resp = client.get("/api/search/files", params={"q": token, "limit": 50})
assert resp.status_code == 200
results = resp.json()
assert isinstance(results, list)
assert all(r.get("type") == "file" for r in results)
file_nos = {r.get("id") for r in results}
assert f1 in file_nos and f2 in file_nos
# Limit restricts results
resp = client.get("/api/search/files", params={"q": token, "limit": 1})
assert resp.status_code == 200
assert isinstance(resp.json(), list) and len(resp.json()) == 1
def test_search_case_insensitive_matching_and_highlight_preserves_casing(client: TestClient):
token = f"MC-{uuid.uuid4().hex[:6]}"
# Create customers with specific casing
id_upper = _create_customer(client, f"{token}-SMITH")
id_mixed = _create_customer(client, f"{token}-Smithson")
# Mixed-case query should match both via case-insensitive search
resp = client.get("/api/search/customers", params={"q": token.lower()})
assert resp.status_code == 200
results = resp.json()
ids = {r.get("id") for r in results}
assert id_upper in ids and id_mixed in ids
# Now search files with mixed-case regarding
owner_id = id_upper
file_no = _create_file(client, owner_id, regarding_token=f"{token}-DoE")
# Query should be case-insensitive
resp = client.get("/api/search/files", params={"q": token.lower()})
assert resp.status_code == 200
files = resp.json()
file_ids = {r.get("id") for r in files}
assert file_no in file_ids
# Ensure highlight preserves original casing in snippet when server supplies text
# For customers highlight may include Name/Email/City with original case
cust = next(r for r in results if r.get("id") == id_upper)
# Server should return a snippet with <strong> around matches, preserving original casing
if cust.get("highlight"):
assert "<strong>" in cust["highlight"]
# The word 'Search' prefix should remain with original case if present
assert any(tag in cust["highlight"] for tag in ["Name:", "City:", "Email:"])
# Also create a ledger entry with mixed-case note and ensure highlight
resp = client.post(
"/api/financial/ledger/",
json=LedgerCreate(
file_no=file_no,
date=date.today().isoformat(),
t_code="NOTE",
t_type="2",
empl_num="E01",
quantity=0.0,
rate=0.0,
amount=0.0,
billed="N",
note=f"MixedCase DoE note {token}"
).model_dump(mode="json")
)
assert resp.status_code == 200
# Ledger search via global endpoints isn't exposed directly here, but query through legacy ledger search when available
# We can at least ensure files search returns highlight on regarding; ledger highlight is already unit-tested
def _create_qdro_with_form_name(file_no: str, form_name: str) -> int:
db = SessionLocal()
try:
qdro = QDRO(file_no=file_no, form_name=form_name, status="DRAFT")
db.add(qdro)
db.commit()
db.refresh(qdro)
return qdro.id
finally:
db.close()
def test_advanced_search_highlights_mixed_case_for_customer_file_qdro(client: TestClient):
token_mixed = f"MiXeD{uuid.uuid4().hex[:6]}"
token_lower = token_mixed.lower()
# Customer with mixed-case in name
cust_id = _create_customer(client, last_suffix=token_mixed)
# File with mixed-case in regarding
file_no = _create_file(client, cust_id, regarding_token=token_mixed)
# QDRO seeded directly with mixed-case in form_name
qdro_id = _create_qdro_with_form_name(file_no, form_name=f"Form {token_mixed} Plan")
# Advanced search across types
payload = {
"query": token_lower,
"search_types": ["customer", "file", "qdro"],
"limit": 50,
}
resp = client.post("/api/search/advanced", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data.get("total_results", 0) >= 3
# Index by (type, id)
results = data["results"]
by_key = {(r["type"], r["id"]): r for r in results}
# Customer
cust_res = by_key.get(("customer", cust_id))
assert cust_res is not None and isinstance(cust_res.get("highlight"), str)
assert "<strong>" in cust_res["highlight"]
assert f"<strong>{token_mixed}</strong>" in cust_res["highlight"]
# File
file_res = by_key.get(("file", file_no))
assert file_res is not None and isinstance(file_res.get("highlight"), str)
assert "<strong>" in file_res["highlight"]
assert f"<strong>{token_mixed}</strong>" in file_res["highlight"]
# QDRO
qdro_res = by_key.get(("qdro", qdro_id))
assert qdro_res is not None and isinstance(qdro_res.get("highlight"), str)
assert "<strong>" in qdro_res["highlight"]
assert f"<strong>{token_mixed}</strong>" in qdro_res["highlight"]
def test_global_search_highlights_mixed_case_for_customer_file_qdro(client: TestClient):
token_mixed = f"MiXeD{uuid.uuid4().hex[:6]}"
token_lower = token_mixed.lower()
# Seed data
cust_id = _create_customer(client, last_suffix=token_mixed)
file_no = _create_file(client, cust_id, regarding_token=token_mixed)
qdro_id = _create_qdro_with_form_name(file_no, form_name=f"QDRO {token_mixed} Case")
# Global search
resp = client.get("/api/search/global", params={"q": token_lower, "limit": 50})
assert resp.status_code == 200
data = resp.json()
# Customers
custs = data.get("customers", [])
cust = next((r for r in custs if r.get("id") == cust_id), None)
assert cust is not None and isinstance(cust.get("highlight"), str)
assert "<strong>" in cust["highlight"]
assert f"<strong>{token_mixed}</strong>" in cust["highlight"]
# Files
files = data.get("files", [])
fil = next((r for r in files if r.get("id") == file_no), None)
assert fil is not None and isinstance(fil.get("highlight"), str)
assert "<strong>" in fil["highlight"]
assert f"<strong>{token_mixed}</strong>" in fil["highlight"]
# QDROs
qdros = data.get("qdros", [])
q = next((r for r in qdros if r.get("id") == qdro_id), None)
assert q is not None and isinstance(q.get("highlight"), str)
assert "<strong>" in q["highlight"]
assert f"<strong>{token_mixed}</strong>" in q["highlight"]

View File

@@ -0,0 +1,186 @@
from app.api.search_highlight import (
build_query_tokens,
highlight_text,
create_customer_highlight,
create_file_highlight,
create_ledger_highlight,
create_qdro_highlight,
)
def test_build_query_tokens_dedup_and_trim():
tokens = build_query_tokens(' John, Smith; "Smith" (J.) ')
assert tokens == ['John', 'Smith', 'J']
def test_highlight_text_case_insensitive_preserves_original():
out = highlight_text('John Smith', ['joHN', 'smiTH'])
assert out == '<strong>John</strong> <strong>Smith</strong>'
def test_highlight_text_overlapping_tokens():
out = highlight_text('Anna and Ann went', ['ann', 'anna'])
# Should highlight both; merged ranges will encompass 'Anna' first, then 'Ann'
assert '<strong>Anna</strong>' in out
assert ' and <strong>Ann</strong> went' in out
def test_highlight_text_multiple_occurrences():
out = highlight_text('alpha beta alpha', ['alpha'])
assert out.count('<strong>alpha</strong>') == 2
def test_highlight_text_returns_original_when_token_absent():
out = highlight_text('Hello World', ['zzz'])
assert out == 'Hello World'
def test_highlight_text_merges_overlapping_tokens_single_range():
out = highlight_text('banana', ['ana', 'nan'])
assert out == 'b<strong>anana</strong>'
def test_build_query_tokens_mixed_case_dedup_order_preserving():
tokens = build_query_tokens('ALPHA alpha Beta beta BETA')
assert tokens == ['ALPHA', 'Beta']
def test_build_query_tokens_trims_wrapping_punctuation_and_ignores_empties():
tokens = build_query_tokens('...Alpha!!!, __Alpha__, (Beta); "beta";; gamma---')
assert tokens == ['Alpha', 'Beta', 'gamma']
def test_build_query_tokens_empty_input():
assert build_query_tokens(' ') == []
def _make_customer(**attrs):
obj = type("CustomerStub", (), {})()
for k, v in attrs.items():
setattr(obj, k, v)
return obj
def test_create_customer_highlight_prefers_name_over_other_fields():
customer = _make_customer(first='John', last='Smith', email='john@example.com', city='Johnstown')
out = create_customer_highlight(customer, 'john')
assert out.startswith('Name: ')
assert 'Email:' not in out and 'City:' not in out
def test_create_customer_highlight_uses_email_when_name_not_matching():
customer = _make_customer(first='Alice', last='Wonder', email='johnson@example.com', city='Paris')
out = create_customer_highlight(customer, 'john')
assert out.startswith('Email: ')
def test_create_customer_highlight_uses_city_when_only_city_matches():
customer = _make_customer(first='Alice', last='Wonder', email='awonder@example.com', city='Ann Arbor')
out = create_customer_highlight(customer, 'arbor')
assert out.startswith('City: ')
def test_create_customer_highlight_requires_full_query_in_single_field():
customer = _make_customer(first='John', last='Smith', email='js@example.com', city='Boston')
# 'john boston' does not occur in any single attribute; should return empty string
out = create_customer_highlight(customer, 'john boston')
assert out == ''
def test_create_customer_highlight_highlights_both_tokens_in_full_name():
customer = _make_customer(first='John', last='Smith', email='js@example.com', city='Boston')
out = create_customer_highlight(customer, 'John Smith')
assert out == 'Name: <strong>John</strong> <strong>Smith</strong>'
def _make_file(**attrs):
obj = type("FileStub", (), {})()
for k, v in attrs.items():
setattr(obj, k, v)
return obj
def test_create_file_highlight_prefers_matter_over_type():
file_obj = _make_file(regarding='Divorce Matter - John Doe', file_type='QDRO')
out = create_file_highlight(file_obj, 'divorce')
assert out.startswith('Matter: ')
assert '<strong>Divorce</strong>' in out
def test_create_file_highlight_uses_type_when_matter_not_matching():
file_obj = _make_file(regarding='Miscellaneous', file_type='Income Tax')
out = create_file_highlight(file_obj, 'tax')
assert out.startswith('Type: ')
# Preserve original casing from the source
assert '<strong>Tax</strong>' in out
def test_create_file_highlight_returns_empty_when_no_match():
file_obj = _make_file(regarding='Misc', file_type='General')
out = create_file_highlight(file_obj, 'unrelated')
assert out == ''
def _make_ledger(**attrs):
obj = type("LedgerStub", (), {})()
for k, v in attrs.items():
setattr(obj, k, v)
return obj
def test_create_ledger_highlight_truncates_to_160_with_suffix_and_highlights():
prefix = 'x' * 50
match = 'AlphaBeta'
filler = 'y' * 200
marker_after = 'ZZZ_AFTER'
note_text = prefix + match + filler + marker_after
ledger = _make_ledger(note=note_text)
out = create_ledger_highlight(ledger, 'alpha')
assert out.startswith('Note: ')
# Should include highlight within the preview
assert '<strong>Alpha</strong>Beta' in out
# Should be truncated with suffix because original length > 160
assert out.endswith('...')
# Ensure content after 160 chars (marker_after) is not present
assert 'ZZZ_AFTER' not in out
def _make_qdro(**attrs):
obj = type("QdroStub", (), {})()
for k, v in attrs.items():
setattr(obj, k, v)
return obj
def test_create_qdro_highlight_prefers_form_name_over_pet_and_case():
qdro = _make_qdro(form_name='Domestic Relations Form - QDRO', pet='Jane Doe', case_number='2024-XYZ')
out = create_qdro_highlight(qdro, 'qdro')
assert out.startswith('Form: ')
assert '<strong>QDRO</strong>' in out
def test_create_qdro_highlight_uses_pet_when_form_not_matching():
qdro = _make_qdro(form_name='Child Support', pet='John Johnson', case_number='A-1')
out = create_qdro_highlight(qdro, 'john')
assert out.startswith('Petitioner: ')
def test_create_qdro_highlight_uses_case_when_only_case_matches():
qdro = _make_qdro(form_name='Child Support', pet='Mary Jane', case_number='Case 12345')
out = create_qdro_highlight(qdro, 'case 123')
assert out.startswith('Case: ')
assert '<strong>Case</strong>' in out and '<strong>123</strong>' in out
def test_create_qdro_highlight_none_or_empty_fields_return_empty():
qdro = _make_qdro(form_name=None, pet=None, case_number=None)
assert create_qdro_highlight(qdro, 'anything') == ''
populated = _make_qdro(form_name='Form A', pet='Pet B', case_number='C-1')
assert create_qdro_highlight(populated, '') == ''
def test_create_qdro_highlight_requires_full_query_in_single_field():
# Tokens present across fields but not as a contiguous substring in any single field
qdro = _make_qdro(form_name='QDRO Plan', pet='Alpha', case_number='123')
out = create_qdro_highlight(qdro, 'plan 123')
assert out == ''

View File

@@ -0,0 +1,85 @@
import os
import pytest
from fastapi.testclient import TestClient
# Ensure required env vars for app import/config
os.environ.setdefault("SECRET_KEY", "x" * 32)
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/delphi_test.sqlite")
from app.main import app # noqa: E402
from app.auth.security import get_current_user, get_admin_user # noqa: E402
from tests.helpers import assert_validation_error, assert_http_error # noqa: E402
class _User:
def __init__(self, is_admin: bool):
self.id = 1 if is_admin else 2
self.username = "admin" if is_admin else "user"
self.is_admin = is_admin
self.is_active = True
self.first_name = "Test"
self.last_name = "User"
@pytest.fixture()
def client_admin():
app.dependency_overrides[get_current_user] = lambda: _User(True)
app.dependency_overrides[get_admin_user] = lambda: _User(True)
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_current_user, None)
app.dependency_overrides.pop(get_admin_user, None)
@pytest.fixture()
def client_user():
app.dependency_overrides[get_current_user] = lambda: _User(False)
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_current_user, None)
def test_get_inactivity_warning_minutes_requires_auth_and_returns_shape(client_user: TestClient):
# Unauthenticated should 401 envelope
app.dependency_overrides.pop(get_current_user, None)
c = TestClient(app)
resp = c.get("/api/settings/inactivity_warning_minutes")
assert_http_error(resp, 403, "Not authenticated")
# Authenticated returns minutes field
app.dependency_overrides[get_current_user] = lambda: _User(False)
resp = c.get("/api/settings/inactivity_warning_minutes")
assert resp.status_code == 200
data = resp.json()
assert set(data.keys()) == {"minutes"}
assert isinstance(data["minutes"], int)
def test_update_theme_preference_validation_and_auth(client_user: TestClient):
# Invalid theme value
resp = client_user.post("/api/auth/theme-preference", json={"theme_preference": "blue"})
assert_http_error(resp, 400, "Theme preference must be 'light' or 'dark'")
# Valid update
resp = client_user.post("/api/auth/theme-preference", json={"theme_preference": "dark"})
assert resp.status_code == 200
body = resp.json()
assert body == {"message": "Theme preference updated successfully", "theme": "dark"}
# Unauthenticated should 401
app.dependency_overrides.pop(get_current_user, None)
c = TestClient(app)
resp = c.post("/api/auth/theme-preference", json={"theme_preference": "light"})
assert_http_error(resp, 403, "Not authenticated")
# If there are admin-only settings updates later, assert 403 for non-admin.
# Placeholder: demonstrate 403 behavior using a known admin-only endpoint (/api/auth/users)
def test_non_admin_forbidden_on_admin_endpoints(client_user: TestClient):
resp = client_user.get("/api/auth/users")
assert_http_error(resp, 403, "Not enough permissions")

122
tests/test_support_api.py Normal file
View File

@@ -0,0 +1,122 @@
import os
import uuid
import pytest
from fastapi.testclient import TestClient
# Ensure required env vars for app import/config
os.environ.setdefault("SECRET_KEY", "x" * 32)
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/delphi_test.sqlite")
from app.main import app # noqa: E402
from app.auth.security import get_current_user, get_admin_user # noqa: E402
from tests.helpers import assert_validation_error, assert_http_error # noqa: E402
@pytest.fixture(scope="module")
def client():
class _Admin:
def __init__(self):
self.id = 1
self.username = "admin"
self.is_admin = True
self.is_active = True
self.first_name = "Admin"
self.last_name = "User"
# For public create, current_user is optional; override admin endpoints
app.dependency_overrides[get_admin_user] = lambda: _Admin()
app.dependency_overrides[get_current_user] = lambda: _Admin()
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_admin_user, None)
app.dependency_overrides.pop(get_current_user, None)
def test_create_ticket_validation_errors(client: TestClient):
# Missing required fields
resp = client.post("/api/support/tickets", json={})
assert_validation_error(resp, "subject")
# Too short subject/description and invalid email
payload = {
"subject": "Hey",
"description": "short",
"contact_name": "",
"contact_email": "not-an-email",
}
resp = client.post("/api/support/tickets", json=payload)
assert_validation_error(resp, "subject")
assert_validation_error(resp, "description")
assert_validation_error(resp, "contact_name")
assert_validation_error(resp, "contact_email")
def _valid_ticket_payload() -> dict:
token = uuid.uuid4().hex[:6]
return {
"subject": f"Support issue {token}",
"description": "A reproducible problem description long enough",
"category": "bug_report",
"priority": "medium",
"contact_name": "John Tester",
"contact_email": f"john.{token}@example.com",
"current_page": "/dashboard",
"browser_info": "pytest-agent",
}
def test_ticket_lifecycle_and_404s_with_audit(client: TestClient):
# Create ticket (public)
payload = _valid_ticket_payload()
resp = client.post("/api/support/tickets", json=payload)
assert resp.status_code == 200
body = resp.json()
ticket_id = body["ticket_id"]
assert body["status"] == "created"
# Get ticket as admin
resp = client.get(f"/api/support/tickets/{ticket_id}")
assert resp.status_code == 200
detail = resp.json()
assert detail["status"] == "open"
assert isinstance(detail.get("responses"), list)
# 404 on missing ticket get/update/respond
resp = client.get("/api/support/tickets/999999")
assert_http_error(resp, 404, "Ticket not found")
resp = client.put("/api/support/tickets/999999", json={"status": "in_progress"})
assert_http_error(resp, 404, "Ticket not found")
resp = client.post("/api/support/tickets/999999/responses", json={"message": "x"})
assert_http_error(resp, 404, "Ticket not found")
# State transitions: open -> in_progress -> resolved
resp = client.put(f"/api/support/tickets/{ticket_id}", json={"status": "in_progress"})
assert resp.status_code == 200
resp = client.get(f"/api/support/tickets/{ticket_id}")
assert resp.status_code == 200
assert resp.json()["status"] == "in_progress"
# Add public response
resp = client.post(
f"/api/support/tickets/{ticket_id}/responses",
json={"message": "We are working on it", "is_internal": False},
)
assert resp.status_code == 200
# Resolve ticket
resp = client.put(f"/api/support/tickets/{ticket_id}", json={"status": "resolved"})
assert resp.status_code == 200
resp = client.get(f"/api/support/tickets/{ticket_id}")
data = resp.json()
assert data["status"] == "resolved"
assert data["resolved_at"] is not None
# Basic list with pagination params should 200
resp = client.get("/api/support/tickets", params={"skip": 0, "limit": 10})
assert resp.status_code == 200
assert isinstance(resp.json(), list)