coming together
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
281
app/api/flexible.py
Normal 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
@@ -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
141
app/api/search_highlight.py
Normal 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 ""
|
||||||
|
|
||||||
|
|
||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
17
app/main.py
17
app/main.py
@@ -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"""
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
37
app/models/flexible.py
Normal 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
6539
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
133
static/js/__tests__/alerts.ui.test.js
Normal file
133
static/js/__tests__/alerts.ui.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
79
static/js/__tests__/highlight.test.js
Normal file
79
static/js/__tests__/highlight.test.js
Normal 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('<div>');
|
||||||
|
expect(out).toContain('&');
|
||||||
|
expect(out).toContain('"');
|
||||||
|
expect(out).toContain(''');
|
||||||
|
expect(esc('Tom & Jerry')).toBe('Tom & 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('<b>');
|
||||||
|
expect(result).toMatch(/<strong>John<\/strong>/i);
|
||||||
|
expect(result).toMatch(/<strong>Smith<\/strong>/i);
|
||||||
|
// Ampersand must be escaped
|
||||||
|
expect(result).toContain('& 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>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
123
static/js/__tests__/upload.ui.test.js
Normal file
123
static/js/__tests__/upload.ui.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
314
static/js/flexible.js
Normal 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
95
static/js/highlight.js
Normal 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
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSafeHTML(element, html) {
|
function setSafeHTML(element, html) {
|
||||||
|
|||||||
46
static/js/upload-helper.js
Normal file
46
static/js/upload-helper.js
Normal 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;
|
||||||
|
})();
|
||||||
|
|
||||||
|
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
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, '"')}')"><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, '"')}')"><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, '"')}', '${((client.first || '') + ' ' + (client.last || '')).replace(/"/g, '"')}', '${(`${client.city || ''}, ${client.abrev || ''}`).replace(/"/g, '"')}')">Select</button>
|
||||||
</td>
|
</td>
|
||||||
`;
|
`;
|
||||||
tbody.appendChild(row);
|
tbody.appendChild(row);
|
||||||
|
|||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
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, '"') : ''}">`;
|
||||||
|
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
113
templates/flexible.html
Normal 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 %}
|
||||||
|
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
11
tests/conftest.py
Normal 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
25
tests/helpers.py
Normal 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
101
tests/test_admin_api.py
Normal 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"}
|
||||||
|
|
||||||
|
|
||||||
168
tests/test_customers_edge_cases.py
Normal file
168
tests/test_customers_edge_cases.py
Normal 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")
|
||||||
|
|
||||||
|
|
||||||
130
tests/test_document_upload_envelope.py
Normal file
130
tests/test_document_upload_envelope.py
Normal 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
155
tests/test_documents_api.py
Normal 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
119
tests/test_files_api.py
Normal 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
263
tests/test_financial_api.py
Normal 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
|
||||||
|
|
||||||
|
|
||||||
79
tests/test_flexible_batch_import.py
Normal file
79
tests/test_flexible_batch_import.py
Normal 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()
|
||||||
|
|
||||||
|
|
||||||
103
tests/test_flexible_import.py
Normal file
103
tests/test_flexible_import.py
Normal 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
132
tests/test_import_api.py
Normal 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
286
tests/test_search_api.py
Normal 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"]
|
||||||
186
tests/test_search_highlight_utils.py
Normal file
186
tests/test_search_highlight_utils.py
Normal 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 == ''
|
||||||
|
|
||||||
85
tests/test_settings_api.py
Normal file
85
tests/test_settings_api.py
Normal 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
122
tests/test_support_api.py
Normal 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)
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user