working on backend

This commit is contained in:
HotSwapp
2025-08-15 22:04:43 -05:00
parent abc7f289d1
commit 0347284556
16 changed files with 3929 additions and 33 deletions

View File

@@ -1 +1 @@
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiIsInR5cGUiOiJhY2Nlc3MiLCJpYXQiOjE3NTUyMDAyNzMsImV4cCI6MTc1NTIxNDY3M30.VfcV_zbhtSe50u1awNC4v2O8CU4PQ9AwhlcNeNn40cM eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiIsImV4cCI6MTc1NTMxMzA3NCwiaWF0IjoxNzU1MzA1ODc0LCJ0eXBlIjoiYWNjZXNzIn0.u0yIrs1ukrDZrl1DCYGQfyZYQizkl4RPfGJeHzXF6D4

View File

@@ -90,14 +90,14 @@ class UserResponse(BaseModel):
id: int id: int
username: str username: str
email: str email: str
first_name: Optional[str] first_name: Optional[str] = None
last_name: Optional[str] last_name: Optional[str] = None
is_admin: bool is_admin: bool = False
is_active: bool is_active: bool = True
is_approver: bool is_approver: bool = False
last_login: Optional[datetime] last_login: Optional[datetime] = None
created_at: Optional[datetime] created_at: Optional[datetime] = None
updated_at: Optional[datetime] updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

