Skip to content

Commit fc8f0a4

Browse files
Add expense locking workflow with bulk lock and unlock capability
- Add "Lock" button for bulk locking reimbursed expenses to "Closed" status - Add lock modal showing eligible/ineligible expenses with warnings - Add unlock functionality in edit modal for locked expenses - Prevent editing, deletion, and attachment changes on locked expenses - Add lock protection to expenses API (PATCH/PUT/DELETE) - Add lock protection to attachments API (POST/DELETE) - Add locked expense banner with unlock button in edit modal - Style locked fields and hide upload sections when locked 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 9f0ab82 commit fc8f0a4

File tree

7 files changed

+543
-6
lines changed

7 files changed

+543
-6
lines changed

app/assets/css/components/modals.css

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -954,3 +954,162 @@ html.dark-mode .confirm-modal-icon-info {
954954
overscroll-behavior: contain;
955955
}
956956
}
957+
958+
/* Lock Modal */
959+
.lock-modal-content {
960+
padding: 1.25rem 1.5rem;
961+
}
962+
963+
.lock-modal-content > p {
964+
font-size: 0.95rem;
965+
color: var(--text-secondary);
966+
margin-bottom: 1rem;
967+
}
968+
969+
.lock-expense-list {
970+
max-height: 300px;
971+
overflow-y: auto;
972+
border: 1px solid var(--border-color);
973+
border-radius: 8px;
974+
margin-bottom: 1rem;
975+
}
976+
977+
.lock-expense-item {
978+
display: flex;
979+
align-items: center;
980+
justify-content: space-between;
981+
padding: 0.75rem 1rem;
982+
border-bottom: 1px solid var(--border-color);
983+
gap: 1rem;
984+
}
985+
986+
.lock-expense-item:last-child {
987+
border-bottom: none;
988+
}
989+
990+
.lock-expense-item.not-eligible {
991+
opacity: 0.5;
992+
background: var(--bg-secondary);
993+
}
994+
995+
.lock-expense-info {
996+
flex: 1;
997+
display: flex;
998+
flex-direction: column;
999+
gap: 0.25rem;
1000+
}
1001+
1002+
.lock-expense-info .provider {
1003+
font-weight: 600;
1004+
color: var(--text-primary);
1005+
font-size: 0.95rem;
1006+
}
1007+
1008+
.lock-expense-info .date {
1009+
color: var(--text-secondary);
1010+
font-size: 0.85rem;
1011+
}
1012+
1013+
.lock-expense-amount {
1014+
font-weight: 600;
1015+
font-variant-numeric: tabular-nums;
1016+
color: var(--text-primary);
1017+
}
1018+
1019+
.lock-expense-status {
1020+
display: flex;
1021+
align-items: center;
1022+
gap: 0.5rem;
1023+
}
1024+
1025+
.lock-expense-status .eligible {
1026+
color: var(--primary-green);
1027+
font-size: 0.8rem;
1028+
font-weight: 500;
1029+
}
1030+
1031+
.lock-expense-status .not-eligible-text {
1032+
color: var(--text-secondary);
1033+
font-size: 0.8rem;
1034+
font-style: italic;
1035+
}
1036+
1037+
.lock-warning {
1038+
display: flex;
1039+
align-items: flex-start;
1040+
gap: 0.5rem;
1041+
background: rgba(245, 158, 11, 0.1);
1042+
border: 1px solid rgba(245, 158, 11, 0.3);
1043+
border-radius: 6px;
1044+
padding: 0.75rem;
1045+
margin-bottom: 1rem;
1046+
color: var(--warning-amber);
1047+
font-size: 0.85rem;
1048+
}
1049+
1050+
.lock-warning svg {
1051+
flex-shrink: 0;
1052+
margin-top: 2px;
1053+
}
1054+
1055+
.lock-summary {
1056+
background: var(--bg-secondary);
1057+
border-radius: 8px;
1058+
padding: 0.75rem 1rem;
1059+
}
1060+
1061+
.lock-summary p {
1062+
margin: 0;
1063+
display: flex;
1064+
justify-content: space-between;
1065+
}
1066+
1067+
/* Locked Expense Banner in Edit Modal */
1068+
.locked-expense-banner {
1069+
background: linear-gradient(135deg, rgba(239, 68, 68, 0.08) 0%, rgba(245, 158, 11, 0.08) 100%);
1070+
border: 1px solid rgba(239, 68, 68, 0.3);
1071+
border-radius: 8px;
1072+
padding: 0.75rem 1rem;
1073+
margin-bottom: 1rem;
1074+
}
1075+
1076+
.locked-banner-content {
1077+
display: flex;
1078+
align-items: center;
1079+
gap: 0.75rem;
1080+
flex-wrap: wrap;
1081+
}
1082+
1083+
.locked-banner-content svg {
1084+
color: var(--danger-red);
1085+
flex-shrink: 0;
1086+
}
1087+
1088+
.locked-banner-content span {
1089+
flex: 1;
1090+
font-size: 0.9rem;
1091+
color: var(--text-primary);
1092+
font-weight: 500;
1093+
}
1094+
1095+
.locked-banner-content .unlock-btn {
1096+
flex-shrink: 0;
1097+
}
1098+
1099+
/* Locked field styling */
1100+
.locked-field {
1101+
opacity: 0.6;
1102+
cursor: not-allowed;
1103+
}
1104+
1105+
@media (max-width: 480px) {
1106+
.locked-banner-content {
1107+
flex-direction: column;
1108+
align-items: flex-start;
1109+
gap: 0.5rem;
1110+
}
1111+
1112+
.locked-banner-content .unlock-btn {
1113+
width: 100%;
1114+
}
1115+
}

