Files
delphi-database/templates/financial.html
2025-08-13 18:53:35 -05:00

1439 lines
77 KiB
HTML

{% extends "base.html" %}
{% block title %}Financial/Ledger - Delphi Database{% endblock %}
{% block content %}
<div class="space-y-6">
<!-- Page Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 rounded-xl">
<i class="fa-solid fa-calculator text-lg"></i>
</div>
<h1 class="text-2xl font-bold text-neutral-900 dark:text-neutral-100">Financial/Ledger</h1>
</div>
<div class="flex items-center gap-3">
<button id="quickTimeBtn" class="flex items-center gap-2 px-4 py-2 bg-success-600 text-white hover:bg-success-700 rounded-lg transition-colors duration-200">
<i class="fa-regular fa-clock"></i>
<span>Quick Time</span>
<kbd class="hidden sm:inline-block ml-2 px-1.5 py-0.5 bg-success-700 rounded text-xs">Ctrl+T</kbd>
</button>
<button id="unbilledBtn" class="flex items-center gap-2 px-4 py-2 bg-warning-600 text-white hover:bg-warning-700 rounded-lg transition-colors duration-200">
<i class="fa-solid fa-hourglass-half"></i>
<span>Unbilled Items</span>
</button>
<button id="dashboardBtn" class="flex items-center gap-2 px-4 py-2 bg-info-600 text-white hover:bg-info-700 rounded-lg transition-colors duration-200">
<i class="fa-solid fa-chart-line"></i>
<span>Dashboard</span>
</button>
<div class="relative">
<button type="button" class="flex items-center gap-2 px-4 py-2 bg-gray-600 text-white hover:bg-gray-700 rounded-lg transition-colors duration-200" onclick="toggleReportsDropdown()">
<i class="fa-regular fa-file-lines"></i>
<span>Reports</span>
</button>
<div id="reportsDropdown" class="hidden absolute right-0 mt-2 w-48 bg-white dark:bg-neutral-800 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-700 z-10">
<a href="#" class="block px-4 py-2 text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700" id="timeReportBtn">Time Summary Report</a>
<a href="#" class="block px-4 py-2 text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700" id="billingReportBtn">Billing Report</a>
<a href="#" class="block px-4 py-2 text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700" id="financialReportBtn">Financial Statement</a>
</div>
</div>
</div>
</div>
<!-- Financial Dashboard Summary -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6" id="dashboardSummary">
<div class="bg-primary-600 text-white rounded-lg p-4 text-center">
<h3 class="text-xl font-bold" id="totalCharges">$0.00</h3>
<p class="text-sm">Total Charges</p>
</div>
<div class="bg-success-600 text-white rounded-lg p-4 text-center">
<h3 class="text-xl font-bold" id="totalOwing">$0.00</h3>
<p class="text-sm">Amount Owing</p>
</div>
<div class="bg-warning-600 text-white rounded-lg p-4 text-center">
<h3 class="text-xl font-bold" id="unbilledAmount">$0.00</h3>
<p class="text-sm">Unbilled</p>
</div>
<div class="bg-info-600 text-white rounded-lg p-4 text-center">
<h3 class="text-xl font-bold" id="totalHours">0.0</h3>
<p class="text-sm">Total Hours</p>
</div>
</div>
<!-- Recent Time Entries -->
<div class="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-soft overflow-hidden">
<div class="flex justify-between items-center px-6 py-4 border-b border-neutral-200 dark:border-neutral-700">
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2"><i class="fa-solid fa-clock-rotate-left"></i><span>Recent Time Entries</span></h2>
<div class="flex gap-2">
<select class="px-3 py-1 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-sm text-neutral-900 dark:text-neutral-100 transition-all duration-200" id="recentDaysFilter">
<option value="7">Last 7 days</option>
<option value="14">Last 14 days</option>
<option value="30">Last 30 days</option>
</select>
<select class="px-3 py-1 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-sm text-neutral-900 dark:text-neutral-100 transition-all duration-200" id="employeeFilter">
<option value="">All Employees</option>
</select>
<select class="px-3 py-1 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-sm text-neutral-900 dark:text-neutral-100 transition-all duration-200" id="statusFilter">
<option value="">All Status</option>
<option value="billed">Billed</option>
<option value="unbilled">Unbilled</option>
</select>
<input type="search" id="descSearch" placeholder="Search description..." class="px-3 py-1 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-sm text-neutral-900 dark:text-neutral-100 transition-all duration-200 w-48" />
<button class="px-3 py-1 bg-neutral-100 dark:bg-neutral-700 text-neutral-800 dark:text-neutral-200 rounded-lg hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors" id="refreshRecentBtn">
<i class="fa-solid fa-rotate-right"></i>
</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm text-left text-neutral-900 dark:text-neutral-100" id="recentEntriesTable">
<thead class="bg-neutral-50 dark:bg-neutral-800/50">
<tr class="border-b border-neutral-200 dark:border-neutral-700">
<th data-sort="date" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Date</th>
<th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">File</th>
<th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Client</th>
<th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Employee</th>
<th data-sort="number" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Hours</th>
<th data-sort="number" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Rate</th>
<th data-sort="number" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Amount</th>
<th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Description</th>
<th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Status</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody id="recentEntriesTableBody">
<!-- Recent entries will be loaded here -->
</tbody>
</table>
</div>
<div class="flex items-center justify-between px-6 py-3 border-t border-neutral-200 dark:border-neutral-700 text-sm" id="recentPagination" aria-live="polite"></div>
</div>
<!-- Action Cards Row -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-1">
<div class="bg-white dark:bg-neutral-800 rounded-lg shadow-md">
<div class="p-4 border-b border-neutral-200 dark:border-neutral-700">
<h2 class="text-lg font-semibold"><i class="fa-solid fa-circle-plus mr-2"></i>Quick Actions</h2>
</div>
<div class="p-4 space-y-3">
<button class="w-full px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors" id="addTimeEntryBtn">
<i class="fa-regular fa-clock mr-2"></i>Add Time Entry
</button>
<button class="w-full px-4 py-2 bg-success-600 text-white rounded-lg hover:bg-success-700 transition-colors" id="recordPaymentBtn">
<i class="fa-solid fa-sack-dollar mr-2"></i>Record Payment
</button>
<button class="w-full px-4 py-2 bg-warning-600 text-white rounded-lg hover:bg-warning-700 transition-colors" id="addExpenseBtn">
<i class="fa-solid fa-receipt mr-2"></i>Add Expense
</button>
<button class="w-full px-4 py-2 bg-info-600 text-white rounded-lg hover:bg-info-700 transition-colors" id="viewLedgerBtn">
<i class="fa-regular fa-file-lines mr-2"></i>View File Ledger
</button>
</div>
</div>
</div>
<div class="lg:col-span-2">
<div class="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-soft">
<div class="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700">
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2"><i class="fa-solid fa-chart-column"></i><span>Top Files by Balance</span></h2>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm text-left text-neutral-900 dark:text-neutral-100" id="topFilesTable">
<thead class="bg-neutral-50 dark:bg-neutral-800/50">
<tr class="border-b border-neutral-200 dark:border-neutral-700">
<th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">File</th>
<th data-sort="number" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Total Charges</th>
<th data-sort="number" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Amount Owing</th>
</tr>
</thead>
<tbody id="topFilesTableBody">
<!-- Top files will be loaded here -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Quick Time Entry Modal -->
<div class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" id="quickTimeModal">
<div class="bg-white dark:bg-neutral-800 rounded-xl shadow-xl max-w-lg w-full">
<div class="flex items-center justify-between px-6 py-4 border-b border-neutral-200 dark:border-neutral-700">
<h2 class="text-lg font-semibold">Quick Time Entry</h2>
<button onclick="closeModal('quickTimeModal')" class="text-neutral-400 hover:text-neutral-600 dark:text-neutral-500 dark:hover:text-neutral-300">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div class="px-6 py-4">
<form id="quickTimeForm" class="space-y-4">
<div>
<label for="quickTimeFile" class="block text-sm font-medium mb-1">File Number *</label>
<div class="relative">
<input type="text" id="quickTimeFile" name="file_no" required class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent">
<button type="button" id="selectFileBtn" class="absolute right-2 top-1/2 transform -translate-y-1/2 text-neutral-400 hover:text-primary-500">
<i class="fa-solid fa-magnifying-glass"></i>
</button>
</div>
<p class="text-sm text-neutral-500 mt-1" id="selectedFileInfo">Enter file number or browse to select</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="quickTimeHours" class="block text-sm font-medium mb-1">Hours *</label>
<input type="number" id="quickTimeHours" name="hours" step="0.25" min="0" required class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent">
</div>
<div>
<label for="quickTimeDate" class="block text-sm font-medium mb-1">Date *</label>
<input type="date" id="quickTimeDate" name="entry_date" required class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent">
</div>
</div>
<div>
<label for="quickTimeDescription" class="block text-sm font-medium mb-1">Description *</label>
<textarea id="quickTimeDescription" name="description" rows="3" required class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" placeholder="Describe the work performed..."></textarea>
</div>
</form>
</div>
<div class="flex justify-end gap-2 px-6 py-4 border-t border-neutral-200 dark:border-neutral-700">
<button onclick="closeModal('quickTimeModal')" class="px-4 py-2 bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600 rounded-lg transition-colors">Cancel</button>
<button id="saveQuickTimeBtn" class="px-4 py-2 bg-primary-600 text-white hover:bg-primary-700 rounded-lg transition-colors">
<i class="fa-regular fa-circle-check mr-2"></i>Save Time Entry
</button>
</div>
</div>
</div>
<!-- Time Entry Modal (Full) -->
<div class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" id="timeEntryModal">
<div class="bg-white dark:bg-neutral-800 rounded-xl shadow-xl max-w-2xl w-full">
<div class="flex items-center justify-between px-6 py-4 border-b border-neutral-200 dark:border-neutral-700">
<h2 class="text-lg font-semibold">Time Entry</h2>
<button onclick="closeModal('timeEntryModal')" class="text-neutral-400 hover:text-neutral-600 dark:text-neutral-500 dark:hover:text-neutral-300">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div class="px-6 py-4">
<form id="timeEntryForm" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label for="timeEntryFile" class="block text-sm font-medium mb-1">File Number *</label>
<div class="relative">
<input type="text" id="timeEntryFile" name="file_no" required class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent">
<button type="button" id="selectTimeFileBtn" class="absolute right-2 top-1/2 transform -translate-y-1/2 text-neutral-400 hover:text-primary-500">
<i class="fa-solid fa-magnifying-glass"></i>
</button>
</div>
</div>
<div>
<label for="timeEntryEmployee" class="block text-sm font-medium mb-1">Employee</label>
<select id="timeEntryEmployee" name="empl_num" class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent">
<option value="">Use file default</option>
</select>
</div>
<div class="col-span-2 sm:col-span-1">
<label for="timeEntryDate" class="block text-sm font-medium mb-1">Date *</label>
<input type="date" id="timeEntryDate" name="date" required class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent">
</div>
<div class="col-span-2 sm:col-span-1">
<label for="timeEntryHours" class="block text-sm font-medium mb-1">Hours *</label>
<input type="number" id="timeEntryHours" name="quantity" step="0.25" min="0" required class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent">
</div>
<div class="col-span-2 sm:col-span-1">
<label for="timeEntryRate" class="block text-sm font-medium mb-1">Rate</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<span class="text-neutral-500 dark:text-neutral-400">$</span>
</span>
<input type="number" id="timeEntryRate" name="rate" step="0.01" min="0" readonly class="w-full pl-7 pr-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent">
</div>
</div>
<div class="col-span-2 sm:col-span-1">
<label for="timeEntryCode" class="block text-sm font-medium mb-1">Transaction Code</label>
<select id="timeEntryCode" name="t_code" class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent">
<option value="TIME">TIME - Time Entry</option>
</select>
</div>
<div class="col-span-2 sm:col-span-1">
<label for="timeEntryAmount" class="block text-sm font-medium mb-1">Amount</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<span class="text-neutral-500 dark:text-neutral-400">$</span>
</span>
<input type="text" id="timeEntryAmount" name="amount" readonly class="w-full pl-7 pr-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent">
</div>
</div>
<div class="col-span-2">
<label for="timeEntryNote" class="block text-sm font-medium mb-1">Description *</label>
<textarea id="timeEntryNote" name="note" rows="3" required class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" placeholder="Describe the work performed in detail..."></textarea>
</div>
</div>
</form>
</div>
<div class="flex justify-end gap-2 px-6 py-4 border-t border-neutral-200 dark:border-neutral-700">
<button onclick="closeModal('timeEntryModal')" class="px-4 py-2 bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600 rounded-lg transition-colors">Cancel</button>
<button id="saveTimeEntryBtn" class="px-4 py-2 bg-primary-600 text-white hover:bg-primary-700 rounded-lg transition-colors">
<i class="fa-regular fa-circle-check mr-2"></i>Save Entry
</button>
</div>
</div>
</div>
<!-- Payment Modal -->
<div class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" id="paymentModal">
<div class="bg-white dark:bg-neutral-800 rounded-xl shadow-xl max-w-2xl w-full">
<div class="flex items-center justify-between px-6 py-4 border-b border-neutral-200 dark:border-neutral-700">
<h2 class="text-lg font-semibold">Record Payment</h2>
<button onclick="closeModal('paymentModal')" class="text-neutral-400 hover:text-neutral-600 dark:text-neutral-500 dark:hover:text-neutral-300">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div class="px-6 py-4">
<form id="paymentForm" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label for="paymentFile" class="block text-sm font-medium mb-1">File Number *</label>
<div class="relative">
<input type="text" id="paymentFile" name="file_no" required class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent">
<button type="button" id="selectPaymentFileBtn" class="absolute right-2 top-1/2 transform -translate-y-1/2 text-neutral-400 hover:text-primary-500">
<i class="fa-solid fa-magnifying-glass"></i>
</button>
</div>
</div>
<div>
<label for="paymentAmount" class="block text-sm font-medium mb-1">Payment Amount *</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<span class="text-neutral-500 dark:text-neutral-400">$</span>
</span>
<input type="number" id="paymentAmount" name="amount" step="0.01" min="0" required class="w-full pl-7 pr-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent">
</div>
</div>
<div class="col-span-2 sm:col-span-1">
<label for="paymentDate" class="block text-sm font-medium mb-1">Payment Date *</label>
<input type="date" id="paymentDate" name="payment_date" required class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent">
</div>
<div class="col-span-2 sm:col-span-1">
<label for="paymentMethod" class="block text-sm font-medium mb-1">Payment Method</label>
<select id="paymentMethod" name="payment_method" class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent">
<option value="CHECK">Check</option>
<option value="CASH">Cash</option>
<option value="CREDIT">Credit Card</option>
<option value="WIRE">Wire Transfer</option>
<option value="ACH">ACH/Bank Transfer</option>
</select>
</div>
<div class="col-span-2 sm:col-span-1">
<label for="paymentReference" class="block text-sm font-medium mb-1">Reference/Check #</label>
<input type="text" id="paymentReference" name="reference" class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" placeholder="Check number or reference">
</div>
<div class="col-span-2 sm:col-span-1">
<div class="flex items-center mt-2">
<input type="checkbox" id="applyToTrust" name="apply_to_trust" class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-neutral-300 rounded">
<label for="applyToTrust" class="ml-2 text-sm text-neutral-700 dark:text-neutral-300">
Apply to Trust Account
</label>
</div>
</div>
<div class="col-span-2">
<label for="paymentNotes" class="block text-sm font-medium mb-1">Notes</label>
<textarea id="paymentNotes" name="notes" rows="2" class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" placeholder="Additional payment notes..."></textarea>
</div>
</div>
</form>
</div>
<div class="flex justify-end gap-2 px-6 py-4 border-t border-neutral-200 dark:border-neutral-700">
<button onclick="closeModal('paymentModal')" class="px-4 py-2 bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600 rounded-lg transition-colors">Cancel</button>
<button id="savePaymentBtn" class="px-4 py-2 bg-success-600 text-white hover:bg-success-700 rounded-lg transition-colors">
<i class="fa-solid fa-sack-dollar mr-2"></i>Record Payment
</button>
</div>
</div>
</div>
<!-- Expense Modal -->
<div class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" id="expenseModal">
<div class="bg-white dark:bg-neutral-800 rounded-xl shadow-xl max-w-2xl w-full">
<div class="flex items-center justify-between px-6 py-4 border-b border-neutral-200 dark:border-neutral-700">
<h2 class="text-lg font-semibold">Record Expense</h2>
<button onclick="closeModal('expenseModal')" class="text-neutral-400 hover:text-neutral-600 dark:text-neutral-500 dark:hover:text-neutral-300">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div class="px-6 py-4">
<form id="expenseForm" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label for="expenseFile" class="block text-sm font-medium mb-1">File Number *</label>
<div class="relative">
<input type="text" id="expenseFile" name="file_no" required class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent">
<button type="button" id="selectExpenseFileBtn" class="absolute right-2 top-1/2 transform -translate-y-1/2 text-neutral-400 hover:text-primary-500">
<i class="fa-solid fa-magnifying-glass"></i>
</button>
</div>
</div>
<div>
<label for="expenseAmount" class="block text-sm font-medium mb-1">Amount *</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<span class="text-neutral-500 dark:text-neutral-400">$</span>
</span>
<input type="number" id="expenseAmount" name="amount" step="0.01" min="0" required class="w-full pl-7 pr-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent">
</div>
</div>
<div class="col-span-2 sm:col-span-1">
<label for="expenseDate" class="block text-sm font-medium mb-1">Date *</label>
<input type="date" id="expenseDate" name="expense_date" required class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent">
</div>
<div class="col-span-2 sm:col-span-1">
<label for="expenseCategory" class="block text-sm font-medium mb-1">Category</label>
<select id="expenseCategory" name="category" class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent">
<option value="MISC">Miscellaneous</option>
<option value="COPY">Copies/Printing</option>
<option value="POSTAGE">Postage</option>
<option value="PHONE">Phone/Communication</option>
<option value="TRAVEL">Travel</option>
<option value="FILING">Filing Fees</option>
<option value="SERVICE">Service of Process</option>
<option value="EXPERT">Expert Witness</option>
</select>
</div>
<div class="col-span-2 sm:col-span-1">
<label for="expenseEmployee" class="block text-sm font-medium mb-1">Employee</label>
<select id="expenseEmployee" name="employee" class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent">
<option value="">Use file default</option>
</select>
</div>
<div class="col-span-2 sm:col-span-1">
<div class="flex items-center mt-2">
<input type="checkbox" id="expenseReceipts" name="receipts" class="h-4 w-4 text-warning-600 focus:ring-warning-500 border-neutral-300 rounded">
<label for="expenseReceipts" class="ml-2 text-sm text-neutral-700 dark:text-neutral-300">
Receipts on File
</label>
</div>
</div>
<div class="col-span-2">
<label for="expenseDescription" class="block text-sm font-medium mb-1">Description *</label>
<textarea id="expenseDescription" name="description" rows="3" required class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" placeholder="Describe the expense..."></textarea>
</div>
</div>
</form>
</div>
<div class="flex justify-end gap-2 px-6 py-4 border-t border-neutral-200 dark:border-neutral-700">
<button onclick="closeModal('expenseModal')" class="px-4 py-2 bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600 rounded-lg transition-colors">Cancel</button>
<button id="saveExpenseBtn" class="px-4 py-2 bg-warning-600 text-white hover:bg-warning-700 rounded-lg transition-colors">
<i class="fa-solid fa-receipt mr-2"></i>Record Expense
</button>
</div>
</div>
</div>
<!-- Unbilled Items Modal -->
<div class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" id="unbilledModal">
<div class="bg-white dark:bg-neutral-800 rounded-xl shadow-xl max-w-4xl w-full">
<div class="flex items-center justify-between px-6 py-4 border-b border-neutral-200 dark:border-neutral-700">
<h2 class="text-lg font-semibold">Unbilled Items</h2>
<button onclick="closeModal('unbilledModal')" class="text-neutral-400 hover:text-neutral-600 dark:text-neutral-500 dark:hover:text-neutral-300">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div class="px-6 py-4">
<div class="flex flex-col sm:flex-row items-center justify-between gap-4 mb-4">
<div class="flex-1">
<div class="flex gap-2">
<input type="text" class="px-3 py-1 border border-neutral-300 dark:border-neutral-600 rounded-lg text-sm" id="unbilledFileFilter" placeholder="Filter by file...">
<select class="px-3 py-1 border border-neutral-300 dark:border-neutral-600 rounded-lg text-sm" id="unbilledEmployeeFilter">
<option value="">All employees</option>
</select>
<button class="px-3 py-1 bg-gray-200 dark:bg-neutral-700 rounded-lg hover:bg-gray-300 dark:hover:bg-neutral-600 transition-colors" id="refreshUnbilledBtn">
<i class="fa-solid fa-rotate-right"></i>
</button>
</div>
</div>
<div class="text-right">
<div class="bg-warning-100 dark:bg-warning-900/30 p-2 rounded-lg">
<strong>Total Unbilled: <span id="totalUnbilledAmount">$0.00</span></strong>
</div>
</div>
</div>
<div id="unbilledItemsContainer">
<!-- Unbilled items will be loaded here -->
</div>
</div>
<div class="flex justify-end gap-2 px-6 py-4 border-t border-neutral-200 dark:border-neutral-700">
<button onclick="closeModal('unbilledModal')" class="px-4 py-2 bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600 rounded-lg transition-colors">Close</button>
<button id="billSelectedBtn" class="px-4 py-2 bg-primary-600 text-white hover:bg-primary-700 rounded-lg transition-colors" disabled>
<i class="fa-regular fa-circle-check mr-2"></i>Mark Selected as Billed
</button>
</div>
</div>
</div>
<!-- Financial Dashboard Modal -->
<div class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" id="financialDashboardModal">
<div class="bg-white dark:bg-neutral-800 rounded-xl shadow-xl max-w-4xl w-full">
<div class="flex items-center justify-between px-6 py-4 border-b border-neutral-200 dark:border-neutral-700">
<h2 class="text-lg font-semibold">Financial Dashboard</h2>
<button onclick="closeModal('financialDashboardModal')" class="text-neutral-400 hover:text-neutral-600 dark:text-neutral-500 dark:hover:text-neutral-300">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div class="px-6 py-4" id="financialDashboardContent">
<!-- Dashboard content will be loaded here -->
</div>
<div class="flex justify-end gap-2 px-6 py-4 border-t border-neutral-200 dark:border-neutral-700">
<button onclick="closeModal('financialDashboardModal')" class="px-4 py-2 bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600 rounded-lg transition-colors">Close</button>
</div>
</div>
</div>
<script>
// Financial management functionality
let dashboardData = null;
let recentEntries = [];
let unbilledData = null;
let recentEditSnapshots = {};
// Authorization and JSON headers are injected by window.http.wrappedFetch
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
// Check authentication first
const token = localStorage.getItem('auth_token');
if (!token) {
window.location.href = '/login';
return;
}
initializeFinancialPage();
setupEventListeners();
loadDashboardData();
loadRecentTimeEntries();
loadEmployeeOptions();
// Set default dates
const today = new Date().toISOString().split('T')[0];
document.getElementById('quickTimeDate').value = today;
document.getElementById('timeEntryDate').value = today;
document.getElementById('paymentDate').value = today;
document.getElementById('expenseDate').value = today;
});
function initializeFinancialPage() {
// Initialize sortable tables using shared helper
try { initializeDataTable('recentEntriesTable'); } catch (_) {}
try { initializeDataTable('topFilesTable'); } catch (_) {}
// Persisted filters restore
try {
const saved = JSON.parse(localStorage.getItem('financial_recent_filters') || '{}');
if (saved && typeof saved === 'object') {
if (saved.days && document.getElementById('recentDaysFilter')) document.getElementById('recentDaysFilter').value = String(saved.days);
if (typeof saved.employee === 'string' && document.getElementById('employeeFilter')) document.getElementById('employeeFilter').value = saved.employee;
if (typeof saved.status === 'string' && document.getElementById('statusFilter')) document.getElementById('statusFilter').value = saved.status;
if (typeof saved.query === 'string' && document.getElementById('descSearch')) document.getElementById('descSearch').value = saved.query;
}
} catch (_) {}
// Persist sort state on header clicks
attachSortPersistence('recentEntriesTable', 'financial_recent_sort');
attachSortPersistence('topFilesTable', 'financial_top_sort');
console.log('Financial page initialized');
}
// Highlight helpers
function _finEscapeHtml(text) {
try { if (window.htmlSanitizer && typeof window.htmlSanitizer.escape === 'function') { return window.htmlSanitizer.escape(text); } } catch (_) {}
const str = String(text == null ? '' : text);
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;').replace(/'/g, '&#39;');
}
function _finBuildTokens(raw) {
return String(raw || '')
.trim()
.replace(/[,_;:]+/g, ' ')
.split(/\s+/)
.map(t => t.replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/g, ''))
.filter(Boolean);
}
function finHighlightText(text, tokens) {
if (!text) return '';
const unique = Array.from(new Set(tokens || []));
if (unique.length === 0) return _finEscapeHtml(text);
let safe = _finEscapeHtml(String(text));
try {
unique.forEach(tok => {
const esc = tok.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`(${esc})`, 'ig');
safe = safe.replace(re, '<mark class="bg-yellow-200 text-neutral-900 rounded px-0.5">$1</mark>');
});
} catch (_) {}
return safe;
}
function setupEventListeners() {
// Quick actions
document.getElementById('quickTimeBtn').addEventListener('click', showQuickTimeModal);
document.getElementById('addTimeEntryBtn').addEventListener('click', showTimeEntryModal);
document.getElementById('recordPaymentBtn').addEventListener('click', showPaymentModal);
document.getElementById('addExpenseBtn').addEventListener('click', showExpenseModal);
document.getElementById('unbilledBtn').addEventListener('click', showUnbilledModal);
document.getElementById('dashboardBtn').addEventListener('click', showDashboardModal);
// Modal save buttons
document.getElementById('saveQuickTimeBtn').addEventListener('click', saveQuickTime);
document.getElementById('saveTimeEntryBtn').addEventListener('click', saveTimeEntry);
document.getElementById('savePaymentBtn').addEventListener('click', savePayment);
document.getElementById('saveExpenseBtn').addEventListener('click', saveExpense);
document.getElementById('billSelectedBtn').addEventListener('click', billSelectedEntries);
// Filters
document.getElementById('recentDaysFilter').addEventListener('change', () => { setRecentServerState({ page: 1 }); loadRecentTimeEntries(); });
document.getElementById('employeeFilter').addEventListener('change', () => { setRecentServerState({ page: 1 }); loadRecentTimeEntries(); });
document.getElementById('refreshRecentBtn').addEventListener('click', loadRecentTimeEntries);
const statusFilterEl = document.getElementById('statusFilter');
if (statusFilterEl) statusFilterEl.addEventListener('change', () => { setRecentServerState({ page: 1 }); loadRecentTimeEntries(); });
const descSearchEl = document.getElementById('descSearch');
if (descSearchEl) descSearchEl.addEventListener('input', (typeof debounce === 'function' ? debounce(() => { setRecentServerState({ page: 1 }); loadRecentTimeEntries(); }, 300) : () => { setRecentServerState({ page: 1 }); loadRecentTimeEntries(); }));
// Persist filters on change
const persistFilters = () => {
const payload = {
days: parseInt(document.getElementById('recentDaysFilter')?.value || '7', 10),
employee: document.getElementById('employeeFilter')?.value || '',
status: document.getElementById('statusFilter')?.value || '',
query: document.getElementById('descSearch')?.value || ''
};
try { localStorage.setItem('financial_recent_filters', JSON.stringify(payload)); } catch (_) {}
};
['recentDaysFilter','employeeFilter','statusFilter','descSearch'].forEach(id => {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener(id === 'descSearch' ? 'input' : 'change', persistFilters);
});
// File selection buttons
document.getElementById('selectFileBtn').addEventListener('click', () => showFileSelector('quickTimeFile'));
document.getElementById('selectTimeFileBtn').addEventListener('click', () => showFileSelector('timeEntryFile'));
document.getElementById('selectPaymentFileBtn').addEventListener('click', () => showFileSelector('paymentFile'));
document.getElementById('selectExpenseFileBtn').addEventListener('click', () => showFileSelector('expenseFile'));
// Calculate amounts when values change
document.getElementById('timeEntryHours').addEventListener('input', calculateTimeAmount);
document.getElementById('timeEntryRate').addEventListener('input', calculateTimeAmount);
document.getElementById('quickTimeHours').addEventListener('blur', validateQuickTimeFile);
}
async function loadDashboardData() {
try {
const response = await window.http.wrappedFetch('/api/financial/financial-dashboard');
if (!response.ok) throw new Error('Failed to load dashboard data');
dashboardData = await response.json();
updateDashboardSummary(dashboardData);
} catch (error) {
console.error('Error loading dashboard data:', error);
showAlert('Error loading financial dashboard: ' + error.message, 'danger');
}
}
function updateDashboardSummary(data) {
document.getElementById('totalCharges').textContent = formatCurrency(data.summary.total_charges);
document.getElementById('totalOwing').textContent = formatCurrency(data.summary.total_owing);
document.getElementById('unbilledAmount').textContent = formatCurrency(data.summary.unbilled_amount);
document.getElementById('totalHours').textContent = data.summary.total_hours.toFixed(1);
// Update top files table
const tbody = document.getElementById('topFilesTableBody');
tbody.innerHTML = '';
data.top_files.forEach(file => {
const row = document.createElement('tr');
row.innerHTML = `
<td><strong>${file.file_no}</strong></td>
<td class="text-right">${formatCurrency(file.total_charges)}</td>
<td class="text-right text-green-600"><strong>${formatCurrency(file.amount_owing)}</strong></td>
`;
tbody.appendChild(row);
});
// Reapply previous sort state if any
reapplyTableSort('topFilesTable');
}
async function loadRecentTimeEntries() {
const days = document.getElementById('recentDaysFilter').value;
const employee = document.getElementById('employeeFilter').value;
const status = (document.getElementById('statusFilter')?.value || '').trim();
const q = (document.getElementById('descSearch')?.value || '').trim();
try {
const { page, limit, sort_by, sort_dir } = getRecentServerState();
const params = new URLSearchParams({ days, page, limit, sort_by, sort_dir });
if (employee) params.append('employee', employee);
if (status) params.append('status', status);
if (q) params.append('q', q);
const response = await window.http.wrappedFetch(`/api/financial/time-entries/recent?${params}`);
if (!response.ok) throw new Error('Failed to load recent entries');
const data = await response.json();
recentEntries = data.entries;
renderRecentPagination(data.total_count, data.page, data.limit);
refreshRecentEntriesView();
} catch (error) {
console.error('Error loading recent entries:', error);
showAlert('Error loading recent time entries: ' + error.message, 'danger');
}
}
function refreshRecentEntriesView() {
displayRecentTimeEntries(recentEntries || []);
}
function applyRecentEntriesFilters(entries) {
const status = (document.getElementById('statusFilter')?.value || '').toLowerCase();
const query = (document.getElementById('descSearch')?.value || '').trim().toLowerCase();
if (!entries || entries.length === 0) return [];
return entries.filter(e => {
let statusOk = true;
if (status === 'billed') statusOk = !!e.billed;
else if (status === 'unbilled') statusOk = !e.billed;
const text = [e.description, e.client_name, e.file_no, e.employee].filter(Boolean).join(' ').toLowerCase();
const queryOk = query ? text.includes(query) : true;
return statusOk && queryOk;
});
}
function displayRecentTimeEntries(entries) {
const tbody = document.getElementById('recentEntriesTableBody');
tbody.innerHTML = '';
const tokens = _finBuildTokens(document.getElementById('descSearch') ? document.getElementById('descSearch').value : '');
if (entries.length === 0) {
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-neutral-500">No recent time entries found</td></tr>';
return;
}
entries.forEach(entry => {
const row = document.createElement('tr');
row.setAttribute('data-entry-id', String(entry.id));
row.innerHTML = `
<td>${formatDate(entry.date)}</td>
<td><strong>${finHighlightText(entry.file_no, tokens)}</strong></td>
<td>${finHighlightText(entry.client_name, tokens)}</td>
<td>${finHighlightText(entry.employee, tokens)}</td>
<td class="text-center">${entry.hours}</td>
<td class="text-right">${formatCurrency(entry.rate)}</td>
<td class="text-right text-green-600"><strong>${formatCurrency(entry.amount)}</strong></td>
<td class="small">${entry.description ? finHighlightText(entry.description.substring(0, 50) + (entry.description.length > 50 ? '...' : ''), tokens) : ''}</td>
<td>
<span class="inline-block px-2 py-0.5 text-xs rounded ${entry.billed ? 'bg-green-100 text-green-700 border border-green-400' : 'bg-yellow-100 text-yellow-700 border border-yellow-500'}">
${entry.billed ? 'Billed' : 'Unbilled'}
</span>
</td>
<td>
<button class="px-2 py-1 border border-blue-600 text-blue-600 rounded hover:bg-blue-100" onclick="editTimeEntry(${entry.id})" title="Edit">
<i class="fa-solid fa-pencil"></i>
</button>
${entry.billed ? '' : `
<button class="ml-1 px-2 py-1 border border-danger-600 text-danger-600 rounded hover:bg-danger-50" onclick="deleteTimeEntry(${entry.id})" title="Delete">
<i class="fa-regular fa-trash-can"></i>
</button>
`}
</td>
`;
tbody.appendChild(row);
});
// Reapply previous sort state if any
reapplyTableSort('recentEntriesTable');
}
// Preserve and reapply table sort state across re-renders
function reapplyTableSort(tableId) {
const table = document.getElementById(tableId);
if (!table) return;
const headerAsc = table.querySelector('th.sort-asc');
const headerDesc = table.querySelector('th.sort-desc');
const header = headerAsc || headerDesc;
if (header) {
// The shared sortTable determines direction based on presence of 'sort-asc' BEFORE it removes classes
if (headerAsc) header.classList.remove('sort-asc');
else if (headerDesc) header.classList.add('sort-asc');
try { sortTable(table, header); } catch (_) {}
return;
}
// No current sort in DOM; attempt to restore persisted sort
const storageKey = tableId === 'recentEntriesTable' ? 'financial_recent_sort' : (tableId === 'topFilesTable' ? 'financial_top_sort' : null);
if (!storageKey) return;
let spec = null;
try { spec = JSON.parse(localStorage.getItem(storageKey) || 'null'); } catch (_) { spec = null; }
if (!spec || typeof spec.columnIndex !== 'number' || !spec.direction) return;
const headerRow = table.querySelector('thead tr');
if (!headerRow) return;
const th = headerRow.children[spec.columnIndex];
if (!th) return;
if (String(spec.direction).toLowerCase() === 'asc') th.classList.remove('sort-asc');
else th.classList.add('sort-asc');
try { sortTable(table, th); } catch (_) {}
}
function attachSortPersistence(tableId, storageKey) {
const table = document.getElementById(tableId);
if (!table) return;
const headers = table.querySelectorAll('th[data-sort]');
headers.forEach((header, idx) => {
header.addEventListener('click', () => {
// Determine target server sort field for recent table
if (tableId === 'recentEntriesTable') {
const fieldMap = ['date','file_no','client_name','empl_num','hours','rate','amount','description','billed'];
const sortBy = fieldMap[idx] || 'date';
// Toggle direction based on current persisted direction
const current = getRecentServerState();
const nextDir = (current.sort_by === sortBy) ? (current.sort_dir === 'asc' ? 'desc' : 'asc') : 'asc';
setRecentServerState({ sort_by: sortBy, sort_dir: nextDir, page: 1 });
loadRecentTimeEntries();
}
// Persist client-side header state for visual cues
setTimeout(() => {
const isAsc = header.classList.contains('sort-asc');
const direction = isAsc ? 'asc' : (header.classList.contains('sort-desc') ? 'desc' : 'asc');
const payload = { columnIndex: idx, direction };
try { localStorage.setItem(storageKey, JSON.stringify(payload)); } catch (_) {}
}, 0);
});
});
}
function getRecentServerState() {
let saved = null;
try { saved = JSON.parse(localStorage.getItem('financial_recent_server') || 'null'); } catch (_) { saved = null; }
const defaults = { page: 1, limit: 50, sort_by: 'date', sort_dir: 'desc' };
return Object.assign({}, defaults, saved || {});
}
function setRecentServerState(partial) {
const current = getRecentServerState();
const next = Object.assign({}, current, partial || {});
try { localStorage.setItem('financial_recent_server', JSON.stringify(next)); } catch (_) {}
return next;
}
function renderRecentPagination(totalCount, page, limit) {
const container = document.getElementById('recentPagination');
if (!container) return;
const totalPages = Math.max(1, Math.ceil((totalCount || 0) / (limit || 50)));
const canPrev = page > 1;
const canNext = page < totalPages;
container.innerHTML = `
<div>
Showing page ${page} of ${totalPages} (${totalCount || 0} entries)
</div>
<div class="space-x-2">
<button class="px-3 py-1 border rounded ${canPrev ? 'hover:bg-neutral-100 dark:hover:bg-neutral-700' : 'opacity-50 cursor-not-allowed'}" ${canPrev ? '' : 'disabled'} id="recentPrevPage">Prev</button>
<button class="px-3 py-1 border rounded ${canNext ? 'hover:bg-neutral-100 dark:hover:bg-neutral-700' : 'opacity-50 cursor-not-allowed'}" ${canNext ? '' : 'disabled'} id="recentNextPage">Next</button>
</div>
`;
const prevBtn = document.getElementById('recentPrevPage');
const nextBtn = document.getElementById('recentNextPage');
if (prevBtn) prevBtn.addEventListener('click', () => { setRecentServerState({ page: page - 1 }); loadRecentTimeEntries(); });
if (nextBtn) nextBtn.addEventListener('click', () => { setRecentServerState({ page: page + 1 }); loadRecentTimeEntries(); });
}
async function loadEmployeeOptions() {
try {
const response = await window.http.wrappedFetch('/api/files/lookups/employees');
if (response.ok) {
const employees = await response.json();
populateEmployeeSelects(employees);
}
} catch (error) {
console.error('Error loading employees:', error);
}
}
function populateEmployeeSelects(employees) {
const selects = ['employeeFilter', 'timeEntryEmployee', 'expenseEmployee', 'unbilledEmployeeFilter'];
selects.forEach(selectId => {
const select = document.getElementById(selectId);
if (!select) return;
// Keep first option
const firstOption = select.children[0];
select.innerHTML = '';
if (firstOption) select.appendChild(firstOption);
employees.forEach(emp => {
const option = document.createElement('option');
option.value = emp.code;
option.textContent = `${emp.code} - ${emp.name}`;
select.appendChild(option);
});
});
}
function showQuickTimeModal() {
document.getElementById('quickTimeModal').classList.remove('hidden');
}
function showTimeEntryModal() {
document.getElementById('timeEntryModal').classList.remove('hidden');
}
function showPaymentModal() {
document.getElementById('paymentModal').classList.remove('hidden');
}
function showExpenseModal() {
document.getElementById('expenseModal').classList.remove('hidden');
}
async function saveQuickTime() {
const form = document.getElementById('quickTimeForm');
const formData = new FormData(form);
if (!form.checkValidity()) {
form.reportValidity();
return;
}
const data = {
file_no: formData.get('file_no'),
hours: parseFloat(formData.get('hours')),
description: formData.get('description'),
entry_date: formData.get('entry_date') || null
};
try {
const response = await window.http.wrappedFetch('/api/financial/time-entry/quick', {
method: 'POST',
body: JSON.stringify(data)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to save time entry');
}
const result = await response.json();
closeModal('quickTimeModal');
showAlert(result.message, 'success');
// Refresh data
loadDashboardData();
loadRecentTimeEntries();
// Clear form
form.reset();
document.getElementById('quickTimeDate').value = new Date().toISOString().split('T')[0];
} catch (error) {
console.error('Error saving quick time:', error);
showAlert('Error saving time entry: ' + error.message, 'danger');
}
}
async function savePayment() {
const form = document.getElementById('paymentForm');
const formData = new FormData(form);
if (!form.checkValidity()) {
form.reportValidity();
return;
}
const data = {
file_no: formData.get('file_no'),
amount: parseFloat(formData.get('amount')),
payment_date: formData.get('payment_date') || null,
payment_method: formData.get('payment_method'),
reference: formData.get('reference') || null,
notes: formData.get('notes') || null,
apply_to_trust: formData.get('apply_to_trust') === 'on'
};
try {
const response = await window.http.wrappedFetch('/api/financial/payments/', {
method: 'POST',
body: JSON.stringify(data)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to record payment');
}
const result = await response.json();
closeModal('paymentModal');
showAlert(result.message, 'success');
// Refresh data
loadDashboardData();
// Clear form
form.reset();
document.getElementById('paymentDate').value = new Date().toISOString().split('T')[0];
} catch (error) {
console.error('Error recording payment:', error);
showAlert('Error recording payment: ' + error.message, 'danger');
}
}
async function saveExpense() {
const form = document.getElementById('expenseForm');
const formData = new FormData(form);
if (!form.checkValidity()) {
form.reportValidity();
return;
}
const data = {
file_no: formData.get('file_no'),
amount: parseFloat(formData.get('amount')),
description: formData.get('description'),
expense_date: formData.get('expense_date') || null,
category: formData.get('category'),
employee: formData.get('employee') || null,
receipts: formData.get('receipts') === 'on'
};
try {
const response = await window.http.wrappedFetch('/api/financial/expenses/', {
method: 'POST',
body: JSON.stringify(data)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to record expense');
}
const result = await response.json();
closeModal('expenseModal');
showAlert(result.message, 'success');
// Refresh data
loadDashboardData();
// Clear form
form.reset();
document.getElementById('expenseDate').value = new Date().toISOString().split('T')[0];
} catch (error) {
console.error('Error recording expense:', error);
showAlert('Error recording expense: ' + error.message, 'danger');
}
}
async function showUnbilledModal() {
// Load unbilled data
try {
const response = await window.http.wrappedFetch('/api/financial/unbilled-entries');
if (!response.ok) throw new Error('Failed to load unbilled entries');
unbilledData = await response.json();
displayUnbilledItems(unbilledData);
openModal('unbilledModal');
} catch (error) {
console.error('Error loading unbilled entries:', error);
showAlert('Error loading unbilled entries: ' + error.message, 'danger');
}
}
function displayUnbilledItems(data) {
document.getElementById('totalUnbilledAmount').textContent = formatCurrency(data.total_unbilled_amount);
const container = document.getElementById('unbilledItemsContainer');
container.innerHTML = '';
const tokens = _finBuildTokens(document.getElementById('unbilledFileFilter') ? document.getElementById('unbilledFileFilter').value : '');
if (data.files.length === 0) {
container.innerHTML = '<div class="text-center text-neutral-500 p-4">No unbilled entries found</div>';
return;
}
data.files.forEach(file => {
const fileCard = document.createElement('div');
fileCard.className = 'bg-white dark:bg-neutral-800 rounded-lg shadow-md mb-3 border border-neutral-200 dark:border-neutral-700';
fileCard.innerHTML = `
<div class="px-4 py-3 flex justify-between items-center border-b border-neutral-200 dark:border-neutral-700">
<div>
<strong>${finHighlightText(file.file_no, tokens)}</strong> - ${finHighlightText(file.client_name, tokens)}
<br><small class="text-neutral-500">${finHighlightText(file.matter, tokens)}</small>
</div>
<div class="text-right">
<div><strong>Total: ${formatCurrency(file.total_amount)}</strong></div>
<div><small>${file.total_hours.toFixed(2)} hours</small></div>
</div>
</div>
<div class="px-4 py-3">
<div class="overflow-x-auto">
<table class="w-full text-sm text-left border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden">
<thead>
<tr>
<th>
<input type="checkbox" class="h-4 w-4 text-primary-600 border-neutral-300 rounded file-select-all"
data-file="${file.file_no}">
</th>
<th>Date</th>
<th>Type</th>
<th>Employee</th>
<th>Qty</th>
<th>Rate</th>
<th>Amount</th>
<th>Description</th>
</tr>
</thead>
<tbody>
${file.entries.map(entry => `
<tr>
<td>
<input type="checkbox" class="h-4 w-4 text-primary-600 border-neutral-300 rounded entry-checkbox"
data-entry-id="${entry.id}" data-file="${file.file_no}">
</td>
<td>${formatDate(entry.date)}</td>
<td>${finHighlightText(entry.type, tokens)}</td>
<td>${finHighlightText(entry.employee, tokens)}</td>
<td class="text-center">${entry.quantity}</td>
<td class="text-right">${formatCurrency(entry.rate)}</td>
<td class="text-right text-green-600">${formatCurrency(entry.amount)}</td>
<td class="small">${entry.description ? finHighlightText(entry.description, tokens) : ''}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
`;
container.appendChild(fileCard);
});
// Add event listeners for checkboxes
setupUnbilledCheckboxes();
}
function setupUnbilledCheckboxes() {
// File select all checkboxes
document.querySelectorAll('.file-select-all').forEach(checkbox => {
checkbox.addEventListener('change', function() {
const fileNo = this.dataset.file;
const checked = this.checked;
document.querySelectorAll(`.entry-checkbox[data-file="${fileNo}"]`).forEach(entryCheckbox => {
entryCheckbox.checked = checked;
});
updateBillButton();
});
});
// Individual entry checkboxes
document.querySelectorAll('.entry-checkbox').forEach(checkbox => {
checkbox.addEventListener('change', updateBillButton);
});
}
function updateBillButton() {
const selectedEntries = document.querySelectorAll('.entry-checkbox:checked');
const billButton = document.getElementById('billSelectedBtn');
billButton.disabled = selectedEntries.length === 0;
billButton.textContent = selectedEntries.length > 0
? `Mark ${selectedEntries.length} Entries as Billed`
: 'Mark Selected as Billed';
}
async function billSelectedEntries() {
const selectedCheckboxes = document.querySelectorAll('.entry-checkbox:checked');
const entryIds = Array.from(selectedCheckboxes).map(cb => parseInt(cb.dataset.entryId));
if (entryIds.length === 0) {
showAlert('Please select entries to bill', 'warning');
return;
}
try {
const response = await window.http.wrappedFetch('/api/financial/bill-entries', {
method: 'POST',
body: JSON.stringify({ entry_ids: entryIds })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to bill entries');
}
const result = await response.json();
showAlert(result.message, 'success');
// Refresh unbilled data
showUnbilledModal();
loadDashboardData();
loadRecentTimeEntries();
} catch (error) {
console.error('Error billing entries:', error);
showAlert('Error billing entries: ' + error.message, 'danger');
}
}
async function showDashboardModal() {
if (!dashboardData) {
await loadDashboardData();
}
displayDashboardModal(dashboardData);
openModal('financialDashboardModal');
}
function displayDashboardModal(data) {
const content = document.getElementById('financialDashboardContent');
content.innerHTML = `
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h6 class="font-semibold mb-2">Financial Summary</h6>
<ul class="space-y-1">
<li class="flex justify-between"><strong>Total Charges:</strong><span>${formatCurrency(data.summary.total_charges)}</span></li>
<li class="flex justify-between"><strong>Amount Owing:</strong><span>${formatCurrency(data.summary.total_owing)}</span></li>
<li class="flex justify-between"><strong>Trust Balance:</strong><span>${formatCurrency(data.summary.total_trust)}</span></li>
<li class="flex justify-between"><strong>Unbilled Amount:</strong><span>${formatCurrency(data.summary.unbilled_amount)}</span></li>
<li class="flex justify-between"><strong>Total Hours:</strong><span>${data.summary.total_hours.toFixed(1)}</span></li>
</ul>
<h6 class="font-semibold mt-4 mb-2">Recent Activity (${data.recent_activity.period_days} days)</h6>
<ul class="space-y-1">
<li class="flex justify-between"><strong>Entries:</strong><span>${data.recent_activity.entries_count}</span></li>
<li class="flex justify-between"><strong>Amount:</strong><span>${formatCurrency(data.recent_activity.total_amount)}</span></li>
</ul>
</div>
<div>
<h6 class="font-semibold mb-2">Employee Activity (30 days)</h6>
<div class="overflow-x-auto">
<table class="w-full text-sm text-left border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden">
<thead class="bg-neutral-100 dark:bg-neutral-700">
<tr>
<th class="px-3 py-2">Employee</th>
<th class="px-3 py-2">Hours</th>
<th class="px-3 py-2">Amount</th>
<th class="px-3 py-2">Entries</th>
</tr>
</thead>
<tbody>
${data.employee_stats.map(stat => `
<tr>
<td class="px-3 py-2">${stat.employee}</td>
<td class="px-3 py-2 text-right">${stat.hours.toFixed(1)}</td>
<td class="px-3 py-2 text-right">${formatCurrency(stat.amount)}</td>
<td class="px-3 py-2 text-center">${stat.entries}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
</div>
`;
}
function calculateTimeAmount() {
const hours = parseFloat(document.getElementById('timeEntryHours').value) || 0;
const rate = parseFloat(document.getElementById('timeEntryRate').value) || 0;
const amount = hours * rate;
document.getElementById('timeEntryAmount').value = formatCurrency(amount).replace('$', '');
}
function showFileSelector(targetInputId) {
// This would open a file selection modal
// For now, just focus on the input
document.getElementById(targetInputId).focus();
}
// Inline edit for recent time entries
function editTimeEntry(entryId) {
const row = document.querySelector(`tr[data-entry-id="${entryId}"]`);
if (!row) return;
if (row.dataset.editing === 'true') {
saveEditedEntry(entryId, row);
return;
}
const entry = (recentEntries || []).find(e => e.id === entryId);
if (!entry) return;
if (entry.billed) {
showAlert('This entry is billed and cannot be edited.', 'warning');
return;
}
// Snapshot for rollback
recentEditSnapshots[entryId] = {
entry: { ...entry },
rowHTML: row.innerHTML
};
row.dataset.editing = 'true';
const hoursCell = row.children[4];
const rateCell = row.children[5];
const amountCell = row.children[6];
const descCell = row.children[7];
const actionsCell = row.children[9];
const currentHours = Number(entry.hours) || 0;
const rateValue = Number(entry.rate) || 0;
hoursCell.innerHTML = `<input type="number" class="w-20 px-2 py-1 border rounded" step="0.25" min="0" value="${currentHours}">`;
descCell.innerHTML = `<input type="text" class="w-full px-2 py-1 border rounded" value="${entry.description ? String(entry.description).replace(/"/g, '&quot;') : ''}">`;
const hoursInput = hoursCell.querySelector('input');
hoursInput.addEventListener('input', () => {
const h = parseFloat(hoursInput.value) || 0;
const amt = h * rateValue;
amountCell.innerHTML = `<strong>${formatCurrency(amt)}</strong>`;
});
actionsCell.innerHTML = `
<button class="px-2 py-1 border border-green-600 text-green-700 rounded hover:bg-green-100 mr-1" onclick="editTimeEntry(${entryId})" title="Save">
<i class="fa-regular fa-circle-check"></i>
</button>
<button class="px-2 py-1 border border-neutral-500 text-neutral-700 rounded hover:bg-neutral-100" onclick="cancelEditEntry(${entryId})" title="Cancel">
<i class="fa-solid fa-xmark"></i>
</button>
`;
}
async function saveEditedEntry(entryId, rowEl) {
const row = rowEl || document.querySelector(`tr[data-entry-id="${entryId}"]`);
if (!row) return;
const hoursInput = row.children[4].querySelector('input');
const descInput = row.children[7].querySelector('input');
const rateText = row.children[5].textContent || '';
const rate = parseFloat((rateText.replace(/[^0-9.\-]/g, ''))) || 0;
const newHours = parseFloat(hoursInput && hoursInput.value ? hoursInput.value : '0') || 0;
const newDesc = descInput ? descInput.value : '';
const newAmount = newHours * rate;
const payload = { quantity: newHours, amount: newAmount, note: newDesc };
const entryIndex = (recentEntries || []).findIndex(e => e.id === entryId);
if (entryIndex === -1) return;
// Optimistic UI update
const snapshot = recentEditSnapshots[entryId];
recentEntries[entryIndex] = { ...recentEntries[entryIndex], hours: newHours, amount: newAmount, description: newDesc };
// Render non-editing cells immediately
row.children[4].innerHTML = `${newHours}`;
row.children[6].innerHTML = `<strong>${formatCurrency(newAmount)}</strong>`;
row.children[7].innerHTML = `${newDesc ? newDesc.substring(0, 50) + (newDesc.length > 50 ? '...' : '') : ''}`;
row.children[9].innerHTML = `
<button class=\"px-2 py-1 border border-blue-600 text-blue-600 rounded hover:bg-blue-100\" onclick=\"editTimeEntry(${entryId})\" title=\"Edit\">\n <i class=\"fa-solid fa-pencil\"></i>\n </button>
`;
delete row.dataset.editing;
try {
const resp = await window.http.wrappedFetch(`/api/financial/ledger/${entryId}`, {
method: 'PUT',
body: JSON.stringify(payload)
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: 'Update failed' }));
throw new Error(err.detail || 'Update failed');
}
showAlert('Entry updated successfully', 'success');
loadDashboardData();
// Resort if needed
reapplyTableSort('recentEntriesTable');
} catch (error) {
// Rollback
if (snapshot && snapshot.entry) {
recentEntries[entryIndex] = { ...snapshot.entry };
}
refreshRecentEntriesView();
showAlert('Failed to update entry: ' + error.message, 'danger');
} finally {
delete recentEditSnapshots[entryId];
}
}
function cancelEditEntry(entryId) {
// Simply re-render current filtered view
delete recentEditSnapshots[entryId];
refreshRecentEntriesView();
}
async function deleteTimeEntry(entryId) {
const idx = (recentEntries || []).findIndex(e => e.id === entryId);
if (idx === -1) return;
const entry = recentEntries[idx];
if (entry.billed) {
showAlert('This entry is billed and cannot be deleted.', 'warning');
return;
}
const ok = window.confirm('Delete this time entry? This cannot be undone.');
if (!ok) return;
const backup = { index: idx, entry: { ...entry } };
recentEntries.splice(idx, 1);
refreshRecentEntriesView();
try {
const resp = await window.http.wrappedFetch(`/api/financial/ledger/${entryId}`, { method: 'DELETE' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: 'Delete failed' }));
throw new Error(err.detail || 'Delete failed');
}
showAlert('Entry deleted', 'success');
loadDashboardData();
} catch (error) {
// rollback
const restoreIndex = Math.min(backup.index, recentEntries.length);
recentEntries.splice(restoreIndex, 0, backup.entry);
refreshRecentEntriesView();
showAlert('Failed to delete entry: ' + error.message, 'danger');
}
}
async function validateQuickTimeFile() {
const fileNo = document.getElementById('quickTimeFile').value;
if (!fileNo) return;
try {
const response = await window.http.wrappedFetch(`/api/files/${fileNo}`);
if (response.ok) {
const file = await response.json();
document.getElementById('selectedFileInfo').textContent =
`File: ${file.file_no} - Rate: ${formatCurrency(file.rate_per_hour)}/hr`;
} else {
document.getElementById('selectedFileInfo').textContent = 'File not found';
}
} catch (error) {
document.getElementById('selectedFileInfo').textContent = 'Error validating file';
}
}
// Utility functions
function formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString();
}
function formatCurrency(amount) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount || 0);
}
function showAlert(message, type = 'info') {
if (window.alerts && typeof window.alerts.show === 'function') {
window.alerts.show(message, type);
} else if (window.showNotification) {
window.showNotification(message, type);
} else {
alert(String(message));
}
}
// Update JavaScript if needed for new classes, e.g., for toggling dropdown
function toggleReportsDropdown() {
document.getElementById('reportsDropdown').classList.toggle('hidden');
}
// Add closeModal function
function closeModal(modalId) {
document.getElementById(modalId).classList.add('hidden');
}
</script>
{% endblock %}