working on backend
This commit is contained in:
@@ -1 +1 @@
|
|||||||
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiIsInR5cGUiOiJhY2Nlc3MiLCJpYXQiOjE3NTUyMDAyNzMsImV4cCI6MTc1NTIxNDY3M30.VfcV_zbhtSe50u1awNC4v2O8CU4PQ9AwhlcNeNn40cM
|
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiIsImV4cCI6MTc1NTMxMzA3NCwiaWF0IjoxNzU1MzA1ODc0LCJ0eXBlIjoiYWNjZXNzIn0.u0yIrs1ukrDZrl1DCYGQfyZYQizkl4RPfGJeHzXF6D4
|
||||||
|
|||||||
@@ -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
1607
app/api/billing.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
47
app/models/billing.py
Normal 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"),
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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**:
|
||||||
|
|
||||||
|
|||||||
@@ -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
136
static/js/batch-progress.js
Normal 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;
|
||||||
|
})();
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 '';
|
||||||
|
|||||||
@@ -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
353
templates/billing.html
Normal 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 %}
|
||||||
|
|
||||||
|
|
||||||
1511
tests/test_billing_statements_api.py
Normal file
1511
tests/test_billing_statements_api.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user