app/assets/js/main.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import {
1010
editExpense,
1111
deleteExpense,
1212
viewExpense,
13-
viewExpenseDetails
13+
viewExpenseDetails,
14+
unlockExpense
1415
} from './services/expenses.js';
1516
import { loadProviders, deleteProvider } from './services/providers.js';
1617
import { loadFamilyMembers, deleteFamilyMember } from './services/familyMembers.js';
@@ -34,6 +35,7 @@ window.editExpense = editExpense;
3435
window.deleteExpense = deleteExpense;
3536
window.viewExpense = viewExpense;
3637
window.viewExpenseDetails = viewExpenseDetails;
38+
window.unlockExpense = unlockExpense;
3739
window.deleteProvider = deleteProvider;
3840
window.deleteFamilyMember = deleteFamilyMember;
3941
window.openPlanYearModal = openPlanYearModal;

app/assets/js/services/expenses.js

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,139 @@ function setupReimbursementLogic() {
152152
});
153153
}
154154

155+
// Handle locked expense state in edit modal
156+
function handleLockedExpenseState(modal, isLocked, expense) {
157+
const form = modal.querySelector('#expenseForm');
158+
const formInputs = form.querySelectorAll('input, select, textarea');
159+
const submitBtn = form.querySelector('button[type="submit"]');
160+
const modalFooter = modal.querySelector('.modal-footer');
161+
162+
// Remove any existing unlock banner
163+
const existingBanner = modal.querySelector('.locked-expense-banner');
164+
if (existingBanner) {
165+
existingBanner.remove();
166+
}
167+
168+
// Get receipt/proof delete buttons and file upload sections
169+
const receiptDeleteBtns = modal.querySelectorAll('.existing-receipt-delete, .receipt-delete-btn');
170+
// Get the parent form-group of file drop zones (contains label + drop zone)
171+
const receiptDropZone = modal.querySelector('#receiptDropZone');
172+
const proofDropZone = modal.querySelector('#proofDropZone');
173+
const receiptUploadSection = receiptDropZone ? receiptDropZone.closest('.form-group') : null;
174+
const proofUploadSection = proofDropZone ? proofDropZone.closest('.form-group') : null;
175+
176+
if (isLocked) {
177+
// Disable all form inputs
178+
formInputs.forEach(input => {
179+
input.disabled = true;
180+
input.classList.add('locked-field');
181+
});
182+
183+
// Hide submit button
184+
if (submitBtn) {
185+
submitBtn.style.display = 'none';
186+
}
187+
188+
// Hide receipt delete buttons
189+
receiptDeleteBtns.forEach(btn => {
190+
btn.style.display = 'none';
191+
});
192+
193+
// Hide entire file upload sections (including labels)
194+
if (receiptUploadSection) {
195+
receiptUploadSection.style.display = 'none';
196+
}
197+
if (proofUploadSection) {
198+
proofUploadSection.style.display = 'none';
199+
}
200+
201+
// Add unlock banner at the top of modal content
202+
const modalBody = modal.querySelector('.modal-body') || form.parentElement;
203+
const banner = document.createElement('div');
204+
banner.className = 'locked-expense-banner';
205+
banner.innerHTML = `
206+
<div class="locked-banner-content">
207+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
208+
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
209+
<path d="M7 11V7a5 5 0 0110 0v4"/>
210+
</svg>
211+
<span>This expense is locked and cannot be edited.</span>
212+
<button type="button" class="btn btn-small btn-primary unlock-btn" onclick="unlockExpense(${expense.id})">Unlock to Edit</button>
213+
</div>
214+
`;
215+
modalBody.insertBefore(banner, modalBody.firstChild);
216+
} else {
217+
// Enable all form inputs
218+
formInputs.forEach(input => {
219+
input.disabled = false;
220+
input.classList.remove('locked-field');
221+
});
222+
223+
// Show submit button
224+
if (submitBtn) {
225+
submitBtn.style.display = '';
226+
}
227+
228+
// Show receipt delete buttons
229+
receiptDeleteBtns.forEach(btn => {
230+
btn.style.display = '';
231+
});
232+
233+
// Show file upload sections
234+
if (receiptUploadSection) {
235+
receiptUploadSection.style.display = '';
236+
}
237+
if (proofUploadSection) {
238+
proofUploadSection.style.display = '';
239+
}
240+
}
241+
}
242+
243+
// Unlock an expense
244+
export async function unlockExpense(expenseId) {
245+
const confirmed = await showConfirmModal({
246+
title: 'Unlock Expense',
247+
message: 'Are you sure you want to unlock this expense? This will change its status from "Closed" to "Reimbursed" and allow editing.',
248+
confirmText: 'Unlock',
249+
cancelText: 'Cancel',
250+
confirmStyle: 'primary',
251+
icon: 'warning'
252+
});
253+
254+
if (confirmed) {
255+
try {
256+
const response = await fetch(`/api/expenses/${expenseId}`, addCSRFToken({
257+
method: 'PUT',
258+
headers: { 'Content-Type': 'application/json' },
259+
body: JSON.stringify({ unlock: true })
260+
}));
261+
262+
const result = await response.json();
263+
if (result.success) {
264+
showAlert('Expense unlocked successfully!', 'success');
265+
// Reload the edit modal with unlocked expense
266+
editExpense(expenseId);
267+
268+
// Update the row in the table
269+
const row = document.querySelector(`tr[data-expense-id="${expenseId}"]`);
270+
if (row) {
271+
const statusBadge = row.querySelector('.status-badge');
272+
if (statusBadge) {
273+
statusBadge.className = 'status-badge status-reimbursed';
274+
statusBadge.textContent = 'Reimbursed';
275+
}
276+
row.dataset.status = 'Reimbursed';
277+
}
278+
} else {
279+
showAlert(result.error || 'Error unlocking expense', 'error');
280+
}
281+
} catch (error) {
282+
console.error('Error:', error);
283+
showAlert('Error unlocking expense', 'error');
284+
}
285+
}
286+
}
287+
155288
// Open Add Expense Modal
156289
export function openAddExpenseModal() {
157290
const expenseModal = document.getElementById('expenseModal');
@@ -491,6 +624,7 @@ export function editExpense(id) {
491624
.then(response => response.json())
492625
.then(async expense => {
493626
const expenseModal = document.getElementById('expenseModal');
627+
const isLocked = expense.status === 'Closed';
494628

495629
document.getElementById('expenseId').value = expense.id;
496630
document.getElementById('dateOfService').value = expense.date_of_service;
@@ -502,7 +636,10 @@ export function editExpense(id) {
502636
document.getElementById('familyMember').value = expense.family_member_id || '';
503637
document.getElementById('claimNumber').value = expense.claim_number || '';
504638
document.getElementById('description').value = expense.description || '';
505-
document.getElementById('modalTitle').textContent = 'Edit Expense';
639+
document.getElementById('modalTitle').textContent = isLocked ? 'View Locked Expense' : 'Edit Expense';
640+
641+
// Handle locked state
642+
handleLockedExpenseState(expenseModal, isLocked, expense);
506643

507644
// Setup smart reimbursement logic
508645
setupReimbursementLogic();

app/routes/attachments.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ def add_attachments(expense_id):
7474
# Get expense details and verify ownership
7575
expense = conn.execute(
7676
'''SELECT e.date_of_service, e.insurance_year, e.category, e.plan_year_id,
77-
e.amount_outflow, p.name as provider_name
77+
e.amount_outflow, e.status, p.name as provider_name
7878
FROM expenses e
7979
LEFT JOIN providers p ON e.provider_id = p.id
8080
WHERE e.id = ? AND e.user_id = ?''',
@@ -84,6 +84,14 @@ def add_attachments(expense_id):
8484
if not expense:
8585
return jsonify({'error': 'Expense not found'}), 404
8686

87+
# Check if expense is locked (Closed status)
88+
if expense['status'] == 'Closed':
89+
return jsonify({
90+
'error': 'Expense is locked',
91+
'message': 'This expense is locked and cannot be modified. Unlock it first to add attachments.',
92+
'locked': True
93+
}), 403
94+
8795
if 'receipts' not in request.files:
8896
return jsonify({'error': 'No files provided'}), 400
8997

@@ -173,10 +181,11 @@ def delete_attachment(attachment_id):
173181
paperless_info = None
174182

175183
with db_connection() as conn:
176-
# Get attachment file path, Paperless info, and verify ownership
184+
# Get attachment file path, Paperless info, expense status, and verify ownership
177185
attachment = conn.execute('''
178186
SELECT a.file_path, a.paperless_doc_id, a.paperless_sync_status,
179-
e.date_of_service, e.category, p.name as provider_name
187+
e.date_of_service, e.category, e.status as expense_status,
188+
p.name as provider_name
180189
FROM attachments a
181190
INNER JOIN expenses e ON a.expense_id = e.id
182191
LEFT JOIN providers p ON e.provider_id = p.id
@@ -186,6 +195,14 @@ def delete_attachment(attachment_id):
186195
if not attachment:
187196
return jsonify({'error': 'Attachment not found or access denied'}), 404
188197

198+
# Check if expense is locked (Closed status)
199+
if attachment['expense_status'] == 'Closed':
200+
return jsonify({
201+
'error': 'Expense is locked',
202+
'message': 'This expense is locked and its attachments cannot be deleted. Unlock the expense first.',
203+
'locked': True
204+
}), 403
205+
189206
# Store Paperless info before deletion
190207
if attachment['paperless_sync_status'] == 'synced':
191208
paperless_info = {

0 commit comments

Comments
 (0)