1607
app/api/billing.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,32 @@ def ensure_schema_updates(engine: Engine) -> None:
"""Ensure missing columns are added for backward-compatible updates.""" """Ensure missing columns are added for backward-compatible updates."""
# Map of table -> {column: SQL type} # Map of table -> {column: SQL type}
updates: Dict[str, Dict[str, str]] = { updates: Dict[str, Dict[str, str]] = {
# Billing batch history (lightweight persistence)
"billing_batches": {
"id": "INTEGER",
"batch_id": "TEXT",
"status": "TEXT",
"total_files": "INTEGER",
"successful_files": "INTEGER",
"failed_files": "INTEGER",
"started_at": "DATETIME",
"updated_at": "DATETIME",
"completed_at": "DATETIME",
"processing_time_seconds": "FLOAT",
"success_rate": "FLOAT",
"error_message": "TEXT",
},
"billing_batch_files": {
"id": "INTEGER",
"batch_id": "TEXT",
"file_no": "TEXT",
"status": "TEXT",
"error_message": "TEXT",
"filename": "TEXT",
"size": "INTEGER",
"started_at": "DATETIME",
"completed_at": "DATETIME",
},
# Forms # Forms
"form_index": { "form_index": {
"keyword": "TEXT", "keyword": "TEXT",

View File

@@ -83,6 +83,7 @@ from app.api.customers import router as customers_router
from app.api.files import router as files_router from app.api.files import router as files_router
from app.api.financial import router as financial_router from app.api.financial import router as financial_router
from app.api.documents import router as documents_router from app.api.documents import router as documents_router
from app.api.billing import router as billing_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
@@ -99,6 +100,7 @@ app.include_router(auth_router, prefix="/api/auth", tags=["authentication"])
app.include_router(customers_router, prefix="/api/customers", tags=["customers"]) app.include_router(customers_router, prefix="/api/customers", tags=["customers"])
app.include_router(files_router, prefix="/api/files", tags=["files"]) app.include_router(files_router, prefix="/api/files", tags=["files"])
app.include_router(financial_router, prefix="/api/financial", tags=["financial"]) app.include_router(financial_router, prefix="/api/financial", tags=["financial"])
app.include_router(billing_router, prefix="/api/billing", tags=["billing"])
app.include_router(documents_router, prefix="/api/documents", tags=["documents"]) app.include_router(documents_router, prefix="/api/documents", tags=["documents"])
app.include_router(search_router, prefix="/api/search", tags=["search"]) app.include_router(search_router, prefix="/api/search", tags=["search"])
app.include_router(admin_router, prefix="/api/admin", tags=["admin"]) app.include_router(admin_router, prefix="/api/admin", tags=["admin"])
@@ -157,6 +159,13 @@ async def financial_page(request: Request):
"financial.html", "financial.html",
{"request": request, "title": "Financial/Ledger - " + settings.app_name} {"request": request, "title": "Financial/Ledger - " + settings.app_name}
) )
@app.get("/billing", response_class=HTMLResponse)
async def billing_page(request: Request):
"""Billing Statements page"""
return templates.TemplateResponse(
"billing.html",
{"request": request, "title": "Billing Statements - " + settings.app_name}
)
@app.get("/documents", response_class=HTMLResponse) @app.get("/documents", response_class=HTMLResponse)

View File

@@ -17,6 +17,7 @@ from .pensions import (
SeparationAgreement, LifeTable, NumberTable, PensionResult SeparationAgreement, LifeTable, NumberTable, PensionResult
) )
from .templates import DocumentTemplate, DocumentTemplateVersion, TemplateKeyword from .templates import DocumentTemplate, DocumentTemplateVersion, TemplateKeyword
from .billing import BillingBatch, BillingBatchFile
from .lookups import ( from .lookups import (
Employee, FileType, FileStatus, TransactionType, TransactionCode, Employee, FileType, FileStatus, TransactionType, TransactionCode,
State, GroupLookup, Footer, PlanInfo, FormIndex, FormList, State, GroupLookup, Footer, PlanInfo, FormIndex, FormList,
@@ -32,5 +33,6 @@ __all__ = [
"SeparationAgreement", "LifeTable", "NumberTable", "PensionResult", "SeparationAgreement", "LifeTable", "NumberTable", "PensionResult",
"Employee", "FileType", "FileStatus", "TransactionType", "TransactionCode", "Employee", "FileType", "FileStatus", "TransactionType", "TransactionCode",
"State", "GroupLookup", "Footer", "PlanInfo", "FormIndex", "FormList", "State", "GroupLookup", "Footer", "PlanInfo", "FormIndex", "FormList",
"PrinterSetup", "SystemSetup", "FormKeyword", "TemplateKeyword" "PrinterSetup", "SystemSetup", "FormKeyword", "TemplateKeyword",
"BillingBatch", "BillingBatchFile"
] ]

47
app/models/billing.py Normal file
View File

@@ -0,0 +1,47 @@
from sqlalchemy import Column, Integer, String, DateTime, Float, Text, Index
from app.models.base import BaseModel
class BillingBatch(BaseModel):
__tablename__ = "billing_batches"
id = Column(Integer, primary_key=True, index=True)
batch_id = Column(String(100), unique=True, nullable=False, index=True)
status = Column(String(32), nullable=False)
total_files = Column(Integer, nullable=False, default=0)
successful_files = Column(Integer, nullable=False, default=0)
failed_files = Column(Integer, nullable=False, default=0)
started_at = Column(DateTime(timezone=True), nullable=False)
updated_at = Column(DateTime(timezone=True))
completed_at = Column(DateTime(timezone=True))
processing_time_seconds = Column(Float)
success_rate = Column(Float)
error_message = Column(Text)
__table_args__ = (
Index("ix_billing_batches_started_at", "started_at"),
Index("ix_billing_batches_updated_at", "updated_at"),
Index("ix_billing_batches_completed_at", "completed_at"),
{},
)
class BillingBatchFile(BaseModel):
__tablename__ = "billing_batch_files"
id = Column(Integer, primary_key=True, index=True)
batch_id = Column(String(100), nullable=False, index=True)
file_no = Column(String(50), nullable=False, index=True)
status = Column(String(32), nullable=False)
error_message = Column(Text)
filename = Column(String(255))
size = Column(Integer)
started_at = Column(DateTime(timezone=True))
completed_at = Column(DateTime(timezone=True))
__table_args__ = (
Index("ix_billing_batch_files_batch_file", "batch_id", "file_no"),
{},
)

View File

@@ -22,9 +22,9 @@ class User(BaseModel):
full_name = Column(String(100)) # Keep for backward compatibility full_name = Column(String(100)) # Keep for backward compatibility
# Authorization # Authorization
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True, nullable=False)
is_admin = Column(Boolean, default=False) is_admin = Column(Boolean, default=False, nullable=False)
is_approver = Column(Boolean, default=False) is_approver = Column(Boolean, default=False, nullable=False)
# User Preferences # User Preferences
theme_preference = Column(String(10), default='light') # 'light', 'dark' theme_preference = Column(String(10), default='light') # 'light', 'dark'

View File

@@ -124,48 +124,48 @@ POST /api/documents/generate-batch
**Legacy Feature**: Specialized module for Qualified Domestic Relations Orders **Legacy Feature**: Specialized module for Qualified Domestic Relations Orders
**Current Status**: ❌ Not implemented **Current Status**: ✅ **COMPLETED**
**Required Components**: **Required Components**:
#### 2.1 QDRO Data Model #### 2.1 QDRO Data Model
- [ ] Create `QDRO` model - [x] Create `QDRO` model
- File number reference - File number reference
- Version tracking - Version tracking
- Plan information (name, type, administrator) - Plan information (name, type, administrator)
- Participant details (employee, spouse/ex-spouse) - Participant details (employee, spouse/ex-spouse)
- Division methodology (percentage, dollar amount, etc.) - Division methodology (percentage, dollar amount, etc.)
- Effective dates and conditions - Effective dates and conditions
- [ ] Plan information database - [x] Plan information database
- [ ] QDRO version management - [x] QDRO version management
#### 2.2 QDRO-Specific Forms #### 2.2 QDRO-Specific Forms
- [ ] QDRO data entry interface - [x] QDRO data entry interface
- [ ] Plan information management - [x] Plan information management
- [ ] Participant role management - [x] Participant role management
- [ ] Division calculation tools - [x] Division calculation tools
#### 2.3 QDRO Document Generation #### 2.3 QDRO Document Generation
- [ ] QDRO-specific templates - [x] QDRO-specific templates
- [ ] Integration with document assembly system - [x] Integration with document assembly system
- [ ] Version control for QDRO revisions - [x] Version control for QDRO revisions
- [ ] Court approval tracking - [x] Court approval tracking
**API Endpoints Needed**: **API Endpoints Needed**:
``` ```
POST /api/qdros POST /api/qdros
GET /api/qdros/{file_no} GET /api/qdros/{file_no}
PUT /api/qdros/{id} PUT /api/qdros/{id}
POST /api/qdros/{id}/generate-document POST /api/qdros/{id}/generate-document
GET /api/qdros/{id}/versions GET /api/qdros/{id}/versions
POST /api/plan-info POST /api/plan-info
``` ```
### 🔴 3. Advanced Billing & Statement Generation ### 🔴 3. Advanced Billing & Statement Generation
**Legacy Feature**: Automated billing statement generation with trust account management **Legacy Feature**: Automated billing statement generation with trust account management
**Current Status**: ⚠️ Partially implemented (basic transactions exist) **Current Status**: 🟡 **IN PROGRESS** (basic transactions exist, working on statement generation)
**Missing Components**: **Missing Components**:

View File

@@ -90,6 +90,10 @@ http {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
} }
# Main application # Main application

