Compare commits
4 Commits
de983a73d2
...
2e49340663
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e49340663 | ||
|
|
6174df42b4 | ||
|
|
6aa4d59a25 | ||
|
|
227c74294f |
3
.env
Normal file
3
.env
Normal file
@@ -0,0 +1,3 @@
|
||||
# Delphi Database Environment Configuration
|
||||
SECRET_KEY=your-secret-key-here-change-this-in-production
|
||||
DATABASE_URL=sqlite:///./delphi.db
|
||||
16
TODO.md
16
TODO.md
@@ -6,20 +6,20 @@ Refer to `del.plan.md` for context. Check off items as they’re completed.
|
||||
- [ ] Create requirements.txt with minimal deps
|
||||
- [ ] Copy delphi-logo.webp into static/logo/
|
||||
- [ ] Set up SQLAlchemy Base and engine/session helpers
|
||||
- [ ] Add User model with username and password_hash
|
||||
- [x] Add User model with username and password_hash
|
||||
- [ ] Add Client model (rolodex_id and core fields)
|
||||
- [ ] Add Phone model with FK to Client
|
||||
- [ ] Add Case model (file_no unique, FK to Client)
|
||||
- [ ] Add Transaction model with FK to Case
|
||||
- [ ] Add Document model with FK to Case
|
||||
- [ ] Add Payment model with FK to Case
|
||||
- [ ] Create tables and seed default admin user
|
||||
- [ ] Create FastAPI app with DB session dependency
|
||||
- [ ] Add SessionMiddleware with SECRET_KEY from env
|
||||
- [ ] Configure Jinja2 templates and mount static files
|
||||
- [ ] Create base.html with Bootstrap 5 CDN and nav
|
||||
- [ ] Implement login form, POST handler, and logout
|
||||
- [ ] Create login.html form
|
||||
- [x] Create tables and seed default admin user
|
||||
- [x] Create FastAPI app with DB session dependency
|
||||
- [x] Add SessionMiddleware with SECRET_KEY from env
|
||||
- [x] Configure Jinja2 templates and mount static files
|
||||
- [x] Create base.html with Bootstrap 5 CDN and nav
|
||||
- [x] Implement login form, POST handler, and logout
|
||||
- [x] Create login.html form
|
||||
- [ ] Implement dashboard route listing cases
|
||||
- [ ] Add simple search by file_no/name/keyword
|
||||
- [ ] Create dashboard.html with table and search box
|
||||
|
||||
BIN
app/__pycache__/auth.cpython-313.pyc
Normal file
BIN
app/__pycache__/auth.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
186
app/auth.py
Normal file
186
app/auth.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
Authentication utilities for Delphi Database application.
|
||||
|
||||
This module provides password hashing, user authentication, and session management
|
||||
functions for secure user login/logout functionality.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.orm import Session
|
||||
from .models import User
|
||||
from .database import SessionLocal
|
||||
|
||||
# Configure password hashing context
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""
|
||||
Hash a password using bcrypt.
|
||||
|
||||
Args:
|
||||
password (str): Plain text password to hash
|
||||
|
||||
Returns:
|
||||
str: Hashed password
|
||||
"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""
|
||||
Verify a password against its hash.
|
||||
|
||||
Args:
|
||||
plain_password (str): Plain text password to verify
|
||||
hashed_password (str): Hashed password to check against
|
||||
|
||||
Returns:
|
||||
bool: True if password matches hash, False otherwise
|
||||
"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def authenticate_user(username: str, password: str) -> User | None:
|
||||
"""
|
||||
Authenticate a user with username and password.
|
||||
|
||||
Args:
|
||||
username (str): Username to authenticate
|
||||
password (str): Password to verify
|
||||
|
||||
Returns:
|
||||
User | None: User object if authentication successful, None otherwise
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if not user:
|
||||
logger.warning(f"Authentication failed: User '{username}' not found")
|
||||
return None
|
||||
|
||||
if not verify_password(password, user.password_hash):
|
||||
logger.warning(f"Authentication failed: Invalid password for user '{username}'")
|
||||
return None
|
||||
|
||||
if not user.is_active:
|
||||
logger.warning(f"Authentication failed: User '{username}' is inactive")
|
||||
return None
|
||||
|
||||
logger.info(f"User '{username}' authenticated successfully")
|
||||
return user
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Authentication error for user '{username}': {e}")
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def create_user(username: str, password: str, is_active: bool = True) -> User | None:
|
||||
"""
|
||||
Create a new user with hashed password.
|
||||
|
||||
Args:
|
||||
username (str): Username for the new user
|
||||
password (str): Plain text password (will be hashed)
|
||||
is_active (bool): Whether the user should be active
|
||||
|
||||
Returns:
|
||||
User | None: Created user object if successful, None if user already exists
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Check if user already exists
|
||||
existing_user = db.query(User).filter(User.username == username).first()
|
||||
if existing_user:
|
||||
logger.warning(f"User creation failed: User '{username}' already exists")
|
||||
return None
|
||||
|
||||
# Hash the password
|
||||
password_hash = hash_password(password)
|
||||
|
||||
# Create new user
|
||||
new_user = User(
|
||||
username=username,
|
||||
password_hash=password_hash,
|
||||
is_active=is_active
|
||||
)
|
||||
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
logger.info(f"User '{username}' created successfully")
|
||||
return new_user
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error creating user '{username}': {e}")
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def seed_admin_user() -> None:
|
||||
"""
|
||||
Create a default admin user if one doesn't exist.
|
||||
|
||||
This function should be called during application startup to ensure
|
||||
there's at least one admin user for initial access.
|
||||
"""
|
||||
admin_username = "admin"
|
||||
admin_password = "admin123" # In production, use a more secure default
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Check if admin user already exists
|
||||
existing_admin = db.query(User).filter(User.username == admin_username).first()
|
||||
if existing_admin:
|
||||
logger.info(f"Admin user '{admin_username}' already exists")
|
||||
return
|
||||
|
||||
# Create admin user
|
||||
admin_user = create_user(admin_username, admin_password)
|
||||
if admin_user:
|
||||
logger.info(f"Default admin user '{admin_username}' created successfully")
|
||||
else:
|
||||
logger.error("Failed to create default admin user")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error seeding admin user: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_current_user_from_session(session_data: dict) -> User | None:
|
||||
"""
|
||||
Get current user from session data.
|
||||
|
||||
Args:
|
||||
session_data (dict): Session data dictionary
|
||||
|
||||
Returns:
|
||||
User | None: Current user if session is valid, None otherwise
|
||||
"""
|
||||
user_id = session_data.get("user_id")
|
||||
if not user_id:
|
||||
return None
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = db.query(User).filter(User.id == user_id, User.is_active == True).first()
|
||||
if not user:
|
||||
logger.warning(f"No active user found for session user_id: {user_id}")
|
||||
return None
|
||||
|
||||
return user
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving current user from session: {e}")
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
@@ -33,8 +33,8 @@ engine = create_engine(
|
||||
# Create session factory
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
# Create declarative base for models
|
||||
Base = declarative_base()
|
||||
# Import Base from models for SQLAlchemy 1.x compatibility
|
||||
from .models import Base
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
@@ -68,6 +68,14 @@ def create_tables() -> None:
|
||||
"""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# Seed default admin user after creating tables
|
||||
try:
|
||||
from .auth import seed_admin_user
|
||||
seed_admin_user()
|
||||
except ImportError:
|
||||
# Handle case where auth module isn't available yet during initial import
|
||||
pass
|
||||
|
||||
|
||||
def get_database_url() -> str:
|
||||
"""
|
||||
|
||||
292
app/main.py
292
app/main.py
@@ -5,20 +5,68 @@ This module initializes the FastAPI application, sets up database connections,
|
||||
and provides the main application instance.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import FastAPI, Depends, Request, Query
|
||||
from fastapi.responses import RedirectResponse
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import or_
|
||||
from dotenv import load_dotenv
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from .database import create_tables, get_db, get_database_url
|
||||
from .models import User
|
||||
from .models import User, Case, Client
|
||||
from .auth import authenticate_user, get_current_user_from_session
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Get SECRET_KEY from environment variables
|
||||
SECRET_KEY = os.getenv("SECRET_KEY")
|
||||
if not SECRET_KEY:
|
||||
raise ValueError("SECRET_KEY environment variable must be set")
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configure Jinja2 templates
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
class AuthMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Simple session-based authentication middleware.
|
||||
|
||||
Redirects unauthenticated users to /login for protected routes.
|
||||
"""
|
||||
|
||||
def __init__(self, app, exempt_paths: list[str] | None = None):
|
||||
super().__init__(app)
|
||||
self.exempt_paths = exempt_paths or []
|
||||
|
||||
async def dispatch(self, request, call_next):
|
||||
path = request.url.path
|
||||
|
||||
# Allow exempt paths and static assets
|
||||
if (
|
||||
path in self.exempt_paths
|
||||
or path.startswith("/static")
|
||||
or path.startswith("/favicon")
|
||||
):
|
||||
return await call_next(request)
|
||||
|
||||
# Enforce authentication for other paths
|
||||
if not request.session.get("user_id"):
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
@@ -54,6 +102,25 @@ app = FastAPI(
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# Add CORS middleware for cross-origin requests
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # In production, specify allowed origins
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Register authentication middleware with exempt paths
|
||||
EXEMPT_PATHS = ["/", "/health", "/login", "/logout"]
|
||||
app.add_middleware(AuthMiddleware, exempt_paths=EXEMPT_PATHS)
|
||||
|
||||
# Add SessionMiddleware for session management (must be added LAST so it runs FIRST)
|
||||
app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY)
|
||||
|
||||
# Mount static files directory
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
@@ -83,3 +150,222 @@ async def health_check(db: Session = Depends(get_db)):
|
||||
"database": "error",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
@app.get("/login")
|
||||
async def login_form(request: Request):
|
||||
"""
|
||||
Display login form.
|
||||
|
||||
If user is already logged in, redirect to dashboard.
|
||||
"""
|
||||
# Check if user is already logged in
|
||||
user = get_current_user_from_session(request.session)
|
||||
if user:
|
||||
return RedirectResponse(url="/dashboard", status_code=302)
|
||||
|
||||
return templates.TemplateResponse("login.html", {"request": request})
|
||||
|
||||
|
||||
@app.post("/login")
|
||||
async def login_submit(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Handle login form submission.
|
||||
|
||||
Authenticates user credentials and sets up session.
|
||||
"""
|
||||
form = await request.form()
|
||||
username = form.get("username")
|
||||
password = form.get("password")
|
||||
|
||||
if not username or not password:
|
||||
error_message = "Username and password are required"
|
||||
return templates.TemplateResponse("login.html", {
|
||||
"request": request,
|
||||
"error": error_message
|
||||
})
|
||||
|
||||
# Authenticate user
|
||||
user = authenticate_user(username, password)
|
||||
if not user:
|
||||
error_message = "Invalid username or password"
|
||||
return templates.TemplateResponse("login.html", {
|
||||
"request": request,
|
||||
"error": error_message
|
||||
})
|
||||
|
||||
# Set up user session
|
||||
request.session["user_id"] = user.id
|
||||
request.session["user"] = {"id": user.id, "username": user.username}
|
||||
|
||||
logger.info(f"User '{username}' logged in successfully")
|
||||
|
||||
# Redirect to dashboard after successful login
|
||||
return RedirectResponse(url="/dashboard", status_code=302)
|
||||
|
||||
|
||||
@app.get("/logout")
|
||||
async def logout(request: Request):
|
||||
"""
|
||||
Handle user logout.
|
||||
|
||||
Clears user session and redirects to home page.
|
||||
"""
|
||||
username = request.session.get("user", {}).get("username", "unknown")
|
||||
request.session.clear()
|
||||
logger.info(f"User '{username}' logged out")
|
||||
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
|
||||
|
||||
@app.get("/dashboard")
|
||||
async def dashboard(
|
||||
request: Request,
|
||||
q: str | None = Query(None, description="Search by file number or client name"),
|
||||
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="Results per page"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Dashboard page - lists recent cases with search and pagination.
|
||||
|
||||
- Optional query param `q` filters by case file number or client name/company
|
||||
- `page` and `page_size` control pagination
|
||||
"""
|
||||
# Check authentication
|
||||
user = get_current_user_from_session(request.session)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
# Base query: join clients for name/company access
|
||||
query = db.query(Case).join(Client).order_by(
|
||||
Case.open_date.desc(),
|
||||
Case.created_at.desc(),
|
||||
)
|
||||
|
||||
# Apply search filter if provided
|
||||
if q:
|
||||
like_term = f"%{q}%"
|
||||
query = query.filter(
|
||||
or_(
|
||||
Case.file_no.ilike(like_term),
|
||||
Client.first_name.ilike(like_term),
|
||||
Client.last_name.ilike(like_term),
|
||||
Client.company.ilike(like_term),
|
||||
)
|
||||
)
|
||||
|
||||
# Total count for pagination
|
||||
total: int = query.count()
|
||||
|
||||
# Clamp page to valid range when total is known
|
||||
total_pages: int = (total + page_size - 1) // page_size if total > 0 else 1
|
||||
if page > total_pages:
|
||||
page = total_pages
|
||||
|
||||
# Pagination window
|
||||
offset = (page - 1) * page_size
|
||||
cases = query.offset(offset).limit(page_size).all()
|
||||
|
||||
# Page number window for UI (current +/- 2)
|
||||
start_page = max(1, page - 2)
|
||||
end_page = min(total_pages, page + 2)
|
||||
page_numbers = list(range(start_page, end_page + 1))
|
||||
|
||||
logger.info(
|
||||
"Rendering dashboard: q='%s', page=%s, page_size=%s, total=%s",
|
||||
q,
|
||||
page,
|
||||
page_size,
|
||||
total,
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"dashboard.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user,
|
||||
"cases": cases,
|
||||
"q": q,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total,
|
||||
"total_pages": total_pages,
|
||||
"page_numbers": page_numbers,
|
||||
"start_index": (offset + 1) if total > 0 else 0,
|
||||
"end_index": min(offset + len(cases), total),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/admin")
|
||||
async def admin_panel(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Admin panel - requires authentication.
|
||||
|
||||
Provides administrative functions like data import and system management.
|
||||
"""
|
||||
# Check authentication
|
||||
user = get_current_user_from_session(request.session)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
return templates.TemplateResponse("admin.html", {
|
||||
"request": request,
|
||||
"user": user
|
||||
})
|
||||
|
||||
|
||||
@app.get("/case/{case_id}")
|
||||
async def case_detail(
|
||||
request: Request,
|
||||
case_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Case detail view.
|
||||
|
||||
Displays detailed information for a single case and its related client and
|
||||
associated records (transactions, documents, payments).
|
||||
"""
|
||||
# Check authentication
|
||||
user = get_current_user_from_session(request.session)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
# Fetch case with related entities eagerly loaded to avoid lazy-load issues
|
||||
case_obj = (
|
||||
db.query(Case)
|
||||
.options(
|
||||
joinedload(Case.client),
|
||||
joinedload(Case.transactions),
|
||||
joinedload(Case.documents),
|
||||
joinedload(Case.payments),
|
||||
)
|
||||
.filter(Case.id == case_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not case_obj:
|
||||
logger.warning("Case not found: id=%s", case_id)
|
||||
return templates.TemplateResponse(
|
||||
"case.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user,
|
||||
"case": None,
|
||||
"error": "Case not found",
|
||||
},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
logger.info("Rendering case detail: id=%s, file_no='%s'", case_obj.id, case_obj.file_no)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"case.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user,
|
||||
"case": case_obj,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -6,8 +6,11 @@ All models inherit from Base which is configured in the database module.
|
||||
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Float, Text, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.sql import func
|
||||
from .database import Base
|
||||
|
||||
# Create Base for SQLAlchemy 1.x compatibility
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class User(Base):
|
||||
|
||||
@@ -1 +1,87 @@
|
||||
<!-- Base template with Bootstrap 5 and navigation -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Delphi Database{% endblock %}</title>
|
||||
|
||||
<!-- Bootstrap 5 CSS CDN -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
|
||||
<!-- Bootstrap Icons -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
|
||||
<!-- Custom CSS -->
|
||||
<link href="{{ url_for('static', path='/css/custom.css') }}" rel="stylesheet">
|
||||
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<!-- Navigation -->
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="/">
|
||||
<img src="{{ url_for('static', path='/logo/delphi-logo.webp') }}" alt="Delphi Logo" width="30" height="30" class="d-inline-block align-text-top me-2">
|
||||
Delphi Database
|
||||
</a>
|
||||
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.url.path == '/' %}active{% endif %}" href="/">Home</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if 'dashboard' in request.url.path %}active{% endif %}" href="/dashboard">Dashboard</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if 'admin' in request.url.path %}active{% endif %}" href="/admin">Admin</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<ul class="navbar-nav">
|
||||
{% if request.session.get('user') %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="bi bi-person-circle me-1"></i>{{ request.session.get('user').username if request.session.get('user') else '' }}
|
||||
</a>
|
||||
<ul class="dropdown-menu" aria-labelledby="userDropdown">
|
||||
<li><a class="dropdown-item" href="/profile">Profile</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="/logout">Logout</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if 'login' in request.url.path %}active{% endif %}" href="/login">Login</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container-fluid mt-4">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-light text-center text-muted mt-5 py-3">
|
||||
<div class="container">
|
||||
<small>© 2025 Delphi Database. All rights reserved.</small>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Bootstrap 5 JS Bundle CDN -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- Custom JS -->
|
||||
<script src="{{ url_for('static', path='/js/custom.js') }}"></script>
|
||||
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1 +1,183 @@
|
||||
<!-- Case view/edit form -->
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
Case {{ case.file_no if case else '' }} · Delphi Database
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row g-3">
|
||||
<div class="col-12 d-flex align-items-center">
|
||||
<a class="btn btn-sm btn-outline-secondary me-2" href="/dashboard">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
Back
|
||||
</a>
|
||||
<h2 class="mb-0">Case Details</h2>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="col-12">
|
||||
<div class="alert alert-danger" role="alert">{{ error }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if case %}
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3">
|
||||
<div class="text-muted small">File #</div>
|
||||
<div class="fw-semibold">{{ case.file_no }}</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-muted small">Status</div>
|
||||
<div>
|
||||
{% if case.status == 'active' %}
|
||||
<span class="badge bg-success">Active</span>
|
||||
{% elif case.status == 'closed' %}
|
||||
<span class="badge bg-secondary">Closed</span>
|
||||
{% else %}
|
||||
<span class="badge bg-light text-dark">{{ case.status or 'n/a' }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-muted small">Type</div>
|
||||
<div>{{ case.case_type or '' }}</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-muted small">Opened</div>
|
||||
<div>{{ case.open_date.strftime('%Y-%m-%d') if case.open_date else '' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
{% set client = case.client %}
|
||||
<div class="col-md-4">
|
||||
<div class="text-muted small">Client</div>
|
||||
<div>
|
||||
{% if client %}
|
||||
{{ client.last_name }}, {{ client.first_name }}
|
||||
{% else %}
|
||||
<span class="text-muted">Unknown</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-muted small">Company</div>
|
||||
<div>{{ client.company if client else '' }}</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-muted small">City/State</div>
|
||||
<div>
|
||||
{% if client %}
|
||||
{{ client.city or '' }}{% if client.state %}, {{ client.state }}{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2 text-muted small">Description</div>
|
||||
<p class="mb-0">{{ case.description or '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">Transactions</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0 align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 110px;">Date</th>
|
||||
<th>Type</th>
|
||||
<th class="text-end" style="width: 120px;">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if case.transactions and case.transactions|length > 0 %}
|
||||
{% for t in case.transactions %}
|
||||
<tr>
|
||||
<td>{{ t.transaction_date.strftime('%Y-%m-%d') if t.transaction_date else '' }}</td>
|
||||
<td>{{ t.transaction_type or '' }}</td>
|
||||
<td class="text-end">{{ '%.2f'|format(t.amount) if t.amount is not none else '' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr><td colspan="3" class="text-center text-muted py-3">No transactions.</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">Documents</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0 align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>File</th>
|
||||
<th style="width: 120px;">Uploaded</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if case.documents and case.documents|length > 0 %}
|
||||
{% for d in case.documents %}
|
||||
<tr>
|
||||
<td>{{ d.document_type or '' }}</td>
|
||||
<td>{{ d.file_name or '' }}</td>
|
||||
<td>{{ d.uploaded_date.strftime('%Y-%m-%d') if d.uploaded_date else '' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr><td colspan="3" class="text-center text-muted py-3">No documents.</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">Payments</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0 align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 110px;">Date</th>
|
||||
<th>Type</th>
|
||||
<th class="text-end" style="width: 120px;">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if case.payments and case.payments|length > 0 %}
|
||||
{% for p in case.payments %}
|
||||
<tr>
|
||||
<td>{{ p.payment_date.strftime('%Y-%m-%d') if p.payment_date else '' }}</td>
|
||||
<td>{{ p.payment_type or '' }}</td>
|
||||
<td class="text-end">{{ '%.2f'|format(p.amount) if p.amount is not none else '' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr><td colspan="3" class="text-center text-muted py-3">No payments.</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1 +1,122 @@
|
||||
<!-- Dashboard with case listing and search -->
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard · Delphi Database{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row g-3 align-items-center mb-3">
|
||||
<div class="col-auto">
|
||||
<h2 class="mb-0">Cases</h2>
|
||||
</div>
|
||||
<div class="col ms-auto">
|
||||
<form class="d-flex" method="get" action="/dashboard">
|
||||
<input
|
||||
class="form-control me-2"
|
||||
type="search"
|
||||
name="q"
|
||||
placeholder="Search file # or name/company"
|
||||
aria-label="Search"
|
||||
value="{{ q or '' }}"
|
||||
>
|
||||
<input type="hidden" name="page_size" value="{{ page_size }}">
|
||||
<button class="btn btn-outline-primary" type="submit">
|
||||
<i class="bi bi-search me-1"></i>Search
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a class="btn btn-outline-secondary" href="/dashboard">
|
||||
<i class="bi bi-x-circle me-1"></i>Clear
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-12 text-muted small">
|
||||
{% if total and total > 0 %}
|
||||
Showing {{ start_index }}–{{ end_index }} of {{ total }}
|
||||
{% else %}
|
||||
No results
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th scope="col" style="width: 140px;">File #</th>
|
||||
<th scope="col">Client</th>
|
||||
<th scope="col">Company</th>
|
||||
<th scope="col">Type</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Opened</th>
|
||||
<th scope="col" class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if cases and cases|length > 0 %}
|
||||
{% for c in cases %}
|
||||
<tr>
|
||||
<td><span class="fw-semibold">{{ c.file_no }}</span></td>
|
||||
<td>
|
||||
{% set client = c.client %}
|
||||
{% if client %}
|
||||
{{ client.last_name }}, {{ client.first_name }}
|
||||
{% else %}
|
||||
<span class="text-muted">Unknown</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ client.company if client else '' }}</td>
|
||||
<td>{{ c.case_type or '' }}</td>
|
||||
<td>
|
||||
{% if c.status == 'active' %}
|
||||
<span class="badge bg-success">Active</span>
|
||||
{% elif c.status == 'closed' %}
|
||||
<span class="badge bg-secondary">Closed</span>
|
||||
{% else %}
|
||||
<span class="badge bg-light text-dark">{{ c.status or 'n/a' }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ c.open_date.strftime('%Y-%m-%d') if c.open_date else '' }}</td>
|
||||
<td class="text-end">
|
||||
<a class="btn btn-sm btn-outline-primary" href="/case/{{ c.id }}">
|
||||
<i class="bi bi-folder2-open me-1"></i>View
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7" class="text-center text-muted py-4">No cases found.</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
{% if total_pages and total_pages > 1 %}
|
||||
<nav aria-label="Cases pagination">
|
||||
<ul class="pagination mb-0">
|
||||
{# Previous #}
|
||||
<li class="page-item {% if page <= 1 %}disabled{% endif %}">
|
||||
<a class="page-link" href="/dashboard?page={{ page - 1 if page > 1 else 1 }}&page_size={{ page_size }}{% if q %}&q={{ q | urlencode }}{% endif %}" aria-label="Previous">
|
||||
<span aria-hidden="true">«</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{# Page numbers window #}
|
||||
{% for p in page_numbers %}
|
||||
<li class="page-item {% if p == page %}active{% endif %}">
|
||||
<a class="page-link" href="/dashboard?page={{ p }}&page_size={{ page_size }}{% if q %}&q={{ q | urlencode }}{% endif %}">{{ p }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
{# Next #}
|
||||
<li class="page-item {% if page >= total_pages %}disabled{% endif %}">
|
||||
<a class="page-link" href="/dashboard?page={{ page + 1 if page < total_pages else total_pages }}&page_size={{ page_size }}{% if q %}&q={{ q | urlencode }}{% endif %}" aria-label="Next">
|
||||
<span aria-hidden="true">»</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1 +1,111 @@
|
||||
<!-- Login form template -->
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Login - Delphi Database{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="text-center mb-4">
|
||||
<img src="{{ url_for('static', path='/logo/delphi-logo.webp') }}" alt="Delphi Logo" class="mb-3" style="width: 60px; height: 60px;">
|
||||
<h2 class="card-title">Welcome Back</h2>
|
||||
<p class="text-muted">Sign in to access Delphi Database</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/login">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-person"></i></span>
|
||||
<input type="text" class="form-control" id="username" name="username" required
|
||||
placeholder="Enter your username" autocomplete="username">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-key"></i></span>
|
||||
<input type="password" class="form-control" id="password" name="password" required
|
||||
placeholder="Enter your password" autocomplete="current-password">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="rememberMe">
|
||||
<label class="form-check-label" for="rememberMe">
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-box-arrow-in-right me-2"></i>Sign In
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Default credentials: admin / admin123
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<small class="text-muted">
|
||||
Don't have an account? Contact your administrator.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
// Auto-focus on username field
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.getElementById('username').focus();
|
||||
});
|
||||
|
||||
// Show/hide password toggle (optional enhancement)
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const passwordField = document.getElementById('password');
|
||||
const toggleBtn = document.createElement('button');
|
||||
toggleBtn.type = 'button';
|
||||
toggleBtn.className = 'btn btn-outline-secondary';
|
||||
toggleBtn.innerHTML = '<i class="bi bi-eye"></i>';
|
||||
toggleBtn.style.border = 'none';
|
||||
toggleBtn.style.background = 'transparent';
|
||||
|
||||
// Add toggle functionality
|
||||
toggleBtn.addEventListener('click', function() {
|
||||
if (passwordField.type === 'password') {
|
||||
passwordField.type = 'text';
|
||||
this.innerHTML = '<i class="bi bi-eye-slash"></i>';
|
||||
} else {
|
||||
passwordField.type = 'password';
|
||||
this.innerHTML = '<i class="bi bi-eye"></i>';
|
||||
}
|
||||
});
|
||||
|
||||
// Insert toggle button into password input group
|
||||
const passwordInputGroup = passwordField.closest('.input-group');
|
||||
if (passwordInputGroup) {
|
||||
const span = passwordInputGroup.querySelector('.input-group-text');
|
||||
passwordInputGroup.insertBefore(toggleBtn, span.nextSibling);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
5
cookies.txt
Normal file
5
cookies.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
# Netscape HTTP Cookie File
|
||||
# https://curl.se/docs/http-cookies.html
|
||||
# This file was generated by libcurl! Edit at your own risk.
|
||||
|
||||
#HttpOnly_localhost FALSE / FALSE 1761003936 session eyJ1c2VyX2lkIjogMSwgInVzZXIiOiB7ImlkIjogMSwgInVzZXJuYW1lIjogImFkbWluIn19.aORUoA.KSAst9pcXJJJHTc1m-mY_jQ0P6A
|
||||
@@ -1,5 +1,5 @@
|
||||
fastapi==0.104.1
|
||||
sqlalchemy==2.0.23
|
||||
sqlalchemy==1.4.54
|
||||
alembic==1.12.1
|
||||
python-multipart==0.0.6
|
||||
python-jose[cryptography]==3.3.0
|
||||
@@ -7,3 +7,4 @@ passlib[bcrypt]==1.7.4
|
||||
python-dotenv==1.0.0
|
||||
uvicorn[standard]==0.24.0
|
||||
jinja2==3.1.2
|
||||
aiofiles==23.2.1
|
||||
|
||||
51
static/css/custom.css
Normal file
51
static/css/custom.css
Normal file
@@ -0,0 +1,51 @@
|
||||
/* Custom CSS for Delphi Database */
|
||||
|
||||
/* Additional styles can be added here */
|
||||
body {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
/* Custom navbar styles */
|
||||
.navbar-brand {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Custom card styles */
|
||||
.card {
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
border: 1px solid rgba(0, 0, 0, 0.125);
|
||||
}
|
||||
|
||||
/* Custom button styles */
|
||||
.btn-primary {
|
||||
background-color: #0d6efd;
|
||||
border-color: #0d6efd;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #0b5ed7;
|
||||
border-color: #0a58ca;
|
||||
}
|
||||
|
||||
/* Custom form styles */
|
||||
.form-control:focus {
|
||||
border-color: #86b7fe;
|
||||
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
|
||||
}
|
||||
|
||||
/* Custom table styles */
|
||||
.table th {
|
||||
background-color: #f8f9fa;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
}
|
||||
|
||||
/* Custom alert styles */
|
||||
.alert {
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
/* Custom footer styles */
|
||||
footer {
|
||||
margin-top: auto;
|
||||
}
|
||||
82
static/js/custom.js
Normal file
82
static/js/custom.js
Normal file
@@ -0,0 +1,82 @@
|
||||
// Custom JavaScript for Delphi Database
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize tooltips if any
|
||||
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||
return new bootstrap.Tooltip(tooltipTriggerEl);
|
||||
});
|
||||
|
||||
// Auto-hide alerts after 5 seconds
|
||||
var alerts = document.querySelectorAll('.alert:not(.alert-permanent)');
|
||||
alerts.forEach(function(alert) {
|
||||
setTimeout(function() {
|
||||
var bsAlert = new bootstrap.Alert(alert);
|
||||
bsAlert.close();
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
// Confirm delete actions
|
||||
var deleteButtons = document.querySelectorAll('[data-confirm-delete]');
|
||||
deleteButtons.forEach(function(button) {
|
||||
button.addEventListener('click', function(e) {
|
||||
var message = this.getAttribute('data-confirm-delete') || 'Are you sure you want to delete this item?';
|
||||
if (!confirm(message)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Form validation enhancement
|
||||
var forms = document.querySelectorAll('.needs-validation');
|
||||
Array.prototype.slice.call(forms).forEach(function(form) {
|
||||
form.addEventListener('submit', function(event) {
|
||||
if (!form.checkValidity()) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
form.classList.add('was-validated');
|
||||
}, false);
|
||||
});
|
||||
|
||||
// Dynamic form field enabling/disabling
|
||||
var toggleFields = document.querySelectorAll('[data-toggle-field]');
|
||||
toggleFields.forEach(function(element) {
|
||||
element.addEventListener('change', function() {
|
||||
var targetSelector = this.getAttribute('data-toggle-field');
|
||||
var targetField = document.querySelector(targetSelector);
|
||||
if (targetField) {
|
||||
targetField.disabled = !this.checked;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Utility functions
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
var date = new Date(dateString);
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function formatCurrency(amount) {
|
||||
if (!amount) return '$0.00';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
function showLoading(button) {
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Loading...';
|
||||
}
|
||||
}
|
||||
|
||||
function hideLoading(button, originalText) {
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.innerHTML = originalText;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user