136
static/js/batch-progress.js Normal file
View File

@@ -0,0 +1,136 @@
/**
* Batch Progress Realtime client.
* - Tries WebSocket first
* - Falls back to HTTP polling on failure
* - Auto heartbeats and reconnection with backoff
*/
(function(){
window.progress = window.progress || {};
function getAuthToken() {
try {
return (window.app && window.app.token) || localStorage.getItem('auth_token') || null;
} catch (_) {
return null;
}
}
function buildWsUrl(path) {
const loc = window.location;
const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:';
const token = encodeURIComponent(getAuthToken() || '');
const sep = path.includes('?') ? '&' : '?';
return `${proto}//${loc.host}${path}${sep}token=${token}`;
}
function defaultOnUpdate(){/* no-op */}
function defaultOnError(){/* no-op */}
/**
* Subscribe to a batch progress stream.
* @param {string} batchId
* @param {(data: object|null) => void} onUpdate
* @param {(error: Error|string) => void} onError
* @param {number} pollIntervalMs
* @returns {() => void} unsubscribe function
*/
function subscribe(batchId, onUpdate = defaultOnUpdate, onError = defaultOnError, pollIntervalMs = 2000) {
let ws = null;
let closed = false;
let pollTimer = null;
let backoffMs = 1000;
async function pollOnce() {
try {
const resp = await window.http.wrappedFetch(`/api/billing/statements/batch-progress/${encodeURIComponent(batchId)}`);
if (!resp.ok) {
const err = await window.http.toError(resp, 'Failed to fetch batch progress');
throw err;
}
const json = await resp.json();
onUpdate(json);
} catch (e) {
onError(e);
}
}
function startPolling() {
if (closed) return;
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(pollOnce, pollIntervalMs);
// immediate first fetch
pollOnce();
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function tryWebSocket() {
const url = buildWsUrl(`/api/billing/statements/batch-progress/ws/${encodeURIComponent(batchId)}`);
try {
ws = new WebSocket(url);
} catch (e) {
onError(e);
startPolling();
return;
}
let pingTimer = null;
ws.onopen = function() {
stopPolling();
backoffMs = 1000;
// send heartbeat pings at 30s
pingTimer = setInterval(function(){
try { ws.send(JSON.stringify({type: 'ping'})); } catch (_) {}
}, 30000);
};
ws.onmessage = function(ev) {
try {
const msg = JSON.parse(ev.data);
if (msg && msg.type === 'progress') {
onUpdate(msg.data);
}
} catch (_) {
// ignore
}
};
ws.onerror = function(ev) {
onError(new Error('WebSocket error'));
};
ws.onclose = function() {
if (pingTimer) { clearInterval(pingTimer); pingTimer = null; }
if (closed) return;
// graceful fallback to polling and schedule reconnect with backoff
startPolling();
setTimeout(function(){
if (!closed) {
backoffMs = Math.min(backoffMs * 2, 30000);
tryWebSocket();
}
}, backoffMs);
};
}
// Kick off
tryWebSocket();
return function unsubscribe() {
closed = true;
stopPolling();
try { if (ws && ws.readyState <= 1) ws.close(); } catch(_) {}
ws = null;
};
}
window.progress.subscribe = subscribe;
})();

View File

@@ -16,6 +16,7 @@ const app = {
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
try { setupGlobalErrorHandlers(); } catch (_) {} try { setupGlobalErrorHandlers(); } catch (_) {}
initializeApp(); initializeApp();
try { initializeBatchProgressUI(); } catch (_) {}
}); });
// Theme Management (centralized) // Theme Management (centralized)
@@ -123,6 +124,141 @@ async function initializeApp() {
console.log('Delphi Database System initialized'); console.log('Delphi Database System initialized');
} }
// Live Batch Progress (Admin Overview)
function initializeBatchProgressUI() {
const listEl = document.getElementById('batchProgressList');
const emptyEl = document.getElementById('batchProgressEmpty');
const refreshBtn = document.getElementById('refreshBatchesBtn');
if (!listEl || !emptyEl) return;
const subscriptions = new Map();
function percent(progress) {
if (!progress || !progress.total_files) return 0;
const done = Number(progress.processed_files || 0);
const total = Number(progress.total_files || 0);
return Math.max(0, Math.min(100, Math.round((done / total) * 100)));
}
function renderRow(progress) {
const pid = progress.batch_id;
const pct = percent(progress);
const status = String(progress.status || '').toUpperCase();
const current = progress.current_file || '';
const success = progress.successful_files || 0;
const failed = progress.failed_files || 0;
const total = progress.total_files || 0;
return (
`<div class="border border-neutral-200 dark:border-neutral-700 rounded-lg p-3" data-batch="${pid}">
<div class="flex items-center justify-between gap-3 mb-2">
<div class="flex items-center gap-2">
<span class="text-xs px-2 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">${pid}</span>
<span class="text-xs font-medium ${status === 'COMPLETED' ? 'text-green-600 dark:text-green-400' : status === 'FAILED' ? 'text-red-600 dark:text-red-400' : status === 'CANCELLED' ? 'text-neutral-500' : 'text-amber-600 dark:text-amber-400'}">${status}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-neutral-500 dark:text-neutral-400">${success}/${total} ✓ • ${failed} ✕</span>
<button class="text-xs px-2 py-1 rounded bg-neutral-100 dark:bg-neutral-800 hover:bg-neutral-200 dark:hover:bg-neutral-700 text-neutral-700 dark:text-neutral-300" data-action="cancel" ${status==='RUNNING'||status==='PENDING' ? '' : 'disabled'}>Cancel</button>
</div>
</div>
<div class="w-full h-2 bg-neutral-100 dark:bg-neutral-800 rounded">
<div class="h-2 rounded ${status==='FAILED'? 'bg-red-500' : status==='CANCELLED' ? 'bg-neutral-500' : 'bg-primary-500'}" style="width:${pct}%"></div>
</div>
<div class="mt-2 flex items-center justify-between text-xs text-neutral-600 dark:text-neutral-400">
<span>${pct}%</span>
<span>${current ? 'Current: '+current : ''}</span>
</div>
</div>`
);
}
async function fetchActiveBatches() {
const resp = await window.http.wrappedFetch('/api/billing/statements/batch-list');
if (!resp.ok) return [];
return await resp.json();
}
function updateEmptyState() {
const hasRows = listEl.children.length > 0;
emptyEl.style.display = hasRows ? 'none' : '';
}
function upsertRow(data) {
const pid = data && data.batch_id ? data.batch_id : null;
if (!pid) return;
let row = listEl.querySelector(`[data-batch="${pid}"]`);
const html = renderRow(data);
if (row) {
row.outerHTML = html;
} else {
const container = document.createElement('div');
container.innerHTML = html;
listEl.prepend(container.firstChild);
}
updateEmptyState();
}
async function cancelBatch(batchId) {
try {
const resp = await window.http.wrappedFetch(`/api/billing/statements/batch-progress/${encodeURIComponent(batchId)}`, { method: 'DELETE' });
if (!resp.ok) {
throw await window.http.toError(resp, 'Failed to cancel batch');
}
// Let stream update the row; no-op here
} catch (e) {
console.warn('Cancel failed', e);
try { alert(window.http.formatAlert(e, 'Cancel failed')); } catch (_) {}
}
}
function attachRowHandlers() {
listEl.addEventListener('click', function(ev){
const btn = ev.target.closest('[data-action="cancel"]');
if (!btn) return;
const row = ev.target.closest('[data-batch]');
if (!row) return;
const pid = row.getAttribute('data-batch');
cancelBatch(pid);
});
}
async function subscribeTo(pid) {
if (!window.progress || typeof window.progress.subscribe !== 'function') return;
if (subscriptions.has(pid)) return;
const unsub = window.progress.subscribe(pid, function(progress){
if (!progress) return;
upsertRow(progress);
const status = String(progress.status || '').toUpperCase();
if (status === 'COMPLETED' || status === 'FAILED' || status === 'CANCELLED') {
// Auto-unsubscribe once terminal
const fn = subscriptions.get(pid);
if (fn) { try { fn(); } catch (_) {} }
subscriptions.delete(pid);
}
}, function(err){
// Non-fatal; polling fallback is handled inside subscribe()
console.debug('progress stream issue', err && err.message ? err.message : err);
});
subscriptions.set(pid, unsub);
}
async function refresh() {
const batches = await fetchActiveBatches();
if (!Array.isArray(batches)) return;
if (batches.length === 0) updateEmptyState();
for (const pid of batches) {
subscribeTo(pid);
}
}
if (refreshBtn) {
refreshBtn.addEventListener('click', function(){ refresh(); });
}
attachRowHandlers();
refresh();
}
// Form validation // Form validation
function initializeFormValidation() { function initializeFormValidation() {
// Native validation handling // Native validation handling

View File

@@ -160,6 +160,22 @@
<div id="adminTabContent" class="space-y-6"> <div id="adminTabContent" class="space-y-6">
<!-- Overview Tab --> <!-- Overview Tab -->
<div id="overview" role="tabpanel" class="space-y-6"> <div id="overview" role="tabpanel" class="space-y-6">
<!-- Live Batch Progress -->
<div class="bg-white dark:bg-neutral-900 rounded-xl shadow-sm dark:shadow-none border border-neutral-200 dark:border-neutral-700">
<div class="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700 flex items-center justify-between">
<h3 class="text-lg font-semibold text-neutral-900 dark:text-white flex items-center gap-2">
<i class="fa-solid fa-list-check text-primary-600 dark:text-primary-400"></i>
Live Batch Progress
</h3>
<button id="refreshBatchesBtn" class="text-neutral-400 hover:text-neutral-600 dark:text-neutral-500 dark:hover:text-neutral-300">
<i class="fa-solid fa-rotate-right"></i>
</button>
</div>
<div class="p-6">
<div id="batchProgressList" class="space-y-3" aria-live="polite"></div>
<div id="batchProgressEmpty" class="text-sm text-neutral-500 dark:text-neutral-400">No active batches</div>
</div>
</div>
<!-- Quick Stats Overview --> <!-- Quick Stats Overview -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- System Statistics --> <!-- System Statistics -->
@@ -1083,7 +1099,17 @@
</div> </div>
<div class="mt-3"> <div class="mt-3">
<label class="block text-sm font-medium mb-1">Webhook Secret</label> <label class="block text-sm font-medium mb-1">Webhook Secret</label>
<input id="routeWebhookSecret" class="w-full px-3 py-2 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg" placeholder="Optional (leave blank to keep existing)"> <div class="relative">
<input id="routeWebhookSecret" type="password" class="w-full px-3 py-2 pr-20 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg" placeholder="Optional (leave blank to keep existing)">
<div class="absolute inset-y-0 right-0 flex items-center">
<button type="button" class="px-2 py-1 text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200" onclick="toggleWebhookSecretVisibility()" title="Toggle visibility">
<i id="webhook-secret-eye" class="fas fa-eye text-sm"></i>
</button>
<button type="button" class="px-2 py-1 text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-200" onclick="generateRandomSecret()" title="Generate random secret">
<i class="fas fa-dice text-sm"></i>
</button>
</div>
</div>
</div> </div>
<p class="text-xs text-neutral-500 dark:text-neutral-400 mt-2">At least one of Email or Webhook URL must be provided. Secret is only updated if a new value is entered.</p> <p class="text-xs text-neutral-500 dark:text-neutral-400 mt-2">At least one of Email or Webhook URL must be provided. Secret is only updated if a new value is entered.</p>
</form> </form>
@@ -1325,6 +1351,9 @@ function showCreateNotificationRouteModal() {
document.getElementById('routeScope').disabled = false; document.getElementById('routeScope').disabled = false;
document.getElementById('routeIdentifier').readOnly = false; document.getElementById('routeIdentifier').readOnly = false;
document.getElementById('qdroRouteForm').reset(); document.getElementById('qdroRouteForm').reset();
// Reset secret field to password type
document.getElementById('routeWebhookSecret').type = 'password';
document.getElementById('webhook-secret-eye').className = 'fas fa-eye text-sm';
openModal('qdroRouteModal'); openModal('qdroRouteModal');
} }
@@ -1348,6 +1377,9 @@ function editNotificationRoute(scope, identifier) {
document.getElementById('routeWebhookUrl').value = link ? link.getAttribute('href') : ''; document.getElementById('routeWebhookUrl').value = link ? link.getAttribute('href') : '';
document.getElementById('routeWebhookSecret').value = ''; document.getElementById('routeWebhookSecret').value = '';
} }
// Reset secret field to password type
document.getElementById('routeWebhookSecret').type = 'password';
document.getElementById('webhook-secret-eye').className = 'fas fa-eye text-sm';
openModal('qdroRouteModal'); openModal('qdroRouteModal');
} }
@@ -1416,6 +1448,30 @@ async function deleteNotificationRoute(scope, identifier) {
} }
} }
// Webhook Secret UI helpers
function toggleWebhookSecretVisibility() {
const input = document.getElementById('routeWebhookSecret');
const eye = document.getElementById('webhook-secret-eye');
if (input.type === 'password') {
input.type = 'text';
eye.className = 'fas fa-eye-slash text-sm';
} else {
input.type = 'password';
eye.className = 'fas fa-eye text-sm';
}
}
function generateRandomSecret() {
// Generate a secure random string (base64url-safe)
const array = new Uint8Array(32); // 32 bytes = 256 bits
crypto.getRandomValues(array);
const secret = Array.from(array, byte =>
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'[byte % 64]
).join('');
document.getElementById('routeWebhookSecret').value = secret;
showAlert('Random secret generated', 'success');
}
// Basic HTML escaping helpers // Basic HTML escaping helpers
function escapeHtml(str) { function escapeHtml(str) {
if (str == null) return ''; if (str == null) return '';

View File

@@ -43,6 +43,10 @@
<i class="fa-solid fa-calculator"></i> <i class="fa-solid fa-calculator"></i>
<span>Ledger</span> <span>Ledger</span>
</a> </a>
<a href="/billing" class="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-file-invoice-dollar"></i>
<span>Billing</span>
</a>
<a href="/documents" data-shortcut="Alt+D" class="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"> <a href="/documents" data-shortcut="Alt+D" class="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-file-lines"></i> <i class="fa-solid fa-file-lines"></i>
<span>Documents</span> <span>Documents</span>
@@ -113,6 +117,10 @@
<i class="fa-solid fa-calculator"></i> <i class="fa-solid fa-calculator"></i>
<span>Ledger</span> <span>Ledger</span>
</a> </a>
<a href="/billing" class="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-file-invoice-dollar"></i>
<span>Billing</span>
</a>
<a href="/documents" class="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"> <a href="/documents" class="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-file-lines"></i> <i class="fa-solid fa-file-lines"></i>
<span>Documents</span> <span>Documents</span>
@@ -403,6 +411,7 @@
<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/upload-helper.js"></script>
<script src="/static/js/keyboard-shortcuts.js"></script> <script src="/static/js/keyboard-shortcuts.js"></script>
<script src="/static/js/batch-progress.js"></script>
{% block extra_scripts %}{% endblock %} {% block extra_scripts %}{% endblock %}

353
templates/billing.html Normal file
View File

@@ -0,0 +1,353 @@
{% extends "base.html" %}
{% block title %}Billing Statements - Delphi Database{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">Billing Statements</h1>
<div class="flex items-center gap-2">
<select id="historyStatus" class="px-3 py-2 border border-neutral-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-900 text-sm">
<option value="">All statuses</option>
<option value="pending">Pending</option>
<option value="running">Running</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
<option value="cancelled">Cancelled</option>
</select>
<input id="startDate" type="date" class="px-3 py-2 border border-neutral-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-900 text-sm" placeholder="Start date" />
<input id="endDate" type="date" class="px-3 py-2 border border-neutral-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-900 text-sm" placeholder="End date" />
<select id="historySort" class="px-3 py-2 border border-neutral-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-900 text-sm">
<option value="updated_desc">Updated (newest)</option>
<option value="updated_asc">Updated (oldest)</option>
<option value="started_desc">Started (newest)</option>
<option value="started_asc">Started (oldest)</option>
<option value="completed_desc">Completed (newest)</option>
<option value="completed_asc">Completed (oldest)</option>
</select>
<button id="historyRefresh" class="px-3 py-2 bg-neutral-100 dark:bg-neutral-800 hover:bg-neutral-200 dark:hover:bg-neutral-700 rounded-lg text-sm">Refresh</button>
<button id="exportCsvBtn" class="px-3 py-2 bg-neutral-100 dark:bg-neutral-800 hover:bg-neutral-200 dark:hover:bg-neutral-700 rounded-lg text-sm">Export CSV</button>
</div>
</div>
<div class="bg-white dark:bg-neutral-900 rounded-xl shadow-sm dark:shadow-none border border-neutral-200 dark:border-neutral-700">
<div class="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700 flex items-center justify-between">
<h2 class="text-lg font-semibold">Batch History</h2>
<span id="historyCount" class="text-xs text-neutral-500"></span>
</div>
<div class="p-6">
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="text-neutral-600 dark:text-neutral-300">
<tr>
<th class="text-left py-2 pr-4">Batch ID</th>
<th class="text-left py-2 pr-4">Status</th>
<th class="text-right py-2 pr-4">Files</th>
<th class="text-left py-2 pr-4">Started</th>
<th class="text-left py-2 pr-4">Updated</th>
<th class="text-left py-2 pr-4">Completed</th>
<th class="text-right py-2">Actions</th>
</tr>
</thead>
<tbody id="historyBody" class="text-neutral-800 dark:text-neutral-200"></tbody>
</table>
</div>
<div id="historyEmpty" class="text-sm text-neutral-500 dark:text-neutral-400">No batches found</div>
<div class="mt-4 flex items-center justify-between">
<div id="pageInfo" class="text-xs text-neutral-500"></div>
<div class="flex items-center gap-2">
<label class="text-xs text-neutral-500">Page size</label>
<select id="pageSize" class="px-2 py-1 border border-neutral-300 dark:border-neutral-700 rounded bg-white dark:bg-neutral-900 text-xs">
<option value="10">10</option>
<option value="25" selected>25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
<button id="pagePrev" class="px-2 py-1 bg-neutral-100 dark:bg-neutral-800 hover:bg-neutral-200 dark:hover:bg-neutral-700 rounded text-xs" disabled>Prev</button>
<button id="pageNext" class="px-2 py-1 bg-neutral-100 dark:bg-neutral-800 hover:bg-neutral-200 dark:hover:bg-neutral-700 rounded text-xs" disabled>Next</button>
</div>
</div>
</div>
</div>
<!-- Batch Details Modal -->
<div id="batchDetailsModal" class="hidden fixed inset-0 bg-black/50 z-50 p-4">
<div class="bg-white dark:bg-neutral-900 rounded-xl max-w-3xl mx-auto shadow-xl border border-neutral-200 dark:border-neutral-700">
<div class="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700 flex items-center justify-between">
<h3 class="text-lg font-semibold">Batch Details</h3>
<button onclick="closeModal('batchDetailsModal')" class="text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div class="p-6 space-y-4">
<div id="batchMeta" class="text-sm text-neutral-600 dark:text-neutral-300"></div>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="text-neutral-600 dark:text-neutral-300">
<tr>
<th class="text-left py-2 pr-4">File No</th>
<th class="text-left py-2 pr-4">Status</th>
<th class="text-left py-2 pr-4">Message</th>
<th class="text-right py-2 pr-4">Actions</th>
</tr>
</thead>
<tbody id="batchFiles"></tbody>
</table>
</div>
<div id="previewWrap" class="hidden border border-neutral-200 dark:border-neutral-700 rounded">
<div class="px-3 py-2 flex items-center justify-between border-b border-neutral-200 dark:border-neutral-700">
<div class="text-sm font-medium">Preview</div>
<button id="closePreview" class="text-xs px-2 py-1 bg-neutral-100 dark:bg-neutral-800 hover:bg-neutral-200 dark:hover:bg-neutral-700 rounded">Close</button>
</div>
<iframe id="previewFrame" class="w-full h-96"></iframe>
</div>
</div>
<div class="px-6 py-4 border-t border-neutral-200 dark:border-neutral-700 flex items-center justify-end gap-3">
<button id="exportBatchCsvBtn" class="px-3 py-2 bg-neutral-100 dark:bg-neutral-800 hover:bg-neutral-200 dark:hover:bg-neutral-700 rounded-lg text-sm" disabled>Export CSV</button>
<button class="px-3 py-2 bg-neutral-100 dark:bg-neutral-800 hover:bg-neutral-200 dark:hover:bg-neutral-700 rounded-lg text-sm" onclick="closeModal('batchDetailsModal')">Close</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
(function(){
const body = document.getElementById('historyBody');
const empty = document.getElementById('historyEmpty');
const count = document.getElementById('historyCount');
const statusSel = document.getElementById('historyStatus');
const sortSel = document.getElementById('historySort');
const refreshBtn = document.getElementById('historyRefresh');
const exportBtn = document.getElementById('exportCsvBtn');
const startInput = document.getElementById('startDate');
const endInput = document.getElementById('endDate');
const pagePrev = document.getElementById('pagePrev');
const pageNext = document.getElementById('pageNext');
const pageSizeSel = document.getElementById('pageSize');
const pageInfo = document.getElementById('pageInfo');
const exportBatchBtn = document.getElementById('exportBatchCsvBtn');
let offset = 0;
let limit = parseInt(pageSizeSel.value, 10) || 25;
let lastCount = 0;
async function fetchHistory() {
const qs = new URLSearchParams();
if (statusSel.value) qs.set('status_filter', statusSel.value);
if (sortSel.value) qs.set('sort', sortSel.value);
if (startInput.value) qs.set('start_date', new Date(startInput.value).toISOString());
if (endInput.value) {
const d = new Date(endInput.value);
d.setHours(23,59,59,999);
qs.set('end_date', d.toISOString());
}
qs.set('limit', String(limit));
qs.set('offset', String(offset));
const resp = await window.http.wrappedFetch('/api/billing/statements/batch-history' + (qs.toString() ? ('?' + qs.toString()) : ''));
if (!resp.ok) return [];
return await resp.json();
}
function badge(status) {
const s = String(status || '').toUpperCase();
const cls = s==='COMPLETED' ? 'text-green-600' : s==='FAILED' ? 'text-red-600' : s==='CANCELLED' ? 'text-neutral-500' : 'text-amber-600';
return `<span class="text-xs font-medium ${cls}">${s}</span>`;
}
function renderRow(item) {
const filesLabel = `${item.successful_files}/${item.total_files} ✓ • ${item.failed_files} ✕`;
return (
`<tr data-batch="${item.batch_id}" class="border-t border-neutral-200 dark:border-neutral-800">
<td class="py-2 pr-4 align-top"><span class="px-2 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800">${item.batch_id}</span></td>
<td class="py-2 pr-4 align-top">${badge(item.status)}</td>
<td class="py-2 pr-4 align-top text-right whitespace-nowrap">${filesLabel}</td>
<td class="py-2 pr-4 align-top whitespace-nowrap">${item.started_at || ''}</td>
<td class="py-2 pr-4 align-top whitespace-nowrap">${item.updated_at || ''}</td>
<td class="py-2 pr-4 align-top whitespace-nowrap">${item.completed_at || ''}</td>
<td class="py-2 pr-0 align-top text-right">
<div class="flex items-center justify-end gap-2">
<button data-action="open" class="px-2 py-1 text-xs bg-neutral-100 dark:bg-neutral-800 hover:bg-neutral-200 dark:hover:bg-neutral-700 rounded">Open</button>
</div>
</td>
</tr>`);
}
async function openBatch(batchId) {
const resp = await window.http.wrappedFetch(`/api/billing/statements/batch-progress/${encodeURIComponent(batchId)}`);
if (!resp.ok) {
const err = await window.http.toError(resp, 'Failed to load batch');
alert(window.http.formatAlert(err));
return;
}
const data = await resp.json();
const metaEl = document.getElementById('batchMeta');
const filesEl = document.getElementById('batchFiles');
const previewWrap = document.getElementById('previewWrap');
const previewFrame = document.getElementById('previewFrame');
previewWrap.classList.add('hidden');
previewFrame.src = 'about:blank';
metaEl.innerHTML = `Batch <strong>${data.batch_id}</strong> • Status: <strong>${String(data.status).toUpperCase()}</strong> • Started: ${data.started_at || ''} • Completed: ${data.completed_at || ''}`;
const rows = [];
for (const f of (data.files || [])) {
const fn = f.statement_meta && f.statement_meta.filename;
const canPreview = fn && fn.toLowerCase().endsWith('.html');
rows.push(
`<tr class="border-t border-neutral-200 dark:border-neutral-800">
<td class="py-2 pr-4">${f.file_no}</td>
<td class="py-2 pr-4">${f.status}</td>
<td class="py-2 pr-4">${f.error_message || ''}</td>
<td class="py-2 pr-4 text-right">
<div class="flex items-center justify-end gap-2">
${fn ? `<a class=\"underline text-primary-600\" href=\"/api/billing/statements/${encodeURIComponent(f.file_no)}/download\" target=\"_blank\">Download</a>` : ''}
${canPreview ? `<button class=\"text-xs px-2 py-1 bg-neutral-100 dark:bg-neutral-800 hover:bg-neutral-200 dark:hover:bg-neutral-700 rounded\" data-action=\"preview\" data-file=\"${encodeURIComponent(f.file_no)}\">Preview</button>` : ''}
</div>
</td>
</tr>`
);
}
filesEl.innerHTML = rows.join('');
// Prepare data for per-batch CSV export
try {
const csvItems = (data.files || []).map(function(f){
const meta = f && f.statement_meta ? f.statement_meta : null;
return {
file_no: f && f.file_no != null ? String(f.file_no) : '',
status: f && f.status != null ? String(f.status) : '',
error_message: f && f.error_message != null ? String(f.error_message) : '',
filename: meta && meta.filename != null ? String(meta.filename) : '',
size: meta && meta.size != null ? String(meta.size) : '',
started_at: f && f.started_at != null ? String(f.started_at) : '',
completed_at: f && f.completed_at != null ? String(f.completed_at) : ''
};
});
if (exportBatchBtn) {
exportBatchBtn.disabled = csvItems.length === 0;
exportBatchBtn.setAttribute('data-export', JSON.stringify(csvItems));
exportBatchBtn.setAttribute('data-batch-id', data && data.batch_id ? String(data.batch_id) : '');
}
} catch (_) {
if (exportBatchBtn) {
exportBatchBtn.disabled = true;
exportBatchBtn.removeAttribute('data-export');
exportBatchBtn.removeAttribute('data-batch-id');
}
}
openModal('batchDetailsModal');
}
async function refresh() {
const items = await fetchHistory();
const rows = items.map(renderRow).join('');
body.innerHTML = rows;
empty.style.display = items.length ? 'none' : '';
count.textContent = items.length ? `${items.length} batches` : '';
lastCount = items.length;
// Pagination controls
pagePrev.disabled = offset <= 0;
pageNext.disabled = lastCount < limit;
const page = Math.floor(offset / limit) + 1;
pageInfo.textContent = `Page ${page}`;
exportBtn.disabled = items.length === 0;
exportBtn.setAttribute('data-export', JSON.stringify(items));
}
document.addEventListener('click', function(ev){
const btn = ev.target.closest('[data-action="open"]');
if (!btn) return;
const row = btn.closest('tr[data-batch]');
if (!row) return;
openBatch(row.getAttribute('data-batch'));
});
document.getElementById('batchDetailsModal').addEventListener('click', async function(ev){
const pbtn = ev.target.closest('[data-action="preview"]');
if (!pbtn) return;
const fileNo = decodeURIComponent(pbtn.getAttribute('data-file'));
const url = `/api/billing/statements/${encodeURIComponent(fileNo)}/download`;
const previewWrap = document.getElementById('previewWrap');
const previewFrame = document.getElementById('previewFrame');
previewFrame.src = url;
previewWrap.classList.remove('hidden');
});
document.getElementById('closePreview').addEventListener('click', function(){
const previewWrap = document.getElementById('previewWrap');
const previewFrame = document.getElementById('previewFrame');
previewFrame.src = 'about:blank';
previewWrap.classList.add('hidden');
});
statusSel.addEventListener('change', refresh);
sortSel.addEventListener('change', refresh);
refreshBtn.addEventListener('click', refresh);
startInput.addEventListener('change', function(){ offset = 0; refresh(); });
endInput.addEventListener('change', function(){ offset = 0; refresh(); });
pageSizeSel.addEventListener('change', function(){ limit = parseInt(pageSizeSel.value, 10) || 25; offset = 0; refresh(); });
pagePrev.addEventListener('click', function(){ if (offset >= limit) { offset -= limit; refresh(); } });
pageNext.addEventListener('click', function(){ if (lastCount === limit) { offset += limit; refresh(); } });
exportBtn.addEventListener('click', function(){
const dataAttr = exportBtn.getAttribute('data-export');
let items = [];
try { items = JSON.parse(dataAttr || '[]'); } catch (_) { items = []; }
if (!Array.isArray(items) || items.length === 0) return;
const headers = ['batch_id','status','total_files','successful_files','failed_files','started_at','updated_at','completed_at','processing_time_seconds'];
const lines = [headers.join(',')];
for (const it of items) {
const row = headers.map(h => {
const v = it[h] == null ? '' : String(it[h]);
if (v.includes(',') || v.includes('"') || v.includes('\n')) {
return '"' + v.replace(/"/g,'""') + '"';
}
return v;
}).join(',');
lines.push(row);
}
const csv = lines.join('\n');
const blob = new Blob([csv], {type:'text/csv;charset=utf-8;'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const stamp = new Date().toISOString().replace(/[:.]/g,'-');
a.download = `billing_batches_${stamp}.csv`;
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 0);
});
if (exportBatchBtn) {
exportBatchBtn.addEventListener('click', function(){
const dataAttr = exportBatchBtn.getAttribute('data-export');
let items = [];
try { items = JSON.parse(dataAttr || '[]'); } catch (_) { items = []; }
if (!Array.isArray(items) || items.length === 0) return;
const headers = ['file_no','status','error_message','filename','size','started_at','completed_at'];
const lines = [headers.join(',')];
for (const it of items) {
const row = headers.map(function(h){
const v = it && it[h] != null ? String(it[h]) : '';
if (v.includes(',') || v.includes('"') || v.includes('\n')) {
return '"' + v.replace(/"/g,'""') + '"';
}
return v;
}).join(',');
lines.push(row);
}
const csv = lines.join('\n');
const blob = new Blob([csv], {type:'text/csv;charset=utf-8;'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const stamp = new Date().toISOString().replace(/[:.]/g,'-');
const batchId = exportBatchBtn.getAttribute('data-batch-id') || 'batch';
a.download = `billing_batch_${batchId}_${stamp}.csv`;
document.body.appendChild(a);
a.click();
setTimeout(function(){ URL.revokeObjectURL(url); a.remove(); }, 0);
});
}
refresh();
})();
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff